Education › Security › Stage 3: Cloud & pipeline

Software supply chain

Dependencies, SBOMs, signing with Sigstore, SLSA levels and defending the build itself.

Intermediate ~35 min read Module 10 of 16

Your application is a thin layer of your own code on top of thousands of packages written by strangers, built by a pipeline that runs code from more strangers, and shipped as an image assembled from yet more. Attackers noticed. Poisoned packages, hijacked maintainer accounts, compromised build servers and tampered artifacts are now routine, and a single one reaches every customer of every company that depends on it. Supply chain security is about knowing exactly what you ship, proving where it came from, and refusing to run anything that cannot prove it. This module covers dependencies, SBOMs, signing with Sigstore, provenance and the SLSA levels, and protecting the build itself.

After this module you can
  • Pin and audit dependencies, and reduce the number of them
  • Generate a software bill of materials and use it to answer "are we affected?" in minutes
  • Sign artifacts and images keylessly with Sigstore and verify signatures before deployment
  • Explain SLSA levels and produce build provenance from a hosted pipeline
  • Harden the build pipeline: pinned actions, minimal permissions, isolated runners, protected branches

Dependencies: fewer, pinned, watched

Every dependency is code you run with your privileges, maintained by someone you have never met, published through an account that can be phished. The attacks are well known: typosquatting (a package one letter away from the real one), dependency confusion (a public package with the same name as your internal one, and a resolver that prefers the higher version), hijacked maintainer accounts publishing a malicious release, and install-time scripts that run on npm install.

  • Fewer: every dependency you remove is a supplier you no longer have to trust. Question transitive bloat.
  • Pinned: lock files with exact versions and hashes (package-lock.json, poetry.lock, requirements.txt --require-hashes, go.sum) so an install today and next month are identical.
  • Sourced: a private registry or proxy that mirrors approved packages, blocks unknown ones, and makes dependency confusion impossible by design.
  • Watched: automated updates (Renovate, Dependabot) with tests, and vulnerability scanning on every change and on a schedule.
  • Quiet: disable install scripts where you can (npm ci --ignore-scripts) and review packages that need them.
Reproducible Python installs with hashes, and a vulnerability check that reads the lock file.
bash
# generate a fully pinned, hashed requirements file from your top-level deps
pip-compile --generate-hashes requirements.in -o requirements.txt

# installs fail if any package's hash does not match the lock
pip install --require-hashes -r requirements.txt

# known vulnerabilities in exactly what is pinned
pip-audit -r requirements.txt --strict
Watch out

A new dependency deserves the same review as new code: who maintains it, how many maintainers, when was it last released, does it need install scripts, what does it pull in transitively. Popular is not the same as safe, but abandoned is a clear signal.

Know what you ship: the SBOM

A software bill of materials is a machine-readable inventory of every component in an artifact: packages, versions, licences, and their relationships, in a standard format (SPDX or CycloneDX). Its value is speed under pressure. When the next critical vulnerability in a logging library is announced, teams with SBOMs answer "which of our two hundred services contain version 2.14.1?" with a query; teams without spend days grepping. Generate an SBOM for every build, store it next to the artifact, and index it.

Generate an SBOM for an image with Syft, then query all stored SBOMs for a vulnerable package with Grype.
bash
# SBOM in CycloneDX JSON, attached to the build as an artifact
syft ghcr.io/acme/orders:sha-1a2b3c4 -o cyclonedx-json > sbom.cdx.json

# scan the SBOM (no need to pull the image again) for known vulnerabilities
grype sbom:sbom.cdx.json --fail-on high

# the question that matters during an incident
jq -r '.components[] | select(.name=="log4j-core") | .version' sbom.cdx.json

SBOMs also serve customers and regulators who increasingly ask for them, and they feed licence compliance. Attach the SBOM to the image as an attestation (next section) so that it travels with the artifact and cannot be swapped.

Sign it: Sigstore and keyless signatures

A signature lets a consumer verify that an artifact was produced by a specific identity and not altered since. Traditional signing keys are a liability: they leak, they expire, they get shared. Sigstore removes the long-lived key. The signer proves its identity with an OIDC token (a CI job's identity, or a developer's login), receives a short-lived certificate from the Fulcio CA, signs, and records the signature in the Rekor transparency log. Verifiers check the signature against the certificate and confirm the certificate's identity claims: this image was signed by the release workflow of this repository on the main branch.

