Skip to content

Commit baa72bc

Browse files
ryanpetrelloclaude
andcommitted
feat(resolver): add PyPI cooldown policy to reject recently-published versions
Adds --pypi-min-age (FROMAGER_PYPI_MIN_AGE) to reject package versions published fewer than N days ago, protecting against supply-chain attacks. Enforcement is automatic for all PyPI resolutions including custom plugins, and fail-closed when upload_time metadata is missing. Co-Authored-By: Claude <claude@anthropic.com> Related: #877
1 parent 9b399c8 commit baa72bc

8 files changed

Lines changed: 474 additions & 4 deletions

File tree

docs/how-tos/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Customize builds with overrides, variants, and version handling.
4848
pyproject-overrides
4949
multiple-versions
5050
pre-release-versions
51+
pypi-cooldown
5152

5253
Analyzing Builds
5354
----------------

docs/how-tos/pypi-cooldown.rst

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
Protect Against Supply-Chain Attacks with PyPI Cooldown
2+
========================================================
3+
4+
Fromager's PyPI cooldown policy rejects package versions that were published
5+
fewer than a configured number of days ago. This protects automated builds from
6+
supply-chain attacks where a malicious version is published and immediately
7+
pulled in before it can be reviewed.
8+
9+
How It Works
10+
------------
11+
12+
When a cooldown is active, any candidate whose ``upload-time`` is more recent
13+
than the cutoff (current time minus the configured minimum age) is not
14+
considered a valid option during constraint resolution. If no versions of a
15+
package satisfy both the cooldown window and any other provided constraints,
16+
resolution fails with an informative error.
17+
18+
The cutoff timestamp is fixed at the start of each run, so all package
19+
resolutions within a single bootstrap share the same boundary.
20+
21+
Enabling the Cooldown
22+
---------------------
23+
24+
Use the global ``--pypi-min-age`` flag, or set the equivalent environment
25+
variable ``FROMAGER_PYPI_MIN_AGE``:
26+
27+
.. code-block:: bash
28+
29+
# Reject versions published in the last 7 days
30+
fromager --pypi-min-age 7 bootstrap -r requirements.txt
31+
32+
# Same, via environment variable (useful for CI and builder integrations)
33+
FROMAGER_PYPI_MIN_AGE=7 fromager bootstrap -r requirements.txt
34+
35+
# Disable the cooldown (default)
36+
fromager --pypi-min-age 0 bootstrap -r requirements.txt
37+
38+
The ``--pypi-min-age`` flag accepts a non-negative integer number of days.
39+
A value of ``0`` (the default) disables the check entirely.
40+
41+
Scope
42+
-----
43+
44+
The cooldown applies to **all resolutions** in a run, including transitive
45+
dependencies and packages fetched from a custom ``--sdist-server-url``. It does
46+
not apply to packages resolved from Git URLs, which use a separate code path.
47+
48+
Note that when using a private package index, the cooldown depends on
49+
``upload-time`` being present in the index's PEP 691 JSON responses. If the
50+
index does not provide that metadata, candidates will be rejected under the
51+
fail-closed policy described below.
52+
53+
Explicit version pins (``package==1.2.3``) are subject to the same cooldown as
54+
unpinned requirements. If the pinned version was published within the cooldown
55+
window, resolution will fail. To unblock a specific run, set ``--pypi-min-age 0``
56+
or use the environment variable.
57+
58+
Fail-Closed Behavior
59+
--------------------
60+
61+
If a candidate has no ``upload-time`` metadata — which can occur with older
62+
PyPI Simple HTML responses — it is rejected when a cooldown is active. Fromager
63+
uses the `PEP 691 JSON Simple API`_ when fetching package metadata, which
64+
reliably includes upload timestamps.
65+
66+
.. _PEP 691 JSON Simple API: https://peps.python.org/pep-0691/
67+
68+
Example
69+
-------
70+
71+
Given a package ``example-pkg`` with three available versions:
72+
73+
* ``2.0.0`` — published 3 days ago
74+
* ``1.9.0`` — published 45 days ago
75+
* ``1.8.0`` — published 120 days ago
76+
77+
With a 7-day cooldown, ``2.0.0`` is blocked and ``1.9.0`` is selected:
78+
79+
.. code-block:: bash
80+
81+
fromager --pypi-min-age 7 bootstrap example-pkg
82+
83+
With a 60-day cooldown, both ``2.0.0`` and ``1.9.0`` are blocked and ``1.8.0``
84+
is selected:
85+
86+
.. code-block:: bash
87+
88+
fromager --pypi-min-age 60 bootstrap example-pkg

src/fromager/__main__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#!/usr/bin/env python3
22

