-
Notifications
You must be signed in to change notification settings - Fork 49
feat(resolver): add PyPI cooldown policy to support configurable rejection of recently-published sdists #989
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
Closed
ryanpetrello
wants to merge
1
commit into
python-wheel-build:main
from
ryanpetrello:pypi-simple-cooldown
Closed
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
Some comments aren't visible on the classic Files Changed page.
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,115 @@ | ||
| Protect Against Supply-Chain Attacks with PyPI Cooldown | ||
| ======================================================== | ||
|
|
||
| Fromager's PyPI 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 ``--pypi-min-age`` flag, or set the equivalent environment | ||
| variable ``FROMAGER_PYPI_MIN_AGE``: | ||
|
|
||
| .. code-block:: bash | ||
|
|
||
| # Reject versions published in the last 7 days | ||
| fromager --pypi-min-age 7 bootstrap -r requirements.txt | ||
|
|
||
| # Same, via environment variable (useful for CI and builder integrations) | ||
| FROMAGER_PYPI_MIN_AGE=7 fromager bootstrap -r requirements.txt | ||
|
|
||
| # Disable the cooldown (default) | ||
| fromager --pypi-min-age 0 bootstrap -r requirements.txt | ||
|
|
||
| The ``--pypi-min-age`` flag accepts a non-negative integer number of days. | ||
| A value of ``0`` (the default) disables the check entirely. | ||
|
|
||
| Scope | ||
| ----- | ||
|
|
||
| The cooldown applies only 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, which use a separate code path. | ||
|
|
||
| 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. | ||
|
|
||
| Explicit version pins (``package==1.2.3``) are subject to the same cooldown as | ||
| unpinned requirements. If the pinned version was published within the cooldown | ||
| window, resolution will fail. To unblock a specific run, set ``--pypi-min-age 0`` | ||
| or use the environment variable. | ||
|
|
||
| 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 --pypi-min-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 --pypi-min-age 60 bootstrap example-pkg | ||
|
|
||
| Overriding the Cooldown for a Pinned Version | ||
| -------------------------------------------- | ||
|
|
||
| If a pinned version is blocked by the cooldown, resolution fails with a message | ||
| identifying the cause: | ||
|
|
||
| .. code-block:: console | ||
|
|
||
| $ fromager --pypi-min-age 7 bootstrap example-pkg==2.0.0 | ||
| ERROR: found 1 candidate(s) for example-pkg==2.0.0 but all were published | ||
| within the last 7 days (PyPI cooldown; oldest is 3 day(s) old) | ||
|
|
||
| To override the cooldown for a single run, set ``--pypi-min-age 0``: | ||
|
|
||
| .. code-block:: bash | ||
|
|
||
| fromager --pypi-min-age 0 bootstrap example-pkg==2.0.0 | ||
|
|
||
| A value of ``0`` disables the cooldown entirely, allowing any version to be | ||
| selected regardless of when it was published. | ||
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,84 @@ | ||
| #!/bin/bash | ||
| # -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- | ||
|
|
||
| # Tests that --pypi-min-age rejects versions published within the cooldown | ||
| # window and falls back to an older stevedore version. Verifies both the | ||
| # CLI flag (--pypi-min-age) and the equivalent environment variable | ||
| # (FROMAGER_PYPI_MIN_AGE) produce identical behaviour. | ||
| # | ||
| # Release timeline (all times UTC): | ||
| # | ||
| # stevedore 5.1.0 2023-05-15 (the expected fallback) | ||
| # stevedore 5.2.0 2024-02-22 (blocked by cooldown) | ||
| # stevedore 5.3.0+ 2024-08-22+ (all blocked by cooldown) | ||
| # | ||
| # We compute --pypi-min-age dynamically as the age of stevedore 5.2.0 in days | ||
| # plus a 1-day buffer, ensuring stevedore 5.2.0 is always just inside the | ||
| # cooldown window while stevedore 5.1.0 (released ~9 months earlier) always | ||
| # clears it. | ||
| # | ||
| # The margin between the cutoff and stevedore 5.1.0's age is fixed at ~292 | ||
| # days (the gap between the two release dates minus the 1-day buffer), so | ||
| # this test remains stable indefinitely as long as no new stevedore release | ||
| # lands between 5.1.0 and 5.2.0. | ||
|
|
||
| SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" | ||
| source "$SCRIPTDIR/common.sh" | ||
|
|
||
| # Compute min-age: days since stevedore 5.2.0 was published, plus a buffer. | ||
| # stevedore 5.2.0 was released 2024-02-22; 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, 2, 22)).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" \ | ||
| --pypi-min-age="$MIN_AGE" \ | ||
| bootstrap 'stevedore' | ||
|
|
||
| pass=true | ||
|
|
||
| # stevedore 5.2.0 is blocked; the resolver must fall back to 5.1.0. | ||
| if ! grep -q "new toplevel dependency stevedore resolves to 5.1.0" "$OUTDIR/bootstrap-flag.log"; then | ||
| echo "FAIL (flag): expected stevedore to resolve to 5.1.0 but it did not" 1>&2 | ||
| pass=false | ||
| fi | ||
|
|
||
| if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.1.0*.whl' | grep -q .; then | ||
| echo "FAIL (flag): stevedore-5.1.0 wheel not found in wheels-repo" 1>&2 | ||
| pass=false | ||
| fi | ||
|
|
||
| # --- Pass 2: enforce the same cooldown via environment variable --- | ||
|
|
||
| # Wipe output so the second run starts clean. | ||
| rm -rf "$OUTDIR/sdists-repo" "$OUTDIR/wheels-repo" "$OUTDIR/work-dir" | ||
|
|
||
| FROMAGER_PYPI_MIN_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.1.0" "$OUTDIR/bootstrap-envvar.log"; then | ||
| echo "FAIL (envvar): expected stevedore to resolve to 5.1.0 but it did not" 1>&2 | ||
| pass=false | ||
| fi | ||
|
|
||
| if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.1.0*.whl' | grep -q .; then | ||
| echo "FAIL (envvar): stevedore-5.1.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,55 @@ | ||
| #!/bin/bash | ||
| # -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- | ||
|
|
||
| # Tests that --pypi-min-age causes resolution to fail with a clear error when | ||
| # a constraint pins a dependency to a version that falls within the cooldown | ||
| # window (i.e., the only candidate allowed by the constraint is too recent). | ||
| # | ||
| # Release timeline (all times UTC): | ||
| # | ||
| # pbr 7.0.3 2025-11-03 (pinned by constraint; blocked by cooldown) | ||
| # | ||
| # We pin pbr==7.0.3 via a constraints file and set --pypi-min-age to the age | ||
| # of pbr 7.0.3 plus a 1-day buffer, so pbr 7.0.3 is always just inside the | ||
| # cooldown window. Because the constraint eliminates all other pbr candidates, | ||
| # the resolver has no valid version to select and must fail. | ||
| # | ||
| # The expected error message confirms that fromager correctly identifies the | ||
| # cooldown as the cause of the resolution failure rather than emitting a | ||
| # generic "no match found" error. | ||
|
|
||
| SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" | ||
| source "$SCRIPTDIR/common.sh" | ||
|
|
||
| # Compute min-age: days since pbr 7.0.3 was published, plus a buffer. | ||
| # pbr 7.0.3 was released 2025-11-03; 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(2025, 11, 3)).days | ||
| print(age + 1) | ||
| ") | ||
|
|
||
| constraints_file=$(mktemp) | ||
| trap "rm -f $constraints_file" EXIT | ||
| echo "pbr==7.0.3" > "$constraints_file" | ||
|
|
||
| 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" \ | ||
| --constraints-file="$constraints_file" \ | ||
| --pypi-min-age="$MIN_AGE" \ | ||
| bootstrap 'stevedore' || true | ||
|
|
||
| pass=true | ||
|
|
||
| # The resolver must fail with a message identifying pbr and the cooldown as the cause. | ||
| if ! grep -q "candidate(s) for pbr.*but all were published within the last.*days (PyPI cooldown" "$OUTDIR/fromager-errors.log"; then | ||
| echo "FAIL: expected pbr cooldown error in fromager-errors.log but did not find it" 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,81 @@ | ||
| #!/bin/bash | ||
| # -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- | ||
|
|
||
| # Tests that --pypi-min-age applies to transitive dependencies, forcing both | ||
| # stevedore and its dependency pbr to fall back to older versions. | ||
| # | ||
| # Release timeline (all times UTC): | ||
| # | ||
| # stevedore 5.1.0 2023-05-15 (the expected fallback for stevedore) | ||
| # pbr 6.0.0 2023-11-07 (blocked by cooldown — the anchor date) | ||
| # stevedore 5.2.0 2024-02-22 (blocked by cooldown) | ||
| # pbr 6.1.0 2024-08-27 (blocked by cooldown) | ||
| # stevedore 5.3.0+ 2024-08-22+ (all blocked by cooldown) | ||
| # pbr 7.x 2025-08-13+ (all blocked by cooldown) | ||
| # | ||
| # pbr 5.11.1 2023-01-11 (the expected fallback for pbr) | ||
| # | ||
| # We compute --pypi-min-age dynamically as the age of pbr 6.0.0 in days plus | ||
| # a 1-day buffer. This places the cutoff just past pbr 6.0.0's release, | ||
| # which also falls past stevedore 5.2.0 (released ~107 days after pbr 6.0.0). | ||
| # | ||
| # The margin between the cutoff and stevedore 5.1.0 is fixed at ~175 days | ||
| # (2023-11-07 minus 2023-05-15, less the 1-day buffer), so stevedore 5.1.0 | ||
| # always clears the cooldown window regardless of when the test runs. | ||
| # | ||
| # The margin between the cutoff and pbr 5.11.1 is fixed at ~304 days | ||
| # (2023-11-07 minus 2023-01-11, less the 1-day buffer), so pbr 5.11.1 | ||
| # similarly always clears the window. | ||
|
|
||
| SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" | ||
| source "$SCRIPTDIR/common.sh" | ||
|
|
||
| # Compute min-age: days since pbr 6.0.0 was published, plus a buffer. | ||
| # pbr 6.0.0 was released 2023-11-07; adding 1 day ensures it is always | ||
| # just inside the cooldown window and forces the resolver to pbr 5.11.1. | ||
| # Because stevedore 5.2.0 (2024-02-22) was released ~107 days after pbr | ||
| # 6.0.0, it is also blocked, and the resolver falls back to stevedore 5.1.0. | ||
| MIN_AGE=$(python3 -c " | ||
| from datetime import date | ||
| age = (date.today() - date(2023, 11, 7)).days | ||
| print(age + 1) | ||
| ") | ||
|
|
||
| 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" \ | ||
| --pypi-min-age="$MIN_AGE" \ | ||
| bootstrap 'stevedore' | ||
|
|
||
| find "$OUTDIR/wheels-repo/" -name '*.whl' | ||
|
|
||
| pass=true | ||
|
|
||
| # stevedore 5.2.0+ are all blocked; the resolver must fall back to 5.1.0. | ||
| if ! grep -q "new toplevel dependency stevedore resolves to 5.1.0" "$OUTDIR/bootstrap.log"; then | ||
| echo "FAIL: expected stevedore to resolve to 5.1.0 but it did not" 1>&2 | ||
| pass=false | ||
| fi | ||
|
|
||
| # pbr 6.0.0+ are all blocked; the resolver must fall back to 5.11.1. | ||
| # pbr is first resolved as a build-backend dependency so we match any dep type. | ||
| if ! grep -q "dependency pbr.*resolves to 5.11.1" "$OUTDIR/bootstrap.log"; then | ||
| echo "FAIL: expected pbr to resolve to 5.11.1 but it did not" 1>&2 | ||
| pass=false | ||
| fi | ||
|
|
||
| # Confirm the expected wheels were actually produced. | ||
| if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.1.0*.whl' | grep -q .; then | ||
| echo "FAIL: stevedore-5.1.0 wheel not found in wheels-repo" 1>&2 | ||
| pass=false | ||
| fi | ||
|
|
||
| if ! find "$OUTDIR/wheels-repo/downloads/" -name 'pbr-5.11.1*.whl' | grep -q .; then | ||
| echo "FAIL: pbr-5.11.1 wheel not found in wheels-repo" 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we add an example for how to override it? Maybe something like:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it should be covered by the usage examples above?
https://github.com/python-wheel-build/fromager/pull/989/changes#diff-eef470054eca70c06f3f21416767579c22d611bd2c870a95b9c2e722dace6862R38