Keyless signing in GitHub Actions after the image push. The job's OIDC token is the identity; no secret is stored.
yaml
permissions:
  contents: read
  packages: write
  id-token: write

steps:
  - uses: sigstore/cosign-installer@v3
  - name: Sign the image by digest
    run: cosign sign --yes "ghcr.io/acme/orders@${DIGEST}"
    env:
      DIGEST: ${{ steps.build.outputs.digest }}
  - name: Attach the SBOM as an attestation
    run: cosign attest --yes --predicate sbom.cdx.json --type cyclonedx "ghcr.io/acme/orders@${DIGEST}"
    env:
      DIGEST: ${{ steps.build.outputs.digest }}
Verifying: only images signed by this repository's release workflow on main are accepted. The same check runs in the cluster's admission controller.
bash
cosign verify \
  --certificate-identity-regexp '^https://github.com/acme/orders/.github/workflows/release.yml@refs/heads/main$' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  ghcr.io/acme/orders@sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
Tip

Sign and verify by digest, never by tag. A tag is a mutable pointer; the digest is the content. Deployment manifests should reference digests, and admission should resolve tags to digests and verify those.

Provenance and SLSA

A signature says who signed; provenance says how the artifact was built: which source commit, which builder, which inputs, which build instructions. SLSA (Supply-chain Levels for Software Artifacts) is the framework that grades this. Level 1: provenance exists. Level 2: it is generated by a hosted build platform and signed, so it cannot be forged by the developer. Level 3: the build runs on hardened, isolated infrastructure where the provenance cannot be tampered with by the build's own steps. Most teams reach level 2 or 3 by using a hosted CI's provenance generator rather than building their own.

Generating SLSA level 3 provenance for a container image with GitHub's attestation action.
yaml
  - uses: actions/attest-build-provenance@v2
    with:
      subject-name: ghcr.io/acme/orders
      subject-digest: ${{ steps.build.outputs.digest }}
      push-to-registry: true
HOSTED BUILDER (SLSA 3)triggershash-pinned installsinventorykeyless signcert + log entrypush image + SBOMattest provenancecosign verifyCommit on mainprotected branchPrivate registryhash-pinned depsrelease.ymlOIDC identitySBOMsyft, CycloneDXSigstoreFulcio + RekorProvenancesigned attestationRegistrydigest + attestationsAdmissionverifies identity
From commit to cluster with nothing unverified in between: the hosted builder signs the image and its provenance with the job's own identity, the SBOM travels as an attestation, and admission refuses anything whose signature, identity or source branch does not match policy.

Provenance makes policy possible. A consumer (a cluster admission controller, a package installer) can require: built from a commit on the protected main branch, by the official workflow, on the hosted builder, with the SBOM attached. Combine with verification by digest and you have closed the gap between "we build good software" and "only software we built can run".

Protect the build itself

The pipeline is a privileged system that executes code from pull requests and holds the credentials to publish. Attackers target it directly: a malicious pull request that exfiltrates secrets, a compromised third-party action, a poisoned build cache. Treat CI configuration as security-critical code.

RiskControl
Third-party actions and images change under youPin actions to a commit SHA, images to a digest; review updates via bot PRs
Pull requests from forks run with secretsFork PRs get no secrets by default; never use pull_request_target with checkout of the PR head
Workflow token can push code or publishpermissions: contents: read at the top; grant packages: write or id-token: write only to the job that needs it
Untrusted input in shell stepsPass ${{ github.event.* }} values through environment variables, never interpolate into run:
Shared, long-lived runners accumulate stateEphemeral runners; no persistent credentials on the runner; isolated per job
Anyone can change the workflowProtected branches, required reviews for .github/, CODEOWNERS
The shape of a hardened workflow header: minimal default permissions, pinned actions, and untrusted input kept out of the shell.
yaml
permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: Greet the PR author safely
        env:
          TITLE: ${{ github.event.pull_request.title }}
        # never interpolate the expression directly into the script
        run: |
          echo "PR title is $TITLE"

