Skip to content

Commit 66643e7

Browse files
ryanpetrelloclaude
andcommitted
feat(resolver): add release-age cooldown to protect against supply-chain attacks
Adds a configurable minimum release age policy that rejects package versions published fewer than N days ago. This protects automated builds from supply-chain attacks where a malicious version is published and immediately pulled in before it can be reviewed. When active, any sdist candidate whose upload-time is more recent than the cutoff is not considered a valid option during constraint resolution. The cutoff is fixed at the start of each run so all resolutions share the same boundary. Key behaviors: - --min-release-age flag (envvar FROMAGER_MIN_RELEASE_AGE) sets the global minimum age in days; 0 (default) disables the check entirely - Enforcement lives in BaseProvider.is_satisfied_by() so it applies to PyPI, GitLab, and any future providers uniformly - Providers that can currently provide release timestamps (PyPI, GitLab) are fail-closed: a candidate with no upload-time metadata is rejected when a cooldown is active; providers without timestamp support yet (GitHub) emit a one-time warning and skip the check instead - resolver_dist.min_release_age in package settings overrides the global flag per-package (None = inherit, 0 = disable, positive int = override) - Top-level dependencies pinned with == in the constraints file are exempt from the cooldown; transitive == specifiers in package metadata are not Co-Authored-By: Claude <claude@anthropic.com>
1 parent 10daf52 commit 66643e7

13 files changed

Lines changed: 1128 additions & 0 deletions

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+
release-age-cooldown
5152

5253
Analyzing Builds
5354
----------------
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
Protect Against Supply-Chain Attacks with Release-Age Cooldown
2+
==============================================================
3+
4+
Fromager's release-age cooldown policy rejects package versions that were
5+
published fewer than a configured number of days ago. This protects automated
6+
builds from supply-chain attacks where a malicious version is published and
7+
immediately 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 ``--min-release-age`` flag, or set the equivalent environment
25+
variable ``FROMAGER_MIN_RELEASE_AGE``:
26+
27+
.. code-block:: bash
28+
29+
# Reject versions published in the last 7 days
30+
fromager --min-release-age 7 bootstrap -r requirements.txt
31+
32+
# Same, via environment variable (useful for CI and builder integrations)
33+
FROMAGER_MIN_RELEASE_AGE=7 fromager bootstrap -r requirements.txt
34+
35+
# Disable the cooldown (default)
36+
fromager --min-release-age 0 bootstrap -r requirements.txt
37+
38+
The ``--min-release-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 **sdist resolution** — selecting which version of a
45+
package to build from source, including transitive dependencies. It does not
46+
apply to:
47+
48+
* Wheel-only lookups, including cache servers (``--cache-wheel-server-url``) and
49+
packages configured as ``pre_built: true`` in variant settings. These use a
50+
different trust model and are not subject to the cooldown regardless of which
51+
server they are fetched from.
52+
* Packages resolved from Git URLs that do not provide timestamp metadata.
53+
54+
Note that sdist resolution from a private package index depends on
55+
``upload-time`` being present in the index's PEP 691 JSON responses. If the
56+
index does not provide that metadata, candidates will be rejected under the
57+
fail-closed policy described below.
58+
59+
60+
Fail-Closed Behavior
61+
--------------------
62+
63+
If a candidate has no ``upload-time`` metadata — which can occur with older
64+
PyPI Simple HTML responses — it is rejected when a cooldown is active. Fromager
65+
uses the `PEP 691 JSON Simple API`_ when fetching package metadata, which
66+
reliably includes upload timestamps.
67+
68+
.. _PEP 691 JSON Simple API: https://peps.python.org/pep-0691/
69+
70+
Example
71+
-------
72+
73+
Given a package ``example-pkg`` with three available versions:
74+
75+
* ``2.0.0`` — published 3 days ago
76+
* ``1.9.0`` — published 45 days ago
77+
* ``1.8.0`` — published 120 days ago
78+
79+
With a 7-day cooldown, ``2.0.0`` is blocked and ``1.9.0`` is selected:
80+
81+
.. code-block:: bash
82+
83+
fromager --min-release-age 7 bootstrap example-pkg
84+
85+
With a 60-day cooldown, both ``2.0.0`` and ``1.9.0`` are blocked and ``1.8.0``
86+
is selected:
87+
88+
.. code-block:: bash
89+
90+
fromager --min-release-age 60 bootstrap example-pkg
91+
92+
Overriding the Cooldown for a Blocked Pin
93+
------------------------------------------
94+
95+
When a version is specified directly in a requirements file or on the
96+
``bootstrap`` command line, it is still subject to the cooldown. If the version
97+
was published within the cooldown window, resolution fails with a message
98+
identifying the cause:
99+
100+
.. code-block:: console
101+
102+
$ fromager --min-release-age 7 bootstrap example-pkg==2.0.0
103+
ERROR: found 1 candidate(s) for example-pkg==2.0.0 but all were published
104+
within the last 7 days (release-age cooldown; oldest is 3 day(s) old)
105+
106+
To unblock for a single run, set ``--min-release-age 0``:
107+
108+
.. code-block:: bash
109+
110+
fromager --min-release-age 0 bootstrap example-pkg==2.0.0
111+
112+
A value of ``0`` disables the cooldown entirely, allowing any version to be
113+
selected regardless of when it was published.
114+
115+
Alternatively, add a per-package override in the package settings file (see
116+
`Per-Package Override`_ below).
117+
118+
Per-Package Override
119+
--------------------
120+
121+
The cooldown can be adjusted on a per-package basis using the
122+
``resolver_dist.min_release_age`` setting in the package's settings file:
123+
124+
.. code-block:: yaml
125+
126+
# overrides/settings/my-package.yaml
127+
resolver_dist:
128+
min_release_age: 0 # disable cooldown for this package
129+
# min_release_age: 30 # or use a different number of days
130+
131+
Valid values:
132+
133+
* Omit the key (default): inherit the global ``--min-release-age`` setting.
134+
* ``0``: disable the cooldown for this package, regardless of the global flag.
135+
* Positive integer: use this many days instead of the global setting.
136+
137+
This is useful when a specific package is trusted enough to allow recent
138+
versions, or when a package's release cadence makes the global cooldown
139+
impractical.

