Your Build Pipeline Is the Attack Surface: Practical Software Supply Chain Defense

When people picture a software supply chain attack, they usually imagine malicious code sneaking into a dependency. In practice, most real compromises happen one step further down: not in your code, but in the machinery that fetches, builds, and publishes it. Hijacked maintainer credentials, look-alike package names, and CI pipelines that run untrusted pull requests with too many permissions have all produced high-profile incidents in recent years. The 2021 dependency confusion incidents, where researchers uploaded private package names to public registries and got internal builds to download them automatically, were a wake-up call — and yet most pipelines today still contain the exact configuration those attacks exploited.

The good news is that effective defense is mostly configuration, not cryptography research. This post walks through the five changes that eliminate the majority of build-path risk: pinning your dependencies, pinning your pipeline, replacing long-lived publish tokens with OIDC, signing artifacts, and scanning continuously. None of them require a dedicated security team; all of them can be landed in a week of incremental pull requests.

Pin the Inputs

The first rule is that builds must be reproducible. A lockfile commits the exact version and integrity hash of every transitive dependency, and CI should refuse to install anything that is not in it. For npm, use npm ci rather than npm install — it deletes node_modules, installs strictly from the lockfile, and fails if the two disagree. Go modules achieve the same guarantee through go.sum, which the toolchain verifies on every build.

npm ci --ignore-scripts

go mod verify

The --ignore-scripts flag deserves a comment. Many historical npm compromises shipped their malicious payload in a package’s postinstall script, which executes on install. Skipping lifecycle scripts during CI installs removes an entire class of attack with a single flag — at the cost of breaking the rare dependency that legitimately needs a build step, which you can then evaluate explicitly rather than trust blindly.

Lockfiles only help if they are actually committed. A repository where CI runs a bare resolve step on every build is an open invitation: a newly published look-alike version of any dependency will be picked up automatically. That is precisely how the dependency confusion attacks worked — internal builds resolved a version newer than any internal one, and the public registry had it.

Pin the Pipeline

Your dependencies are pinned, but what about the actions your workflow itself runs? A reference like actions/checkout@v7 points at a mutable tag; if the account publishing that action is compromised, the tag can be re-pointed at malicious code, and your pipeline executes it with whatever secrets the job holds. GitHub’s own security hardening guide recommends pinning third-party actions to a full commit SHA. SHAs are immutable, so the code you reviewed is the code that runs.

While you are in there, restrict workflow permissions. Jobs should default to read-only and explicitly request only what they need:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
      - run: npm ci --ignore-scripts
      - run: npm test

A job that can only read source code cannot exfiltrate a GITHUB_TOKEN with write access, cannot deploy, and cannot publish. If a build step needs more, grant it to that one job — not to every workflow in the repository via the top-level default.

Retire Long-Lived Publish Tokens

Historically, publishing a package meant storing a long-lived registry token as a CI secret. Those tokens do not expire, they can be copied from a compromised runner, and a single leaked token grants the attacker the maintainer’s publish rights — which is how several hijacked packages reached production over the years.

The modern replacement is trusted publishing, generally available on npm since July 2025. Instead of a token, your workflow receives a short-lived OpenID Connect token minted by GitHub for that specific run; the registry verifies which repository, workflow, and commit it came from before accepting the publish. PyPI has offered the same mechanism for years, documented in its trusted publishers guide. The workflow needs exactly one elevated permission:

permissions:
  id-token: write

jobs:
  publish:
    runs-on: ubuntu-latest
    environment: release
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org
      - run: npm ci --ignore-scripts
      - run: npm publish

Note what is absent: no NODE_AUTH_TOKEN, no secret to rotate, nothing for an attacker to steal. A stolen OIDC token is worthless minutes later and only ever authenticates one workflow file in one repository. When publishing through trusted publishing, the npm CLI also attaches a provenance attestation to the package automatically — a signed statement, aligned with the SLSA framework, recording exactly which repository and commit built the artifact. Consumers can then verify that the tarball they install came from where it claims.

Sign What You Ship

Provenance covers npm packages. For container images, the equivalent is Sigstore cosign, which signs images and stores the signatures in an append-only transparency log. Its keyless mode signs with a short-lived certificate bound to your OIDC identity — again, no key material stored in CI:

cosign sign --yes registry.example.com/acme/api:1.42.0

Verification then happens at deploy time, and this is the part most teams skip. A signature only helps if something checks it before the image runs:

cosign verify registry.example.com/acme/api:1.42.0 \
  --certificate-identity-regexp https://github.com/acme/api/.github/workflows/release.yml@.* \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

This command confirms three things: the image was signed by a certificate issued to your release workflow, the certificate was valid at signing time, and the signature exists in the public transparency log. Wire it into your deployment job, or enforce it in-cluster with a policy engine such as Kyverno so that unsigned or foreign-signed images are rejected at admission. The signing details, including key-based variants and KMS integrations, are in the cosign container signing guide.

Scan Continuously

Everything so far prevents new attacks. But dependencies already in your tree acquire vulnerabilities over time, which makes scanning a standing requirement rather than a one-off audit. Dependabot can open automated pull requests on GitHub, and OSV-Scanner matches your lockfiles against the open OSV vulnerability database — across 19+ lockfile formats, including vendored and container-scoped dependencies. A CI job that fails the build on new known CVEs turns “we should really update that” into a gate:

osv-scanner scan source -r .

Pair scanning with npm audit signatures, which checks that every installed npm package carries a valid registry attestation, and you have closed the loop: you know what you depend on, that it was built where it claims, and that it carries no known critical vulnerabilities.

The One-Week Checklist

If you can only do five things, do these, roughly in order of risk eliminated: commit lockfiles and install with npm ci --ignore-scripts (or verify go.sum); pin every third-party GitHub Action to a full commit SHA; move package publishing to trusted publishing and delete the stored registry token; sign container images with cosign keyless mode and verify at admission; and add a lockfile vulnerability scan to CI. Each change is a small pull request, and together they convert your build pipeline from the softest target in the organization into one an attacker has to actually work against.

Leave a Reply

Your email address will not be published. Required fields are marked *