From c1f388737e0684312644aac4191b44803100cfe0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 00:16:33 +0000 Subject: [PATCH] fix(ci): download pinned uv 0.12.1 from GitHub Releases releases.astral.sh now returns HTTP 403 for the pinned archive and blocks org-wide OpenCode coverage-evidence. Keep the same SHA-256 pin and allow only one hop onto GitHub's official release-asset hosts. Closes #1109 Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 4 +- CHANGELOG.md | 1 + .../trusted-uv-lock-materialization.md | 40 ++++-- .../materialize_base_python_requirements.py | 101 ++++++++++---- ...st_materialize_base_python_requirements.py | 42 +++++- tests/test_trusted_uv_download_contract.py | 2 +- .../test_uv_redirect_and_coverage_contract.py | 22 ++- tests/test_uv_redirect_boundary.py | 126 +++++++++++++++++- 8 files changed, 287 insertions(+), 51 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6fe6621b6..d4ef00ada 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -94,7 +94,9 @@ sequenceDiagram `scripts/ci/` ships with 100% statement/branch coverage and 100% docstrings. CI installs Python tools only with `pip install --require-hashes`. Contract -tests pin workflow structure and governance prose so drift fails closed. +tests pin workflow structure and governance prose so drift fails closed. The +trusted `uv` exporter is downloaded from the literal GitHub Releases URL for +`uv` 0.12.1; `releases.astral.sh` is not the network sink. ## Related durable documents diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de9130a5..412daff72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Download the pinned `uv` 0.12.1 exporter from the official GitHub Releases URL instead of `releases.astral.sh`, which now returns HTTP 403 and blocks org-wide OpenCode `coverage-evidence`. The SHA-256 pin is unchanged. The opener may follow one hop onto `release-assets.githubusercontent.com` or `objects.githubusercontent.com` and still rejects every other host, userinfo, non-HTTPS scheme, and nondefault port (ContextualWisdomLab/.github#1109). - Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. diff --git a/docs/doctoring/trusted-uv-lock-materialization.md b/docs/doctoring/trusted-uv-lock-materialization.md index 8f78759ca..2d83e8bda 100644 --- a/docs/doctoring/trusted-uv-lock-materialization.md +++ b/docs/doctoring/trusted-uv-lock-materialization.md @@ -18,10 +18,16 @@ The implementation therefore: absence; 3. installs one process-wide urllib opener with an empty proxy map and a redirect handler that rejects every redirect before urllib creates a target request; -4. downloads one fixed official Astral `uv` archive from a literal HTTPS URL and - accepts a response only when its parsed origin remains HTTPS, - `releases.astral.sh`, and the absent or explicit default port 443; malformed - or nondefault ports fail closed; +4. downloads one fixed official `uv` archive from the literal GitHub Releases + HTTPS URL and accepts a response only when its parsed origin remains HTTPS on + `github.com`, `release-assets.githubusercontent.com`, or + `objects.githubusercontent.com` with the absent or explicit default port 443; + malformed or nondefault ports, userinfo, and any other host fail closed. The + opener may follow exactly one hop from `github.com` onto those two GitHub + release-asset hosts. `releases.astral.sh` is no longer the network sink + because that vanity host now returns HTTP 403 for the pinned 0.12.1 archive + (ContextualWisdomLab/.github#1109) while the GitHub Releases asset keeps the + same SHA-256 digest; 5. verifies the bounded archive with a pinned SHA-256 digest before extraction; 6. accepts only the expected regular-file tar member within explicit size bounds; 7. writes the executable with mode `0755` and verifies that it reports the exact @@ -104,10 +110,12 @@ Regression coverage must prove: - base-revision-only reads and rejection of unsafe revision/path shapes; - an absent sibling project is skipped, but an inventoried project blob that cannot be read propagates a fatal error before uv starts; -- the download opener is cached, disables ambient proxies, and rejects redirects - before following them; -- fixed HTTPS scheme and hostname validation, acceptance only of an absent or - explicit port 443, rejection of malformed and nondefault ports, bounded reads, +- the download opener is cached, disables ambient proxies, and follows only one + `github.com` → GitHub release-asset CDN hop before rejecting every other + redirect; +- fixed HTTPS scheme and hostname validation for GitHub Releases plus the two + official asset hosts, acceptance only of an absent or explicit port 443, + rejection of userinfo, malformed ports, and nondefault ports, bounded reads, archive digest, member type, member size, executable size, executable mode, and exact version; - frozen, offline, cacheless, noninteractive exporter arguments; @@ -171,6 +179,9 @@ accepted by the coverage sandbox. ## References +Astral Software, Inc. (n.d.). *Installation*. uv documentation. Retrieved +August 18, 2026, from https://docs.astral.sh/uv/getting-started/installation/ + Astral Software, Inc. (n.d.). *Exporting a lockfile*. uv documentation. Retrieved August 4, 2026, from https://docs.astral.sh/uv/concepts/projects/export/ @@ -184,9 +195,16 @@ Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform Resource Identifi (URI): Generic syntax* (STD 66; RFC 3986). Internet Engineering Task Force. https://doi.org/10.17487/RFC3986 +Fielding, R. (Ed.), Nottingham, M. (Ed.), & Reschke, J. (Ed.). (2022). *HTTP +semantics* (RFC 9110). Internet Engineering Task Force. +https://doi.org/10.17487/RFC9110 + GitHub. (n.d.). *actions/checkout*. GitHub. Retrieved August 5, 2026, from https://github.com/actions/checkout +GitHub, Inc. (n.d.). *About releases*. GitHub Docs. Retrieved August 18, 2026, +from https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases + GitHub, Inc. (n.d.). *Events that trigger workflows*. GitHub Docs. Retrieved August 5, 2026, from https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows @@ -197,6 +215,12 @@ Supply-chain Levels for Software Artifacts. (2025). *SLSA specification Supply-chain Levels for Software Artifacts. (2025). *Provenance (version 1.2)*. https://slsa.dev/spec/v1.2/provenance +MITRE. (2026a). *CWE-601: URL redirection to untrusted site ('open redirect')*. +https://cwe.mitre.org/data/definitions/601.html + +MITRE. (2026b). *CWE-918: Server-side request forgery (SSRF)*. +https://cwe.mitre.org/data/definitions/918.html + Supply-chain Levels for Software Artifacts. (2025). *Source: Requirements for producing source (version 1.2)*. https://slsa.dev/spec/v1.2/source-requirements diff --git a/scripts/ci/materialize_base_python_requirements.py b/scripts/ci/materialize_base_python_requirements.py index 7a9c204b8..e4ebf473a 100755 --- a/scripts/ci/materialize_base_python_requirements.py +++ b/scripts/ci/materialize_base_python_requirements.py @@ -39,9 +39,17 @@ UV_EXPORT_TIMEOUT_SECONDS = 120 TRUSTED_UV_VERSION = "0.12.1" TRUSTED_UV_ARCHIVE_URL = ( - "https://releases.astral.sh/github/uv/releases/download/0.12.1/" + "https://github.com/astral-sh/uv/releases/download/0.12.1/" "uv-x86_64-unknown-linux-gnu.tar.gz" ) +TRUSTED_UV_RELEASE_HOST = "github.com" +TRUSTED_UV_ASSET_HOSTS = frozenset( + { + "release-assets.githubusercontent.com", + "objects.githubusercontent.com", + } +) +TRUSTED_UV_FINAL_HOSTS = frozenset({TRUSTED_UV_RELEASE_HOST, *TRUSTED_UV_ASSET_HOSTS}) TRUSTED_UV_ARCHIVE_SHA256 = ( "90b2f223fb69d19db49e117da601f64978593417988530aa733d456141b4bcbb" ) @@ -50,10 +58,51 @@ TRUSTED_UV_DOWNLOAD_MAX_BYTES = 64 * 1024 * 1024 TRUSTED_UV_BINARY_MAX_BYTES = 64 * 1024 * 1024 TRUSTED_UV_VERSION_TIMEOUT_SECONDS = 10 +TRUSTED_UV_ORIGIN_ERROR = ( + "trusted uv archive redirected outside the fixed GitHub release HTTPS origin" +) + + +def _https_default_port(parsed: urllib.parse.ParseResult) -> bool: + """Return whether one parsed URL uses the implicit or explicit HTTPS port.""" + try: + return parsed.port in (None, 443) + except ValueError: + return False + + +def _is_trusted_uv_https_host( + url: str, + allowed_hosts: frozenset[str], +) -> bool: + """Return whether ``url`` is HTTPS, default-port, and host-allowlisted.""" + parsed = urllib.parse.urlparse(url) + return ( + parsed.scheme == "https" + and parsed.hostname in allowed_hosts + and parsed.username is None + and parsed.password is None + and _https_default_port(parsed) + ) -class _RejectTrustedUvRedirects(urllib.request.HTTPRedirectHandler): - """Reject every redirect before urllib issues a request to its target.""" +def _is_trusted_uv_release_request(url: str) -> bool: + """Return whether the current request is still the GitHub Releases origin.""" + return _is_trusted_uv_https_host(url, frozenset({TRUSTED_UV_RELEASE_HOST})) + + +def _is_trusted_uv_asset_location(url: str) -> bool: + """Return whether the next hop is an official GitHub release-asset host.""" + return _is_trusted_uv_https_host(url, TRUSTED_UV_ASSET_HOSTS) + + +def _is_trusted_uv_final_origin(url: str) -> bool: + """Return whether the completed response stayed on a trusted HTTPS origin.""" + return _is_trusted_uv_https_host(url, TRUSTED_UV_FINAL_HOSTS) + + +class _TrustedUvReleaseAssetRedirects(urllib.request.HTTPRedirectHandler): + """Follow one GitHub Releases hop onto the official asset CDN only.""" def redirect_request( self, @@ -63,18 +112,31 @@ def redirect_request( message: str, headers: Any, new_url: str, - ) -> None: - """Fail closed for all redirect status codes and target locations.""" - del request, response, code, message, headers, new_url - raise RuntimeError("trusted uv archive redirects are forbidden") + ) -> urllib.request.Request: + """Allow github.com → GitHub asset CDN and reject every other hop.""" + if not _is_trusted_uv_release_request(request.full_url) or not ( + _is_trusted_uv_asset_location(new_url) + ): + raise RuntimeError(TRUSTED_UV_ORIGIN_ERROR) + followed = super().redirect_request( + request, + response, + code, + message, + headers, + new_url, + ) + if followed is None: + raise RuntimeError(TRUSTED_UV_ORIGIN_ERROR) + return followed @functools.cache def _install_trusted_uv_url_opener() -> None: - """Install one process-wide no-proxy, no-redirect opener for the fixed URL.""" + """Install one process-wide no-proxy opener for the fixed GitHub URL.""" opener = urllib.request.build_opener( urllib.request.ProxyHandler({}), - _RejectTrustedUvRedirects(), + _TrustedUvReleaseAssetRedirects(), ) urllib.request.install_opener(opener) @@ -226,27 +288,12 @@ def _download_trusted_uv_archive() -> bytes: # prove that neither user data nor repository content selects a scheme, # host, path, query, fragment, method, or request header. with urllib.request.urlopen( # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected # nosec B310 - "https://releases.astral.sh/github/uv/releases/download/0.12.1/" + "https://github.com/astral-sh/uv/releases/download/0.12.1/" "uv-x86_64-unknown-linux-gnu.tar.gz", timeout=TRUSTED_UV_DOWNLOAD_TIMEOUT_SECONDS, ) as response: - final_url = urllib.parse.urlparse(response.geturl()) - try: - final_port = final_url.port - except ValueError as exc: - raise RuntimeError( - "trusted uv archive redirected outside the fixed " - "releases.astral.sh HTTPS origin" - ) from exc - if ( - (final_url.scheme, final_url.hostname) - != ("https", "releases.astral.sh") - or final_port not in (None, 443) - ): - raise RuntimeError( - "trusted uv archive redirected outside the fixed " - "releases.astral.sh HTTPS origin" - ) + if not _is_trusted_uv_final_origin(response.geturl()): + raise RuntimeError(TRUSTED_UV_ORIGIN_ERROR) payload = bytearray() while len(payload) <= TRUSTED_UV_DOWNLOAD_MAX_BYTES: chunk = response.read( diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index 317ab5f5c..cc457748b 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -525,7 +525,7 @@ def _trusted_uv_archive( def test_download_trusted_uv_archive_accepts_fixed_https_origin( monkeypatch: pytest.MonkeyPatch, ) -> None: - """The downloader returns bounded bytes from the fixed Astral HTTPS origin.""" + """The downloader returns bounded bytes from the fixed GitHub HTTPS origin.""" payload = b"archive" response = FakeHttpResponse(materializer.TRUSTED_UV_ARCHIVE_URL, payload) monkeypatch.setattr(materializer.urllib.request, "urlopen", lambda *_a, **_k: response) @@ -533,11 +533,49 @@ def test_download_trusted_uv_archive_accepts_fixed_https_origin( assert materializer._download_trusted_uv_archive() == payload +def test_download_trusted_uv_archive_accepts_github_release_asset_origin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The official GitHub release-asset CDN remains a valid final HTTPS origin.""" + payload = b"archive" + response = FakeHttpResponse( + "https://release-assets.githubusercontent.com/" + "github-production-release-asset/699532645/archive", + payload, + ) + monkeypatch.setattr(materializer.urllib.request, "urlopen", lambda *_a, **_k: response) + + assert materializer._download_trusted_uv_archive() == payload + + +def test_download_trusted_uv_archive_accepts_legacy_objects_asset_origin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The previous GitHub release-asset hostname remains a valid final origin.""" + payload = b"archive" + response = FakeHttpResponse( + "https://objects.githubusercontent.com/github-production-release-asset/1/file", + payload, + ) + monkeypatch.setattr(materializer.urllib.request, "urlopen", lambda *_a, **_k: response) + + assert materializer._download_trusted_uv_archive() == payload + + +@pytest.mark.parametrize( + "unsafe_url", + [ + "https://example.invalid/uv.tar.gz", + "https://user@github.com/astral-sh/uv/releases/download/0.12.1/uv.tar.gz", + "https://:secret@github.com/astral-sh/uv/releases/download/0.12.1/uv.tar.gz", + ], +) def test_download_trusted_uv_archive_rejects_unsafe_redirect( monkeypatch: pytest.MonkeyPatch, + unsafe_url: str, ) -> None: """A redirect away from the fixed HTTPS release host fails closed.""" - response = FakeHttpResponse("https://example.invalid/uv.tar.gz") + response = FakeHttpResponse(unsafe_url) monkeypatch.setattr(materializer.urllib.request, "urlopen", lambda *_a, **_k: response) with pytest.raises(RuntimeError, match="redirected outside"): diff --git a/tests/test_trusted_uv_download_contract.py b/tests/test_trusted_uv_download_contract.py index 02f3c5961..380151db6 100644 --- a/tests/test_trusted_uv_download_contract.py +++ b/tests/test_trusted_uv_download_contract.py @@ -9,7 +9,7 @@ _REPO_ROOT = Path(__file__).resolve().parents[1] _MATERIALIZER = _REPO_ROOT / "scripts" / "ci" / "materialize_base_python_requirements.py" _EXPECTED_URL = ( - "https://releases.astral.sh/github/uv/releases/download/0.12.1/" + "https://github.com/astral-sh/uv/releases/download/0.12.1/" "uv-x86_64-unknown-linux-gnu.tar.gz" ) _SEMGREP_DYNAMIC_URL_RULE = ( diff --git a/tests/test_uv_redirect_and_coverage_contract.py b/tests/test_uv_redirect_and_coverage_contract.py index 0830624ef..bb83a9afc 100644 --- a/tests/test_uv_redirect_and_coverage_contract.py +++ b/tests/test_uv_redirect_and_coverage_contract.py @@ -17,15 +17,16 @@ @pytest.mark.parametrize( "unsafe_url", [ - "https://releases.astral.sh:444/github/uv/releases/download/0.12.1/uv.tar.gz", - "https://releases.astral.sh:not-a-port/github/uv/releases/download/0.12.1/uv.tar.gz", + "https://github.com:444/astral-sh/uv/releases/download/0.12.1/uv.tar.gz", + "https://github.com:not-a-port/astral-sh/uv/releases/download/0.12.1/uv.tar.gz", + "https://release-assets.githubusercontent.com:444/github-production-release-asset/1/file", ], ) def test_trusted_uv_download_rejects_nondefault_or_malformed_ports( monkeypatch: pytest.MonkeyPatch, unsafe_url: str, ) -> None: - """The pinned Astral host cannot redirect to another or malformed service port.""" + """The pinned GitHub release origin cannot land on another or malformed port.""" response = FakeHttpResponse(unsafe_url) monkeypatch.setattr( @@ -38,14 +39,21 @@ def test_trusted_uv_download_rejects_nondefault_or_malformed_ports( materializer._download_trusted_uv_archive() +@pytest.mark.parametrize( + "trusted_url", + [ + "https://github.com:443/astral-sh/uv/releases/download/0.12.1/uv-x86_64-unknown-linux-gnu.tar.gz", + "https://release-assets.githubusercontent.com:443/github-production-release-asset/1/file", + "https://objects.githubusercontent.com:443/github-production-release-asset/1/file", + ], +) def test_trusted_uv_download_accepts_explicit_default_https_port( monkeypatch: pytest.MonkeyPatch, + trusted_url: str, ) -> None: - """An explicit port 443 still denotes the fixed trusted HTTPS origin.""" + """An explicit port 443 still denotes a fixed trusted HTTPS origin.""" - response = FakeHttpResponse( - "https://releases.astral.sh:443/github/uv/releases/download/0.12.1/uv.tar.gz" - ) + response = FakeHttpResponse(trusted_url) monkeypatch.setattr( materializer.urllib.request, "urlopen", diff --git a/tests/test_uv_redirect_boundary.py b/tests/test_uv_redirect_boundary.py index fd98592e8..c453070f7 100644 --- a/tests/test_uv_redirect_boundary.py +++ b/tests/test_uv_redirect_boundary.py @@ -18,12 +18,34 @@ def clear_trusted_uv_opener_cache() -> Iterator[None]: materializer._install_trusted_uv_url_opener.cache_clear() +def test_trusted_uv_redirect_handler_allows_one_github_asset_hop() -> None: + """GitHub Releases may take one hop onto the official release-asset CDN.""" + handler = materializer._TrustedUvReleaseAssetRedirects() + original = urllib.request.Request(materializer.TRUSTED_UV_ARCHIVE_URL) + allowed = ( + "https://release-assets.githubusercontent.com/" + "github-production-release-asset/699532645/archive" + ) + + followed = handler.redirect_request( + original, + None, + 302, + "Found", + {}, + allowed, + ) + + assert followed is not None + assert followed.full_url == allowed + + def test_trusted_uv_redirect_handler_rejects_before_following() -> None: - """Every HTTP redirect is rejected before urllib creates a target request.""" - handler = materializer._RejectTrustedUvRedirects() + """Non-allowlisted hops are rejected before urllib creates a target request.""" + handler = materializer._TrustedUvReleaseAssetRedirects() original = urllib.request.Request(materializer.TRUSTED_UV_ARCHIVE_URL) - with pytest.raises(RuntimeError, match="redirects are forbidden"): + with pytest.raises(RuntimeError, match="redirected outside"): handler.redirect_request( original, None, @@ -34,10 +56,104 @@ def test_trusted_uv_redirect_handler_rejects_before_following() -> None: ) +def test_trusted_uv_redirect_handler_rejects_asset_host_follow_on() -> None: + """A second hop from the asset CDN cannot retarget the download.""" + handler = materializer._TrustedUvReleaseAssetRedirects() + current = urllib.request.Request( + "https://release-assets.githubusercontent.com/" + "github-production-release-asset/699532645/archive" + ) + + with pytest.raises(RuntimeError, match="redirected outside"): + handler.redirect_request( + current, + None, + 302, + "Found", + {}, + "https://objects.githubusercontent.com/other", + ) + + +def test_trusted_uv_redirect_handler_allows_legacy_objects_asset_hop() -> None: + """The previous GitHub release-asset hostname remains a valid first hop.""" + handler = materializer._TrustedUvReleaseAssetRedirects() + original = urllib.request.Request(materializer.TRUSTED_UV_ARCHIVE_URL) + allowed = "https://objects.githubusercontent.com/github-production-release-asset/1/file" + + followed = handler.redirect_request( + original, + None, + 302, + "Found", + {}, + allowed, + ) + + assert followed is not None + assert followed.full_url == allowed + + +def test_trusted_uv_redirect_handler_fails_closed_when_parent_drops_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A parent handler that drops the follow-on request cannot open a new origin.""" + handler = materializer._TrustedUvReleaseAssetRedirects() + original = urllib.request.Request(materializer.TRUSTED_UV_ARCHIVE_URL) + allowed = ( + "https://release-assets.githubusercontent.com/" + "github-production-release-asset/699532645/archive" + ) + + monkeypatch.setattr( + urllib.request.HTTPRedirectHandler, + "redirect_request", + lambda *_args, **_kwargs: None, + ) + + with pytest.raises(RuntimeError, match="redirected outside"): + handler.redirect_request( + original, + None, + 302, + "Found", + {}, + allowed, + ) + + +@pytest.mark.parametrize( + "new_url", + [ + "https://user@release-assets.githubusercontent.com/archive", + "https://:secret@release-assets.githubusercontent.com/archive", + "https://release-assets.githubusercontent.com:444/archive", + "https://release-assets.githubusercontent.com:not-a-port/archive", + "http://release-assets.githubusercontent.com/archive", + ], +) +def test_trusted_uv_redirect_handler_rejects_unsafe_asset_locations( + new_url: str, +) -> None: + """Userinfo, non-HTTPS, and nondefault ports cannot become the asset origin.""" + handler = materializer._TrustedUvReleaseAssetRedirects() + original = urllib.request.Request(materializer.TRUSTED_UV_ARCHIVE_URL) + + with pytest.raises(RuntimeError, match="redirected outside"): + handler.redirect_request( + original, + None, + 302, + "Found", + {}, + new_url, + ) + + def test_trusted_uv_opener_is_cached_and_disables_ambient_proxies( monkeypatch: pytest.MonkeyPatch, ) -> None: - """The dedicated process installs one no-proxy, no-redirect opener.""" + """The dedicated process installs one no-proxy GitHub-origin opener.""" captured: dict[str, object] = {"builds": 0, "installs": 0} sentinel = object() @@ -64,4 +180,4 @@ def fake_install_opener(opener: object) -> None: assert len(handlers) == 2 assert isinstance(handlers[0], urllib.request.ProxyHandler) assert handlers[0].proxies == {} - assert isinstance(handlers[1], materializer._RejectTrustedUvRedirects) + assert isinstance(handlers[1], materializer._TrustedUvReleaseAssetRedirects)