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.
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:
| Stage | Real incident | What happened |
|---|---|---|
| Dependency | event-stream (2018) | A new maintainer added code to a popular npm package that stole cryptocurrency wallets from one downstream app |
| Dependency | xz-utils (2024) | A contributor spent two years gaining maintainer trust, then hid a backdoor targeting SSH in release tarballs |
| Build | SolarWinds (2020) | Attackers modified the build system so signed, official releases contained a backdoor |
| CI tooling | tj-actions/changed-files (2025) | Attackers repointed the action's version tags to a malicious commit that dumped CI secrets into build logs |
| Distribution | Codecov (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:
{
"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:
| Ecosystem | Lockfile | Strict install in CI |
|---|---|---|
| npm | package-lock.json | npm ci (fails if lockfile and manifest disagree) |
| pnpm | pnpm-lock.yaml | pnpm install --frozen-lockfile |
| Go | go.sum | Verified automatically on every build; checksums are also checked against sum.golang.org |
| Python (pip-tools) | requirements.txt with hashes | pip install --require-hashes -r requirements.txt |
| Python (uv) | uv.lock | uv 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:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
cooldown:
default-days: 5
- package-ecosystem: github-actions
directory: /
schedule:
interval: weeklyInstall 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:
# .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:
git ls-remote --tags https://github.com/actions/checkout refs/tags/v7.0.13d3c42e5aac5ba805825da76410c181273ba90b1 refs/tags/v7.0.1steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: falseIf 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:
| Level | Requirement | What it defends against |
|---|---|---|
| Build L0 | Nothing | — |
| Build L1 | Provenance exists, describing how the artifact was built | Mistakes; gives you an audit trail |
| Build L2 | Builds run on a hosted platform that generates and signs the provenance | Tampering after the build; forged provenance |
| Build L3 | The build platform is hardened: builds are isolated from each other and signing keys are unreachable from build steps | A 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:
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: trueAnyone can then verify where an image came from:
gh attestation verify oci://ghcr.io/acme/orders-api@sha256:4c1e... --owner acmeHosted 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:
- The signer (your CI job) gets an OIDC token proving its identity, e.g. "workflow
release.ymlinacme/orders-apionrefs/heads/main." - Fulcio, Sigstore's certificate authority, exchanges the token for a signing certificate valid for about ten minutes, bound to that identity.
- cosign signs the image digest with an ephemeral key and records the signature in Rekor, a public append-only transparency log.
- The ephemeral private key is discarded. There is nothing left to steal.
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:
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"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 certificatesRun 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.
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:latestShow solution
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-allgave 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: falsestops checkout leaving a token in.git/configfor later steps to find.- The tag
latestis replaced by the commit SHA, and signing targets the immutable digest. github.workflow_refexpands toacme/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 cior 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/attestgives 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.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.What makes Sigstore signing 'keyless'?
3.Why is `cosign verify` without `--certificate-identity` (or with a regexp of `.*`) insufficient?
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.