Chapter 23Lesson 02~205 minutes

Artifact Attestations, SBOM Provenance, Verification, and SLSA-Oriented Builds: Guided Hands-On Workflow

Build a tiny artifact, generate provenance and SBOM attestations in a disposable public repository, verify them, tamper with the bytes, and preserve failure evidence.

Hands-onactions/attestGitHub CLITamper testEvidence

Learning objectives

  • Create a disposable public repository whose mandatory path uses only free GitHub-hosted resources and fake data.
  • Build one deterministic text artifact, calculate its digest, create provenance and SPDX SBOM evidence, and record run identity.
  • Generate live GitHub attestations with full-SHA-pinned first-party actions when public-repository attestations are available.
  • Verify the exact artifact, then modify one byte and preserve the expected verification failure.
  • Complete an offline/local simulation when live attestation generation is unavailable.

1. Lab scenario: a tiny releasable artifact with two claims

Create a disposable public repository named gha-attestation-lab. The “application” is intentionally tiny: a source text file is converted into dist/hello.txt. The workflow records SHA-256, generates a minimal SPDX 2.3 JSON SBOM tied to that digest, creates one provenance attestation and one SBOM attestation, and prints only attestation identifiers/URLs—not OIDC tokens or credentials.

Public repositories are the mandatory live path because artifact attestations are available there on current GitHub plans. If your lab must remain private and you do not have GitHub Enterprise Cloud, use the faithful local simulation in Section 11; it teaches digest binding and policy evaluation without pretending to create a GitHub/Sigstore signature.

2. Preflight and exact assumptions (verified 2026-09-10)

Item Lab assumption Evidence to retain
Repository Disposable public repository URL + visibility screenshot/text note.
Runner ubuntu-24.04 Set-up-job image metadata.
Checkout actions/checkout v7.0.1 @ 3d3c42e5aac5ba805825da76410c181273ba90b1 Workflow manifest.
Attest actions/attest v4.2.2 @ 1e69f48acb82d1966a394da916b4c1698aa569d6 Workflow manifest + release mapping.
Permissions contents read; id-token/attestations/artifact-metadata write Workflow YAML; no write-all.
Secrets None No secret references.
CLI Record installed gh --version before verification Evidence packet; syntax verified against current CLI manual.

3. Create deterministic source and build script

The build script converts source to a normalized artifact. Keeping the transformation simple makes the digest relationship easy to reason about. The build also writes a checksum file as independent byte-identity evidence.

mkdir -p src scripts
printf 'hello from attested build
' > src/message.txt
cat > scripts/build.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
rm -rf dist
mkdir -p dist
tr '[:lower:]' '[:upper:]' < src/message.txt > dist/hello.txt
sha256sum dist/hello.txt > dist/hello.txt.sha256
EOF
chmod +x scripts/build.sh

4. Workflow: build first, then attest the exact bytes

The job requests only the scopes needed for source checkout and attestation generation. It does not publish a package or deploy anything. Two invocations of actions/attest create two distinct predicate types for the same subject: default SLSA provenance and SPDX SBOM.

name: Attested tiny build
on:
  workflow_dispatch:
permissions:
  contents: read
  id-token: write
  attestations: write
  artifact-metadata: write
jobs:
  build-attest:
    runs-on: ubuntu-24.04
    steps:
      - name: Checkout exact workflow source
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Build and record identity
        shell: bash
        run: |
          ./scripts/build.sh
          printf 'run_id=%s\nrun_attempt=%s\nsource_sha=%s\n' \
            "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$GITHUB_SHA"
          cat dist/hello.txt.sha256
          python3 --version
          gh --version | head -1

      - name: Generate minimal SPDX SBOM for the exact file
        shell: bash
        run: |
          python3 - <<'PY'
          import hashlib, json, os, datetime
          p='dist/hello.txt'
          digest=hashlib.sha256(open(p,'rb').read()).hexdigest()
          doc={
            'spdxVersion':'SPDX-2.3',
            'dataLicense':'CC0-1.0',
            'SPDXID':'SPDXRef-DOCUMENT',
            'name':'gha-attestation-lab',
            'documentNamespace':f'https://example.invalid/spdx/{os.environ["GITHUB_SHA"]}',
            'creationInfo':{
              'created':datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
              'creators':['Tool: gha-attestation-course-lab']
            },
            'files':[{
              'fileName':'dist/hello.txt',
              'SPDXID':'SPDXRef-File-hello',
              'checksums':[{'algorithm':'SHA256','checksumValue':digest}],
              'licenseConcluded':'NOASSERTION',
              'copyrightText':'NOASSERTION'
            }]
          }
          open('dist/sbom.spdx.json','w').write(json.dumps(doc,indent=2)+'\n')
          PY

      - name: Attest build provenance
        id: provenance
        uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
        with:
          subject-path: dist/hello.txt

      - name: Attest SPDX SBOM
        id: sbom
        uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
        with:
          subject-path: dist/hello.txt
          sbom-path: dist/sbom.spdx.json

      - name: Record non-secret attestation evidence
        shell: bash
        env:
          PROV_ID: ${{ steps.provenance.outputs.attestation-id }}
          PROV_URL: ${{ steps.provenance.outputs.attestation-url }}
          SBOM_ID: ${{ steps.sbom.outputs.attestation-id }}
          SBOM_URL: ${{ steps.sbom.outputs.attestation-url }}
        run: |
          printf 'provenance_id=%s\nprovenance_url=%s\n' "$PROV_ID" "$PROV_URL"
          printf 'sbom_id=%s\nsbom_url=%s\n' "$SBOM_ID" "$SBOM_URL"

5. Run once and freeze the evidence before testing failures

