litomore.me

LitoMore's Mind

Some Thoughts On `pnpm audit --fix`

Tags = [ pnpm, Security ]

Recently our project upgraded to pnpm 11. While maintaining the project dependencies, I took a look at how pnpm audit --fix fixes vulnerabilities. pnpm audit itself is easy to understand: it checks whether dependencies in the lockfile match known vulnerabilities. But once --fix enters the picture, things become a little more subtle.

I originally thought it would directly update the dependency tree to safe versions. In practice, pnpm does something else by default: pnpm audit --fix is equivalent to pnpm audit --fix=override. It writes fixed versions into overrides in pnpm-workspace.yaml, then lets the next pnpm install apply that resolution rule. This actually fits pnpm's model pretty well. Many vulnerabilities appear in transitive dependencies, and you cannot necessarily change the upstream dependency's package.json directly. In that situation, using overrides to express "this range must no longer resolve to a vulnerable version" is more stable than forcing a change to some direct dependency. But it also has an obvious side effect: the rule stays in the project configuration, and every future install will continue to be affected by it.

Later I also looked at pnpm audit --fix=update. This mode is closer to "fix the current result": it tries to update vulnerable packages so the versions in the lockfile move out of the affected range, and it may also update direct dependency declarations when necessary. Compared with override, update leaves less configuration burden behind, but it also cannot necessarily fix every vulnerability. If the dependency graph is blocked, or if the advisory has no available patched range, it can only leave remaining vulnerabilities. So the two modes are not really a difference between "conservative" and "aggressive". One changes the rule, and the other changes the result. override decides how resolution should work in the future; update decides what is currently in the lockfile.

What about a hybrid mode: update first, then override whatever cannot be fixed? It sounds reasonable, but the more I think about it, the harder it is to define. If update only fixes part of the paths, how should the report count that? If an override is then written automatically, should the next run try to delete it? If the user already wrote their own override, how can pnpm know which one was generated by audit? Maybe these unclear boundaries are exactly why pnpm keeps the two modes separate today. You can first run pnpm audit --fix=update, and if there are still remaining issues, decide whether to run the default pnpm audit --fix. It is not as automated, but at least the side effects are clear.

Another interesting detail is minimumReleaseAge. pnpm v11 changed its default value to 1440, which means newly published versions need to wait one day before they can be resolved. This setting is for supply chain security, preventing projects from immediately installing a just-published version before the community has had time to notice problems. But security patches are also often just published. This creates a conflict for audit --fix: the patched version is a security fix, but it may not yet satisfy minimumReleaseAge. pnpm handles this by adding the minimum patched version for each advisory to minimumReleaseAgeExclude when minimumReleaseAge is enabled, allowing security fixes to bypass the release-age window. This applies to both pnpm audit --fix and pnpm audit --fix=update.

I think this tradeoff is understandable. Staying on a known vulnerable version is a certain risk, while a just-published patched version is a potential risk. pnpm chooses to deal with the certain risk first. But this design leaves a maintenance problem. Exact versions in minimumReleaseAgeExclude are usually only useful for a short time. Once a version has been published for more than a day, it already satisfies minimumReleaseAge, so the corresponding exclude is no longer necessary. pnpm does not automatically clean it up today.

That is also why I opened pnpm/pnpm#11668. If you use pnpm audit --fix or pnpm audit --fix=update for a long time, minimumReleaseAgeExclude will keep accumulating versions. They were meaningful at the time, but after the release-age window has passed, they become historical records in the configuration. Half a year later, when you look at pnpm-workspace.yaml, it is hard to tell whether a particular exclude was added automatically by audit, added manually by a maintainer, or has long been safe to remove. Automatic cleanup looks simple, but it has real costs. pnpm needs to know when each version was published, which may require reading registry metadata. It also needs to avoid accidentally deleting exact versions written by users. So I would prefer an explicit cleanup mechanism in the future, instead of having audit --fix or install quietly clean things up in the background.

My maintenance workflow will probably look like this: if I want to leave behind as little long-term configuration as possible, I will first try pnpm audit --fix=update; if update cannot fix everything, then I will consider the default pnpm audit --fix. But whenever minimumReleaseAge is enabled in a project, I will take an extra look at pnpm-workspace.yaml, because both overrides and minimumReleaseAgeExclude are not one-time logs. They continue to affect future installs.

After going through this, I feel that the modules in pnpm 11 are somewhat disconnected from each other. It has gained many good features, but in real development scenarios the experience can still feel a little awkward. Next, I will probably try to send pnpm a code change in the direction mentioned in pnpm/pnpm#11668. Rather than letting audit --fix or install do too many things automatically, I want to first try an explicit maintenance entry point, such as adding a pnpm clean command dedicated to cleaning up minimumReleaseAgeExclude entries that are no longer needed. That way the cleanup action is predictable, and it will not be mixed together with the security fix itself.