Finally, review your own trust decisions periodically: which registries you pull from, which actions you allow (GitHub can restrict to verified creators or an allow-list), which people can approve a release. Supply chain security is not a product; it is knowing your suppliers and making the pipeline the only road to production.

Hands-on practice

Pin, inventory, sign, verify

  1. In a project you own, generate a hashed lock file (pip-compile --generate-hashes or npm ci with a committed lock) and make CI fail if the lock is out of date with the manifest.
  2. Run pip-audit or npm audit and fix or explicitly accept each high finding. Add the check to CI.
  3. Generate a CycloneDX SBOM for your container image with Syft and answer, with jq, how many components it has and which have no declared licence.
  4. Add keyless cosign sign to your release workflow (the zero-to-production pipeline is ready for it: id-token: write is already granted). Push a build and confirm a signature exists with cosign tree or the registry UI.
  5. Run cosign verify with the exact identity regexp for your workflow; then try a wrong identity and confirm verification fails.
  6. Pin every action in your workflows to a commit SHA with a version comment. Set the workflow's default permissions to contents: read and grant write permissions per job.
  7. Add the provenance attestation step and inspect the resulting attestation for the source commit and builder id.
Cheat sheet

Software supply chain — at a glance

Main things to focus on

  • Fewer dependencies, exact pins with hashes, a private registry, automated updates with tests
  • SBOM per build (SPDX or CycloneDX), stored and queryable: "are we affected?" in minutes
  • Sign by digest with Sigstore keyless; verify identity and issuer before deploy and at admission
  • Provenance says how it was built; SLSA levels grade how trustworthy that claim is
  • Harden CI: pinned actions, minimal token permissions, no secrets for fork PRs, no untrusted input in shell
  • The pipeline is the only road to production, and it is protected like production

Dependencies

pip-compile --generate-hashes / pip install --require-hashesExact, hash-verified Python installs
npm ci --ignore-scriptsInstall from the lock, no install-time scripts
pip-audit / npm audit / osv-scannerKnown vulnerabilities in what is pinned
private registry / proxy with allow-listBlocks dependency confusion and unknown packages
Renovate / DependabotUpdates as reviewed PRs with tests

SBOM

syft IMAGE -o cyclonedx-jsonInventory of an image or directory
grype sbom:FILE --fail-on highScan the SBOM without the artifact
jq '.components[] | select(.name=="PKG")'Incident-time query
cosign attest --type cyclonedx --predicate sbom.cdx.jsonAttach the SBOM to the image

Sigstore

cosign sign --yes IMAGE@DIGESTKeyless signing with the job's OIDC identity
cosign verify --certificate-identity-regexp ... --certificate-oidc-issuer ...Accept only your workflow's signatures
cosign tree IMAGEList signatures and attestations for an image
Fulcio (certs) / Rekor (transparency log)The public infrastructure behind keyless
verify by digest, never tagTags move; digests are content

Provenance and SLSA

actions/attest-build-provenanceSigned SLSA provenance from GitHub's hosted builder
SLSA 1: provenance existsDocumented build
SLSA 2: hosted platform, signedDeveloper cannot forge it
SLSA 3: isolated, hardened builderBuild steps cannot tamper with provenance
policy: built from main by release.yml on the hosted builderWhat admission can require

Pipeline hardening

uses: org/action@<40-char sha> # vX.Y.ZPin actions to commits
permissions: {contents: read} at top; per-job writesLeast privilege for the workflow token
env: X=${{ github.event... }} then $XNever interpolate untrusted input into run:
avoid pull_request_target + checkout of PR headClassic secret-exfiltration pattern
ephemeral runners; CODEOWNERS for .github/No state, reviewed workflow changes

Common pitfalls

  • Floating versions (^1.2.0, latest, @v4) that silently pull a compromised release.
  • Trusting a public registry to resolve an internal package name.
  • Signing with a long-lived key stored as a CI secret, which leaks like any other secret.
  • Verifying a signature without checking the certificate identity; anyone can sign anything keylessly.
  • Echoing pull request titles or branch names directly in a shell step, giving contributors code execution on the runner.
  • Producing SBOMs and never storing or indexing them, so the incident-time question still takes days.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →