Home/Blog

Software Supply Chain Security: A Practical Defense Guide

Secure your software supply chain: lockfiles, install-script limits, release cooldowns, hardened CI, SBOMs, signed provenance and an incident playbook.

Software Supply Chain Security: A Practical Defense Guide

Software supply chain security means controlling what third-party code enters your build and proving what comes out of it. In practice that is four layers: pin and vet dependencies, keep install-time code away from credentials, harden the CI system that builds and publishes, and sign artifacts with provenance that something downstream actually verifies. The npm attacks of 2025 and 2026 set the priority: attackers now go after maintainer accounts and CI runners, because one stolen publish token reaches every project that installs the next version.

Key takeaways

  • Most supply chain compromises arrive as a new version of a package you already trust. Lockfiles, reviewed lockfile diffs and a release cooldown stop the bulk of them.
  • Install scripts run with the permissions of whoever runs the install. Disable them by default and allowlist the few packages that need them.
  • The CI runner is the most valuable target in your pipeline. Pin actions to commit SHAs, scope permissions per job, and never let untrusted pull request code share a cache or a job with publishing.
  • SBOMs and provenance are only useful if something queries and verifies them. Generate them in CI and check them at deploy time.
  • Valid provenance proves where an artifact was built, not that the code inside it is safe.
  • Write the incident playbook before you need it: find every affected build, rotate every secret those builds could reach, rebuild from a clean state.

How supply chain attacks actually arrive

VectorHow it worksReal exampleWhat stops it
Maintainer account takeoverAttacker phishes or steals a maintainer's credentials and publishes a malicious versionThe September 2025 compromise of chalk, debug and other npm packages after a phishing email; the Axios compromise of March 2026Lockfiles, release cooldown, install-script controls
Self-propagating wormMalware steals publish tokens from machines that install it and republishes the victim's own packagesShai-Hulud on npm in September 2025, and the Mini Shai-Hulud waves of 2026Script controls, short-lived publish credentials, trusted publishing
CI pipeline compromiseAttacker gets code running in your CI through a poisoned action, cache or pull request workflowThe tj-actions/changed-files tag rewrite in March 2025; the TanStack release pipeline in May 2026SHA pinning, per-job permissions, isolated publish jobs
Long-con maintainerAttacker earns commit rights over years, then hides a backdoor in release artifactsThe xz utils backdoor (CVE-2024-3094) in 2024Building from source tags, reproducible builds, distro and vendor review
Dependency confusionA public package with the same name as your internal one wins resolutionAlex Birsan's 2021 research against many large companiesScoped registries, a proxy that pins each name to one source
Typosquatting and slopsquattingA lookalike name, or a name an AI coding assistant hallucinated, is registered with malware insideOngoing on npm and PyPIAllowlists, review of new dependencies, behavior analysis
Build system compromiseThe build server injects code that is not in the sourceSolarWinds (2020)Ephemeral, isolated builds, provenance, independent rebuilds

The pattern across most of the recent incidents: the malicious code arrives as a new version of something you already depend on, it runs at install time, and its first job is to steal credentials, often so it can spread.

Layer 1: Control what enters

Lockfiles, honored everywhere

Commit lockfiles (package-lock.json, pnpm-lock.yaml, yarn.lock, uv.lock, poetry.lock, go.sum, Cargo.lock) and make CI install exactly what they record:

npm ci                              # fails if package.json and the lockfile disagree
pnpm install --frozen-lockfile
yarn install --immutable            # Yarn 2 and later
uv sync --locked                    # fails if uv.lock is out of date
pip install --require-hashes -r requirements.txt
cargo build --locked

Then treat lockfile changes as code. A pull request that bumps one direct dependency but rewrites forty lines of transitive resolutions deserves a look, and a new git URL or tarball URL in a lockfile deserves a hard look. The TanStack payload rode in on a github: specifier inside optionalDependencies precisely because few reviewers read that far.

A release cooldown

The malicious versions in the recent npm incidents were live for hours, sometimes minutes, before they were detected and pulled. If your tooling refuses to adopt any version until it has been public for a few days, your builds were never in that window. Renovate supports this directly:

{
  "extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
  "minimumReleaseAge": "3 days",
  "internalChecksFilter": "strict",
  "packageRules": [
    {
      "matchUpdateTypes": ["major"],
      "dependencyDashboardApproval": true
    }
  ]
}

pnpm has the same idea built into the package manager: minimumReleaseAge, measured in minutes, arrived in pnpm 10.16 and has defaulted to one day since pnpm 11. Raise it in pnpm-workspace.yaml, next to allowBuilds, the allowlist of packages permitted to run install scripts:

minimumReleaseAge: 4320      # minutes: ignore versions published in the last three days
allowBuilds:                 # the only packages allowed to run lifecycle scripts
  esbuild: true
  sharp: true

For Python, uv can resolve as if it were an earlier date with --exclude-newer, which gives you a reproducible cutoff for lock updates.

Security fixes are the exception. When a patched release fixes a vulnerability you are exposed to, bypass the cooldown deliberately and read the diff.

Vetting new dependencies

Adding a dependency is a trust decision about every maintainer in its tree. Before a new package lands, check:

  • Who publishes it, and whether releases come with provenance (npm shows it on the package page, and npm audit signatures checks it locally).
  • Whether it has install scripts, and whether a utility library has any reason to touch the network or the filesystem.
  • How deep its own dependency tree is, and whether any of it is single-maintainer and abandoned.
  • Its OpenSSF Scorecard, which checks practices like branch protection, pinned dependencies and signed releases.
  • Whether twenty lines of your own code would do the job.
npm view some-package scripts       # any preinstall, install or postinstall entries?
npm audit signatures                # registry signatures and provenance for installed packages
docker run --rm -e GITHUB_AUTH_TOKEN gcr.io/openssf/scorecard:stable \
  --repo=github.com/some-org/some-package

Behavior analysis tools such as Socket flag new network calls, obfuscated code and install scripts on dependency update pull requests, which is exactly where you want a second pair of eyes.

Private package names

Dependency confusion only works when a resolver can choose between your internal registry and the public one. Remove the choice. In npm, a scope binds to one registry:

@acme:registry=https://npm.internal.acme.com/
//npm.internal.acme.com/:_authToken=${NPM_INTERNAL_TOKEN}

In Python, --extra-index-url is the classic trap because pip merges the indexes and picks the highest version from either. With uv, pin internal packages to an explicit index:

[[tool.uv.index]]
name = "internal"
url = "https://pypi.internal.acme.com/simple"
explicit = true
 
[tool.uv.sources]
acme-auth = { index = "internal" }

Where the public registry's policy allows it, register your internal names there as placeholders too.

Layer 2: Keep install-time code away from secrets

Most of the malicious packages in the recent npm waves did their work in a lifecycle script or a build step, on the machine running the install. On a developer laptop that machine holds SSH keys, cloud credentials and browser sessions. On a CI runner it holds deploy secrets and often a token that can publish packages.

  • Disable install scripts by default. Since version 10, pnpm does not run dependency lifecycle scripts unless the package is allowlisted, with allowBuilds in current releases. With npm, set ignore-scripts=true in .npmrc and run the few build steps you need explicitly.
  • Install without secrets in the environment. In CI, run the dependency install in a job that has no deploy credentials. Secrets needed only for deployment belong only in the deploy job.
  • Isolate developer installs. Dev containers, or a separate OS user for package installs, keep a malicious postinstall away from ~/.ssh and ~/.aws. It is friction, so start with the people who hold publish rights.
  • Prefer short-lived credentials. Long-lived npm and PyPI tokens on laptops are exactly what the worms harvest. Publish through trusted publishing (OIDC from CI), and use short-lived, narrowly scoped tokens where you must use tokens at all.

Layer 3: Harden the CI that builds and publishes

CI is the most attractive target you run: it executes code from many sources and holds credentials to production and to package registries. Our write-up of the TanStack and AntV attacks walks through a real chain. The workflow below is shaped to break chains like it. Replace each PINNED_SHA with the full commit SHA of the release you reviewed, and let Renovate keep them current.

name: release
on:
  push:
    tags: ["v*"]
 
permissions: {}                      # nothing by default; each job asks for what it needs
 
jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@PINNED_SHA          # v7
        with:
          persist-credentials: false
      - uses: actions/setup-node@PINNED_SHA        # v7
        with:
          node-version: 24
      - run: npm ci --ignore-scripts
      - run: npm run build
      - run: npm test
      - uses: actions/upload-artifact@PINNED_SHA   # v7
        with:
          name: dist
          path: dist/
 
  publish:
    needs: build
    runs-on: ubuntu-latest
    environment: npm-publish         # protected: required reviewers, tag-only deployments
    permissions:
      contents: read
      id-token: write                # OIDC for trusted publishing and provenance
    steps:
      - uses: actions/checkout@PINNED_SHA
        with:
          persist-credentials: false
      - uses: actions/setup-node@PINNED_SHA
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org
      - uses: actions/download-artifact@PINNED_SHA # v8
        with:
          name: dist
          path: dist/
      - run: npm publish --provenance --access public