Dispatch the workflow. Before doing any tamper experiment, save the original run ID, run attempt, source SHA, workflow YAML, runner image metadata, checksum, provenance attestation ID/URL and SBOM attestation ID/URL. Download/copy dist/hello.txt from the same source revision locally by running ./scripts/build.sh; compare its checksum with the build log.

The critical state is the digest. The attestation subject must match the exact bytes you verify. A same-name file from another build is not automatically the same subject.

6. Verify provenance with an explicit expected identity

Authenticate the GitHub CLI to your disposable repository, record gh --version, then verify the artifact. Replace the placeholders with the actual owner/repository. The signer-workflow restriction makes the trust decision stronger than owner-only verification.

gh --version
gh attestation verify dist/hello.txt   --repo YOUR_OWNER/gha-attestation-lab   --signer-workflow YOUR_OWNER/gha-attestation-lab/.github/workflows/attested-build.yml

7. Verify that an SPDX predicate also exists for the same subject

The default verifier expects SLSA provenance. To select the SPDX SBOM predicate, specify its predicate type. Verification proves that the signed SBOM claim is attached to the exact subject digest under the required identity; it does not independently prove the inventory is complete.

gh attestation verify dist/hello.txt   --repo YOUR_OWNER/gha-attestation-lab   --predicate-type https://spdx.dev/Document/v2.3

8. Negative test A: alter one byte and preserve the failure

Copy the valid artifact before changing it. Then append one byte to the test copy. Keep the original verification output and the tampered verification output side by side. Do not overwrite the original evidence.

cp dist/hello.txt dist/hello.valid.txt
cp dist/hello.txt dist/hello.tampered.txt
printf 'X' >> dist/hello.tampered.txt
sha256sum dist/hello.valid.txt dist/hello.tampered.txt

gh attestation verify dist/hello.tampered.txt   --repo YOUR_OWNER/gha-attestation-lab || true

Expected observation: the digest changes and no matching valid attestation verifies for the modified subject. The failure is evidence that verification is bound to bytes rather than file name.

9. Negative test B: require the wrong signer identity

Use the original valid bytes but deliberately require a signer workflow that did not create the attestation. This proves that “artifact bytes are unchanged” and “builder identity is approved” are independent checks.

gh attestation verify dist/hello.valid.txt   --repo YOUR_OWNER/gha-attestation-lab   --signer-workflow YOUR_OWNER/gha-attestation-lab/.github/workflows/not-the-builder.yml || true

Expected observation: identity-constrained verification fails even though the artifact digest itself has not changed.

10. Evidence packet

Evidence Capture
Event/source workflow_dispatch, source ref and exact GITHUB_SHA.
Run Run ID + attempt + job/step conclusions.
Runner/tooling Runner image details, Python version, actual gh --version.
Action manifest Checkout and attest full SHAs plus release mappings.
Subject Artifact filename and SHA-256.
Provenance Attestation ID/URL + successful identity-constrained verify output.
SBOM SPDX file + SBOM attestation ID/URL + predicate-type verify output.
Negative evidence Tampered digest + failure; wrong-signer failure.
Limitations No release/package/deployment; public disposable repo; toy SBOM.

11. Faithful local fallback when live attestations are unavailable

If repository visibility/plan or connectivity prevents live attestation generation, do not fabricate a Sigstore success. Instead simulate the data model locally: compute the subject digest, create a JSON statement with source/workflow identity, and write a policy checker that requires exact digest/repository/workflow/ref. Mark the result UNSIGNED SIMULATION. This teaches policy mechanics but does not claim cryptographic provenance.

import hashlib, json
artifact = open('dist/hello.txt','rb').read()
digest = hashlib.sha256(artifact).hexdigest()
statement = {
  "simulation": True,
  "subject": {"name":"hello.txt", "sha256":digest},
  "builder": {
    "repository":"YOUR_OWNER/gha-attestation-lab",
    "workflow":".github/workflows/attested-build.yml",
    "ref":"refs/heads/main"
  }
}
open('dist/provenance.simulation.json','w').write(json.dumps(statement,indent=2))
assert statement['subject']['sha256'] == hashlib.sha256(artifact).hexdigest()
assert statement['builder']['workflow'] == '.github/workflows/attested-build.yml'
print('UNSIGNED SIMULATION: digest + policy matched')

12. Cleanup and rollback

Keep the evidence packet long enough to finish the lesson, then delete the disposable repository if desired. Deleting a workflow run, artifact or attestation is a separate GitHub state mutation and should not be used merely to make a failed experiment disappear. Preserve first-failure evidence first.

No cloud credential, registry login, package publication or production infrastructure is created by this lab.

13. Challenge: choose the missing layer

A team verifies SHA-256 but cannot prove which workflow produced the release. Another team verifies provenance but cannot answer which components are inside the artifact. A third team verifies both but accepts any workflow from the organization. Identify the missing control for each: provenance, SBOM, or identity-constrained verification policy.

14. Lesson summary

You have created two signed claims for the same subject, verified positive identity/digest cases, forced two independent negative cases, and preserved enough evidence to explain each result. The next lesson turns these mechanics into design choices.

Next lesson

Artifact Attestations, SBOM Provenance, Verification, and SLSA-Oriented Builds: Configuration, Design Patterns, and Trade-Offs

Continue with the next lesson to build on the current concepts, evidence, security boundaries, and operational practices.

Knowledge check

Why does the lab create the artifact before calling actions/attest?

What does the tamper test prove?

Why run a wrong-signer test against the original valid file?

If the local fallback passes, have you created GitHub artifact provenance?

Why does the SBOM verification not prove the SBOM is complete?

Official references and version notes

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.