Skip to main content
View Markdown ↗

Copy this page

Select and copy the Markdown below, then paste it into your LLM.

On this page

Producing an audit evidence bundle

An auditor asks: "Show me proof that release v1.4.2 was built from main, ran the SAST scanner, produced an SBOM, didn't use unpinned actions, and was signed by an authorized identity."

The answer should not be "let me forward you some Slack screenshots." It should be a single signed archive: every CI/lock attestation produced by the release pipeline, plus the policy that gated promotion, plus the verification report, plus a manifest telling the auditor which file answers which question. This tutorial walks through producing exactly that.

Everything below composes primitives already exercised in the supply-chain defense tutorial, the five-step GitHub Actions pipeline, the container signing tutorial, and the release promotion gate. No new CI/lock features. Just careful packaging.

What goes in the bundle

SectionWhat's in itComes from
pipeline/The 5 per-step DSSE envelopes plus their products (SARIF, SBOM, binary)The 5-step pipeline run
container/The docker-build attestation (with docker, oci, sbom predicates), the OCI tarball, the image SBOMThe container signing run
gate/The build attestation that was the input to cilock verify, plus its companion public keyThe release-promotion-gate run
policy/The signed policy + the public key needed to verify the policy itselfWhatever you used at promotion time
verify-report.txtCaptured stdout/stderr of a fresh cilock verify run against the included attestationsRe-run at bundle-creation time
MANIFEST.mdThe auditor-question table from belowGenerated
audit-bundle.attestation.jsonA signed attestation whose materials are every file abovecilock run --step audit-bundle -- tar czf ...

The last row is the trick. By wrapping the tar step with cilock run, the bundle's contents become the materials of a new attestation. Anyone re-deriving the tarball later can hash it against audit-bundle.attestation.json and prove the archive hasn't been edited since.

Step 1: Collect attestations for a release

In a real release pipeline you'd query Archivista by commit SHA. For demo-cilock we pull artifacts from the prior workflow runs:

- name: Resolve source runs
  id: resolve
  env:
    GH_TOKEN: ${{ github.token }}
  run: |
    PIPELINE_ID=$(gh run list -R ${{ github.repository }} \
      --workflow=github-actions-pipeline.yml \
      --status=success --limit=1 --json databaseId -q '.[0].databaseId')
    CONTAINER_ID=$(gh run list -R ${{ github.repository }} \
      --workflow=sign-and-verify-container.yml \
      --status=success --limit=1 --json databaseId -q '.[0].databaseId')
    GATE_ID=$(gh run list -R ${{ github.repository }} \
      --workflow=release-promotion-gate.yml \
      --status=success --limit=1 --json databaseId -q '.[0].databaseId')
    echo "pipeline-id=$PIPELINE_ID" >> "$GITHUB_OUTPUT"
    echo "container-id=$CONTAINER_ID" >> "$GITHUB_OUTPUT"
    echo "gate-id=$GATE_ID" >> "$GITHUB_OUTPUT"

- name: Download artifacts from each source run
  env:
    GH_TOKEN: ${{ github.token }}
  run: |
    mkdir -p bundle/pipeline bundle/container bundle/gate
    gh run download ${{ steps.resolve.outputs.pipeline-id }} \
      -R ${{ github.repository }} -n pipeline-attestations -D bundle/pipeline
    gh run download ${{ steps.resolve.outputs.container-id }} \
      -R ${{ github.repository }} -n build-evidence       -D bundle/container
    gh run download ${{ steps.resolve.outputs.gate-id }} \
      -R ${{ github.repository }} -n build-bundle         -D bundle/gate

gh run download requires permissions: actions: read on the workflow.

Step 2: Re-run verify against the assembled inputs

Don't trust that the gate ran. Re-run cilock verify from inside the bundling job and capture the output.

The key ID comes from cilock keyid show, which prints it exactly as cilock verify computes it: the SHA-256 of the key re-encoded as PKIX PEM, not of the file's bytes. The block stops if the ID is not 64 hex characters, because an empty ID would produce a policy that trusts no key. set -o pipefail keeps a failed cilock verify from being hidden by tee. A GitHub Actions run: step without shell: bash runs bash -e without pipefail, so the job would otherwise pass with a failing report.

