npm

package-lock.json, lockfileVersion 2 and 3.

$ ./target/release/stranger scan fixtures/npm-xl.package-lock.json

  npm-xl.package-lock.json 1,376 packages   (150 direct · 1,226 transitive · 14 workspace)

  ⚠  INSTALL SCRIPTS        8     arbitrary code at install time

  ⚠  TRIVIAL                29    (2.1% of third-party)

  ⚠  VERSION DRIFT          76    same package at 2+ versions in one tree

  ·  UNPINNED               — no signal in this format

  risk 62/100    456ms    third-party deps used to compute this: 0

All five rules can fire on an npm tree, though pinning never will: every entry records a resolved version, so every entry is exactly pinned.

What it refuses

lockfileVersion 1, by name:

$ mkdir -p /tmp/v1
$ echo '{"lockfileVersion": 1, "dependencies": {}}' > /tmp/v1/package-lock.json
$ ./target/release/stranger scan /tmp/v1
stranger: /tmp/v1/package-lock.json: lockfileVersion 1 is not supported; stranger reads 2 and 3. Run `npm install` with npm 7 or newer to upgrade the file.

Version 1 kept the tree in a nested dependencies object and has no packages map at all. Refusing it beats mis-reading it: a reader that looked for packages and found nothing would report a clean project with zero dependencies, which is the worst possible output for an auditing tool.

A file with no lockfileVersion field is refused as not looking like a package-lock.json. A file with a lockfileVersion but no packages map is refused too.

A filename that is not one of the seven it knows:

$ mkdir -p /tmp/renametest
$ printf 'flask==3.0.0\n' > /tmp/renametest/requirements-dev.txt
$ ./target/release/stranger scan /tmp/renametest/requirements-dev.txt
stranger: requirements-dev.txt: not a lockfile stranger knows. It reads: package-lock.json, pnpm-lock.yaml, Cargo.lock, requirements.txt, poetry.lock, uv.lock, go.mod, yarn.lock

Cargo.lock was the example here until the Cargo reader landed and it started reading fine. The list in the message comes from the same constant discovery uses, so the message could not go stale — only this page could, and did.

Keys are install paths, not names

The awkward part of the format is that packages is keyed by where npm put the thing on disk:

"node_modules/@babel/core": { ... },
"node_modules/eslint/node_modules/semver": { ... }

The package name is the segment after the last node_modules/, with a scope slash after it counting as part of the name. 184 of npm-xl's 1,390 entries are nested like that, so it is not a rare path.

Resolving one package's dependency to another package's entry means reproducing npm's own lookup: try the nearest node_modules directory, then walk up. A dependency c of the package at node_modules/a/node_modules/b is the first of these that exists:

node_modules/a/node_modules/b/node_modules/c
node_modules/a/node_modules/c
node_modules/c

Getting that wrong does not produce a parse error. It produces a graph with the wrong edges, which silently corrupts in-degree, which is the clause the detection rule leans on hardest. tests/npm.rs asserts the nested count and that every nested entry keeps its own identity, including scoped ones.

Those same nested keys are what version drift reads: they are how the format spells duplication.

Which fields are read

fieldused for
lockfileVersionaccept or refuse the file
versionprinted beside the name; compared for equality by the drift rule
dev, optionalrecorded on each package; no rule uses them yet
linkmarks a symlink to a workspace member as first-party
hasInstallScriptthe install scripts rule
integritypresence recorded; never verified, see Limits
dependencies, devDependencies, optionalDependencies, peerDependenciesgraph edges

peerDependencies is in that list on purpose. A peer dep is a real maintainer writing down a real name, which is exactly the evidence the detection rule wants, and counting it can only make the rule quieter. devDependencies only ever appears on the root entry.

The empty-string key is the root project. It is in the map and is not one of the dependencies being counted, which is why npm-xl reports 1,390 entries from a 1,391-entry map. It is also why the root project's own hasInstallScript does not count as a supply-chain signal: that one is your build.

First-party entries

An entry is first-party when its key contains no node_modules/ at all — that is a workspace directory like apps/desktop — or when it carries "link": true, which is the symlink npm leaves in node_modules pointing at one. npm-xl has 14 of them, 7 of which are links.

First-party packages are skipped by every rule, never counted as direct dependencies of themselves, and reported separately in the header's workspace field. Monorepos covers why their outgoing edges are treated as manifest declarations rather than as evidence.

Discovery

stranger scan <dir> recurses, skipping node_modules and twelve other directories, and matches any filename ending in one of the seven names it knows.

Pointing at a file skips the walk, and the match there is the same suffix rule, so a lockfile you have renamed still reads:

$ ./target/release/stranger scan fixtures/npm-s.package-lock.json

  npm-s.package-lock.json  405 packages   (12 direct · 393 transitive)

  ⚠  INSTALL SCRIPTS        3     arbitrary code at install time

  ⚠  TRIVIAL                10    (2.5% of third-party)

  ⚠  VERSION DRIFT          30    same package at 2+ versions in one tree

  ·  UNPINNED               — no signal in this format

  risk 56/100    53ms    third-party deps used to compute this: 0

Fixture counts

Every entry count was measured with jq '.packages | length - 1' against the file, not copied from notes. The notes said npm-xl held 1,391 entries. It holds 1,390.

fixtureentriesthird-partydirectworkspace
npm-xs373710
npm-s405405120
npm-m582576206
npm-l754754320
npm-xl1,3901,37615014
poisoned757757350

poisoned is npm-l plus three planted names, all inserted as root dependencies with no parent. It added exactly three entries, three roots, and no edges — tests/npm.rs asserts all three, because nothing depends on a hallucination.

$ ./target/release/stranger scan -v fixtures/npm-m.package-lock.json