Skip to content
23 changes: 22 additions & 1 deletion .github/workflows/exact-artifact-sbom-attestation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
actions: read
contents: read
id-token: write
attestations: write
Expand All @@ -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:
Expand Down Expand Up @@ -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
39 changes: 22 additions & 17 deletions docs/doctoring/exact-artifact-sbom-attestation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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/
3 changes: 1 addition & 2 deletions scripts/ci/verify_exact_artifact_sbom_handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -387,4 +386,4 @@ def main(argv: list[str] | None = None) -> int:


if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main())
27 changes: 27 additions & 0 deletions tests/test_exact_artifact_outer_receipt_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""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


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 "evidence_artifact_digest:" in workflow
assert workflow.count("--evidence-artifact-digest") == 2
4 changes: 2 additions & 2 deletions tests/test_exact_artifact_sbom_attestation_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
21 changes: 18 additions & 3 deletions tests/test_verify_exact_artifact_sbom_handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Loading