set -o pipefail
cd bundle/gate
KEYID=$(cilock keyid show signing.pub | awk '{print $1}')
printf '%s' "$KEYID" | grep -Eq '^[0-9a-f]{64}

    
    

    
    
    
    
  

 || { echo "no key ID for signing.pub" >&2; exit 1; }
PUBKEY_B64=$(base64 < signing.pub | tr -d '\n')
sed -e "s|__PUBKEY_ID__|${KEYID}|g" -e "s|__PUBKEY_B64__|${PUBKEY_B64}|g" \
    policies/policy-promotion-template.json > policy.json
cilock sign -f policy.json -o policy-signed.json -k signing.key

cilock verify \
  -p policy-signed.json -k signing.pub \
  -a build.attestation.json -f myapp \
  --enable-archivista=false 2>&1 | tee ../verify-report.txt

verify-report.txt is the human-readable receipt the auditor wants alongside the raw evidence.

Step 3: Write the manifest

The auditor doesn't want to grep through DSSE envelopes. They want a table:

# Release evidence manifest

| Auditor question | Evidence file | How to inspect |
|---|---|---|
| Show me the SBOM for this release. | `pipeline/build.attestation.json` | `jq -r .payload < $f \| base64 -d \| jq '.predicate.attestations[] \| select(.type \| test("cyclonedx")) \| .attestation'` |
| Show that the SAST scanner ran in CI before this build. | `pipeline/sast.attestation.json`: its `command-run` attestation records the scanner as the step's observed command; `pipeline/gosec-results.sarif` is that step's product and on its own shows only that a report was collected (see [SBOM and SARIF evidence](./sbom-and-sarif-evidence.md#what-each-attestor-does)) | `jq -r .payload < $f \| base64 -d \| jq '.predicate.attestations[] \| select(.type \| test("command-run")) \| .attestation.cmd'` |
| Prove the image in production matches what was built. | `container/build.attestation.json` `oci` predicate's `imageid` | `jq ...` |
| Show that policy gated the release. | `verify-report.txt` (line: `Verification succeeded / Evidence: Step: build`) | `cat verify-report.txt` |
| Prove this bundle hasn't been edited since release. | `audit-bundle.attestation.json` and its `-material-inventory.json` companion (materials' digests) | `jq ...` |

Generate the manifest from a template so it stays in sync with the bundle contents.

Step 4: Sign the bundle itself

Wrap the tar step with cilock run so the bundle's materials become a signed attestation:

cilock run \
  --step audit-bundle \
  -k signing.key \
  --enable-archivista=false \
  --material-manifest \
  -o audit-bundle.attestation.json \
  -- tar czf release-evidence.tar.gz bundle/ verify-report.txt MANIFEST.md

Under the default compact evidence profile the material predicate commits to every input file through its Merkle root but does not carry the per-file list. --material-manifest keeps that list: cilock writes it beside the attestation as audit-bundle.attestation.json-material-inventory.json, a signed file-inventory/v0.1 statement whose sha256 the material predicate records as inventory.digest. Archive both files. The product predicate carries the tarball's sha256 inline. The bundle is a notarized record of itself.

Step 5: Replay from a clean clone

Six months from now, the auditor downloads the bundle and runs:

#!/usr/bin/env bash
# replay-audit-bundle.sh
set -euo pipefail

BUNDLE="${1:?usage: replay-audit-bundle.sh <release-evidence.tar.gz> <audit-bundle.attestation.json> <bundle.pub>}"
ATTESTATION="${2:?see usage}"
BUNDLE_PUB="${3:?see usage}"
INVENTORY="${ATTESTATION}-material-inventory.json"

tar xzf "$BUNDLE"

# Confirm every bundled file's digest matches the audit-bundle attestation.
# In a compact build the material/v0.3 predicate carries
# { merkleRoot, treeSize, inventory: { state: "detached", digest, ... } }.
# The per-file list lives in the inventory companion, and its predicate must
# hash to the digest the material predicate signed.
WANT=$(jq -r .payload < "$ATTESTATION" | base64 -d \
  | jq -r '.predicate.attestations[]
           | select(.type | test("material"))
           | .attestation.inventory.digest')
jq -r .payload < "$INVENTORY" | base64 -d > inventory.statement.json
GOT=$(jq -cj .predicate inventory.statement.json | sha256sum | awk '{print $1}')
[ "$WANT" = "$GOT" ] || { echo "material inventory does not match the signed predicate" >&2; exit 1; }
jq -r '.predicate.entries[] | "\(.fileDigest)  \(.path)"' inventory.statement.json \
  > expected.sha256
sha256sum --check expected.sha256

# Re-run cilock verify against the bundled build attestation. The bundle
# carries the already-signed policy plus the public key, so no signing
# key is needed on the verifier side.
cd bundle/gate
cilock verify \
  -p policy-signed.json -k signing.pub \
  -a build.attestation.json -f myapp \
  --enable-archivista=false

Two checks: the bundle's contents match what the notary attestation recorded (sha256sum --check), and the gate policy still passes when re-run from cold storage (cilock verify). If either fails, the evidence has been tampered with or the policy was rewritten since the release. Either is grounds for rejection.

The canonical auditor questions

QuestionEvidencePredicate type
What source built this release?pipeline/build.attestation.json subject commithash:...git
Who triggered the build?The Fulcio cert's URI: SAN inside the DSSE envelope(cert chain)
Were any actions unpinned?The github-action attestor's actionref and refpinned fields, per tutorial 1github-action
Did the SAST scanner run?pipeline/sast.attestation.jsonsarif
What did the SAST scanner find?pipeline/gosec-results.sarif(raw SARIF)
Is there a signed SBOM?pipeline/build.attestation.json predicatehttps://cyclonedx.org/bom
Is the image in production the one that was built?container/build.attestation.json oci predicate's imageid cross-checks the registry manifest's .config.digest; see tutorial 4oci
Was the cosign signature valid?container/cosign-verify.txt (output of cosign verify)(cosign)
Did policy gate the promotion?verify-report.txt containing Verification succeeded / Evidence: Step: build(cilock verify output)
Was any secret leaked through stdout?Each step's secretscan predicate (if enabled); see tutorial 1secretscan
Did anything covertly read credentials from disk?Each step's command-run v0.2 predicate: processes[].openedFiles entries index paths[] and digests[] (if --trace was on; Linux records opened files, macOS records working-directory reads as syscalls); see tutorial 1command-run
Has the evidence been edited since release?audit-bundle.attestation.json material inventory digests vs. sha256sum of bundle contentsmaterial

Treat this table as the contract. If you add a new attestor in CI, add a row here so an auditor knows where to find its answer.

What this is not

  • Not a substitute for retention policy. GitHub Actions artifacts default to 90 days. If you want the bundle available 5 years later, push it to durable object storage (S3, GCS) at release time and record the upload as a separate cilock run step so the upload itself is attested.
  • Not a substitute for Archivista. Archivista is the supply-chain-native attestation store; an evidence bundle is what you hand an auditor who doesn't speak GraphQL. Both can coexist; pull the bundle's contents from Archivista at release time.
  • Not free of the upstream limitations. Detection is post-execution, and --trace runs only on Linux and macOS. With --trace, network activity is recorded (connections, DNS lookups, TLS SNI) but not blocked. See the trust model page for the honest list.

Further reading

  • Verify in a release gate, the operational guidance for soft-fail vs. fail-closed and where in your CD the bundle creation step belongs.
  • NIST SP 800-204D, the standard that calls out signed pipeline attestations as a first-class control. Frederick Kautz of TestifySec is a co-author.
  • The TestifySec blog post Preventing the Claude Code Leak with Attestation Policies walks through the same wrap-attest-policy-verify loop against the March 2026 Claude Code source map incident; the policy enforcement pattern is identical, just over one step instead of a release-wide bundle.

Reference generated from the product documentation. Match commands and support details to your installed release.