From 9dcf91a7610ab8ea7ffd19fe55d0f7c1e6c3ebea Mon Sep 17 00:00:00 2001 From: Joshua Date: Tue, 6 Oct 2026 11:34:58 -0500 Subject: [PATCH 1/2] ci: enforce bounded no-fix advisory exceptions --- .github/advisory-exceptions.json | 4 + .github/dependabot.yml | 16 +- .github/osv-scanner-empty.toml | 3 + .github/security-scanner/Dockerfile | 7 + .github/workflows/auto-fix.yml | 68 +++- .github/workflows/security-scan.yml | 46 ++- .github/workflows/validate.yml | 16 +- .pre-commit-config.yaml | 10 + CONTRIBUTING.md | 57 +++- SECURITY.md | 9 + pyproject.toml | 2 +- scripts/__init__.py | 1 + scripts/advisory_policy.py | 435 ++++++++++++++++++++++++ scripts/audit-dependencies.sh | 66 ++++ scripts/uv_advisory_policy.py | 171 ++++++++++ tests/test_advisory_policy.py | 362 ++++++++++++++++++++ tests/test_autofix_policy_guard.py | 158 +++++++++ tests/test_dependency_audit_workflow.py | 190 +++++++++++ tests/test_uv_advisory_policy.py | 260 ++++++++++++++ 19 files changed, 1862 insertions(+), 19 deletions(-) create mode 100644 .github/advisory-exceptions.json create mode 100644 .github/osv-scanner-empty.toml create mode 100644 .github/security-scanner/Dockerfile create mode 100644 scripts/__init__.py create mode 100644 scripts/advisory_policy.py create mode 100644 scripts/audit-dependencies.sh create mode 100644 scripts/uv_advisory_policy.py create mode 100644 tests/test_advisory_policy.py create mode 100644 tests/test_autofix_policy_guard.py create mode 100644 tests/test_dependency_audit_workflow.py create mode 100644 tests/test_uv_advisory_policy.py diff --git a/.github/advisory-exceptions.json b/.github/advisory-exceptions.json new file mode 100644 index 0000000..987d205 --- /dev/null +++ b/.github/advisory-exceptions.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "exceptions": [] +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d14f05d..bd73da2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,5 @@ --- -# Dependabot configuration: three ecosystems, grouped except for uv-build. +# Dependabot configuration: four ecosystems, grouped except for uv-build. # Auto-merge (dependabot-auto-merge.yml) supplies the ruleset's guarded bot approval # and squash-merges after Validate is green. Human-authored PRs still require human # approval. auto-fix.yml mechanically repairs lint-only failures. @@ -73,3 +73,17 @@ updates: - "dependencies" - "github-actions" open-pull-requests-limit: 10 + + # The native OSV CLI container owns one immutable scanner pin. Keeping it here + # lets the same review, cooldown, and required-check policy maintain the scanner. + - package-ecosystem: "docker" + directory: "/.github/security-scanner" + schedule: + interval: "daily" + cooldown: + default-days: 7 + commit-message: + prefix: "ci" + labels: + - "dependencies" + open-pull-requests-limit: 10 diff --git a/.github/osv-scanner-empty.toml b/.github/osv-scanner-empty.toml new file mode 100644 index 0000000..aa9f545 --- /dev/null +++ b/.github/osv-scanner-empty.toml @@ -0,0 +1,3 @@ +# Raw scans never apply advisory-wide or package-wide suppression. +# Exact temporary risk acceptances live in advisory-exceptions.json and are +# adjudicated against complete raw results by scripts/advisory_policy.py. diff --git a/.github/security-scanner/Dockerfile b/.github/security-scanner/Dockerfile new file mode 100644 index 0000000..f0ec67a --- /dev/null +++ b/.github/security-scanner/Dockerfile @@ -0,0 +1,7 @@ +# Single scanner version owner; Dependabot's docker ecosystem maintains this pin. +FROM ghcr.io/google/osv-scanner:v2.6.0@sha256:afd838850ac1a0fcc15ff4a041dc9ba11123c3f0d2666217a5f0fcf9222b55fa + +# Upstream keeps its entrypoint under /root. Relocate the unchanged binary so the +# scanner can run as the checkout owner's unprivileged UID with a read-only root. +RUN cp /root/osv-scanner /usr/local/bin/osv-scanner && chmod 0755 /usr/local/bin/osv-scanner +ENTRYPOINT ["/usr/local/bin/osv-scanner"] diff --git a/.github/workflows/auto-fix.yml b/.github/workflows/auto-fix.yml index 4b3134c..e84fcd1 100644 --- a/.github/workflows/auto-fix.yml +++ b/.github/workflows/auto-fix.yml @@ -73,10 +73,74 @@ jobs: github-token: ${{ github.token }} path: fixes + - name: Read the trusted PR file tree + if: steps.guard.outputs.skip == 'false' && steps.download.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + REPO: ${{ github.repository }} + run: gh api "repos/$REPO/git/trees/$HEAD_SHA?recursive=1" > trusted-tree.json + + - name: Validate mechanical fix scope + id: scope + if: steps.guard.outputs.skip == 'false' && steps.download.outcome == 'success' + # This code comes from the trusted base workflow. Never load a checker + # from the PR artifact or checkout before minting a write credential. + shell: python + run: | + # BEGIN AUTOFIX SCOPE GUARD + import json + import os + from pathlib import Path + + fixes = json.loads(Path("fixes/fixes.json").read_text(encoding="utf-8")) + tree = json.loads(Path("trusted-tree.json").read_text(encoding="utf-8")) + if tree.get("truncated") is not False: + raise ValueError("Cannot verify a partial PR file tree") + tracked = { + item["path"]: item["mode"] + for item in tree["tree"] + if item["type"] == "blob" + } + protected_files = { + ".github/advisory-exceptions.json", + ".github/osv-scanner-empty.toml", + "pyproject.toml", + } + protected_prefixes = ( + ".github/workflows/", + ".github/security-scanner/", + "scripts/", + ) + if not isinstance(fixes, list): + raise ValueError("Mechanical fixes must be a JSON array") + for entry in fixes: + if not isinstance(entry, dict): + raise ValueError("Malformed mechanical fix entry") + path = entry.get("path") + if ( + not isinstance(path, str) + or "\\" in path + or any(part in {"", ".", ".."} for part in path.split("/")) + or path not in tracked + or entry.get("mode") != tracked[path] + or entry.get("status") != "M" + or entry.get("mode") not in {"100644", "100755"} + or path in protected_files + or path.startswith(protected_prefixes) + ): + raise ValueError( + f"Refusing auto-fix of protected, new, or noncanonical path: {path!r}. " + "A maintainer must review policy and automation changes." + ) + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: + output.write("safe=true\n") + # END AUTOFIX SCOPE GUARD + - name: Mint a GitHub App token id: app-token # Only mint a write-capable token once we actually have fixes to push. - if: steps.guard.outputs.skip == 'false' && steps.download.outcome == 'success' + if: steps.scope.outputs.safe == 'true' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.APP_CLIENT_ID }} @@ -85,7 +149,7 @@ jobs: permission-contents: write - name: Rebuild the fix commit via the Git Data API and update the branch - if: steps.guard.outputs.skip == 'false' && steps.download.outcome == 'success' + if: steps.scope.outputs.safe == 'true' env: # gh api authenticates as the App installation (Contents: write). GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 16cd6c9..131baac 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -47,12 +47,44 @@ jobs: - name: Run the full prek gate run: uv run prek run --all-files --show-diff-on-failure --color always - - name: uv audit (advisory scan of uv.lock) - run: uv audit --preview-features audit + # Run advisory scans in a fresh job: test/pre-commit code in full-gate must not + # be able to mutate the policy, adapters, or source tree used by the scanners. + deps-security: + name: deps-security + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.11.29" + # gate owns the shared cache write; parallel saves race on the same key. + save-cache: false + + - name: Sync the environment + env: + UV_MALWARE_CHECK: "1" + run: uv sync --locked + + - name: Audit dependencies with the bounded no-fix policy + run: bash scripts/audit-dependencies.sh - - name: OSV-Scanner (repo-wide) - uses: google/osv-scanner-action/osv-scanner-action@a345acffa64b0eaede81a3d9aae6141214d9c8fc # v2.6.0 + - name: Preserve raw dependency audit evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - scan-args: |- - --recursive - ./ + name: dependency-audit-${{ github.job }} + path: ${{ runner.temp }}/optimizer-dependency-audit/ + retention-days: 14 + if-no-files-found: warn diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index bf8e252..5d2797a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -83,15 +83,17 @@ jobs: UV_MALWARE_CHECK: "1" run: uv sync --locked - - name: uv audit (advisory scan of uv.lock) - run: uv audit --preview-features audit + - name: Audit dependencies with the bounded no-fix policy + run: bash scripts/audit-dependencies.sh - - name: OSV-Scanner (repo-wide) - uses: google/osv-scanner-action/osv-scanner-action@a345acffa64b0eaede81a3d9aae6141214d9c8fc # v2.6.0 + - name: Preserve raw dependency audit evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - scan-args: |- - --recursive - ./ + name: dependency-audit-${{ github.job }} + path: ${{ runner.temp }}/optimizer-dependency-audit/ + retention-days: 14 + if-no-files-found: warn # Conventional-Commit PR-title lint. The squash merge uses the PR title as the # release-relevant commit message, so the title must be a valid Conventional diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab74eb1..4e26cc1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -140,6 +140,16 @@ repos: pass_filenames: false always_run: true + # Validate the exception contract offline as part of the same local/CI gate. + - repo: local + hooks: + - id: advisory-policy + name: bounded advisory exception policy + language: system + entry: uv run python -m scripts.advisory_policy + pass_filenames: false + always_run: true + # -- Security related hooks -------------------------------------------- - repo: https://github.com/PyCQA/bandit rev: 92ae8b82fb422a639f0ed8d99e96cea769594e08 # frozen: 1.9.4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76c5f86..22f8249 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,7 +55,7 @@ on four automation behaviors: [`CHANGELOG.md`](CHANGELOG.md), tag, and cut a GitHub Release. Publishing to PyPI (OIDC trusted publishing with PEP 740 attestations) is gated behind the `PUBLISH_TO_PYPI` repo variable; while it is off, the release path still builds and validates the artifact. -- **Dependabot** (`.github/dependabot.yml`) opens cooldown-gated PRs daily for three +- **Dependabot** (`.github/dependabot.yml`) opens cooldown-gated PRs daily for four ecosystems: grouped `uv` dev tools, grouped `pre-commit` hook revisions, and grouped `github-actions` pins. `uv-build` stays in its own PR because Dependabot cannot refresh its build-system range reliably inside a group. @@ -68,3 +68,58 @@ on four automation behaviors: The configuration mirrors the local gate exactly, so "green locally" and "green in CI" mean the same thing. For the agent-facing execution contract, see `AGENTS.md`. + +## Temporary dependency risk acceptance + +Both required dependency scanners use the same empty-by-default policy in +[`.github/advisory-exceptions.json`](.github/advisory-exceptions.json). This is an +escape hatch for an explicitly reviewed, short-lived **no-fix** case. Prefer a +canonical dependency or lockfile update whenever a fixed release is available. +Neither Dependabot nor the mechanical auto-fixer may create or extend an acceptance. +The privileged auto-fix consumer prevents direct protected-file writes and verifies paths against the actual PR file tree +before obtaining its write token, and refuses policy, scanner, workflow, packaging, +new-file, symlink, or mode changes. Ordinary existing-file formatting and lockfile +repairs remain eligible. This guard does not prove that arbitrary proposed source +changes are mechanically generated. Both PR and weekly dependency audits run in +a separate fresh job from tests and pre-commit hooks. + +Each entry must identify one exact GHSA advisory, `PyPI` package, and version +already in `uv.lock`, plus an exclusive UTC `expiresAt`, `reason`, `noFixReason`, +and a credential-free HTTPS `upstream` reference. Accepting a vulnerability is a +maintainer risk decision; a green accepted-risk result does not mean it is fixed. +The current policy contains no accepted risks. + +The PR and weekly checks run `scripts/audit-dependencies.sh`: + +1. Run the pinned `uv audit` once without local ignore configuration and with + `--locked`; preserve its JSON, diagnostic output, and numeric exit status +2. Run the official, digest-pinned OSV CLI once against the repository recursively, + with a read-only source mount, an explicitly empty config, and all-package JSON +3. Validate each original report independently against the same exact-scope policy +4. Fail on operational errors, missing/incomplete/malformed results, unknown + findings, unrelated packages or versions, available fixes, stale or expired + entries +5. Report every accepted risk prominently, including its expiry and upstream + reference, and retain raw reports as the `dependency-audit-*` CI artifact + +uv's adverse package statuses retain their native warning behavior and remain +visible outside the vulnerability-exception mechanism. The existing +`UV_MALWARE_CHECK=1` enforcement during environment sync remains unchanged. + +There is no advisory-wide ignore flag, severity floor, second filtered scan, or +blanket `continue-on-error` gate. Unknown scanner-format changes fail closed and +require a tested adapter update. The official OSV CLI digest has a single owner in +[`.github/security-scanner/Dockerfile`](.github/security-scanner/Dockerfile), which +Dependabot maintains under the existing review and required-check policy. + +The offline policy contract runs in the normal `prek` gate. To reproduce both +online scanners locally, install Docker and run: + +```bash +uv sync --locked +bash scripts/audit-dependencies.sh +``` + +Reports are written under `${RUNNER_TEMP:-${TMPDIR:-/tmp}}/optimizer-dependency-audit`. +Use the raw reports to investigate a failure; never change a scanner exit code or +weaken a required check to make an update merge. diff --git a/SECURITY.md b/SECURITY.md index 40cb239..3326fcb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,3 +23,12 @@ the automation itself (workflow token scope, SHA-pinning, OIDC publishing) are i Only the latest released version is supported. Fixes ship as new releases rather than as patches to older versions. + +## Dependency advisory policy + +Required PR and weekly scans use an empty-by-default, exact-scope no-fix policy. +An exception requires explicit maintainer risk acceptance for a named advisory, +locked package/version, rationale, upstream evidence, and a UTC expiry. New, +malformed, unrelated, fixable, or expired findings remain blocking, and raw scanner +results stay visible. See [the contributor guide](CONTRIBUTING.md#temporary-dependency-risk-acceptance) +for the contract and reproduction commands. diff --git a/pyproject.toml b/pyproject.toml index 4ed0d34..e863c4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ addopts = "-q --cov --cov-report=term-missing" [tool.coverage.run] branch = true -source = ["skill_optimizer", "optimize_description_v2"] +source = ["skill_optimizer", "optimize_description_v2", "scripts"] [tool.coverage.report] show_missing = true diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..1624e09 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Repository maintenance policy checks.""" diff --git a/scripts/advisory_policy.py b/scripts/advisory_policy.py new file mode 100644 index 0000000..a0570d4 --- /dev/null +++ b/scripts/advisory_policy.py @@ -0,0 +1,435 @@ +"""Adjudicate one complete, unsuppressed OSV result with bounded risk acceptances. + +The official scanner runs once with empty config and ``--all-packages``. Its raw +exit status and every finding are checked against exact lock scope and no-fix +evidence. This never creates or extends risk acceptance and never rescans with +advisory-wide ignores. Keep the original report alongside adjudicated results. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import tomllib +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import cast +from urllib.parse import urlsplit + +GHSA = re.compile(r"GHSA-[23456789cfghjmpqrvwx]{4}(?:-[23456789cfghjmpqrvwx]{4}){2}") +EXPIRY = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.000)?Z") +POLICY_FIELDS = { + "advisory", + "ecosystem", + "package", + "version", + "expiresAt", + "reason", + "noFixReason", + "upstream", +} + + +class PolicyError(ValueError): + """An unsafe, incomplete, or unsupported advisory policy or report.""" + + +def require(condition: bool, message: str) -> None: + """Reject input that does not establish the required invariant.""" + if not condition: + raise PolicyError(message) + + +def mapping(value: object, label: str) -> dict[str, object]: + """Require a JSON/TOML object with string keys.""" + require(isinstance(value, dict), f"{label} must be an object") + return cast("dict[str, object]", value) + + +def array(value: object, label: str) -> list[object]: + """Require a JSON array.""" + require(isinstance(value, list), f"{label} must be an array") + return cast("list[object]", value) + + +def string(value: object, label: str) -> str: + """Require a nonempty, single-line, stripped string.""" + require(isinstance(value, str), f"{label} must be a string") + result = cast("str", value) + require( + bool(result) + and result == result.strip() + and all(ord(character) >= 32 for character in result), + f"{label} must be nonempty, stripped, and single-line", + ) + return result + + +def unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + """Reject duplicate JSON keys rather than silently selecting the last.""" + result: dict[str, object] = {} + for key, value in pairs: + require(key not in result, f"duplicate JSON key: {key}") + result[key] = value + return result + + +def read_json(path: Path) -> object: + """Read strict JSON, rejecting duplicate keys and nonfinite constants.""" + + def invalid_constant(value: str) -> object: + message = f"invalid JSON constant: {value}" + raise PolicyError(message) + + return cast( + "object", + json.loads( + path.read_text(encoding="utf-8"), + object_pairs_hook=unique_object, + parse_constant=invalid_constant, + ), + ) + + +@dataclass(frozen=True) +class ExceptionEntry: + """An explicit reviewed acceptance of one exact PyPI advisory finding.""" + + advisory: str + package: str + version: str + expires_at: datetime + reason: str + no_fix_reason: str + upstream: str + + +def locked_packages(lock: object) -> set[tuple[str, str]]: + """Return exact registry package identities from the canonical uv lock.""" + packages: set[tuple[str, str]] = set() + for raw in array(mapping(lock, "lock").get("package"), "lock.package"): + item = mapping(raw, "locked package") + source = mapping(item.get("source"), "locked package source") + if "registry" not in source: + continue + identity = ( + string(item.get("name"), "name"), + string(item.get("version"), "version"), + ) + require(identity not in packages, f"duplicate locked package: {identity}") + packages.add(identity) + require(bool(packages), "lock must contain registry packages") + return packages + + +def load_policy( + data: object, locked: set[tuple[str, str]], now: datetime +) -> tuple[ExceptionEntry, ...]: + """Validate the closed policy schema, exact lock scope, and UTC expiry.""" + policy = mapping(data, "policy") + require( + set(policy) == {"version", "exceptions"}, "unknown or missing policy fields" + ) + require( + type(policy["version"]) is int and policy["version"] == 1, + "policy version must be 1", + ) + entries: list[ExceptionEntry] = [] + identities: set[tuple[str, str, str]] = set() + for raw in array(policy["exceptions"], "exceptions"): + item = mapping(raw, "exception") + require(set(item) == POLICY_FIELDS, "unknown or missing exception fields") + values = {key: string(value, key) for key, value in item.items()} + require( + GHSA.fullmatch(values["advisory"]) is not None, + "advisory must be an exact GHSA identifier", + ) + require( + values["ecosystem"] == "PyPI", "only PyPI lockfile exceptions are supported" + ) + require( + (values["package"], values["version"]) in locked, + "exception package/version must match uv.lock exactly", + ) + require( + EXPIRY.fullmatch(values["expiresAt"]) is not None, + "expiresAt must be an explicit UTC timestamp", + ) + expiry = datetime.fromisoformat(values["expiresAt"]) + require(expiry > now, f"expired exception: {values['advisory']}") + upstream = urlsplit(values["upstream"]) + require( + upstream.scheme == "https" + and bool(upstream.hostname) + and not upstream.username + and not upstream.password, + "upstream must be a credential-free HTTPS reference", + ) + identity = (values["advisory"], values["package"], values["version"]) + require(identity not in identities, "duplicate exception") + identities.add(identity) + entries.append( + ExceptionEntry( + values["advisory"], + values["package"], + values["version"], + expiry, + values["reason"], + values["noFixReason"], + values["upstream"], + ) + ) + return tuple(entries) + + +def no_fixed_release(vulnerability: dict[str, object], name: str) -> None: + """Require affected-package evidence and reject every published fixed event.""" + matched = False + for raw in array(vulnerability.get("affected"), "affected"): + affected = mapping(raw, "affected entry") + package = mapping(affected.get("package"), "affected package") + if package.get("ecosystem") != "PyPI" or package.get("name") != name: + continue + matched = True + ranges = array(affected.get("ranges", []), "affected ranges") + versions = array(affected.get("versions", []), "affected versions") + require(bool(ranges or versions), "missing affected range/version evidence") + for version in versions: + string(version, "affected version") + for raw_range in ranges: + interval = mapping(raw_range, "affected range") + require( + interval.get("type") in {"ECOSYSTEM", "SEMVER"}, + "unsupported affected range type", + ) + events = array(interval.get("events"), "range events") + require(bool(events), "empty affected events") + for raw_event in events: + event = mapping(raw_event, "range event") + require( + len(event) == 1 + and set(event) <= {"introduced", "fixed", "last_affected", "limit"}, + "malformed range event", + ) + require( + "fixed" not in event, + "upstream now reports a fixed release; remove the exception and upgrade", + ) + string(next(iter(event.values())), "range event version") + require(matched, "missing affected-package evidence for exception") + + +def validate_report( + report: object, + entries: tuple[ExceptionEntry, ...], + locked: set[tuple[str, str]], + root: Path, + scan_exit: int, +) -> list[str]: + """Validate complete unsuppressed OSV JSON and return visible accepted risks.""" + data = mapping(report, "OSV report") + require( + set(data) + <= { + "results", + "experimental_config", + "experimental_generic_findings", + "image_metadata", + "license_summary", + }, + "unknown OSV report fields", + ) + require( + not data.get("experimental_generic_findings"), + "unsupported generic OSV findings", + ) + results = array(data.get("results"), "OSV results") + require(bool(results), "empty OSV results; --all-packages is required") + observed: set[tuple[str, str]] = set() + used: set[ExceptionEntry] = set() + findings = 0 + for raw_source in results: + source_result = mapping(raw_source, "source result") + require( + set(source_result) <= {"source", "packages", "experimental_pes"}, + "unknown source result fields", + ) + source = mapping(source_result.get("source"), "source") + path = string(source.get("path"), "source path") + is_lock = source.get("type") == "lockfile" and path in { + "uv.lock", + str(root.resolve() / "uv.lock"), + "/github/workspace/uv.lock", + "/src/uv.lock", + } + require( + not source_result.get("experimental_pes"), + "unexpected prefiltered VEX evidence", + ) + for raw_package in array(source_result.get("packages"), "packages"): + package_result = mapping(raw_package, "package result") + require( + set(package_result) + <= { + "package", + "dependency_groups", + "vulnerabilities", + "groups", + "licenses", + "license_violations", + }, + "unknown package result fields", + ) + package = mapping(package_result.get("package"), "package") + vulnerabilities = array( + package_result.get("vulnerabilities", []), "vulnerabilities" + ) + vulnerability_ids = [ + string(mapping(item, "vulnerability").get("id"), "vulnerability id") + for item in vulnerabilities + ] + group_ids: list[str] = [] + for raw_group in array(package_result.get("groups", []), "groups"): + group = mapping(raw_group, "group") + ids = array(group.get("ids"), "group ids") + require(bool(ids), "empty advisory group") + group_ids.extend(string(value, "group id") for value in ids) + require( + len(set(vulnerability_ids)) == len(vulnerability_ids) + and sorted(group_ids) == sorted(vulnerability_ids), + "inconsistent vulnerability groups", + ) + require( + not package_result.get("license_violations"), + "license violations cannot be excepted", + ) + require( + not package.get("deprecated"), "deprecated packages cannot be excepted" + ) + if is_lock and package.get("ecosystem") == "PyPI": + observed.add( + ( + string(package.get("name"), "package name"), + string(package.get("version"), "package version"), + ) + ) + for raw_vulnerability in vulnerabilities: + findings += 1 + vulnerability = mapping(raw_vulnerability, "vulnerability") + identifier = string(vulnerability.get("id"), "vulnerability id") + aliases = { + string(value, "alias") + for value in array(vulnerability.get("aliases", []), "aliases") + } + matching = [ + entry + for entry in entries + if entry.advisory in {identifier, *aliases} + and is_lock + and package.get("ecosystem") == "PyPI" + and package.get("name") == entry.package + and package.get("version") == entry.version + ] + require( + len(matching) == 1, + f"unaccepted vulnerability: {identifier} in {package.get('name')}@{package.get('version')} ({path})", + ) + entry = matching[0] + no_fixed_release(vulnerability, entry.package) + used.add(entry) + require( + locked <= observed, + f"incomplete OSV report; missing locked packages: {sorted(locked - observed)}", + ) + require( + type(scan_exit) is int and scan_exit in {0, 1}, "scanner operational failure" + ) + require(scan_exit == int(findings > 0), "scanner exit/report mismatch") + require( + used == set(entries), + "stale exception: remove entries not matched by the raw scan", + ) + messages = [ + f"ACCEPTED RISK: {entry.advisory} / PyPI {entry.package}@{entry.version}; " + f"expires {entry.expires_at.isoformat()}; {entry.reason}; " + f"no-fix rationale: {entry.no_fix_reason}; upstream: {entry.upstream}" + for entry in entries + ] + if not messages: + messages.append( + "No accepted risks; no known vulnerabilities in the complete OSV report." + ) + return messages + + +def adjudicate( + report: object, + entries: tuple[ExceptionEntry, ...], + locked: set[tuple[str, str]], + root: Path, + scan_exit: int, +) -> tuple[dict[str, object], list[str]]: + """Return reporter input only after every raw finding passes exact validation.""" + messages = validate_report(report, entries, locked, root, scan_exit) + result = mapping(json.loads(json.dumps(report)), "validated report") + for raw_source in array(result["results"], "results"): + for raw_package in array(mapping(raw_source, "source")["packages"], "packages"): + package = mapping(raw_package, "package") + # Validation has proven every removed finding was explicitly accepted. + # This is only the reporter view; the full raw evidence is retained. + package.pop("vulnerabilities", None) + package.pop("groups", None) + return result, messages + + +def main(argv: Sequence[str] | None = None) -> int: + """Validate policy offline, or adjudicate one raw scan for native reporting.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--policy", type=Path, default=Path(".github/advisory-exceptions.json") + ) + parser.add_argument("--lock", type=Path, default=Path("uv.lock")) + parser.add_argument( + "--scanner-config", type=Path, default=Path(".github/osv-scanner-empty.toml") + ) + parser.add_argument("--report", type=Path) + parser.add_argument("--output", type=Path) + parser.add_argument("--summary", type=Path) + parser.add_argument("--scan-exit", type=int) + args = parser.parse_args(argv) + try: + require( + tomllib.loads(args.scanner_config.read_text(encoding="utf-8")) == {}, + "raw scanner configuration must be empty", + ) + locked = locked_packages(tomllib.loads(args.lock.read_text(encoding="utf-8"))) + entries = load_policy(read_json(args.policy), locked, datetime.now(UTC)) + if args.report is None: + require( + args.output is None and args.summary is None and args.scan_exit is None, + "report is required to generate reporter input", + ) + print(f"Advisory policy valid: {len(entries)} explicit exception(s).") + return 0 + require(args.output is not None, "output is required with report") + result, messages = adjudicate( + read_json(args.report), entries, locked, args.lock.parent, args.scan_exit + ) + args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + summary = "\n".join(messages) + "\n" + print(summary, end="") + if args.summary is not None: + with args.summary.open("a", encoding="utf-8") as stream: + stream.write(summary) + except (PolicyError, ValueError, OSError) as error: + print(f"Advisory policy failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/audit-dependencies.sh b/scripts/audit-dependencies.sh new file mode 100644 index 0000000..7ce0f31 --- /dev/null +++ b/scripts/audit-dependencies.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Keep original scanner output and exit status; only the scoped policy adjudicates. +set -euo pipefail + +output_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/optimizer-dependency-audit" +mkdir -p "$output_dir" +summary_args=() +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + summary_args=(--summary "$GITHUB_STEP_SUMMARY") +fi + +if uv audit --no-config --locked --preview-features audit --output-format json \ + > "$output_dir/uv.json" 2> "$output_dir/uv.log"; then + uv_exit=0 +else + uv_exit=$? +fi +printf '%s\n' "$uv_exit" > "$output_dir/uv.exit-code" +cat "$output_dir/uv.log" "$output_dir/uv.json" +if uv run python -m scripts.uv_advisory_policy \ + --report "$output_dir/uv.json" --exit-code "$uv_exit" "${summary_args[@]}"; then + uv_gate=0 +else + uv_gate=1 +fi + +# The Dockerfile is the single digest-pinned version owner, updated by Dependabot. +# Build errors cannot be mistaken for a scan with accepted vulnerability findings. +docker build --pull --tag optimizer-osv-scanner .github/security-scanner \ + > "$output_dir/osv-build.log" 2>&1 || { + cat "$output_dir/osv-build.log" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + echo "OSV scanner could not start; dependency audit is blocked." >> "$GITHUB_STEP_SUMMARY" + fi + exit 1 +} +if docker run --rm --read-only --cap-drop=ALL --security-opt=no-new-privileges \ + --user "$(id -u):$(id -g)" --env HOME=/tmp --env XDG_CACHE_HOME=/tmp/cache \ + --tmpfs /tmp:rw,noexec,nosuid,size=128m \ + --volume "$PWD:/src:ro" --workdir /src optimizer-osv-scanner \ + scan source --recursive --all-packages --format=json \ + --config=/src/.github/osv-scanner-empty.toml ./ \ + > "$output_dir/osv.json" 2> "$output_dir/osv.log"; then + osv_exit=0 +else + osv_exit=$? +fi +printf '%s\n' "$osv_exit" > "$output_dir/osv.exit-code" +cat "$output_dir/osv.log" "$output_dir/osv.json" +if uv run python -m scripts.advisory_policy \ + --report "$output_dir/osv.json" --scan-exit "$osv_exit" \ + --output "$output_dir/osv-adjudicated.json" "${summary_args[@]}"; then + osv_gate=0 +else + osv_gate=1 +fi + +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + printf "Dependency policy exit status: uv=%s, OSV=%s (0=pass, 1=blocked). Raw evidence retained.\n" \ + "$uv_gate" "$osv_gate" >> "$GITHUB_STEP_SUMMARY" +fi + +if [ "$uv_gate" -ne 0 ] || [ "$osv_gate" -ne 0 ]; then + echo 'Dependency audit failed; inspect the original scanner reports and policy decision.' >&2 + exit 1 +fi diff --git a/scripts/uv_advisory_policy.py b/scripts/uv_advisory_policy.py new file mode 100644 index 0000000..176edcb --- /dev/null +++ b/scripts/uv_advisory_policy.py @@ -0,0 +1,171 @@ +"""Adjudicate raw uv audit JSON without advisory-wide suppression flags. + +Only exact, unexpired no-fix findings already approved in the shared policy may +pass. The original JSON and scanner exit status stay available in CI artifacts. +""" + +import argparse +import sys +import tomllib +from collections.abc import Sequence +from datetime import UTC, datetime +from pathlib import Path + +from scripts.advisory_policy import ( + ExceptionEntry, + PolicyError, + array, + load_policy, + locked_packages, + mapping, + read_json, + require, + string, +) + +VULNERABILITY_FIELDS = { + "dependency", + "id", + "display_id", + "aliases", + "summary", + "description", + "link", + "fix_versions", + "published", + "modified", +} + + +def validate_uv_report( + report: object, + entries: tuple[ExceptionEntry, ...], + locked: set[tuple[str, str]], + exit_code: int, +) -> list[str]: + """Require a complete raw uv report and exact no-fix exception matches.""" + require(type(exit_code) is int and exit_code in {0, 1}, "uv operational failure") + data = mapping(report, "uv report") + require( + set(data) == {"schema", "summary", "vulnerabilities", "adverse_statuses"}, + "unknown or missing uv report fields", + ) + require(data["schema"] == {"version": "preview"}, "unsupported uv JSON schema") + summary = mapping(data["summary"], "summary") + require( + set(summary) == {"audited_packages", "vulnerabilities", "adverse_statuses"}, + "unknown or missing uv summary fields", + ) + require( + all(type(value) is int and value >= 0 for value in summary.values()), + "invalid uv summary counters", + ) + require( + summary["audited_packages"] == len(locked) and bool(locked), + "uv did not audit every locked registry package", + ) + findings = array(data["vulnerabilities"], "vulnerabilities") + statuses = array(data["adverse_statuses"], "adverse_statuses") + require( + summary["vulnerabilities"] == len(findings) + and summary["adverse_statuses"] == len(statuses), + "uv summary does not match findings", + ) + warnings: list[str] = [] + for raw_status in statuses: + status = mapping(raw_status, "adverse status") + require( + set(status) == {"name", "status", "reason"}, "malformed uv adverse status" + ) + name = string(status["name"], "status package") + require( + name in {package for package, _ in locked}, "status is outside the lockfile" + ) + require( + status["status"] in ("archived", "deprecated", "quarantined"), + "unknown adverse status", + ) + reason = status["reason"] + require( + reason is None or isinstance(reason, str), "invalid adverse status reason" + ) + warnings.append( + f"PACKAGE STATUS WARNING (uv): {name} is {status['status']}; reason: {reason!r}" + ) + require(exit_code == int(bool(findings)), "uv exit status contradicts findings") + used: set[ExceptionEntry] = set() + for raw in findings: + finding = mapping(raw, "vulnerability") + require(set(finding) == VULNERABILITY_FIELDS, "malformed uv vulnerability") + dependency = mapping(finding["dependency"], "dependency") + require(set(dependency) == {"name", "version"}, "malformed uv dependency") + name = string(dependency["name"], "dependency name") + version = string(dependency["version"], "dependency version") + require((name, version) in locked, "uv finding is outside the lockfile") + identifier = string(finding["id"], "advisory id") + aliases = { + string(value, "alias") for value in array(finding["aliases"], "aliases") + } + display_id = string(finding["display_id"], "display id") + require(display_id in {identifier, *aliases}, "uv display ID is not an alias") + for field in ("summary", "description", "link", "published", "modified"): + require( + finding[field] is None or isinstance(finding[field], str), + f"invalid uv {field}", + ) + fixes = array(finding["fix_versions"], "fix_versions") + require( + not fixes, f"upgrade {name}@{version}: {identifier} has a fixed release" + ) + matching = [ + entry + for entry in entries + if entry.advisory in {identifier, *aliases} + and entry.package == name + and entry.version == version + ] + require( + len(matching) == 1, + f"unaccepted vulnerability: {identifier} in PyPI {name}@{version}", + ) + used.add(matching[0]) + require(used == set(entries), "stale exception not matched by raw uv scan") + decisions = [ + f"ACCEPTED RISK (uv): {entry.advisory} / PyPI {entry.package}@{entry.version}; " + f"expires {entry.expires_at.isoformat()}; {entry.reason}; " + f"no-fix rationale: {entry.no_fix_reason}; upstream: {entry.upstream}" + for entry in entries + ] or ["No accepted risks; no known vulnerabilities in the complete uv report."] + return decisions + warnings + + +def main(argv: Sequence[str] | None = None) -> int: + """Check one raw uv scan and publish its explicit risk decision.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--policy", type=Path, default=Path(".github/advisory-exceptions.json") + ) + parser.add_argument("--lock", type=Path, default=Path("uv.lock")) + parser.add_argument("--report", type=Path, required=True) + parser.add_argument("--exit-code", type=int, required=True) + parser.add_argument("--summary", type=Path) + args = parser.parse_args(argv) + try: + locked = locked_packages(tomllib.loads(args.lock.read_text(encoding="utf-8"))) + entries = load_policy(read_json(args.policy), locked, datetime.now(UTC)) + messages = validate_uv_report( + read_json(args.report), entries, locked, args.exit_code + ) + summary = "\n".join(messages) + "\n" + print(summary, end="") + if args.summary is not None: + with args.summary.open("a", encoding="utf-8") as stream: + stream.write(summary) + except (PolicyError, ValueError, OSError) as error: + print(f"uv advisory policy failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_advisory_policy.py b/tests/test_advisory_policy.py new file mode 100644 index 0000000..50d0876 --- /dev/null +++ b/tests/test_advisory_policy.py @@ -0,0 +1,362 @@ +"""Fail-closed coverage for bounded vulnerability acceptance and raw OSV evidence.""" + +import copy +import json +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from scripts.advisory_policy import ( + PolicyError, + adjudicate, + load_policy, + locked_packages, + main, + read_json, + validate_report, +) + +NOW = datetime(2026, 10, 6, tzinfo=UTC) +LOCKED = {("demo", "1.0.0")} +GHSA = "GHSA-2345-6789-cfgh" + + +def entry() -> dict[str, object]: + return { + "advisory": GHSA, + "ecosystem": "PyPI", + "package": "demo", + "version": "1.0.0", + "expiresAt": "2026-10-21T00:00:00.000Z", + "reason": "Dev-only risk reviewed for this release", + "noFixReason": "Upstream confirms no patched release exists", + "upstream": "https://example.com/issues/123", + } + + +def policy(*entries: dict[str, object]) -> dict[str, object]: + return {"version": 1, "exceptions": list(entries)} + + +def vulnerability(identifier: str = GHSA) -> dict[str, object]: + return { + "id": identifier, + "aliases": [], + "affected": [ + { + "package": {"name": "demo", "ecosystem": "PyPI"}, + "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}]}], + } + ], + } + + +def report( + *vulns: dict[str, object], + name: str = "demo", + version: str = "1.0.0", + source: str = "uv.lock", +) -> dict[str, object]: + item: dict[str, object] = { + "package": {"name": name, "version": version, "ecosystem": "PyPI"} + } + if vulns: + item.update( + { + "vulnerabilities": list(vulns), + "groups": [{"ids": [value["id"]]} for value in vulns], + } + ) + return { + "results": [ + {"source": {"type": "lockfile", "path": source}, "packages": [item]} + ] + } + + +def test_empty_policy_keeps_clean_scans_clean() -> None: + entries = load_policy(policy(), LOCKED, NOW) + assert validate_report(report(), entries, LOCKED, Path.cwd(), 0) == [ + "No accepted risks; no known vulnerabilities in the complete OSV report." + ] + + +def test_accepted_risk_is_visible_and_only_the_reporter_view_changes() -> None: + raw = report(vulnerability()) + original = copy.deepcopy(raw) + result, messages = adjudicate( + raw, load_policy(policy(entry()), LOCKED, NOW), LOCKED, Path.cwd(), 1 + ) + assert raw == original + assert "vulnerabilities" not in json.dumps(result) + assert ( + "ACCEPTED RISK" in messages[0] + and GHSA in messages[0] + and "demo@1.0.0" in messages[0] + ) + assert "2026-10-21" in messages[0] and "upstream" in messages[0] + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("advisory", "*"), + ("ecosystem", "npm"), + ("package", "other"), + ("version", "1.*"), + ("expiresAt", "2026-10-06T00:00:00.000Z"), + ("expiresAt", "2026-10-06"), + ("expiresAt", "2026-13-40T00:00:00.000Z"), + ("reason", " "), + ("noFixReason", ""), + ("upstream", "http://example.com"), + ("upstream", "https://secret@example.com"), + ("upstream", True), + ("extra", "unknown"), + ], +) +def test_policy_rejects_invalid_or_expired_entries(field: str, value: object) -> None: + item = entry() + item[field] = value + with pytest.raises((PolicyError, ValueError)): + load_policy(policy(item), LOCKED, NOW) + + +@pytest.mark.parametrize( + "bad", + [ + None, + [], + {}, + {"version": True, "exceptions": []}, + {"version": 1, "exceptions": {}}, + {"version": 1, "exceptions": [], "extra": 1}, + ], +) +def test_policy_schema_is_closed(bad: object) -> None: + with pytest.raises(PolicyError): + load_policy(bad, LOCKED, NOW) + + +def test_duplicate_entries_are_rejected() -> None: + with pytest.raises(PolicyError, match="duplicate"): + load_policy(policy(entry(), entry()), LOCKED, NOW) + + +@pytest.mark.parametrize( + "text", ['{"version":1,"version":1,"exceptions":[]}', '{"value":NaN}', "{"] +) +def test_json_is_strict(tmp_path: Path, text: str) -> None: + path = tmp_path / "policy.json" + path.write_text(text) + with pytest.raises(ValueError): + read_json(path) + + +@pytest.mark.parametrize("exit_code", [-1, 2, 126, 127, 128, 255]) +def test_operational_errors_never_become_accepted_risks(exit_code: int) -> None: + with pytest.raises(PolicyError, match="operational"): + validate_report( + report(vulnerability()), + load_policy(policy(entry()), LOCKED, NOW), + LOCKED, + Path.cwd(), + exit_code, + ) + + +@pytest.mark.parametrize(("raw", "code"), [(report(), 1), (report(vulnerability()), 0)]) +def test_exit_and_report_must_agree(raw: object, code: int) -> None: + entries = load_policy(policy(entry()) if code == 0 else policy(), LOCKED, NOW) + with pytest.raises(PolicyError, match="mismatch"): + validate_report(raw, entries, LOCKED, Path.cwd(), code) + + +@pytest.mark.parametrize( + "raw", + [ + report(vulnerability("GHSA-cfgh-jmpq-rvwx")), + report(vulnerability(), name="other"), + report(vulnerability(), version="2.0.0"), + report(vulnerability(), source="nested/uv.lock"), + report(vulnerability(), vulnerability("GHSA-cfgh-jmpq-rvwx")), + ], +) +def test_unrelated_advisory_package_version_or_source_still_blocks(raw: object) -> None: + with pytest.raises(PolicyError, match="unaccepted"): + validate_report( + raw, load_policy(policy(entry()), LOCKED, NOW), LOCKED, Path.cwd(), 1 + ) + + +def test_no_exception_fails_every_finding() -> None: + with pytest.raises(PolicyError, match="unaccepted"): + validate_report(report(vulnerability()), (), LOCKED, Path.cwd(), 1) + + +def test_known_alias_is_accepted_only_for_exact_package() -> None: + vuln = vulnerability("PYSEC-2026-1") + vuln["aliases"] = [GHSA] + assert validate_report( + report(vuln), load_policy(policy(entry()), LOCKED, NOW), LOCKED, Path.cwd(), 1 + ) + + +@pytest.mark.parametrize( + "affected", + [ + None, + [], + [{"package": {"name": "other", "ecosystem": "PyPI"}}], + [ + { + "package": {"name": "demo", "ecosystem": "PyPI"}, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [{"introduced": "0"}, {"fixed": "1.0.1"}], + } + ], + } + ], + [ + { + "package": {"name": "demo", "ecosystem": "PyPI"}, + "ranges": [{"type": "GIT", "events": [{"introduced": "0"}]}], + } + ], + ], +) +def test_missing_or_fixed_upstream_evidence_blocks(affected: object) -> None: + vuln = vulnerability() + vuln["affected"] = affected + with pytest.raises(PolicyError): + validate_report( + report(vuln), + load_policy(policy(entry()), LOCKED, NOW), + LOCKED, + Path.cwd(), + 1, + ) + + +@pytest.mark.parametrize( + "raw", + [ + None, + {}, + {"results": []}, + {"results": "bad"}, + {"results": [], "error": "scan failed"}, + report(name="other"), + { + "results": [ + { + "source": {"path": "uv.lock", "type": "lockfile"}, + "packages": [ + {"package": dict[str, object](), "groups": [{"ids": [GHSA]}]} + ], + } + ] + }, + ], +) +def test_malformed_or_incomplete_reports_fail_closed(raw: object) -> None: + with pytest.raises(PolicyError): + validate_report(raw, (), LOCKED, Path.cwd(), 0) + + +def test_stale_policy_requires_removal() -> None: + with pytest.raises(PolicyError, match="stale"): + validate_report( + report(), load_policy(policy(entry()), LOCKED, NOW), LOCKED, Path.cwd(), 0 + ) + + +def test_uv_lock_requires_real_registry_identities() -> None: + lock = { + "package": [ + { + "name": "demo", + "version": "1.0.0", + "source": {"registry": "https://pypi.org/simple"}, + }, + {"name": "project", "version": "1", "source": {"editable": "."}}, + ] + } + assert locked_packages(lock) == LOCKED + with pytest.raises(PolicyError): + locked_packages({"package": []}) + + +def test_cli_never_writes_reporter_input_after_failure(tmp_path: Path) -> None: + root = Path(__file__).parent.parent + bad = tmp_path / "bad.json" + bad.write_text("{}") + output = tmp_path / "result.json" + assert ( + main( + [ + "--policy", + str(root / ".github/advisory-exceptions.json"), + "--lock", + str(root / "uv.lock"), + "--report", + str(bad), + "--scan-exit", + "127", + "--output", + str(output), + ] + ) + == 1 + ) + assert not output.exists() + + +def test_raw_scanner_config_cannot_hide_findings(tmp_path: Path) -> None: + config = tmp_path / "osv-scanner.toml" + config.write_text('[[IgnoredVulns]]\nid = "GHSA-2345-6789-cfgh"\n') + assert main(["--scanner-config", str(config)]) == 1 + + +def test_explicit_affected_version_without_fixed_release_can_be_accepted() -> None: + vuln = vulnerability() + vuln["affected"] = [ + {"package": {"name": "demo", "ecosystem": "PyPI"}, "versions": ["1.0.0"]} + ] + assert ( + "ACCEPTED RISK" + in validate_report( + report(vuln), + load_policy(policy(entry()), LOCKED, NOW), + LOCKED, + Path.cwd(), + 1, + )[0] + ) + + +def test_cli_offline_and_clean_report_paths(tmp_path: Path) -> None: + lock = tmp_path / "uv.lock" + lock.write_text( + '[[package]]\nname="demo"\nversion="1.0.0"\nsource={registry="https://pypi.org/simple"}\n', + encoding="utf-8", + ) + policy_path = tmp_path / "policy.json" + policy_path.write_text(json.dumps(policy()), encoding="utf-8") + scan = tmp_path / "scan.json" + raw = json.dumps(report()) + scan.write_text(raw, encoding="utf-8") + output = tmp_path / "adjudicated.json" + summary = tmp_path / "summary.md" + args = ["--policy", str(policy_path), "--lock", str(lock)] + assert main(args) == 0 + assert main([*args, "--output", str(output)]) == 1 + args += ["--report", str(scan), "--output", str(output), "--scan-exit", "0"] + assert main(args) == 0 + assert main([*args, "--summary", str(summary)]) == 0 + assert scan.read_text(encoding="utf-8") == raw + assert "No accepted risks" in summary.read_text(encoding="utf-8") + assert json.loads(output.read_text(encoding="utf-8")) == report() diff --git a/tests/test_autofix_policy_guard.py b/tests/test_autofix_policy_guard.py new file mode 100644 index 0000000..9a8fc00 --- /dev/null +++ b/tests/test_autofix_policy_guard.py @@ -0,0 +1,158 @@ +"""Execute the trusted workflow's exact guard without credentials or GitHub writes.""" + +import json +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +WORKFLOW = Path(__file__).resolve().parent.parent / ".github/workflows/auto-fix.yml" + + +def _run_guard( + tmp_path: Path, path: str, status: str = "M", mode: str = "100644" +) -> subprocess.CompletedProcess[str]: + workflow = WORKFLOW.read_text(encoding="utf-8") + start = workflow.index(" # BEGIN AUTOFIX SCOPE GUARD") + end = workflow.index(" # END AUTOFIX SCOPE GUARD") + script = textwrap.dedent(workflow[start:end]) + tracked = [ + "uv.lock", + "src/skill_optimizer/cli.py", + "tests/test_cli.py", + "README.md", + ".pre-commit-config.yaml", + ".github/advisory-exceptions.json", + ".github/osv-scanner-empty.toml", + ".github/security-scanner/Dockerfile", + ".github/workflows/validate.yml", + ".github/workflows/auto-fix.yml", + "scripts/advisory_policy.py", + "scripts/__init__.py", + "scripts/uv_advisory_policy.py", + "scripts/audit-dependencies.sh", + "pyproject.toml", + ] + (tmp_path / "trusted-tree.json").write_text( + json.dumps( + { + "truncated": False, + "tree": [ + {"type": "blob", "mode": "100644", "path": name} for name in tracked + ], + } + ), + encoding="utf-8", + ) + (tmp_path / "fixes").mkdir() + (tmp_path / "fixes/fixes.json").write_text( + json.dumps( + [{"path": path, "status": status, "mode": mode, "content": "dGVzdA=="}] + ), + encoding="utf-8", + ) + return subprocess.run( + [sys.executable, "-c", script], + cwd=tmp_path, + check=False, + capture_output=True, + text=True, + env={**os.environ, "GITHUB_OUTPUT": str(tmp_path / "output")}, + ) + + +@pytest.mark.parametrize( + "path", + [ + "uv.lock", + "src/skill_optimizer/cli.py", + "tests/test_cli.py", + "README.md", + ".pre-commit-config.yaml", + ], +) +def test_ordinary_existing_mechanical_repairs_remain_allowed( + tmp_path: Path, path: str +) -> None: + result = _run_guard(tmp_path, path) + assert result.returncode == 0, result.stderr + assert (tmp_path / "output").read_text(encoding="utf-8") == "safe=true\n" + + +@pytest.mark.parametrize( + "path", + [ + ".github/advisory-exceptions.json", + ".github/osv-scanner-empty.toml", + ".github/security-scanner/Dockerfile", + ".github/workflows/validate.yml", + ".github/workflows/auto-fix.yml", + "scripts/advisory_policy.py", + "scripts/__init__.py", + "scripts/uv_advisory_policy.py", + "scripts/audit-dependencies.sh", + "pyproject.toml", + "scripts//advisory_policy.py", + "./scripts/advisory_policy.py", + "src/../scripts/advisory_policy.py", + "/scripts/advisory_policy.py", + "scripts\\advisory_policy.py", + "", + "sitecustomize.py", + "json.py", + ".venv/lib/python3.14/site-packages/sitecustomize.py", + ], +) +def test_protected_and_noncanonical_paths_never_gain_write_token( + tmp_path: Path, path: str +) -> None: + result = _run_guard(tmp_path, path) + assert result.returncode != 0 + assert not (tmp_path / "output").exists() + + +@pytest.mark.parametrize( + "status,mode", [("A", "100644"), ("D", "000000"), ("T", "120000"), ("M", "120000")] +) +def test_new_files_deletions_and_symlinks_require_review( + tmp_path: Path, status: str, mode: str +) -> None: + assert _run_guard(tmp_path, "sitecustomize.py", status, mode).returncode != 0 + assert not (tmp_path / "output").exists() + + +def test_write_steps_require_successful_scope_guard() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + for step in ( + "Mint a GitHub App token", + "Rebuild the fix commit via the Git Data API and update the branch", + ): + assert f"- name: {step}\n" in workflow + section = workflow.split(f"- name: {step}\n", 1)[1].split(" - name:", 1)[0] + assert "if: steps.scope.outputs.safe == 'true'" in section + assert workflow.index("- name: Read the trusted PR file tree") < workflow.index( + "- name: Validate mechanical fix scope" + ) + assert workflow.index("- name: Validate mechanical fix scope") < workflow.index( + "- name: Mint a GitHub App token" + ) + + +def test_both_dependency_scans_are_isolated_from_test_and_hook_execution() -> None: + root = WORKFLOW.parent + for filename in ("validate.yml", "security-scan.yml"): + workflow = (root / filename).read_text(encoding="utf-8") + dependency_job = workflow.split(" deps-security:\n", 1)[1].split( + "\n # Conventional-Commit", 1 + )[0] + assert "bash scripts/audit-dependencies.sh" in dependency_job + assert "persist-credentials: false" in dependency_job + assert "prek run" not in dependency_job + assert "uv run pytest" not in dependency_job + weekly = (root / "security-scan.yml").read_text(encoding="utf-8") + full_gate = weekly.split(" full-gate:\n", 1)[1].split(" deps-security:\n", 1)[0] + assert "prek run" in full_gate + assert "audit-dependencies.sh" not in full_gate diff --git a/tests/test_dependency_audit_workflow.py b/tests/test_dependency_audit_workflow.py new file mode 100644 index 0000000..64a337f --- /dev/null +++ b/tests/test_dependency_audit_workflow.py @@ -0,0 +1,190 @@ +"""Exercise both scanner adapters through the real shell entry point.""" + +import json +import os +import shutil +import subprocess +import sys +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +GHSA = "GHSA-2345-6789-cfgh" + + +def _fixtures(accepted: bool) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + entry = { + "advisory": GHSA, + "ecosystem": "PyPI", + "package": "example", + "version": "1.0", + "expiresAt": (datetime.now(UTC) + timedelta(days=1)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ), + "reason": "Synthetic test only", + "noFixReason": "No patched release in synthetic report", + "upstream": "https://example.test/advisory", + } + uv_finding: dict[str, Any] = { + "dependency": {"name": "example", "version": "1.0"}, + "id": GHSA, + "display_id": GHSA, + "aliases": [], + "summary": None, + "description": None, + "link": None, + "fix_versions": [], + "published": None, + "modified": None, + } + osv_finding = { + "id": GHSA, + "aliases": [], + "affected": [ + { + "package": {"name": "example", "ecosystem": "PyPI"}, + "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}]}], + } + ], + } + uv_report: dict[str, Any] = { + "schema": {"version": "preview"}, + "summary": { + "audited_packages": 1, + "vulnerabilities": int(accepted), + "adverse_statuses": 0, + }, + "vulnerabilities": [uv_finding] if accepted else [], + "adverse_statuses": [], + } + package: dict[str, Any] = { + "package": {"name": "example", "version": "1.0", "ecosystem": "PyPI"} + } + if accepted: + package.update(vulnerabilities=[osv_finding], groups=[{"ids": [GHSA]}]) + osv_report = { + "results": [ + {"source": {"type": "lockfile", "path": "uv.lock"}, "packages": [package]} + ] + } + return ( + {"version": 1, "exceptions": [entry] if accepted else []}, + uv_report, + osv_report, + ) + + +@pytest.mark.parametrize( + "scenario", + [ + "clean", + "accepted", + "uv-operational", + "osv-operational", + "uv-fixed", + "osv-unrelated", + "build-failure", + ], +) +def test_end_to_end_scanner_policy(tmp_path: Path, scenario: str) -> None: + work = tmp_path / "work" + work.mkdir() + shutil.copytree( + ROOT / "scripts", work / "scripts", ignore=shutil.ignore_patterns("__pycache__") + ) + shutil.copytree( + ROOT / ".github/security-scanner", work / ".github/security-scanner" + ) + shutil.copyfile( + ROOT / ".github/osv-scanner-empty.toml", work / ".github/osv-scanner-empty.toml" + ) + (work / "uv.lock").write_text( + '[[package]]\nname="example"\nversion="1.0"\nsource={registry="https://pypi.org/simple"}\n', + encoding="utf-8", + ) + accepted = scenario != "clean" + policy, uv_report, osv_report = _fixtures(accepted) + if scenario == "uv-fixed": + uv_report["vulnerabilities"][0]["fix_versions"] = ["1.1"] + if scenario == "osv-unrelated": + osv_report["results"][0]["packages"].append( + { + "package": {"name": "other", "version": "9.0", "ecosystem": "PyPI"}, + "vulnerabilities": [{"id": GHSA}], + "groups": [{"ids": [GHSA]}], + } + ) + (work / ".github/advisory-exceptions.json").write_text( + json.dumps(policy), encoding="utf-8" + ) + for name, report in (("uv", uv_report), ("osv", osv_report)): + (tmp_path / f"{name}.json").write_text(json.dumps(report), encoding="utf-8") + binaries = tmp_path / "bin" + binaries.mkdir() + fake_uv = binaries / "uv" + fake_uv.write_text( + f"#!{sys.executable}\n" + + """import os, sys +from pathlib import Path +if sys.argv[1] == "audit": + assert "--no-config" in sys.argv and "--locked" in sys.argv + print(Path(os.environ["UV_FIXTURE"]).read_text()) + raise SystemExit(int(os.environ["UV_EXIT"])) +os.execv(sys.executable, [sys.executable, *sys.argv[3:]]) +""", + encoding="utf-8", + ) + fake_docker = binaries / "docker" + fake_docker.write_text( + f"#!{sys.executable}\n" + + """import os, sys +from pathlib import Path +if sys.argv[1] == "build": + raise SystemExit(int(os.environ["BUILD_EXIT"])) +assert "--recursive" in sys.argv and "--all-packages" in sys.argv +assert "--read-only" in sys.argv and "--cap-drop=ALL" in sys.argv +assert any(value.endswith(":/src:ro") for value in sys.argv) +assert "--config=/src/.github/osv-scanner-empty.toml" in sys.argv +print(Path(os.environ["OSV_FIXTURE"]).read_text()) +raise SystemExit(int(os.environ["OSV_EXIT"])) +""", + encoding="utf-8", + ) + fake_uv.chmod(0o755) + fake_docker.chmod(0o755) + summary = tmp_path / "summary.md" + env = { + **os.environ, + "PATH": f"{binaries}{os.pathsep}{os.environ['PATH']}", + "RUNNER_TEMP": str(tmp_path), + "GITHUB_STEP_SUMMARY": str(summary), + "UV_FIXTURE": str(tmp_path / "uv.json"), + "OSV_FIXTURE": str(tmp_path / "osv.json"), + "UV_EXIT": "2" if scenario == "uv-operational" else str(int(accepted)), + "OSV_EXIT": "127" if scenario == "osv-operational" else str(int(accepted)), + "BUILD_EXIT": "1" if scenario == "build-failure" else "0", + } + result = subprocess.run( + ["bash", "scripts/audit-dependencies.sh"], + cwd=work, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == (0 if scenario in {"clean", "accepted"} else 1), ( + result.stdout + result.stderr + ) + output = tmp_path / "optimizer-dependency-audit" + assert json.loads((output / "uv.json").read_text(encoding="utf-8")) == uv_report + if scenario != "build-failure": + assert ( + json.loads((output / "osv.json").read_text(encoding="utf-8")) == osv_report + ) + if scenario == "accepted": + assert summary.read_text(encoding="utf-8").count("ACCEPTED RISK") == 2 + if scenario == "clean": + assert "ACCEPTED RISK" not in summary.read_text(encoding="utf-8") diff --git a/tests/test_uv_advisory_policy.py b/tests/test_uv_advisory_policy.py new file mode 100644 index 0000000..070bfec --- /dev/null +++ b/tests/test_uv_advisory_policy.py @@ -0,0 +1,260 @@ +"""The uv security gate accepts only exact, current, no-fix risk decisions.""" + +import copy +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import pytest + +from scripts.advisory_policy import PolicyError, load_policy +from scripts.uv_advisory_policy import main, validate_uv_report + +LOCKED = {("example", "1.0"), ("another", "2.0")} +NOW = datetime(2026, 10, 6, tzinfo=UTC) +POLICY: dict[str, Any] = { + "version": 1, + "exceptions": [ + { + "advisory": "GHSA-2345-6789-cfgh", + "ecosystem": "PyPI", + "package": "example", + "version": "1.0", + "expiresAt": "2026-10-21T00:00:00.000Z", + "reason": "Synthetic test risk acceptance only", + "noFixReason": "Upstream has no fixed release", + "upstream": "https://example.test/advisory", + } + ], +} + + +def _finding() -> dict[str, Any]: + return { + "dependency": {"name": "example", "version": "1.0"}, + "id": "PYSEC-2026-1", + "display_id": "GHSA-2345-6789-cfgh", + "aliases": ["GHSA-2345-6789-cfgh"], + "summary": "A synthetic finding", + "description": None, + "link": None, + "fix_versions": [], + "published": None, + "modified": None, + } + + +def _report(*findings: dict[str, Any]) -> dict[str, Any]: + return { + "schema": {"version": "preview"}, + "summary": { + "audited_packages": 2, + "vulnerabilities": len(findings), + "adverse_statuses": 0, + }, + "vulnerabilities": list(findings), + "adverse_statuses": [], + } + + +def test_clean_report_with_empty_policy_passes() -> None: + assert "No accepted risks" in validate_uv_report(_report(), (), LOCKED, 0)[0] + + +def test_exact_alias_no_fix_match_is_visible() -> None: + entries = load_policy(POLICY, LOCKED, NOW) + result = validate_uv_report(_report(_finding()), entries, LOCKED, 1) + assert "ACCEPTED RISK (uv)" in result[0] + assert "example@1.0" in result[0] + assert "2026-10-21" in result[0] + assert "no-fix rationale" in result[0] + + +@pytest.mark.parametrize("code", [-1, 2, 127, 128, True]) +def test_operational_error_never_passes_even_with_findings(code: int) -> None: + entries = load_policy(POLICY, LOCKED, NOW) + with pytest.raises(PolicyError, match="operational"): + validate_uv_report(_report(_finding()), entries, LOCKED, code) + + +@pytest.mark.parametrize("findings,code", [([], 1), ([_finding()], 0)]) +def test_exit_status_must_agree_with_raw_findings( + findings: list[dict[str, Any]], code: int +) -> None: + with pytest.raises(PolicyError, match="contradicts"): + validate_uv_report(_report(*findings), (), LOCKED, code) + + +def test_unapproved_finding_and_mixed_findings_fail() -> None: + with pytest.raises(PolicyError, match="unaccepted"): + validate_uv_report(_report(_finding()), (), LOCKED, 1) + unrelated = _finding() + unrelated["dependency"] = {"name": "another", "version": "2.0"} + with pytest.raises(PolicyError, match="unaccepted"): + validate_uv_report( + _report(_finding(), unrelated), load_policy(POLICY, LOCKED, NOW), LOCKED, 1 + ) + + +@pytest.mark.parametrize("change", ["name", "version", "advisory"]) +def test_exact_scope_is_required(change: str) -> None: + finding = _finding() + if change == "name": + finding["dependency"]["name"] = "another" + elif change == "version": + finding["dependency"]["version"] = "9.0" + else: + finding["id"] = "GHSA-cfgh-2345-6789" + finding["display_id"] = finding["id"] + finding["aliases"] = [] + with pytest.raises(PolicyError, match=r"outside|unaccepted"): + validate_uv_report( + _report(finding), load_policy(POLICY, LOCKED, NOW), LOCKED, 1 + ) + + +def test_fixed_release_requires_upgrade() -> None: + finding = _finding() + finding["fix_versions"] = ["1.1"] + with pytest.raises(PolicyError, match="fixed release"): + validate_uv_report( + _report(finding), load_policy(POLICY, LOCKED, NOW), LOCKED, 1 + ) + + +def test_unmatched_exception_is_stale() -> None: + with pytest.raises(PolicyError, match="stale"): + validate_uv_report(_report(), load_policy(POLICY, LOCKED, NOW), LOCKED, 0) + + +@pytest.mark.parametrize( + "section,field,value", + [ + (None, "extra", []), + (None, "schema", {"version": "changed"}), + ("summary", "extra", 0), + ("summary", "audited_packages", True), + ("summary", "audited_packages", 1), + ("summary", "vulnerabilities", 1), + (None, "vulnerabilities", None), + (None, "adverse_statuses", None), + ], +) +def test_malformed_or_incomplete_reports_fail( + section: str | None, field: str, value: object +) -> None: + report = _report() + target = report if section is None else report[section] + target[field] = value + with pytest.raises(PolicyError): + validate_uv_report(report, (), LOCKED, 0) + + +@pytest.mark.parametrize( + "field,value", + [ + ("fix_versions", None), + ("aliases", "GHSA-2345-6789-cfgh"), + ("dependency", {}), + ("summary", 42), + ("display_id", "not-an-alias"), + ], +) +def test_malformed_finding_fails(field: str, value: object) -> None: + finding = _finding() + finding[field] = value + with pytest.raises(PolicyError): + validate_uv_report( + _report(finding), load_policy(POLICY, LOCKED, NOW), LOCKED, 1 + ) + + +@pytest.mark.parametrize("status", ["archived", "deprecated", "quarantined"]) +@pytest.mark.parametrize("reason", [None, "Native package status warning"]) +def test_adverse_statuses_preserve_native_warnings( + status: str, reason: str | None +) -> None: + report = _report() + report["adverse_statuses"] = [ + {"name": "example", "status": status, "reason": reason} + ] + report["summary"]["adverse_statuses"] = 1 + messages = validate_uv_report(report, (), LOCKED, 0) + assert any( + "PACKAGE STATUS WARNING" in message and status in message + for message in messages + ) + report["vulnerabilities"] = [_finding()] + report["summary"]["vulnerabilities"] = 1 + with pytest.raises(PolicyError, match="unaccepted"): + validate_uv_report(report, (), LOCKED, 1) + + +@pytest.mark.parametrize( + "field,value", + [("status", "unknown"), ("reason", 42), ("name", "unknown"), ("extra", None)], +) +def test_malformed_adverse_statuses_fail_closed(field: str, value: object) -> None: + report = _report() + status: dict[str, object] = { + "name": "example", + "status": "archived", + "reason": None, + } + status[field] = value + report["adverse_statuses"] = [status] + report["summary"]["adverse_statuses"] = 1 + with pytest.raises(PolicyError): + validate_uv_report(report, (), LOCKED, 0) + + +def test_cli_preserves_raw_report_and_writes_summary(tmp_path: Path) -> None: + policy = tmp_path / "policy.json" + policy.write_text('{"version":1,"exceptions":[]}', encoding="utf-8") + lock = tmp_path / "uv.lock" + lock.write_text( + '[[package]]\nname="example"\nversion="1.0"\nsource={registry="https://pypi.org/simple"}\n' + '[[package]]\nname="another"\nversion="2.0"\nsource={registry="https://pypi.org/simple"}\n', + encoding="utf-8", + ) + report = tmp_path / "uv.json" + raw = json.dumps(_report()) + report.write_text(raw, encoding="utf-8") + summary = tmp_path / "summary.md" + args = [ + "--policy", + str(policy), + "--lock", + str(lock), + "--report", + str(report), + "--exit-code", + "0", + "--summary", + str(summary), + ] + assert main(args) == 0 + assert main(args[:-2]) == 0 + assert report.read_text(encoding="utf-8") == raw + assert "No accepted risks" in summary.read_text(encoding="utf-8") + report.write_text('{"schema": {}, "schema": {}}', encoding="utf-8") + assert main(args) == 1 + report.unlink() + assert main(args) == 1 + + +def test_repository_policy_passes_contract() -> None: + root = Path(__file__).resolve().parent.parent + policy = json.loads( + (root / ".github/advisory-exceptions.json").read_text(encoding="utf-8") + ) + assert policy["version"] == 1 + assert isinstance(policy["exceptions"], list) + + +def test_expired_policy_cannot_reach_uv_adjudication() -> None: + policy = copy.deepcopy(POLICY) + policy["exceptions"][0]["expiresAt"] = "2026-10-06T00:00:00.000Z" + with pytest.raises(PolicyError, match="expired"): + load_policy(policy, LOCKED, NOW) From 9aaf5803a5758fc3c145c03d277087b9e33a3258 Mon Sep 17 00:00:00 2001 From: Joshua Date: Tue, 6 Oct 2026 11:39:37 -0500 Subject: [PATCH 2/2] ci: preserve the official OSV image entrypoint --- .github/security-scanner/Dockerfile | 5 ----- tests/test_dependency_audit_workflow.py | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/security-scanner/Dockerfile b/.github/security-scanner/Dockerfile index f0ec67a..f628add 100644 --- a/.github/security-scanner/Dockerfile +++ b/.github/security-scanner/Dockerfile @@ -1,7 +1,2 @@ # Single scanner version owner; Dependabot's docker ecosystem maintains this pin. FROM ghcr.io/google/osv-scanner:v2.6.0@sha256:afd838850ac1a0fcc15ff4a041dc9ba11123c3f0d2666217a5f0fcf9222b55fa - -# Upstream keeps its entrypoint under /root. Relocate the unchanged binary so the -# scanner can run as the checkout owner's unprivileged UID with a read-only root. -RUN cp /root/osv-scanner /usr/local/bin/osv-scanner && chmod 0755 /usr/local/bin/osv-scanner -ENTRYPOINT ["/usr/local/bin/osv-scanner"] diff --git a/tests/test_dependency_audit_workflow.py b/tests/test_dependency_audit_workflow.py index 64a337f..3f81f8e 100644 --- a/tests/test_dependency_audit_workflow.py +++ b/tests/test_dependency_audit_workflow.py @@ -2,6 +2,7 @@ import json import os +import re import shutil import subprocess import sys @@ -188,3 +189,17 @@ def test_end_to_end_scanner_policy(tmp_path: Path, scenario: str) -> None: assert summary.read_text(encoding="utf-8").count("ACCEPTED RISK") == 2 if scenario == "clean": assert "ACCEPTED RISK" not in summary.read_text(encoding="utf-8") + + +def test_scanner_uses_the_official_digest_pinned_native_entrypoint() -> None: + dockerfile = (ROOT / ".github/security-scanner/Dockerfile").read_text( + encoding="utf-8" + ) + instructions = [ + line for line in dockerfile.splitlines() if line and not line.startswith("#") + ] + assert len(instructions) == 1 + assert re.fullmatch( + r"FROM ghcr\.io/google/osv-scanner:v\d+\.\d+\.\d+@sha256:[0-9a-f]{64}", + instructions[0], + )