e2e/ci_bootstrap_suite.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ run_test "bootstrap_prerelease"
2626
run_test "bootstrap_cache"
2727
run_test "bootstrap_sdist_only"
2828

29+
test_section "bootstrap cooldown tests"
30+
run_test "bootstrap_cooldown"
31+
run_test "bootstrap_cooldown_transitive"
32+
run_test "bootstrap_cooldown_constraint_conflict"
33+
2934
test_section "bootstrap git URL tests"
3035
run_test "bootstrap_git_url"
3136
run_test "bootstrap_git_url_tag"

e2e/test_bootstrap_cooldown.sh

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/bin/bash
2+
# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*-
3+
4+
# Tests that --min-release-age rejects versions published within the cooldown
5+
# window and falls back to an older stevedore version. Verifies both the
6+
# CLI flag (--min-release-age) and the equivalent environment variable
7+
# (FROMAGER_MIN_RELEASE_AGE) produce identical behaviour.
8+
#
9+
# Release timeline (all times UTC):
10+
#
11+
# stevedore 5.1.0 2023-05-15 (the expected fallback)
12+
# stevedore 5.2.0 2024-02-22 (blocked by cooldown)
13+
# stevedore 5.3.0+ 2024-08-22+ (all blocked by cooldown)
14+
#
15+
# We compute --min-release-age dynamically as the age of stevedore 5.2.0 in days
16+
# plus a 1-day buffer, ensuring stevedore 5.2.0 is always just inside the
17+
# cooldown window while stevedore 5.1.0 (released ~9 months earlier) always
18+
# clears it.
19+
#
20+
# The margin between the cutoff and stevedore 5.1.0's age is fixed at ~292
21+
# days (the gap between the two release dates minus the 1-day buffer), so
22+
# this test remains stable indefinitely as long as no new stevedore release
23+
# lands between 5.1.0 and 5.2.0.
24+
25+
SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
26+
source "$SCRIPTDIR/common.sh"
27+
28+
# Compute min-age: days since stevedore 5.2.0 was published, plus a buffer.
29+
# stevedore 5.2.0 was released 2024-02-22; adding 1 day ensures it is
30+
# always just inside the cooldown window regardless of when the test runs.
31+
MIN_AGE=$(python3 -c "
32+
from datetime import date
33+
age = (date.today() - date(2024, 2, 22)).days
34+
print(age + 1)
35+
")
36+
37+
# --- Pass 1: enforce cooldown via CLI flag ---
38+
39+
fromager \
40+
--log-file="$OUTDIR/bootstrap-flag.log" \
41+
--error-log-file="$OUTDIR/fromager-errors-flag.log" \
42+
--sdists-repo="$OUTDIR/sdists-repo" \
43+
--wheels-repo="$OUTDIR/wheels-repo" \
44+
--work-dir="$OUTDIR/work-dir" \
45+
--min-release-age="$MIN_AGE" \
46+
bootstrap 'stevedore'
47+
48+
pass=true
49+
50+
# stevedore 5.2.0 is blocked; the resolver must fall back to 5.1.0.
51+
if ! grep -q "new toplevel dependency stevedore resolves to 5.1.0" "$OUTDIR/bootstrap-flag.log"; then
52+
echo "FAIL (flag): expected stevedore to resolve to 5.1.0 but it did not" 1>&2
53+
pass=false
54+
fi
55+
56+
if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.1.0*.whl' | grep -q .; then
57+
echo "FAIL (flag): stevedore-5.1.0 wheel not found in wheels-repo" 1>&2
58+
pass=false
59+
fi
60+
61+
# --- Pass 2: enforce the same cooldown via environment variable (FROMAGER_MIN_RELEASE_AGE) ---
62+
63+
# Wipe output so the second run starts clean.
64+
rm -rf "$OUTDIR/sdists-repo" "$OUTDIR/wheels-repo" "$OUTDIR/work-dir"
65+
66+
FROMAGER_MIN_RELEASE_AGE="$MIN_AGE" fromager \
67+
--log-file="$OUTDIR/bootstrap-envvar.log" \
68+
--error-log-file="$OUTDIR/fromager-errors-envvar.log" \
69+
--sdists-repo="$OUTDIR/sdists-repo" \
70+
--wheels-repo="$OUTDIR/wheels-repo" \
71+
--work-dir="$OUTDIR/work-dir" \
72+
bootstrap 'stevedore'
73+
74+
if ! grep -q "new toplevel dependency stevedore resolves to 5.1.0" "$OUTDIR/bootstrap-envvar.log"; then
75+
echo "FAIL (envvar): expected stevedore to resolve to 5.1.0 but it did not" 1>&2
76+
pass=false
77+
fi
78+
79+
if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.1.0*.whl' | grep -q .; then
80+
echo "FAIL (envvar): stevedore-5.1.0 wheel not found in wheels-repo" 1>&2
81+
pass=false
82+
fi
83+
84+
$pass
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/bin/bash
2+
# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*-
3+
4+
# Tests that an exact == pin in the operator constraints file exempts a package
5+
# from the release-age cooldown, even when the pinned version was published
6+
# within the cooldown window.
7+
#
8+
# Release timeline (all times UTC):
9+
#
10+
# pbr 7.0.3 2025-11-03 (pinned by constraint; would be blocked
11+
# without the == pin exemption)
12+
#
13+
# We pin pbr==7.0.3 via a constraints file and set --min-release-age to the
14+
# age of pbr 7.0.3 plus a 1-day buffer, so pbr 7.0.3 is always just inside
15+
# the cooldown window. Because the constraints file contains an exact ==
16+
# pin, the cooldown is exempted and the bootstrap must succeed with pbr 7.0.3.
17+
#
18+
# This tests @dhellmann's requirement: operator-curated == pins in the
19+
# constraints file are trusted and exempt from cooldown, but transitive ==
20+
# specifiers in dependency metadata are not.
21+
22+
SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
23+
source "$SCRIPTDIR/common.sh"
24+
25+
# Compute min-release-age: days since pbr 7.0.3 was published, plus a buffer.
26+
# pbr 7.0.3 was released 2025-11-03; adding 1 day ensures it is always
27+
# just inside the cooldown window regardless of when the test runs.
28+
MIN_AGE=$(python3 -c "
29+
from datetime import date
30+
age = (date.today() - date(2025, 11, 3)).days
31+
print(age + 1)
32+
")
33+
34+
constraints_file=$(mktemp)
35+
trap "rm -f $constraints_file" EXIT
36+
echo "pbr==7.0.3" > "$constraints_file"
37+
38+
fromager \
39+
--log-file="$OUTDIR/bootstrap.log" \
40+
--error-log-file="$OUTDIR/fromager-errors.log" \
41+
--sdists-repo="$OUTDIR/sdists-repo" \
42+
--wheels-repo="$OUTDIR/wheels-repo" \
43+
--work-dir="$OUTDIR/work-dir" \
44+
--constraints-file="$constraints_file" \
45+
--min-release-age="$MIN_AGE" \
46+
bootstrap 'stevedore'
47+
48+
pass=true
49+
50+
# The == pin exemption must allow pbr 7.0.3 through despite the cooldown.
51+
if ! find "$OUTDIR/wheels-repo/downloads/" -name 'pbr-7.0.3*.whl' | grep -q .; then
52+
echo "FAIL: pbr-7.0.3 wheel not found — == constraint pin exemption did not work" 1>&2
53+
pass=false
54+
fi
55+
56+
# Stevedore itself should also have been built successfully.
57+
if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-*.whl' | grep -q .; then
58+
echo "FAIL: stevedore wheel not found in wheels-repo" 1>&2
59+
pass=false
60+
fi
61+
62+
$pass
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/bin/bash
2+
# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*-
3+
4+
# Tests that --min-release-age applies to transitive dependencies, forcing both
5+
# stevedore and its dependency pbr to fall back to older versions.
6+
#
7+
# Release timeline (all times UTC):
8+
#
9+
# stevedore 5.1.0 2023-05-15 (the expected fallback for stevedore)
10+
# pbr 6.0.0 2023-11-07 (blocked by cooldown — the anchor date)
11+
# stevedore 5.2.0 2024-02-22 (blocked by cooldown)
12+
# pbr 6.1.0 2024-08-27 (blocked by cooldown)
13+
# stevedore 5.3.0+ 2024-08-22+ (all blocked by cooldown)
14+
# pbr 7.x 2025-08-13+ (all blocked by cooldown)
15+
#
16+
# pbr 5.11.1 2023-01-11 (the expected fallback for pbr)
17+
#
18+
# We compute --min-release-age dynamically as the age of pbr 6.0.0 in days plus
19+
# a 1-day buffer. This places the cutoff just past pbr 6.0.0's release,
20+
# which also falls past stevedore 5.2.0 (released ~107 days after pbr 6.0.0).
21+
#
22+
# The margin between the cutoff and stevedore 5.1.0 is fixed at ~175 days
23+
# (2023-11-07 minus 2023-05-15, less the 1-day buffer), so stevedore 5.1.0
24+
# always clears the cooldown window regardless of when the test runs.
25+
#
26+
# The margin between the cutoff and pbr 5.11.1 is fixed at ~304 days
27+
# (2023-11-07 minus 2023-01-11, less the 1-day buffer), so pbr 5.11.1
28+
# similarly always clears the window.
29+
30+
SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
31+
source "$SCRIPTDIR/common.sh"
32+
33+
# Compute min-age: days since pbr 6.0.0 was published, plus a buffer.
34+
# pbr 6.0.0 was released 2023-11-07; adding 1 day ensures it is always
35+
# just inside the cooldown window and forces the resolver to pbr 5.11.1.
36+
# Because stevedore 5.2.0 (2024-02-22) was released ~107 days after pbr
37+
# 6.0.0, it is also blocked, and the resolver falls back to stevedore 5.1.0.
38+
MIN_AGE=$(python3 -c "
39+
from datetime import date
40+
age = (date.today() - date(2023, 11, 7)).days
41+
print(age + 1)
42+
")
43+
44+
fromager \
45+
--log-file="$OUTDIR/bootstrap.log" \
46+
--error-log-file="$OUTDIR/fromager-errors.log" \
47+
--sdists-repo="$OUTDIR/sdists-repo" \
48+
--wheels-repo="$OUTDIR/wheels-repo" \
49+
--work-dir="$OUTDIR/work-dir" \
50+
--min-release-age="$MIN_AGE" \
51+
bootstrap 'stevedore'
52+
53+
find "$OUTDIR/wheels-repo/" -name '*.whl'
54+
55+
pass=true
56+
57+
# stevedore 5.2.0+ are all blocked; the resolver must fall back to 5.1.0.
58+
if ! grep -q "new toplevel dependency stevedore resolves to 5.1.0" "$OUTDIR/bootstrap.log"; then
59+
echo "FAIL: expected stevedore to resolve to 5.1.0 but it did not" 1>&2
60+
pass=false
61+
fi
62+
63+
# pbr 6.0.0+ are all blocked; the resolver must fall back to 5.11.1.
64+
# pbr is first resolved as a build-backend dependency so we match any dep type.
65+
if ! grep -q "dependency pbr.*resolves to 5.11.1" "$OUTDIR/bootstrap.log"; then
66+
echo "FAIL: expected pbr to resolve to 5.11.1 but it did not" 1>&2
67+
pass=false
68+
fi
69+
70+
# Confirm the expected wheels were actually produced.
71+
if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.1.0*.whl' | grep -q .; then
72+
echo "FAIL: stevedore-5.1.0 wheel not found in wheels-repo" 1>&2
73+
pass=false
74+
fi
75+
76+
if ! find "$OUTDIR/wheels-repo/downloads/" -name 'pbr-5.11.1*.whl' | grep -q .; then
77+
echo "FAIL: pbr-5.11.1 wheel not found in wheels-repo" 1>&2
78+
pass=false
79+
fi
80+
81+
$pass

0 commit comments

Comments
 (0)