Skip to content
TutorialSecurity & OWASP

Supply chain security: lockfiles, provenance and OWASP A03

A Vue project installs hundreds of packages written by strangers. Here is how to make each install predictable, slow down fresh releases, and know what to do when one goes bad.

vuejs.school team23 Jun 20268 min readpnpm 11 · Node 24

Your app is mostly other people's code. A new Vite and Vue project installs a few dozen packages before you write a line, and a type checker, a test runner and a linter add many more. Every one of them is maintained by someone whose laptop, password and npm token you now depend on. When one of those is stolen, the attacker publishes a new version, and your next install runs it.

The defence is mostly a few files you commit once: the lockfile, a pnpm-workspace.yaml with safer settings, a CI workflow with pinned actions and a Dependabot config. On top of those come a few audit commands and a plan for the day a package you use is compromised. The examples use pnpm 11 on Node 24, with the npm equivalents where they differ.

What OWASP A03 asks of a Vue project

The OWASP Top 10:2025 lists Software Supply Chain Failures as A03. OWASP defines them as breakdowns or compromises in the way software is built, distributed or updated, and the category grew out of the older "components with known vulnerabilities". It came first in OWASP's community survey, with half of respondents ranking it number one.

One of its example scenarios is from npm. The Shai-Hulud worm in September 2025 seeded malicious versions of popular packages. A postinstall script, which npm runs automatically after installing a package, harvested secrets from the machine, then used any npm token it found to publish poisoned versions of that developer's own packages. OWASP says it reached more than 500 package versions before npm stopped it.

Five habits from the page's checklist matter most for a front-end project:

  • Track every version you use, including transitive dependencies (the dependencies of your dependencies).
  • Get packages only from trusted sources, and prefer signed ones.
  • Scan for known vulnerabilities.
  • Keep your CI at least as secure as what it builds.
  • Choose each version deliberately, rather than taking whatever is newest.

Commit the lockfile, install frozen

A lockfile records the exact version and integrity hash of every package in the tree. With it, two installs a month apart produce the same node_modules. Without it, a range like ^3.5.0 in package.json resolves to whatever is newest on the day, including a version published ten minutes ago.

Commit pnpm-lock.yaml. In CI, install from it and refuse to change it:

pnpm install --frozen-lockfile

If package.json and the lockfile disagree, this fails instead of quietly resolving something new. pnpm 11 also adds pnpm ci, which deletes node_modules first and then runs the same frozen install. On npm the command is npm ci.

Review lockfile changes in pull requests like code. A dependency update that touches forty packages you have never heard of deserves a second look.

What pnpm 11 blocks by default

pnpm 11, published on 28 April, changed several defaults to close the doors attacks like Shai-Hulud walk through. The release notes list them under "Security & Build Defaults":

  • A one-day delay. minimumReleaseAge now defaults to 1440 minutes: pnpm picks a version at least a day old whenever one fits your range, which gives the community and the registry time to find and remove malware.
  • Build scripts are opt-in. No dependency may run install scripts unless you allow it, and strictDepBuilds is now on, so an unapproved script fails the install with ERR_PNPM_IGNORED_BUILDS instead of printing a warning you scroll past.
  • No exotic transitive dependencies. blockExoticSubdeps is on: only your direct dependencies may come from a git repository or a tarball URL.

Settings now live in pnpm-workspace.yaml, even in a project that is not a workspace. This is the file to commit:

pnpm-workspace.yaml
minimumReleaseAge: 1440
minimumReleaseAgeStrict: true
trustPolicy: no-downgrade
allowBuilds:
  esbuild: true

What each line does:

  • minimumReleaseAge: 1440 repeats the default, but writing it yourself turns on strict mode. With only the built-in value, when nothing in your range is old enough, pnpm installs a younger version anyway and adds it to minimumReleaseAgeExclude in this file. In strict mode it fails the install instead, or asks you first in an interactive terminal.
  • minimumReleaseAgeStrict: true states strict mode outright, so nobody reading the file has to know that rule.
  • trustPolicy: no-downgrade refuses a version that arrives with weaker proof of where it was built than the versions before it. The provenance section below explains that proof.
  • allowBuilds lists the packages whose install scripts you trust.

When an unlisted package needs a build, run pnpm approve-builds to decide, or pnpm approve-builds esbuild '!core-js' to allow one and deny another in a single command (the quotes stop your shell from reading the !). An approval by name covers every later version, so take a second look when a package you approved publishes a new one.

