-
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
2
commits 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,132 @@ | ||
| 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 both **sdist resolution** and **pre-built wheel | ||
| resolution** — any candidate whose ``upload-time`` is more recent than the | ||
| cutoff is rejected, regardless of whether it is an sdist or a wheel. | ||
|
|
||
| The following are **not** subject to the cooldown: | ||
|
|
||
| * Fromager's internal build and cache wheel servers. These are not used for | ||
| version selection — they are checked only for already-resolved pinned | ||
| versions — so the cooldown has no insertion point. | ||
| * Packages resolved from Git URLs. Git timestamps are set by the client, not | ||
| the server, and cannot be trusted for cooldown enforcement. | ||
|
|
||
| Resolution from a private package index (sdist or wheel) depends on | ||
| ``upload-time`` being present in the index's PEP 691 JSON responses. If the | ||
| index does not provide that metadata, candidates are rejected under the | ||
| fail-closed policy described below. Use ``resolver_dist.min_release_age: 0`` | ||
| to bypass cooldown for packages from indexes that structurally cannot supply | ||
| timestamps. | ||
|
|
||
|
|
||
| Fail-Closed Behavior | ||
| -------------------- | ||
|
|
||
| If a candidate has no ``upload-time`` metadata — whether it is an sdist or a | ||
| wheel — 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 for PyPI.org. | ||
|
|
||
| .. _PEP 691 JSON Simple API: https://peps.python.org/pep-0691/ | ||
|
|
||
| For indexes that only implement the `PEP 503`_ HTML API and cannot supply | ||
| timestamps, use the per-package ``resolver_dist.min_release_age: 0`` override | ||
| to bypass the cooldown for affected packages rather than disabling it globally. | ||
|
|
||
| .. _PEP 503: https://peps.python.org/pep-0503/ | ||
|
|
||
| .. note:: | ||
|
|
||
| If you are writing a ``get_resolver_provider`` plugin that uses | ||
| :class:`~fromager.resolver.PyPIProvider` with a private index that only | ||
| implements the PEP 503 HTML API, pass ``supports_upload_time=False`` to | ||
| ``PyPIProvider``. This switches the provider from fail-closed to | ||
| warn-and-skip, so candidates without upload timestamps are skipped with a | ||
| warning rather than causing resolution to fail. | ||
|
|
||
| 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,3 @@ | ||
| variants: | ||
| cpu: | ||
| pre_built: true |
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.
25 changes: 25 additions & 0 deletions
25
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,25 @@ | ||
| 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, | ||
| 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.
20 changes: 20 additions & 0 deletions
20
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,20 @@ | ||
| 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, | ||
| ) |
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.
|
||
| 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.