Skip to content

Commit a11daf3

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) Co-Authored-By: Claude <claude@anthropic.com>
1 parent 5a49262 commit a11daf3

24 files changed

+1304
-0
lines changed

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: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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+
Per-Package Override
93+
--------------------
94+
95+
The cooldown can be adjusted on a per-package basis using the
96+
``resolver_dist.min_release_age`` setting in the package's settings file:
97+
98+
.. code-block:: yaml
99+
100+
# overrides/settings/my-package.yaml
101+
resolver_dist:
102+
min_release_age: 0 # disable cooldown for this package
103+
# min_release_age: 30 # or use a different number of days
104+
105+
Valid values:
106+
107+
* Omit the key (default): inherit the global ``--min-release-age`` setting.
108+
* ``0``: disable the cooldown for this package, regardless of the global flag.
109+
* Positive integer: use this many days instead of the global setting.
110+
111+
This is useful when a specific package is trusted enough to allow recent
112+
versions, or when a package's release cadence makes the global cooldown
113+
impractical.

e2e/ci_bootstrap_suite.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ 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_gitlab"
33+
run_test "bootstrap_cooldown_github"
34+
run_test "bootstrap_cooldown_override"
35+
2936
test_section "bootstrap git URL tests"
3037
run_test "bootstrap_git_url"
3138
run_test "bootstrap_git_url_tag"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
resolver_dist:
2+
min_release_age: 0
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[build-system]
2+
requires = ["setuptools"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "github-example-with-package-plugin"
7+
version = "0.1.0"
8+
description = "Example Fromager package plugin demonstrating GitHubTagProvider via get_resolver_provider"
9+
requires-python = ">=3.12"
10+
dependencies = []
11+
12+
[project.entry-points."fromager.project_overrides"]
13+
stevedore = "package_plugins.stevedore_github"

e2e/github_override_example/src/package_plugins/__init__.py

Whitespace-only changes.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from fromager import context, resolver
2+
from packaging.requirements import Requirement
3+
4+
5+
def get_resolver_provider(
6+
ctx: context.WorkContext,
7+
req: Requirement,
8+
sdist_server_url: str,
9+
include_sdists: bool,
10+
include_wheels: bool,
11+
req_type: resolver.RequirementType | None = None,
12+
ignore_platform: bool = False,
13+
) -> resolver.GitHubTagProvider:
14+
"""Return a GitHubTagProvider for the stevedore test repo on github.com."""
15+
return resolver.GitHubTagProvider(
16+
organization="python-wheel-build",
17+
repo="stevedore-test-repo",
18+
constraints=ctx.constraints,
19+
req_type=req_type,
20+
cooldown=ctx.cooldown,
21+
override_download_url=(
22+
"https://github.com/{organization}/{repo}"
23+
"/archive/refs/tags/{tagname}.tar.gz"
24+
),
25+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[build-system]
2+
requires = ["setuptools"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "gitlab-example-with-package-plugin"
7+
version = "0.1.0"
8+
description = "Example Fromager package plugin demonstrating GitLabTagProvider via get_resolver_provider"
9+
requires-python = ">=3.12"
10+
dependencies = []
11+
12+
[project.entry-points."fromager.project_overrides"]
13+
python_gitlab = "package_plugins.python_gitlab"

e2e/gitlab_override_example/src/package_plugins/__init__.py

Whitespace-only changes.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from fromager import context, resolver
2+
from packaging.requirements import Requirement
3+
4+
5+
def get_resolver_provider(
6+
ctx: context.WorkContext,
7+
req: Requirement,
8+
sdist_server_url: str,
9+
include_sdists: bool,
10+
include_wheels: bool,
11+
req_type: resolver.RequirementType | None = None,
12+
ignore_platform: bool = False,
13+
) -> resolver.GitLabTagProvider:
14+
"""Return a GitLabTagProvider for the python-gitlab project on gitlab.com."""
15+
return resolver.GitLabTagProvider(
16+
project_path="python-gitlab/python-gitlab",
17+
constraints=ctx.constraints,
18+
req_type=req_type,
19+
cooldown=ctx.cooldown,
20+
)

0 commit comments

Comments
 (0)