-
Notifications
You must be signed in to change notification settings - Fork 49
feat(resolver): add release-age cooldown to protect against supply-chain attacks #1018
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ryanpetrello
wants to merge
1
commit into
python-wheel-build:main
Choose a base branch
from
ryanpetrello:release-age-cooldown
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| Protect Against Supply-Chain Attacks with Release-Age Cooldown | ||
| ============================================================== | ||
|
|
||
| Fromager's release-age cooldown policy rejects package versions that were | ||
| published fewer than a configured number of 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. | ||
|
|
||
| How It Works | ||
| ------------ | ||
|
|
||
| When a cooldown is active, any candidate whose ``upload-time`` is more recent | ||
| than the cutoff (current time minus the configured minimum age) is not | ||
| considered a valid option during constraint resolution. If no versions of a | ||
| package satisfy both the cooldown window and any other provided constraints, | ||
| resolution fails with an informative error. | ||
|
|
||
| The cutoff timestamp is fixed at the start of each run, so all package | ||
| resolutions within a single bootstrap share the same boundary. | ||
|
|
||
| Enabling the Cooldown | ||
| --------------------- | ||
|
|
||
| Use the global ``--min-release-age`` flag, or set the equivalent environment | ||
| variable ``FROMAGER_MIN_RELEASE_AGE``: | ||
|
|
||
| .. code-block:: bash | ||
|
|
||
| # Reject versions published in the last 7 days | ||
| fromager --min-release-age 7 bootstrap -r requirements.txt | ||
|
|
||
| # Same, via environment variable (useful for CI and builder integrations) | ||
| FROMAGER_MIN_RELEASE_AGE=7 fromager bootstrap -r requirements.txt | ||
|
|
||
| # Disable the cooldown (default) | ||
| fromager --min-release-age 0 bootstrap -r requirements.txt | ||
|
|
||
| The ``--min-release-age`` flag accepts a non-negative integer number of days. | ||
| A value of ``0`` (the default) disables the check entirely. | ||
|
|
||
| Scope | ||
| ----- | ||
|
|
||
| The cooldown applies to **sdist resolution** — selecting which version of a | ||
| package to build from source, including transitive dependencies. It does not | ||
| apply to: | ||
|
|
||
| * Wheel-only lookups, including cache servers (``--cache-wheel-server-url``) and | ||
| packages configured as ``pre_built: true`` in variant settings. These use a | ||
| different trust model and are not subject to the cooldown regardless of which | ||
| server they are fetched from. | ||
| * Packages resolved from Git URLs that do not provide timestamp metadata. | ||
|
|
||
| Note that sdist resolution from a private package index depends on | ||
| ``upload-time`` being present in the index's PEP 691 JSON responses. If the | ||
| index does not provide that metadata, candidates will be rejected under the | ||
| fail-closed policy described below. | ||
|
|
||
|
|
||
| Fail-Closed Behavior | ||
| -------------------- | ||
|
|
||
| If a candidate has no ``upload-time`` metadata — which can occur with older | ||
| PyPI Simple HTML responses — it is rejected when a cooldown is active. Fromager | ||
| uses the `PEP 691 JSON Simple API`_ when fetching package metadata, which | ||
| reliably includes upload timestamps. | ||
|
|
||
| .. _PEP 691 JSON Simple API: https://peps.python.org/pep-0691/ | ||
|
|
||
| Example | ||
| ------- | ||
|
|
||
| Given a package ``example-pkg`` with three available versions: | ||
|
|
||
| * ``2.0.0`` — published 3 days ago | ||
| * ``1.9.0`` — published 45 days ago | ||
| * ``1.8.0`` — published 120 days ago | ||
|
|
||
| With a 7-day cooldown, ``2.0.0`` is blocked and ``1.9.0`` is selected: | ||
|
|
||
| .. code-block:: bash | ||
|
|
||
| fromager --min-release-age 7 bootstrap example-pkg | ||
|
|
||
| With a 60-day cooldown, both ``2.0.0`` and ``1.9.0`` are blocked and ``1.8.0`` | ||
| is selected: | ||
|
|
||
| .. code-block:: bash | ||
|
|
||
| fromager --min-release-age 60 bootstrap example-pkg | ||
|
|
||
| Per-Package Override | ||
| -------------------- | ||
|
|
||
| The cooldown can be adjusted on a per-package basis using the | ||
| ``resolver_dist.min_release_age`` setting in the package's settings file: | ||
|
|
||
| .. code-block:: yaml | ||
|
|
||
| # overrides/settings/my-package.yaml | ||
| resolver_dist: | ||
| min_release_age: 0 # disable cooldown for this package | ||
| # min_release_age: 30 # or use a different number of days | ||
|
|
||
| Valid values: | ||
|
|
||
| * Omit the key (default): inherit the global ``--min-release-age`` setting. | ||
| * ``0``: disable the cooldown for this package, regardless of the global flag. | ||
| * Positive integer: use this many days instead of the global setting. | ||
|
|
||
| This is useful when a specific package is trusted enough to allow recent | ||
| versions, or when a package's release cadence makes the global cooldown | ||
| impractical. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| resolver_dist: | ||
| min_release_age: 0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| [build-system] | ||
| requires = ["setuptools"] | ||
| build-backend = "setuptools.build_meta" | ||
|
|
||
| [project] | ||
| name = "github-example-with-package-plugin" | ||
| version = "0.1.0" | ||
| description = "Example Fromager package plugin demonstrating GitHubTagProvider via get_resolver_provider" | ||
| requires-python = ">=3.12" | ||
| dependencies = [] | ||
|
|
||
| [project.entry-points."fromager.project_overrides"] | ||
| stevedore = "package_plugins.stevedore_github" |
Empty file.
26 changes: 26 additions & 0 deletions
26
e2e/github_override_example/src/package_plugins/stevedore_github.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| from packaging.requirements import Requirement | ||
|
|
||
| from fromager import context, resolver | ||
|
|
||
|
|
||
| def get_resolver_provider( | ||
| ctx: context.WorkContext, | ||
| req: Requirement, | ||
| sdist_server_url: str, | ||
| include_sdists: bool, | ||
| include_wheels: bool, | ||
| req_type: resolver.RequirementType | None = None, | ||
| ignore_platform: bool = False, | ||
| ) -> resolver.GitHubTagProvider: | ||
| """Return a GitHubTagProvider for the stevedore test repo on github.com.""" | ||
| return resolver.GitHubTagProvider( | ||
| organization="python-wheel-build", | ||
| repo="stevedore-test-repo", | ||
| constraints=ctx.constraints, | ||
| req_type=req_type, | ||
| cooldown=ctx.cooldown, | ||
| override_download_url=( | ||
| "https://github.com/{organization}/{repo}" | ||
| "/archive/refs/tags/{tagname}.tar.gz" | ||
| ), | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| [build-system] | ||
| requires = ["setuptools"] | ||
| build-backend = "setuptools.build_meta" | ||
|
|
||
| [project] | ||
| name = "gitlab-example-with-package-plugin" | ||
| version = "0.1.0" | ||
| description = "Example Fromager package plugin demonstrating GitLabTagProvider via get_resolver_provider" | ||
| requires-python = ">=3.12" | ||
| dependencies = [] | ||
|
|
||
| [project.entry-points."fromager.project_overrides"] | ||
| python_gitlab = "package_plugins.python_gitlab" |
Empty file.
21 changes: 21 additions & 0 deletions
21
e2e/gitlab_override_example/src/package_plugins/python_gitlab.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| from packaging.requirements import Requirement | ||
|
|
||
| from fromager import context, resolver | ||
|
|
||
|
|
||
| def get_resolver_provider( | ||
| ctx: context.WorkContext, | ||
| req: Requirement, | ||
| sdist_server_url: str, | ||
| include_sdists: bool, | ||
| include_wheels: bool, | ||
| req_type: resolver.RequirementType | None = None, | ||
| ignore_platform: bool = False, | ||
| ) -> resolver.GitLabTagProvider: | ||
| """Return a GitLabTagProvider for the python-gitlab project on gitlab.com.""" | ||
| return resolver.GitLabTagProvider( | ||
| project_path="python-gitlab/python-gitlab", | ||
| constraints=ctx.constraints, | ||
| req_type=req_type, | ||
| cooldown=ctx.cooldown, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| #!/bin/bash | ||
| # -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- | ||
|
|
||
| # Tests that --min-release-age rejects versions published within the cooldown | ||
| # window and falls back to an older stevedore version. Verifies both the | ||
| # CLI flag (--min-release-age) and the equivalent environment variable | ||
| # (FROMAGER_MIN_RELEASE_AGE) produce identical behaviour. | ||
| # | ||
| # Release timeline (all times UTC): | ||
| # | ||
| # stevedore 5.1.0 2023-05-15 | ||
| # stevedore 5.2.0 2024-02-22 | ||
| # stevedore 5.3.0 2024-08-22 (the expected fallback) | ||
| # stevedore 5.4.0 2024-11-20 (blocked by cooldown) | ||
| # stevedore 5.5.0+ future (all blocked by cooldown) | ||
| # | ||
| # We compute --min-release-age dynamically as the age of stevedore 5.4.0 in | ||
| # days plus a 1-day buffer, ensuring stevedore 5.4.0 is always just inside the | ||
| # cooldown window while stevedore 5.3.0 (released ~90 days earlier) always | ||
| # clears it. | ||
| # | ||
| # Anchoring to 5.4.0 (released 2024-11-20) also ensures the build toolchain | ||
| # can use flit_core 3.10.0 (released 2024-10-31), which is required for | ||
| # Python 3.14 compatibility. | ||
|
|
||
| SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" | ||
| source "$SCRIPTDIR/common.sh" | ||
|
|
||
| # Compute min-age: days since stevedore 5.4.0 was published, plus a buffer. | ||
| # stevedore 5.4.0 was released 2024-11-20; adding 1 day ensures it is | ||
| # always just inside the cooldown window regardless of when the test runs. | ||
| MIN_AGE=$(python3 -c " | ||
| from datetime import date | ||
| age = (date.today() - date(2024, 11, 20)).days | ||
| print(age + 1) | ||
| ") | ||
|
|
||
| # --- Pass 1: enforce cooldown via CLI flag --- | ||
|
|
||
| fromager \ | ||
| --log-file="$OUTDIR/bootstrap-flag.log" \ | ||
| --error-log-file="$OUTDIR/fromager-errors-flag.log" \ | ||
| --sdists-repo="$OUTDIR/sdists-repo" \ | ||
| --wheels-repo="$OUTDIR/wheels-repo" \ | ||
| --work-dir="$OUTDIR/work-dir" \ | ||
| --min-release-age="$MIN_AGE" \ | ||
| bootstrap 'stevedore' | ||
|
|
||
| pass=true | ||
|
|
||
| # stevedore 5.4.0 is blocked; the resolver must fall back to 5.3.0. | ||
| if ! grep -q "new toplevel dependency stevedore resolves to 5.3.0" "$OUTDIR/bootstrap-flag.log"; then | ||
| echo "FAIL (flag): expected stevedore to resolve to 5.3.0 but it did not" 1>&2 | ||
| pass=false | ||
| fi | ||
|
|
||
| if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.3.0*.whl' | grep -q .; then | ||
| echo "FAIL (flag): stevedore-5.3.0 wheel not found in wheels-repo" 1>&2 | ||
| pass=false | ||
| fi | ||
|
|
||
| # --- Pass 2: enforce the same cooldown via environment variable (FROMAGER_MIN_RELEASE_AGE) --- | ||
|
|
||
| # Wipe output so the second run starts clean. | ||
| rm -rf "$OUTDIR/sdists-repo" "$OUTDIR/wheels-repo" "$OUTDIR/work-dir" | ||
|
|
||
| FROMAGER_MIN_RELEASE_AGE="$MIN_AGE" fromager \ | ||
| --log-file="$OUTDIR/bootstrap-envvar.log" \ | ||
| --error-log-file="$OUTDIR/fromager-errors-envvar.log" \ | ||
| --sdists-repo="$OUTDIR/sdists-repo" \ | ||
| --wheels-repo="$OUTDIR/wheels-repo" \ | ||
| --work-dir="$OUTDIR/work-dir" \ | ||
| bootstrap 'stevedore' | ||
|
|
||
| if ! grep -q "new toplevel dependency stevedore resolves to 5.3.0" "$OUTDIR/bootstrap-envvar.log"; then | ||
| echo "FAIL (envvar): expected stevedore to resolve to 5.3.0 but it did not" 1>&2 | ||
| pass=false | ||
| fi | ||
|
|
||
| if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.3.0*.whl' | grep -q .; then | ||
| echo "FAIL (envvar): stevedore-5.3.0 wheel not found in wheels-repo" 1>&2 | ||
| pass=false | ||
| fi | ||
|
|
||
| $pass |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| #!/bin/bash | ||
| # -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- | ||
|
|
||
| # Tests that when --min-release-age is active and a package is resolved through | ||
| # the GitHubTagProvider, the cooldown is NOT enforced (because GitHub does not | ||
| # yet provide upload timestamps), but a warning is emitted for each candidate. | ||
| # | ||
| # The stevedore test repo (python-wheel-build/stevedore-test-repo) is used as | ||
| # a convenient GitHub-hosted package with known tags. | ||
| # | ||
| # MIN_AGE is anchored to stevedore 5.4.1 (2025-02-20), so it is large enough | ||
| # that enforcement WOULD block the resolved candidate — confirming that the | ||
| # GitHubTagProvider correctly skips enforcement rather than failing closed. | ||
| # Using a modest cooldown (rather than 9999 days) avoids inadvertently blocking | ||
| # PyPI build dependencies like setuptools, which are always recent. | ||
|
|
||
| SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" | ||
| source "$SCRIPTDIR/common.sh" | ||
|
|
||
| # Anchor: stevedore 5.4.1 was released 2025-02-20. | ||
| # MIN_AGE exceeds its age, so the cooldown would block it if enforced. | ||
| MIN_AGE=$(python3 -c " | ||
| from datetime import date | ||
| age = (date.today() - date(2025, 2, 20)).days | ||
| print(age + 1) | ||
| ") | ||
|
|
||
| # Install the override plugin that routes stevedore through GitHubTagProvider. | ||
| # Uninstall on exit so its entry points don't leak into subsequent e2e tests. | ||
| trap 'python3 -m pip uninstall -y github_override_example >/dev/null 2>&1 || true' EXIT | ||
| pip install "$SCRIPTDIR/github_override_example" | ||
|
|
||
| fromager \ | ||
| --log-file="$OUTDIR/bootstrap.log" \ | ||
| --error-log-file="$OUTDIR/fromager-errors.log" \ | ||
| --sdists-repo="$OUTDIR/sdists-repo" \ | ||
| --wheels-repo="$OUTDIR/wheels-repo" \ | ||
| --work-dir="$OUTDIR/work-dir" \ | ||
| --min-release-age="$MIN_AGE" \ | ||
| bootstrap 'stevedore' | ||
|
|
||
| find "$OUTDIR/wheels-repo/" -name '*.whl' | ||
|
|
||
| pass=true | ||
|
|
||
| # Resolution must succeed despite the 9999-day cooldown — GitHub timestamps | ||
| # are not yet supported, so the cooldown is skipped rather than enforced. | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-*.whl' | grep -q .; then | ||
| echo "FAIL: no stevedore wheel found — resolution should have succeeded despite cooldown" 1>&2 | ||
| pass=false | ||
| fi | ||
|
|
||
| # A warning must be emitted explaining why the cooldown was skipped. | ||
| if ! grep -q "not yet implemented" "$OUTDIR/bootstrap.log"; then | ||
| echo "FAIL: expected cooldown-skipped warning not found in log" 1>&2 | ||
| pass=false | ||
| fi | ||
|
|
||
| $pass | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.