What each choice buys you:

  • Commit SHA pins. A tag like @v4 can be moved by whoever controls the action's repository, which is exactly what happened with tj-actions/changed-files. A full SHA cannot.
  • Empty default permissions. Each job gets only what it needs. The build job cannot push to the repository or mint OIDC tokens.
  • Build and publish are separate jobs. Third-party code runs in the build job, which holds no publish credentials. The publish job runs no dependency install at all.
  • A protected environment for publishing. Required reviewers and tag rules mean a compromised workflow on some branch cannot reach the publish credentials.
  • No pull_request_target that checks out pull request code. That combination runs untrusted code with the base repository's privileges and cache scope. If you need the trigger for labeling or comments, never build the pull request head inside it.
  • Caches do not cross trust boundaries. Do not restore, in a release job, a cache that untrusted workflows can write. When in doubt, build releases without a cache.

Self-hosted runners should be ephemeral, one job per VM or container, with restricted egress so that a stolen secret has nowhere to go. GitHub-hosted runners are already ephemeral; egress filtering needs a tool such as StepSecurity's Harden-Runner or your own network controls.

Layer 4: SBOMs, signatures and provenance, verified

An SBOM tells you what is inside an artifact. A signature tells you who produced it. Provenance tells you how, and from which source, it was built. Each is only worth something if a later step checks it.

Generate them in CI:

syft ghcr.io/acme/api:1.8.2 -o cyclonedx-json=sbom.cdx.json          # SBOM for the image
grype sbom:sbom.cdx.json --only-fixed --fail-on high                  # fail on fixable highs
cosign sign --yes ghcr.io/acme/api@sha256:IMAGE_DIGEST                # keyless Sigstore signature
cosign attest --yes --type cyclonedx --predicate sbom.cdx.json \
  ghcr.io/acme/api@sha256:IMAGE_DIGEST                                # SBOM as a signed attestation

On GitHub, the actions/attest action (which actions/attest-build-provenance now wraps) produces a signed SLSA provenance attestation for any artifact, and consumers check it with the GitHub CLI:

gh attestation verify oci://ghcr.io/acme/api:1.8.2 --owner acme

Verify at deploy time, not only at build time. In Kubernetes, an admission policy (Kyverno image verification rules or the Sigstore policy-controller) can refuse any image not signed by your release workflow. The check it performs is equivalent to:

cosign verify ghcr.io/acme/api@sha256:IMAGE_DIGEST \
  --certificate-identity-regexp '^https://github.com/acme/api/.github/workflows/release.yml@refs/tags/v' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

What the SLSA levels actually mean

SLSA (Supply-chain Levels for Software Artifacts) is the common vocabulary for build integrity. Since version 1.0, its Build track has three levels. The older four-level model still quoted in many articles is retired.

LevelRequirementWhat it protects against
Build L1Provenance exists and describes how the artifact was builtConfusion and mistakes about what was shipped
Build L2Built on a hosted build platform that signs the provenanceTampering with the artifact after the build
Build L3Hardened build platform: runs are isolated from each other, and signing material is out of reach of build stepsTampering during the build by insiders, compromised credentials or other tenants

GitHub artifact attestations target Build L2 out of the box and Build L3 when the build runs in a reusable workflow. The TanStack incident is the caveat to remember: those packages carried valid provenance because the attacker ran code inside the legitimate pipeline. Provenance proves where something was built. It does not prove the code was safe.

Known vulnerabilities in dependencies

Known-vulnerable dependencies are a different problem from malicious ones, and mostly a prioritization problem. Scan continuously (OSV-Scanner, Grype, Trivy, Dependabot or Snyk), then rank findings by:

  • Exploitation: is it in CISA's Known Exploited Vulnerabilities catalog, and is its EPSS score meaningful?
  • Reachability: does your code call the vulnerable function, or is it a transitive dependency of a dev tool?
  • Exposure: a network-facing service, an internal batch job, or build time only?
  • Fix cost: a patch release you can take today, or a major upgrade?

Treat a brand new critical score with some skepticism until you have read the record itself. Our post on the fabricated SQLite CVE that scored 9.8 shows why.

osv-scanner scan source -r .                                           # every lockfile in the repo
trivy image --severity HIGH,CRITICAL --exit-code 1 ghcr.io/acme/api:1.8.2

Regulation is catching up

