I Gave Install-Script Permission to a Package I Don't Have

There’s exactly one file in one of my wiki projects that’s a security control rather than a config file, and I hadn’t read it in months.

pnpm 11 removed onlyBuiltDependencies along with four related settings and replaced all of them with a single allowBuilds map, described in the docs as “a map of package matchers to explicitly allow (true) or disallow (false) script execution.” A postinstall script is arbitrary code running on your machine at install time with your permissions, so the list of packages allowed to have one is the shortest and highest-consequence list in the repo.

Mine has six entries. Two true, four false. Here’s the true half:

allowBuilds:
  sharp: true # native libvips bindings (image processing)
  "@xenova/transformers": true # native ML runtime

And here’s the count:

$ grep -c 'transformers' pnpm-lock.yaml
0
$ grep -rn 'xenova' --include='*.json' --include='*.yaml' --include='*.ts' . | grep -v node_modules
pnpm-workspace.yaml:28:  "@xenova/transformers": true # native ML runtime

Zero references in the lockfile. One reference in the entire repository, and it’s the line granting the permission.

This project used to run embeddings locally. That approach is gone, search goes through a hosted retrieval service now, and the project’s own direction notes say not to reintroduce local embeddings. The dependency left. The standing permission to execute native build scripts on my machine stayed behind, waiting for a package that’s never going to be installed.

A dead grant isn’t a vulnerability, it’s a broken instrument

Nobody’s exploiting this. The package isn’t in the tree, so nothing runs. I want to be clear about that before anyone gets excited.

What it costs me is the ability to trust the file. An allowlist works as a control only if every entry means I read this package’s install script and I accept it. The moment one entry instead means this was true in a previous version of the project, the list becomes a record of the past, and the next person to open it has no way to tell the two kinds of entry apart without re-deriving all six from the lockfile. The next person is me, in four months, at which point I will absolutely assume the file is current.

This is the failure mode of every allowlist I’ve kept. They only grow. Removing an entry requires noticing that something left, and nothing tells you when a dependency stops existing.

The other true entry has the opposite problem

sharp is granted a native build. sharp is also not in my package.json. It shows up anyway:

$ pnpm why sharp
[email protected]
└─┬ [email protected]
  ├─┬ @astrojs/[email protected]
  │ └── <package> (dependencies)
  └── <package> (dependencies)

Astro declares it as an optional dependency. I wrote about that mechanic in a different repo a few days ago, so I’ll skip the re-explanation. What’s new to me here is that the same workspace file also pins it:

overrides:
  vite: ^8.2.2
  sharp: ^0.35.3

The comment sitting above that block warns that several of these pins are CVE remediations for transitive dependencies, and not to relax a bound without checking the advisory it was added for. That’s a good note. It’s also attached to a package I never asked for, whose version I control only because I reached into the resolver and overrode somebody else’s optional dependency.

The false entries turned out to be the good news

  "@prisma/engines": false
  prisma: false
  better-sqlite3: false
  esbuild: false

These four aren’t a new restriction, and it took me a minute to work out why they’re written down at all.

Under pnpm 10, onlyBuiltDependencies was an exclusive allowlist. Anything not on it got skipped. All four of these were already being skipped, silently, for as long as this project has existed. pnpm 11’s strictDepBuilds refuses to skip quietly, and exits non-zero when a dependency has an unreviewed build script.

So the upgrade changed no behavior at all. It changed whether the behavior was visible, and converted four invisible skips into four decisions I had to write down and sign. That’s the good version of a breaking change, and it’s the reason the dead @xenova/transformers entry was sitting right there for me to trip over.

One more thing, since I had the shell open

$ grep -c '0.34.5' pnpm-lock.yaml
0
$ du -sh node_modules/.pnpm/@[email protected]
 15M

[email protected] has zero references in the current lockfile and is still on disk, next to 15M of libvips binaries compiled for it, which in turn sit next to the 17M of libvips 1.3.2 that the current resolution actually uses. The override moved from ^0.34.4 to ^0.35.3 in a dependency sweep last week, and the old native payload never left. That’s pnpm store prune territory rather than a correctness bug, but it’s 15M of compiled image-processing code on a machine whose project doesn’t declare an image-processing dependency.

What I’m changing

  1. Reconcile the allowlist against the lockfile in CI. Every key in allowBuilds should resolve to something in pnpm-lock.yaml, or the build should complain. It’s maybe ten lines of test and I don’t have it.
  2. Say why, next to every entry. Two of my six have a reason comment. The reason is worth more than the package name, because the reason is the thing that expires.
  3. Re-read the file whenever an override moves. The pins and the build grants describe the same dependency graph, and they drifted apart without a word.

The security value of an allowlist lives in the reviewing, not in the file. I’ve been maintaining the file.

Sources

I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].

security javascript Dependencies Pnpm