From c18d6789f602ed19022a0c21a964b510ce5543a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:00:31 +0900 Subject: [PATCH 1/7] test(release): expose circular outer artifact receipt contract --- ...t_exact_artifact_outer_receipt_contract.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/test_exact_artifact_outer_receipt_contract.py diff --git a/tests/test_exact_artifact_outer_receipt_contract.py b/tests/test_exact_artifact_outer_receipt_contract.py new file mode 100644 index 0000000000..7ba693d210 --- /dev/null +++ b/tests/test_exact_artifact_outer_receipt_contract.py @@ -0,0 +1,28 @@ +"""Regression contract for the exact-artifact outer transport receipt.""" + +from pathlib import Path + +from scripts.ci import verify_exact_artifact_sbom_handoff as verifier + + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +_WORKFLOW = _REPOSITORY_ROOT / ".github/workflows/exact-artifact-sbom-attestation.yml" + + +def test_source_identity_is_constructible_before_github_returns_artifact_digest() -> None: + """Keep the post-upload GitHub digest out of the pre-upload inner identity.""" + source = Path(verifier.__file__).read_text(encoding="utf-8") + + assert '"evidence_artifact_digest": arguments.evidence_artifact_digest' not in source + assert 'parser.add_argument("--evidence-artifact-digest"' not in source + + +def test_outer_artifact_receipt_is_reverified_before_credentialed_signing() -> None: + """Verify the returned artifact receipt twice without moving it into inner bytes.""" + workflow = _WORKFLOW.read_text(encoding="utf-8") + + assert workflow.count("Verify immutable same-run artifact metadata") == 2 + assert workflow.count(".digest == $digest") == 2 + assert workflow.count(".workflow_run.id == $run_id") == 2 + assert workflow.count("--evidence-artifact-digest") == 0 + assert "evidence_artifact_digest:" in workflow From cc58723ffc88dd5d3c3173ca2bfd5c092cc3f26a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:03:03 +0900 Subject: [PATCH 2/7] fix(release): decouple inner identity from outer artifact digest --- scripts/ci/verify_exact_artifact_sbom_handoff.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/ci/verify_exact_artifact_sbom_handoff.py b/scripts/ci/verify_exact_artifact_sbom_handoff.py index f887a436e0..4bc21fe579 100644 --- a/scripts/ci/verify_exact_artifact_sbom_handoff.py +++ b/scripts/ci/verify_exact_artifact_sbom_handoff.py @@ -295,7 +295,6 @@ def verify(arguments: argparse.Namespace) -> dict[str, Any]: "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": { @@ -387,4 +386,4 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file From b56726530a7e54fa37616c0fec3119b2e9c35326 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:04:14 +0900 Subject: [PATCH 3/7] test(release): seal source identity before outer receipt exists --- ...test_verify_exact_artifact_sbom_handoff.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/test_verify_exact_artifact_sbom_handoff.py b/tests/test_verify_exact_artifact_sbom_handoff.py index 2c8f6658d0..192ff20017 100644 --- a/tests/test_verify_exact_artifact_sbom_handoff.py +++ b/tests/test_verify_exact_artifact_sbom_handoff.py @@ -58,13 +58,12 @@ def _write_json(path: Path, value: object) -> None: def _identity(arguments: argparse.Namespace) -> dict[str, object]: - """Return the exact identity document expected by the verifier.""" + """Return the pre-upload 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": { @@ -177,6 +176,22 @@ def test_valid_handoff_is_verified_and_manifest_is_deterministic(tmp_path: Path) assert output.read_text(encoding="utf-8").endswith("\n") +def test_outer_artifact_digest_can_arrive_after_inner_identity_is_sealed( + tmp_path: Path, +) -> None: + """Keep the GitHub upload receipt outside the bytes whose digest it describes.""" + arguments = _valid_handoff(tmp_path) + identity_path = Path(arguments.evidence_root, "source-identity.json") + sealed_identity_digest = _digest(identity_path) + identity = json.loads(identity_path.read_text(encoding="utf-8")) + + assert "evidence_artifact_digest" not in identity + arguments.evidence_artifact_digest = "sha256:" + ("c" * 64) + + verifier.verify(arguments) + assert _digest(identity_path) == sealed_identity_digest + + def test_main_prints_success_and_returns_zero( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: @@ -562,4 +577,4 @@ def test_resealed_unexpected_predicate_is_rejected_before_signing(tmp_path: Path _rewrite_checksums(root, arguments) with pytest.raises(verifier.EvidenceError, match="canonical CycloneDX predicate"): - verifier.verify(arguments) + verifier.verify(arguments) \ No newline at end of file From 2d13615b0c1eb948b58e6b0af5d65890d660dc03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:05:38 +0900 Subject: [PATCH 4/7] fix(release): reverify outer artifact receipt before signing --- .../exact-artifact-sbom-attestation.yml | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/exact-artifact-sbom-attestation.yml b/.github/workflows/exact-artifact-sbom-attestation.yml index f7f04a40b1..b038c5478e 100644 --- a/.github/workflows/exact-artifact-sbom-attestation.yml +++ b/.github/workflows/exact-artifact-sbom-attestation.yml @@ -163,6 +163,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: + actions: read contents: read id-token: write attestations: write @@ -189,6 +190,26 @@ jobs: 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 sealed evidence without executing it uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -379,4 +400,4 @@ jobs: name: exact-artifact-sbom-offline-verification path: offline-attestation-evidence if-no-files-found: error - retention-days: 90 + retention-days: 90 \ No newline at end of file From 31a77608be718b96a6b8cfda321c6e0e75652862 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:05:51 +0900 Subject: [PATCH 5/7] test(release): keep digest as outer receipt input only --- tests/test_exact_artifact_outer_receipt_contract.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_exact_artifact_outer_receipt_contract.py b/tests/test_exact_artifact_outer_receipt_contract.py index 7ba693d210..861ab55044 100644 --- a/tests/test_exact_artifact_outer_receipt_contract.py +++ b/tests/test_exact_artifact_outer_receipt_contract.py @@ -14,7 +14,6 @@ def test_source_identity_is_constructible_before_github_returns_artifact_digest( source = Path(verifier.__file__).read_text(encoding="utf-8") assert '"evidence_artifact_digest": arguments.evidence_artifact_digest' not in source - assert 'parser.add_argument("--evidence-artifact-digest"' not in source def test_outer_artifact_receipt_is_reverified_before_credentialed_signing() -> None: @@ -24,5 +23,5 @@ def test_outer_artifact_receipt_is_reverified_before_credentialed_signing() -> N assert workflow.count("Verify immutable same-run artifact metadata") == 2 assert workflow.count(".digest == $digest") == 2 assert workflow.count(".workflow_run.id == $run_id") == 2 - assert workflow.count("--evidence-artifact-digest") == 0 assert "evidence_artifact_digest:" in workflow + assert workflow.count("--evidence-artifact-digest") == 2 From be107f2e19875147a04a83c4712b9af07dc859cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:07:08 +0900 Subject: [PATCH 6/7] test(release): require read-only receipt recheck before signing --- tests/test_exact_artifact_sbom_attestation_contract.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_exact_artifact_sbom_attestation_contract.py b/tests/test_exact_artifact_sbom_attestation_contract.py index 1cb2569070..08fa9b1460 100644 --- a/tests/test_exact_artifact_sbom_attestation_contract.py +++ b/tests/test_exact_artifact_sbom_attestation_contract.py @@ -160,11 +160,11 @@ def test_credentialed_job_uses_exact_permissions_and_immutable_trusted_source() assert "${{ job.workflow_sha }}" not in workflow assert workflow.count("persist-credentials: false") >= 2 assert "needs: verify-evidence-artifact" in signer + assert "actions: read" 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", @@ -274,4 +274,4 @@ def test_doctoring_records_claim_boundary_recovery_and_primary_sources() -> None 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 + assert "Using artifact attestations" in doctoring \ No newline at end of file From fe610c3e5bf8b478fba13b93a89ab1b62b3155b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:07:52 +0900 Subject: [PATCH 7/7] docs(release): separate inner identity from outer transport receipt --- .../exact-artifact-sbom-attestation.md | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/docs/doctoring/exact-artifact-sbom-attestation.md b/docs/doctoring/exact-artifact-sbom-attestation.md index 88b63ce21a..71a31e9337 100644 --- a/docs/doctoring/exact-artifact-sbom-attestation.md +++ b/docs/doctoring/exact-artifact-sbom-attestation.md @@ -4,14 +4,14 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include; ## 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 organization-owned reusable workflow signs only an already sealed, same-run evidence artifact. The caller seals its inner source/artifact identity before upload, then supplies the immutable GitHub Actions artifact ID, name, and digest returned by the upload as an outer transport receipt. The trusted workflow independently verifies that receipt 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. +2. `attest-exact-artifacts` runs only after the first job succeeds and receives `actions: read`, `id-token: write`, `attestations: write`, `artifact-metadata: write`, and `contents: read`. Before downloading or signing, it independently re-fetches the same artifact ID and rechecks the outer name, digest, workflow-run ID, expiry state, repository, and source SHA. It then downloads the same immutable artifact ID, repeats the data-only inner 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. +Both jobs load the verifier from `ContextualWisdomLab/.github` at `${{ github.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: @@ -22,26 +22,31 @@ The handoff contains exactly: - `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. RFC 8259 forbids NaN and Infinity as JSON numbers (Bray, 2017); the verifier therefore rejects `parse_constant` values instead of accepting Python's default extension. 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. +The inner `source-identity.json` binds repository, exact source SHA, evidence artifact name, predicate/schema, wheel/sdist filenames and SHA-256 values, and both SBOM filenames and SHA-256 values. It deliberately does **not** contain the GitHub Actions artifact digest. That digest does not exist until after the six-file artifact is uploaded, so putting it inside one of the uploaded members would create a self-referential fixed-point requirement. `checksums.sha256` binds the other five files, and externally supplied file digests bind all six files including the checksum file itself. The post-upload artifact ID/name/digest remain an outer receipt and are verified against GitHub Actions metadata in both the read-only intake job and the credentialed signer job. + +Each SBOM is strict RFC 8259 JSON: duplicate names, non-finite numbers, malformed UTF-8, and oversized control data fail closed. RFC 8259 forbids NaN and Infinity as JSON numbers (Bray, 2017); the verifier therefore rejects `parse_constant` values instead of accepting Python's default extension. 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] + B --> C[Caller seals source identity and checksums] + C --> D[Caller uploads exact six-file artifact] + D --> E[GitHub returns artifact ID, name, digest] + E --> F[Read-only outer metadata and inner data verification] + F --> G[Credentialed job rechecks outer receipt] + G --> H[Credentialed job repeats inner verification] + H --> I[Wheel SBOM attestation] + H --> J[Sdist SBOM attestation] + I --> K[Online signer/predicate/source verification] + J --> K + K --> L[Sigstore bundles and trusted root export] + L --> M[README and deterministic SHA256SUMS] + M --> N[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. +Before upload, a caller can construct the entire six-file handoff using its exact `source_repository`, 40-character `source_sha`, artifact name, filenames, file SHA-256 digests, CycloneDX schema URI, and SBOM predicate type. After upload, the caller passes the returned same-run artifact ID and artifact digest to the reusable workflow without rewriting `source-identity.json` or any checksum-bearing member. The workflow rejects a caller repository or source SHA that does not match the live GitHub run context and rejects an outer artifact receipt that does not match GitHub's same-run metadata. 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. @@ -68,7 +73,7 @@ Generate a new trusted root whenever new signed material enters an offline envir 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. +3. Determine whether the defect is in build output, SBOM generation, the sealed handoff, outer receipt verification, trusted inner 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. @@ -103,4 +108,4 @@ Internet Engineering Task Force. (2005). *A universally unique identifier (UUID) 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/ +Sigstore Project. (2024). *Sigstore bundle format*. https://docs.sigstore.dev/about/bundle/ \ No newline at end of file