On npm, the closest equivalents are min-release-age=1 in .npmrc, measured in days and available since npm 11.10, and ignore-scripts=true. Node 24.17 ships npm 11.13, so both work out of the box. ignore-scripts has no allow list: it skips every script during install, your own project's postinstall included.

Audit what you install and check where it came from

pnpm audit (or npm audit) compares your lockfile with the registry's advisory database and lists known vulnerabilities with the version that fixes each:

pnpm audit --prod --audit-level high
pnpm audit --fix=update
pnpm audit signatures
  • --prod skips dev dependencies such as your test runner, whose vulnerabilities do not reach the browser.
  • --audit-level high shows only high and critical advisories.
  • --fix=update, new in pnpm 11, updates packages in the lockfile rather than adding overrides.

Know the limits. An audit only knows about problems someone has already reported. It will not catch malware published this morning, and it cannot tell whether the vulnerable function is one you ever call.

pnpm audit signatures, added in 11.1, verifies the registry's signature on each installed package, using the public keys the registry publishes. It fails when a signature is invalid, or when a package is unsigned on a registry that signs packages. Either way, what you installed is not something the registry vouched for.

Provenance answers a different question: where was this package built? A package published with provenance has a signed, public record linking it to the source repository and the CI run that built it. That does not prove the code is safe, as npm's own docs point out, but an attacker with only a stolen token cannot produce it.

trustPolicy: no-downgrade in the config above builds on this. If earlier versions of a package came from a trusted publisher (a CI workflow that npm lets publish without a long-lived token, covered below) or with provenance, and a new version arrives with weaker evidence, pnpm refuses to install it. A release pushed from someone's laptop with a stolen token looks exactly like that.

To know what you ship, generate a software bill of materials (SBOM), a machine-readable list of every component and version in a build. OWASP's first prevention step is to manage one centrally. pnpm 11 writes it in CycloneDX or SPDX format: pnpm sbom --sbom-format cyclonedx --prod > sbom.cdx.json. Attach it to each release, so that when an advisory lands you can answer "were we affected?" by searching a file.

Pin the actions in your CI

Your CI runs third-party code too: every uses: line in a GitHub Actions workflow. A tag such as @v6 can be moved by whoever controls that repository. GitHub's security guide says pinning to a full commit SHA is the only way to use an action as an immutable release. Keep the version in a comment so people can read it:

.github/workflows/ci.yml
name: CI
on: [push, pull_request]
permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
      - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
        with:
          version: 11
      - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm test

permissions: contents: read gives the job's token read access and nothing else, so a compromised step cannot push to your repository. If you publish packages yourself, use npm's trusted publishing from CI: it replaces long-lived npm tokens with short-lived ones tied to one workflow, and adds provenance automatically.

Update in groups, after a delay

Pinning without updating just collects known vulnerabilities. Let a bot open the pull requests, grouped so you review a handful a week rather than thirty. Dependabot, built into GitHub, keeps the pinned SHAs above current and can wait before proposing a fresh release:

.github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
    groups:
      actions:
        patterns: ['*']
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
    cooldown:
      default-days: 3
    groups:
      vue:
        patterns: ['vue', '@vue/*', 'vue-router', 'pinia']
      dev-tools:
        dependency-type: development
        update-types: [minor, patch]

Each entry under groups turns many pull requests into one: all actions together, the Vue packages together, and minor and patch updates of dev dependencies together. cooldown is Dependabot's version of minimumReleaseAge; security updates skip it.

One caveat: Dependabot's documentation lists pnpm support up to version 10, so on a pnpm 11 project, check that its first pull request updates pnpm-lock.yaml correctly before you rely on it. Renovate is the common alternative and groups updates the same way.

When a package you use is compromised

When a package in your tree is reported compromised, work through these steps in order:

  1. Find out if you have it. pnpm why debug (with the affected package's name) shows whether it is installed and which dependency pulls it in. Search pnpm-lock.yaml for the bad version number.
  2. Assume secrets are gone if it ran. If the bad version was installed on a laptop or in CI, its scripts may have read environment variables and token files. Rotate npm, GitHub and cloud tokens, and any secret that machine could see. Do this before cleaning up.
  3. Force a good version. Add an overrides entry to pnpm-workspace.yaml, such as debug: 4.4.1, run pnpm install, and commit the new lockfile. If the fixed release is less than a day old, the delay blocks it too: list it under minimumReleaseAgeExclude. pnpm audit --fix does this for you when an advisory names the fixed version.
  4. Rebuild and redeploy everything built while the bad version was installed, from the fixed lockfile.
Check it stuck3 questions from the Supply chain and integrity quiz.
Practice now
Was this helpful?

Keep reading