diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f06b150b..01bc2d96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,14 +117,12 @@ jobs: if: steps.runtime.outputs.available == 'true' run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s integration_docker - # Advisory OSV-scanner sweep over the Python dependency lockfile (issue #34). - # Non-gating: CVE findings surface as an uploaded JSON artifact and never fail - # the build. The `osv_scan` nox session is kept out of the hermetic `verify` - # graph; genuine scanner/setup failures still fail this job's step (visible as - # a soft failure) so the advisory posture never hides a broken scan. + # Gating OSV-scanner sweep over the Python dependency lockfile (issue #1098). + # Kept out of the hermetic `verify` graph because the scanner is networked. + # Findings and scanner/setup errors both fail, with distinct diagnostics; the + # report upload remains unconditional so either failure is reviewable. supply-chain: runs-on: ubuntu-latest - continue-on-error: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -132,7 +130,7 @@ jobs: python-version: "3.12" - name: Install uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v8 - - name: Run OSV-scanner (advisory) + - name: Run OSV-scanner (gating) run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s osv_scan - name: Upload OSV-scanner report if: always() diff --git a/docs/decisions/issue-1098-gov-913-supply-chain-security-preflight.md b/docs/decisions/issue-1098-gov-913-supply-chain-security-preflight.md new file mode 100644 index 00000000..6939c8d8 --- /dev/null +++ b/docs/decisions/issue-1098-gov-913-supply-chain-security-preflight.md @@ -0,0 +1,122 @@ +# Issue 1098 GOV-913 Supply-Chain Security Preflight + +Date: 2026-08-11 + +Issue: #1098. + +Requirement: GOV-913 (Trust And Integrity Of Reusable Assets). + +This note records the review boundary and enforcement policy for repairing two +known vulnerabilities in the frozen Python dependency graph. It covers +repository dependencies and CI automation only. It does not change reusable +asset semantics, SDL, runtime trust policy, OCI publication, or release +automation. + +## Findings And Fixed Floors + +The audited `origin/dev` lock at commit +`5c210d520e884cca0c08ad201b033fae920ce0c2` resolved: + +| Package | Locked finding | Affected surface | First fixed release | +| --- | --- | --- | --- | +| Click | `8.3.1`; PYSEC-2026-2132 / CVE-2026-7246 | `click.edit()` command launch could pass shell metacharacters to an OS shell | `8.3.3` | +| cryptography | `49.0.0`; GHSA-g6cj-pr64-35w5 / CVE-2026-69247 | PKCS#7 EnvelopedData RSA decryption exposed distinguishable errors and timing | `50.0.0` | + +The project therefore declares `click>=8.3.3` and +`cryptography>=50.0.0`. The Click declaration is also dependency hygiene: +`raes_cli.semantic` imports Click directly, so relying only on Typer's +transitive declaration was insufficient. + +Primary advisory and fix sources: + +- +- +- +- + +## Reachability And Consumer Review + +Reachability was reviewed to bound the incident, not to justify suppressing a +known-vulnerable package: + +- OpenRAE's direct Click call is `click.get_binary_stream("stdin")`. No + `click.edit()` or pager call was found in production source. Typer and Uvicorn + are the other resolved Click consumers. Their published constraints accept + Click 8.3.3. +- OpenRAE directly uses cryptography for Ed25519 private-key loading, signing, + and verification. No `pkcs7_decrypt_der`, `pkcs7_decrypt_pem`, + `pkcs7_decrypt_smime`, or EnvelopedData call was found. AsyncSSH and the + PyJWT crypto extra are the other resolved consumers; their published + constraints accept cryptography 50.0.0. +- The cryptography advisory requires repeated decryption of attacker-supplied + EnvelopedData with observable outcomes. That route was not found in the + current OpenRAE call graph. This is an observed limit, not a universal + non-exploitability claim for every downstream deployment or future consumer. + +The reviewed fixed releases support OpenRAE's Python `>=3.11` floor. The lock +refresh is intentionally limited to Click, cryptography, and resolver-required +metadata and artifacts, then checked with frozen reverse-dependency trees and a +live OSV scan. + +## Existing Scanner Lineage + +Issue #34 introduced the canonical scanner architecture: + +- scan only `implementations/python/uv.lock`; +- acquire a pinned OSV-Scanner binary with release checksum verification; +- keep the networked scan outside the hermetic `verify` graph; +- write one ignored JSON report and upload it even after failure; +- distinguish vulnerability exit code 1 from scanner/setup errors. + +Those decisions remain. Issue #34's initial advisory result policy is +superseded by issue #1098 because concrete vulnerable releases were able to +remain in the frozen lock without failing CI. + +## Required Failure Contract + +The scanner wrapper has one closed outcome classification: + +| Exit result | Classification | CI behavior | +| --- | --- | --- | +| `0` | clean | pass | +| `1` | findings | fail with a vulnerability-specific diagnostic | +| any other value | scanner error | fail with the exact scanner/setup exit code | + +The CI job must not use job-level or step-level `continue-on-error`. Its report +upload remains guarded by `if: always()` so both findings and tool failures +leave reviewable evidence. A scanner failure can never be interpreted as a +clean scan. + +OSV remains a standalone CI gate rather than part of `verify`: the local proof +graph is designed to be hermetic, while OSV acquisition and advisory lookup are +network-dependent. See the official return-code contract at +. + +## Alternatives Rejected + +- **Suppress based on current reachability.** The vulnerable APIs are not + observed today, but the packages are shipped and remain callable by direct, + transitive, or future consumers. Suppression would make a clean-lock claim + false. +- **Refresh only the lock.** The old project constraints could select a + vulnerable release again, and Click's direct import would remain undeclared. +- **Raise floors but leave OSV advisory.** This repairs one snapshot without + preventing the same failure mode for later advisories. +- **Put OSV inside hermetic verification.** This would make ordinary local and + proof verification depend on external network availability. The dedicated + required CI job preserves the correct boundary. + +## Verification And Nonclaims + +Regression coverage checks the direct dependency floors, locked versions, +closed OSV exit classification, distinct nox failures, absence of CI soft-fail +configuration, and unconditional report upload. Focused CLI and registry +signing tests protect the two used dependency surfaces. The final review also +runs a live OSV scan, frozen dependency trees, Ruff, repository policy, +requirement governance, and the canonical verification graph. + +This remediation does not claim that dependency scanning proves software +security, that unobserved vulnerable entry points are unreachable in every +downstream use, or that OSV availability is hermetic. It establishes the +narrower invariant that the reviewed frozen lock has no reported OSV findings +and that the required scan cannot fail silently. diff --git a/docs/decisions/issue-1106-gov-913-osv-cache-integrity.md b/docs/decisions/issue-1106-gov-913-osv-cache-integrity.md new file mode 100644 index 00000000..c144ebd0 --- /dev/null +++ b/docs/decisions/issue-1106-gov-913-osv-cache-integrity.md @@ -0,0 +1,38 @@ +# Issue 1106 / GOV-913 OSV Scanner Cache Integrity + +Date: 2026-08-11 + +Issue: #1106. Requirement: GOV-913. Related: #34 and #1098. + +## Decision + +The repository pins the official OSV-Scanner v2.4.0 SHA-256 for each admitted +Linux and macOS amd64/arm64 asset. Every `ensure_osv_scanner` call validates an +existing cache entry with `lstat`, accepts only a regular non-symlink executable, +opens the final component without following links where the host supports that +flag, verifies the opened file's identity with `fstat`, and hashes its complete +bounded bytes against the repository pin before returning it. +Tampered regular files and symlinks are unlinked and reacquired; directories or +an unsafe cache-parent shape fail closed. + +Downloaded bytes are checked against the same repository pin and installed from +a uniquely named sibling temporary file with an atomic replace. No partial +download is ever published at the executable path. Remote checksum metadata is +not the root of trust for an already reviewed tool version. +Release-asset acquisition also has a finite 60-second request timeout so a +stalled endpoint cannot hold the verification lane indefinitely. + +## Nonclaims + +This local cache integrity boundary does not make OSV advisory availability +hermetic, establish host compromise resistance, or replace release upgrade +review. A process with authority to mutate executable bytes continuously can +still race any path-based execution; repository CI assumes its workspace is not +actively controlled by another principal. + +## Verification + +Tests cover valid hits without network access, tampered bytes, symlinks, +directories, missing platform pins, download mismatch, atomic replacement, and +concurrent acquisition. The live clean-lock scan, Ruff, repository policy, and +required supply-chain job remain mandatory. diff --git a/docs/requirements/GOV-913/requirement.md b/docs/requirements/GOV-913/requirement.md index c87fa00e..a8e4454d 100644 --- a/docs/requirements/GOV-913/requirement.md +++ b/docs/requirements/GOV-913/requirement.md @@ -26,3 +26,15 @@ Requirement inventory expansion. Reusable ecosystem assets need explicit trust a - IMPLEMENTS → ADR `docs/decisions/adrs/adr-071-reusable-asset-trust-and-integrity-policy.md` (ADR-071: Reusable Asset Trust and Integrity Policy) - TESTS → TEST `implementations/python/tests/test_reusable_asset_trust_policy.py` (Reusable-asset trust policy contract tests) - IMPLEMENTS → GITHUB_ISSUE `115` (Trust & integrity of reusable assets (GOV-913)) +- IMPLEMENTS → GITHUB_ISSUE `1098` (Upgrade vulnerable Click and cryptography locks and gate OSV findings) +- IMPLEMENTS → CONFIG `implementations/python/pyproject.toml` (Fixed Click and cryptography dependency floors) +- IMPLEMENTS → CONFIG `implementations/python/uv.lock` (Reviewed frozen dependency resolution) +- IMPLEMENTS → CONFIG `.github/workflows/ci.yml` (Required OSV dependency-vulnerability gate) +- IMPLEMENTS → CONFIG `noxfile.py` (Explicit OSV findings and scanner-error enforcement) +- IMPLEMENTS → CODE_FILE `tools/osv_scanner_tool.py` (OSV result classification) +- IMPLEMENTS → DOCUMENTATION `docs/decisions/issue-1098-gov-913-supply-chain-security-preflight.md` (Dependency vulnerability and gating preflight) +- TESTS → TEST `implementations/python/tests/test_repo_policy_tools.py` (Dependency-floor and OSV gate regression tests) +- DOCUMENTS → GITHUB_ISSUE `1106` (Cached OSV-Scanner integrity validation) +- DOCUMENTS → DOCUMENTATION `docs/decisions/issue-1106-gov-913-osv-cache-integrity.md` (Repository pin and atomic cache decision) +- IMPLEMENTS → CODE_FILE `tools/osv_scanner_tool.py` (Per-use cache type, mode, and digest validation) +- TESTS → TEST `implementations/python/tests/test_repo_policy_tools.py` (Tampered, symlinked, and atomic OSV cache regressions) diff --git a/implementations/python/packages/raes_reference_backend/drivers/oci.py b/implementations/python/packages/raes_reference_backend/drivers/oci.py index 037e5cd1..194c6776 100644 --- a/implementations/python/packages/raes_reference_backend/drivers/oci.py +++ b/implementations/python/packages/raes_reference_backend/drivers/oci.py @@ -22,10 +22,8 @@ from __future__ import annotations -import re import subprocess from collections.abc import Callable -from dataclasses import dataclass from raes_backend_protocols.naming import provider_resource_name from raes_contracts.diagnostics import Diagnostic, Severity @@ -38,6 +36,7 @@ NetworkHandle, NetworkSpec, ) +from raes_reference_backend.drivers.oci_image_trust import ImageTrustPolicy from raes_reference_backend.drivers.oci_observation import ownership_fields_match, substrate_observations _DOMAIN = "runtime" @@ -70,68 +69,6 @@ def _default_runner(argv: list[str], **kwargs) -> subprocess.CompletedProcess: return subprocess.run(argv, **kwargs) -# OCI/distribution reference grammar, restricted to the trust boundary's needs. -# The name is an optional ``registry[:port]`` domain plus one or more lowercase -# path components; character classes for separators and alphanumerics are -# disjoint, so matching is linear (no catastrophic backtracking). -_REF_DOMAIN_COMPONENT = r"(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])" -# Docker and containerd also accept a bracketed IPv6 authority (``[2001:db8::1]``, -# optionally with a port), so a registry reachable only over IPv6 must not be -# rejected. Bounded and built from a disjoint character class, so still linear. -_REF_IPV6_AUTHORITY = r"\[[0-9A-Fa-f:.]{2,45}\]" -_REF_DOMAIN = rf"(?:{_REF_DOMAIN_COMPONENT}(?:\.{_REF_DOMAIN_COMPONENT})*|{_REF_IPV6_AUTHORITY})(?::[0-9]+)?" -_REF_PATH_COMPONENT = r"[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*" -_REF_NAME = rf"(?:{_REF_DOMAIN}/)?{_REF_PATH_COMPONENT}(?:/{_REF_PATH_COMPONENT})*" -_REF_TAG = r"[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}" - -# A digest-pinned reference is the driver's trust anchor: the content is bound -# to a specific manifest digest a plan author cannot swap. It is accepted only -# when a well-formed name (with optional tag) is terminated by a canonical -# ``sha256:`` digest of exactly 64 lowercase hex characters. ``fullmatch`` keeps -# the digest anchored at the very end, so an unanchored ``@sha256:`` substring -# that never actually pins content -- ``evil/img@sha256:x/pull-me:latest``, -# ``foo@sha256:short`` -- is rejected rather than trusted. -_DIGEST_PINNED_REF = re.compile(rf"{_REF_NAME}(?::{_REF_TAG})?@sha256:[0-9a-f]{{64}}") - -# The interpreter synthesizes ``raes-reference/`` (or -# ``raes-reference/base``) for a node that pins no image source. Match that -# exact placeholder shape -- a single lowercase path component -- so a -# ``default_image`` substitution can never be triggered by a plan-author ref -# that merely starts with the prefix while smuggling extra ``/``, ``:``, or -# ``@`` structure past it. -_PLACEHOLDER_REF = re.compile(rf"raes-reference/{_REF_PATH_COMPONENT}") - - -@dataclass(frozen=True) -class ImageTrustPolicy: - """Operator policy deciding which container images may be realized. - - A plan author controls ``spec.image_ref`` (via ``node.source``) and ``run`` - pulls+executes it; fixed argv stops shell injection but is not an image - trust boundary. Only the operator ``default_image``, an explicit - ``allowed_images`` entry, or a digest-pinned ref (``...@sha256:...``) is - permitted, so plan submission cannot become arbitrary-image code execution. - """ - - default_image: str | None = None - allowed_images: tuple[str, ...] = () - allow_digest_pinned: bool = True - - def image_for(self, image_ref: str) -> str: - # A configured default overrides the synthesized ``raes-reference/*`` - # placeholder so an image-less plan can still realize against a registry. - if self.default_image and _PLACEHOLDER_REF.fullmatch(image_ref): - return self.default_image - return image_ref - - def permits(self, image: str) -> bool: - if self.default_image is not None and image == self.default_image: - return True - if image in self.allowed_images: - return True - return self.allow_digest_pinned and _DIGEST_PINNED_REF.fullmatch(image) is not None - - _DEFAULT_IMAGE_POLICY = ImageTrustPolicy() diff --git a/implementations/python/packages/raes_reference_backend/drivers/oci_image_trust.py b/implementations/python/packages/raes_reference_backend/drivers/oci_image_trust.py new file mode 100644 index 00000000..ba5e4945 --- /dev/null +++ b/implementations/python/packages/raes_reference_backend/drivers/oci_image_trust.py @@ -0,0 +1,70 @@ +"""Container-image trust policy for the reference OCI driver.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +# OCI/distribution reference grammar, restricted to the trust boundary's needs. +# The name is an optional ``registry[:port]`` domain plus one or more lowercase +# path components; character classes for separators and alphanumerics are +# disjoint, so matching is linear (no catastrophic backtracking). +_REF_DOMAIN_COMPONENT = r"(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])" +# Docker and containerd also accept a bracketed IPv6 authority (``[2001:db8::1]``, +# optionally with a port), so a registry reachable only over IPv6 must not be +# rejected. Bounded and built from a disjoint character class, so still linear. +_REF_IPV6_AUTHORITY = r"\[[0-9A-Fa-f:.]{2,45}\]" +_REF_DOMAIN = rf"(?:{_REF_DOMAIN_COMPONENT}(?:\.{_REF_DOMAIN_COMPONENT})*|{_REF_IPV6_AUTHORITY})(?::[0-9]+)?" +_REF_PATH_COMPONENT = r"[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*" +_REF_NAME = rf"(?:{_REF_DOMAIN}/)?{_REF_PATH_COMPONENT}(?:/{_REF_PATH_COMPONENT})*" +_REF_TAG = r"[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}" + +# A digest-pinned reference is the driver's trust anchor: the content is bound +# to a specific manifest digest a plan author cannot swap. It is accepted only +# when a well-formed name (with optional tag) is terminated by a canonical +# ``sha256:`` digest of exactly 64 lowercase hex characters. ``fullmatch`` keeps +# the digest anchored at the very end, so an unanchored ``@sha256:`` substring +# that never actually pins content -- ``evil/img@sha256:x/pull-me:latest``, +# ``foo@sha256:short`` -- is rejected rather than trusted. +_DIGEST_PINNED_REF = re.compile(rf"{_REF_NAME}(?::{_REF_TAG})?@sha256:[0-9a-f]{{64}}") + +# The interpreter synthesizes ``raes-reference/`` (or +# ``raes-reference/base``) for a node that pins no image source. Match that +# exact placeholder shape -- a single lowercase path component -- so a +# ``default_image`` substitution can never be triggered by a plan-author ref +# that merely starts with the prefix while smuggling extra ``/``, ``:``, or +# ``@`` structure past it. +_PLACEHOLDER_REF = re.compile(rf"raes-reference/{_REF_PATH_COMPONENT}") + + +@dataclass(frozen=True) +class ImageTrustPolicy: + """Operator policy deciding which container images may be realized. + + A plan author controls ``spec.image_ref`` (via ``node.source``) and ``run`` + pulls+executes it; fixed argv stops shell injection but is not an image + trust boundary. Only the operator ``default_image``, an explicit + ``allowed_images`` entry, or a digest-pinned ref (``...@sha256:...``) is + permitted, so plan submission cannot become arbitrary-image code execution. + """ + + default_image: str | None = None + allowed_images: tuple[str, ...] = () + allow_digest_pinned: bool = True + + def image_for(self, image_ref: str) -> str: + # A configured default overrides the synthesized ``raes-reference/*`` + # placeholder so an image-less plan can still realize against a registry. + if self.default_image and _PLACEHOLDER_REF.fullmatch(image_ref): + return self.default_image + return image_ref + + def permits(self, image: str) -> bool: + if self.default_image is not None and image == self.default_image: + return True + if image in self.allowed_images: + return True + return self.allow_digest_pinned and _DIGEST_PINNED_REF.fullmatch(image) is not None + + +__all__ = ["ImageTrustPolicy"] diff --git a/implementations/python/pyproject.toml b/implementations/python/pyproject.toml index c46751b8..ad581a78 100644 --- a/implementations/python/pyproject.toml +++ b/implementations/python/pyproject.toml @@ -17,10 +17,11 @@ classifiers = [ ] dependencies = [ "typer>=0.12.0", + "click>=8.3.3", "pydantic>=2.0.0", "rich>=13.0.0", "PyYAML>=6.0", - "cryptography>=46.0.7", + "cryptography>=50.0.0", "defusedxml>=0.7.1", "fastapi>=0.115.0", "uvicorn[standard]>=0.34.0", @@ -186,6 +187,7 @@ ignore = [ "packages/raes_cli/**" = ["B008"] # typer requires function calls in defaults "packages/raes_runtime/control_plane.py" = ["S112"] # intentional exception suppression "packages/raes/module_registry/__init__.py" = ["S310", "S202", "F401"] # S310/S202: explicit OCI URL fetch + tar extract; F401: intentional facade re-exports +"tools/osv_scanner_tool.py" = ["S310"] # Exact GitHub HTTPS prefix and pinned asset digest are enforced before download "packages/raes_runtime/control_plane_api/__init__.py" = ["F401"] # intentional facade re-exports (_receipt_response) "packages/raes_mcp/tools/authoring/__init__.py" = ["F401"] # intentional facade re-export (register) "packages/raes_mcp/tools/inspection/__init__.py" = ["F401"] # intentional facade re-export (register) diff --git a/implementations/python/tests/test_http_download.py b/implementations/python/tests/test_http_download.py index 6865010a..a292028c 100644 --- a/implementations/python/tests/test_http_download.py +++ b/implementations/python/tests/test_http_download.py @@ -19,8 +19,8 @@ def __enter__(self) -> _Response: def __exit__(self, *args: object) -> None: return None - def read(self) -> bytes: - return self.payload + def read(self, size: int = -1) -> bytes: + return self.payload if size < 0 else self.payload[:size] def test_download_retries_transient_disconnects_with_bounded_backoff() -> None: @@ -81,3 +81,14 @@ def opener(url: str, *, timeout: float) -> _Response: _sleeper=lambda _delay: None, ) assert calls == 1 + + +def test_download_enforces_the_requested_size_bound() -> None: + with pytest.raises(RuntimeError, match="exceeds the download limit"): + download_bytes( + "https://example.invalid/pinned-tool", + description="pinned tool", + max_bytes=3, + _opener=lambda _url, **_kwargs: _Response(b"four"), + _sleeper=lambda _delay: None, + ) diff --git a/implementations/python/tests/test_repo_policy_tools.py b/implementations/python/tests/test_repo_policy_tools.py index c7b9b7af..e1c40e0d 100644 --- a/implementations/python/tests/test_repo_policy_tools.py +++ b/implementations/python/tests/test_repo_policy_tools.py @@ -3,10 +3,12 @@ import importlib.util import inspect import json +import os import shutil import subprocess import sys import threading +import tomllib import types from contextlib import nullcontext from pathlib import Path @@ -22,6 +24,8 @@ import tools.osv_scanner_tool as osv_scanner_tool import tools.policy.conftest_tool as conftest_tool import yaml +from packaging.requirements import Requirement +from packaging.version import Version from tools.check_adr_immutability import ( amendment_refs, canonical_content, @@ -2297,7 +2301,6 @@ def test_osv_scanner_release_asset_names_match_platform_conventions( # OSV-Scanner ships plain per-platform binaries, not archives. assert osv_scanner_tool._release_asset_name("2.4.0") == expected - assert osv_scanner_tool._checksums_asset_name("2.4.0") == "osv-scanner_SHA256SUMS" @pytest.mark.parametrize("system", ["Windows", "Plan9"]) @@ -2317,24 +2320,357 @@ def test_osv_scanner_binary_path_uses_repo_local_cache(tmp_path: Path) -> None: ) -def test_osv_scanner_expected_checksum_parses_sha256sums() -> None: - sha256sums = ( - "aaaa1111 osv-scanner_linux_amd64\n" - "bbbb2222 osv-scanner_darwin_arm64\n" - "cccc3333 osv-scanner_windows_amd64.exe\n" +def test_osv_scanner_checksums_are_repository_pinned_for_every_admitted_asset() -> None: + assert osv_scanner_tool.OSV_SCANNER_SHA256["2.4.0"] == { + "osv-scanner_darwin_amd64": "088119325156321c34c456ac3703d6013538fd71cbac82b891ab34db491e4d66", + "osv-scanner_darwin_arm64": "9ca3185ad63e9ab54f7cb90f46a7362be02d80e37f0123d095a54355ea202f5d", + "osv-scanner_linux_amd64": "15314940c10d26af9c6649f150b8a47c1262e8fc7e17b1d1029b0e479e8ed8a0", + "osv-scanner_linux_arm64": "44e580752910f0ff36ec99aff59af20f65df1e859aa31e5605a8f0d055b496e9", + } + + +def _pin_fake_osv_download(monkeypatch: pytest.MonkeyPatch, payload: bytes) -> None: + asset = "osv-scanner_darwin_arm64" + monkeypatch.setattr(osv_scanner_tool.platform, "system", lambda: "Darwin") + monkeypatch.setattr(osv_scanner_tool.platform, "machine", lambda: "arm64") + monkeypatch.setattr( + osv_scanner_tool, + "OSV_SCANNER_SHA256", + {"2.4.0": {asset: osv_scanner_tool.sha256(payload).hexdigest()}}, + ) + + +def test_osv_scanner_valid_cache_hit_rehashes_without_network( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + payload = b"reviewed-scanner" + _pin_fake_osv_download(monkeypatch, payload) + binary = osv_scanner_tool.osv_scanner_binary_path(tmp_path) + binary.parent.mkdir(parents=True) + binary.write_bytes(payload) + binary.chmod(0o755) + monkeypatch.setattr( + osv_scanner_tool, + "download_bytes", + lambda _url, **_kwargs: pytest.fail("network used for valid cache"), ) - assert osv_scanner_tool._expected_checksum(sha256sums, "osv-scanner_darwin_arm64") == "bbbb2222" - assert osv_scanner_tool._expected_checksum(sha256sums, "osv-scanner_missing") is None + assert osv_scanner_tool.ensure_osv_scanner(tmp_path) == binary -def test_osv_scanner_advisory_exit_codes_are_clean_and_vulns_only() -> None: - # 0 (no vulns) and 1 (vulns found) are advisory-success; scanner/setup - # error codes (127 general error, 128 no packages found) are not. - assert 0 in osv_scanner_tool.OSV_ADVISORY_EXIT_CODES - assert 1 in osv_scanner_tool.OSV_ADVISORY_EXIT_CODES - assert 127 not in osv_scanner_tool.OSV_ADVISORY_EXIT_CODES - assert 128 not in osv_scanner_tool.OSV_ADVISORY_EXIT_CODES +@pytest.mark.parametrize("cache_kind", ["tampered", "non-executable", "symlink"]) +def test_osv_scanner_invalid_file_cache_is_reacquired_atomically( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + cache_kind: str, +) -> None: + payload = b"reviewed-scanner" + _pin_fake_osv_download(monkeypatch, payload) + binary = osv_scanner_tool.osv_scanner_binary_path(tmp_path) + binary.parent.mkdir(parents=True) + outside = tmp_path / "outside" + outside.write_bytes(b"must-remain") + if cache_kind == "symlink": + binary.symlink_to(outside) + else: + binary.write_bytes(payload if cache_kind == "non-executable" else b"tampered") + binary.chmod(0o644 if cache_kind == "non-executable" else 0o755) + monkeypatch.setattr(osv_scanner_tool, "download_bytes", lambda _url, **_kwargs: payload) + + installed = osv_scanner_tool.ensure_osv_scanner(tmp_path) + + assert installed == binary + assert installed.read_bytes() == payload + assert installed.stat().st_mode & 0o100 + assert outside.read_bytes() == b"must-remain" + + +def test_osv_scanner_unsafe_cache_shapes_fail_closed(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + payload = b"reviewed-scanner" + _pin_fake_osv_download(monkeypatch, payload) + binary = osv_scanner_tool.osv_scanner_binary_path(tmp_path) + binary.mkdir(parents=True) + + with pytest.raises(RuntimeError, match="not a regular file"): + osv_scanner_tool.ensure_osv_scanner(tmp_path) + + shutil.rmtree(tmp_path / ".cache") + outside = tmp_path / "outside-cache" + outside.mkdir() + (tmp_path / ".cache").symlink_to(outside, target_is_directory=True) + with pytest.raises(RuntimeError, match="unsafe osv-scanner cache directory"): + osv_scanner_tool.ensure_osv_scanner(tmp_path) + + with pytest.raises(RuntimeError, match="cache path escapes"): + osv_scanner_tool._safe_cache_parent(tmp_path, tmp_path.parent / "outside" / "osv-scanner") + + +def test_osv_scanner_cache_read_error_is_sanitized(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + binary = tmp_path / "osv-scanner" + binary.write_bytes(b"scanner") + binary.chmod(0o755) + monkeypatch.setattr(osv_scanner_tool, "_sha256_path", lambda _path: (_ for _ in ()).throw(OSError("secret"))) + + with pytest.raises(RuntimeError, match="failed to validate cached osv-scanner") as raised: + osv_scanner_tool._validated_cache_hit(binary, "0" * 64) + assert "secret" not in str(raised.value) + + +def test_osv_scanner_cache_final_identity_read_fails_closed( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + payload = b"scanner" + binary = tmp_path / "osv-scanner" + binary.write_bytes(payload) + binary.chmod(0o755) + expected = osv_scanner_tool.sha256(payload).hexdigest() + monkeypatch.setattr(osv_scanner_tool, "_sha256_path", lambda _path: expected) + real_lstat = Path.lstat + calls = 0 + + def fail_second_lstat(path: Path) -> os.stat_result: + nonlocal calls + if path == binary: + calls += 1 + if calls == 2: + raise OSError("changed") + return real_lstat(path) + + monkeypatch.setattr(Path, "lstat", fail_second_lstat) + + with pytest.raises(RuntimeError, match="failed to validate cached osv-scanner"): + osv_scanner_tool._validated_cache_hit(binary, expected) + + +def test_osv_scanner_cache_hash_rejects_last_component_swap( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + payload = b"reviewed-scanner" + binary = tmp_path / "osv-scanner" + outside = tmp_path / "outside-scanner" + binary.write_bytes(payload) + binary.chmod(0o755) + outside.write_bytes(payload) + real_open = osv_scanner_tool.os.open + + def swap_before_open(path: object, flags: int, *args: object) -> int: + if Path(path) == binary: + binary.unlink() + binary.symlink_to(outside) + return real_open(path, flags, *args) + + monkeypatch.setattr(osv_scanner_tool.os, "open", swap_before_open) + expected = osv_scanner_tool.sha256(payload).hexdigest() + + with pytest.raises(RuntimeError, match="failed to validate cached osv-scanner"): + osv_scanner_tool._validated_cache_hit(binary, expected) + assert outside.read_bytes() == payload + + +def test_osv_scanner_cache_hash_rejects_unbounded_or_changed_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + binary = tmp_path / "osv-scanner" + binary.write_bytes(b"abc") + monkeypatch.setattr(osv_scanner_tool, "_MAX_BINARY_BYTES", 2) + with pytest.raises(OSError, match="bounded regular file"): + osv_scanner_tool._sha256_path(binary) + + monkeypatch.setattr(osv_scanner_tool, "_MAX_BINARY_BYTES", 3) + real_open = osv_scanner_tool.os.open + + def grow_before_open(path: object, flags: int, *args: object) -> int: + if Path(path) == binary: + binary.write_bytes(b"abcd") + return real_open(path, flags, *args) + + monkeypatch.setattr(osv_scanner_tool.os, "open", grow_before_open) + with pytest.raises(OSError, match="exceeds the size bound"): + osv_scanner_tool._sha256_path(binary) + + +def test_osv_scanner_cache_hash_rejects_open_and_post_hash_identity_changes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + binary = tmp_path / "osv-scanner" + other = tmp_path / "other" + binary.write_bytes(b"scanner") + other.write_bytes(b"other") + real_fstat = osv_scanner_tool.os.fstat + monkeypatch.setattr(osv_scanner_tool.os, "fstat", lambda _descriptor: other.stat()) + with pytest.raises(OSError, match="changed while it was opened"): + osv_scanner_tool._sha256_path(binary) + + monkeypatch.setattr(osv_scanner_tool.os, "fstat", real_fstat) + real_samestat = osv_scanner_tool.os.path.samestat + calls = 0 + + def identity_changes(left: os.stat_result, right: os.stat_result) -> bool: + nonlocal calls + calls += 1 + return real_samestat(left, right) if calls == 1 else False + + monkeypatch.setattr(osv_scanner_tool.os.path, "samestat", identity_changes) + with pytest.raises(OSError, match="changed while it was hashed"): + osv_scanner_tool._sha256_path(binary) + + +def test_osv_scanner_download_has_a_finite_timeout(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + payload = b"reviewed-scanner" + _pin_fake_osv_download(monkeypatch, payload) + observed: dict[str, object] = {} + + def download(url: str, **kwargs: object) -> bytes: + observed.update(url=url, **kwargs) + return payload + + monkeypatch.setattr(osv_scanner_tool, "download_bytes", download) + + assert osv_scanner_tool.ensure_osv_scanner(tmp_path).read_bytes() == payload + assert observed == { + "url": "https://github.com/google/osv-scanner/releases/download/v2.4.0/osv-scanner_darwin_arm64", + "description": "osv-scanner", + "timeout_seconds": 60, + "max_bytes": 256 * 1024 * 1024, + } + + +def test_osv_scanner_download_rejects_an_untrusted_release_url( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + payload = b"reviewed-scanner" + _pin_fake_osv_download(monkeypatch, payload) + monkeypatch.setattr(osv_scanner_tool, "_release_base_url", lambda _version: "file:///tmp") + monkeypatch.setattr( + osv_scanner_tool, + "download_bytes", + lambda _url, **_kwargs: pytest.fail("unsafe URL reached the network client"), + ) + + with pytest.raises(RuntimeError, match="unsafe osv-scanner release URL"): + osv_scanner_tool.ensure_osv_scanner(tmp_path) + + +def test_osv_scanner_download_timeout_fails_closed(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + payload = b"reviewed-scanner" + _pin_fake_osv_download(monkeypatch, payload) + + def timeout(_url: str, **kwargs: object) -> bytes: + assert kwargs["timeout_seconds"] == 60 + raise RuntimeError("failed to download osv-scanner after 5 attempts") + + monkeypatch.setattr(osv_scanner_tool, "download_bytes", timeout) + + with pytest.raises(RuntimeError, match="failed to download osv-scanner"): + osv_scanner_tool.ensure_osv_scanner(tmp_path) + + +def test_osv_scanner_unpinned_version_and_download_mismatch_fail_closed( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + payload = b"reviewed-scanner" + _pin_fake_osv_download(monkeypatch, payload) + with pytest.raises(RuntimeError, match="no repository-pinned checksum"): + osv_scanner_tool.ensure_osv_scanner(tmp_path, version="9.9.9") + + monkeypatch.setattr(osv_scanner_tool, "download_bytes", lambda _url, **_kwargs: b"different") + with pytest.raises(RuntimeError, match="checksum mismatch"): + osv_scanner_tool.ensure_osv_scanner(tmp_path) + assert not osv_scanner_tool.osv_scanner_binary_path(tmp_path).exists() + + +def test_osv_scanner_oversized_download_is_rejected(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + payload = b"four" + _pin_fake_osv_download(monkeypatch, payload) + monkeypatch.setattr(osv_scanner_tool, "_MAX_BINARY_BYTES", 3) + monkeypatch.setattr(osv_scanner_tool, "download_bytes", lambda _url, **_kwargs: payload) + + with pytest.raises(RuntimeError, match="exceeds the download limit"): + osv_scanner_tool.ensure_osv_scanner(tmp_path) + + +def test_osv_scanner_concurrent_acquisition_publishes_only_complete_bytes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + payload = b"reviewed-scanner" + _pin_fake_osv_download(monkeypatch, payload) + barrier = threading.Barrier(2, timeout=3) + + def concurrent_download(_url: str, **_kwargs: object) -> bytes: + barrier.wait() + return payload + + monkeypatch.setattr(osv_scanner_tool, "download_bytes", concurrent_download) + results: list[Path] = [] + failures: list[BaseException] = [] + + def acquire() -> None: + try: + results.append(osv_scanner_tool.ensure_osv_scanner(tmp_path)) + except BaseException as exc: # noqa: BLE001 - preserve worker failure for the main assertion + failures.append(exc) + + workers = [threading.Thread(target=acquire) for _ in range(2)] + for worker in workers: + worker.start() + for worker in workers: + worker.join(timeout=5) + + assert failures == [] + assert len(results) == 2 + assert results[0] == results[1] + assert results[0].read_bytes() == payload + assert list(results[0].parent.glob(".*.download")) == [] + + +def test_osv_scanner_cache_parent_tolerates_directory_creation_race( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + binary = osv_scanner_tool.osv_scanner_binary_path(tmp_path) + real_mkdir = Path.mkdir + raced = False + + def create_then_report_race(path: Path, *args: object, **kwargs: object) -> None: + nonlocal raced + if not raced and path == tmp_path / ".cache": + raced = True + real_mkdir(path, *args, **kwargs) + raise FileExistsError(path) + real_mkdir(path, *args, **kwargs) + + monkeypatch.setattr(Path, "mkdir", create_then_report_race) + + parent = osv_scanner_tool._safe_cache_parent(tmp_path, binary) + + assert raced is True + assert parent == binary.parent + assert parent.is_dir() + + +@pytest.mark.parametrize( + ("exit_code", "expected"), + [ + (0, osv_scanner_tool.OSVScanOutcome.CLEAN), + (1, osv_scanner_tool.OSVScanOutcome.FINDINGS), + (2, osv_scanner_tool.OSVScanOutcome.SCANNER_ERROR), + (127, osv_scanner_tool.OSVScanOutcome.SCANNER_ERROR), + (128, osv_scanner_tool.OSVScanOutcome.SCANNER_ERROR), + (-9, osv_scanner_tool.OSVScanOutcome.SCANNER_ERROR), + ], +) +def test_osv_scanner_exit_codes_distinguish_findings_from_scanner_errors( + exit_code: int, expected: osv_scanner_tool.OSVScanOutcome +) -> None: + assert osv_scanner_tool.classify_osv_exit_code(exit_code) is expected def _fake_osv_binary(tmp_path: Path, *, exit_code: int, payload: str = '{"results": []}') -> Path: @@ -2358,6 +2694,95 @@ def test_run_osv_scanner_captures_stdout_and_returns_exit_code(tmp_path: Path) - assert report.read_text() == '{"results": [1]}' +def _run_nox_osv_scan( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + *, + exit_code: int, +) -> None: + noxfile = load_noxfile_with_fake_nox(monkeypatch) + + class FakeSession: + def log(self, _message: str) -> None: + pass + + lockfile = tmp_path / "implementations" / "python" / "uv.lock" + lockfile.parent.mkdir(parents=True) + lockfile.write_text("", encoding="utf-8") + report = lockfile.with_name("osv-scanner-report.json") + scanner_binary = tmp_path / "osv-scanner" + monkeypatch.setattr(noxfile, "REPO_ROOT", tmp_path) + monkeypatch.setattr(noxfile, "OSV_LOCKFILE_PATH", lockfile) + monkeypatch.setattr(noxfile, "OSV_REPORT_PATH", report) + monkeypatch.setattr(noxfile, "ensure_osv_scanner", lambda _repo_root: scanner_binary) + + def fake_run_osv_scanner(actual_lockfile: Path, actual_report: Path, *, binary: Path) -> int: + assert actual_lockfile == lockfile + assert actual_report == report + assert binary == scanner_binary + return exit_code + + monkeypatch.setattr(noxfile, "run_osv_scanner", fake_run_osv_scanner) + noxfile.osv_scan(FakeSession()) + + +def test_nox_osv_scan_accepts_only_a_clean_result( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _run_nox_osv_scan(monkeypatch, tmp_path, exit_code=0) + + +def test_nox_osv_scan_gates_vulnerability_findings( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + with pytest.raises(RuntimeError, match=r"reported vulnerabilities \(exit code 1\)"): + _run_nox_osv_scan(monkeypatch, tmp_path, exit_code=1) + + +def test_nox_osv_scan_surfaces_scanner_errors_distinctly( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + with pytest.raises(RuntimeError, match="scanner/setup error exit code 127"): + _run_nox_osv_scan(monkeypatch, tmp_path, exit_code=127) + + +def test_security_dependency_floors_and_frozen_lock_exclude_vulnerable_releases() -> None: + project = tomllib.loads((REPO_ROOT / "implementations/python/pyproject.toml").read_text(encoding="utf-8")) + direct_requirements: dict[str, Requirement] = {} + for value in project["project"]["dependencies"]: + requirement = Requirement(value) + direct_requirements[requirement.name.lower()] = requirement + locked = tomllib.loads((REPO_ROOT / "implementations/python/uv.lock").read_text(encoding="utf-8")) + locked_versions = { + package["name"].lower(): Version(package["version"]) for package in locked["package"] if "version" in package + } + + for name, last_vulnerable, first_fixed in ( + ("click", Version("8.3.2"), Version("8.3.3")), + ("cryptography", Version("49.0.0"), Version("50.0.0")), + ): + requirement = direct_requirements[name] + assert Version("0") not in requirement.specifier + assert last_vulnerable not in requirement.specifier + assert first_fixed in requirement.specifier + assert locked_versions[name] >= first_fixed + + +def test_supply_chain_ci_scan_is_gating_and_preserves_failure_report() -> None: + workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")) + job = workflow["jobs"]["supply-chain"] + assert "continue-on-error" not in job + assert all("continue-on-error" not in step for step in job["steps"]) + + scan_step = next(step for step in job["steps"] if step.get("name") == "Run OSV-scanner (gating)") + assert "nox -f noxfile.py -s osv_scan" in scan_step["run"] + upload_step = next(step for step in job["steps"] if step.get("name") == "Upload OSV-scanner report") + assert upload_step["if"] == "always()" + + def test_extra_published_schema_paths_detects_stale_generated_files(tmp_path: Path) -> None: schemas_root = tmp_path / "contracts" / "schemas" write_text(schemas_root / "backend-manifest" / "backend-manifest-v2.json", "{}\n") diff --git a/implementations/python/uv.lock b/implementations/python/uv.lock index 294815ff..34e92002 100644 --- a/implementations/python/uv.lock +++ b/implementations/python/uv.lock @@ -351,14 +351,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -476,58 +476,58 @@ toml = [ [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, - { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, - { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, - { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, ] [[package]] @@ -1241,6 +1241,7 @@ source = { editable = "." } dependencies = [ { name = "asyncssh" }, { name = "blake3" }, + { name = "click" }, { name = "cryptography" }, { name = "defusedxml" }, { name = "fastapi" }, @@ -1281,8 +1282,9 @@ docs = [ requires-dist = [ { name = "asyncssh", specifier = ">=2.23.0" }, { name = "blake3", specifier = ">=1.0.8,<2" }, + { name = "click", specifier = ">=8.3.3" }, { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "cryptography", specifier = ">=46.0.7" }, + { name = "cryptography", specifier = ">=50.0.0" }, { name = "defusedxml", specifier = ">=0.7.1" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "furo", marker = "extra == 'docs'", specifier = ">=2024.5.6" }, diff --git a/noxfile.py b/noxfile.py index 2b213135..b529b538 100644 --- a/noxfile.py +++ b/noxfile.py @@ -19,7 +19,8 @@ from tools.gitleaks_tool import ensure_gitleaks from tools.osv_scanner_tool import ( - OSV_ADVISORY_EXIT_CODES, + OSVScanOutcome, + classify_osv_exit_code, ensure_osv_scanner, run_osv_scanner, ) @@ -904,7 +905,7 @@ def _run_docker_integration_tests(session: nox.Session, reporter: SessionReporte ) -def _run_osv_scan(session: nox.Session, reporter: SessionReporter, *, gating: bool = False) -> None: +def _run_osv_scan(_session: nox.Session, reporter: SessionReporter) -> None: def _scan() -> None: lockfile = OSV_LOCKFILE_PATH if not lockfile.exists(): @@ -912,13 +913,13 @@ def _scan() -> None: binary = ensure_osv_scanner(REPO_ROOT) exit_code = run_osv_scanner(lockfile, OSV_REPORT_PATH, binary=binary) report_rel = OSV_REPORT_PATH.relative_to(REPO_ROOT) - if exit_code not in OSV_ADVISORY_EXIT_CODES: - raise RuntimeError(f"osv-scanner failed with exit code {exit_code}; report at {report_rel}") - if exit_code == 1: - message = f"osv-scanner reported vulnerabilities; see {report_rel}" - if gating: - raise RuntimeError(message) - session.warn(message) + outcome = classify_osv_exit_code(exit_code) + if outcome is OSVScanOutcome.FINDINGS: + raise RuntimeError(f"osv-scanner reported vulnerabilities (exit code {exit_code}); see {report_rel}") + if outcome is OSVScanOutcome.SCANNER_ERROR: + raise RuntimeError( + f"osv-scanner failed with scanner/setup error exit code {exit_code}; report at {report_rel}" + ) reporter.run( "osv-scan / uv.lock", @@ -1170,19 +1171,16 @@ def docs_links(session: nox.Session) -> None: @nox.session(name="osv_scan") def osv_scan(session: nox.Session) -> None: - """Advisory OSV-Scanner sweep over the Python dependency lockfile (issue #34). - - Intentionally NOT wired into `verify` / `hook-pre-push`: findings are - advisory, so this runs as a standalone, non-gating CI job that publishes a - JSON report artifact. Genuine scanner/setup failures (missing lockfile, - error exit codes) still fail the session so they are never hidden. Pass - `-- --gating` to fail on discovered vulnerabilities once branch protection - promotes it from advisory to blocking. + """Required OSV-Scanner sweep over the Python dependency lockfile (#1098). + + This remains outside `verify` / `hook-pre-push` because acquiring and running + OSV-Scanner requires network access. The standalone CI job is gating: both + findings and scanner/setup errors fail, with distinct diagnostics, while CI + still publishes the JSON report artifact. """ reporter = SessionReporter(session, "osv_scan") - gating = "--gating" in session.posargs try: - _run_osv_scan(session, reporter, gating=gating) + _run_osv_scan(session, reporter) finally: reporter.summary() diff --git a/tools/http_download.py b/tools/http_download.py index 6799aaf2..d9adcaf3 100644 --- a/tools/http_download.py +++ b/tools/http_download.py @@ -18,7 +18,7 @@ def __enter__(self) -> _Response: ... def __exit__(self, *args: object) -> None: ... - def read(self) -> bytes: ... + def read(self, size: int = -1) -> bytes: ... def _open_https(url: str, *, timeout: float) -> _Response: @@ -29,26 +29,48 @@ def _open_https(url: str, *, timeout: float) -> _Response: return build_opener().open(request, timeout=timeout) +def _validate_download_options(*, attempts: int, max_bytes: int | None) -> None: + if attempts < 1: + raise ValueError("download attempts must be positive") + if max_bytes is not None and max_bytes < 0: + raise ValueError("download size bound must be non-negative") + + +def _read_response( + response: _Response, + *, + max_bytes: int | None, + description: str, + url: str, +) -> bytes: + if max_bytes is None: + return response.read() + payload = response.read(max_bytes + 1) + if len(payload) > max_bytes: + raise RuntimeError(f"{description} from {url} exceeds the download limit") + return payload + + def download_bytes( url: str, *, description: str, attempts: int = 5, timeout_seconds: float = 60, + max_bytes: int | None = None, _opener: Callable[..., _Response] | None = None, _sleeper: Callable[[float], None] | None = None, ) -> bytes: """Download bytes with bounded retries for transient transport failures.""" - if attempts < 1: - raise ValueError("download attempts must be positive") + _validate_download_options(attempts=attempts, max_bytes=max_bytes) opener = _opener or _open_https sleeper = _sleeper or time.sleep last_error: BaseException | None = None for attempt in range(attempts): try: with opener(url, timeout=timeout_seconds) as response: - return response.read() + return _read_response(response, max_bytes=max_bytes, description=description, url=url) except HTTPError as exc: last_error = exc if exc.code not in _RETRYABLE_HTTP_STATUS: diff --git a/tools/osv_scanner_tool.py b/tools/osv_scanner_tool.py index f3cdce4e..1373f04d 100644 --- a/tools/osv_scanner_tool.py +++ b/tools/osv_scanner_tool.py @@ -1,9 +1,11 @@ from __future__ import annotations +import os import platform -import shutil import stat import subprocess +import tempfile +from enum import StrEnum from hashlib import sha256 from pathlib import Path @@ -12,13 +14,38 @@ REPO_ROOT = Path(__file__).resolve().parents[1] -# Exit codes osv-scanner uses for a scan that ran successfully: -# 0 -> packages found, no vulnerabilities/findings -# 1 -> packages found, vulnerabilities/findings present (advisory here) -# Any other code (e.g. 127 general error, 128 no packages found) indicates a -# scanner or setup failure that must be surfaced, not silently swallowed. +# Exit codes osv-scanner uses for scan results. Any other code (for example, +# 127 for a general error or 128 when no packages were found) is a scanner or +# setup failure, not a vulnerability result. # See https://google.github.io/osv-scanner/output/#return-codes -OSV_ADVISORY_EXIT_CODES = frozenset({0, 1}) +OSV_CLEAN_EXIT_CODE = 0 +OSV_FINDINGS_EXIT_CODE = 1 +_MAX_BINARY_BYTES = 256 * 1024 * 1024 +_DOWNLOAD_TIMEOUT_SECONDS = 60 + +OSV_SCANNER_SHA256 = { + "2.4.0": { + "osv-scanner_darwin_amd64": "088119325156321c34c456ac3703d6013538fd71cbac82b891ab34db491e4d66", + "osv-scanner_darwin_arm64": "9ca3185ad63e9ab54f7cb90f46a7362be02d80e37f0123d095a54355ea202f5d", + "osv-scanner_linux_amd64": "15314940c10d26af9c6649f150b8a47c1262e8fc7e17b1d1029b0e479e8ed8a0", + "osv-scanner_linux_arm64": "44e580752910f0ff36ec99aff59af20f65df1e859aa31e5605a8f0d055b496e9", + } +} + + +class OSVScanOutcome(StrEnum): + CLEAN = "clean" + FINDINGS = "findings" + SCANNER_ERROR = "scanner-error" + + +def classify_osv_exit_code(exit_code: int) -> OSVScanOutcome: + """Classify a scanner result without conflating findings with tool errors.""" + if exit_code == OSV_CLEAN_EXIT_CODE: + return OSVScanOutcome.CLEAN + if exit_code == OSV_FINDINGS_EXIT_CODE: + return OSVScanOutcome.FINDINGS + return OSVScanOutcome.SCANNER_ERROR def _release_base_url(version: str = OSV_SCANNER_VERSION) -> str: @@ -49,40 +76,130 @@ def _release_asset_name(version: str = OSV_SCANNER_VERSION) -> str: return f"osv-scanner_{platform_name}_{arch}" -def _checksums_asset_name(_version: str = OSV_SCANNER_VERSION) -> str: - return "osv-scanner_SHA256SUMS" - - def osv_scanner_binary_path(repo_root: Path = REPO_ROOT, *, version: str = OSV_SCANNER_VERSION) -> Path: return repo_root / ".cache" / "raes-sdl" / "tooling" / "osv-scanner" / version / "osv-scanner" -def _expected_checksum(checksums_text: str, asset_name: str) -> str | None: - for line in checksums_text.splitlines(): - checksum, _, name = line.partition(" ") - if name.strip() == asset_name: - return checksum.strip() - return None +def _sha256_path(path: Path) -> str: + """Hash one bounded regular file without following its final component.""" + + before = path.lstat() + if not stat.S_ISREG(before.st_mode) or before.st_size > _MAX_BINARY_BYTES: + raise OSError("cached scanner is not a bounded regular file") + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) + flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOINHERIT", 0) + descriptor = os.open(path, flags) + digest = sha256() + total = 0 + with os.fdopen(descriptor, "rb") as stream: + opened = os.fstat(stream.fileno()) + if not stat.S_ISREG(opened.st_mode) or not os.path.samestat(before, opened): + raise OSError("cached scanner changed while it was opened") + while chunk := stream.read(1024 * 1024): + total += len(chunk) + if total > _MAX_BINARY_BYTES: + raise OSError("cached scanner exceeds the size bound") + digest.update(chunk) + after = path.lstat() + if total != opened.st_size or not stat.S_ISREG(after.st_mode) or not os.path.samestat(opened, after): + raise OSError("cached scanner changed while it was hashed") + return digest.hexdigest() + + +def _safe_cache_parent(repo_root: Path, binary_path: Path) -> Path: + """Create the fixed cache chain without following repository-local symlinks.""" + + root = repo_root.resolve() + try: + parts = binary_path.parent.relative_to(repo_root).parts + except ValueError as exc: + raise RuntimeError("osv-scanner cache path escapes the repository") from exc + current = root + for part in parts: + current /= part + try: + mode = current.lstat().st_mode + except FileNotFoundError: + try: + current.mkdir() + continue + except FileExistsError: + mode = current.lstat().st_mode + if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode): + raise RuntimeError(f"unsafe osv-scanner cache directory: {current}") + return current + + +def _validated_cache_hit(binary_path: Path, expected_checksum: str) -> bool: + try: + mode = binary_path.lstat().st_mode + except FileNotFoundError: + return False + if stat.S_ISLNK(mode): + binary_path.unlink() + return False + if not stat.S_ISREG(mode): + raise RuntimeError(f"unsafe osv-scanner cache entry: {binary_path} is not a regular file") + try: + actual_checksum = _sha256_path(binary_path) + except OSError as exc: + raise RuntimeError(f"failed to validate cached osv-scanner at {binary_path}") from exc + try: + final_mode = binary_path.lstat().st_mode + except OSError as exc: + raise RuntimeError(f"failed to validate cached osv-scanner at {binary_path}") from exc + valid = stat.S_ISREG(final_mode) and actual_checksum == expected_checksum and bool(final_mode & stat.S_IXUSR) + if not valid: + binary_path.unlink() + return valid + + +def _install_binary(binary_path: Path, binary_bytes: bytes) -> None: + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{binary_path.name}.", + suffix=".download", + dir=binary_path.parent, + ) + temporary_path = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(binary_bytes) + stream.flush() + os.fsync(stream.fileno()) + temporary_path.chmod(0o755) + os.replace(temporary_path, binary_path) + directory_descriptor = os.open(binary_path.parent, os.O_RDONLY) + try: + os.fsync(directory_descriptor) + finally: + os.close(directory_descriptor) + finally: + temporary_path.unlink(missing_ok=True) def ensure_osv_scanner(repo_root: Path = REPO_ROOT, *, version: str = OSV_SCANNER_VERSION) -> Path: - binary_path = osv_scanner_binary_path(repo_root, version=version) - if binary_path.exists(): + asset_name = _release_asset_name(version) + expected_checksum = OSV_SCANNER_SHA256.get(version, {}).get(asset_name) + if expected_checksum is None: + raise RuntimeError(f"no repository-pinned checksum for osv-scanner asset {asset_name}") + requested_path = osv_scanner_binary_path(repo_root, version=version) + binary_path = _safe_cache_parent(repo_root, requested_path) / requested_path.name + if _validated_cache_hit(binary_path, expected_checksum): return binary_path - binary_path.parent.mkdir(parents=True, exist_ok=True) - asset_name = _release_asset_name(version) base_url = _release_base_url(version) asset_url = f"{base_url}/{asset_name}" - checksums_url = f"{base_url}/{_checksums_asset_name(version)}" - - checksums_text = download_bytes(checksums_url, description="osv-scanner checksums").decode("utf-8") - - expected_checksum = _expected_checksum(checksums_text, asset_name) - if not expected_checksum: - raise RuntimeError(f"missing checksum for osv-scanner asset {asset_name}") - - binary_bytes = download_bytes(asset_url, description="osv-scanner") + if not asset_url.startswith("https://github.com/google/osv-scanner/releases/download/"): + raise RuntimeError(f"unsafe osv-scanner release URL: {asset_url}") + + binary_bytes = download_bytes( + asset_url, + description="osv-scanner", + timeout_seconds=_DOWNLOAD_TIMEOUT_SECONDS, + max_bytes=_MAX_BINARY_BYTES, + ) + if len(binary_bytes) > _MAX_BINARY_BYTES: + raise RuntimeError(f"osv-scanner asset {asset_name} exceeds the download limit") actual_checksum = sha256(binary_bytes).hexdigest() if actual_checksum != expected_checksum: @@ -90,11 +207,7 @@ def ensure_osv_scanner(repo_root: Path = REPO_ROOT, *, version: str = OSV_SCANNE f"osv-scanner checksum mismatch for {asset_name}: expected {expected_checksum}, got {actual_checksum}" ) - # Atomic-ish install: write to a sibling temp path, chmod, then move into place. - tmp_path = binary_path.with_suffix(".download") - tmp_path.write_bytes(binary_bytes) - tmp_path.chmod(tmp_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - shutil.move(tmp_path, binary_path) + _install_binary(binary_path, binary_bytes) return binary_path @@ -104,8 +217,8 @@ def run_osv_scanner(lockfile: Path, report_path: Path, *, binary: Path) -> int: OSV-Scanner writes the machine-readable report to stdout under ``--format json`` and progress/logging to stderr, so redirecting stdout to - ``report_path`` captures a clean JSON document. The caller decides whether a - given exit code is advisory (see ``OSV_ADVISORY_EXIT_CODES``) or fatal. + ``report_path`` captures a clean JSON document. The caller classifies the + result with :func:`classify_osv_exit_code` and applies repository policy. """ report_path.parent.mkdir(parents=True, exist_ok=True) with report_path.open("wb") as report_file: