Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/how-tos/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Customize builds with overrides, variants, and version handling.
pyproject-overrides
multiple-versions
pre-release-versions
pypi-cooldown

Analyzing Builds
----------------
Expand Down
115 changes: 115 additions & 0 deletions docs/how-tos/pypi-cooldown.rst
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
Copy link
Copy Markdown
Contributor

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:

If a pinned version is blocked::

      $ fromager --pypi-min-age 7 bootstrap package==2.0.0
      ERROR: found 1 candidate for package==2.0.0 but all are within the 7-day cooldown window

  To override temporarily::

      $ fromager --pypi-min-age 0 bootstrap package==2.0.0

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
5 changes: 5 additions & 0 deletions e2e/ci_bootstrap_suite.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ run_test "bootstrap_prerelease"
run_test "bootstrap_cache"
run_test "bootstrap_sdist_only"

test_section "bootstrap cooldown tests"
run_test "bootstrap_cooldown"
run_test "bootstrap_cooldown_transitive"
run_test "bootstrap_cooldown_constraint_conflict"

test_section "bootstrap git URL tests"
run_test "bootstrap_git_url"
run_test "bootstrap_git_url_tag"
Expand Down
84 changes: 84 additions & 0 deletions e2e/test_bootstrap_cooldown.sh
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
55 changes: 55 additions & 0 deletions e2e/test_bootstrap_cooldown_constraint_conflict.sh
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
81 changes: 81 additions & 0 deletions e2e/test_bootstrap_cooldown_transitive.sh
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
Loading
Loading