Step 10 of 25

Software supply chain security

Lock dependencies, defend against typosquatting and dependency confusion, pin CI actions by SHA, and sign and verify images with cosign.

19 min 8 sections

Attackers have learned that it's often easier to compromise something you trust than to attack your application directly: a popular package, a CI action, a build server. Your supply chain is everything that turns source code into a running artifact, and every link in it can be tampered with. This lesson walks the chain from dependencies to deployment, showing the concrete control for each link: lockfiles, registry configuration, SHA-pinned actions, SLSA provenance, and keyless signatures with Sigstore.

How supply chain attacks happen

Real incidents map onto distinct stages, and each needs a different defence:

StageReal incidentWhat happened
Dependencyevent-stream (2018)A new maintainer added code to a popular npm package that stole cryptocurrency wallets from one downstream app
Dependencyxz-utils (2024)A contributor spent two years gaining maintainer trust, then hid a backdoor targeting SSH in release tarballs
BuildSolarWinds (2020)Attackers modified the build system so signed, official releases contained a backdoor
CI toolingtj-actions/changed-files (2025)Attackers repointed the action's version tags to a malicious commit that dumped CI secrets into build logs
DistributionCodecov (2021)A modified uploader script on the vendor's server exfiltrated environment variables from customers' CI

The common thread: the victims ran code they didn't review because it came from a trusted name. Supply chain security replaces implicit trust in names with explicit checks on exact versions, digests and signatures.

Lockfiles and pinning

A manifest like "express": "^4.21.0" describes a range. The version you get depends on the day you install. A lockfile records the exact resolved version and a cryptographic hash of every package, direct and transitive:

json
{
  "packages": {
    "node_modules/express": {
      "version": "4.21.2",
      "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
      "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="
    }
  }
}

Commit lockfiles, and install from them in CI with the command that refuses to deviate:

EcosystemLockfileStrict install in CI
npmpackage-lock.jsonnpm ci (fails if lockfile and manifest disagree)
pnpmpnpm-lock.yamlpnpm install --frozen-lockfile
Gogo.sumVerified automatically on every build; checksums are also checked against sum.golang.org
Python (pip-tools)requirements.txt with hashespip install --require-hashes -r requirements.txt
Python (uv)uv.lockuv sync --locked

Pinning without updating just freezes old vulnerabilities in place. Let a bot open update pull requests, so every version change goes through review and CI. A short cooldown avoids installing a release in the first days after publication, when most malicious versions are caught and pulled:

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
    cooldown:
      default-days: 5
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly

Install scripts are another risk: npm runs a package's postinstall hook with your user's permissions, CI secrets included. If your build doesn't need them, run npm ci --ignore-scripts.

Typosquatting and dependency confusion

Typosquatting publishes a malicious package under a name one typo away from a popular one, such as reqeusts for requests or crossenv for cross-env. Defences are cultural as much as technical: copy install commands from the project's own documentation, review new dependencies in pull requests like any other code (who maintains it, how old is it, how many downloads), and let SCA flag known-malicious packages.

Dependency confusion is subtler. Your company has an internal package acme-auth on a private registry. An attacker publishes acme-auth version 99.0.0 to the public registry. If your installer checks both registries and picks the highest version, it installs the attacker's package. The fix is to make each name resolve from exactly one place:

ini
# .npmrc: all @acme packages come only from the internal registry
@acme:registry=https://npm.acme.internal/

Scoped names (@acme/auth) plus a scope-to-registry mapping make confusion impossible for npm. For Python, the dangerous flag is --extra-index-url, which merges a second index with PyPI. Use a single --index-url pointing at a proxy (Artifactory, Nexus, or a cloud artifact registry) that serves internal packages and proxies public ones, and register your internal names on the public registry as empty placeholders.

Pinning CI actions by commit SHA

uses: some-org/some-action@v4 looks pinned, but a Git tag is a movable pointer. Whoever controls the repository, or whoever steals the maintainer's token, can repoint v4 at new code. That is exactly what happened with tj-actions/changed-files: thousands of workflows referencing a version tag ran the attacker's commit without a single change on their side.

A full commit SHA is immutable. Look it up and keep the human-readable version as a comment:

bash
git ls-remote --tags https://github.com/actions/checkout refs/tags/v7.0.1
text
3d3c42e5aac5ba805825da76410c181273ba90b1	refs/tags/v7.0.1
yaml
steps:
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
    with:
      persist-credentials: false

If a tag is annotated, that SHA is the tag object, not the commit: also ask for 'refs/tags/v7.0.1^{}' and use the SHA on that dereferenced line, which is the commit itself. Dependabot and Renovate understand the @<sha> # vX.Y.Z convention and update both together, so pinning doesn't mean falling behind. GitHub organisations can also enforce SHA pinning through the allowed-actions policy, which makes the rule impossible to forget.

The same principle applies to container images: FROM node:22 is a tag, FROM node:22@sha256:… is a digest. Pin base images by digest in Dockerfiles and let the update bot move them. GitLab's equivalent risk is include: of remote templates and components; pin those to a commit SHA too.

SLSA and build provenance

SLSA (Supply-chain Levels for Software Artifacts, pronounced "salsa") is a framework for answering one question about an artifact: can I trust that it was built from the source I think, by the process I think? Its Build track defines levels:

LevelRequirementWhat it defends against
Build L0Nothing—
Build L1Provenance exists, describing how the artifact was builtMistakes; gives you an audit trail
Build L2Builds run on a hosted platform that generates and signs the provenanceTampering after the build; forged provenance
Build L3The build platform is hardened: builds are isolated from each other and signing keys are unreachable from build stepsA compromised build step forging provenance for a different artifact

