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..36da6e4eb --- /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-24.04 + 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-24.04 + 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..2d0fe4082 --- /dev/null +++ b/.github/workflows/exact-artifact-sbom-attestation.yml @@ -0,0 +1,330 @@ +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 + env: + SOURCE_REPOSITORY: ${{ inputs.source_repository }} + SOURCE_SHA: ${{ inputs.source_sha }} + EVIDENCE_ARTIFACT_NAME: ${{ inputs.evidence_artifact_name }} + EVIDENCE_ARTIFACT_DIGEST: ${{ inputs.evidence_artifact_digest }} + 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 }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python3 -I trusted-intake/scripts/ci/verify_exact_artifact_sbom_handoff.py \ + --source-repository "$SOURCE_REPOSITORY" \ + --source-sha "$SOURCE_SHA" \ + --evidence-artifact-name "$EVIDENCE_ARTIFACT_NAME" \ + --evidence-artifact-digest "$EVIDENCE_ARTIFACT_DIGEST" \ + --evidence-root sealed-evidence \ + --wheel-filename "$WHEEL_FILENAME" \ + --wheel-sha256 "$WHEEL_SHA256" \ + --wheel-sbom-filename "$WHEEL_SBOM_FILENAME" \ + --wheel-sbom-sha256 "$WHEEL_SBOM_SHA256" \ + --sdist-filename "$SDIST_FILENAME" \ + --sdist-sha256 "$SDIST_SHA256" \ + --sdist-sbom-filename "$SDIST_SBOM_FILENAME" \ + --sdist-sbom-sha256 "$SDIST_SBOM_SHA256" \ + --source-identity-sha256 "$SOURCE_IDENTITY_SHA256" \ + --checksum-sha256 "$CHECKSUM_SHA256" \ + --predicate-type "$PREDICATE_TYPE" \ + --cyclonedx-schema "$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 + env: + SOURCE_REPOSITORY: ${{ inputs.source_repository }} + SOURCE_SHA: ${{ inputs.source_sha }} + EVIDENCE_ARTIFACT_NAME: ${{ inputs.evidence_artifact_name }} + EVIDENCE_ARTIFACT_DIGEST: ${{ inputs.evidence_artifact_digest }} + 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 }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python3 -I trusted-signer/scripts/ci/verify_exact_artifact_sbom_handoff.py \ + --source-repository "$SOURCE_REPOSITORY" \ + --source-sha "$SOURCE_SHA" \ + --evidence-artifact-name "$EVIDENCE_ARTIFACT_NAME" \ + --evidence-artifact-digest "$EVIDENCE_ARTIFACT_DIGEST" \ + --evidence-root sealed-evidence \ + --wheel-filename "$WHEEL_FILENAME" \ + --wheel-sha256 "$WHEEL_SHA256" \ + --wheel-sbom-filename "$WHEEL_SBOM_FILENAME" \ + --wheel-sbom-sha256 "$WHEEL_SBOM_SHA256" \ + --sdist-filename "$SDIST_FILENAME" \ + --sdist-sha256 "$SDIST_SHA256" \ + --sdist-sbom-filename "$SDIST_SBOM_FILENAME" \ + --sdist-sbom-sha256 "$SDIST_SBOM_SHA256" \ + --source-identity-sha256 "$SOURCE_IDENTITY_SHA256" \ + --checksum-sha256 "$CHECKSUM_SHA256" \ + --predicate-type "$PREDICATE_TYPE" \ + --cyclonedx-schema "$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_FILENAME: ${{ inputs.wheel_filename }} + SDIST_FILENAME: ${{ inputs.sdist_filename }} + 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 "$WHEEL_FILENAME" "$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/${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/${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" + install -m 0444 "${RUNNER_TEMP}/verified-signer.json" \ + offline-attestation-evidence/verified-handoff.json + cat > offline-attestation-evidence/README.md <<'EOF' + # Offline SBOM attestation verification + + This directory is data-only release evidence. It contains the exact + wheel and source-distribution Sigstore bundles, the GitHub trusted + root captured during signing, and the independently verified handoff + manifest. Verify `SHA256SUMS` before using any member. + + Run `gh attestation verify` for each distribution with its matching + bundle, `trusted_root.jsonl`, exact source repository and SHA, exact + signer repository/workflow, and expected predicate type. A successful + signature does not prove that the SBOM is complete or that the + software is vulnerability-free. + EOF + { + printf '\n## Exact signed identity\n\n' + printf -- '- Source repository: `%s`\n' "$SOURCE_REPOSITORY" + printf -- '- Source SHA: `%s`\n' "$SOURCE_SHA" + printf -- '- Signer repository: `%s`\n' "$SIGNER_REPOSITORY" + printf -- '- Signer workflow: `%s`\n' "$signer_workflow" + printf -- '- Predicate type: `%s`\n' "$PREDICATE_TYPE" + printf -- '- Wheel: `%s`\n' "$WHEEL_FILENAME" + printf -- '- Source distribution: `%s`\n' "$SDIST_FILENAME" + } >> offline-attestation-evidence/README.md + ( + cd offline-attestation-evidence + LC_ALL=C find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%f\n' \ + | LC_ALL=C sort \ + | while IFS= read -r evidence_file; do + sha256sum "$evidence_file" + done > SHA256SUMS + ) + chmod 0444 \ + offline-attestation-evidence/README.md \ + offline-attestation-evidence/SHA256SUMS \ + offline-attestation-evidence/trusted_root.jsonl \ + 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..ce265a84a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,11 @@ 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 +- Hardened exact-artifact SBOM verification with strict finite RFC 8259 JSON, integer CycloneDX document versions, deterministic UUIDv5 subject identities, exact filename properties and single SHA-256 root bindings, environment-only shell input transfer, pinned Ubuntu 24.04 quality runners, and checksum-sealed beginner-readable offline evidence. - Bound both trusted-uv quality jobs to `github.event.pull_request.head.sha` and added a permanent two-checkout regression contract so exact-head compatibility, coverage, docstring, and compilation claims cannot silently measure GitHub's generated pull-request merge revision. - Made Strix treat only a single LiteLLM provider-error line containing NVIDIA NIM context and model-catalog 404 evidence as cross-model fallback evidence, rejecting cross-line signal assembly and provider-like target source literals; moved the public default to Nemotron 3 Super 120B and added a second NVIDIA hosted candidate before GitHub Models without neutralizing reported vulnerabilities. diff --git a/docs/doctoring/exact-artifact-sbom-attestation.md b/docs/doctoring/exact-artifact-sbom-attestation.md new file mode 100644 index 000000000..3012c78c7 --- /dev/null +++ b/docs/doctoring/exact-artifact-sbom-attestation.md @@ -0,0 +1,100 @@ +# 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. Caller inputs enter shell steps only through explicitly named environment variables; they are never interpolated directly into a shell program. + +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 is strict RFC 8259 JSON: duplicate names, non-finite numbers, malformed UTF-8, and oversized control data fail closed. Each CycloneDX document must have integer document version `1`, a deterministic RFC 4122 UUIDv5 serial derived from the exact filename and SHA-256 digest, and one root component of type `file`. That root component must name the exact distribution, carry exactly one `cwl:artifact:filename` property, and contain exactly one canonical SHA-256 hash record with no alternate algorithm or unreviewed fields. + +## 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[README and deterministic SHA256SUMS] + J --> K[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`, the deterministic verified-handoff manifest, a beginner-readable `README.md`, and a lexicographically ordered `SHA256SUMS` covering every offline-evidence file except the checksum manifest itself. Verify `SHA256SUMS` before passing any member to GitHub CLI. + +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, attestation bundles, README, trusted root, and checksum manifest. +3. Determine whether the defect is in build output, SBOM generation, the sealed handoff, trusted verification, signing, or offline packaging. +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 L3 (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 + +Internet Engineering Task Force. (2005). *A universally unique identifier (UUID) URN namespace* (RFC 4122). RFC Editor. https://www.rfc-editor.org/rfc/rfc4122 + +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..e05855828 --- /dev/null +++ b/scripts/ci/verify_exact_artifact_sbom_handoff.py @@ -0,0 +1,368 @@ +#!/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 +import uuid +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" +_FILENAME_PROPERTY = "cwl:artifact:filename" + + +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 _reject_nonfinite_constant(value: str) -> Any: + """Reject JSON extensions for NaN and positive or negative infinity.""" + raise EvidenceError(f"non-finite JSON number is forbidden: {value}") + + +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, + parse_constant=_reject_nonfinite_constant, + ) + 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 _cyclonedx_serial_number(subject_name: str, subject_sha256: str) -> str: + """Return the canonical UUIDv5 serial number for one exact distribution.""" + identity = f"urn:cwl:artifact:{subject_name}:sha256:{subject_sha256}" + return f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, identity)}" + + +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") + version = document.get("version") + if (type(version), version) != (int, 1): + raise EvidenceError(f"{path.name} document version must be the integer 1") + expected_serial = _cyclonedx_serial_number(subject_name, subject_sha256) + if document.get("serialNumber") != expected_serial: + raise EvidenceError(f"{path.name} serial number does not match the exact subject") + + 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}") + if component.get("type") != "file": + raise EvidenceError(f"{path.name} root component type must be file") + + expected_property = {"name": _FILENAME_PROPERTY, "value": subject_name} + if component.get("properties") != [expected_property]: + raise EvidenceError(f"{path.name} root component filename property is not exact") + + expected_hash = {"alg": "SHA-256", "content": subject_sha256} + if component.get("hashes") != [expected_hash]: + raise EvidenceError( + f"{path.name} root component must contain one canonical SHA-256 subject hash" + ) + + +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..d007b1758 --- /dev/null +++ b/tests/test_exact_artifact_sbom_attestation_contract.py @@ -0,0 +1,269 @@ +"""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" +) +QUALITY_WORKFLOW = Path( + ".github/workflows/exact-artifact-sbom-attestation-quality.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 _run_blocks(workflow: str) -> list[str]: + """Return every indentation-bounded multiline shell body.""" + lines = workflow.splitlines() + blocks: list[str] = [] + index = 0 + while index < len(lines): + if lines[index] != " run: |": + index += 1 + continue + index += 1 + body: list[str] = [] + while index < len(lines) and ( + lines[index].startswith(" ") or lines[index] == "" + ): + body.append(lines[index]) + index += 1 + blocks.append("\n".join(body)) + return blocks + + +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 + + run_blocks = _run_blocks(workflow) + assert run_blocks, "workflow must declare multiline run blocks" + for block in run_blocks: + assert "${{ inputs." not in block, ( + "caller input must enter shell commands through an environment variable: " + f"{block}" + ) + + 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() + assert "offline-attestation-evidence/README.md" in signer + assert "offline-attestation-evidence/SHA256SUMS" in signer + assert "sha256sum" in signer + + +def test_quality_workflow_pins_supported_runner_images() -> None: + """Keep exact supply-chain evidence on an explicit runner image.""" + workflow = _required_text(QUALITY_WORKFLOW, "attestation quality workflow") + assert "ubuntu-latest" not in workflow + assert workflow.count("runs-on: ubuntu-24.04") == 2 + + +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 "does not claim SLSA Build L3 (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..fea80d82b --- /dev/null +++ b/tests/test_verify_exact_artifact_sbom_handoff.py @@ -0,0 +1,552 @@ +"""Behavior and hostile-input tests for exact artifact/SBOM handoff verification.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import uuid +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 _serial_number(name: str, digest: str) -> str: + """Return the canonical UUIDv5 serial number for one exact subject.""" + identity = f"urn:cwl:artifact:{name}:sha256:{digest}" + return f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, identity)}" + + +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", + "serialNumber": _serial_number(name, digest), + "version": 1, + "metadata": { + "component": { + "type": "file", + "name": name, + "hashes": [{"alg": "SHA-256", "content": digest}], + "properties": [ + {"name": "cwl:artifact:filename", "value": name} + ], + } + }, + } + + +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, "version": "1"}, "document version"), + (lambda value: {**value, "serialNumber": "urn:uuid:wrong"}, "serial number"), + (lambda value: {**value, "metadata": {}}, "root component"), + ( + lambda value: { + **value, + "metadata": { + "component": { + **value["metadata"]["component"], + "name": "wrong", + } + }, + }, + "root component", + ), + ( + lambda value: { + **value, + "metadata": { + "component": { + **value["metadata"]["component"], + "type": "library", + } + }, + }, + "root component type", + ), + ( + lambda value: { + **value, + "metadata": { + "component": { + **value["metadata"]["component"], + "properties": [], + } + }, + }, + "filename property", + ), + ( + lambda value: { + **value, + "metadata": { + "component": { + **value["metadata"]["component"], + "hashes": [], + } + }, + }, + "canonical SHA-256", + ), + ( + lambda value: { + **value, + "metadata": { + "component": { + **value["metadata"]["component"], + "hashes": [ + *value["metadata"]["component"]["hashes"], + {"alg": "SHA-1", "content": "0" * 40}, + ], + } + }, + }, + "canonical SHA-256", + ), + ( + lambda value: { + **value, + "metadata": { + "component": { + **value["metadata"]["component"], + "hashes": [ + { + **value["metadata"]["component"]["hashes"][0], + "unexpected": "field", + } + ], + } + }, + }, + "canonical SHA-256", + ), + ], +) +def test_cyclonedx_semantics_fail_closed( + tmp_path: Path, mutation: object, message: str +) -> None: + """Reject malformed document and exact root-component subject bindings.""" + 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_nonfinite_and_oversize( + tmp_path: Path, +) -> 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) + + for literal in ("NaN", "Infinity", "-Infinity"): + constant = tmp_path / f"{literal.removeprefix('-')}.json" + constant.write_text('{"value":' + literal + "}", encoding="utf-8") + with pytest.raises(verifier.EvidenceError, match="non-finite"): + verifier._load_json(constant) + + oversized = tmp_path / "oversized.json" + oversized.write_text('{"padding":"aaaa"}', encoding="utf-8") + 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