Version drift

One name, installed at more than one version, in one tree.

$ ./target/release/stranger scan --format json fixtures/poisoned.package-lock.json | jq -r '.findings[] | select(.rule=="drift") | "\(.package)  \(.detail)"' | head -8
@types/unist  2 versions: 2.0.11, 3.0.3
ajv  2 versions: 6.12.6, 8.20.0
ajv-formats  2 versions: 2.1.1, 3.0.1
ansi-regex  2 versions: 5.0.1, 6.2.2
balanced-match  2 versions: 1.0.2, 4.0.4
brace-expansion  3 versions: 1.1.12, 2.0.2, 5.0.7
chalk  2 versions: 4.1.2, 5.6.2
commander  2 versions: 11.1.0, 14.0.3

Why the lockfile already knows

npm deduplicates what it can and nests what it cannot. When two packages want incompatible ranges of the same name, the loser gets its own copy at node_modules/parent/node_modules/name.

Those nested keys are not a quirk of the file format — they are how the format spells duplication. 184 of npm-xl's 1,390 entries are nested. So this rule needs no resolver, no registry and no node_modules on disk. It is reading the answer npm already wrote down.

Why medium

Nothing is exploitable because ansi-regex is installed at both 5.0.1 and 6.2.2. The argument is about the next advisory rather than today.

When a CVE lands on that name, the bump you make in your own manifest moves the copy your manifest reaches and leaves the other one pinned by whoever nested it. The fix reads as done while the vulnerable code is still on disk. Duplication is the thing that turns patching into a negotiation.

Not high, because there is no vulnerability here yet. Not low, because it decides how much tomorrow costs.

One finding per name

npm-xl has 76 drifted names behind 180 distinct versions:

$ ./target/release/stranger scan --format json fixtures/npm-xl.package-lock.json | jq '[.findings[] | select(.rule=="drift") | (.detail | capture("(?<n>[0-9]+) versions") | .n | tonumber)] | {names: length, versions: add}'
{
  "names": 76,
  "versions": 180
}

Reporting each of those 180 would be a wall; reporting the 76 names is something you read. So the finding carries the name, an empty version, and the full version list in detail:

{"rule":"drift","severity":"medium","package":"brace-expansion","version":"","detail":"3 versions: 1.1.16, 2.1.2, 5.0.7"}

That is npm-xl's brace-expansion. The block at the top of this page is the poisoned fixture's, which drifts the same name at 1.1.12, 2.0.2 and 5.0.7 — different tree, different versions, same shape of finding. Every block on this page names the fixture it came from in the command above it; the version lists only match across two blocks by coincidence.

An empty version field in the JSON is how a consumer tells this rule's findings apart from the others.

Versions are compared for equality, but listed in order

Whether two versions differ is the whole rule — it never asks which is newer. The list is sorted anyway, with src/semver.rs, because a human reads it:

$ ./target/release/stranger scan --format json fixtures/poisoned.package-lock.json | jq -r '.findings[] | select(.rule=="drift" and .package=="minimatch") | .detail'
3 versions: 3.1.2, 9.0.5, 10.2.5

This page said the opposite for most of the weekend, and was quoting 10.2.5, 3.1.2, 9.0.5 to prove it — byte order, where 1 sorts before 3 and the newest release leads the list. That was true when it was written. It stopped being true when drift.rs started sorting with the comparator, and nothing noticed until the checker started running the block.

So src/semver.rs is called: drift.rs imports Version and sorts with it. It implements precedence including the prerelease rules from section 11 that most implementations get wrong by accident, build metadata is ignored for ordering as the spec requires, and a version it cannot parse sorts last rather than throwing the finding away.

What it cannot see

Whether the two copies matter. Two versions of a type-definitions package is noise; two versions of a crypto library is not, and the lockfile records nothing that would tell them apart.

It also cannot see duplication that npm resolved away. If your tree happens to have deduplicated to one version today, a range in some transitive manifest can still float it apart tomorrow, and nothing in this file predicts that.

Not on pip

requirements.txt is a flat list of names, so a name appearing twice is a conflict pip would reject rather than drift it would nest. The rule technically runs on pip trees and cannot fire on a well-formed one.

$ ./target/release/stranger scan -v fixtures/npm-l.package-lock.json | tail -20