Supply chain controls are moving from good practice into law and procurement:

  • EU Cyber Resilience Act. Regulation (EU) 2024/2847 entered into force in December 2024. Manufacturers' obligations to report actively exploited vulnerabilities and severe incidents apply from 11 September 2026, and the main obligations, including identifying components by drawing up a software bill of materials, apply from 11 December 2027. If you sell software with digital elements into the EU, SBOMs and vulnerability handling stop being optional.
  • US federal procurement. Executive Order 14028 in 2021 pushed secure development attestations and SBOMs into federal software procurement, anchored on NIST's Secure Software Development Framework (SP 800-218). In January 2026, OMB memo M-26-05 dropped the mandatory common attestation form in favor of risk-based checks set by each agency, which can still require an SBOM on request.

What goes wrong in production

  • npm install in CI. It can rewrite the lockfile and resolve new versions. Use npm ci or the frozen-lockfile equivalent, and fail the build if the lockfile would change.
  • Dependency bots that auto-merge with no cooldown. A bot that merges every patch release within minutes is an automated delivery channel for the next compromised version. Add a cooldown and require tests to pass.
  • Secrets available to the install step. The dependency install runs in the same job and environment as the deploy, so one malicious postinstall reads everything.
  • Shared caches across trust levels. A cache written by a pull request workflow and restored by the release workflow is a write path into your release.
  • SBOMs nobody can query. Teams generate SBOMs for compliance and store them where no one can search them. When the next incident lands, "which services ship version X?" should take one query, not a week.
  • Provenance nobody verifies. Signing without verification is ceremony. Verification belongs at the deploy gate or in the admission controller.
  • Mutable base images. FROM node:24 changes underneath you. Pin base images by digest and let your update bot move the digest.
  • Proxies that remember. An internal registry proxy can keep serving a malicious version after the public registry removes it. The playbook must purge it from the proxy as well.

When a dependency is compromised: the playbook

  1. Scope exposure. Query your SBOM store and lockfiles for the affected package and versions across every repository, image and environment, including developer machines and CI runners, not only deployed services.
  2. Find the window. Which builds and installs ran while the malicious version was available? CI logs and lockfile history give you the list.
  3. Assume the secrets are gone. Rotate every credential present on any machine that ran the install: CI secrets, cloud keys, registry tokens, SSH keys. Rotation comes before investigation, not after.
  4. Block and purge. Pin to a known-good version, block the bad versions in your proxy registry and purge them from every cache.
  5. Rebuild clean. Rebuild affected artifacts from known-good inputs on fresh runners, redeploy, and retire artifacts built during the window.
  6. Hunt for persistence. Check the published indicators for the incident: new repositories or workflow files, unexpected package publishes from your accounts, unusual outbound connections from runners.
  7. Tell people. Notify customers whose data or builds may be affected and, where the CRA or your contracts require it, regulators and downstream users within the required timelines.

Frequently Asked Questions

What is software supply chain security?

Software supply chain security is the practice of controlling and verifying everything that goes into building and shipping your software: third-party dependencies, build tools, CI pipelines and the artifacts you publish. It covers keeping malicious or vulnerable code out, protecting the build from tampering, and giving consumers a way to verify what they receive. Most current attacks target package maintainer accounts and CI runners rather than your own source code.

What is an SBOM and do I need one?

A software bill of materials (SBOM) is a machine-readable inventory of the components in a piece of software, usually in SPDX or CycloneDX format. You need one to answer quickly which of your services contain a vulnerable or compromised package, and increasingly because customers and regulations such as the EU Cyber Resilience Act expect it. Generate it in CI for every release and store it somewhere you can query.

What is SLSA?

SLSA (Supply-chain Levels for Software Artifacts) is an OpenSSF framework for build integrity. Its Build track defines three levels: provenance that exists (L1), provenance signed by a hosted build platform (L2), and a hardened build platform where builds are isolated and cannot reach the signing keys (L3). SLSA proves how and where an artifact was built, not that its source code was safe.

How do you prevent dependency confusion attacks?

Make sure your package manager can never choose between a public and a private source for the same package name. Use scoped packages bound to your internal registry in npm, explicit per-package indexes in Python tooling such as uv, and a proxy registry that knows which names are internal. Registering your internal names on public registries as placeholders adds a second layer where the registry allows it.

Should I pin GitHub Actions to commit SHAs?

Yes. A tag such as v4 can be moved to a different commit by anyone who controls the action's repository, which is how the tj-actions/changed-files compromise reached the workflows that used it in March 2025. A full commit SHA cannot be moved. Let Renovate or Dependabot update the pinned SHAs so you still receive fixes.

Need help securing your supply chain?

We build, ship and operate production systems, so our pipelines are on the receiving end of these attacks too. We harden dependency intake, CI and release signing the way we run our own: enforced by configuration, verified at deploy time, and owned by engineers who are on the pager when something gets through.