Software Supply Chain Security: SBOMs, Sigstore, and Lockfile Attacks
How typosquatting, dependency confusion, and lockfile tampering compromise builds, and how SBOMs, Sigstore signing, and SLSA-aligned CI pinning stop them.
Modern applications are assembled, not written. A typical Node or Python service might contain a few thousand lines of first-party code sitting on top of hundreds of transitive dependencies, most of which nobody on the team has read. That ratio is exactly why software supply chain attacks have become one of the highest-leverage ways to compromise organizations: instead of attacking one target, an attacker who poisons a popular package compromises every downstream project that installs it, often silently, at build time, with the victim’s own CI credentials.
This article covers the two ends of the problem: how packages get poisoned in the first place (typosquatting, dependency confusion, lockfile tampering) and how to build a pipeline that would catch or block each of those techniques — SBOMs, Sigstore-based signing, SLSA levels as a maturity model, and CI pinning discipline.
How Packages Get Poisoned
Typosquatting
An attacker publishes a package with a name deliberately close to a popular one — reqeusts instead of requests, expresss instead of express, cross-env.js instead of cross-env. A developer typing quickly, or a script with a typo that nobody caught in review, installs the malicious package instead of the intended one.
# The typo that ships a backdoor instead of the real package
npm install expresss # attacker-controlled package, one character off
Typosquatted packages typically mirror the real package’s functionality closely enough to avoid immediate suspicion, while quietly exfiltrating environment variables, SSH keys, or CI secrets during install (via a postinstall script) or at runtime.
Dependency Confusion
Dependency confusion targets organizations that use both public registries and internal/private package names. If a company has an internal package named @acme/auth-utils that is never published to the public npm registry, an attacker can publish a public package with that exact name. If the build tooling isn’t configured to prefer the internal registry unconditionally, and the public package has a higher version number, package managers will often resolve to the public (malicious) one instead.
# package.json - vulnerable if the registry resolution isn't scoped explicitly:
# "dependencies": { "@acme/auth-utils": "^1.0.0" }
# .npmrc - the fix: scope the private namespace to the internal registry, unconditionally
@acme:registry=https://npm.internal.acme.example/
//npm.internal.acme.example/:_authToken=${NPM_INTERNAL_TOKEN}
Without that scoped registry mapping, a single stray public package with a higher semver number silently wins dependency resolution, and the attacker’s postinstall script runs with your build’s permissions.
Lockfile Tampering
Lockfiles (package-lock.json, yarn.lock, poetry.lock, Pipfile.lock) exist to make installs reproducible and to pin exact resolved versions and integrity hashes. Two failure modes undermine that guarantee:
- The lockfile isn’t committed or isn’t enforced in CI, so
npm installre-resolves versions against the registry at build time instead of using pinned entries fromnpm ci. A “patch” release published between your last local install and the CI build can introduce a compromised version transparently. - The lockfile is tampered with directly — a malicious pull request edits the resolved URL or integrity hash for one dependency to point at an attacker-controlled tarball, betting that reviewers will skim a multi-thousand-line lockfile diff without checking each hash.
# WRONG in CI: re-resolves dependencies, lockfile is advisory only
npm install
# RIGHT in CI: fails if package.json and the lockfile disagree, uses pinned integrity hashes exactly
npm ci
npm ci (and its equivalents — yarn install --frozen-lockfile, pip install --require-hashes, poetry install --no-update) refuses to proceed if the lockfile doesn’t match the manifest, closing the “silently re-resolved” gap. It does nothing, however, against a maliciously edited lockfile that was merged in good faith — that’s a code review and CI verification problem, addressed below.
SBOMs: Knowing What You Actually Ship
A Software Bill of Materials is a structured, machine-readable inventory of every component in a build: direct and transitive dependencies, their exact versions, license information, and often cryptographic hashes. The two dominant formats are CycloneDX and SPDX; both are widely tool-supported and neither is objectively “better” — pick based on what your downstream consumers or compliance regime expects.
# Generate a CycloneDX SBOM for a Node project
npx @cyclonedx/cyclonedx-npm --output-file sbom.cdx.json
# Generate an SBOM directly from a built container image (works for any language)
syft packages docker:my-app:1.4.2 -o cyclonedx-json > sbom.cdx.json
# Generate an SPDX SBOM instead, same source
syft packages docker:my-app:1.4.2 -o spdx-json > sbom.spdx.json
An SBOM by itself doesn’t stop an attack — it’s a record, not a control. Its value shows up downstream: when a new CVE is disclosed against a library, you can grep your fleet of SBOMs for that exact version instead of manually auditing every service’s dependency tree, and when an incident happens, the SBOM tells you precisely what was running at that point in time.
# Check a previously generated SBOM against known vulnerabilities
grype sbom:sbom.cdx.json
# Fail a CI job if any high/critical vulnerability is found
grype sbom:sbom.cdx.json --fail-on high
Treat SBOM generation as a build artifact, not an afterthought: generate one per release, store it alongside the built image or package, and keep historical SBOMs so you can answer “were we exposed to this CVE six months ago” without re-checking out old commits.
Sigstore and cosign: Proving Where an Artifact Came From
An SBOM tells you what’s inside a build. Signing tells you the build actually came from your pipeline and hasn’t been swapped for something else between build and deploy. Sigstore’s cosign is the tool most teams reach for now, largely because its “keyless” signing mode ties a signature to an OIDC identity (your CI provider’s token) instead of a long-lived private key that itself becomes something to protect and rotate.
# Sign a container image using keyless signing (CI identity via OIDC)
cosign sign my-registry.example/my-app:1.4.2
# Verify the signature and that it came from your expected CI identity
cosign verify my-registry.example/my-app:1.4.2 \
--certificate-identity "https://github.com/acme/my-app/.github/workflows/build.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
The verification step is the part teams skip, and it’s the part that actually matters: signing an artifact is worthless if your deploy step never checks the signature before pulling it. Wire verification into the deploy path itself, not just into a CI job whose output nobody consults.
# GitHub Actions - sign then attach an SBOM as a companion attestation
- name: Build and push image
run: docker buildx build --push -t $REGISTRY/my-app:${{ github.sha }} .
- name: Sign image (keyless)
run: cosign sign --yes $REGISTRY/my-app:${{ github.sha }}
- name: Attach SBOM as an in-toto attestation
run: cosign attest --yes --predicate sbom.cdx.json --type cyclonedx $REGISTRY/my-app:${{ github.sha }}
cosign attest binds the SBOM itself to the signed image as an attestation, so a verifier can confirm both “this image came from our pipeline” and “this is the exact dependency list it was built with,” in one check.
SLSA Levels: A Maturity Model, Not a Checkbox
SLSA (Supply-chain Levels for Software Artifacts) describes increasing levels of build integrity guarantees rather than a single pass/fail bar. Conceptually:
| Level | What it guarantees | Practical translation |
|---|---|---|
| SLSA 1 | Build process is documented and produces provenance metadata | You generate some record of how the artifact was built |
| SLSA 2 | Build runs on a hosted/managed build service; provenance is signed | Builds happen in CI (not a laptop), and the provenance can’t be silently forged |
| SLSA 3 | Build platform is hardened against tampering; provenance is non-forgeable even by the build service’s own users | Isolated, ephemeral build environments; no persistent access that could tamper with a specific build |
| SLSA 4 (highest) | Two-person review of all changes, hermetic and reproducible builds | Reserved for the most sensitive software; most teams won’t reach this and don’t need to |
Don’t treat this as a certification to chase all at once. For most small teams, moving from “builds happen on someone’s laptop and get manually uploaded” to “builds happen in CI, produce a signed SBOM, and are verified before deploy” is the jump from SLSA 0 to somewhere around SLSA 2 — and it’s the jump that closes off the most realistic attack paths (a compromised laptop, an unaudited manual release step).
CI Pinning Discipline
Everything above assumes your CI pipeline itself is trustworthy. A pipeline that pulls its own tooling unpinned is a supply chain attack surface in its own right.
# WEAK: floating tags and an unverified piped script
# steps:
# - uses: actions/checkout@v4
# - uses: some-org/some-action@main # mutable ref - can change under you
# - run: curl -sL https://get.some-tool.sh | bash # unpinned, unverified script
# HARDENED: every action pinned to a commit SHA, every download verified
steps:
- uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- uses: some-org/some-action@a1b2c3d4e5f6... # pinned commit, not a tag
- run: |
curl -sL -o tool.tar.gz https://get.some-tool.sh/tool-1.2.3.tar.gz
echo "expectedsha256hash tool.tar.gz" | sha256sum -c -
tar xzf tool.tar.gz
Pin third-party GitHub Actions to a full commit SHA, not a tag — tags are mutable and a compromised action maintainer (or a compromised account) can repoint v4 to malicious code without you ever seeing a version bump. Apply the same standard to any script piped into a shell during CI: download to a file, verify its checksum against a value you pinned separately, then execute it.
Key Takeaways
- Typosquatting and dependency confusion both exploit ambiguity in name resolution; scope private package namespaces to your internal registry explicitly rather than relying on the public registry to “just not have” a conflicting name.
- Enforce
npm ci/--frozen-lockfile/--require-hashesin CI so builds fail loudly on any drift between the manifest and the lockfile, instead of silently re-resolving dependencies. - Generate an SBOM (CycloneDX or SPDX) per release and keep it alongside the built artifact — it’s the record you’ll need when the next widely-disclosed dependency CVE lands.
- Sign artifacts with Sigstore/cosign using keyless (OIDC-based) signing, and — critically — verify the signature at deploy time, not just at build time.
- Attach the SBOM to the signed image as an attestation so provenance and dependency inventory travel together.
- Use SLSA levels as a roadmap, not a certification to chase all at once; moving from ad-hoc builds to CI-only, signed, verified builds closes most realistic attack paths.
- Pin every third-party CI action to a commit SHA and every shell-piped install script to a checksummed download — a CI pipeline with floating references is itself a supply chain risk.