Provenance is a signed statement in the in-toto format: "artifact with digest sha256:ab12… was built by workflow release.yml at commit 9f3e… in repository acme/orders-api, triggered by a push to main." Consumers verify the signature and check those fields before trusting the artifact.

On GitHub Actions, actions/attest produces signed SLSA provenance using a short-lived Sigstore certificate:

yaml
permissions:
  contents: read
  packages: write          # push the image to GHCR
  id-token: write          # request an OIDC token for signing
  attestations: write      # store the attestation
  artifact-metadata: write
steps:
  # ... build and push the image, capturing its digest in steps.push ...
  - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
    with:
      subject-name: ghcr.io/acme/orders-api
      subject-digest: ${{ steps.push.outputs.digest }}
      push-to-registry: true

Anyone can then verify where an image came from:

bash
gh attestation verify oci://ghcr.io/acme/orders-api@sha256:4c1e... --owner acme

Hosted GitHub runners with this action meet Build L2; running the build in a reusable workflow, isolated from the calling repository's own steps, is the usual route to L3.

Signing and verifying images with cosign

Signing proves an image came from you and hasn't changed since. Traditional signing means managing a long-lived private key, which then becomes the thing attackers steal. Sigstore removes the key:

  1. The signer (your CI job) gets an OIDC token proving its identity, e.g. "workflow release.yml in acme/orders-api on refs/heads/main."
  2. Fulcio, Sigstore's certificate authority, exchanges the token for a signing certificate valid for about ten minutes, bound to that identity.
  3. cosign signs the image digest with an ephemeral key and records the signature in Rekor, a public append-only transparency log.
  4. The ephemeral private key is discarded. There is nothing left to steal.
yaml
jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write          # required for keyless signing
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: push
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          push: true
          tags: ghcr.io/acme/orders-api:${{ github.sha }}
      - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
      - name: Sign by digest
        run: cosign sign --yes "ghcr.io/acme/orders-api@${{ steps.push.outputs.digest }}"

Always sign the digest, never a tag: a tag can be moved to a different image after you sign it.

A signature is worthless unless something checks it, and checking that a signature exists is not enough, because anyone can get a valid Sigstore signature for their own identity. Verification must pin who signed:

bash
cosign verify ghcr.io/acme/orders-api@sha256:4c1e... \
  --certificate-identity "https://github.com/acme/orders-api/.github/workflows/release.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com"
text
Verification for ghcr.io/acme/orders-api@sha256:4c1e... --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - Existence of the claims in the transparency log was verified offline
  - The code-signing certificate was verified using trusted certificate authority certificates

Run that verification where it counts: as a gate in the deploy job, and in the cluster through an admission controller (Sigstore policy-controller or Kyverno's verifyImages rule) so that unsigned images, or images signed by any other workflow, are rejected before they run.

Practice

You inherit this release workflow. Harden it: pin every action by SHA, apply least-privilege permissions, sign the pushed image keylessly, and add a verify step that would fail if the image was signed by a different workflow.

yaml
on:
  push:
    branches: [main]
permissions: write-all
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v7
        with:
          push: true
          tags: ghcr.io/acme/orders-api:latest
Show solution
yaml
on:
  push:
    branches: [main]
permissions: {}                # nothing by default; grant per job
jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: push
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          push: true
          tags: ghcr.io/acme/orders-api:${{ github.sha }}
      - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
      - name: Sign
        env:
          IMAGE: ghcr.io/acme/orders-api@${{ steps.push.outputs.digest }}
        run: cosign sign --yes "$IMAGE"
      - name: Verify
        env:
          IMAGE: ghcr.io/acme/orders-api@${{ steps.push.outputs.digest }}
        run: |
          cosign verify "$IMAGE" \
            --certificate-identity "https://github.com/${{ github.workflow_ref }}" \
            --certificate-oidc-issuer "https://token.actions.githubusercontent.com"

What changed and why:

  • permissions: write-all gave every step write access to the repository, issues and releases. Now the default is nothing and the job holds only what it uses.
  • Each action is pinned to the commit its tag pointed to when reviewed; the comment keeps it readable and lets Dependabot update it.
  • persist-credentials: false stops checkout leaving a token in .git/config for later steps to find.
  • The tag latest is replaced by the commit SHA, and signing targets the immutable digest.
  • github.workflow_ref expands to acme/orders-api/.github/workflows/release.yml@refs/heads/main, exactly the identity in the Fulcio certificate, so the verify step fails for a signature made by any other workflow or branch.

The digest is passed through env rather than interpolated into the script, the same injection-safe habit from the CI lesson.

Key takeaways

  • Supply chain attacks exploit trust in names; replace that trust with checks on exact versions, digests and signatures.
  • Commit lockfiles, install with npm ci or equivalent, and let a bot propose reviewed updates with a short cooldown.
  • Prevent dependency confusion by resolving each package name from exactly one registry.
  • Pin third-party CI actions and base images by full SHA or digest; tags can be moved.
  • SLSA Build levels measure how trustworthy your provenance is; actions/attest gives signed provenance on hosted runners.
  • Sign image digests keylessly with cosign, and verify the exact signer identity at deploy and admission time.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Your workflow uses `some-org/scan-action@v3`. The maintainer's account is compromised and v3 is repointed to malicious code. What would have protected you?

  2. 2.What makes Sigstore signing 'keyless'?

  3. 3.Why is `cosign verify` without `--certificate-identity` (or with a regexp of `.*`) insufficient?

  4. 4.An attacker publishes `[email protected]` to the public npm registry, matching your private package's name. Which control prevents it being installed?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.