diff --git a/.github/workflows/exact-artifact-sbom-attestation-quality.yml b/.github/workflows/exact-artifact-sbom-attestation-quality.yml new file mode 100644 index 000000000..43a154178 --- /dev/null +++ b/.github/workflows/exact-artifact-sbom-attestation-quality.yml @@ -0,0 +1,114 @@ +name: Exact Artifact SBOM Attestation Quality + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/exact-artifact-sbom-attestation.yml" + - ".github/workflows/exact-artifact-sbom-attestation-quality.yml" + - "scripts/ci/verify_exact_artifact_sbom_handoff.py" + - "tests/test_exact_artifact_sbom_attestation_contract.py" + - "tests/test_verify_exact_artifact_sbom_handoff.py" + - "docs/doctoring/exact-artifact-sbom-attestation.md" + - "CHANGELOG.md" + push: + branches: [main] + paths: + - ".github/workflows/exact-artifact-sbom-attestation.yml" + - ".github/workflows/exact-artifact-sbom-attestation-quality.yml" + - "scripts/ci/verify_exact_artifact_sbom_handoff.py" + - "tests/test_exact_artifact_sbom_attestation_contract.py" + - "tests/test_verify_exact_artifact_sbom_handoff.py" + - "docs/doctoring/exact-artifact-sbom-attestation.md" + - "CHANGELOG.md" + +concurrency: + group: exact-artifact-sbom-attestation-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + minimum-python-contract: + name: Python 3.10 contract + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact contributor head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Verify exact workflow source checkout + env: + EXPECTED_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA" + + - name: Set up minimum supported Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.10" + + - name: Compile production and contracts on Python 3.10 + run: | + python -m compileall -q \ + scripts/ci/verify_exact_artifact_sbom_handoff.py \ + tests/test_exact_artifact_sbom_attestation_contract.py \ + tests/test_verify_exact_artifact_sbom_handoff.py + + exact-contract: + name: Python 3.14 exact contract and complete coverage + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact contributor head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Verify exact workflow source checkout + env: + EXPECTED_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA" + + - name: Set up current stable Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install hash-locked quality tooling + run: python -m pip install --disable-pip-version-check --require-hashes -r requirements-opencode-review-ci-hashes.txt + + - name: Run exact contracts with complete verifier branch coverage + run: | + python -m coverage erase + python -m coverage run --branch -m pytest -q \ + tests/test_exact_artifact_sbom_attestation_contract.py \ + tests/test_verify_exact_artifact_sbom_handoff.py + python -m coverage report \ + --include=scripts/ci/verify_exact_artifact_sbom_handoff.py \ + --show-missing \ + --fail-under=100 + python -m interrogate --fail-under=100 scripts/ci/verify_exact_artifact_sbom_handoff.py + + - name: Compile production and contract files + run: | + python -m compileall -q \ + scripts/ci/verify_exact_artifact_sbom_handoff.py \ + tests/test_exact_artifact_sbom_attestation_contract.py \ + tests/test_verify_exact_artifact_sbom_handoff.py diff --git a/.github/workflows/exact-artifact-sbom-attestation.yml b/.github/workflows/exact-artifact-sbom-attestation.yml new file mode 100644 index 000000000..c7c298a05 --- /dev/null +++ b/.github/workflows/exact-artifact-sbom-attestation.yml @@ -0,0 +1,256 @@ +name: Exact Artifact SBOM Attestation + +on: + workflow_call: + inputs: + source_repository: + required: true + type: string + source_sha: + required: true + type: string + evidence_artifact_id: + required: true + type: string + evidence_artifact_name: + required: true + type: string + evidence_artifact_digest: + required: true + type: string + wheel_filename: + required: true + type: string + wheel_sha256: + required: true + type: string + wheel_sbom_filename: + required: true + type: string + wheel_sbom_sha256: + required: true + type: string + sdist_filename: + required: true + type: string + sdist_sha256: + required: true + type: string + sdist_sbom_filename: + required: true + type: string + sdist_sbom_sha256: + required: true + type: string + source_identity_sha256: + required: true + type: string + checksum_sha256: + required: true + type: string + predicate_type: + required: true + type: string + cyclonedx_schema: + required: true + type: string + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + verify-evidence-artifact: + name: Verify inert sealed evidence + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + actions: read + contents: read + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Materialize immutable trusted verifier + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: trusted-intake + persist-credentials: false + sparse-checkout: scripts/ci/verify_exact_artifact_sbom_handoff.py + sparse-checkout-cone-mode: false + + - name: Verify immutable same-run artifact metadata + env: + GH_TOKEN: ${{ github.token }} + SOURCE_REPOSITORY: ${{ inputs.source_repository }} + SOURCE_SHA: ${{ inputs.source_sha }} + ARTIFACT_ID: ${{ inputs.evidence_artifact_id }} + ARTIFACT_NAME: ${{ inputs.evidence_artifact_name }} + ARTIFACT_DIGEST: ${{ inputs.evidence_artifact_digest }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$SOURCE_REPOSITORY" = "$GITHUB_REPOSITORY" + test "$SOURCE_SHA" = "$GITHUB_SHA" + artifact_json="$(gh api "/repos/${SOURCE_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")" + jq -e \ + --arg name "$ARTIFACT_NAME" \ + --arg digest "$ARTIFACT_DIGEST" \ + --argjson run_id "$GITHUB_RUN_ID" \ + '.name == $name and .digest == $digest and .workflow_run.id == $run_id and .expired == false' \ + <<<"$artifact_json" >/dev/null + + - name: Download exact same-run evidence by immutable artifact ID + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v6.0.0 + with: + artifact-ids: ${{ inputs.evidence_artifact_id }} + path: sealed-evidence + + - name: Verify sealed evidence as inert bounded data + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python3 -I trusted-intake/scripts/ci/verify_exact_artifact_sbom_handoff.py \ + --source-repository '${{ inputs.source_repository }}' \ + --source-sha '${{ inputs.source_sha }}' \ + --evidence-artifact-name '${{ inputs.evidence_artifact_name }}' \ + --evidence-artifact-digest '${{ inputs.evidence_artifact_digest }}' \ + --evidence-root sealed-evidence \ + --wheel-filename '${{ inputs.wheel_filename }}' \ + --wheel-sha256 '${{ inputs.wheel_sha256 }}' \ + --wheel-sbom-filename '${{ inputs.wheel_sbom_filename }}' \ + --wheel-sbom-sha256 '${{ inputs.wheel_sbom_sha256 }}' \ + --sdist-filename '${{ inputs.sdist_filename }}' \ + --sdist-sha256 '${{ inputs.sdist_sha256 }}' \ + --sdist-sbom-filename '${{ inputs.sdist_sbom_filename }}' \ + --sdist-sbom-sha256 '${{ inputs.sdist_sbom_sha256 }}' \ + --source-identity-sha256 '${{ inputs.source_identity_sha256 }}' \ + --checksum-sha256 '${{ inputs.checksum_sha256 }}' \ + --predicate-type '${{ inputs.predicate_type }}' \ + --cyclonedx-schema '${{ inputs.cyclonedx_schema }}' \ + --output-manifest "${RUNNER_TEMP}/verified-intake.json" + + attest-exact-artifacts: + name: Attest exact wheel and sdist SBOMs + needs: verify-evidence-artifact + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Materialize immutable trusted verifier + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: trusted-signer + persist-credentials: false + sparse-checkout: scripts/ci/verify_exact_artifact_sbom_handoff.py + sparse-checkout-cone-mode: false + + - name: Download exact sealed evidence without executing it + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v6.0.0 + with: + artifact-ids: ${{ inputs.evidence_artifact_id }} + path: sealed-evidence + + - name: Reverify evidence inside the credentialed boundary + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python3 -I trusted-signer/scripts/ci/verify_exact_artifact_sbom_handoff.py \ + --source-repository '${{ inputs.source_repository }}' \ + --source-sha '${{ inputs.source_sha }}' \ + --evidence-artifact-name '${{ inputs.evidence_artifact_name }}' \ + --evidence-artifact-digest '${{ inputs.evidence_artifact_digest }}' \ + --evidence-root sealed-evidence \ + --wheel-filename '${{ inputs.wheel_filename }}' \ + --wheel-sha256 '${{ inputs.wheel_sha256 }}' \ + --wheel-sbom-filename '${{ inputs.wheel_sbom_filename }}' \ + --wheel-sbom-sha256 '${{ inputs.wheel_sbom_sha256 }}' \ + --sdist-filename '${{ inputs.sdist_filename }}' \ + --sdist-sha256 '${{ inputs.sdist_sha256 }}' \ + --sdist-sbom-filename '${{ inputs.sdist_sbom_filename }}' \ + --sdist-sbom-sha256 '${{ inputs.sdist_sbom_sha256 }}' \ + --source-identity-sha256 '${{ inputs.source_identity_sha256 }}' \ + --checksum-sha256 '${{ inputs.checksum_sha256 }}' \ + --predicate-type '${{ inputs.predicate_type }}' \ + --cyclonedx-schema '${{ inputs.cyclonedx_schema }}' \ + --output-manifest "${RUNNER_TEMP}/verified-signer.json" + + - name: Attest exact wheel with its CycloneDX SBOM + id: attest-wheel + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-name: ${{ inputs.wheel_filename }} + subject-digest: sha256:${{ inputs.wheel_sha256 }} + sbom-path: sealed-evidence/${{ inputs.wheel_sbom_filename }} + + - name: Attest exact source distribution with its CycloneDX SBOM + id: attest-sdist + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-name: ${{ inputs.sdist_filename }} + subject-digest: sha256:${{ inputs.sdist_sha256 }} + sbom-path: sealed-evidence/${{ inputs.sdist_sbom_filename }} + + - name: Verify online and prepare offline bundles + env: + GH_TOKEN: ${{ github.token }} + SIGNER_REPOSITORY: ${{ job.workflow_repository }} + PREDICATE_TYPE: ${{ inputs.predicate_type }} + SOURCE_REPOSITORY: ${{ inputs.source_repository }} + SOURCE_SHA: ${{ inputs.source_sha }} + WHEEL_BUNDLE: ${{ steps.attest-wheel.outputs.bundle-path }} + SDIST_BUNDLE: ${{ steps.attest-sdist.outputs.bundle-path }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + signer_workflow="${SIGNER_REPOSITORY}/.github/workflows/exact-artifact-sbom-attestation.yml" + mkdir -p offline-attestation-evidence + install -m 0444 "$WHEEL_BUNDLE" offline-attestation-evidence/wheel-sbom-attestation.json + install -m 0444 "$SDIST_BUNDLE" offline-attestation-evidence/sdist-sbom-attestation.json + gh attestation trusted-root > offline-attestation-evidence/trusted_root.jsonl + for artifact in '${{ inputs.wheel_filename }}' '${{ inputs.sdist_filename }}'; do + gh attestation verify "sealed-evidence/${artifact}" \ + --repo "$SOURCE_REPOSITORY" \ + --signer-repo "$SIGNER_REPOSITORY" \ + --signer-workflow "$signer_workflow" \ + --source-digest "$SOURCE_SHA" \ + --predicate-type "$PREDICATE_TYPE" + done + gh attestation verify 'sealed-evidence/${{ inputs.wheel_filename }}' \ + --repo "$SOURCE_REPOSITORY" \ + --bundle offline-attestation-evidence/wheel-sbom-attestation.json \ + --custom-trusted-root offline-attestation-evidence/trusted_root.jsonl \ + --signer-repo "$SIGNER_REPOSITORY" \ + --signer-workflow "$signer_workflow" \ + --source-digest "$SOURCE_SHA" \ + --predicate-type "$PREDICATE_TYPE" + gh attestation verify 'sealed-evidence/${{ inputs.sdist_filename }}' \ + --repo "$SOURCE_REPOSITORY" \ + --bundle offline-attestation-evidence/sdist-sbom-attestation.json \ + --custom-trusted-root offline-attestation-evidence/trusted_root.jsonl \ + --signer-repo "$SIGNER_REPOSITORY" \ + --signer-workflow "$signer_workflow" \ + --source-digest "$SOURCE_SHA" \ + --predicate-type "$PREDICATE_TYPE" + cp "${RUNNER_TEMP}/verified-signer.json" offline-attestation-evidence/verified-handoff.json + + - name: Export beginner-readable offline verification evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: exact-artifact-sbom-offline-verification + path: offline-attestation-evidence + if-no-files-found: error + retention-days: 90 diff --git a/CHANGELOG.md b/CHANGELOG.md index e601de81b..fb6e510d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Semantic Versioning where the repository publishes a release. ### Added +- Added an organization-owned reusable exact-artifact SBOM attestation boundary that validates inert six-file wheel/sdist evidence, binds CycloneDX 1.7 predicates to exact SHA-256 subjects, signs through least-privilege GitHub artifact attestations, and exports online and offline verification bundles. - Added exact-base `uv.lock` materialization that reconstructs standalone nested projects with a checksum-pinned official `uv` exporter, isolated frozen/offline execution, strict exact-pin and SHA-256 output validation, and complete Python 3.10/3.14 quality evidence. ### Fixed diff --git a/docs/doctoring/exact-artifact-sbom-attestation.md b/docs/doctoring/exact-artifact-sbom-attestation.md new file mode 100644 index 000000000..c7e0254f4 --- /dev/null +++ b/docs/doctoring/exact-artifact-sbom-attestation.md @@ -0,0 +1,95 @@ +# Exact-artifact SBOM attestation + +## Trust boundary + +The organization-owned reusable workflow signs only an already sealed, same-run evidence artifact. The caller supplies immutable identifiers and digests, but the trusted workflow independently verifies them before minting an OIDC token or invoking `actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26`. + +The boundary has two jobs: + +1. `verify-evidence-artifact` has only `actions: read` and `contents: read`. It confirms the exact artifact ID, name, digest, workflow-run ID, expiry state, source repository, source SHA, six-file cardinality, SHA-256 handoff, strict JSON, CycloneDX specification 1.7 identity, and root distribution binding. +2. `attest-exact-artifacts` receives `id-token: write`, `attestations: write`, `artifact-metadata: write`, and `contents: read` only after the first job succeeds. It downloads the same immutable artifact ID, repeats the data-only verification, and signs the exact wheel and source distribution separately. + +Both jobs load the verifier from `${{ job.workflow_repository }}` at `${{ job.workflow_sha }}` with persisted Git credentials disabled. Caller-controlled source is never checked out in the signing boundary. Downloaded files are treated as inert bytes: the workflow does not import, install, build, test, execute, source, or unpack them. + +The handoff contains exactly: + +- one wheel; +- one CycloneDX 1.7 wheel SBOM; +- one source distribution; +- one CycloneDX 1.7 source-distribution SBOM; +- `source-identity.json`; and +- `checksums.sha256`. + +The checksum file binds the other five files. Externally supplied digests bind all six files, including the checksum file itself. Each SBOM root component must name the exact distribution and include its exact SHA-256 digest. + +## Exact-head lifecycle + +```mermaid +flowchart LR + A[Caller builds exact source SHA] --> B[Caller creates wheel, sdist, two SBOMs] + B --> C[Caller seals six-file artifact] + C --> D[Read-only metadata and data verification] + D --> E[Credentialed job repeats verification] + E --> F[Wheel SBOM attestation] + E --> G[Sdist SBOM attestation] + F --> H[Online signer/predicate/source verification] + G --> H + H --> I[Sigstore bundles and trusted root export] + I --> J[Offline verification artifact] +``` + +A caller must pass its exact `source_repository`, 40-character `source_sha`, same-run artifact ID, artifact name, artifact digest, filenames, SHA-256 digests, CycloneDX schema URI, and SBOM predicate type. The workflow rejects a caller repository or source SHA that does not match the live GitHub run context. + +The verifier emits deterministic compact JSON containing the verified source identity, predicate, schema, filenames, sizes, and hashes. It publishes the manifest atomically and rejects an output symlink. + +## Offline verification + +The signing job preserves both Sigstore bundles, a fresh `trusted_root.jsonl`, and the deterministic verified-handoff manifest. An operator imports the distribution, its matching bundle, the trusted root, and GitHub CLI into the offline environment, then runs: + +```bash +gh attestation verify path/to/distribution \ + --repo OWNER/REPOSITORY \ + --bundle path/to/attestation.json \ + --custom-trusted-root path/to/trusted_root.jsonl \ + --signer-repo ContextualWisdomLab/.github \ + --signer-workflow ContextualWisdomLab/.github/.github/workflows/exact-artifact-sbom-attestation.yml \ + --source-digest EXACT_SOURCE_SHA \ + --predicate-type EXPECTED_SBOM_PREDICATE +``` + +Generate a new trusted root whenever new signed material enters an offline environment. A previously exported root cannot reveal revocation or later key rotation that occurred after export. + +## Incident recovery and rollback + +1. Disable the caller release workflow without changing or deleting existing evidence. +2. Preserve the failed run ID, artifact ID, artifact digest, source SHA, verification output, and attestation bundles. +3. Determine whether the defect is in build output, SBOM generation, the sealed handoff, trusted verification, or signing. +4. Revoke or delete an invalid GitHub attestation only after preserving a forensic copy and documenting affected consumers. +5. Correct the source or workflow through a protected pull request. Never overwrite a distribution while retaining its old filename or digest claim. +6. Rebuild from a new exact source SHA, generate new artifacts and SBOMs, and rerun the complete verification and attestation lifecycle. +7. Publish an incident note identifying invalid subjects, replacement subjects, and consumer actions. + +Rollback means restoring a previously reviewed workflow version and producing new signed material. It does not mean reusing an old attestation for newly built bytes. + +## Claims deliberately not made + +- An SBOM attestation does not prove that the software is vulnerability-free, malware-free, correct, safe, or fit for a particular purpose. +- This workflow does not claim SLSA Build Lx (v1.2). It supplies a narrow SBOM authenticity and exact-subject binding control, not a complete build provenance level. +- CycloneDX conformance does not prove that the component inventory is complete or semantically correct. +- A valid signature does not make caller-provided predicate content trustworthy by itself; the trusted reusable workflow and verifier are the policy boundary. +- Offline verification cannot detect revocation or trusted-root rotation that happened after the trusted root was exported. +- `artifact-metadata: write` does not imply that a non-registry distribution has been published, deployed, or approved for release. + +## References + +CycloneDX Core Working Group. (2025). *CycloneDX specification 1.7*. OWASP Foundation. https://cyclonedx.org/specification/overview/ + +GitHub. (2026). *Using artifact attestations to establish provenance for builds*. GitHub Docs. https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations + +GitHub. (2026). *Verifying attestations offline*. GitHub Docs. https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/verify-attestations-offline + +GitHub. (2026). *actions/attest* (Version 4.1.0) [Computer software]. https://github.com/actions/attest + +Open Source Security Foundation. (2025). *SLSA specification version 1.2*. https://slsa.dev/spec/v1.2/ + +Sigstore Project. (2024). *Sigstore bundle format*. https://docs.sigstore.dev/about/bundle/ diff --git a/scripts/ci/verify_exact_artifact_sbom_handoff.py b/scripts/ci/verify_exact_artifact_sbom_handoff.py new file mode 100644 index 000000000..ac14302d1 --- /dev/null +++ b/scripts/ci/verify_exact_artifact_sbom_handoff.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""Verify one sealed wheel/sdist/SBOM handoff without executing its contents.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import stat +import tempfile +from pathlib import Path +from typing import Any, Iterable + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_SHA1_RE = re.compile(r"^[0-9a-f]{40}$") +_REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +_ARTIFACT_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +_CHECKSUM_RE = re.compile(r"^([0-9a-f]{64}) [ *]([^/\\]+)$") +_MAX_JSON_BYTES = 16 * 1024 * 1024 +_MAX_CONTROL_BYTES = 1024 * 1024 +_SOURCE_IDENTITY = "source-identity.json" +_CHECKSUM_FILE = "checksums.sha256" + + +class EvidenceError(ValueError): + """Describe a deterministic sealed-evidence validation failure.""" + + +def _reject_duplicate_keys(pairs: Iterable[tuple[str, Any]]) -> dict[str, Any]: + """Build one JSON object while rejecting duplicate property names.""" + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise EvidenceError(f"duplicate JSON property: {key}") + result[key] = value + return result + + +def _load_json(path: Path, maximum_bytes: int = _MAX_JSON_BYTES) -> Any: + """Load strict bounded UTF-8 JSON from one regular non-symlink file.""" + _require_regular_file(path) + if path.stat().st_size > maximum_bytes: + raise EvidenceError(f"JSON file exceeds {maximum_bytes} bytes: {path.name}") + try: + text = path.read_text(encoding="utf-8", errors="strict") + return json.loads(text, object_pairs_hook=_reject_duplicate_keys) + except UnicodeError as error: + raise EvidenceError(f"invalid UTF-8 in {path.name}") from error + except json.JSONDecodeError as error: + raise EvidenceError(f"invalid JSON in {path.name}: {error.msg}") from error + + +def _require_regular_file(path: Path) -> None: + """Require one existing regular file with no symlink endpoint.""" + try: + mode = path.lstat().st_mode + except FileNotFoundError as error: + raise EvidenceError(f"missing evidence file: {path.name}") from error + if stat.S_ISLNK(mode) or not stat.S_ISREG(mode): + raise EvidenceError(f"evidence member is not a regular file: {path.name}") + + +def _validate_filename(value: str, label: str) -> str: + """Return a safe root-level evidence filename.""" + if not value or value in {".", ".."} or Path(value).name != value: + raise EvidenceError(f"{label} must be one root-level filename") + if "/" in value or "\\" in value or "\x00" in value: + raise EvidenceError(f"{label} contains a forbidden path character") + return value + + +def _validate_sha256(value: str, label: str) -> str: + """Return one lowercase hexadecimal SHA-256 digest.""" + if not _SHA256_RE.fullmatch(value): + raise EvidenceError(f"{label} must be 64 lowercase hexadecimal characters") + return value + + +def _sha256(path: Path) -> str: + """Hash one regular evidence file without loading it into memory.""" + _require_regular_file(path) + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _require_digest(path: Path, expected: str, label: str) -> None: + """Require one file to match its externally supplied SHA-256 digest.""" + actual = _sha256(path) + if actual != expected: + raise EvidenceError(f"{label} digest mismatch: expected {expected}, got {actual}") + + +def _parse_checksums(path: Path) -> dict[str, str]: + """Parse a canonical sorted GNU-style SHA-256 checksum file.""" + _require_regular_file(path) + if path.stat().st_size > _MAX_CONTROL_BYTES: + raise EvidenceError("checksum file exceeds the control-file size limit") + try: + lines = path.read_text(encoding="utf-8", errors="strict").splitlines() + except UnicodeError as error: + raise EvidenceError("checksum file is not strict UTF-8") from error + parsed: dict[str, str] = {} + order: list[str] = [] + for line in lines: + match = _CHECKSUM_RE.fullmatch(line) + if match is None: + raise EvidenceError("checksum file contains a noncanonical line") + digest, filename = match.groups() + if filename in parsed: + raise EvidenceError(f"duplicate checksum filename: {filename}") + parsed[filename] = digest + order.append(filename) + if order != sorted(order): + raise EvidenceError("checksum entries must be sorted by filename") + return parsed + + +def _validate_cyclonedx( + path: Path, + *, + schema: str, + subject_name: str, + subject_sha256: str, +) -> None: + """Validate a CycloneDX 1.7 document bound to one exact distribution.""" + document = _load_json(path) + if not isinstance(document, dict): + raise EvidenceError(f"{path.name} must contain a JSON object") + if document.get("$schema") != schema: + raise EvidenceError(f"{path.name} uses an unexpected CycloneDX schema") + if document.get("bomFormat") != "CycloneDX" or document.get("specVersion") != "1.7": + raise EvidenceError(f"{path.name} must be CycloneDX specification 1.7") + metadata = document.get("metadata") + component = metadata.get("component") if isinstance(metadata, dict) else None + if not isinstance(component, dict) or component.get("name") != subject_name: + raise EvidenceError(f"{path.name} root component does not name {subject_name}") + hashes = component.get("hashes") + expected_hash = {"alg": "SHA-256", "content": subject_sha256} + if not isinstance(hashes, list) or expected_hash not in hashes: + raise EvidenceError(f"{path.name} root component is not bound to the subject digest") + + +def _atomic_json(path: Path, value: dict[str, Any]) -> None: + """Publish deterministic JSON atomically without following an output symlink.""" + path.parent.mkdir(parents=True, exist_ok=True) + if path.is_symlink(): + raise EvidenceError("output manifest path must not be a symlink") + payload = json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n" + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.chmod(temporary, 0o644) + os.replace(temporary, path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +def verify(arguments: argparse.Namespace) -> dict[str, Any]: + """Validate exact evidence and return its deterministic verification manifest.""" + if not _REPOSITORY_RE.fullmatch(arguments.source_repository): + raise EvidenceError("source repository must use owner/name form") + if not _SHA1_RE.fullmatch(arguments.source_sha): + raise EvidenceError("source SHA must be a lowercase 40-character Git SHA") + if not _ARTIFACT_DIGEST_RE.fullmatch(arguments.evidence_artifact_digest): + raise EvidenceError("evidence artifact digest must use sha256:") + + root = Path(arguments.evidence_root) + if root.is_symlink() or not root.is_dir(): + raise EvidenceError("evidence root must be a non-symlink directory") + root = root.resolve() + + names = { + "wheel": _validate_filename(arguments.wheel_filename, "wheel filename"), + "wheel_sbom": _validate_filename( + arguments.wheel_sbom_filename, "wheel SBOM filename" + ), + "sdist": _validate_filename(arguments.sdist_filename, "sdist filename"), + "sdist_sbom": _validate_filename( + arguments.sdist_sbom_filename, "sdist SBOM filename" + ), + "source_identity": _SOURCE_IDENTITY, + "checksums": _CHECKSUM_FILE, + } + if len(set(names.values())) != len(names): + raise EvidenceError("all six evidence filenames must be distinct") + + actual_members: set[str] = set() + for member in root.iterdir(): + if member.is_symlink() or not member.is_file(): + raise EvidenceError(f"unexpected non-regular evidence member: {member.name}") + actual_members.add(member.name) + expected_members = set(names.values()) + if actual_members != expected_members: + missing = sorted(expected_members - actual_members) + extra = sorted(actual_members - expected_members) + raise EvidenceError(f"evidence cardinality mismatch; missing={missing}, extra={extra}") + + expected_digests = { + names["wheel"]: _validate_sha256(arguments.wheel_sha256, "wheel SHA-256"), + names["wheel_sbom"]: _validate_sha256( + arguments.wheel_sbom_sha256, "wheel SBOM SHA-256" + ), + names["sdist"]: _validate_sha256(arguments.sdist_sha256, "sdist SHA-256"), + names["sdist_sbom"]: _validate_sha256( + arguments.sdist_sbom_sha256, "sdist SBOM SHA-256" + ), + names["source_identity"]: _validate_sha256( + arguments.source_identity_sha256, "source identity SHA-256" + ), + names["checksums"]: _validate_sha256( + arguments.checksum_sha256, "checksum SHA-256" + ), + } + for filename, expected in expected_digests.items(): + _require_digest(root / filename, expected, filename) + + checksums = _parse_checksums(root / names["checksums"]) + checksum_subjects = expected_members - {names["checksums"]} + if set(checksums) != checksum_subjects: + raise EvidenceError("checksum file must bind exactly the other five evidence files") + for filename in checksum_subjects: + if checksums[filename] != expected_digests[filename]: + raise EvidenceError(f"checksum handoff mismatch for {filename}") + + identity = _load_json(root / names["source_identity"], _MAX_CONTROL_BYTES) + if not isinstance(identity, dict): + raise EvidenceError("source identity must contain a JSON object") + expected_identity = { + "schema_version": "1.0", + "source_repository": arguments.source_repository, + "source_sha": arguments.source_sha, + "evidence_artifact_name": arguments.evidence_artifact_name, + "evidence_artifact_digest": arguments.evidence_artifact_digest, + "predicate_type": arguments.predicate_type, + "cyclonedx_schema": arguments.cyclonedx_schema, + "artifacts": { + "wheel": { + "filename": names["wheel"], + "sha256": expected_digests[names["wheel"]], + "sbom_filename": names["wheel_sbom"], + "sbom_sha256": expected_digests[names["wheel_sbom"]], + }, + "sdist": { + "filename": names["sdist"], + "sha256": expected_digests[names["sdist"]], + "sbom_filename": names["sdist_sbom"], + "sbom_sha256": expected_digests[names["sdist_sbom"]], + }, + }, + } + if identity != expected_identity: + raise EvidenceError("source identity does not exactly match the sealed handoff") + + _validate_cyclonedx( + root / names["wheel_sbom"], + schema=arguments.cyclonedx_schema, + subject_name=names["wheel"], + subject_sha256=expected_digests[names["wheel"]], + ) + _validate_cyclonedx( + root / names["sdist_sbom"], + schema=arguments.cyclonedx_schema, + subject_name=names["sdist"], + subject_sha256=expected_digests[names["sdist"]], + ) + + manifest = { + "result": "PASS", + "source_repository": arguments.source_repository, + "source_sha": arguments.source_sha, + "predicate_type": arguments.predicate_type, + "cyclonedx_schema": arguments.cyclonedx_schema, + "files": [ + { + "filename": filename, + "sha256": expected_digests[filename], + "size_bytes": (root / filename).stat().st_size, + } + for filename in sorted(expected_members) + ], + } + _atomic_json(Path(arguments.output_manifest), manifest) + return manifest + + +def _parser() -> argparse.ArgumentParser: + """Create the strict command-line parser for sealed handoff verification.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-repository", required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--evidence-artifact-name", required=True) + parser.add_argument("--evidence-artifact-digest", required=True) + parser.add_argument("--evidence-root", required=True) + parser.add_argument("--wheel-filename", required=True) + parser.add_argument("--wheel-sha256", required=True) + parser.add_argument("--wheel-sbom-filename", required=True) + parser.add_argument("--wheel-sbom-sha256", required=True) + parser.add_argument("--sdist-filename", required=True) + parser.add_argument("--sdist-sha256", required=True) + parser.add_argument("--sdist-sbom-filename", required=True) + parser.add_argument("--sdist-sbom-sha256", required=True) + parser.add_argument("--source-identity-sha256", required=True) + parser.add_argument("--checksum-sha256", required=True) + parser.add_argument("--predicate-type", required=True) + parser.add_argument("--cyclonedx-schema", required=True) + parser.add_argument("--output-manifest", required=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + """Run sealed-evidence verification and emit one compact decision line.""" + arguments = _parser().parse_args(argv) + try: + manifest = verify(arguments) + except EvidenceError as error: + raise SystemExit(f"sealed evidence verification failed: {error}") from error + print( + "sealed evidence verification passed: " + f"{len(manifest['files'])} files at {manifest['source_sha']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_exact_artifact_sbom_attestation_contract.py b/tests/test_exact_artifact_sbom_attestation_contract.py new file mode 100644 index 000000000..2e2feb3b4 --- /dev/null +++ b/tests/test_exact_artifact_sbom_attestation_contract.py @@ -0,0 +1,228 @@ +"""Contracts for the organization-owned exact-artifact SBOM attestation workflow.""" + +from __future__ import annotations + +import re +from pathlib import Path + +REUSABLE_WORKFLOW = Path( + ".github/workflows/exact-artifact-sbom-attestation.yml" +) +VERIFIER = Path("scripts/ci/verify_exact_artifact_sbom_handoff.py") +DOCTORING = Path("docs/doctoring/exact-artifact-sbom-attestation.md") +ATTEST_ACTION_PIN = "actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26" +CHECKOUT_ACTION_PIN = "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" +DOWNLOAD_ACTION_PIN = ( + "actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131" +) +UPLOAD_ACTION_PIN = ( + "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" +) + + +def _required_text(path: Path, label: str) -> str: + """Return one required UTF-8 repository file or fail with a useful contract.""" + assert path.is_file(), f"{label} is missing: {path}" + return path.read_text(encoding="utf-8") + + +def _workflow_call_block(workflow: str) -> str: + """Return the top-level event block from one GitHub Actions workflow.""" + match = re.search(r"(?ms)^on:\n(?P.*?)(?=^\S|\Z)", workflow) + assert match is not None, "workflow must declare a top-level on block" + return match.group("body") + + +def _job_block(workflow: str, job_name: str) -> str: + """Return one exact top-level job body from a workflow source file.""" + jobs_match = re.search(r"(?ms)^jobs:\n(?P.*)\Z", workflow) + assert jobs_match is not None, "workflow must declare jobs" + jobs_body = jobs_match.group("body") + job_match = re.search( + rf"(?ms)^ {re.escape(job_name)}:\n(?P.*?)(?=^ [A-Za-z0-9_-]+:\n|\Z)", + jobs_body, + ) + assert job_match is not None, f"missing workflow job: {job_name}" + return job_match.group(0) + + +def test_reusable_workflow_is_call_only_with_explicit_handoff_inputs() -> None: + """Accept sealed evidence only through an explicit reusable-workflow contract.""" + workflow = _required_text(REUSABLE_WORKFLOW, "reusable attestation workflow") + event_block = _workflow_call_block(workflow) + + assert re.search(r"(?m)^ workflow_call:\s*$", event_block) + for forbidden_trigger in ( + "pull_request", + "push", + "schedule", + "workflow_dispatch", + "repository_dispatch", + ): + assert not re.search( + rf"(?m)^ {re.escape(forbidden_trigger)}:\s*$", + event_block, + ) + + required_inputs = { + "source_repository", + "source_sha", + "evidence_artifact_id", + "evidence_artifact_name", + "evidence_artifact_digest", + "wheel_filename", + "wheel_sha256", + "wheel_sbom_filename", + "wheel_sbom_sha256", + "sdist_filename", + "sdist_sha256", + "sdist_sbom_filename", + "sdist_sbom_sha256", + "source_identity_sha256", + "checksum_sha256", + "predicate_type", + "cyclonedx_schema", + } + for input_name in required_inputs: + input_match = re.search( + rf"(?ms)^ {re.escape(input_name)}:\n" + rf"(?P(?:^ .*\n)+)", + event_block, + ) + assert input_match is not None, f"missing workflow input: {input_name}" + input_body = input_match.group("body") + assert re.search(r"(?m)^ required: true\s*$", input_body) + assert re.search(r"(?m)^ type: string\s*$", input_body) + + +def test_artifact_intake_verifies_exact_immutable_same_run_metadata() -> None: + """Fail closed on artifact identity before the credentialed attestation job.""" + workflow = _required_text(REUSABLE_WORKFLOW, "reusable attestation workflow") + intake = _job_block(workflow, "verify-evidence-artifact") + + assert "permissions:" in intake + assert "actions: read" in intake + assert "contents: read" in intake + assert "id-token: write" not in intake + assert "attestations: write" not in intake + assert "artifact-metadata: write" not in intake + assert "${{ inputs.evidence_artifact_id }}" in intake + assert "${{ inputs.evidence_artifact_name }}" in intake + assert "${{ inputs.evidence_artifact_digest }}" in intake + assert "${{ inputs.source_repository }}" in intake + assert "GITHUB_RUN_ID" in intake + assert "/actions/artifacts/" in intake + assert ".workflow_run.id" in intake + assert ".expired" in intake + assert DOWNLOAD_ACTION_PIN in intake + assert "artifact-ids: ${{ inputs.evidence_artifact_id }}" in intake + + +def test_credentialed_job_uses_exact_permissions_and_immutable_trusted_source() -> None: + """Keep signing authority separate from caller-controlled source and credentials.""" + workflow = _required_text(REUSABLE_WORKFLOW, "reusable attestation workflow") + signer = _job_block(workflow, "attest-exact-artifacts") + + assert ATTEST_ACTION_PIN in signer + assert CHECKOUT_ACTION_PIN in workflow + assert workflow.count("repository: ${{ job.workflow_repository }}") >= 2 + assert workflow.count("ref: ${{ job.workflow_sha }}") >= 2 + assert workflow.count("persist-credentials: false") >= 2 + assert "needs: verify-evidence-artifact" in signer + assert "contents: read" in signer + assert "id-token: write" in signer + assert "attestations: write" in signer + assert "artifact-metadata: write" in signer + assert "actions: read" not in signer + + for forbidden_permission in ( + "actions: write", + "contents: write", + "issues: write", + "packages: write", + "pull-requests: write", + "security-events: write", + ): + assert forbidden_permission not in workflow + + assert DOWNLOAD_ACTION_PIN in signer + assert "artifact-ids: ${{ inputs.evidence_artifact_id }}" in signer + assert "repository: ${{ github.repository }}" not in workflow + assert "ref: ${{ inputs.source_sha }}" not in workflow + assert "secrets: inherit" not in workflow + assert "COPILOT_GITHUB_TOKEN" not in workflow + assert "NVIDIA_NIM_API_KEY" not in workflow + + +def test_verifier_is_data_only_and_workflow_never_executes_downloaded_evidence() -> None: + """Treat every caller artifact as inert bounded data before attestation.""" + workflow = _required_text(REUSABLE_WORKFLOW, "reusable attestation workflow") + verifier = _required_text(VERIFIER, "sealed-evidence verifier") + + assert workflow.count("verify_exact_artifact_sbom_handoff.py") >= 2 + assert "--source-repository" in workflow + assert "--source-sha" in workflow + assert "--evidence-root" in workflow + assert "--output-manifest" in workflow + assert "subprocess" not in verifier + assert "os.system" not in verifier + assert "exec(" not in verifier + assert "eval(" not in verifier + assert "importlib" not in verifier + assert "zipfile" not in verifier + assert "tarfile" not in verifier + + for unsafe_command in ( + "pip install", + "python -m build", + "pytest", + "npm ", + "cargo ", + "chmod +x", + "source ", + ): + assert not re.search( + rf"(?m)^\s*{re.escape(unsafe_command)}", + workflow, + ) + + +def test_workflow_attests_each_exact_distribution_and_exports_offline_evidence() -> None: + """Bind one CycloneDX predicate to each exact distribution and preserve bundles.""" + workflow = _required_text(REUSABLE_WORKFLOW, "reusable attestation workflow") + signer = _job_block(workflow, "attest-exact-artifacts") + + assert signer.count(ATTEST_ACTION_PIN) == 2 + assert signer.count("sbom-path:") == 2 + assert signer.count("subject-name:") == 2 + assert signer.count("subject-digest:") == 2 + assert "predicate-type" in signer + assert "bundle-path" in signer + assert "gh attestation verify" in signer + assert "--signer-repo" in signer + assert "--signer-workflow" in signer + assert "--predicate-type" in signer + assert "gh attestation trusted-root" in signer + assert UPLOAD_ACTION_PIN in signer + assert "offline" in signer.lower() + + +def test_doctoring_records_claim_boundary_recovery_and_primary_sources() -> None: + """Require buyer-readable operations, rollback, nonclaims, and APA 7 evidence.""" + doctoring = _required_text(DOCTORING, "SBOM attestation doctoring") + + for required_section in ( + "## Trust boundary", + "## Exact-head lifecycle", + "## Offline verification", + "## Incident recovery and rollback", + "## Claims deliberately not made", + "## References", + ): + assert required_section in doctoring + + assert "SLSA Build Lx (v1.2)" in doctoring + assert "59d89421af93a897026c735860bf21b6eb4f7b26" in doctoring + assert "CycloneDX specification 1.7" in doctoring + assert "SLSA specification version 1.2" in doctoring + assert "Using artifact attestations" in doctoring diff --git a/tests/test_verify_exact_artifact_sbom_handoff.py b/tests/test_verify_exact_artifact_sbom_handoff.py new file mode 100644 index 000000000..2c15608da --- /dev/null +++ b/tests/test_verify_exact_artifact_sbom_handoff.py @@ -0,0 +1,461 @@ +"""Behavior and hostile-input tests for exact artifact/SBOM handoff verification.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path + +import pytest + +from scripts.ci import verify_exact_artifact_sbom_handoff as verifier + +SCHEMA = "https://cyclonedx.org/schema/bom-1.7.schema.json" +PREDICATE = "https://cyclonedx.org/bom" + + +def _digest(path: Path) -> str: + """Return one fixture file's SHA-256 digest.""" + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _sbom(name: str, digest: str) -> dict[str, object]: + """Return the minimum valid CycloneDX root-component fixture.""" + return { + "$schema": SCHEMA, + "bomFormat": "CycloneDX", + "specVersion": "1.7", + "metadata": { + "component": { + "type": "file", + "name": name, + "hashes": [{"alg": "SHA-256", "content": digest}], + } + }, + } + + +def _write_json(path: Path, value: object) -> None: + """Write deterministic fixture JSON.""" + path.write_text( + json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + + +def _identity(arguments: argparse.Namespace) -> dict[str, object]: + """Return the exact identity document expected by the verifier.""" + return { + "schema_version": "1.0", + "source_repository": arguments.source_repository, + "source_sha": arguments.source_sha, + "evidence_artifact_name": arguments.evidence_artifact_name, + "evidence_artifact_digest": arguments.evidence_artifact_digest, + "predicate_type": arguments.predicate_type, + "cyclonedx_schema": arguments.cyclonedx_schema, + "artifacts": { + "wheel": { + "filename": arguments.wheel_filename, + "sha256": arguments.wheel_sha256, + "sbom_filename": arguments.wheel_sbom_filename, + "sbom_sha256": arguments.wheel_sbom_sha256, + }, + "sdist": { + "filename": arguments.sdist_filename, + "sha256": arguments.sdist_sha256, + "sbom_filename": arguments.sdist_sbom_filename, + "sbom_sha256": arguments.sdist_sbom_sha256, + }, + }, + } + + +def _rewrite_checksums( + root: Path, + arguments: argparse.Namespace, + *, + entries: dict[str, str] | None = None, + sort_entries: bool = True, +) -> None: + """Rewrite and externally reseal the checksum control file.""" + values = entries or { + arguments.wheel_filename: arguments.wheel_sha256, + arguments.wheel_sbom_filename: arguments.wheel_sbom_sha256, + arguments.sdist_filename: arguments.sdist_sha256, + arguments.sdist_sbom_filename: arguments.sdist_sbom_sha256, + "source-identity.json": arguments.source_identity_sha256, + } + names = sorted(values) if sort_entries else list(values) + (root / "checksums.sha256").write_text( + "".join(f"{values[name]} {name}\n" for name in names), + encoding="utf-8", + ) + arguments.checksum_sha256 = _digest(root / "checksums.sha256") + + +def _valid_handoff(tmp_path: Path) -> argparse.Namespace: + """Create one complete exact six-file handoff and its CLI arguments.""" + root = tmp_path / "evidence" + root.mkdir(parents=True) + wheel = root / "example-1.0.0-py3-none-any.whl" + sdist = root / "example-1.0.0.tar.gz" + wheel.write_bytes(b"wheel-bytes\x00") + sdist.write_bytes(b"sdist-bytes\xff") + wheel_sha = _digest(wheel) + sdist_sha = _digest(sdist) + wheel_sbom = root / "example-wheel.cdx.json" + sdist_sbom = root / "example-sdist.cdx.json" + _write_json(wheel_sbom, _sbom(wheel.name, wheel_sha)) + _write_json(sdist_sbom, _sbom(sdist.name, sdist_sha)) + + arguments = argparse.Namespace( + source_repository="ContextualWisdomLab/example", + source_sha="a" * 40, + evidence_artifact_name="release-evidence", + evidence_artifact_digest="sha256:" + ("b" * 64), + evidence_root=str(root), + wheel_filename=wheel.name, + wheel_sha256=wheel_sha, + wheel_sbom_filename=wheel_sbom.name, + wheel_sbom_sha256=_digest(wheel_sbom), + sdist_filename=sdist.name, + sdist_sha256=sdist_sha, + sdist_sbom_filename=sdist_sbom.name, + sdist_sbom_sha256=_digest(sdist_sbom), + source_identity_sha256="", + checksum_sha256="", + predicate_type=PREDICATE, + cyclonedx_schema=SCHEMA, + output_manifest=str(tmp_path / "verified.json"), + ) + _write_json(root / "source-identity.json", _identity(arguments)) + arguments.source_identity_sha256 = _digest(root / "source-identity.json") + _rewrite_checksums(root, arguments) + return arguments + + +def _reseal_json_member( + arguments: argparse.Namespace, + filename: str, + value: object, +) -> None: + """Rewrite one JSON member while preserving every outer digest binding.""" + root = Path(arguments.evidence_root) + _write_json(root / filename, value) + if filename == arguments.wheel_sbom_filename: + arguments.wheel_sbom_sha256 = _digest(root / filename) + elif filename == arguments.sdist_sbom_filename: + arguments.sdist_sbom_sha256 = _digest(root / filename) + _write_json(root / "source-identity.json", _identity(arguments)) + arguments.source_identity_sha256 = _digest(root / "source-identity.json") + _rewrite_checksums(root, arguments) + + +def test_valid_handoff_is_verified_and_manifest_is_deterministic(tmp_path: Path) -> None: + """Verify the happy path and deterministic sorted output contract.""" + arguments = _valid_handoff(tmp_path) + manifest = verifier.verify(arguments) + output = Path(arguments.output_manifest) + + assert manifest["result"] == "PASS" + assert len(manifest["files"]) == 6 + assert json.loads(output.read_text(encoding="utf-8")) == manifest + assert output.read_text(encoding="utf-8").endswith("\n") + + +def test_main_prints_success_and_returns_zero(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Exercise the public command-line success entrypoint.""" + arguments = _valid_handoff(tmp_path) + argv: list[str] = [] + for name, value in vars(arguments).items(): + argv.extend(("--" + name.replace("_", "-"), str(value))) + + assert verifier.main(argv) == 0 + assert "6 files" in capsys.readouterr().out + + +@pytest.mark.parametrize( + ("attribute", "value", "message"), + [ + ("source_repository", "not-a-repository", "owner/name"), + ("source_sha", "A" * 40, "lowercase 40-character"), + ("evidence_artifact_digest", "sha256:nope", "sha256:"), + ("wheel_sha256", "0" * 63, "wheel SHA-256"), + ], +) +def test_invalid_external_identifiers_fail_closed( + tmp_path: Path, attribute: str, value: str, message: str +) -> None: + """Reject malformed repository, source, artifact, and file digests.""" + arguments = _valid_handoff(tmp_path) + setattr(arguments, attribute, value) + with pytest.raises(verifier.EvidenceError, match=message): + verifier.verify(arguments) + + +@pytest.mark.parametrize("filename", ["", ".", "..", "../escape.whl", "a\\b.whl", "a\x00b.whl"]) +def test_unsafe_filenames_are_rejected(tmp_path: Path, filename: str) -> None: + """Keep every evidence member at one non-hostile root-level filename.""" + arguments = _valid_handoff(tmp_path) + arguments.wheel_filename = filename + with pytest.raises(verifier.EvidenceError, match="filename"): + verifier.verify(arguments) + + +def test_duplicate_expected_filenames_are_rejected(tmp_path: Path) -> None: + """Require six distinct semantic evidence members.""" + arguments = _valid_handoff(tmp_path) + arguments.sdist_filename = arguments.wheel_filename + with pytest.raises(verifier.EvidenceError, match="distinct"): + verifier.verify(arguments) + + +@pytest.mark.parametrize("kind", ["missing", "file", "symlink"]) +def test_evidence_root_must_be_a_real_directory(tmp_path: Path, kind: str) -> None: + """Reject absent, regular-file, and symlink roots.""" + arguments = _valid_handoff(tmp_path) + target = tmp_path / "bad-root" + if kind == "file": + target.write_text("not a directory", encoding="utf-8") + elif kind == "symlink": + target.symlink_to(Path(arguments.evidence_root), target_is_directory=True) + arguments.evidence_root = str(target) + with pytest.raises(verifier.EvidenceError, match="evidence root"): + verifier.verify(arguments) + + +def test_extra_missing_and_nonregular_members_fail_cardinality(tmp_path: Path) -> None: + """Reject extras, omissions, directories, and symlinks in the sealed root.""" + arguments = _valid_handoff(tmp_path) + root = Path(arguments.evidence_root) + (root / "extra.txt").write_text("extra", encoding="utf-8") + with pytest.raises(verifier.EvidenceError, match="cardinality"): + verifier.verify(arguments) + (root / "extra.txt").unlink() + (root / arguments.wheel_filename).unlink() + with pytest.raises(verifier.EvidenceError, match="cardinality"): + verifier.verify(arguments) + + arguments = _valid_handoff(tmp_path / "again") + root = Path(arguments.evidence_root) + (root / arguments.wheel_filename).unlink() + (root / arguments.wheel_filename).mkdir() + with pytest.raises(verifier.EvidenceError, match="non-regular"): + verifier.verify(arguments) + + arguments = _valid_handoff(tmp_path / "third") + root = Path(arguments.evidence_root) + target = root / arguments.sdist_filename + target.unlink() + target.symlink_to(arguments.wheel_filename) + with pytest.raises(verifier.EvidenceError, match="non-regular"): + verifier.verify(arguments) + + +def test_distribution_digest_mismatch_fails_before_semantic_parsing(tmp_path: Path) -> None: + """Reject changed bytes even when filenames and control files are unchanged.""" + arguments = _valid_handoff(tmp_path) + Path(arguments.evidence_root, arguments.wheel_filename).write_bytes(b"tampered") + with pytest.raises(verifier.EvidenceError, match="digest mismatch"): + verifier.verify(arguments) + + +@pytest.mark.parametrize( + "payload", + [ + "not canonical\n", + ("0" * 64) + " duplicate\n" + ("1" * 64) + " duplicate\n", + ], +) +def test_malformed_or_duplicate_checksum_lines_are_rejected( + tmp_path: Path, payload: str +) -> None: + """Reject malformed and duplicate checksum records after external resealing.""" + arguments = _valid_handoff(tmp_path) + checksum = Path(arguments.evidence_root, "checksums.sha256") + checksum.write_text(payload, encoding="utf-8") + arguments.checksum_sha256 = _digest(checksum) + with pytest.raises(verifier.EvidenceError, match="checksum"): + verifier.verify(arguments) + + +def test_unsorted_wrong_set_and_wrong_value_checksums_are_rejected(tmp_path: Path) -> None: + """Bind exactly the other five evidence files in canonical order and value.""" + arguments = _valid_handoff(tmp_path) + root = Path(arguments.evidence_root) + values = { + arguments.wheel_filename: arguments.wheel_sha256, + arguments.wheel_sbom_filename: arguments.wheel_sbom_sha256, + arguments.sdist_filename: arguments.sdist_sha256, + arguments.sdist_sbom_filename: arguments.sdist_sbom_sha256, + "source-identity.json": arguments.source_identity_sha256, + } + reversed_values = dict(reversed(list(sorted(values.items())))) + _rewrite_checksums(root, arguments, entries=reversed_values, sort_entries=False) + with pytest.raises(verifier.EvidenceError, match="sorted"): + verifier.verify(arguments) + + values.pop(arguments.sdist_sbom_filename) + _rewrite_checksums(root, arguments, entries=values) + with pytest.raises(verifier.EvidenceError, match="exactly"): + verifier.verify(arguments) + + values[arguments.sdist_sbom_filename] = arguments.sdist_sbom_sha256 + values[arguments.wheel_filename] = "f" * 64 + _rewrite_checksums(root, arguments, entries=values) + with pytest.raises(verifier.EvidenceError, match="handoff mismatch"): + verifier.verify(arguments) + + +def test_source_identity_must_be_an_exact_object(tmp_path: Path) -> None: + """Reject non-object and semantically mismatched source identities.""" + arguments = _valid_handoff(tmp_path) + root = Path(arguments.evidence_root) + _write_json(root / "source-identity.json", []) + arguments.source_identity_sha256 = _digest(root / "source-identity.json") + _rewrite_checksums(root, arguments) + with pytest.raises(verifier.EvidenceError, match="JSON object"): + verifier.verify(arguments) + + identity = _identity(arguments) + identity["source_sha"] = "c" * 40 + _write_json(root / "source-identity.json", identity) + arguments.source_identity_sha256 = _digest(root / "source-identity.json") + _rewrite_checksums(root, arguments) + with pytest.raises(verifier.EvidenceError, match="exactly match"): + verifier.verify(arguments) + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + (lambda value: [], "JSON object"), + (lambda value: {**value, "$schema": "wrong"}, "unexpected CycloneDX schema"), + (lambda value: {**value, "bomFormat": "SPDX"}, "specification 1.7"), + (lambda value: {**value, "specVersion": "1.6"}, "specification 1.7"), + (lambda value: {**value, "metadata": {}}, "root component"), + ( + lambda value: { + **value, + "metadata": {"component": {"name": "wrong", "hashes": []}}, + }, + "root component", + ), + ( + lambda value: { + **value, + "metadata": { + "component": { + "name": value["metadata"]["component"]["name"], + "hashes": [], + } + }, + }, + "not bound", + ), + ], +) +def test_cyclonedx_semantics_fail_closed( + tmp_path: Path, mutation: object, message: str +) -> None: + """Reject the wrong schema, version, root component, or subject hash.""" + arguments = _valid_handoff(tmp_path) + root = Path(arguments.evidence_root) + original = json.loads((root / arguments.wheel_sbom_filename).read_text(encoding="utf-8")) + altered = mutation(original) # type: ignore[operator] + _reseal_json_member(arguments, arguments.wheel_sbom_filename, altered) + with pytest.raises(verifier.EvidenceError, match=message): + verifier.verify(arguments) + + +def test_strict_json_rejects_duplicate_keys_bad_utf8_and_oversize( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Exercise strict bounded JSON parsing boundaries directly.""" + duplicate = tmp_path / "duplicate.json" + duplicate.write_text('{"a":1,"a":2}', encoding="utf-8") + with pytest.raises(verifier.EvidenceError, match="duplicate"): + verifier._load_json(duplicate) + + malformed = tmp_path / "malformed.json" + malformed.write_text("{", encoding="utf-8") + with pytest.raises(verifier.EvidenceError, match="invalid JSON"): + verifier._load_json(malformed) + + bad_utf8 = tmp_path / "bad.json" + bad_utf8.write_bytes(b"\xff") + with pytest.raises(verifier.EvidenceError, match="UTF-8"): + verifier._load_json(bad_utf8) + + oversized = tmp_path / "oversized.json" + oversized.write_text("{}", encoding="utf-8") + monkeypatch.setattr(Path, "stat", lambda self: argparse.Namespace(st_size=3)) + with pytest.raises(verifier.EvidenceError, match="exceeds"): + verifier._load_json(oversized, maximum_bytes=2) + + +def test_regular_file_and_output_publication_edges( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Cover missing inputs, output symlinks, and temporary cleanup fallback.""" + missing = tmp_path / "missing" + with pytest.raises(verifier.EvidenceError, match="missing"): + verifier._require_regular_file(missing) + + directory = tmp_path / "directory" + directory.mkdir() + with pytest.raises(verifier.EvidenceError, match="regular"): + verifier._require_regular_file(directory) + + output = tmp_path / "output.json" + output.symlink_to(missing) + with pytest.raises(verifier.EvidenceError, match="symlink"): + verifier._atomic_json(output, {"result": "PASS"}) + output.unlink() + + monkeypatch.setattr(os, "replace", lambda source, destination: None) + verifier._atomic_json(output, {"result": "PASS"}) + assert not output.exists() + + +def test_main_converts_validation_errors_to_system_exit(tmp_path: Path) -> None: + """Keep command-line failures compact and free of tracebacks by default.""" + arguments = _valid_handoff(tmp_path) + arguments.source_repository = "bad" + argv: list[str] = [] + for name, value in vars(arguments).items(): + argv.extend(("--" + name.replace("_", "-"), str(value))) + with pytest.raises(SystemExit, match="sealed evidence verification failed"): + verifier.main(argv) + +def test_checksum_control_file_bounds_and_entrypoint_are_covered( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Cover bounded checksum decoding and the real module entrypoint.""" + checksum = tmp_path / "checksums.sha256" + checksum.write_text(("0" * 64) + " payload.bin\n", encoding="utf-8") + monkeypatch.setattr(verifier, "_MAX_CONTROL_BYTES", 4) + with pytest.raises(verifier.EvidenceError, match="size limit"): + verifier._parse_checksums(checksum) + + monkeypatch.setattr(verifier, "_MAX_CONTROL_BYTES", 1024) + checksum.write_bytes(b"\xff") + with pytest.raises(verifier.EvidenceError, match="strict UTF-8"): + verifier._parse_checksums(checksum) + + import runpy + import sys + + arguments = _valid_handoff(tmp_path / "entrypoint") + argv: list[str] = [] + for name, value in vars(arguments).items(): + argv.extend(("--" + name.replace("_", "-"), str(value))) + monkeypatch.setattr(sys, "argv", [str(verifier.__file__), *argv]) + with pytest.raises(SystemExit) as exit_info: + runpy.run_path(str(verifier.__file__), run_name="__main__") + assert exit_info.value.code == 0 + assert "sealed evidence verification passed" in capsys.readouterr().out