Web security

Supply chain and dependency hygiene

What the 2025 npm attacks changed, OWASP's new supply-chain category, lockfiles and frozen installs, pnpm's release-age delay and blocked install scripts, provenance and trusted publishing, update automation with cooldowns, CI with least privilege, SBOMs, and how to keep Nuxt and its ecosystem patched.

In September 2025 a phishing attack on a single maintainer pushed credential-stealing versions of chalk, debug and sixteen other packages with billions of weekly downloads, and a self-propagating worm ("Shai-Hulud") followed, harvesting npm and cloud tokens from developer machines and CI. OWASP added Software Supply Chain Failures to the Top 10 in its 2025 edition. Interviewers now ask "how is your dependency pipeline protected", and "we run npm audit" is no longer an answer.

Know

  • The attack surface: a compromised maintainer account (phishing, token theft), a malicious or typosquatted package, a hijacked abandoned package, install scripts that run arbitrary code on install, dependency confusion (a public package shadowing an internal name), a poisoned lockfile in a pull request, and CI secrets exposed to untrusted code. Transitive dependencies mean you inherit all of it hundreds of times over.
  • Lockfiles and frozen installs: commit the lockfile, install with pnpm install --frozen-lockfile (or npm ci) in CI so a build cannot silently resolve a newer version; review lockfile diffs in PRs (lockfile-lint or a bot that flags registry or integrity changes).
  • Release-age delay: pnpm's minimumReleaseAge (added in 10.16, September 2025; one day by default since pnpm 11, April 2026) refuses to install versions published less than N minutes ago, with minimumReleaseAgeExclude for your own packages; most malicious releases are detected and pulled within hours, so a 1–3 day delay removes most of the risk at almost no cost. Renovate's minimumReleaseAge does the same for automated updates.
  • Install scripts: pnpm 10 does not run lifecycle scripts of dependencies by default; approve the few that need them (allowBuilds in pnpm-workspace.yaml since pnpm 11, previously onlyBuiltDependencies, or pnpm approve-builds). With npm, --ignore-scripts plus explicit rebuilds. Install scripts were the payload in the 2025 attacks.
  • Provenance and signatures: packages published with npm publish --provenance from CI carry a Sigstore attestation linking the tarball to a source commit and workflow; npm audit signatures verifies registry signatures and attestations. Trusted publishing (OIDC from GitHub Actions to npm, no long-lived tokens) is how you publish your own packages; enable 2FA and granular tokens everywhere else.
  • Vulnerability scanning is necessary and insufficient: pnpm audit/npm audit report known CVEs with much noise (dev-only paths, unreachable code); behavioural scanners (Socket, Snyk, GitHub's dependency review) flag install scripts, network access, obfuscation and maintainer changes, which is where novel attacks appear. Use pnpm audit --prod for what ships, and overrides or pnpm patch to fix a transitive vulnerability before upstream does.
  • Update cadence: Renovate or Dependabot with grouped PRs, a cooldown, and CI running the full test suite; security updates fast, feature updates weekly; read release notes for the framework. Nuxt shipped security releases on the 4.4 and 4.5 lines: subscribe to nuxt/nuxt advisories and upgrade with npx nuxt upgrade.
  • Fewer, better dependencies: prefer the platform (fetch, Intl, structuredClone, crypto.subtle) and framework built-ins over micro-packages; check maintainer count, release history and install scripts before adding; pnpm why to understand why something is in the tree; remove what nothing uses.
  • CI hardening: permissions: least privilege per workflow (contents: read), pin third-party actions to a commit SHA, never run untrusted PR code with secrets (pull_request_target is dangerous), separate build and publish jobs, short-lived OIDC credentials to cloud providers, secrets never echoed, artifacts attested. Developer machines are part of the chain: separate npm tokens, no .npmrc tokens in dotfiles synced to cloud drives.
  • Inventory: an SBOM (CycloneDX or SPDX) generated at build for incident response ("are we affected by X?") and licence compliance; the dependency graph in GitHub does part of this.
  • Layers and modules you publish are someone else's supply chain: provenance, 2FA, small dependency trees, no install scripts, and a security policy file so researchers can reach you.

How it works

Repository configuration that would have blocked the 2025 attacks for most consumers:

pnpm-workspace.yaml
# pnpm 11+: settings live here, not in .npmrc. Never install a version younger than 3 days (default: 1 day); own packages exempt
minimumReleaseAge: 4320
minimumReleaseAgeExclude:
  - '@team/*'
# dependencies' lifecycle scripts are blocked by default; approve explicitly
allowBuilds:
  esbuild: true
  sharp: true
# CI installs with `pnpm install --frozen-lockfile` (or `pnpm ci`, pnpm 11+) so nothing resolves anew
package.json (excerpt)
{
  "engines": { "node": ">=24 <27", "pnpm": ">=11" },
  "packageManager": "pnpm@10.20.0",
  "pnpm": {
    "overrides": { "vulnerable-transitive": ">=2.1.4" }
  }
}
renovate.json
{
  "extends": ["config:recommended", ":semanticCommits"],
  "minimumReleaseAge": "3 days",
  "lockFileMaintenance": { "enabled": true, "schedule": ["before 6am on monday"] },
  "packageRules": [
    { "matchUpdateTypes": ["patch"], "groupName": "patch updates", "automerge": true },
    { "matchPackageNames": ["nuxt", "@nuxt/*", "vue", "nitropack"], "groupName": "nuxt", "schedule": ["every weekend"] }
  ],
  "vulnerabilityAlerts": { "enabled": true, "minimumReleaseAge": null }
}

A CI workflow with least privilege, pinned actions and frozen installs, plus trusted publishing for an internal layer:

.github/workflows/ci.yml
name: CI
on: [pull_request, push]
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad5a7be3e2a5ef0a # v5.x, pinned to a SHA
      - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # pinned
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # pinned
        with: { node-version-file: .node-version, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm audit --prod --audit-level=high
      - run: pnpm lint && pnpm typecheck && pnpm test
  publish:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write            # OIDC: trusted publishing, no stored npm token
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad5a7be3e2a5ef0a
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
        with: { node-version-file: .node-version, registry-url: https://registry.npmjs.org }
      - run: pnpm install --frozen-lockfile && pnpm build
      - run: pnpm publish --provenance --access restricted --no-git-checks

Checking what you actually run, and reacting to an advisory:

pnpm why chalk                          # who pulls it in, at which versions
pnpm audit --prod                       # known CVEs in shipped code only
npm audit signatures                    # registry signatures + provenance attestations
pnpm ls --depth=Infinity --json | jq '.. | objects | select(has("scripts")) | .name' | sort -u   # who has install scripts
pnpm patch some-dep@1.2.3               # hotfix a transitive dependency until upstream releases
npx nuxt upgrade --dedupe               # framework security release
Gotcha· `npm audit` says 0 vulnerabilities

Audit compares versions against a database of known vulnerabilities; the malicious chalk versions were clean by that measure for the hours they were live. Release-age delays, blocked install scripts, provenance checks and behavioural scanning are what address unknown-malicious releases; audit remains useful for the known ones.

Gotcha· Pinning actions by tag

uses: some/action@v3 follows a tag that the action's maintainer, or an attacker with their credentials, can move to malicious code. Pin to a full commit SHA and let Renovate update the pin; the tag can stay in a comment for readability.

Verify before the interview:

The current pnpm minor and the exact minimumReleaseAge semantics (minutes), the default for dependency lifecycle scripts, and npm's trusted-publishing rollout status, as of the interview week.

docs ↗

Exercise

Exercise
  • Add minimum-release-age to a project and run pnpm update --latest; observe which versions are held back and why.
  • List every dependency with install scripts in a project you own and decide for each whether it needs to run.
  • Pin the actions in one workflow to SHAs, set permissions: contents: read, and confirm the workflow still passes; then try pnpm install without --frozen-lockfile after editing the lockfile by hand and watch CI catch it.

Be able to say

Be able to say· How is your dependency supply chain protected?

"Assume any package can turn malicious for a few hours, because in 2025 the most-downloaded ones did. So: the lockfile is committed and CI installs with --frozen-lockfile; pnpm's minimumReleaseAge holds back versions younger than a few days, which covers the window in which malicious releases get caught; dependencies' install scripts are blocked by default and only a handful are approved; updates come through Renovate with a cooldown and grouped PRs that run the full suite, with security updates exempted from the delay; pnpm audit --prod catches known CVEs and a behavioural scanner flags install scripts and network access in new releases. CI runs with least-privilege permissions, actions pinned to SHAs, no secrets exposed to untrusted pull requests, and our own packages publish with provenance through OIDC trusted publishing. And I keep the tree small, preferring platform and framework built-ins to micro-packages."

Be able to say· What did the 2025 npm incidents teach the ecosystem?

"That the weak point is people and tokens, not code review. A phished maintainer account pushed malicious versions of packages with billions of weekly downloads, and a worm then spread by stealing tokens from developers' machines and CI and publishing more infected packages. The responses that stuck: npm moved to granular tokens, mandatory 2FA and trusted publishing via OIDC; pnpm added a minimum release age and kept install scripts blocked by default; OWASP made supply chain failures a Top 10 category; and teams started pinning CI actions to SHAs and treating lockfile changes as security-relevant diffs. The lesson for a reviewer is that npm audit protects against yesterday's known vulnerabilities, while release delays, script blocking and provenance protect against today's unknown ones."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.