Supply chain and integrity
Use pnpm's release delay and build approval, check a package before you add it, pin CI actions to commits, verify CDN scripts and webhooks, and keep an SBOM ready for the next advisory.
What you'll learn
When you finish, you can do these.- 1Set pnpm's release delay, build approval and overrides
- 2Check a package's name and npm page before you add it
- 3Pin CI actions to commits and list every package you ship
- 4Verify outside scripts and webhooks before trusting them
The Corner Shop, the small online shop this track keeps safe, is a Nuxt app (Vue with its own server) that installs several hundred packages: code other people publish on npm, the public registry. On 8 September 2025 a maintainer of two very common ones, chalk and debug, gave his login to a phishing email. That afternoon, the attacker published versions of 18 of his packages that rewrote crypto payments in sites using them. Six days later the Shai-Hulud worm used a post-install script, code that runs when a package is installed, to steal developers' publishing keys and spread to more than 500 packages.
Attacks through code you install are Software Supply Chain Failures, third on the OWASP Top 10:2025, the list of web risks most teams use. The Code tab holds the shop's guards, from settings for pnpm, the tool that installs its packages, to a check on its payment provider's calls. You need one idea from Security misconfiguration, headers and CSP: a Content Security Policy, a header that says which scripts a page may run.
What pnpm 11 and 12 already do
A package reaches your users in five hops, and pnpm guards the first ones.
- Registrynpm, or your company's
- Lockfileexact versions
- CI installfrozen
- Build
- CDNSRI for outside scripts
pnpm blocks both paths those two attacks used: a fresh version and an install script. The release delay, minimumReleaseAge, makes pnpm skip any version published less than a day ago. Your install then gets the newest allowed version that is at least a day old. Most hijacked chalk versions were removed the same day.
Build approval stops install scripts: a package's postinstall script does not run until you allow that package by name, so the Shai-Hulud script would have stayed a file on disk.
Both settings live in pnpm-workspace.yaml, pnpm's settings file in the Code tab. The shop raises the delay to three days, 4320 minutes:
minimumReleaseAge: 4320
allowBuilds:
esbuild: true
simple-git-hooks: false
overrides:
nanoid: ^3.3.18
esbuild may run its install script and simple-git-hooks may not. The last two lines are an override: after an advisory, a public warning about a vulnerable version, it forces a safe version of nanoid, a package the shop's packages pull in.
Commit pnpm-lock.yaml, the lockfile that records every installed version, and install with --frozen-lockfile in CI, the checks a service such as GitHub runs on every push. A build then fails instead of quietly installing something new.
Before you add a package
The release delay cannot help when a package is a trap from its first version, so check it before you install it.
Typosquatting is publishing malware under a near miss of a real name, such as nuxt-securty for nuxt-security. A newer trap comes from AI assistants: they sometimes suggest packages that do not exist, and attackers register those names.
Dependency confusion targets private packages, kept on a company's own registry, such as the shop's @corner-shop/ui. An attacker publishes a public package with the same name, hoping your install picks it. One line in .npmrc, the file that tells pnpm which registry to use, closes that gap:
1@corner-shop:registry=https://npm.pkg.github.com
Every package in the @corner-shop scope, the part of the name before the slash, now comes from the company registry only.
Before pnpm add, which installs a new package, open its page on npmjs.com. Check the repository link, the weekly downloads, the publish date and whether it has install scripts.
CI, outside scripts, SBOM and webhooks
CI steps, scripts from other hosts and calls from other services need checks of their own.
GitHub Actions, the ready-made steps a CI workflow runs, are dependencies too. In actions/checkout@v7, v7 is a tag: a release name that whoever controls the action's repository can move to other code. A full commit SHA, the 40-character id git gives each commit, can only ever mean one commit. The shop's .github/workflows/ci.yml pins every step this way:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Every run gets that exact commit, and the comment names the release.
A software bill of materials (SBOM) lists every package and version in a build. pnpm 12 writes one itself, pnpm sbom --sbom-format cyclonedx, and the shop's workflow keeps one from every build. When the next advisory lands, one search of that file answers "are we affected?" in seconds.
The shop loads a confetti effect from jsDelivr, a public file host called a CDN, and that script runs with the same rights as your own. A Content Security Policy decides which script tags may run, not what the files contain.
Subresource Integrity (SRI) checks the contents. Pin the version in the URL and add an integrity attribute with the file's hash, a fingerprint of its contents, which the browser checks before running it. In Nuxt the tag goes in app.head in nuxt.config.ts:
app: {
head: {
script: [{
src: 'https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js',
integrity: 'sha384-sPwMflxqfAN+Q5mvlkLmHiX3PORGbZSXHiSGPTXT9VHCD/AB+b+r+vJWsqprv+7k',
crossorigin: 'anonymous',
}],
},
},
Compute the hash with curl -s <url> | openssl dgst -sha384 -binary | openssl base64 -A. Keep crossorigin: 'anonymous': without it, the browser cannot check a file from another host and refuses to run it. If the file changes, Chrome refuses it too: Failed to find a valid digest in the 'integrity' attribute for resource.
Integrity also covers data. A webhook is a call another service makes to your server, such as a payment provider saying an order is paid. The shop's server/api/webhooks/payments.post.ts checks its signature, an HMAC: a hash of the exact body made with a secret only the two of you share. Without that check, anyone who finds the URL could mark an order paid. That is A08, Software or Data Integrity Failures.
What goes wrong
Two mistakes switch off a pnpm guard while every install keeps working. The first puts an override where pnpm 10 read it, in package.json, the file that lists the project's packages.
"pnpm": {
"overrides": { "nanoid": "^3.3.18" }
}
The install succeeds and prints [WARN] The "pnpm" field in package.json is no longer read by pnpm. The following keys were ignored: "pnpm.overrides". pnpm why nanoid, which traces every installed copy, still shows the old version. Move it to pnpm-workspace.yaml:
overrides:
nanoid: ^3.3.18
The second is switching approval off to silence an error. pnpm add simple-git-hooks stops with ERR_PNPM_IGNORED_BUILDS and Ignored build scripts: simple-git-hooks@2.14.0, because nobody has approved its install script. One line makes the error go away:
dangerouslyAllowAllBuilds: true
The protection goes with it: every install script of every future dependency runs, including a hijacked one. Decide per package, with pnpm approve-builds, which asks about each one, or by hand:
allowBuilds:
esbuild: true
simple-git-hooks: false
What to remember
- pnpm's release delay and build approval stop fresh versions and install scripts. Keep them and your overrides in
pnpm-workspace.yaml, and never turn approval off wholesale. - Check a new package's name, repository and age before adding it, and map private scopes to your registry in
.npmrc. - Pin actions to commit SHAs, install with
--frozen-lockfile, and keep an SBOM per build. - Add SRI to any script from another host, and verify the signature of every webhook before acting on it.
In your project
Do these in a real project. Each one says how to confirm it worked.- Add an override to
pnpm-workspace.yaml, runpnpm install, thenpnpm why <name>: every path shows a version inside your range. Move it under"pnpm"inpackage.json, and the install warns that the field is ignored. - Pick one
uses:line in your workflow, look up the tag's commit withgit ls-remote https://github.com/actions/checkout refs/tags/v7.0.1, and replace the tag with that SHA plus a# v7.0.1comment. The next run passes, and the step log names the same commit. - Run
pnpm sbom --sbom-format cyclonedx --sbom-type application --out sbom.json, thengrep -m1 -o '"pkg:npm/nuxt@[^"]*"' sbom.json. It prints the exact Nuxt version you ship, such as"pkg:npm/nuxt@4.6.1".