3+
import datetime
34
import logging
45
import pathlib
56
import sys
@@ -143,6 +144,15 @@
143144
help="Build sdist and when with network isolation (unshare -cn)",
144145
show_default=True,
145146
)
147+
@click.option(
148+
"--pypi-min-age",
149+
type=click.IntRange(min=0),
150+
default=0,
151+
help=(
152+
"Reject PyPI package versions published fewer than this many days ago "
153+
"(0 disables the check). Also settable via FROMAGER_PYPI_MIN_AGE."
154+
),
155+
)
146156
@click.pass_context
147157
def main(
148158
ctx: click.Context,
@@ -163,6 +173,7 @@ def main(
163173
variant: str,
164174
jobs: int | None,
165175
network_isolation: bool,
176+
pypi_min_age: int,
166177
) -> None:
167178
# Save the debug flag so invoke_main() can use it.
168179
global _DEBUG
@@ -249,6 +260,11 @@ def main(
249260
network_isolation=network_isolation,
250261
max_jobs=jobs,
251262
settings_dir=settings_dir,
263+
pypi_cooldown=(
264+
context.Cooldown(min_age=datetime.timedelta(days=pypi_min_age))
265+
if pypi_min_age > 0
266+
else None
267+
),
252268
)
253269
wkctx.setup()
254270
ctx.obj = wkctx

src/fromager/bootstrapper.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,7 @@ def _download_wheel_from_cache(
10171017
sdist_server_url=self.cache_wheel_server_url,
10181018
include_sdists=False,
10191019
include_wheels=True,
1020+
skip_cooldowns=True,
10201021
)
10211022
wheelfile_name = pathlib.Path(urlparse(wheel_url).path)
10221023
pbi = self.ctx.package_build_info(req)

src/fromager/context.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
import collections
4+
import dataclasses
5+
import datetime
46
import logging
57
import os
68
import pathlib
@@ -31,6 +33,20 @@
3133
ROOT_BUILD_REQUIREMENT = canonicalize_name("", validate=False)
3234

3335

36+
@dataclasses.dataclass
37+
class Cooldown:
38+
"""Policy for rejecting recently-published package versions.
39+
40+
bootstrap_time is fixed at construction so all resolutions in a single run
41+
share the same cutoff.
42+
"""
43+
44+
min_age: datetime.timedelta
45+
bootstrap_time: datetime.datetime = dataclasses.field(
46+
default_factory=lambda: datetime.datetime.now(datetime.UTC)
47+
)
48+
49+
3450
class WorkContext:
3551
def __init__(
3652
self,
@@ -46,6 +62,7 @@ def __init__(
4662
max_jobs: int | None = None,
4763
settings_dir: pathlib.Path | None = None,
4864
wheel_server_url: str = "",
65+
pypi_cooldown: Cooldown | None = None,
4966
):
5067
if active_settings is None:
5168
active_settings = packagesettings.Settings(
@@ -95,6 +112,8 @@ def __init__(
95112

96113
self._parallel_builds = False
97114

115+
self.pypi_cooldown: Cooldown | None = pypi_cooldown
116+
98117
def enable_parallel_builds(self) -> None:
99118
self._parallel_builds = True
100119

src/fromager/resolver.py

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from . import overrides
3434
from .candidate import Candidate
3535
from .constraints import Constraints
36+
from .context import Cooldown
3637
from .extras_provider import ExtrasProvider
3738
from .http_retry import RETRYABLE_EXCEPTIONS, retry_on_exception
3839
from .request_session import session
@@ -48,6 +49,9 @@
4849
PYTHON_VERSION = Version(python_version())
4950
DEBUG_RESOLVER = os.environ.get("DEBUG_RESOLVER", "")
5051
PYPI_SERVER_URL = "https://pypi.org/simple"
52+
53+
# Sentinel meaning "inherit cooldown from ctx.pypi_cooldown".
54+
_UNSET: typing.Final[object] = object()
5155
GITHUB_URL = "https://github.com"
5256

5357
# all supported tags
@@ -85,8 +89,12 @@ def resolve(
8589
include_wheels: bool = True,
8690
req_type: RequirementType | None = None,
8791
ignore_platform: bool = False,
92+
skip_cooldowns: bool = False,
8893
) -> tuple[str, Version]:
8994
# Create the (reusable) resolver.
95+
extra_kwargs: dict[str, object] = {}
96+
if skip_cooldowns:
97+
extra_kwargs["cooldown"] = None
9098
provider = overrides.find_and_invoke(
9199
req.name,
92100
"get_resolver_provider",
@@ -98,6 +106,7 @@ def resolve(
98106
sdist_server_url=sdist_server_url,
99107
req_type=req_type,
100108
ignore_platform=ignore_platform,
109+
**extra_kwargs,
101110
)
102111
return resolve_from_provider(provider, req)
103112

@@ -110,6 +119,7 @@ def default_resolver_provider(
110119
include_wheels: bool,
111120
req_type: RequirementType | None = None,
112121
ignore_platform: bool = False,
122+
cooldown: Cooldown | None | object = _UNSET,
113123
) -> (
114124
PyPIProvider
115125
| GenericProvider
@@ -118,13 +128,19 @@ def default_resolver_provider(
118128
| VersionMapProvider
119129
):
120130
"""Lookup resolver provider to resolve package versions"""
131+
effective_cooldown = (
132+
ctx.pypi_cooldown
133+
if cooldown is _UNSET
134+
else typing.cast(Cooldown | None, cooldown)
135+
)
121136
return PyPIProvider(
122137
include_sdists=include_sdists,
123138
include_wheels=include_wheels,
124139
sdist_server_url=sdist_server_url,
125140
constraints=ctx.constraints,
126141
req_type=req_type,
127142
ignore_platform=ignore_platform,
143+
cooldown=effective_cooldown,
128144
)
129145

130146

@@ -397,11 +413,13 @@ def __init__(
397413
constraints: Constraints | None = None,
398414
req_type: RequirementType | None = None,
399415
use_resolver_cache: bool = True,
416+
cooldown: Cooldown | None = None,
400417
):
401418
super().__init__()
402419
self.constraints = constraints or Constraints()
403420
self.req_type = req_type
404421
self.use_cache_candidates = use_resolver_cache
422+
self.cooldown = cooldown
405423

406424
@property
407425
def cache_key(self) -> str:
@@ -470,10 +488,38 @@ def validate_candidate(
470488
f"{identifier}: skipping bad version {candidate.version} from {bad_versions}"
471489
)
472490
return False
473-
for r in identifier_reqs:
474-
if self.is_satisfied_by(requirement=r, candidate=candidate):
475-
return True
476-
return False
491+
492+
if not any(
493+
self.is_satisfied_by(requirement=r, candidate=candidate)
494+
for r in identifier_reqs
495+
):
496+
return False
497+
498+
# Fail closed: if upload_time is missing we cannot verify the package
499+
# is old enough, so we reject it rather than silently bypassing the policy.
500+
if self.cooldown is not None:
501+
if candidate.upload_time is None:
502+
if DEBUG_RESOLVER:
503+
logger.debug(
504+
"%s: skipping %s — upload_time unknown, required for cooldown",
505+
identifier,
506+
candidate.version,
507+
)
508+
return False
509+
cutoff = self.cooldown.bootstrap_time - self.cooldown.min_age
510+
if candidate.upload_time > cutoff:
511+
if DEBUG_RESOLVER:
512+
age = self.cooldown.bootstrap_time - candidate.upload_time
513+
logger.debug(
514+
"%s: skipping %s uploaded %s ago (cooldown: %s)",
515+
identifier,
516+
candidate.version,
517+
age,
518+
self.cooldown.min_age,
519+
)
520+
return False
521+
522+
return True
477523

478524
def get_preference(
479525
self,
@@ -608,11 +654,13 @@ def __init__(
608654
ignore_platform: bool = False,
609655
*,
610656
use_resolver_cache: bool = True,
657+
cooldown: Cooldown | None = None,
611658
):
612659
super().__init__(
613660
constraints=constraints,
614661
req_type=req_type,
615662
use_resolver_cache=use_resolver_cache,
663+
cooldown=cooldown,
616664
)
617665
self.include_sdists = include_sdists
618666
self.include_wheels = include_wheels
@@ -683,6 +731,35 @@ def _get_no_match_error_message(
683731
else:
684732
file_type_info = "wheels"
685733

734+
# If a cooldown is active, check whether it's responsible for the
735+
# failure so we can give a more actionable error message.
736+
if self.cooldown is not None:
737+
cutoff = self.cooldown.bootstrap_time - self.cooldown.min_age
738+
all_candidates = list(self._find_cached_candidates(identifier))
739+
missing_time = [c for c in all_candidates if c.upload_time is None]
740+
cooldown_blocked = [
741+
c
742+
for c in all_candidates
743+
if c.upload_time is not None and c.upload_time > cutoff
744+
]
745+
if missing_time and not cooldown_blocked:
746+
return (
747+
f"found {len(missing_time)} candidate(s) for {r} but none have "
748+
f"upload timestamp metadata; cannot enforce the "
749+
f"{self.cooldown.min_age.days}-day cooldown"
750+
)
751+
if cooldown_blocked:
752+
oldest_days = min(
753+
(self.cooldown.bootstrap_time - c.upload_time).days
754+
for c in cooldown_blocked
755+
if c.upload_time is not None
756+
)
757+
return (
758+
f"found {len(cooldown_blocked)} candidate(s) for {r} but all "
759+
f"are within the {self.cooldown.min_age.days}-day cooldown window "
760+
f"(oldest is {oldest_days} day(s) old)"
761+
)
762+
686763
return (
687764
f"found no match for {r} using {self.get_provider_description()}, "
688765
f"searching for {file_type_info}, {prerelease_info} pre-release versions"

src/fromager/wheels.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,7 @@ def resolve_prebuilt_wheel(
476476
req_type=req_type,
477477
# pre-built wheels must match platform
478478
ignore_platform=False,
479+
skip_cooldowns=True,
479480
)
480481
except Exception as e:
481482
excs.append(e)

0 commit comments

Comments
 (0)