stranger
A model suggests a package. You paste the name into package.json. The name is
not real — nobody has ever published it — and the first person to notice
registers it and ships whatever they like to everyone who runs npm install.
stranger reads a lockfile and tells you which names in it look invented, which
of them run code when you install them, and which are installed at four
different versions at once. It does not install anything, does not resolve
anything, and never opens a socket.
$ ./target/release/stranger scan fixtures/poisoned.package-lock.json
poisoned.package-lock.json 757 packages (35 direct · 722 transitive)
⚠ HALLUCINATION RISK 3
chalck@5.3.0 not in corpus · d=1 from "chalk" · root-only, no parent
expres@4.18.2 not in corpus · d=1 from "express" · root-only, no parent
lodahs@4.17.21 not in corpus · d=1 from "lodash" · root-only, no parent
⚠ INSTALL SCRIPTS 3 arbitrary code at install time
⚠ TRIVIAL 35 (4.6% of third-party)
⚠ VERSION DRIFT 55 same package at 2+ versions in one tree
· UNPINNED — no signal in this format
risk 81/100 21ms third-party deps used to compute this: 0
Three planted names, three findings, no false positives. The critical rule lists
every hit; the other three collapse to a count until you ask for more with -v.
The empty manifest
The last field of that output is checkable. Cargo.toml has three dependency
tables and all three are empty, so the lockfile of a lockfile auditor is one
package long:
$ cargo tree
stranger v0.1.0 (/home/keir/stranger)
$ grep -c '^\[\[package\]\]' Cargo.lock
1
That is the whole dependency graph. No serde_json, no clap, no strsim, no
toml, no owo-colors. The JSON reader, the TOML reader, the argument parser,
the edit distance, the semver comparator, the terminal handling and the report
writer are all in src/, and the test harness is the one Rust ships with, so
there is no dev-dependency escape hatch in use either.
STDLIB.md names each crate that was replaced and what was given up.
#![forbid(unsafe_code)] sits at the top of both src/lib.rs and src/main.rs,
and CI fails the build if cargo tree ever prints a second line, if unsafe
appears in src/, or if anything reaches for Command::new.
Rust's standard library has no TLS and no HTTP client. A binary with no dependencies therefore cannot make a network request — the guarantee comes from the empty manifest, not from anyone's restraint.
Reading files other tools produced
The hackathon rules forbid shelling out to installed tooling. Their FAQ rules this design in explicitly:
Parsing files those tools already produced is fine, because nothing third-party ends up in your artifact.
Two conditions attach. It has to be disclosed, and it has to degrade when the
file is not there. Both hold. The corpus of known-real names and the fixture
lockfiles are written up in
corpus/PROVENANCE.md,
fixtures/README.md
and STDLIB.md, and pointing stranger at a directory with no lockfile prints
what it looked for and exits 0:
$ rm -rf /tmp/empty && mkdir -p /tmp/empty
$ ./target/release/stranger scan /tmp/empty
no lockfile stranger reads in /tmp/empty
looked for: package-lock.json, pnpm-lock.yaml, Cargo.lock, requirements.txt, poetry.lock, uv.lock, go.mod, yarn.lock
$ echo $?
0
What it reads today
Seven formats across four ecosystems: package-lock.json (v2, v3),
pnpm-lock.yaml (v9), Cargo.lock (v3, v4), poetry.lock, uv.lock,
requirements.txt and go.mod. Five rules, one of which has an idea in it — the
co-occurrence rule, which separates a hallucinated name from
a legitimate sibling using something other than spelling. The rest of this book
is what those things do, what they measure, and where they are wrong.
Start here:
$ make && ./target/release/stranger scan fixtures/poisoned.package-lock.json
Installing
There is nothing to install. Clone, type make, and a binary appears.
$ git clone https://github.com/keirsalterego/stranger
$ cd stranger
$ make
cargo build --release --locked
Compiling stranger v0.1.0 (<your clone>)
Finished `release` profile [optimized] target(s) in 2.18s
Under three seconds cold, because there is nothing to compile except this crate.
The --locked matters even with an empty manifest. It makes Cargo refuse to
write Cargo.lock, so a build that somehow needed a dependency fails instead of
quietly acquiring one.
The compiler is pinned in rust-toolchain.toml:
[toolchain]
channel = "1.98.0"
components = ["clippy", "rustfmt"]
Not stable. 1.98 is where str::substr_range and NumBuffer::format_into
landed, and both are load-bearing — the first gives the JSON and TOML parsers
their error positions, the second does digit grouping in the report without
allocating. A floating stable would also make the build non-reproducible for no
gain. See Reproducible builds.
Nothing is fetched. There is no cargo fetch step because there is nothing to
fetch, which means the build works with the network cable out:
$ CARGO_NET_OFFLINE=true cargo build --release --locked --offline
Finished `release` profile [optimized] target(s) in 0.01s
What you get
$ ls -l target/release/stranger
-rwxrwxr-x 2 keir keir 4064792 Aug 30 01:46 target/release/stranger
4,064,792 bytes, of which 2,960,053 is the corpus of known-real package names —
140,066 for npm, 15,000 for PyPI, 5,000 for crates.io — compiled in with
include_str!. Nearly three quarters of the binary is corpus. That is the reason the tool works
on a plane: no cache directory, no first-run download, no "corpus not found"
failure mode.
Copy the binary wherever you like. It reads the file you point it at and nothing else; there is no config file and no state directory.
$ make && ./target/release/stranger --help
Your first scan
fixtures/poisoned.package-lock.json is a real 754-package lockfile with three
fake names added by hand. Scan it:
$ ./target/release/stranger scan fixtures/poisoned.package-lock.json
poisoned.package-lock.json 757 packages (35 direct · 722 transitive)
⚠ HALLUCINATION RISK 3
chalck@5.3.0 not in corpus · d=1 from "chalk" · root-only, no parent
expres@4.18.2 not in corpus · d=1 from "express" · root-only, no parent
lodahs@4.17.21 not in corpus · d=1 from "lodash" · root-only, no parent
⚠ INSTALL SCRIPTS 3 arbitrary code at install time
⚠ TRIVIAL 35 (4.6% of third-party)
⚠ VERSION DRIFT 55 same package at 2+ versions in one tree
· UNPINNED — no signal in this format
risk 81/100 71ms third-party deps used to compute this: 0
The header
poisoned.package-lock.json 757 packages (35 direct · 722 transitive)
The lead number counts third-party packages only. Direct is the count named by
a manifest in this repository — the root package.json, and any workspace
member's. Transitive is everything reached only through another package. A
workspace member is neither, so it gets a third field of its own when there is
one:
$ ./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 382ms third-party deps used to compute this: 0
The file holds 1,390 entries. 14 of them are your own code, so the tree you got from strangers is 1,376. Monorepos covers why that split is the same one the detection rule runs on.
Collapsed rules
Critical findings are always listed. Everything else reports a count and what the count means, because a 1,390-package tree produces 76 drift findings and 29 trivial ones, and printing all of them buries the three that matter under a hundred lines nobody scrolls back through.
-v prints the lot:
$ ./target/release/stranger scan -v fixtures/npm-xs.package-lock.json
npm-xs.package-lock.json 37 packages (1 direct · 36 transitive)
⚠ TRIVIAL 4 (10.8% of third-party)
es-errors@1.3.0 one expression, one publisher · inlining it removes an account from your build
gopd@1.2.0 one expression, one publisher · inlining it removes an account from your build
has-symbols@1.1.0 predicate-shaped, resolves nothing · size not measured, see rule docs
hasown@2.0.4 one expression, one publisher · inlining it removes an account from your build
· UNPINNED — no signal in this format
risk 9/100 6ms third-party deps used to compute this: 0
-q drops the header and the risk line and prints findings only, which is the
form to pipe into something:
$ ./target/release/stranger scan -q fixtures/poisoned.package-lock.json
⚠ HALLUCINATION RISK 3
chalck@5.3.0 not in corpus · d=1 from "chalk" · root-only, no parent
expres@4.18.2 not in corpus · d=1 from "express" · root-only, no parent
lodahs@4.17.21 not in corpus · d=1 from "lodash" · root-only, no parent
⚠ INSTALL SCRIPTS 3 arbitrary code at install time
⚠ TRIVIAL 35 (4.6% of third-party)
⚠ VERSION DRIFT 55 same package at 2+ versions in one tree
A finding
lodahs@4.17.21 not in corpus · d=1 from "lodash" · root-only, no parent
The three fields after the version are the three clauses of the rule, in the terms the rule actually used, so you can disagree with it:
- not in corpus —
lodahsis not one of the 140,066 npm names compiled into the binary. - d=1 from "lodash" — its Damerau-Levenshtein distance to
lodashis 1. One transposition. Under plain Levenshtein it would be 2, which is why the distance function is the one it is. - root-only, no parent — nothing else in these 757 packages depends on it.
All three have to hold. The co-occurrence rule is why, and
stranger tree lodahs prints the in-edges the third one is about, so
you do not have to take that line's word for it.
Every rule writes its own detail in its own terms. The other four are
install scripts,
version drift, trivial packages and
unpinned requirements.
Colour
Findings are coloured by the worst severity in their block: red for critical, orange for high, yellow for medium, dim for low. Sixteen-colour SGR only, so it renders the way your theme intends rather than the way the author's monitor looked.
Four inputs decide whether escapes are emitted, highest priority first:
--no-color— you said so, out loud, this run.NO_COLOR— off regardless of TTY.CLICOLOR_FORCE— on regardless of TTY.- stdout is a TTY.
Off beats on at every tie, so a stray CLICOLOR_FORCE in a CI image cannot spray
escapes into a log that asked for none. Both variables count only when present
and non-empty, so NO_COLOR= means nothing was said rather than "off".
A pipe is not a TTY, so piped output is plain bytes you can grep:
$ ./target/release/stranger scan fixtures/poisoned.package-lock.json | cat -v | head -4
poisoned.package-lock.json 757 packages (35 direct M-BM-7 722 transitive)
M-bM-^ZM- HALLUCINATION RISK 3
The M-BM-7 is the UTF-8 middle dot, not an escape code. --format json never
carries colour under any setting: a program that has to strip SGR codes out of a
string field will not.
The footer
risk 81/100 56ms third-party deps used to compute this: 0
The risk number is a band for the worst severity present, plus position inside the band for how many findings share it:
| worst finding | band |
|---|---|
| critical | 75–98 |
| high | 50–73 |
| medium | 25–48 |
| low | 1–24 |
| nothing | 0 |
The band is the same question --fail-on asks, on purpose — the headline number
and the gate should not disagree about what is serious. Volume saturates inside
the band and never fills it, so a worse severity always outranks more of a lesser
one, and there is always a worse tree than the one in front of you.
It is not calibrated against anything, because there is nothing honest to calibrate it against. Two projects are comparable at the band; two scans of the same project are comparable outright. The findings are the output; the score is a handle.
This was a sum of severity weights capped at 100 until the cap turned out to be
doing all the work — nine of the sixteen fixtures scored exactly 100, including
both poisoned.package-lock.json and the clean npm-l it was built from.
The milliseconds are wall time for that run, measured by the tool. It moves
around. make bench runs the largest fixture fifty times:
Benchmark 1: target/release/stranger scan fixtures/npm-xl.package-lock.json
Time (mean ± σ): 413.0 ms ± 82.5 ms [User: 404.8 ms, System: 3.4 ms]
Range (min … max): 371.2 ms … 660.2 ms 50 runs
Most of that is the nearest-neighbour scan for names that miss the corpus.
$ ./target/release/stranger scan -v fixtures/npm-m.package-lock.json
Looking at one package
stranger scan says a package has no parent. stranger tree shows you.
$ ./target/release/stranger tree <pkg> [path]
Same walk, same readers, same lockfiles — it just prints the graph around one
name instead of the findings across a file. path defaults to . and can be a
single lockfile or a directory to walk.
The one it was built for
The third clause of the co-occurrence rule is nothing in
the lockfile depends on it. Clauses 1 and 2 you can check by hand: a name is in
corpus/npm.txt or it is not, and an edit distance is arithmetic. Clause 3 is a
claim about a graph, and until you can see the graph the only thing to do with it
is believe the report.
$ ./target/release/stranger tree lodahs fixtures/poisoned.package-lock.json
fixtures/poisoned.package-lock.json npm · 757 packages
lodahs@4.17.21 node_modules/lodahs
depended on by in-degree 0 · root-only, no parent
nothing in this lockfile depends on it. The only
reference to the name in the file is the manifest under
audit. That is clause 3 of the co-occurrence rule: a
hallucinated package is a root dependency, because
nothing real has ever heard of it.
depends on nothing
root-only, no parent is the same phrase the finding prints, on purpose. The
report, this page and src/rules/slopsquat.rs should not have three ways of
saying one thing.
A real package gives the other answer, out of the same reader and a tree the same size:
$ ./target/release/stranger tree accepts fixtures/npm-l.package-lock.json
fixtures/npm-l.package-lock.json npm · 754 packages
accepts@2.0.0 node_modules/accepts
dev-only
depended on by in-degree 1
express@5.2.1
depends on 2 direct, to depth 3
├─ mime-types@3.0.2
│ └─ mime-db@1.54.0
└─ negotiator@1.0.0
The out-edge tree
--depth decides how far down it goes, three by default. The cut is announced
on the line it happens at, because a tree that stops without saying so reads as a
package that depends on less than it does:
$ ./target/release/stranger tree qs fixtures/npm-l.package-lock.json --depth 1
fixtures/npm-l.package-lock.json npm · 754 packages
qs@6.15.3 node_modules/qs
dev-only
depended on by in-degree 2
body-parser@2.3.0
express@5.2.1
depends on 2 direct, to depth 1
├─ es-define-property@1.0.1
└─ side-channel@1.1.1 · 5 more below, past --depth 1
--depth 0 removes the limit. That is safe to type because the walk expands
each package once and not once per path: a lockfile is a directed graph with
heavy sharing, and printing every path through one is exponential in the depth.
A name whose dependencies were printed earlier comes back as a leaf marked (*),
with a legend under the tree.
Real lockfiles also contain cycles — npm records peer dependencies in both
directions often enough that a → b → a is ordinary — and a cycle is marked
where it closes rather than followed:
$ ./target/release/stranger tree eslint fixtures/npm-xl.package-lock.json --depth 0 | grep -B 1 -A 1 cycle
├─ @eslint-community/eslint-utils@4.9.1
│ ├─ eslint@9.39.4 · cycle, back to a name already above it
│ └─ eslint-visitor-keys@3.4.3
One name, several versions
Version drift is one of the four things this tool reports,
so the one thing tree must not do is pick a version. npm spells a duplicated
package as a second entry under a nested install path, and every entry gets its
own block:
$ ./target/release/stranger tree ansi-regex fixtures/npm-l.package-lock.json
fixtures/npm-l.package-lock.json npm · 754 packages
ansi-regex@5.0.1 node_modules/ansi-regex
dev-only
depended on by in-degree 1
strip-ansi@6.0.1
depends on nothing
ansi-regex@6.2.2 node_modules/ora/node_modules/ansi-regex
dev-only
depended on by in-degree 1
strip-ansi@7.2.0
depends on nothing
ansi-regex@6.2.2 node_modules/string-width/node_modules/ansi-regex
dev-only
depended on by in-degree 1
strip-ansi@7.2.0
depends on nothing
The install path is printed next to each version because it is what makes a
second copy a second copy. It also shows up in the in-edge list when two parents
would otherwise be indistinguishable — five packages depending on ms can be
three separate entries called debug@3.2.7, each nested somewhere different, and
a list that repeated the same label three times with no explanation reads as a
rendering fault.
Flat formats have no graph
requirements.txt records a list, not a graph. There are no edges in the file to
read, so in-degree 0 there is the format declining to say rather than a number
anyone measured — which is the exact confusion clause 3 exists to avoid. It says
so instead of printing a zero:
$ ./target/release/stranger tree tensorflow-gpu fixtures/reqs-xs.requirements.txt
fixtures/reqs-xs.requirements.txt pypi · 12 packages
tensorflow-gpu
flat format no graph in this file
requirements.txt records no dependency edges at all, so
there is no in-degree here to read and no out-edges to
walk. Every package in it trivially has in-degree 0,
which is why clause 3 is vacuous on a flat file and the
rule falls back to two clauses. Point this at a
poetry.lock or a uv.lock and there is a graph to look at.
tensorflow-gpu is the false positive Limits uses to make the
same point: a real, deprecated PyPI package that clauses 1 and 2 both fire on,
with no clause 3 available to save it. The fix is a different file, and both of
them are already readable:
$ ./target/release/stranger tree requests fixtures/poetry-s.poetry.lock
fixtures/poetry-s.poetry.lock pypi · 54 packages
requests@2.32.5
depended on by in-degree 3
cachecontrol@0.14.3
poetry@1.8.5
requests-toolbelt@1.0.0
depends on 4 direct, to depth 3
├─ certifi@2025.8.3
├─ charset-normalizer@3.4.3
├─ idna@3.10
└─ urllib3@2.6.3
When it is not there
A name that is not in the tree is an answer, not a failure. Exit 0, and a list of
what is close, measured with the same Damerau-Levenshtein function and the same
threshold the rule uses. One name in five files is five lines, because the
question tree answers is "where is it", and the real lodash is in all five
while the planted lodahs is in two:
$ ./target/release/stranger tree lodashh fixtures/
no package named `lodashh` in the 23 lockfiles under fixtures/
close names that are there:
lodash d=1 · fixtures/npm-s.package-lock.json
lodash d=1 · fixtures/npm-xl.package-lock.json
lodash d=1 · fixtures/pnpm-l.pnpm-lock.yaml
lodash d=1 · fixtures/yarn-l.yarn.lock
lodash d=1 · fixtures/yarn-m.yarn.lock
lodahs d=2 · fixtures/hostile.package-lock.json
lodahs d=2 · fixtures/poisoned.package-lock.json
$ echo $?
0
JSON
One object on one line, and it is one object rather than the newline-delimited
stream scan emits: scan answers a question per file, tree
answers one question about one name, and "it is in none of these files" is not
something a stream of per-file objects can say.
$ ./target/release/stranger tree lodahs fixtures/poisoned.package-lock.json --format json
{"query":"lodahs","found":true,"lockfiles":1,"depth":3,"occurrences":[{"source":"fixtures/poisoned.package-lock.json","ecosystem":"npm","name":"lodahs","version":"4.17.21","key":"node_modules/lodahs","first_party":false,"direct":true,"records_edges":true,"in_degree":0,"parents":[],"dependencies":[]}],"near":[]}
| field | type | what it is |
|---|---|---|
query | string | the name as you typed it |
found | bool | whether occurrences is non-empty |
lockfiles | number | how many files were read to answer this |
depth | number | the --depth in force; 0 means no limit |
occurrences | array | one per entry that matched, in path then version order |
near | array | populated only when found is false |
An occurrence:
| field | type | what it is |
|---|---|---|
source | string | the path as you gave it, not canonicalised |
ecosystem | string | npm, pypi or crates.io |
name, version, key | string | as the lockfile spelled them |
first_party | bool | a workspace member — your own code |
direct | bool | named by a manifest in this repository |
records_edges | bool | false on requirements.txt, true everywhere else |
in_degree | number or null | null when records_edges is false |
parents | array | {name, version}, one per package with an edge in |
dependencies | array | the out-edge tree, nested |
in_degree is null and not 0 on a flat format for the same reason the human
output refuses to print a number there. Nobody measured 0.
A dependency node is {name, version, dependencies}, plus a stop when the walk
stopped at it — "cycle", "repeat" or "depth", and depth carries hidden,
the count of direct dependencies not shown:
$ ./target/release/stranger tree qs fixtures/npm-l.package-lock.json --depth 1 --format json | jq -c '.occurrences[0].dependencies'
[{"name":"es-define-property","version":"1.0.1","dependencies":[]},{"name":"side-channel","version":"1.1.1","stop":"depth","hidden":5,"dependencies":[]}]
Two runs over one tree produce the same bytes, human or JSON. There is no timing in this output at all, so unlike a scan there is nothing to strip before diffing two of them.
Flags
| flag | effect |
|---|---|
--depth <n> | levels of out-edges; default 3, 0 for no limit |
--format <human|json> | as scan |
--no-color | as scan; also NO_COLOR and CLICOLOR_FORCE |
-q, --quiet | drop the file header and the explanatory prose, keep the numbers |
--fail-on and -v are scan flags and tree says so rather than reporting an
unknown option — tree produces no findings, so there is nothing to gate on and
nothing collapsed to expand.
$ ./target/release/stranger tree express --fail-on high
stranger: `--fail-on` is a scan flag; tree reports no findings to gate on
$ echo $?
2
JSON output
--format json writes one object per lockfile, on one line, followed by a
newline. A directory holding both a package-lock.json and a requirements.txt
produces two lines rather than an array, so the stream is newline-delimited JSON
and a consumer reads it a line at a time:
$ rm -rf /tmp/mixed && mkdir -p /tmp/mixed
$ cp fixtures/poisoned.requirements.txt /tmp/mixed/requirements.txt
$ cat > /tmp/mixed/package-lock.json <<'EOF'
{
"name": "mixed",
"lockfileVersion": 3,
"packages": {
"": { "name": "mixed", "dependencies": { "expres": "4.18.2" } },
"node_modules/expres": {
"version": "4.18.2",
"resolved": "https://registry.npmjs.org/expres/-/expres-4.18.2.tgz",
"integrity": "sha512-AA"
}
}
}
EOF
$ ./target/release/stranger scan --format json /tmp/mixed | jq -c '{source, packages, findings: (.findings|length)}'
{"source":"/tmp/mixed/package-lock.json","packages":1,"findings":1}
{"source":"/tmp/mixed/requirements.txt","packages":6,"findings":5}
$ ./target/release/stranger scan --format json fixtures/poisoned.requirements.txt
{"source":"fixtures/poisoned.requirements.txt","ecosystem":"pypi","packages":6,"direct":6,"transitive":0,"workspace":0,"integrity":0,"risk":79,"findings":[{"rule":"slopsquat","severity":"critical","package":"python-dateutils","version":"2.9.0","detail":"not in corpus · d=1 from \"python-dateutil\" · no dependency graph in this format"},{"rule":"slopsquat","severity":"critical","package":"requests-http","version":"1.0.2","detail":"not in corpus · d=2 from \"requests-html\" · no dependency graph in this format"},{"rule":"pinning","severity":"low","package":"flask","version":"","detail":"~=3.0 · capped at the major, still floats below the cap"},{"rule":"pinning","severity":"high","package":"numpy","version":"","detail":"no bound in either direction · resolves to whatever is newest at install time"},{"rule":"pinning","severity":"medium","package":"urllib3","version":"","detail":">=1.26 · a range, so the file does not say what installs"}],"not_applicable":["install-script"]}
The blind-spot object
A scan that could not see everything leads with one extra object, and it is the
one with no source key — which is how a consumer tells the two apart
without guessing:
$ rm -rf /tmp/blind && mkdir -p /tmp/blind/ok /tmp/blind/locked
$ cp fixtures/npm-xs.package-lock.json /tmp/blind/ok/package-lock.json
$ touch /tmp/blind/bun.lock
$ chmod 000 /tmp/blind/locked
$ ./target/release/stranger scan --format json /tmp/blind | head -1
{"unreadable":["/tmp/blind/locked"],"unsupported":["bun.lock"]}
$ chmod 755 /tmp/blind/locked
unreadable is directories that exist and would not open. Each one removes an
unknown number of lockfiles from the answer, so the scan exits 2 rather than
reporting on a list it knows is short. unsupported is lockfile names with no
reader behind them, which is information rather than a failure and does not
change the exit code.
Prose on this stream is the one thing a consumer reading a line at a time cannot survive. A second object shape is not, which is why the blind spots are JSON here and a sentence in the human report.
not_applicable, and why it is not the same as clean
A rule that cannot fire in a format is not a rule that found nothing.
poetry.lock records no install-script flag at all, so the scripts rule is mute
there — and a CI gate that only sees "findings":[] would read that as a Python
project with no install-time code execution, which is not a claim this tool can
make about any Python project.
$ ./target/release/stranger scan --format json fixtures/poetry-m.poetry.lock | jq -c '{findings: (.findings|length), not_applicable}'
{"findings":0,"not_applicable":["install-script","pinning"]}
The human report says the same thing with a · instead of a ⚠.
The object
| field | type | what it is |
|---|---|---|
source | string | the path as you gave it, not canonicalised |
ecosystem | string | npm, pypi, crates.io or go. All four appear — go.mod reads; what Go has no corpus for is the detection rule |
packages | number | third-party packages; workspace members are excluded |
direct | number | named by a manifest in this repository |
transitive | number | packages - direct |
workspace | number | first-party entries set aside; 0 on a non-monorepo |
integrity | number | third-party entries that recorded an integrity field. Presence, never correctness — std ships no crypto, so no hash is ever computed. See Limits |
not_applicable | array | rules that cannot fire in this format, by id. Absent from the array is not the same as clean — see below |
risk | number | 0–98; a band for the worst severity plus a term for volume |
findings | array | worst rule first, then alphabetical by package within a rule |
workspace is the one header number that cannot be rebuilt from the others.
packages, direct and transitive are all third-party counts, so without it a
monorepo and a flat project of the same dependency count read identically.
A finding
| field | type | what it is |
|---|---|---|
rule | string | slopsquat, install-script, trivial, drift or pinning |
severity | string | low, medium, high or critical |
package | string | the name as the lockfile spelled it |
version | string | may be empty |
detail | string | why this fired, in the rule's own terms |
version is empty in two cases that are worth knowing about. A drift finding
is one per name across all its versions, so there is no single version to put
there and the versions are in detail instead. A pinning finding on a
requirement with no == has no resolved version to record at all — that is the
finding.
Findings are ordered by rules::ORDER — slopsquat, install-script, trivial,
drift, pinning — and alphabetically by package inside each rule. The order is
stable across runs, so a diff between two scans is a diff and not a reshuffle.
detail is prose meant for a person. Its shape is stable enough to read and not
stable enough to parse — if you need the edit distance, the nearest name or the
drifted version list as data, say so and they can become fields.
Everything is listed
--format json ignores the collapsing that the human report does. A rule that
prints as VERSION DRIFT 76 on a terminal emits all 76 objects here, whether or
not you passed -v. Same for -q: it changes nothing about the JSON.
$ ./target/release/stranger scan --format json fixtures/npm-xl.package-lock.json | jq '.findings | length'
113
Escaping and colour
The writer escapes what RFC 8259 section 7 requires — ", \, the three
whitespace shorthands, and anything below U+0020 as \uXXXX — and nothing
else. The · separators in detail go out as literal UTF-8, which is legal JSON
and what every parser expects. Note the \" around "python-dateutil" above:
that is a quoted name inside detail, correctly escaped.
JSON is never coloured, under any combination of CLICOLOR_FORCE, NO_COLOR and
TTY. It goes to a program, and a program that has to strip SGR codes out of a
string field will not.
No pretty-printer
There is none, and adding one would mean writing a second serialiser to check. Pipe it:
$ ./target/release/stranger scan --format json fixtures/poisoned.requirements.txt | jq '.findings[0]'
Exit codes
Three of them, and the split between 1 and 2 is the point.
| code | meaning |
|---|---|
| 0 | clean, findings below the --fail-on threshold, or no lockfile found |
| 1 | a finding at or above the threshold |
| 2 | bad usage, or a file that could not be read or parsed |
A CI gate that cannot tell a finding from a broken invocation is a CI gate somebody turns off. So a typo in a flag exits 2, a missing file exits 2, a lockfile in a format the reader refuses exits 2, and only an actual finding exits 1.
Without --fail-on
Nothing ever exits 1. The scan reports and returns 0.
$ ./target/release/stranger scan fixtures/poisoned.package-lock.json > /dev/null
$ echo $?
0
That is deliberate. stranger scan on its own is a thing you read; making it
fail by default would mean everyone learns to write || true.
With --fail-on
The threshold is compared against the worst severity seen. Levels order
low < medium < high < critical, and the five rules occupy four different
levels, so the choice matters.
| rule | severity |
|---|---|
| slopsquat | critical |
| install-script | high |
| pinning | high, medium or low, depending on the specifier |
| drift | medium |
| trivial | low |
npm-xl has install scripts, drift and trivial findings but no hallucinated
names, which makes it a clean demonstration of the threshold actually doing
something:
$ ./target/release/stranger scan --fail-on critical fixtures/npm-xl.package-lock.json > /dev/null
$ echo $?
0
$ ./target/release/stranger scan --fail-on high fixtures/npm-xl.package-lock.json > /dev/null
$ echo $?
1
npm-xs has nothing but four trivial findings, so it separates low from
medium:
$ ./target/release/stranger scan --fail-on low fixtures/npm-xs.package-lock.json > /dev/null
$ echo $?
1
$ ./target/release/stranger scan --fail-on medium fixtures/npm-xs.package-lock.json > /dev/null
$ echo $?
0
Exit 2
$ ./target/release/stranger scan fixtures/nope.json
stranger: fixtures/nope.json: no such file or directory
$ echo $?
2
$ ./target/release/stranger scan --format yaml fixtures/poisoned.package-lock.json
stranger: --format takes `human` or `json`, not `yaml`
$ echo $?
2
$ 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
$ echo $?
2
Errors go to stderr, findings to stdout. Because the reader parses a lockfile
before anything is written, a --format json run that exits 2 has printed
nothing to stdout — a downstream parser gets an empty stream and a non-zero
status rather than half an object.
A closed pipe is not an error
$ ./target/release/stranger scan fixtures/poisoned.package-lock.json | head -3
poisoned.package-lock.json 757 packages (35 direct · 722 transitive)
$ echo $?
0
head closes the pipe as soon as it has what it wants, and every write after
that fails with EPIPE. That is the shell working correctly, so it exits 0 and
says nothing. The alternative is an error message on every piped invocation.
A directory it could not open is an error
$ rm -rf /tmp/shut && mkdir -p /tmp/shut/proj
$ cp fixtures/poisoned.package-lock.json /tmp/shut/proj/
$ chmod 000 /tmp/shut/proj
$ ./target/release/stranger scan /tmp/shut --fail-on critical
could not look inside 1 path — this scan is incomplete
/tmp/shut/proj
$ echo $?
2
$ chmod 755 /tmp/shut/proj
Exit 2, and it outranks the findings — a directory that will not open is not
a bump, it is stranger being unable to do its job. --fail-on asks "is there a
finding at or above this level", and over a list of lockfiles that is short by an
unknown number the honest answer is neither 0 nor 1.
The cost is real and it is worth naming. One 0700 directory anywhere under
the scan root turns the gate red, whether or not it had a lockfile in it. That is
the trade: the alternative is the bug this replaced, where chmod 000 over a
directory holding a poisoned lockfile printed no lockfile and exited 0. A green
tick over a directory nobody could open is worse than a red one over a directory
that turned out to be empty, because only one of those two failures is silent.
A directory that is absent is not a blind spot and is not counted — a path that is not there hides nothing.
No lockfile is not an error
$ rm -rf /tmp/empty && mkdir -p /tmp/empty
$ ./target/release/stranger scan /tmp/empty
no lockfile stranger reads in /tmp/empty
looked for: package-lock.json, pnpm-lock.yaml, Cargo.lock, requirements.txt, poetry.lock, uv.lock, go.mod, yarn.lock
$ echo $?
0
Exit 0. Running the tool across a repository of mixed languages should not turn red on the directories it has nothing to say about. See A project whose toolchain you do not have.
$ ./target/release/stranger scan --fail-on high fixtures/npm-xl.package-lock.json; echo $?
Comparing two lockfiles
scan answers is this tree bad. A reviewer looking at a pull request has a
narrower question, and it is not the same one: did this change make it worse.
$ ./target/release/stranger diff fixtures/npm-l.package-lock.json fixtures/poisoned.package-lock.json
fixtures/npm-l.package-lock.json -> fixtures/poisoned.package-lock.json
added 3
chalck@5.3.0
expres@4.18.2
lodahs@4.17.21
introduced 4 findings
chalck@5.3.0 not in corpus · d=1 from "chalk" · root-only, no parent
expres@4.18.2 not in corpus · d=1 from "express" · root-only, no parent
lodahs@4.17.21 not in corpus · d=1 from "lodash" · root-only, no parent
lodahs@4.17.21 runs code at install time · lockfile records the flag, not the script
poisoned.package-lock.json is npm-l with three names planted in it. A scan
of it also reports 55 version-drift findings and 35 trivial packages, every one
of which was already in npm-l. The diff is the three that arrived.
Why the gate is not just two scans
--fail-on means something different here, deliberately. On scan it is the
worst finding in the tree; on diff it is the worst finding the change
introduced.
$ ./target/release/stranger diff fixtures/npm-l.package-lock.json fixtures/poisoned.package-lock.json --fail-on high -q
$ echo $?
1
Reverse the arguments and the same two files pass, because taking a problem out is not putting one in:
$ ./target/release/stranger diff fixtures/poisoned.package-lock.json fixtures/npm-l.package-lock.json --fail-on high -q
$ echo $?
0
While scan fails on that tree in both directions:
$ ./target/release/stranger scan fixtures/poisoned.package-lock.json --fail-on high -q
$ echo $?
1
That asymmetry is the reason the subcommand exists. A repository with 211
trivial packages fails a scan gate on every pull request until somebody turns
the gate off; it passes a diff gate until a pull request adds something.
Findings are matched by rule and package, not by version
A bumped dependency keeps its findings. esbuild@0.20.0 with an install script
becoming esbuild@0.21.0 with an install script is not one finding fixed and
one introduced — it is a version change, which the changed block prints, and
no change in risk.
The cost is real: a package already flagged for a rule can change version and the new version's finding is not called new.
A finding can move without a package moving
The two lockfiles do not have to be the same format — only the same ecosystem —
and the formats do not all record the same things. npm records install scripts;
yarn v1 has no field for them. So migrating a project
between the two changes the finding set while added, removed and changed
are all empty, and that is a real result rather than a glitch: the packages did
not move, but what is now visible about them did.
diff prints those findings and gates on them. The alternative — deciding
there is nothing to say because no package moved — is how it printed
no change to the dependency tree and exited 1 at the same time, which in CI
is a red build with nothing on screen to explain it.
In CI
$ git show HEAD~1:package-lock.json > /tmp/before.package-lock.json
$ stranger diff /tmp/before.package-lock.json package-lock.json --fail-on high
Exit 1 means this change introduced something at or above the threshold. See exit codes, and gate a pull request for the whole-tree version of the same job.
JSON
One object, not a stream — a diff is one comparison however many packages it touched. No timing field, for the same reason the scan object has none: two runs over the same pair have to produce the same bytes.
$ ./target/release/stranger diff --format json fixtures/npm-l.package-lock.json fixtures/poisoned.package-lock.json | head -c 120
{"old":"fixtures/npm-l.package-lock.json","new":"fixtures/poisoned.package-lock.json","added":["chalck@5.3.0","expres@4.
What it refuses
Two different ecosystems. A Cargo.lock against a package-lock.json parses
fine and produces a diff in which every package was added and every package was
removed — a confident, detailed, meaningless answer.
$ ./target/release/stranger diff fixtures/cargo-s.Cargo.lock fixtures/npm-xs.package-lock.json
stranger: fixtures/cargo-s.Cargo.lock and fixtures/npm-xs.package-lock.json are different ecosystems (crates.io and npm); there is nothing to compare
Two formats of one ecosystem are fine, and that diff is the one somebody migrating from npm to pnpm wants.
The co-occurrence rule
Edit distance on its own is not a rule.
http-proxy-agent and https-proxy-agent are both real npm packages, both
depended on by other packages in npm-xl, and one edit apart. So are
safe-buffer and safer-buffer. Take just the 1,077 distinct names the npm
fixtures install: between them they have 9,453 neighbours within distance 2 in a
140,066-name corpus, every one a package that exists. Any threshold loose enough
to catch a typo is loose enough to catch a legitimate sibling. Precision collapses
and the tool becomes noise.
The clause that separates them is not about spelling at all:
A hallucinated package is a root dependency. Nothing depends on it, because nothing real has ever heard of it. A model put it in your manifest; no maintainer ever put it in theirs.
https-proxy-agent is depended on by other packages. lodahs is not, and that is
the part that matters — the only reference to it in the tree under audit is the
manifest under audit.
Not "because it does not exist", which is how this page put it for most of the
weekend. A one-off registry check on 2026-08-30 says lodahs resolves on npm as
0.0.1-security: a holding package published by npm's security team for a name
they took down. expres resolves too, as a genuine 2012 package at 0.0.5. Both
of the versions the fixture pins — lodahs@4.17.21, expres@4.18.2 — are
fabricated, mirroring the real lodash and express releases, and neither has
ever been published. chalck does not resolve at all.
The rule is unaffected, and the check makes its premise sharper rather than softer. Clause 3 never asked whether a name is registered. It asks whether anything real depends on it, and for a squat that npm has already parked under a security hold the answer is emphatically no. See fixtures/README.md for the full check.
Three clauses
A name is reported when all three hold.
- It is not in the corpus of names known to exist.
- It is within edit distance 2 of a name that is.
- Nothing in the lockfile depends on it. Its in-degree is zero.
Clause 1 is a binary search over a sorted list compiled into the binary. Clause 2 is Damerau-Levenshtein, the unrestricted Lowrance-Wagner variant rather than the optimal-string-alignment version most libraries ship under that name, with the threshold at 2. Clause 3 is a lookup in a vector of in-degrees built from the lockfile's own edges.
They run in the order 1, 3, 2. Clause 1 first because it is a binary search and
it eliminates all but a couple of dozen names. Clause 3 before clause 2 because
it is one array index and clause 2 is a linear scan of 140,066 names — on the
npm-xl fixture that ordering is most of the difference between 234 ms and
something considerably worse.
Packages marked first-party are skipped before any of it. Somebody in this repository wrote them; they are not strangers.
Every hit is critical, and it is the only rule that is. It is also the only one
whose findings are always listed rather than collapsed to a count.
Clause 3, demonstrated
Two lockfiles, the same fake package, one edge of difference. Write the first:
$ mkdir -p /tmp/a && cat > /tmp/a/package-lock.json <<'EOF'
{
"name": "demo",
"lockfileVersion": 3,
"packages": {
"": { "dependencies": { "expres": "^4.18.2" } },
"node_modules/expres": {
"version": "4.18.2",
"resolved": "https://registry.npmjs.org/expres/-/expres-4.18.2.tgz"
}
}
}
EOF
$ ./target/release/stranger scan /tmp/a
package-lock.json 1 packages (1 direct · 0 transitive)
⚠ HALLUCINATION RISK 1
expres@4.18.2 not in corpus · d=1 from "express" · root-only, no parent
· UNPINNED — no signal in this format
risk 77/100 34ms third-party deps used to compute this: 0
Now the same name, reached through a real package that claims to depend on it:
$ mkdir -p /tmp/b && cat > /tmp/b/package-lock.json <<'EOF'
{
"name": "demo",
"lockfileVersion": 3,
"packages": {
"": { "dependencies": { "body-parser": "^1.20.2" } },
"node_modules/body-parser": {
"version": "1.20.2",
"dependencies": { "expres": "^4.18.2" }
},
"node_modules/expres": {
"version": "4.18.2",
"resolved": "https://registry.npmjs.org/expres/-/expres-4.18.2.tgz"
}
}
}
EOF
$ ./target/release/stranger scan /tmp/b
package-lock.json 2 packages (1 direct · 1 transitive)
no findings
· UNPINNED — no signal in this format
risk 0/100 6ms third-party deps used to compute this: 0
Clauses 1 and 2 are identical in both files. expres is absent from the corpus
either way and one deletion from express either way. The only thing that
changed is that a maintainer other than you wrote the name down, and the rule
goes quiet.
That is the conservative direction on purpose. An extra in-edge suppresses a
finding; it can never invent one. The same reasoning is why peerDependencies
counts as an edge alongside dependencies, devDependencies and
optionalDependencies — a peer dep is still a real maintainer writing down a
real name, and counting it can only make the rule quieter.
Which edges count
Not all of them. An edge is evidence only if a stranger drew it.
Edges out of the root manifest do not count. That manifest is the thing under
audit; "the file an LLM helped write lists this package" is not evidence that the
package exists. Those go into a separate roots list, not into edges.
Neither do edges out of a workspace member. Same repository, same author, same
absence of evidence. That refinement is doing real work: both monorepo fixtures
here declare workspaces and keep almost nothing in the root manifest, so a
reader that only looked at the root entry would report zero direct
dependencies for a 576-package project. A hallucinated name added to
apps/desktop/package.json would arrive with an in-edge and never be examined.
$ mkdir -p /tmp/c && cat > /tmp/c/package-lock.json <<'EOF'
{
"name": "monorepo",
"lockfileVersion": 3,
"packages": {
"": { "workspaces": ["apps/*"] },
"apps/desktop": { "dependencies": { "expres": "^4.18.2" } },
"node_modules/desktop": { "resolved": "apps/desktop", "link": true },
"node_modules/expres": {
"version": "4.18.2",
"resolved": "https://registry.npmjs.org/expres/-/expres-4.18.2.tgz"
}
}
}
EOF
$ ./target/release/stranger scan /tmp/c
package-lock.json 1 packages (1 direct · 0 transitive · 2 workspace)
⚠ HALLUCINATION RISK 1
expres@4.18.2 not in corpus · d=1 from "express" · root-only, no parent
· UNPINNED — no signal in this format
risk 77/100 20ms third-party deps used to compute this: 0
The workspace member declared it and the rule still fires. Compare with /tmp/b,
where a third-party package declared it and the rule did not.
Why distance 2
At 2, lodash is within range of 6 corpus names. At 3 it is within range of 49 —
lodash-es, lodash.eq, lodash.gt, lodash.lt, ldap, slash, soda and
forty-two more, every one of them a real package — and precision on the fixtures
falls off a cliff. Two still catches every single-character slip — deletion,
insertion, substitution, transposition — which is the entire population of typos
a model actually produces.
Transposition is why the distance function is Damerau and not plain Levenshtein.
lodahs is one transposition from lodash: Damerau distance 1, Levenshtein
distance 2.
This paragraph used to end "under Levenshtein it would sit in the same bucket as a
large population of legitimate siblings, and the threshold that catches it catches
half the registry with it". That is backwards, and the inequality says so:
Levenshtein permits a strict subset of Damerau's edits, so Levenshtein-at-k is
always the tighter filter, never the looser one. At the threshold that ships,
plain Levenshtein returns one candidate for lodahs and Damerau returns three.
What Damerau buys is the reported distance, the tie-break below, and the threshold
the rule could drop to — at k = 1 Damerau finds lodash and Levenshtein finds
nothing. What it does not buy is a different set of findings at k = 2, where the
two metrics agree on every name in every fixture. The full argument is in
DECISIONS.md.
When several corpus names are within range, ties go to the shorter one. asn1s is
one edit from both asn1 and asn1js, and the finding names asn1. That is a
display choice and not a detection one: the finding fires on either neighbour, and
which of them gets printed changes nothing about whether it fires.
Distance 2 is not a small net
requests-http was planted in fixtures/poisoned.requirements.txt as a name the
rule was not expected to catch — a hallucinated name that is not a near-miss of
anything real. It fires anyway:
$ ./target/release/stranger scan fixtures/poisoned.requirements.txt
poisoned.requirements.txt 6 packages (6 direct · 0 transitive)
⚠ HALLUCINATION RISK 2
python-dateutils@2.9.0 not in corpus · d=1 from "python-dateutil" · no dependency graph in this format
requests-http@1.0.2 not in corpus · d=2 from "requests-html" · no dependency graph in this format
⚠ UNPINNED 3 no exact version recorded
· INSTALL SCRIPTS — no signal in this format
risk 79/100 6ms third-party deps used to compute this: 0
requests-html is a real PyPI package two edits away. The finding is a true
positive — requests-http does not exist — but the prediction about it was
wrong, and it was wrong because nobody checked whether a real neighbour happened
to exist. Two edits reaches further than intuition suggests.
Where clause 3 is vacuous
On requirements.txt there are no dependency edges, so every package trivially
has in-degree 0, clause 3 eliminates nothing, and the rule degenerates to the two
clauses the ablation was written precisely because nobody trusted on their own.
pip covers the consequence and
False positives shows it costing a real one.
stranger tree refuses to print an in-degree on one of those files at all —
Looking at one package.
Is clause 3 worth anything
Against the full corpus, measurably nothing. Against a corpus missing 10% of its names, it cuts false positives from 36 to 1 at no cost in recall. That is a number, not an assertion, and it is on the next page.
$ ./target/release/stranger scan fixtures/poisoned.package-lock.json
The ablation table
The third clause is the claim, so it gets measured rather than asserted.
Ground truth is the fixture set. poisoned.package-lock.json contains exactly
three planted names — expres, lodahs, chalck — and the other five npm
fixtures contain none, so any finding outside the planted set is a false positive
by construction. 3,925 packages across six files.
$ make ablation
Against the full corpus, the clause is worth nothing
| in-degree clause | TP | FP | FN | precision | recall |
|---|---|---|---|---|---|
| on (shipped) | 3 | 0 | 0 | 1.000 | 1.000 |
| off (ablated) | 3 | 0 | 0 | 1.000 | 1.000 |
Identical. Both configurations find all three planted names and flag nothing else across 3,925 packages.
That is a real result and it belongs at the top of this page rather than buried at the bottom. It also measures the wrong thing. The 140,066-name corpus contains every package in every fixture, so clause 1 alone is sufficient and no other clause can possibly show a difference. Perfect scores here say the corpus is good, not that the rule is.
No corpus is ever complete
npm accepts thousands of new names a day. This corpus is a snapshot taken on one
afternoon — 2026-08-28, one curl run, written up in corpus/PROVENANCE.md. A
package published the day after that snapshot is, to clause 1, indistinguishable
from a package that does not exist.
So the question worth measuring is what happens as clause 1 degrades. Delete a fraction of the corpus and watch which clause is still holding the rule up. The thinning is a seeded xorshift, so the table is reproducible rather than different every run.
| corpus kept | in-degree clause | TP | FP | precision | recall |
|---|---|---|---|---|---|
| 100% (140066) | on | 3 | 0 | 1.000 | 1.000 |
| 100% (140066) | off | 3 | 0 | 1.000 | 1.000 |
| 90% (126004) | on | 3 | 1 | 0.750 | 1.000 |
| 90% (126004) | off | 3 | 36 | 0.077 | 1.000 |
| 70% (98197) | on | 2 | 6 | 0.250 | 0.667 |
| 70% (98197) | off | 2 | 127 | 0.016 | 0.667 |
| 50% (69897) | on | 1 | 8 | 0.111 | 0.333 |
| 50% (69897) | off | 1 | 175 | 0.006 | 0.333 |
| 25% (35134) | on | 1 | 5 | 0.167 | 0.333 |
| 25% (35134) | off | 1 | 177 | 0.006 | 0.333 |
Read the 90% row first, because it is the realistic one. Ten percent of the corpus missing is roughly what a few months of registry growth looks like. The clause takes false positives from 36 down to 1 — a 36-fold cut — and recall stays at 1.000. Nothing was traded for it.
At 70% the ratio is 127 to 6, about 21-fold. At 25% it is 177 to 5, about 35-fold. The clause never makes precision worse at any level, and it never costs a true positive relative to running without it; the ablation test asserts both of those rather than leaving them to the reader's eye.
These numbers moved when the length budget landed, and one of them moved the wrong way
Every false-positive count on this table is roughly a third of what it was
before distance::CHARS_PER_EDIT, because most of what a thinned corpus used to
hand clause 3 were short names sitting near something by arithmetic. Those never
reach clause 3 now. Precision improves at every level.
Recall got worse, and it is not a rounding artefact. At 70% the table used to read 3 true positives and now reads 2; at 50% and 25% it used to read 2 and now reads 1. That is the length budget refusing an edit the old threshold granted.
The honest reading is that the finding it lost was luck rather than detection. By
70% the thinning has deleted express itself, so expres no longer has its real
parent in the corpus and what it was matching was espree — a name it has nothing
to do with, as the section below works through. The budget declines to spend an
edit reaching a stranger. But it is a recall change, it is a cost, and a page
that only published the precision half of it would be doing the thing this
repository keeps saying it will not do.
The verdict outlives the explanation
Something more interesting than the headline number falls out of the decay run, and it took reproducing the thinning by hand to see.
The thinning is deterministic — seed 0x5EED1234, the same xorshift, over
corpus/npm.txt in order — so you can ask exactly which names it deleted. At 90%
all three parents survive. At 70% express is gone. At 50% and 25% lodash is
gone too, and only chalk is left.
Now look at what the tool reports at 70%. express has been deleted, and expres
is still flagged — matching espree at distance 2. At 50% and 25% it matches
rxpress. The finding is still correct, and its stated reason is not.
70% expres -> espree (d=2)
50% expres -> rxpress (d=2)
25% expres -> rxpress (d=2)
So the two halves of a finding decay at different rates. The verdict — this
name has no evidence behind it — survives corpus loss much better than the
explanation — this name is a typo of that one. By the time clause 1 has lost
30% of its coverage, the "nearest real name" printed in detail is a name the
typo has nothing to do with.
The practical reading: treat d=1 from "chalk" as the rule showing its working,
not as an identification. It is the closest surviving corpus entry, which is only
the actual parent when the corpus still contains the actual parent.
The recall drop is not the clause
Recall falls from 1.000 to 0.667 at 50% and stays there at 25%, in both columns. That is worth being clear about, because a careless reading blames the in-degree clause for it.
It is clause 2 failing, not clause 3. Thinning at 50% deleted lodash itself.
lodahs is still absent from the corpus and still has no parent, but there is no
longer a real name within distance 2 for it to be a typo of, so clause 2 finds
nothing and the rule stays quiet. Losing a planted name's parent loses the planted
name.
expres kept firing through the same loss because a coincidental neighbour
existed. lodahs had none. Which of the two happens is luck about the shape of
the registry, not a property of the rule.
That is a corpus-coverage failure. It shows up identically whether the in-degree clause is on or off, which is exactly what you would expect from a failure that has nothing to do with it. A tool whose corpus is half gone has bigger problems than which clause is enabled.
Running it
The decay table scans the fixtures ten times against a 140,000-name corpus and
took about two minutes before the prefilter and takes four seconds now, and it
keeps its own target because it is still not something to pay on every cargo test:
$ make ablation
cargo test --release --test ablation -- --nocapture --include-ignored
...
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.03s
CI runs it on every push, because the README quotes its numbers. The fast table
runs on every cargo test.
$ cargo test --test ablation -- --nocapture
False positives
On the npm fixture set with the full corpus, there are none. 3,925 packages across six lockfiles, three findings, and all three are the planted names.
That number is a property of the corpus and of the npm format. Here is where it breaks.
A real package published after the snapshot
This is the failure mode, and it is not hypothetical — the repository ships a fixture that triggers it.
$ ./target/release/stranger scan fixtures/reqs-xs.requirements.txt
reqs-xs.requirements.txt 12 packages (12 direct · 0 transitive)
⚠ HALLUCINATION RISK 1
tensorflow-gpu not in corpus · d=1 from "tensorflow-cpu" · no dependency graph in this format
⚠ UNPINNED 12 no exact version recorded
· INSTALL SCRIPTS — no signal in this format
risk 77/100 4ms third-party deps used to compute this: 0
tensorflow-gpu is a real PyPI package. It is deprecated, which is why it is
absent from a top-15,000 corpus, and it is one edit from tensorflow-cpu, which
is present. Clauses 1 and 2 both fire. There is no clause 3 on a flat file to
stop them.
You can check both halves yourself:
$ grep -x -E 'tensorflow(-gpu|-cpu)?' corpus/pypi.txt
tensorflow
tensorflow-cpu
Clause 1 asks whether a name is in a list fetched on 2026-08-28. npm accepts thousands of new names a day and PyPI is no slower. A package published after that date, or one that fell off a popularity ranking before it, fails clause 1 for a reason that has nothing to do with being fake.
The ablation table puts a number on the npm side: delete 10% of the corpus, roughly what a few months of registry growth costs you, and the false positive count goes from 0 to 3. Delete 30% and it goes to 16.
Short names, and the two false positives that are gone
Two findings were on this page until the last day of the window: ksni in
cargo-m.Cargo.lock and taze in pnpm-l.pnpm-lock.yaml. Both were described as
real packages that had fallen below a popularity cut — bad luck, and the corpus's
fault.
That was the wrong diagnosis. Both names are four characters long, and length turns out to be the variable that matters.
The measurement is leave-one-out over each corpus: take a real name, pretend it is missing — which is exactly what a real package below the cut looks like to clause 1 — and ask whether the rest of the list offers it a neighbour. That is the false positive rate, as a function of length:
| chars | npm k=1 | npm k=2 | pypi k=1 | pypi k=2 | crates k=1 | crates k=2 |
|---|---|---|---|---|---|---|
| 2 | 99.6% | 100.0% | 88.0% | 100.0% | 36.4% | 100.0% |
| 3 | 98.6% | 100.0% | 69.9% | 100.0% | 60.4% | 100.0% |
| 4 | 51.9% | 100.0% | 43.9% | 98.9% | 41.4% | 99.1% |
| 5 | 40.5% | 97.5% | 34.0% | 93.8% | 18.7% | 78.9% |
| 6 | 36.5% | 85.8% | 16.3% | 76.6% | 12.5% | 51.5% |
| 8 | 30.0% | 63.0% | 11.3% | 35.6% | 7.0% | 26.6% |
| 9 | 27.7% | 55.1% | 5.9% | 23.1% | 4.1% | 19.3% |
| 10 | 18.8% | 46.1% | 2.1% | 14.9% | 4.8% | 9.5% |
At four characters, a name absent from npm finds a neighbour within two edits
every single time. So clause 2 was not weighing evidence about ksni or
taze; it was passing everything, and the rule was really running on two clauses:
"not in the corpus" and "in-degree zero". For a real package that nobody depends
on — a devDependency of the root manifest, say — that is a guaranteed CRITICAL.
The threshold is a function of length now: one edit per five characters, capped at
two, which is distance::budget_for. Five is where the table points. Reading the
npm column, a hit at k = 1 stops being the likelier outcome at five characters and
a hit at k = 2 at ten characters — below a coin flip being the bar, because a
clause that fires on most inputs is not evidence about any of them. That is one
edit per five characters, twice.
Nine policies were swept against every fixture, with the seven planted names as ground truth and everything else counted as a false positive:
| policy | TP | FP | recall | precision |
|---|---|---|---|---|
2 — a flat threshold, what shipped | 7 | 5 | 1.000 | 0.583 |
min(2, len / 3) | 7 | 4 | 1.000 | 0.636 |
min(2, (len - 1) / 3) | 7 | 3 | 1.000 | 0.700 |
min(2, len / 4) | 7 | 3 | 1.000 | 0.700 |
min(2, len / 5) — ships | 7 | 1 | 1.000 | 0.875 |
1 — a tighter flat threshold instead | 6 | 4 | 0.857 | 0.600 |
Recall does not move. All seven planted names still fire, at the same distances and against the same parents.
Four policies tie at 0.875, so the fixtures do not pick between them — the
leave-one-out table does. (len - 1) / 4 hands out two edits at nine characters,
where npm still answers 55% of the time. (len - 1) / 5 and len / 6 refuse
nunpy its edit at five characters, and nunpy is a true positive tests/pip.rs
already holds the rule to.
The last row is there because "just lower the threshold" is the obvious
alternative, and it is worse at both ends: it loses requests-http — a genuine
hallucination two edits from requests-html — and still keeps four false
positives.
Both tables are in the doc comment on distance::CHARS_PER_EDIT, and both are
tests: tests/corpus.rs::length_is_the_false_positive_rate (ignored by default,
about six minutes) and tests/ablation.rs::edit_budget_policy_sweep.
What it does not fix is the next section. tensorflow-gpu is fourteen
characters, and at fourteen characters a near-miss really is evidence — no length
policy reaches it, and it should not.
Flat formats lose the clause that would have saved it
The tensorflow-gpu finding is the pip case specifically. requirements.txt
records no dependency edges, so every package has in-degree 0, clause 3 eliminates
nothing, and the rule runs on two clauses instead of three.
On an npm tree the same package would have had a chance: something real depends on
tensorflow-gpu, and that edge would have suppressed the finding. On a flat file
there is no edge to find.
The fix is a different file rather than a better reader, and both of those files
read today. poetry.lock and uv.lock record the
resolved graph, so a Python project that keeps one of them gets three clauses
where a requirements.txt project gets two. poetry-m scans as 233 packages,
75 direct and 158 transitive; uv-m as 249, 91 direct and 158 transitive. Those
transitive counts are clause 3's raw material, and on reqs-xs above the same
column reads 0.
This is not retroactive relief for tensorflow-gpu. That fixture is a
requirements.txt and stays a two-clause scan; the point is that the format is
the thing to change, not the reader.
The nearest name can be wrong even when the verdict is right
detail names the closest corpus entry within distance 2. When the corpus is
complete that is nearly always the typo's actual parent. When it is not, the tool
still picks the closest surviving name and prints it with the same confidence. In
the 70% ablation row, expres is reported against espree rather than express,
because express had been deleted.
Treat d=1 from "chalk" as the rule showing its working, not as an
identification.
A brand-new direct dependency
The worst case for the rule is the case it is designed for, seen from the other side: you have added a genuinely new, genuinely real package to your manifest. It is a root dependency, nothing depends on it, and it is too recent for the corpus. All three clauses fire.
There is no way for the tool to tell that apart from a hallucination, because from inside the lockfile there is no difference. The evidence that would settle it — does this name exist on the registry — is on the network, and the tool does not go there.
The right reading of a finding is "no evidence this name is real", not "this name is fake".
The trivial rule is wrong more often than it is right
slopsquat gets the careful treatment because it is the rule with an idea in it.
The noisiest rule is trivial, and it says so in its own
documentation: its second clause looks for a predicate-shaped name that resolves
no dependencies, and has no way to know how long the file behind that name is.
is-callable is dozens of lines of edge cases around one typeof. is-docker
reads /proc and memoises the answer. Both are reported. Neither is a one-liner.
That is not an occasional miss — it is a good share of what the clause finds on a
real tree, which is why the rule is low and collapses to a count by default.
What it misses
Two categories, and they matter more than the false positives.
A typosquat that actually got registered. The corpus is a list of names that
exist on npm and PyPI, harvested from npm and PyPI. It is not a list of names that
are safe. If an attacker registered lodahs this morning, it would be in a corpus
rebuilt this afternoon, clause 1 would pass, and the rule would never fire. The
corpus makes the tool quiet about real names; whether a real name is malicious is
a different question and this tool does not ask it.
A hallucinated name that is genuinely close to nothing. Clause 2 needs a
neighbour within two edits. A name like api-client-utils has none and stays
silent. Note that this net is wider than it looks — requests-http was planted as
an example of exactly this and turned out to be two edits from the real
requests-html, so the rule caught it after all.
What it deliberately ignores
Workspace members and link: true entries are first-party and skipped before any
clause runs, by every rule. In npm-xl that is 14 of 1,390 entries. Without the
exclusion every monorepo scan is mostly noise about the project scanning itself.
Checking a finding
You cannot do it from the lockfile — that is the whole point. Open the registry page for the name. If it does not exist, you have your answer. If it exists but was published last week by an account with no history, you have a different and more interesting answer.
$ ./target/release/stranger scan --format json fixtures/reqs-xs.requirements.txt | jq -r '.findings[] | select(.rule=="slopsquat") | .package'
Install scripts
npm install runs a dependency's preinstall, install and postinstall hooks
as part of installing it. Before your test suite, before your own first line of
code, with your environment and whatever your ssh agent is holding.
That is the whole argument for high. For these packages the gap between "a name
appeared in the lockfile" and "that name's code ran on this machine" is one
command, and no review step fits inside it.
$ ./target/release/stranger scan -v fixtures/npm-m.package-lock.json
npm-m.package-lock.json 576 packages (20 direct · 556 transitive · 6 workspace)
⚠ INSTALL SCRIPTS 4 arbitrary code at install time
esbuild@0.27.7 runs code at install time · lockfile records the flag, not the script
fsevents@2.3.3 runs code at install time · lockfile records the flag, not the script
sharp@0.34.5 runs code at install time · lockfile records the flag, not the script
unrs-resolver@1.12.2 runs code at install time · lockfile records the flag, not the script
The signal
One field:
$ jq '.packages["node_modules/esbuild"] | {version, hasInstallScript}' fixtures/npm-xl.package-lock.json
{
"version": "0.28.1",
"hasInstallScript": true
}
That is everything lockfileVersion 3 records. Not the body of the script, not
which of the three hooks, not even the script's name. The body is in the tarball
on the registry, and stranger does not fetch.
What it cannot see
esbuild unpacking a platform binary and a package curling a payload produce the
identical line in this report. Reading that line as triage is the mistake the
rule's own source comment exists to prevent, and the detail string is worded so
it never implies otherwise: runs code at install time · lockfile records the
flag, not the script.
A finding here is a list of packages to look at, ordered by nothing. It is not a verdict on any of them.
What is excluded
The root project's own hasInstallScript is your build, not a stranger's. So are
a workspace member's and its link: true symlink. All three are dropped.
jq counts 9 flagged entries in npm-xl; the reader reports 8. The missing one
is the root entry, deliberately.
Copies count separately
One finding per lockfile entry, not per name. Two entries for one name are two
installs, and two installs run the hook twice, so both are reported. fsevents
appears twice in npm-xl — once at the top level and once under a workspace
member — and tests/rules.rs asserts both hits are there:
$ jq -r '.packages | to_entries[] | select(.key | endswith("node_modules/fsevents")) | "\(.key) \(.value.version)"' fixtures/npm-xl.package-lock.json
apps/desktop/node_modules/fsevents 2.3.2
node_modules/fsevents 2.3.3
Both are reported:
$ ./target/release/stranger scan -v fixtures/npm-xl.package-lock.json | head -12
npm-xl.package-lock.json 1,376 packages (150 direct · 1,226 transitive · 14 workspace)
⚠ INSTALL SCRIPTS 8 arbitrary code at install time
agent-browser@0.26.0 runs code at install time · lockfile records the flag, not the script
electron@40.10.2 runs code at install time · lockfile records the flag, not the script
electron-winstaller@5.4.0 runs code at install time · lockfile records the flag, not the script
esbuild@0.28.1 runs code at install time · lockfile records the flag, not the script
fsevents@2.3.2 runs code at install time · lockfile records the flag, not the script
fsevents@2.3.3 runs code at install time · lockfile records the flag, not the script
node-pty@1.1.0 runs code at install time · lockfile records the flag, not the script
unicode-animations@1.0.3 runs code at install time · lockfile records the flag, not the script
Version is part of the sort key, so the order is stable across scans.
It fires on npm only
requirements.txt records nothing equivalent. A pip source distribution can run
whatever setup.py wants during installation and the file does not say that it
will, which is a real blind spot rather than a rule that does not apply — see
Limits.
Reading a hit
The poisoned fixture shows why this rule is worth having next to the hallucination rule rather than instead of it:
$ ./target/release/stranger scan --format json fixtures/poisoned.package-lock.json | jq -r '.findings[] | select(.rule=="install-script") | .package'
lodahs
sharp
unrs-resolver
lodahs appears under two rules that mean different things. One says the name has
no evidence behind it. The other says that if you install it, it runs code. Both
are true and neither implies the other.
$ ./target/release/stranger scan -v fixtures/npm-m.package-lock.json
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
Trivial packages
Dependencies whose whole job is one expression. left-pad was one of these, and
so was event-stream.
$ ./target/release/stranger scan -v fixtures/npm-xs.package-lock.json
npm-xs.package-lock.json 37 packages (1 direct · 36 transitive)
⚠ TRIVIAL 4 (10.8% of third-party)
es-errors@1.3.0 one expression, one publisher · inlining it removes an account from your build
gopd@1.2.0 one expression, one publisher · inlining it removes an account from your build
has-symbols@1.1.0 predicate-shaped, resolves nothing · size not measured, see rule docs
hasown@2.0.4 one expression, one publisher · inlining it removes an account from your build
· UNPINNED — no signal in this format
risk 9/100 10ms third-party deps used to compute this: 0
It does not measure triviality
Start here, because everything else on this page depends on it.
A package-lock.json entry holds version, resolved, integrity, license,
engines and the dependency lists. There is no unpacked size, no file count, no
export list, no line count anywhere in the format. All of that is in the tarball,
the tarball is on the registry, and stranger does not fetch.
So this rule recognises names. Nothing it prints should be read as though it had measured anything.
Two clauses, and they are different in kind
A hand-written list. Two dozen packages whose published purpose is a single
expression or a re-export of a builtin — isarray, is-number, left-pad,
object-assign. Picked by hand, which makes the boundary somebody's judgement
rather than a threshold. Against a registry holding millions of names, two dozen
is nothing. That is the honest size of the clause, and there is no version of it
that is not a list somebody wrote.
Shape. A name that reads as a predicate (is-…, has-…, scope stripped)
and that resolves no dependencies of its own. Both halves come out of the
lockfile. The second half is what stops it firing on is-glob and
has-tostringtag, which turned out to need help.
The detail string keeps the two apart, because they deserve different amounts of
trust:
one expression, one publisher · inlining it removes an account from your build
predicate-shaped, resolves nothing · size not measured, see rule docs
How clause 2 is wrong
It has no idea how long a file is.
is-callable is dozens of lines of edge cases around one typeof. is-docker
reads /proc and memoises the answer. Both are predicate-shaped, both resolve
nothing, both are reported, and neither is a one-liner. That is the false-positive
mode, and it is not the exception — it is a good share of what clause 2 finds on a
real tree.
A hit is worth twenty seconds of attention. It is not a verdict.
It under-reports at least as badly
function-bind, wrappy and util-deprecate are in the same weight class as
anything on the list and are not on it, because nobody is going to claim to have
read them all. Clause 2 is blind to any micro-package that depends on another
micro-package — once needs wrappy — and to every one that is not named like a
predicate, which is most of them.
Why low
None of this is a vulnerability. It is a count of publishers who can push straight
into your build, for code you could have inlined. left-pad and event-stream
were both packages this size, so the count is worth having. It is never urgent,
and it collapses to a count unless you pass -v.
The percentage
⚠ TRIVIAL 29 (2.1% of third-party)
The denominator is the header's own package count — third-party entries only. The rule never looks at a first-party package, so a workspace member cannot be a hit, and counting one in the denominator would print a share of a population the numerator was never drawn from.
That was the bug for most of the weekend: the percentage divided by every entry
in the lockfile while the header divided by third-party ones. On npm-m it read
17/582 where the header's own numbers say 17/576 — 2.9% against 3.0%. Under a
tenth of a point on every fixture here, which is exactly why it survived so long.
tests/cli.rs now derives both numbers from one line of output and fails if they
ever disagree again.
Duplicates collapse
npm nests the same version of is-extendable under two different parents in
npm-xl. Two install hooks would be two events, so
install scripts reports both. Two copies of one expression
are one fact, so this rule prints it once. Different versions still count
separately — is-docker at 2.2.1 and 3.0.0 are two findings, and
version drift is the rule that cares that both exist.
$ ./target/release/stranger scan --format json fixtures/npm-xl.package-lock.json | jq -r '.findings[] | select(.rule=="trivial") | .detail' | sort | uniq -c
Unpinned requirements
An unpinned requirement is not a vulnerability. It is the mechanism by which
somebody else's vulnerability reaches you without anybody changing a file: the
compromised release ships, pip install -r requirements.txt runs in CI, and the
diff that introduced it is empty.
Every published pip supply-chain incident has that shape, and it is the reason a rule about punctuation is worth writing.
$ ./target/release/stranger scan -v fixtures/poisoned.requirements.txt
poisoned.requirements.txt 6 packages (6 direct · 0 transitive)
⚠ HALLUCINATION RISK 2
python-dateutils@2.9.0 not in corpus · d=1 from "python-dateutil" · no dependency graph in this format
requests-http@1.0.2 not in corpus · d=2 from "requests-html" · no dependency graph in this format
⚠ UNPINNED 3 no exact version recorded
flask ~=3.0 · capped at the major, still floats below the cap
numpy no bound in either direction · resolves to whatever is newest at install time
urllib3 >=1.26 · a range, so the file does not say what installs
· INSTALL SCRIPTS — no signal in this format
risk 79/100 6ms third-party deps used to compute this: 0
The file behind that:
$ cat fixtures/poisoned.requirements.txt
requests==2.31.0
urllib3>=1.26
python-dateutils==2.9.0
requests-http==1.0.2
flask~=3.0
numpy
requests==2.31.0 is exact and produces nothing.
Three severities, and the ranking is a ranking of how much future gets in
| specifier | pin | severity | why |
|---|---|---|---|
numpy | unconstrained | high | no bound in either direction |
>=1.26, <2, !=1.5 | range | medium | open-ended in at least one direction |
~=1.2, ==1.2.* | compatible | low | capped at the major, floats below the cap |
==2.31.0, ===2.31.0 | exact | — | not a finding |
Unconstrained is high. pip install numpy today and the same command in March
install different programs, and there is nothing in the repository that records
which one you tested.
A range is medium. >=1.0 is the common case and it is open above: every
release the maintainer has not published yet already matches. <2 and !=1.5 are
open below instead, which is a smaller window but the same class of answer — the
file does not say what installs. One notch under unconstrained because at least
one end is written down.
Compatible is low. ~=1.2 caps the major, so a hostile 2.0 cannot arrive.
That is a real reduction, and it is not a pin: the compromised releases that
actually happened were patch releases of a package people already trusted, and
every one of those still matches.
Nothing here is critical. An unpinned dependency is a way to be compromised later, not evidence of being compromised now. Critical is reserved for slopsquat, where the finding is a name that should not exist.
It fires on PyPI only, and in practice on one PyPI format
npm, cargo and go all record a resolved version, so every entry those readers
produce is Pin::Exact and there is nothing to say. Firing on them would mean
either a rule that never triggers or a rule that has started guessing. The rule
returns immediately on any non-PyPI tree.
poetry.lock and uv.lock are PyPI, so the rule does run over them — and finds
nothing, because those two readers set Pin::Exact on every entry they build and
Pin::Exact is the arm this rule skips. A lockfile records one resolved version
per package; there is no specifier left to classify.
So the rule walks 233 poetry-m entries and 249 uv-m entries and returns an
empty list from both. That is a rule doing nothing rather than a rule being
switched off, and it is why the limits grid reads never for
poetry and uv but not for requirements.txt.
A direct reference is unconstrained
pkg @ https://host/pkg.whl
A direct reference names bytes rather than a version, and the bytes at a URL are whatever the host serves next time. So it classifies as unconstrained, with no version recorded, which is the honest reading.
The finding has no version
version is empty in the JSON for every pinning finding except none — if a
requirement had an exact version it would not be a finding. That is how a consumer
tells these apart:
$ ./target/release/stranger scan --format json fixtures/poisoned.requirements.txt | jq -c '.findings[] | select(.rule=="pinning")'
{"rule":"pinning","severity":"low","package":"flask","version":"","detail":"~=3.0 · capped at the major, still floats below the cap"}
{"rule":"pinning","severity":"high","package":"numpy","version":"","detail":"no bound in either direction · resolves to whatever is newest at install time"}
{"rule":"pinning","severity":"medium","package":"urllib3","version":"","detail":">=1.26 · a range, so the file does not say what installs"}
The specifier is quoted verbatim in detail because the finding has to be
arguable. >=1.26 is something you can check against the file; "unpinned" is
something you have to take on trust.
What it cannot see
Whether the range is deliberate. A library publishing to PyPI is supposed to
declare ranges; an application deploying from a requirements.txt is not. The
file does not say which one it belongs to, and this rule does not guess.
It also cannot see the resolved versions that a real deployment used — not from
this file. All three of the files that do record them are readable, so the answer
is to point stranger at one of those instead.
pip freeze writes a requirements.txt with == on every line, which is the
same reader and produces no pinning findings at all:
$ mkdir -p /tmp/freeze
$ printf 'flask==3.0.0\nnumpy==2.1.0\nurllib3==2.2.1\nrequests==2.31.0\n' > /tmp/freeze/requirements.txt
$ cat /tmp/freeze/requirements.txt
flask==3.0.0
numpy==2.1.0
urllib3==2.2.1
requests==2.31.0
$ ./target/release/stranger scan /tmp/freeze
requirements.txt 4 packages (4 direct · 0 transitive)
no findings
· INSTALL SCRIPTS — no signal in this format
risk 0/100 0ms third-party deps used to compute this: 0
That still records no graph, so the detection rule stays
on two clauses. poetry.lock and uv.lock record
both the versions and the graph, and are the file to keep if you get to choose.
$ ./target/release/stranger scan -v fixtures/reqs-xs.requirements.txt
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
| field | used for |
|---|---|
lockfileVersion | accept or refuse the file |
version | printed beside the name; compared for equality by the drift rule |
dev, optional | recorded on each package; no rule uses them yet |
link | marks a symlink to a workspace member as first-party |
hasInstallScript | the install scripts rule |
integrity | presence recorded; never verified, see Limits |
dependencies, devDependencies, optionalDependencies, peerDependencies | graph 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.
| fixture | entries | third-party | direct | workspace |
|---|---|---|---|---|
npm-xs | 37 | 37 | 1 | 0 |
npm-s | 405 | 405 | 12 | 0 |
npm-m | 582 | 576 | 20 | 6 |
npm-l | 754 | 754 | 32 | 0 |
npm-xl | 1,390 | 1,376 | 150 | 14 |
poisoned | 757 | 757 | 35 | 0 |
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
pnpm
pnpm-lock.yaml, lockfileVersion 9 and 6.
$ ./target/release/stranger scan fixtures/pnpm-l.pnpm-lock.yaml
pnpm-l.pnpm-lock.yaml 850 packages (29 direct · 821 transitive)
⚠ TRIVIAL 23 (2.7% of third-party)
⚠ VERSION DRIFT 58 same package at 2+ versions in one tree
· INSTALL SCRIPTS — no signal in this format
· UNPINNED — no signal in this format
risk 46/100 148ms third-party deps used to compute this: 0
taze used to appear in that block, and was the npm half of a pair of false
positives — ksni on cargo was the other. Both are four characters
long, and that is why: on npm, a four-character name has a neighbour within two
edits 100% of the time, so clause 2 was a formality rather than a filter. The
length budget refuses it the edit and taze stops firing.
False positives has the table.
It is still in the fixture. A fixture that stops demonstrating the bug it was kept for is still the file the fix was measured against.
pnpm packages are npm packages, so this reader shares npm's corpus and the trivial rule's name list works here unchanged. What it does not share is the file.
Three sections, and they are not interchangeable
| section | keyed by | what it carries |
|---|---|---|
importers | workspace directory, . for a single-package repo | the project's own manifests: name: {specifier, version} |
packages | name@version | 850 distinct tarballs — resolution, engines, hasBin, peerDependencies, deprecated |
snapshots | name@version plus a peer suffix | the installed instances. This is where the edges are. |
Reading edges out of packages instead of snapshots gets you a package list and
no graph, which is the failure mode that matters: the
detection rule's third clause needs in-degree, and a tree
with no edges makes every package look like a root.
deprecated is a block scalar
The one field in that table that is not a flow collection or a scalar on its own line. pnpm copies the registry's deprecation message verbatim, and those run to paragraphs, so it writes them as a YAML literal block:
q@1.5.1:
resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw…}
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
deprecated: |-
You or someone you depend on is using Q, the JavaScript Promise library
that gave JavaScript developers strong feelings about promises.
(For a CapTP with native promises, see @endo/eventual-send)
Nothing here reads the message. What matters is that the reader gets to the end
of it and finds engines: again rather than treating an indented English
sentence as structure — so the YAML subset parses literal
block scalars with all three chomping modes (|, |-, |+) rather than
refusing them. Refusing meant refusing the whole lockfile, and any tree holding
one deprecated package holds one of these.
The folded form > is still refused by name, along with an explicit
indentation indicator (|2). Neither appears in a lockfile, and a folded
scalar that joins its lines slightly wrong still parses — which is the failure
this subset is arranged against.
Peer suffixes
The same tarball can be installed more than once with different peers resolved, so
a snapshot key carries a parenthesised suffix its packages entry does not:
packages: astro@5.7.10
snapshots: astro@5.7.10(@types/node@22.15.3)(jiti@2.4.2)
Dependency values carry it too — '@volar/kit': 2.4.23(typescript@5.8.3). Both
ends of every edge are truncated at the first ( before lookup.
Splitting these on @ is how a naive reader loses every scoped package.
@babel/core@7.27.1 has to split at the last @, and the @types/node@22.15.3
inside a suffix must not be split at all. tests/pnpm.rs asserts the 1,851 edges
resolve and that scoped names survive.
What the format does not record
Everything in this section is about version 9. Version 6 records all three and is read for all three, which is the odd part: the newer file is the one that tells you less.
Install scripts. Version 9 dropped requiresBuild and did not replace it.
hasBin: true appears 42 times in this fixture and is not a substitute — it
means the package ships a bin to symlink, not that code runs at install time.
Mapping one to the other would put 42 High findings in the report, every one
invented. So install_script is false for every package in a v9 file and
install scripts says nothing about that tree, and
that silence means not measured rather than nothing found.
Dev-only packages. pnpm 9 records dev-ness on the importer's manifest, not on
the package, and does not mark the transitive closure. dev is false throughout
rather than guessed at with a graph walk.
First-party packages. A v9 workspace member lives in importers and never in
packages, and pnpm writes its dependents' references to it as link:../name,
which resolves to no package entry. So first_party is false everywhere in a v9
file for want of anything to be true about, and the header prints no workspace
count — where the npm reader has to work for the same distinction.
Version 6 is the same file with two sections fused
pnpm 8 wrote it, and it is still on disk in a great many repositories — one of
the four pnpm-lock.yaml files on the machine this was written on. The fixture
is mongodb/mongo's own lockfile, taken as found:
$ ./target/release/stranger scan fixtures/pnpm-v6.pnpm-lock.yaml
pnpm-v6.pnpm-lock.yaml 89 packages (7 direct · 82 transitive · 1 workspace)
⚠ TRIVIAL 2 (2.2% of third-party)
⚠ VERSION DRIFT 2 same package at 2+ versions in one tree
· UNPINNED — no signal in this format
risk 29/100 <ms> third-party deps used to compute this: 0
Four differences, and the reader is otherwise the v9 reader:
- No
snapshotssection. Apackagesentry carries its own resolveddependencies, so the tarball list and the instance list are one section. - Keys start with
/, and the peer suffix rides on thepackageskey rather than on a separate snapshot key. Two entries for one tarball at different peers collapse to the singlePackagev9 would have written, which is what keeps a package count comparable across the two versions. - A single project writes its manifest at the top level and no
importersat all, so the document itself is the one importer. - It records
requiresBuild,dev, and local packages, all three of which v9 gave up.
That last one has teeth. A file: dependency gets a real packages entry keyed
by its path, with its actual name in a name field because the key has no
version to carry one:
file:buildscripts/eslint-plugin-mongodb:
resolution: {directory: buildscripts/eslint-plugin-mongodb, type: directory}
name: eslint-plugin-mongodb
dev: false
Read literally, that audits the project's own code as a stranger under the name
file:buildscripts/eslint-plugin-mongodb, and stranger tree could not find it
under the name its own repository uses. Taking the name field and setting
first_party fixes both, and is the same call the npm reader makes
about a workspace key — which is why the header above says 1 workspace:
$ ./target/release/stranger tree eslint-plugin-mongodb fixtures/pnpm-v6.pnpm-lock.yaml
fixtures/pnpm-v6.pnpm-lock.yaml npm · 89 packages
eslint-plugin-mongodb file:buildscripts/eslint-plugin-mongodb
workspace member — your own code, not a stranger
What it refuses
$ mkdir -p /tmp/v5
$ printf "lockfileVersion: '5.4'\n" > /tmp/v5/pnpm-lock.yaml
$ ./target/release/stranger scan /tmp/v5/pnpm-lock.yaml
stranger: /tmp/v5/pnpm-lock.yaml: lockfileVersion 5.4 is not supported; stranger reads 9 and 6. Run `pnpm install` with pnpm 8 or newer to upgrade the file.
Version 5 and below key their packages /name/version rather than
/name@version. The last @ in a scoped key is then part of the name and there
is no version to find, so a reader that guessed would split every scoped name in
the wrong place. Refusing by name beats that.
The version arrives as the string "9.0", because pnpm quotes it and
the YAML subset types exactly two tokens — true and false —
and leaves everything else a string. no and on are npm package names this
repository's own corpus can confirm — y and off are registered too but sit
below its popularity cut —
and a reader that turned no@1.0.0 into a boolean would drop a package out of an
audit without saying anything.
$ ./target/release/stranger scan -v fixtures/pnpm-l.pnpm-lock.yaml
cargo
Cargo.lock, version 3 and 4.
$ ./target/release/stranger scan fixtures/cargo-m.Cargo.lock
cargo-m.Cargo.lock 708 packages (34 direct · 674 transitive · 15 workspace)
⚠ VERSION DRIFT 70 same package at 2+ versions in one tree
· INSTALL SCRIPTS — no signal in this format
· UNPINNED — no signal in this format
risk 46/100 8ms third-party deps used to compute this: 0
ksni used to appear in that block. It is a real crate below the top 5,000 and it
was a false positive — and it was four characters long, which turned out to be the
whole story: a name that short is within two edits of something in every
registry, so clause 2 was not filtering it at all. The length budget
(distance::CHARS_PER_EDIT) gives a four-character name no edits, and ksni stops
firing. False positives has the measurement.
The crates.io corpus's size is still a published number rather than an implementation detail, because that is the limit underneath: 5,000 names is the top of crates.io and not the whole of it, and a longer crate below the cut would still be reported.
Structurally this is the easiest of the three formats: an array of [[package]]
tables, no install paths to reproduce, no nesting. What makes it non-trivial is
that its dependency strings are not names.
The three shapes of a dependency string
Cargo writes the shortest form that is unambiguous and promotes only when it must:
"bytes" name
"winapi 0.3.9" name version
"qux 1.0.0 (registry+https://…)" name version source
The second appears when two entries share a name — cargo-m has five hashbrowns
and five windows-sys. The third appears when two entries share a name and a
version and differ only in origin.
Counted across all three fixtures, 5,689 dependency strings in total:
| fixture | bare | name+version | name+version+source |
|---|---|---|---|
cargo-s | 251 | 8 | 0 |
cargo-m | 1,723 | 500 | 0 |
cargo-l | 2,610 | 597 | 0 |
The third shape is not exercised by any real fixture here. It is implemented and tested against a hand-written file, and that is the honest status: handled, unmeasured.
The second shape is very much exercised — 1,105 strings, a fifth of the corpus —
and reading one as a bare name is not a near miss. All 22 of cargo-m's
windows-sys edges carry a version and there are five entries they could mean, so
dropping the version collapses all 22 onto one entry. Fourteen of them point at
0.61.2, so the luckiest possible guess still lands 8 edges on the wrong package,
and the unluckiest lands 21. Silently, too — no parse error, just a graph with
wrong edges, which corrupts the in-degree the
detection rule leans on.
The invariant that makes the bare form safe is Cargo's, not this reader's — a bare name is written only when exactly one entry carries it. That was checked rather than assumed: across all three fixtures, zero bare names refer to a duplicated package and zero dependency strings of any shape fail to resolve.
No source means somebody in this repo wrote it
source | what it is |
|---|---|
registry+https://… | crates.io, or another registry |
git+https://…#rev | a git dependency — not first-party |
| absent | a workspace member or a path = "…" dependency |
There is nowhere to fetch a path dependency from, so Cargo writes no source key
at all. That is the whole first-party test, and it is npm's link: true rule
wearing different clothes. It matters for the same reason: cargo-l is a 944-entry
workspace with 93 members, and if their edges counted as evidence, a hallucinated
crate added to any one of those 93 Cargo.toml files would arrive with in-degree 1
and never be looked at. Those edges become roots instead.
A git dependency is deliberately not first-party. Somebody outside this repo wrote
it, and bypassing crates.io is if anything more interesting — but it also means the
crates.io corpus was never asked about it, which is why
the name rules stay quiet on anything whose origin is not the
registry the corpus samples. That fix removed two of cargo-m's three original
findings.
What the format does not record
Build scripts. Cargo runs build.rs at compile time — the same
arbitrary-code-execution shape npm's hasInstallScript flags — and Cargo.lock
records nothing about it. install_script is false on every package, so
install scripts never fires on a Rust tree.
Inventing a proxy (the -sys suffix, say) would produce a confident wrong answer,
which is worse than a blank. A real answer needs the .crate archive or the
index's metadata, and both mean fetching.
dev-dependencies, and optional ones. Cargo.lock distinguishes neither. A
feature-gated dependency that was resolved is written exactly like any other, so
dev and optional are false on every package — a limitation, not a measurement.
Splitting them out needs Cargo.toml, the workspace's, and feature unification,
which is a resolver.
Checksums on v1 files. checksum on the package table is v2-and-later; v1 kept
them in a [metadata] table keyed "checksum bytes 1.0.0 (registry+…)". This
reader does not look there, so a v1 file reads as having no integrity anywhere.
There is no v1 file in the corpus and Cargo has rewritten them on every
cargo update since 2019.
has_integrity is otherwise exact. 93 of cargo-l's 944 entries have no checksum
and all 93 are the workspace members. cargo-m has 34 without one: 15 workspace
members and 19 git dependencies, which have a source and no checksum because a git
revision is its own integrity claim.
What it refuses
Two shapes, both of which mean the file is not the thing its name says it is.
No [[package]] array at all:
$ mkdir -p /tmp/refuse-cargo
$ printf 'x = 1\n' > /tmp/refuse-cargo/Cargo.lock
$ ./target/release/stranger scan /tmp/refuse-cargo/Cargo.lock
stranger: /tmp/refuse-cargo/Cargo.lock: no [[package]] entries; this does not look like a Cargo.lock
Refusing beats the alternative for the same reason it does in the
npm reader: a reader that looked for package, found nothing and
carried on would report a clean project with zero dependencies, and a clean
report on an unread file is the worst output an auditing tool can produce.
An entry with no name or no version:
$ printf '[[package]]\nversion = "1.0"\n' > /tmp/refuse-cargo/x.Cargo.lock
$ ./target/release/stranger scan /tmp/refuse-cargo/x.Cargo.lock
stranger: /tmp/refuse-cargo/x.Cargo.lock: [[package]] #1 has no `name`
The ordinal rather than a line number, and that is a real limit rather than an
oversight: positions die at the end of toml::parse, which hands back a value
tree with no spans in it. [[package]] #1 is still something you can count to.
Threading spans through the whole Value type to improve one message was not
worth what it would cost every other user of the parser.
Everything the TOML subset refuses is refused here too, and
decisions documents that subset. The important half is that it
refuses rather than guesses: Cargo.lock is written by one program, so anything
outside the subset means either a cargo change worth knowing about or a corrupt
file, and both deserve an error rather than a partial tree.
Which rules can fire
Only two. slopsquat on registry crates, and drift — every entry records an
exact version so pinning has nothing to say, install_script is never set, and
the trivial rule's name list is npm micro-packages.
$ ./target/release/stranger scan fixtures/cargo-l.Cargo.lock
poetry and uv
poetry.lock and uv.lock — the two Python lockfiles that record a graph. One
module reads both, because the piece worth writing exactly once is turning a
dependency's name into an entry, and that is where the whole file can go quietly
wrong.
$ ./target/release/stranger scan fixtures/poetry-m.poetry.lock
poetry-m.poetry.lock 233 packages (75 direct · 158 transitive)
no findings
· INSTALL SCRIPTS — no signal in this format
· UNPINNED — no signal in this format
risk 0/100 16ms third-party deps used to compute this: 0
$ ./target/release/stranger scan fixtures/uv-m.uv.lock
uv-m.uv.lock 249 packages (91 direct · 158 transitive · 1 workspace)
⚠ VERSION DRIFT 1 same package at 2+ versions in one tree
· INSTALL SCRIPTS — no signal in this format
· UNPINNED — no signal in this format
risk 27/100 20ms third-party deps used to compute this: 0
A clean report on a 233-package tree is a real result and is published as one.
Why these exist when pip.rs already reads Python
requirements.txt records no edges, so every package in it has in-degree
0 and the detection rule's third clause eliminates nothing.
That costs a real false positive on tensorflow-gpu.
Both formats here record the resolved graph, which is the entire reason to read
them. A reader that produced packages and no edges would leave the rule exactly as
weak as it already was — and it is measured rather than asserted: clause 3 removes
45.5% to 80.0% of candidates on poetry and uv, and structurally zero on
requirements.txt, at every corpus size.
The source for those numbers is tests/pypi.rs, which prints the whole table
under cargo test --test pypi -- --nocapture. It is a different measurement from
the ablation, which is npm-only — this page used to
cite the ablation for a PyPI number, which is the wrong table.
What each one records
| poetry.lock | uv.lock | |
|---|---|---|
| entry | [[package]] | [[package]] |
| edges | [package.dependencies], keys are names | dependencies = [ { name = "…" } ] |
| optional edges | [package.extras] — not read | [package.optional-dependencies] — read |
| hashes | files array of {file, hash} | sdist / wheels |
| dev split | yes, per package, in poetry 2.x's groups | no |
| the root project | absent | source = { editable = "." } |
poetry-m has 112 dependency sub-tables, poetry-s has 20. uv-m has 141 dependencies
arrays and 7 optional blocks, and no [[package.dependencies]] array-of-tables
anywhere — that shape does not occur.
Why uv's optional block is read and poetry's extras are not
This looks inconsistent and is not.
uv's optional block holds resolved references. davey is in uv-m because
somebody asked for discord-py[voice], so that is a real install edge.
poetry's extras block is the package's metadata copied verbatim whether anyone
asked or not — 1,049 PEP 508 strings in poetry-m, of which 754 name packages that
are not in the lock at all. Feeding the other 295 into edges would put
non-install edges in the graph, and a package mentioned by somebody's unrequested
docs extra would stop looking like a root when it is one.
That costs the detection rule 295 pieces of real "a maintainer has heard of this
name" evidence, and losing evidence makes the rule fire more, not less. The
upgrade is a separate mentioned set for clauses that want weaker evidence than an
install edge — not a wider definition of edges.
poetry does not record the root project
There is no [[package]] for the thing being locked, and its direct dependencies
live in pyproject.toml, which is not this file. uv does record it, and its
dependency list is the answer poetry makes you infer — which is why the uv header
prints 1 workspace and poetry's prints none.
What neither records
Install-time code. No field in either format says a package runs setup.py at
install time, so install_script is false on every entry and
install scripts never fires on a Python tree. There
is no honest proxy — an sdist with no wheel is suggestive and is not the same
claim — so nothing is invented. This is a real blind spot relative to npm.
A dev split, in uv's case. uv-m has no dev-dependencies at all, and group
membership in uv attaches to the edge rather than the package, so dev is false on
every uv entry.
What each one refuses
Both readers check a marker before they read anything, and the marker is the
point. Cargo.lock, poetry.lock and uv.lock are all TOML with a
[[package]] array, so the file name is the only thing that says which reader
should get it — and a renamed file would otherwise be read by the wrong one and
silently produce a tree with no edges in it, because cargo writes its
dependencies as strings and these two write inline tables.
So poetry requires metadata.lock-version and uv requires requires-python,
each of which appears in every file its own tool writes and in nothing else here:
$ mkdir -p /tmp/refuse-py
$ printf '[[package]]\nname = "a"\nversion = "1"\n' > /tmp/refuse-py/x.poetry.lock
$ ./target/release/stranger scan /tmp/refuse-py/x.poetry.lock
stranger: /tmp/refuse-py/x.poetry.lock: no `metadata.lock-version`; this is not the lockfile its name claims to be
An entry with no name is refused by ordinal, exactly as in
cargo and for the same reason — the value tree
carries no positions.
An absent package array is deliberately not an error. A project with no
dependencies gets a lockfile that is a header and nothing else, and refusing that
would be a false alarm about a file that is simply empty. The marker check above
is what makes that safe: the file has already proved it is a poetry or uv
lockfile before the empty array is accepted as meaning zero packages.
The TOML that makes this possible
Both files, plus Cargo.lock, go through one subset parser.
It has no date type, and the only reason that is sufficient is that uv.lock
stores timestamps as strings:
upload-time = "2026-03-26T01:21:00.379Z"
If uv wrote them as TOML datetimes the parser would refuse the file, loudly, with a
line and column. The only bare integers across all six fixtures are version and
revision.
poetry writes quoted keys containing dots — "jaraco.classes" = "*". That is one
key whose name contains a dot, not a dotted key; quoting decides, not the dot.
Conflating them silently invents a jaraco table.
$ ./target/release/stranger scan -v fixtures/uv-m.uv.lock
pip
requirements.txt. It is not a lockfile.
That is the first thing to say about it, because everything else follows. A
requirements.txt is the input a resolver takes, not the answer it gives. There
is no resolved version unless somebody typed ==, and there is no dependency
information at all. It is read here because people commit it and then treat it as
a lockfile.
$ ./target/release/stranger scan fixtures/poisoned.requirements.txt
poisoned.requirements.txt 6 packages (6 direct · 0 transitive)
⚠ HALLUCINATION RISK 2
python-dateutils@2.9.0 not in corpus · d=1 from "python-dateutil" · no dependency graph in this format
requests-http@1.0.2 not in corpus · d=2 from "requests-html" · no dependency graph in this format
⚠ UNPINNED 3 no exact version recorded
· INSTALL SCRIPTS — no signal in this format
risk 79/100 6ms third-party deps used to compute this: 0
The format is flat, and the detection rule pays for it
There are no transitive entries, no nesting, and nothing that says one line needs
another. So every package is a root, roots is 0..packages.len(), and edges
is left empty on purpose. That is not the reader giving up — the edges are not
in the file to read.
Which makes the detection rule's third clause — nothing real depends on this name — vacuous on this format. Every package trivially has in-degree 0, the clause eliminates nothing, and the rule degenerates to not-in-corpus AND near-a-real-name. Those two are exactly the half of the conjunction that the ablation table exists because nobody trusted on its own, so a pip scan is noisier than an npm scan by construction and no amount of parsing care changes it.
False positives has this costing a real one on a real fixture.
The upgrade is a different file, not a better reader, and both of those files
read today. poetry.lock and uv.lock record the resolved graph:
$ ./target/release/stranger scan fixtures/poetry-m.poetry.lock
poetry-m.poetry.lock 233 packages (75 direct · 158 transitive)
no findings
· INSTALL SCRIPTS — no signal in this format
· UNPINNED — no signal in this format
risk 0/100 10ms third-party deps used to compute this: 0
158 of those 233 are transitive, and they can only be called transitive because
the edges are in the file. That is the number requirements.txt cannot produce —
it reports every package as direct — and clause 3 is the rule that spends it.
Which rules can fire
| rule | on pip |
|---|---|
| slopsquat | yes, with clause 3 doing nothing |
| pinning | yes, and only here |
| install-script | no — the format records nothing equivalent |
| drift | technically, but a well-formed file cannot trigger it |
| trivial | technically, but the name list is npm micro-packages |
What it parses
PEP 508 requirements, which are more than a name and a version:
flask [async] >= 3.0 ; python_version < "3.10"
requests==2.31.0 \
--hash=sha256:aaa \
--hash=sha256:bbb
pkg @ https://host/pkg.whl
The order the pieces come off matters and is the reason the reader is longer than it looks.
Continuations join first, before anything else sees the text, which is also pip's order. The line number reported for a joined line is where the logical line started, so an editor gets sent to the right place.
Comments are (^|\s+)#.*$, not "cut at the first #". The difference is
load-bearing: pkg @ https://host/x.zip#sha256=… puts a # in a URL fragment,
and cutting at the first one truncates the URL into something that still parses.
Per-requirement options split by token, not by line, because continuations
have already been joined. That runs before the marker is cut off — pip puts the
options after the marker, and cutting at ; first would throw the hashes away.
The environment marker comes off before the version is read. A marker is
expression syntax carrying the same operators a specifier does — ; python_version < "3.10" has a < in it — so a classifier that ran first would read the marker as
a range and report a pinned requirement as unpinned. Order, not cleverness.
Extras come off after the name and before the specifier.
flask[async]>=3.0 puts the bracket group between the two. Which extras were
asked for is not recorded: an extra changes what gets installed alongside, never
which version of this name gets installed, and the version is what this reader is
for.
What it skips
-r base.txt and -c constraints.txt are not followed. An include means file
IO, relative-path resolution and cycle detection, and it quietly turns one file's
audit into a directory crawl. Point stranger at the other file.
-e editables are not packages.
--index-url and --extra-index-url are dropped, and that omission is worth
naming rather than hiding. An extra index is the dependency-confusion vector, and
a line adding one is more interesting than most of the packages under it. It is
dropped because there is nowhere honest to put it: Tree holds packages, and this
is a fact about the file. Minting a Package to carry it would put a lie in the
package count and a fake name in the report. The upgrade is a field on Tree and
a rule that reads it.
What it refuses
The line above is about what it skips — a line it can read and has nothing to
say about. This is the other half: a line it cannot read at all. A
requirements.txt is hand-edited, unlike every other format here, so the errors
carry a position and quote the fragment.
$ mkdir -p /tmp/refuse-pip
$ printf '@ohno>=1\n' > /tmp/refuse-pip/a.requirements.txt
$ ./target/release/stranger scan /tmp/refuse-pip/a.requirements.txt
stranger: /tmp/refuse-pip/a.requirements.txt: `@ohno>=1` does not begin with a package name at 1:1
$ printf 'flask =!= 3\n' > /tmp/refuse-pip/b.requirements.txt
$ ./target/release/stranger scan /tmp/refuse-pip/b.requirements.txt
stranger: /tmp/refuse-pip/b.requirements.txt: `flask=!=3` is not a version specifier stranger understands at 1:1
An unclosed extras bracket is the third, and it has its own block further down under syntax errors.
The split between skipping and refusing is a judgement, and it is the one worth arguing with. A URL or a VCS reference is skipped because there is no project name in it for the corpus to be asked about, so the rules have nothing to say either way — and refusing the file over one of those used to take every other requirement in it down as well. A line that is meant to be a name and a specifier and is neither is refused, because reading it as anything would mean guessing what the author meant.
What the format does not record
- A dependency graph. Covered above; it is the big one.
- Install-time code execution. A source distribution can run whatever
setup.pywants during installation, and this file does not say that it will. - A dev/test split. That lives in a second file whose name is not something to guess from.
- Optionality. An environment marker is a condition, not npm's
failure-is-tolerated
optional, sooptionalis always false. - Integrity, usually.
--hash=sha256:…is the only integrity this format has and it is opt-in. Presence is recorded; nothing verifies it.
Names are kept as written
Pillow, python-dateutil and python_dateutil are one project to PyPI under
PEP 503, and three different strings in a file. Folding them at read time would
have the report quote a name that is not in the file, so the reader keeps the
spelling and the corpus normalises at comparison time instead:
$ ./target/release/stranger scan --format json fixtures/poisoned.requirements.txt | jq -r '.findings[0].package'
python-dateutils
Syntax errors carry a position
$ mkdir -p /tmp/bad
$ printf 'flask[async>=3.0\n' > /tmp/bad/requirements.txt
$ ./target/release/stranger scan /tmp/bad/requirements.txt
stranger: /tmp/bad/requirements.txt: `flask[async>=3.0` has an unclosed `[` in its extras at 1:1
Line and column are 1-based, counted on the logical line. For a joined line the column points at the start of the first piece, and the message quotes the fragment so the rest is findable.
$ ./target/release/stranger scan -v fixtures/reqs-xs.requirements.txt
go
go.mod. Like requirements.txt it is a manifest rather than a resolver's
answer, and unlike requirements.txt that is almost fine.
$ ./target/release/stranger scan fixtures/gomod-m.go.mod
gomod-m.go.mod 174 packages (50 direct · 124 transitive)
no findings
· HALLUCINATION RISK — no signal in this format
· INSTALL SCRIPTS — no signal in this format
· UNPINNED — no signal in this format
risk 0/100 0ms third-party deps used to compute this: 0
No findings is the honest answer here rather than a clean bill of health, and the last section says why in a table.
The version on the line is a floor, and usually the ceiling too
Minimal version selection is what makes a manifest readable as a lockfile. Each
require names a minimum, and the build picks the largest minimum anybody in
the module graph named. Since Go 1.17 go mod tidy writes the whole build list
into the file — every indirect module, at the version that was selected — so the
file and the build agree unless somebody hand-edited one of them.
So every entry is Pin::Exact and unpinned requirements
has nothing to say. Reporting these as ranges would fire that rule on all 174
entries above and be wrong 174 times.
The caveat is the go line. On a go 1.16 module the same file lists direct
requirements only, and the rest of the tree lives in the module graph, which is
not in this file and not reachable without the network. Both fixtures here are
1.17 or later.
// indirect is the entire graph
There are no edges in this format. A go.mod records that a module is needed
transitively and never through what, so edges is empty, every package has
in-degree 0, and the detection rule's third clause is
vacuous here in exactly the way it is on a requirements.txt.
What the format does give up is the direct/transitive split, which is real and
which a requirements.txt cannot produce at all: 50 direct against 124
// indirect in the fixture above.
What each directive does here
| directive | what stranger does with it |
|---|---|
require | one Package, direct unless the line ends // indirect |
module | the module being audited — not a dependency, and its absence means this is not a go.mod |
replace | a target starting ./, ../ or / makes the module first-party; any other target takes its origin off the registry |
exclude | parsed, then dropped: it names a version that must not be selected, which is the opposite of a dependency |
retract, go, toolchain, godebug, tool, ignore | consumed, and nothing is read out of them |
| anything else | a syntax error with a line and column |
The difference between consumed and skipped is the whole reason that fifth
row exists. A retract block holds bare versions, and in the wild it holds
[v1.11.0, v1.11.2] ranges as well:
retract (
v1.4.1 // #218
v1.4.0 // #218 panic on saveSessionTicket
)
A reader that skipped forward to the next line it recognised would read those as module paths and invent two packages out of a block that names none.
Refusing an unknown directive rather than skipping it is the same call the
TOML subset makes. The go team keeps adding directives —
toolchain in 1.21, godebug in 1.23, tool in 1.24 — and a reader that
quietly ignores what it has not heard of will one day quietly ignore a require
spelled slightly wrong.
Module paths may be quoted
The go grammar reads a module path as a Go string literal when it is written as
one, in either spelling. This is not a corner of the format —
gopkg.in/yaml.v3, one of the most depended-on modules in the ecosystem, ships
a go.mod that quotes every path in it:
module "gopkg.in/yaml.v3"
require (
"gopkg.in/check.v1" v0.0.0-20161208181325-20d25e280405
)
The quotes come off the path rather than travelling with it. That part is not
cosmetic: a package named "gopkg.in/check.v1" — quotes included — is absent
from every corpus and scores as a stranger on the strength of its own
punctuation.
What stays refused is a path holding a Go string escape. The module proxy
URL-encodes the path it fetches, so a path that needs an escape resolves to
nothing, and reading \n as two characters would invent a package name no
registry holds. Refusing it is the same call as the unknown directive above.
go.sum is not read
It was the obvious next file and it earns nothing. Three reasons, in ascending order of how much they settle it:
- it holds a line for every module version in the graph, not the build list,
because
go mod tidykeeps hashes for versions that lost the selection — so counting packages out of it overstates the tree; go mod tidyalso guarantees a line for everything that is in the build, sohas_integritycomputed from it would be a constanttrue, and a constant is not a signal;- the field it would populate is presence and never correctness, because the
standard library has no SHA-256 to check an
h1:hash with. That is the same wall the npm reader hits onintegrity, written up in Limits.
A second file, opened by a reader that is handed one string, for a column of
true.
Which rules can fire
Effectively none, and the fixtures above report nothing at all.
| rule | on go.mod | why |
|---|---|---|
| slopsquat | no | there is no ranked list of module paths to be absent from |
| pinning | no | MVS makes every entry exact |
| install-script | no | the module system has no install-time hook to record |
| drift | no | a module path appears once; two major versions are two paths |
| trivial | effectively never | the list is npm micro-packages — but it matches the last path segment, so a module ending /is-foo would fire |
Three of those are the format recording nothing the rule reads, one is the
trivial list being about a different registry, and the first is a decision:
proxy.golang.org publishes no ranked list of modules and a module path is a
domain, so "not in the corpus" would mean "not in a list nobody publishes".
corpus::names returns an empty slice for Go and
slopsquat::scan stops on its first line, so the rule is off for this ecosystem
by decision rather than by the arithmetic of an empty list. tests/gomod.rs
hands it a corpus containing a one-edit neighbour of a real module in the tree
and it still says nothing.
What is left is the tree, the split, and the package list:
$ ./target/release/stranger scan --format json fixtures/gomod-xs.go.mod
{"source":"fixtures/gomod-xs.go.mod","ecosystem":"go","packages":6,"direct":5,"transitive":1,"workspace":0,"integrity":0,"risk":0,"findings":[],"not_applicable":["slopsquat","install-script","pinning"]}
Syntax errors carry a position
An unterminated block is reported where it opened, not at end of file, because the opening line is the one worth going to:
$ mkdir -p /tmp/bad
$ printf 'module example.com/m\n\nrequire (\n\tgithub.com/pkg/errors v0.9.1\n' > /tmp/bad/go.mod
$ ./target/release/stranger scan /tmp/bad/go.mod
stranger: /tmp/bad/go.mod: `require (` is never closed at 3:1
$ ./target/release/stranger scan -v fixtures/gomod-xs.go.mod
yarn
yarn.lock, the v1 format.
$ ./target/release/stranger scan fixtures/yarn-l.yarn.lock
yarn-l.yarn.lock 593 packages (26 direct · 567 transitive)
⚠ TRIVIAL 23 (3.9% of third-party)
⚠ VERSION DRIFT 82 same package at 2+ versions in one tree
· INSTALL SCRIPTS — no signal in this format
· UNPINNED — no signal in this format
risk 46/100 <ms> third-party deps used to compute this: 0
The fixtures are three real lockfiles: npm packages ship their own inside their
tarballs, so pdf-lib supplies a 4,408-line one and uri-js a 2,558-line one.
Neither was written for this reader.
Not YAML, despite reading like it
yarn v1 wrote its own format: entries at column 0, two-space fields under each,
and quoting that is optional except where a value would otherwise break the
line. yaml.rs is next door and deliberately unused — a YAML parser hands back
lodash@^4.17.20, lodash@~4.17.0 as one scalar key that then has to be taken
apart anyway.
The edges are specifiers, not versions
This is the one thing that makes yarn different from every other format here, and getting it wrong produces a tree with no edges rather than an error.
A dependencies line names a range:
"@babel/code-frame@^7.0.0":
version "7.0.0"
dependencies:
"@babel/highlight" "^7.0.0"
The entry "@babel/highlight" "^7.0.0" points at is the one whose key list
contains the literal specifier @babel/highlight@^7.0.0. Only the key list can
answer that: ^7.0.0 never appears in the target's version field, which says
7.0.0. A reader that matched on the resolved version would find nothing, parse
every entry successfully, and report a package count with an empty edge set —
which reads as "nothing depends on anything" and hands every package an
in-degree of zero, the exact shape the co-occurrence
rule fires on.
One entry answers to several keys:
"@babel/generator@^7.9.0", "@babel/generator@^7.9.5":
version "7.9.5"
75 of the 593 entries in yarn-l are written that way, and the file depends on
that one through both keys. A reader that took only the first would silently
lose every edge arriving through the second.
What it refuses
$ mkdir -p /tmp/berry
$ printf '__metadata:\n version: 8\n' > /tmp/berry/yarn.lock
$ ./target/release/stranger scan /tmp/berry/yarn.lock
stranger: /tmp/berry/yarn.lock: this is a Yarn Berry (v2+) lockfile; stranger reads the v1 format
Berry is a real YAML document keyed name@npm:range with its own __metadata
version counter. Same filename, different format.
An empty file is the other one, and it used to be the worst answer this tool could give:
$ mkdir -p /tmp/empty-yarn
$ printf '' > /tmp/empty-yarn/yarn.lock
$ ./target/release/stranger scan /tmp/empty-yarn/yarn.lock
stranger: /tmp/empty-yarn/yarn.lock: no entries and no `# yarn lockfile v1` header; this is not the lockfile its name claims to be
Every other reader here refuses a file that is not the thing its name claims —
Cargo.lock wants a [[package]], go.mod wants a module, poetry.lock
wants a metadata.lock-version. This one had no such check, because the v1
header is a comment and a hand-edited or concatenated lockfile can be missing it
while its entries are perfectly good. That reasoning holds for a file with
entries and quietly fails for a file without any: a truncated or zero-byte
yarn.lock read as a clean tree of nothing, risk 0/100, and a --fail-on
gate went green over a lockfile nobody had read.
So the header is required only when there is no other evidence. A project with no dependencies really does produce a header and nothing else, and that is still zero packages rather than an error.
Nested blocks
An entry's fields are key value pairs, except for the ones that are a bare
name: opening a block indented under it. Six of them turn up:
| block | read as |
|---|---|
dependencies, optionalDependencies, peerDependencies | graph edges |
engines, os, cpu, dependenciesMeta, peerDependenciesMeta | consumed and dropped |
peerDependencies is an edge for the same reason it is one in npm: a
peer dep is a real maintainer writing down a real name, which is the evidence
the detection rule wants, and an in-edge can only make
that rule quieter. Both readers produce Ecosystem::Npm and
stranger diff will put one against the other, so a name
with in-degree 1 under npm must not have in-degree 0 under yarn — otherwise
migrating a project between the two reports findings the migration did not
introduce.
engines is the counter-case and the reason the block matters rather than the
line: node ">=6" is the same two-token shape as a dependency line and names
no package at all.
The bare header itself is the trap. peerDependencies: has no value on its
line, so a field scanner that splits every line into key value has nothing to
split and reports a syntax error — against a file that has none, and against
most of the real yarn v1 lockfiles in existence, since peer dependencies are
ordinary.
What the format does not record
The root manifest. Direct dependencies live in package.json, which is not
this file, so roots is derived the way poetry's is: an entry
nothing else depends on can only have arrived through the root. The derivation
is wrong in one direction — a direct dependency something else also needs has an
in-edge and drops out of the count.
Install scripts, dev-ness, and workspace membership. None of the three is in a v1 lockfile, so install scripts reports no signal in this format rather than nothing found.
Integrity. Present on all 593 entries of yarn-l and on none of yarn-xs,
whose yarn predates the field. Reported as present or absent, never as checked —
std has no crypto.
Gate a pull request
The lockfile is the artefact worth gating. It is the file a model's suggestion ends up in, it is the file nobody reads in a diff of 4,000 lines, and it is the file that decides what actually gets installed.
$ ./target/release/stranger scan --fail-on high .
Exit 1 if anything at or above high is found, 0 otherwise, 2 if the run itself
was broken.
GitHub Actions
name: supply chain
on: [pull_request]
jobs:
stranger:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: keirsalterego/stranger
path: .stranger
- run: make -C .stranger
- run: ./.stranger/target/release/stranger scan --fail-on high .
No toolchain setup step, no cache step, no registry credentials. The build has nothing to fetch, so the job works on a runner with the network firewalled off.
Pick a threshold and mean it
The five rules occupy four severities, so the threshold decides what the gate is actually for.
| threshold | catches |
|---|---|
critical | hallucinated names only |
high | plus install scripts, plus unconstrained pip requirements |
medium | plus version drift, plus pip ranges |
low | plus trivial packages |
--fail-on high is the reasonable default: hallucinated names and code that runs
at install time, without failing a build over is-number.
npm-xl demonstrates the difference — it has install scripts and drift but no
hallucinated names:
$ ./target/release/stranger scan --fail-on critical fixtures/npm-xl.package-lock.json > /dev/null
$ echo $?
0
$ ./target/release/stranger scan --fail-on high fixtures/npm-xl.package-lock.json > /dev/null
$ echo $?
1
Note that --fail-on low will fail almost every real npm tree, because almost
every real npm tree contains a micro-package. That is a statement about npm, not a
bug, but it makes low unusable as a gate.
What the gate prints
$ ./target/release/stranger scan --fail-on high fixtures/poisoned.package-lock.json
poisoned.package-lock.json 757 packages (35 direct · 722 transitive)
⚠ HALLUCINATION RISK 3
chalck@5.3.0 not in corpus · d=1 from "chalk" · root-only, no parent
expres@4.18.2 not in corpus · d=1 from "express" · root-only, no parent
lodahs@4.17.21 not in corpus · d=1 from "lodash" · root-only, no parent
⚠ INSTALL SCRIPTS 3 arbitrary code at install time
⚠ TRIVIAL 35 (4.6% of third-party)
⚠ VERSION DRIFT 55 same package at 2+ versions in one tree
· UNPINNED — no signal in this format
risk 81/100 55ms third-party deps used to compute this: 0
$ echo $?
1
The critical block is listed in full and the rest collapses to counts, which is
the right shape for a CI log. Add -v if you want the whole thing in the job
output; add -q to drop the header and risk line.
Colour is off automatically, because a CI log is not a TTY. If your runner
renders ANSI and you want it, set CLICOLOR_FORCE=1.
Failing the build is not the only option
A finding is "no evidence this name is real", not proof of an attack. If your tree carries new packages often enough that a hard gate would cry wolf, post the report instead of blocking:
$ ./target/release/stranger scan --format json . > stranger.json
Exit code 0, machine-readable output, everything listed regardless of -v, and a
human decides. The JSON output page has the field list, and
False positives is worth reading before you
choose which mode you want.
Do not gate on the risk score
risk is a band for the worst severity plus a saturating term for volume, and it
is not calibrated against anything. Comparing today's score with yesterday's on the
same project is meaningful; comparing two projects is meaningful only at the band,
and a threshold on the number would be a number pretending to be a measurement.
--fail-on compares severities, which are at least defined, and it does not read
risk at all. Gating on the band would be gating on --fail-on the long way
round.
$ ./target/release/stranger scan --fail-on high fixtures/poisoned.package-lock.json; echo $?
Audit a lockfile you did not write
Somebody sends you a repository. A contractor's handover, a candidate's
take-home, a dependency you are about to vendor, an npm package you are
considering. You want to know what is in the tree before you type npm install,
because npm install is the part that runs other people's code.
$ ./target/release/stranger scan path/to/their/package-lock.json
Nothing is installed, nothing is resolved, no node_modules appears, and the
registry is never contacted. The lockfile is read as text.
Why the order matters
The usual way to inspect a dependency tree is to install it first and then ask the installed tree questions. That is backwards for an unknown repository: the install is the risky operation.
That risk is measurable from the file itself, which is the point of the install scripts rule:
$ ./target/release/stranger scan -v fixtures/npm-m.package-lock.json | head -8
npm-m.package-lock.json 576 packages (20 direct · 556 transitive · 6 workspace)
⚠ INSTALL SCRIPTS 4 arbitrary code at install time
esbuild@0.27.7 runs code at install time · lockfile records the flag, not the script
fsevents@2.3.3 runs code at install time · lockfile records the flag, not the script
sharp@0.34.5 runs code at install time · lockfile records the flag, not the script
unrs-resolver@1.12.2 runs code at install time · lockfile records the flag, not the script
Four packages whose code runs before yours does, named before you install any of them. Reading the lockfile inverts the order: the tool never executes anything from the tree, and it has nothing of its own to execute either — an empty dependency manifest means auditing a hostile lockfile cannot pull a hostile parser.
On a file, not a directory
Point at the whole repository and discovery does the work: it walks six levels
down, skipping node_modules and twelve other named directories, and picks up
any of the seven filenames in lock::KNOWN — package-lock.json,
pnpm-lock.yaml, Cargo.lock, requirements.txt, poetry.lock, uv.lock,
go.mod.
On a handover that is what you want, because you do not yet know which
ecosystems are in there.
Point at a single file and discovery is skipped entirely. The format is then chosen by the same suffix match, so an archived or renamed lockfile still reads:
$ ./target/release/stranger scan fixtures/poisoned.package-lock.json
poisoned.package-lock.json 757 packages (35 direct · 722 transitive)
⚠ HALLUCINATION RISK 3
chalck@5.3.0 not in corpus · d=1 from "chalk" · root-only, no parent
expres@4.18.2 not in corpus · d=1 from "express" · root-only, no parent
lodahs@4.17.21 not in corpus · d=1 from "lodash" · root-only, no parent
⚠ INSTALL SCRIPTS 3 arbitrary code at install time
⚠ TRIVIAL 35 (4.6% of third-party)
⚠ VERSION DRIFT 55 same package at 2+ versions in one tree
· UNPINNED — no signal in this format
risk 81/100 94ms third-party deps used to compute this: 0
One path per run. A second positional argument is a usage error rather than a silently ignored one:
$ ./target/release/stranger scan a/package-lock.json b/package-lock.json
stranger: scan takes one path; got a second, `b/package-lock.json`
Loop in the shell if you have several:
$ for f in */package-lock.json; do ./target/release/stranger scan "$f"; done
Reading the result
The header tells you the size of what you were about to install. 757 packages (35 direct · 722 transitive) means 35 names somebody chose and 722 that came along.
Read the critical block first — it is listed in full because it is the answer.
Then decide whether the counts underneath deserve a -v. On a tree you did not
write, INSTALL SCRIPTS usually does.
A finding tells you a name has no evidence behind it, or that a package runs code, or that something is installed twice. None of that says the package is malicious, and a clean scan does not say the tree is safe. Limits is the honest list of what is not checked, and integrity hashes are at the top of it.
What it will not tell you
Whether the sha512-… integrity fields are correct. Whether an install script is
benign. Whether a package that genuinely exists is nonetheless hostile. Those need
either cryptography or the network, and this binary has neither.
$ ./target/release/stranger scan -v fixtures/npm-xl.package-lock.json | head -14
Monorepos
A workspace member's package.json is a manifest, not evidence.
That sentence is the whole page, and it changes both numbers a monorepo scan produces.
The counting problem
npm records the whole workspace in one package-lock.json at the root. In a
workspace layout the root manifest usually declares almost nothing — the actual
dependencies live in apps/*/package.json and packages/*/package.json, and the
lockfile carries them as entries keyed by directory.
A reader that took direct dependencies from the root entry alone would report zero direct dependencies for a 576-package project. Check the root entry yourself and the gap is stark:
$ jq '.packages[""] | {deps: (.dependencies|length), workspaces}' fixtures/npm-m.package-lock.json
{
"deps": 0,
"workspaces": [
"apps/*",
"packages/*"
]
}
What the reader actually reports:
$ ./target/release/stranger scan fixtures/npm-m.package-lock.json
npm-m.package-lock.json 576 packages (20 direct · 556 transitive · 6 workspace)
⚠ INSTALL SCRIPTS 4 arbitrary code at install time
⚠ TRIVIAL 17 (3.0% of third-party)
⚠ VERSION DRIFT 20 same package at 2+ versions in one tree
· UNPINNED — no signal in this format
risk 58/100 12ms third-party deps used to compute this: 0
20 direct, because the dependencies a workspace member declares are dependencies
this repository chose. npm-xl is the same story at larger scale: its root entry
declares 2 dependencies across 5 workspace globs, and the reader reports 150
direct out of 1,376.
Three numbers, not two
576 packages (20 direct · 556 transitive · 6 workspace).
The lead count is third-party only. A workspace member is neither a direct dependency nor a transitive one — it is your own code — so it is excluded from both and reported separately. The file holds 582 entries; 6 of them are yours.
The workspace field only appears when there is one. npm-l has none and prints
two numbers.
The detection problem
The counting is cosmetic. The consequence for the rules is not.
The detection rule fires only when nothing depends on a name. If an edge out of
apps/desktop counted as a normal dependency edge, then a hallucinated name added
to apps/desktop/package.json would arrive with in-degree 1, clause 3 would
suppress it, and the tool would never look at it again. Exactly the scenario the
tool exists for, hidden by exactly the layout most large JavaScript projects use.
So edges out of a first-party entry go into roots, not edges. Same manifest,
same author, same absence of evidence as the root package.json.
$ mkdir -p /tmp/c && cat > /tmp/c/package-lock.json <<'EOF'
{
"name": "monorepo",
"lockfileVersion": 3,
"packages": {
"": { "workspaces": ["apps/*"] },
"apps/desktop": { "dependencies": { "expres": "^4.18.2" } },
"node_modules/desktop": { "resolved": "apps/desktop", "link": true },
"node_modules/expres": {
"version": "4.18.2",
"resolved": "https://registry.npmjs.org/expres/-/expres-4.18.2.tgz"
}
}
}
EOF
$ ./target/release/stranger scan /tmp/c
package-lock.json 1 packages (1 direct · 0 transitive · 2 workspace)
⚠ HALLUCINATION RISK 1
expres@4.18.2 not in corpus · d=1 from "express" · root-only, no parent
· UNPINNED — no signal in this format
risk 77/100 34ms third-party deps used to compute this: 0
The workspace member declared the name and it is still reported. Put the same name behind a third-party package and it is not — that pair is on The co-occurrence rule.
Workspace members are never findings
Two kinds of entry are first-party. A key with no node_modules/ in it is a
workspace directory. A "link": true entry is the symlink npm leaves in
node_modules pointing at one.
Both are skipped before any rule runs, not only the detection rule. Your own
build scripts are not INSTALL SCRIPTS findings, and your own package names are
not TRIVIAL ones. npm-xl has 14 first-party entries, 7 links among them, and
tests/rules.rs asserts none of them ever appears in a finding.
Discovery walks down, and the skip list is what makes that safe
stranger scan . at a workspace root does not read only that directory. It walks
down from there, six levels deep, sorting as it goes and never following a
symlink, and it refuses to enter node_modules, target, dist, vendor,
nine other names and every dot-directory.
On a plain npm workspace the walk still ends up with the one lockfile npm
actually wrote at the root, because the per-member package.json files are not
lockfiles and have no resolved versions in them. The recursion earns its keep on
the layouts where that is not true — a lockfile per app, or more than one
ecosystem:
$ rm -rf /tmp/mono
$ mkdir -p /tmp/mono/apps/web /tmp/mono/services/api /tmp/mono/node_modules/vendored
$ cp fixtures/npm-xs.package-lock.json /tmp/mono/apps/web/package-lock.json
$ cp fixtures/npm-xs.package-lock.json /tmp/mono/node_modules/vendored/package-lock.json
$ cp fixtures/reqs-xs.requirements.txt /tmp/mono/services/api/requirements.txt
$ find /tmp/mono -type f | sort
/tmp/mono/apps/web/package-lock.json
/tmp/mono/node_modules/vendored/package-lock.json
/tmp/mono/services/api/requirements.txt
$ ./target/release/stranger scan --format json /tmp/mono | jq -r .source
/tmp/mono/apps/web/package-lock.json
/tmp/mono/services/api/requirements.txt
Three lockfiles on disk, two audited. The one under node_modules is somebody
else's vendored copy, and a populated node_modules holds hundreds of them —
walking into it turns one scan into four hundred irrelevant ones.
tests/cli.rs::a_directory_scan_skips_vendored_lockfiles asserts the skip, and
a_directory_scan_is_deterministic asserts the walk finds Cargo.lock,
uv.lock and requirements.txt across the fixtures directory in sorted order.
Each file found produces its own report block:
$ rm -rf /tmp/mixed && mkdir -p /tmp/mixed
$ cp fixtures/poisoned.requirements.txt /tmp/mixed/requirements.txt
$ cat > /tmp/mixed/package-lock.json <<'EOF'
{
"name": "mixed",
"lockfileVersion": 3,
"packages": {
"": { "name": "mixed", "dependencies": { "expres": "4.18.2" } },
"node_modules/expres": {
"version": "4.18.2",
"resolved": "https://registry.npmjs.org/expres/-/expres-4.18.2.tgz",
"integrity": "sha512-AA"
}
}
}
EOF
$ ./target/release/stranger scan /tmp/mixed
package-lock.json 1 packages (1 direct · 0 transitive)
⚠ HALLUCINATION RISK 1
expres@4.18.2 not in corpus · d=1 from "express" · root-only, no parent
· UNPINNED — no signal in this format
risk 77/100 17ms third-party deps used to compute this: 0
requirements.txt 6 packages (6 direct · 0 transitive)
⚠ HALLUCINATION RISK 2
python-dateutils@2.9.0 not in corpus · d=1 from "python-dateutil" · no dependency graph in this format
requests-http@1.0.2 not in corpus · d=2 from "requests-html" · no dependency graph in this format
⚠ UNPINNED 3 no exact version recorded
· INSTALL SCRIPTS — no signal in this format
risk 79/100 6ms third-party deps used to compute this: 0
--fail-on compares against the worst severity across all of them. --format json emits one object per line rather than an array — see
JSON output.
$ ./target/release/stranger scan -v fixtures/npm-xl.package-lock.json | head -3
A project whose toolchain you do not have
You have a repository in a language you do not build. No node, no npm, no
cargo, no python. You still want to know what its dependency tree contains.
$ ./target/release/stranger scan .
The lockfile is already on disk. Somebody's toolchain wrote it, and it records resolved versions, integrity fields, install-script flags and the dependency edges between them. Reading it needs a JSON parser, not a package manager.
Why this is allowed
The hackathon rules forbid shelling out to installed tooling. Their FAQ rules this design in explicitly:
Parsing files those tools already produced is fine, because nothing third-party ends up in your artifact.
Two conditions attach and both are met.
Disclosed. The corpus of known-real names and the fixture lockfiles are
written up in
corpus/PROVENANCE.md,
fixtures/README.md
and STDLIB.md, with dates, sources and counts. Both are data. Nothing there is
compiled as source, nothing executes, and the corpus is embedded with
include_str! and read as text.
Degrades gracefully. No lockfile is not an error. The tool says what it looked for and exits 0:
$ rm -rf /tmp/empty && mkdir -p /tmp/empty
$ ./target/release/stranger scan /tmp/empty
no lockfile stranger reads in /tmp/empty
looked for: package-lock.json, pnpm-lock.yaml, Cargo.lock, requirements.txt, poetry.lock, uv.lock, go.mod, yarn.lock
$ echo $?
0
That message names the formats rather than saying "nothing found", so the answer to "why did it not scan my project" is in the output instead of in the source. The list comes from the same constant discovery uses, so it cannot drift out of date.
The corpus degrades honestly too
If the compiled-in name list were empty, every name would fail clause 1, the nearest-neighbour search would find nothing, and the rule would report nothing. An absent corpus makes the tool quiet, not hysterical.
That is not hypothetical. Ecosystem::Go runs in exactly that state on purpose:
go.mod reads, proxy.golang.org publishes no ranked list and module paths are
domains, so there is no Go corpus and the detection rule can never fire on one.
The rule does not even reach its first clause there — an empty corpus stops it —
so it is off by decision rather than by arithmetic. Named in
Limits and go rather than shipped as a
rule that silently does nothing.
A filename it does not recognise
$ 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
$ echo $?
2
The match is on the end of the filename and nothing reads the contents to
second-guess it, so a file renamed at the front still works and one renamed at the
back does not. It is a name it does not know rather than a format it cannot parse —
that file is a perfectly ordinary requirements.txt under another name.
Pointing at a specific file you believe is a lockfile and being wrong is a usage error — exit 2. Scanning a directory that happens to hold nothing readable is not — exit 0. The difference is whether you asserted something.
All eight formats read, which is the point of this page: Cargo.lock needs no
cargo, uv.lock needs no uv, pnpm-lock.yaml needs no pnpm or Node,
yarn.lock needs no yarn, and go.mod needs no go.
A semver comparator (src/semver.rs) exists and is tested and is not wired into
the binary, because no rule has yet asked an ordering question.
No network, checkable
$ cargo tree
stranger v0.1.0 (/home/keir/stranger)
$ grep -c '^\[\[package\]\]' Cargo.lock
1
One package. Rust's standard library has no TLS and no HTTP client, so a binary
with no dependencies cannot open a socket. CI enforces all three of those — the
one-line cargo tree, no unsafe in src/, and no Command::new anywhere — so
the claim fails the build rather than quietly rotting.
Run the scan with the cable out and it behaves identically. So does the build:
$ CARGO_NET_OFFLINE=true cargo build --release --locked --offline
Finished `release` profile [optimized] target(s) in 0.01s
$ ./target/release/stranger scan /tmp/empty; echo $?
stranger, and the site serving this page
Two scans that are worth running because of what they are, not what they find.
The empty manifest, checked by the tool that argues for it
stranger's whole claim is that it has no dependencies. It can make that claim
about itself, in its own output format, using the same reader it points at anybody
else's project:
$ ./target/release/stranger scan Cargo.lock
Cargo.lock 0 packages (0 direct · 0 transitive · 1 workspace)
no findings
· INSTALL SCRIPTS — no signal in this format
· UNPINNED — no signal in this format
risk 0/100 0ms third-party deps used to compute this: 0
0 packages. The one workspace entry is stranger itself — a [[package]]
with no source key, because there is nowhere to fetch it from, which is the same
test the cargo reader applies to any path dependency.
That last line of the footer is fixed text, not a computation. It says the same thing on every scan, and this is the one run where you can check it against the line above it.
The other two ways to ask are in
Reproducible builds and deps-proof.txt:
$ cargo tree | wc -l
1
$ grep -c '^\[\[package\]\]' Cargo.lock
1
Three different questions — the resolver's, the lockfile's, and the auditor's — and they agree.
The site that serves this book
This page is published at keir.is-a.dev/stranger/. The domain is served by an
Astro site in a separate repository, and that repository has a
pnpm-lock.yaml with 850 packages in it. Running stranger against the thing
hosting stranger's documentation is the closing check, and it is published
whatever it says:
$ ./target/release/stranger scan ~/keir.is-a.dev
pnpm-lock.yaml 850 packages (29 direct · 821 transitive)
⚠ TRIVIAL 23 (2.7% of third-party)
⚠ VERSION DRIFT 58 same package at 2+ versions in one tree
· INSTALL SCRIPTS — no signal in this format
· UNPINNED — no signal in this format
risk 46/100 146ms third-party deps used to compute this: 0
There used to be a hallucination finding here, and it was wrong
This section said, for most of the weekend, that the scan above reported one
HALLUCINATION RISK and that it was a false positive. It is gone from the block —
not tuned away, and the difference matters.
taze is a real, maintained npm package for
updating dependency ranges, it sits outside the most-downloaded 140,066 names, and
clause 3 had no chance to save it: nothing depends on taze, because a
devDependency of the root manifest genuinely has in-degree zero. The neighbour the
tool named, gaze, is one deletion away and real. The page called that the rule
working correctly on bad information, and left it in on the grounds that a better
screenshot would have made a worse tool.
That reading was too generous to the rule. taze is four characters long, and
a four-character name has a neighbour within two edits 100% of the time on npm —
so clause 2 was not weighing evidence about taze, it was passing everything. The
finding was not the corpus being incomplete. It was the threshold being a constant
where it should have been a function of length.
distance::CHARS_PER_EDIT is the fix and the
measurement behind it.
So what this page now shows is a scan with no hallucination finding on it, which
is a weaker screenshot and a truer one. The rule still gets tensorflow-gpu
wrong, in a requirements.txt fixture, for a reason no length policy can fix —
that one is fourteen characters, and at fourteen characters a near-miss really is
evidence. False positives keeps it.
The lockfile is already a fixture
fixtures/pnpm-l.pnpm-lock.yaml is that file, byte for byte:
$ sha256sum ~/keir.is-a.dev/pnpm-lock.yaml fixtures/pnpm-l.pnpm-lock.yaml
a04b16fb54b274f40d9fef0dbad27616c1e6755409383c5a07e106075c23981a …/keir.is-a.dev/pnpm-lock.yaml
a04b16fb54b274f40d9fef0dbad27616c1e6755409383c5a07e106075c23981a fixtures/pnpm-l.pnpm-lock.yaml
Which means the pnpm reader was developed against this exact file and taze was a
known false positive from before the reader shipped until the last day of the
window. It was left in rather than removed from the fixture, and that is the only
reason the length budget could be measured against it at all: a detector whose
test data has had its inconvenient case deleted has nothing left to measure a fix
with.
Try it on yours
$ ./target/release/stranger scan ~/some-project
No install, no resolve, no network, and no toolchain for the ecosystem you are auditing — see a project whose toolchain you do not have.
Decisions
Why the things are the way they are, written as they were decided rather than reconstructed afterwards.
The repository also carries DECISIONS.md, which is the submission's own copy and includes a written defence section. Where the two overlap, the root file is canonical — this page is the readable one, and the table at the bottom says which decisions live on other pages of this book instead.
One crate, not a workspace
stranger is twenty-five source files in one crate. It could be stranger-json,
stranger-toml, stranger-lock and so on, and that would look more serious.
A workspace here would be an abstraction with one consumer. None of these modules
are separately useful, none are separately versioned, and splitting them buys a
longer build and a Cargo.lock with more [[package]] blocks in it. For a
project whose central claim is that the file contains exactly one, that is an
actively bad trade.
Modules give the same separation. cargo build already tells me if json.rs
broke npm.rs.
Skipping the Single File bonus
There is a bonus for shipping as one file. Twenty-five files crushed into one
main.rs trades a 25% criterion for a 5% bonus, and the 25% one is code quality
judged by somebody who will not enjoy scrolling past a JSON parser to reach an
argument parser.
Declining it with a reason reads as judgement. Declining it silently reads as an oversight.
The corpus is data, and it is compiled in
160,066 package names ship inside the binary through include_str! — 2,960,053
bytes of text in a 4,064,792-byte release binary. Nearly three quarters of the
binary is corpus.
The alternative, fetching at runtime or reading a cache directory, would have made
the central claim false. stranger works on a plane. There is no corpus not
found failure mode, no first-run download, no stale-cache logic, and no code path
where a network timeout changes the answer. Three megabytes deletes an entire
category of failure.
Rust's standard library has no TLS, so a network request here is not merely against policy, it is unavailable. That is worth stating as a design property rather than apologising for as a limitation.
Why the in-degree clause exists
Edit distance is not a rule. http-proxy-agent is one edit from
https-proxy-agent; both are real, both are in the corpus, and both are depended
on by other packages in npm-xl. So are safe-buffer and safer-buffer, also one
edit apart. Take just the 1,077 distinct names the npm fixtures install: between
them they have 9,453 neighbours within distance 2 in the corpus, every one a
package that exists. A threshold loose enough to catch a typo catches legitimate
siblings, and precision collapses.
The observation that separates them is not about spelling:
A hallucinated package is a root dependency. Nothing depends on it, because nothing real has ever heard of it. A model put it in your manifest; no maintainer ever put it in theirs.
The only reference to lodahs — one transposition from lodash — anywhere in the
world is the manifest being audited. Real packages, including the boring near-miss
siblings, are depended upon by other packages.
So the rule is a conjunction: not in corpus, and within edit distance 2, and in-degree zero. The co-occurrence rule has the detail, including why the clauses are evaluated 1, 3, 2.
The refinement the fixtures forced
The first version counted every dependency edge as evidence. Both monorepo
fixtures then reported zero direct dependencies for projects of 582 and 1,390
lockfile entries, because both declare workspaces and keep almost nothing in the
root manifest.
Fixing that by "also read the workspace members" would have been wrong in an
interesting way. An edge out of a workspace member is the same manifest, by the
same author, as the root. If a model wrote apps/desktop/package.json, a
hallucinated name in it arrives with an in-edge and clause 3 never looks at it.
Workspace-member edges are recorded as roots, not as evidence. Same author, same absence of independent confirmation. Monorepos covers what that looks like in use.
Measuring the idea instead of asserting it
Against the full corpus the clause is worth exactly nothing: 1.000 precision and 1.000 recall with it and without it. That result is at the top of the ablation table, because a measurement that undercuts my own idea is the one most worth publishing.
It measures nothing because the corpus contains every package in every fixture, so clause 1 alone suffices and nothing else can show a difference. No real corpus has that property. So the experiment thins the corpus and re-measures: at 90% coverage, the clause takes false positives from 36 to 1 at no cost to recall.
Making the corpus a parameter of the rule rather than a global is what made that measurable. An assumption you cannot vary is one you cannot measure.
A corpus can only speak about one registry
The Cargo reader landed and immediately produced three findings on
cargo-m.Cargo.lock. Two were wrong in a way worth fixing rather than
documenting: slint and sg are real crates fetched straight from git. They
never went through crates.io, so a crates.io corpus cannot have heard of them, and
only workspace members reference them, so nothing depends on them either. All
three clauses fire on a package that is entirely legitimate.
The bug was treating "absent from a list" as evidence when the list was never asked. Packages now carry an origin, and the name rules stay quiet unless the lockfile says the package came from the registry the corpus samples.
What that does not fix is a real registry package outside the corpus. False positives has the three live ones.
The TOML subset
One parser reads Cargo.lock, poetry.lock and uv.lock — three formats for
the price of one, which bought more per line written than anything after JSON.
Accepted: key = value, [table] and [dotted.table] headers,
[[array.of.tables]], basic strings with the full escape set including \uXXXX
and \UXXXXXXXX, literal strings, multi-line strings with the line-ending
backslash fold, decimal integers with _ separators, booleans, arrays over as
many lines as they like with a trailing comma allowed, single-line inline tables,
and # comments.
Refused, each with a line and column: floats, dates, times and date-times as bare
values; hex, octal and binary integers; dotted keys outside a table header;
inline tables spread over several lines, which is TOML 1.1; duplicate keys; and a
[table] header that reopens a table already defined.
Refusing beats guessing. A parser that improvises at a construct it does not know produces a plausible wrong answer, and a plausible wrong answer in a security tool is worse than an error.
Three things the real files taught that guessing would have missed:
- The subset is only sufficient because
uv.lockstores timestamps as strings —upload-time = "2026-03-26T01:21:00.379Z". If it wrote them as TOML datetimes, this parser would refuse the file, loudly. The only bare integers in all six fixtures areversionandrevision: 1, 3 and 4. - poetry writes quoted keys containing dots:
"jaraco.classes" = "*". That is one key whose name contains a dot, not a dotted key. Quoting decides, not the dot — conflating them silently invents ajaracotable. - No triple-quoted string appears anywhere in the corpus. Not one, across six real lockfiles, contrary to what I assumed going in. They are implemented because a lockfile is allowed to contain one and mis-reading it would be worse than refusing it, but nothing here exercises them.
Two booleans, and everything else stays a string
YAML 1.1's implicit typing turns no, on, off and y into booleans. That is
the Norway problem, and in a supply-chain tool it is not a curiosity.
no, on, y and off are all registered npm package names. A reader that
turned the key no@1.0.0 into a boolean would drop a package out of an audit
without saying anything. no and on are in corpus/npm.txt; y and off are registered on npm and are not, because the corpus is the top 140,066 names by download count and not the whole registry. Which is the same distinction the tool makes about Origin::Elsewhere: absence from a popularity sample is not evidence a package does not exist. So yaml.rs types exactly two tokens — lowercase true and lowercase
false, which the fixture genuinely needs for hasBin and optional — and
leaves null, ~, Yes, 010 and 1e3 as strings.
What the xorshift is for
Rust's standard library has no random number generator, and two things need one: property tests over random short strings, and deterministic corpus thinning for the ablation.
Five lines of xorshift64*, seeded from SystemTime nanoseconds in the property
tests — and printed, so a failure replays — and from a fixed constant in the
ablation, so the published table reproduces.
It has a short period and fails statistical tests a real generator passes. That is
fine for four-letter strings out of a four-character alphabet. It would not be
fine for anything security-sensitive, and nothing here is: stranger computes no
hashes, verifies no signatures, holds no key material and makes no nonces. The
rules forbid rolling your own crypto, and this is not crypto.
Reading files that other tools produced
The hackathon rules forbid shelling out to an installed tool, and the FAQ rules this design in explicitly:
Parsing files those tools already produced is fine, because nothing third-party ends up in your artifact.
Two conditions attach and both are honoured. It is disclosed, in STDLIB.md and
corpus/PROVENANCE.md. And it degrades gracefully: a directory with no lockfile
prints what it looked for and exits 0.
stranger never executes another program. Not npm, not pip, not cargo, not
git. There is no std::process::Command anywhere in src/, and CI fails the
build if one appears. A project whose toolchain you do not
have demonstrates the point.
Decisions that live on other pages
Decisions about using the tool live where somebody meets them:
| decision | page |
|---|---|
| why exit 2 is separate from exit 1 | Exit codes |
| why non-critical rules collapse to a count | Your first scan |
| the colour precedence order | Your first scan |
| why clause order is 1, 3, 2 | The co-occurrence rule |
| why distance 2 and not 3 | The co-occurrence rule |
| why each rule has the severity it has | its own page under the other four rules |
| why versions are compared for equality only | Version drift |
| why the trivial list is hand-written | Trivial packages |
why --index-url is dropped | pip |
| the three reproducible-build settings | Reproducible builds |
What was traded away
The crate-by-crate accounting of what the standard library replaced —
serde_json, clap, toml, serde_yaml, semver, strsim, walkdir,
glob, owo-colors, comfy-table, is-terminal, rand, rayon,
crossbeam-channel, itoa, once_cell, anyhow and thiserror — is in
STDLIB.md, with
download counts and an honest note on what each substitution gave up.
It also carries the disclosure of data that is not code: the name corpus and the fixture lockfiles. That disclosure is one of the two conditions attached to the FAQ ruling above.
Check the ones that are checkable
Several decisions on this page are assertions a reader can test rather than take on trust. These are the commands, and they need no network:
$ grep -rn 'Command::new' src/ | wc -l
0
$ cargo tree | wc -l
1
$ grep -c '^\[\[package\]\]' Cargo.lock
1
$ make ablation
The last one re-derives the corpus-decay table, which takes about four seconds.
Limits
What this tool does not do. A hidden limitation reads as an oversight; a named one reads as judgement.
The four that matter most are argued in full in the LIMITS section of the README, which is the canonical home for them. Restated here in one line each so this page is not misleading by omission, and then the rest, which the README does not carry.
The headline four
Integrity hashes are never verified. Rust's standard library has no
cryptography of any kind, so the reader records whether an integrity field is
present and never whether it is correct.
hasInstallScript is a bare boolean. Code runs at install time; what that code
is lives in a tarball on the registry, and stranger does not fetch. See
Install scripts.
No Go corpus. go.mod reads — 174 requirements out of gomod-m, 50 direct
against 124 // indirect — and the name is the part that does not.
proxy.golang.org publishes no ranked list and module paths are domains, so the
corpus is empty on purpose and the detection rule can never fire on a Go module.
tests/corpus.rs asserts that emptiness so it stays intentional, and
tests/gomod.rs asserts the silence. See go.
Flat formats have no graph. requirements.txt and go.mod record no
dependency edges, so clause 3 is vacuous and the detection rule runs on two
clauses — on requirements.txt, at any rate, being the one of the two where it
runs at all. See pip and, for it costing a real false positive,
False positives. stranger tree says the file
has no graph rather than reporting the in-degree 0 it would find there.
Seven formats, five rules, and most pairs are not real
package-lock.json (lockfileVersion 2 and 3), pnpm-lock.yaml (v9 and v6),
Cargo.lock (v3 and v4), poetry.lock, uv.lock, requirements.txt,
go.mod. Four ecosystems, three shared parsers — JSON, YAML, TOML — with
requirements.txt and go.mod reading their own lines, and one graph model.
The grid below is the useful limit, because most of it is empty. A rule that cannot fire on your ecosystem is not protecting you from anything:
| rule | npm | pnpm | cargo | poetry / uv | requirements.txt | go.mod |
|---|---|---|---|---|---|---|
| slopsquat | yes | yes | registry crates only | yes | yes, weakened | never — no corpus |
| install-script | yes | never | never | never | never | never |
| trivial | yes | yes | effectively never | effectively never | effectively never | effectively never |
| drift | yes | yes | yes | yes | not on a well-formed file | never |
| pinning | never | never | never | never | yes | never |
Most of the install-script row is one fact repeated. install_script is a
field npm has and nobody else records — pnpm does not carry it, Cargo.lock says
nothing about build.rs, and neither poetry nor uv notes that a package runs
setup.py. The reader sets the flag to false and each one says so in its module
docs, so a quiet report on those four formats means not measured, never safe.
go.mod is the one cell in that row where false is a measurement: the module
system has no install-time hook to record. go mod download fetches and unpacks
a zip, and nothing in it runs until you build.
trivial is a hand-written list of npm micro-packages plus a predicate-shaped
name heuristic. Nothing stops it running elsewhere and nothing makes it useful
there; on pnpm-l it fires 23 times because pnpm packages are npm packages.
pinning is the mirror image: every other format on this list records an exact
version for every entry, so the rule has nothing to say and never says it.
slopsquat on cargo is narrowed on purpose. A crates.io corpus can only speak
about crates.io, so a package the lockfile marks as coming from git or a path
is skipped rather than reported —
why that matters. On go.mod it is not narrowed
but switched off, because there is no ranked list of module paths for a name to
be absent from and pretending otherwise would make every module a candidate.
A clean npm scan means: no name is absent-from-corpus-and-near-a-real-one-and- unvouched-for, nothing declares an install script, nothing appears at two versions, and nothing is a known micro-package. It is not an audit.
The corpus is a snapshot, and it lists existence, not safety
140,066 npm names, 15,000 PyPI, 5,000 crates.io, fetched on 2026-08-28 in one pass. A package published after that date is indistinguishable to clause 1 from a package that does not exist. The ablation table measures how fast that ages.
The corpus is also a list of names that exist, harvested from the registries. A typosquat that actually got registered is in the corpus, passes clause 1, and is never reported.
The trivial rule measures nothing
Named here because it is the loudest rule and the least trustworthy. It has no
access to file sizes, line counts or export lists — none of that is in a lockfile
— so it recognises names, using a hand-written list of two dozen and a shape
heuristic. is-callable and is-docker are both reported and neither is a
one-liner. Trivial packages has the argument.
Discovery matches names, not contents
stranger scan <dir> recurses, but it finds a lockfile by its filename. The
seven names in lock::KNOWN are the whole list:
$ rm -rf /tmp/nolock && mkdir -p /tmp/nolock
$ ./target/release/stranger scan /tmp/nolock
no lockfile stranger reads in /tmp/nolock
looked for: package-lock.json, pnpm-lock.yaml, Cargo.lock, requirements.txt, poetry.lock, uv.lock, go.mod, yarn.lock
The match is on the end of the filename, so a prefixed copy is still found —
poisoned.package-lock.json and npm-xl.package-lock.json both scan, which is
why the fixtures directory in this repository works. A file renamed at the other
end does not: requirements-dev.txt and deps.lock are invisible to a directory
scan, and nothing inspects contents to second-guess that. Point at such a file
directly and the reader is chosen by the same suffix rule, so it stays invisible
there too.
Two of those cuts are guesses rather than facts, and both are now printed under
-v as not descended into, with the reason beside each one — a policy that
hides a lockfile hides a lockfile, whether or not the policy was reasonable.
dist is the guess. Most dist/ directories are build output and auditing them
is noise; some are the directory a package is published from, and the skip list
cannot tell those apart. The hidden-directory rule is the bigger cut by far —
every name starting with . goes, not just the seven dotted names below — and
.ci and .github are both places people keep a real lockfile.
Fourteen lockfile names are recognised and have no reader: Gemfile.lock,
Pipfile.lock, Podfile.lock, bun.lock, bun.lockb, composer.lock,
conan.lock, go.sum, gradle.lockfile, mix.lock, packages.lock.json,
pdm.lock and pubspec.lock. Finding one prints found but not read: and exits 0. Naming a file it will not read is not the same as reading
it, and it is a great deal better than what it replaces — eight lockfiles in a
directory used to print "no lockfile", which tells somebody with a bun project
that their repository has no lockfile.
walk::SKIP names thirteen directories the walk will not enter — node_modules,
.git, target, vendor, .venv, venv, __pycache__, .tox,
.mypy_cache, .pytest_cache, dist, .next, .svelte-kit — and every other
dot-directory is skipped by a separate check, which is why the seven dotted names
on that list are belt and braces. The walk caps at MAX_DEPTH = 6, sorts for
determinism, and does not follow symlinks. Auditing four hundred vendored
lockfiles belonging to other people is worse than auditing none, and
tests/cli.rs::a_directory_scan_skips_vendored_lockfiles asserts a lockfile
inside node_modules is not picked up.
Depth 6 counts directories below the one you named: a lockfile six levels down is
found, and one seven levels down is not. A dist/ or .cache/ you actually
wanted audited has to be named as the scan path directly.
Numbers that do not quite line up
The human report prints risk N/100 and 100 is not a score this tool can
produce. The number is a band for the worst severity — critical is 75 — plus
24 * n / (n + 8) for the count at that severity, and that term is below 24 for
every n. So the real ceiling is 98, it takes 184 critical findings in one tree
to reach it, and the worst fixture in this repository sits at 81. The /100 is a
denominator readers expect rather than one the arithmetic produces;
JSON output documents the honest range, 0–98.
The workspace count used to be the entry here — the human report printed it and
the JSON object did not. It does now, as workspace, so a consumer no longer has
to parse the header line to tell a monorepo from a flat project of the same size.
The risk score is not a measurement
A band for the worst severity present — critical 75, high 50, medium 25, low 1 — plus a saturating term for how many findings share it. It is not calibrated against anything, and there is nothing honest to calibrate it against. Two projects are comparable at the band and not below it. Two scans of the same project are comparable outright.
Gate on --fail-on, which compares severities, and not on this number.
It used to be the weights summed and capped at 100, which saturated on any real
tree: nine of the sixteen fixtures here scored exactly 100, including both
poisoned.package-lock.json and the clean npm-l it was built from. That is
fixed, and the number is still a handle rather than a measurement.
Parser details worth knowing
JSON numbers are parsed as f64. RFC 8259 puts no limit on magnitude or precision
and f64 does; nothing in a lockfile is a number this tool does arithmetic on, so
the lossy case is unreachable in practice rather than handled.
Duplicate JSON object keys resolve last-one-wins, which RFC 8259 declines to specify.
Nesting deeper than 128 levels is an error rather than a stack overflow, and it carries a position:
stranger: nesting deeper than 128 at 1:129
The margin is wider than that number suggests. Every package-lock.json fixture
here nests to exactly 5 containers, npm-xl included — the root object, then
packages, then one entry, then peerDependenciesMeta, then one peer name. npm
writes a flat map keyed by install path rather than a tree, so the depth does not
grow with the size of the project; npm-xs at 37 packages and npm-xl at 1,376
nest identically.
The pip reader does not follow -r includes and drops --index-url lines, which
is the more interesting of the two omissions — an extra index is the
dependency-confusion vector. pip explains why there is nowhere
honest to put it yet.
Column widths assume ASCII names
Cell width is one column per Unicode scalar, which is wrong for East Asian wide
forms, combining marks and emoji ZWJ sequences. Registry names are ASCII in
practice — npm permits only URL-safe characters, PyPI normalises to [a-z0-9.-],
crates.io to [A-Za-z0-9_-] — so it is exact for every name a lockfile can hand
over, and a name with an accent in it still lines up. If a registry that permits
CJK identifiers turns up, this needs a generated width table.
$ ./target/release/stranger scan -v fixtures/npm-xl.package-lock.json
Building
$ make
cargo build --release --locked
Compiling stranger v0.1.0 (<your clone>)
Finished `release` profile [optimized] target(s) in 2.18s
Under three seconds cold, because there is nothing to compile except this crate.
Targets
| target | what it runs |
|---|---|
make | cargo build --release --locked |
make test | cargo test |
make lint | cargo clippy --all-targets -- -D warnings then cargo fmt --check |
make fmt | cargo fmt |
make ablation | the decay table, four seconds since the prefilter landed |
make bench | 50 timed runs on the largest fixture |
make proof | regenerate deps-proof.txt |
make repro | build twice, compare hashes |
make demo | the twelve beats of the submission video, run rather than typed |
make sweep | every lockfile on this machine, read twice and compared |
make clean | cargo clean |
Tests
$ make test
405 tests across 22 files, plus 15 unit tests inside src/. Five are
#[ignore]d because they are slow — the corpus-decay ablation, the
false-positive-by-length sweep, two deep fuzz campaigns and the JSON differential
run — and each has a make target or a script beside it.
The counts below are grep -c '#\[test\]' tests/*.rs, so they are re-derivable
rather than remembered. They went stale twice before that was written down.
| file | tests | what it covers |
|---|---|---|
tests/cli.rs | 47 | exit codes, -q, -v, blind spots, a flag borrowed from a sibling command, and no escapes down a pipe |
tests/yaml.rs | 36 | the subset, literal block scalars, the flow indicators that used to invent a key, and a linearity bound on flow collections |
tests/toml.rs | 34 | the accepted subset, every construct refused with a position, and the header depth that used to abort in Drop |
tests/json_conformance.rs | 30 | RFC 8259 clause by clause, each test citing its section |
tests/pip.rs | 29 | PEP 508 shapes, continuations, comments, markers, extras |
tests/pnpm.rs | 25 | the three sections, v6 alongside v9, and that two legal spellings of one lockfile agree |
tests/gomod.rs | 20 | require blocks, pseudo-versions, retract, replace, exclude, quoted paths |
tests/walk.rs | 18 | the skip list, depth cap, sorted order, symlinks, and what it could not open |
tests/term.rs | 18 | the four-input colour decision table, column widths, control-character replacement |
tests/tree.rs | 17 | in-degree, out-edges, depth, near names, and the flags a reader set |
tests/json.rs | 17 | malformed input, surrogate pairs, deep nesting, error positions |
tests/yarn.rs | 19 | specifier-keyed edges, the nested blocks an entry carries, Berry and the headerless empty file refused by name |
tests/pypi.rs | 16 | poetry and uv, and clause 3's share under corpus decay |
tests/cargo.rs | 14 | the three shapes of a dependency string, workspace members, git origins |
tests/rules.rs | 12 | all five rules against the fixtures |
tests/corpus.rs | 12 | sortedness, PEP 503 normalisation, length bucketing, the false-positive-by-length table |
tests/distance.rs | 11 | the OSA counterexample, Damerau against plain Levenshtein, three property tests |
tests/semver.rs | 10 | precedence, including prerelease ordering |
tests/npm.rs | 9 | fixture counts, nested entries, workspace members, refused versions |
tests/fuzz.rs | 5 | mutation campaigns over every parser and all eight readers |
tests/ablation.rs | 4 | the three tables |
tests/fixtures.rs | 2 | every npm fixture parses and its count is what it should be |
tests/cli.rs drives the built binary rather than the library, because exit codes
and stdout are the actual contract with a CI job and neither is visible from
inside main.
The distance property tests are seeded from the clock so repeated runs explore
different inputs, and the seed is printed on failure so a bad case can be
replayed. The corpus test asserting byte-order sortedness is not decoration:
binary_search on an unsorted slice returns a wrong answer quietly, and shell
sort is locale-dependent, so the files are generated with LC_ALL=C and checked
rather than trusted.
The sweep
$ make sweep
The 23 fixtures were chosen partly because they are interesting: a v6 pnpm
lockfile, a yarn entry answering to two specifiers, a retract block full of
bare versions. That biases them. They are good at the hard case and say nothing
about the ordinary one, because nobody picked an ordinary file to include.
A developer's disk is a few thousand lockfiles picked by nobody. make sweep
runs every one it can find through the reader for it and separates two
outcomes: a refusal — a lockfileVersion this tool does not read, a Berry file
wearing yarn's name — is an answer and counts as a pass. A file the reader
could not get through is a bug, and the script exits 1 on one.
On the machine this was written on, 1,484 lockfiles across all eight formats: 8 refused, 0 unread.
Getting there took four fixes, and this is the part worth stating plainly — every one of the four was in the ordinary case, and not one was reachable from the fixtures:
| what refused a valid file | |
|---|---|
| yarn | a bare peerDependencies: header |
| yaml | a deprecated: |- block scalar |
| go.mod | a quoted module path — gopkg.in/yaml.v3 ships one |
| diff | printed no change and exited 1 in the same breath |
Two of them had a comment beside the bug asserting the case did not arise. The go.mod one said nothing in the wild does this, and the counterexample was already on the disk.
The sweep looks for one failure and the yarn reader had
the opposite one, which no amount of sweeping would have found: an empty file
read as a clean tree of nothing rather than being refused. A pass that only asks
did the reader get through it scores that as success. It was caught by feeding
every reader a zero-byte file of its own name and noticing that seven said
this is not the lockfile its name claims and one said risk 0/100.
Getting through is not the same as being right
That first pass catches a refusal and misses the worse failure: a reader that
gets through a file and returns the wrong number. The fixtures cannot catch it
either, because the expected counts in tests/ were produced by this reader —
a test written that way pins the behaviour, it does not check it.
So the second half of make sweep counts the same files again in Python, from
each format's spec rather than from src/:
| counted independently as | |
|---|---|
Cargo.lock | [[package]] blocks; one with no source is a workspace member |
package-lock.json | entries under packages, minus the root, workspace directories and links |
yarn.lock | entry headers, the only lines at column 0 |
pnpm-lock.yaml | keys at one indent under packages:, counted by line rather than parsed |
go.mod | paths under require, in both spellings, and under no other directive |
poetry.lock | [[package]] blocks — poetry writes none for the root, so all of them |
uv.lock | [[package]] blocks, minus the one whose source is not a registry |
| the drift rule | names holding more than one version, recomputed from the raw file |
Python's own json module doing the npm parse is the point of that second row:
the hand-rolled src/json.rs and a mature implementation have to
agree on a hundred real files, or one of them is wrong. The three TOML rows are
the same argument aimed at src/toml.rs.
The pnpm row is line-based on purpose. src/yaml.rs is
the thing under test, and an oracle that parsed the document with the same shape
of parser could agree with it for the same reasons both were wrong. A pnpm
lockfile puts its package keys at exactly one indent under a top-level
packages: and nothing else there, which a line scan can see without a YAML
parser at all.
1,373 lockfiles crosschecked, 0 mismatches: 1,058 Cargo.lock, 146 go.mod,
107 package-lock.json, 41 yarn.lock, 16 pnpm-lock.yaml, and four each of
poetry.lock and uv.lock.
Seven of the eight readers have a second opinion. requirements.txt is the one
that does not, and deliberately: a line in it is a requirement, so any oracle
simple enough to be independent gets the answer wrong. A naive one counts 361
requirement lines across the 35 files on this machine where the reader counts
197, and the reader is right — 162 of the difference are --hash continuation
lines, which belong to the requirement above them. An oracle that knew that
would be a second copy of the reader, and two copies of one idea agree with each
other for free.
Writing the oracles found a bug in an oracle rather than in a reader, which is
the outcome that makes the exercise worth doing at all. Poetry puts
source = ["Cython (>=3.0.11,<3.1.0)"] inside one lxml block's
[package.extras], and the first draft — a regex over the whole block, the same
shape the Cargo.lock row uses — read that as the package's own source and
called lxml a local project. The reader was right and the second opinion was
wrong. Blocks are cut at their first nested table header now.
The sweep is not in cargo test and cannot be: its corpus is whatever happens
to be on the machine, so it is neither fixed nor portable, and a test that
passes because you have no Go modules installed is not a test. It is a separate
target for the same reason make bench is.
The ablation
$ make ablation
cargo test --release --test ablation -- --nocapture --include-ignored
...
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.03s
Prints both tables from The ablation table. Release mode is not optional — the decay run scans the fixtures ten times against 140,066 names, and a debug build makes it unpleasant.
Benchmarks
make bench writes bench.md. It reports p50 and p99, not a mean and a
standard deviation: a scan is not normally distributed, the tail is where a CI job
notices, and a mean with an 82 ms sigma — which is what this section used to
quote — says less than either percentile does.
| target | runs | p50 ms | p99 ms |
|---|---|---|---|
stranger scan fixtures/npm-xl.package-lock.json (1,376 third-party packages) | 100 | 37.2 | 39.4 |
| 500 names, all in the corpus | 100 | 9.5 | 10.1 |
| 500 names, none in the corpus | 5 | 62.8 | 63.8 |
Measured on an Intel Core i5-10200H at 2.40 GHz, 8 cores, governor
performance — the governor is in the file because a powersave reading is a
measurement of the governor and not of the tool. One fresh process per sample,
five warmup runs first, page cache warm, nearest-rank percentiles with no
interpolation.
The third row is the point of the file. A name in the corpus is answered by a binary search; a name that is not costs a sweep, and the two 500-name rows are the same file shape at the same size differing only in whether the names hit.
That cliff used to be the headline here, and it was enormous: this table read
10,102 ms on the miss row against 9.5 on the hit row, a factor of a thousand,
and the paragraph said so. corpus::ByLength had halved it and the comment on
that type named the remaining ceiling — the widest length band — and deferred the
fix to a deletion-neighbourhood index it could not afford the memory for.
There was a cheaper half it had not taken. Every candidate now carries a 64-bit
map of which characters it contains, and a candidate whose map differs from the
query's in more than 2k bits cannot be within k edits, because no single edit
moves more than two characters in or out of the set. That is a bound rather than
a heuristic, one XOR and a popcount rejects most of the band before the
edit-distance table is allocated, and tests/corpus.rs holds the whole search to
an exhaustive sweep so a bound off by one would fail rather than quietly stop
finding slopsquats.
The cliff is 6.6× now instead of 1,080×, and the scan a judge actually runs got six times faster as a side effect. It is still a cliff, the widest band is still the ceiling, and the index is still the thing that would remove it.
bench.md is gitignored on purpose — it is a timing on one machine, not a claim
— so make bench gives you your own. It uses hyperfine when it is installed and
falls back to a plain timing loop when it is not, which also publishes its own
floor, so make bench never answers with command not found.
Lint
$ make lint
cargo clippy --all-targets -- -D warnings
Checking stranger v0.1.0 (...)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
cargo fmt --check
Clippy with -D warnings across all targets, then a formatting check. Both must
be clean.
What CI enforces
.github/workflows/ci.yml runs the above plus three checks that guard the entry's
central claim, and it runs them before anything else:
- name: The manifest is empty
run: |
test "$(cargo tree | wc -l)" -eq 1
test "$(grep -c '^\[\[package\]\]' Cargo.lock)" -eq 1
Then no unsafe anywhere in src/ (excluding the forbid(unsafe_code) attribute
itself, which the naive grep would otherwise match), and no Command::new
anywhere — stranger reads files, it does not run programs. After that: format,
clippy, tests, an offline build, the full ablation, and the reproducible-build
check.
Reproducibility
rust-toolchain.toml pins the compiler to 1.98.0 rather than stable. Two
reasons: str::substr_range and NumBuffer::format_into both landed in 1.98 and
both are used, and a floating channel makes the binary non-reproducible for no
benefit. Reproducible builds has the rest.
--locked makes Cargo refuse to write Cargo.lock. With three empty dependency
tables there is nothing it could write, which is the point — a build that somehow
acquired a dependency fails instead of quietly succeeding.
The book
$ make docs
checked 37 pages, no broken relative links
checked 37 pages: 127 commands reproduce, 48 not runnable here (`-v` lists them)
Two checks, both standard-library-only Python, neither of which ever enters
Cargo.toml.
check-links.py resolves every relative markdown link against the file it
appears in and exits non-zero on the first target that does not exist. mdBook turns
a broken link into a 404 without complaining, so a rotted page looks exactly like a
fine one until somebody clicks it. External URLs are skipped, because checking
those needs the network and this is a repository whose whole argument is that it
does not need the network.
check-output.py runs every $ stranger scan fixtures/... in the book and the
README and compares what the tool actually prints against what the page says it
prints. Elapsed milliseconds are the one thing allowed to differ, because they are
a measurement rather than a claim.
The ones it reports as not runnable are the make, cargo, strings, jq and
grep lines in the same blocks — a make ablation inside a check that runs on
every push would repeat what make ablation already tells
you. Every stranger invocation against a fixture is checked, and -v prints the
rest by name so the gap is a list rather than a number.
It exists because two separate rots got through in one afternoon. The
risk score changed from a capped sum to a band, which
renumbered every published figure — on a branch that did not yet contain the four
new format pages being written against the old score on
another branch. Both merged green and three pages went out quoting a number the
tool would not print. Separately, the co-occurrence rule's
own three worked examples had stopped firing entirely when packages gained an
origin, because their hand-written lockfiles carry no resolved field.
Neither was catchable by review. Both are caught now, in ci rather than in the
docs workflow, because the check needs the release binary.
$ make test && make lint
Reproducible builds
Same commit, same toolchain, two different directories, one binary.
$ make repro
commit: 9d2e472a46bbac0c6b92b217539c03a84c56379f
rustc: rustc 1.98.0 (88d9e12ae 2026-08-18)
epoch: 1787940000
build A /tmp/stranger-repro.311450/a
4400252c85f11eef5202d940140d9ef493e51d94fc97428fada7cad03c8c0d08
build B /tmp/stranger-repro.311450/b-with-a-deliberately-longer-name
4400252c85f11eef5202d940140d9ef493e51d94fc97428fada7cad03c8c0d08
MATCH — byte-identical across two directories
The hash is for whatever you have checked out, and it moves whenever src/ or
Cargo.lock moves. The line carrying the claim is MATCH, not the hash.
Which is not a hypothetical caveat: this page and the README quoted two different
runs, at two different commits, with two different hashes, for the same claim —
which reads like one of them was invented. They are one run now, and re-synced
from a single make repro at the freeze. CI runs scripts/repro.sh on every pull
request and every push to main, so a commit that stops reproducing fails there
rather than being discovered by a judge.
Two directories, not one
The hackathon's FAQ sets the bar at "same machine, same toolchain, build twice", which same-directory-twice already meets. This does more, because the absolute build path is the single thing most likely to leak into a binary — it ends up in panic messages.
So the second directory is deliberately longer than the first. If the path were leaking, the two binaries would differ in length before they differed in content, and the check would fail loudly instead of passing by luck.
The three settings
SOURCE_DATE_EPOCH=1787940000 CARGO_INCREMENTAL=0 \
RUSTFLAGS="--remap-path-prefix=$PWD=/build -C debuginfo=0" \
cargo build --release --locked --offline
SOURCE_DATE_EPOCH pins anything that would otherwise embed a build time. The
value is the hackathon kickoff, 2026-08-28 18:00 UTC.
CARGO_INCREMENTAL=0 because incremental artifacts are not deterministic.
--remap-path-prefix rewrites the build directory out of anything that carries
it, and -C debuginfo=0 drops the rest of the path leakage that lives in debug
info.
Neither turns out to be load-bearing, and the honest thing is to say so rather
than let the flag take credit. Two plain cargo build --release --locked runs in
two directories of different lengths, with none of the three settings, produce the
same binary and the same hash as make repro does. The reason is that Cargo
compiles the local crate through a relative path, so panic! locations come out
as src/main.rs and there is no absolute path for the remap to rewrite:
$ strings target/release/stranger | grep -E '^src/[a-z]+\.rs$' | head -3
src/tree.rs
src/main.rs
src/toml.rs
The absolute paths that are in the binary belong to the standard library, and they point into the rustup toolchain rather than into this repository:
$ strings target/release/stranger | grep -c "$HOME/.rustup"
20
Those are identical for every build on one machine and different on another, which
is exactly the boundary the FAQ draws. The settings stay because they are what
stops this from silently ceasing to be true — a include_str!(concat!(env!("...")))
or a build script would put the build directory in the binary tomorrow, and the
flag is already there when it does.
How the check works
scripts/repro.sh does not copy the working tree. It runs git archive HEAD into
each directory, so both builds see exactly the committed state and an untracked
file cannot make the check pass or fail for the wrong reason.
Then it builds each with --locked --offline, hashes
target/release/stranger with sha256sum, and compares.
When it fails
The script says where to start:
DIFFER
Bisect order: path leakage in panic messages, then build-id, then
incremental artifacts. Compare with:
cmp -l A/target/release/stranger B/target/release/stranger | head
Path leakage first because it is the most common and the reason the two directories have different lengths.
Why an empty manifest makes this easy
Most of what breaks reproducible builds in Rust is dependencies: a build script
that reads the clock or the hostname, a crate that embeds its own path, a
proc-macro whose output depends on iteration order. There are none here. There
is also no build.rs in this crate and no code generation, so the only inputs are
the source, the corpus text files, and the compiler.
CI runs ./scripts/repro.sh on every push, so a change that breaks determinism
fails the build rather than being discovered later.
$ make repro
JSON conformance
stranger nominates serde_json for the
Package Killer bonus — 1,227,048,507 all-time downloads, 288,758,389 in the last
ninety days, measured 2026-08-28. Almost every Rust program that reads a
package-lock.json reaches for it.
The case against reaching for it here is src/json.rs, and "I wrote a JSON
parser" is not that case. Anyone can write one that reads the happy path. Two
things are on this page instead, and both are commands you can run.
Clause by clause
$ cargo test --test json_conformance
29 tests, each citing the RFC 8259 section it comes from. The grammar has few enough productions to walk exhaustively, so it is walked:
| §2 | the six structural characters, whitespace being exactly four characters |
| §3 | the literal names, lowercase and whole — True and nul are errors |
| §4 | objects, member names, trailing commas, and that a duplicate name takes the last |
| §5 | arrays, trailing and missing commas |
| §6 | leading zeros, leading +, a decimal point with digits on both sides, the exponent forms, and the numbers Rust's own float parser would have taken but JSON does not — inf, NaN, 1., .5 |
| §7 | all eight two-character escapes, \uXXXX, surrogate pairs, unescaped control characters, unterminated strings |
| §8 | a leading byte order mark, and lone or mispaired surrogates |
| §9 | one value and no more |
Eight escapes, not six: " \ / b f n r t. This repository said six
in two places until somebody counted.
Against a reference implementation
$ ./scripts/json-differential.sh
Two million generated and mutated inputs, fed to this parser and to CPython's
json, comparing both the accept/reject decision and the parsed value.
The oracle is configured with parse_constant so it rejects NaN, Infinity
and -Infinity. Python accepts all three by default and RFC 8259 has none of
them, so leaving that alone would have been comparing against Python's dialect
rather than against the grammar.
1,997,016 agreed. 2,984 disagreed, in four classes. None of the disagreements was about a value: every time both accepted, both built the same thing, down to the IEEE-754 bits.
| n | class | who is right |
|---|---|---|
| 1,093 | a leading BOM — we skip it, CPython raises | neither. §8.1 says a parser MAY ignore one |
| 898 | lone high surrogate, "\ud83e" | neither. §8.2 does not forbid unpaired surrogates |
| 825 | lone low surrogate, "\udd80" | same |
| 168 | high surrogate then a non-surrogate escape | same |
Every class is a place the RFC permits both answers, which is the honest reading and is less satisfying than "we were right four times". Both choices here are still choices, and both were made for a reason:
The BOM is skipped because a package-lock.json saved by a Windows editor
starts with one, and the useful behaviour is to audit that file rather than to
refuse it.
Unpaired surrogates are rejected because a Rust String cannot hold one.
Accepting means either WTF-8 or substituting U+FFFD — and silently rewriting a
character of a package name is exactly the bug class this tool exists to find. A
corrupt name should stop the scan, not quietly become a different name. That is
the same reasoning as term::sanitize, pointed the other way.
What the campaign did not find
No defect in src/json.rs. That is a result reported rather than a claim made,
and it was checked past the edges the campaign reaches: a 100,000-digit integer,
a 100,000-digit exponent, a \u escape truncated at every offset, one whose
fourth byte lands mid-codepoint, the depth cap at 127, 128 and 129, and error
columns counted in characters behind multi-byte text. All correct.
tests/fuzz.rs adds 1,366,400 mutants and 282,563 truncation prefixes across
every parser and all eight readers, with no panic anywhere. It said seven
until 2026-08-30, and it was right: READERS had seven entries and the tool had
eight. yarn is the one it was missing, and it is the reader that then needed two
fixes in a day. The array length is spelled out now, so a ninth reader will not
compile until it is in the campaign too.
Python is dev-time tooling
python3 runs the oracle and nothing else. It never enters Cargo.toml, it is
not linked, it does not ship, and the binary a judge builds has no idea it
exists. The 29 clause tests run on a machine that does not have it; the
differential run says so in a sentence and exits clean.
That is the same standing as the curl that fetched the name corpus, and it is
disclosed the same way — in STDLIB.md, in corpus/PROVENANCE.md, and here.
The STDLIB log
Eighteen crates, and what the standard library did instead.
The canonical file is STDLIB.md, and it carries the part that matters: an honest what I gave up paragraph on every entry. This page is the index — what was replaced, by what, and where the code is. It deliberately does not repeat the rationale, because two copies of an argument drift and one of them starts lying.
Download counts are what the crates.io API returned on 2026-08-28, quoted in the two forms it actually reports. It has no weekly figure, so none is invented.
Package Killer nomination
serde_json — 1,227,048,507 all-time · 288,758,389 in 90 days
rand is bigger — 1,605,926,795 — and it is deliberately not the nomination.
The bonus asks for one crate nailed, and five lines of xorshift is not a case
for anything. The nomination goes to the substitution with the most work behind
it, not to the largest number in the table.
src/json.rs is
a complete RFC 8259 reader: escapes, \uXXXX with surrogate-pair recombination,
the number grammar checked by hand before f64::from_str sees the slice, a nesting
limit, and line/column on every error derived from a byte offset.
The position tracking is the part worth defending. The parser keeps the original
input alongside the unconsumed remainder and asks the standard library where one
sits inside the other — str::substr_range, stable in 1.98. No cursor struct
threaded through thirty functions, no line counter to keep in sync, because the
cursor is the remainder.
The other seventeen
| crate | all-time | replaced by | what did the work |
|---|---|---|---|
rand | 1,605,926,795 | src/distance.rs, tests/ | five lines of xorshift64*, seeded from SystemTime |
thiserror | 1,377,720,340 | src/error.rs | one enum, hand-written Display and Error::source |
itoa | 1,265,455,201 | src/report.rs | core::fmt::NumBuffer + format_into (1.98) |
once_cell | 1,187,857,958 | src/corpus.rs | std::sync::LazyLock (1.80) |
clap | 1,083,204,108 | src/cli.rs | a match over std::env::args |
strsim | 1,024,185,642 | src/distance.rs | unrestricted Damerau-Levenshtein, written out |
semver | 945,451,453 | src/semver.rs | the precedence rules from the spec's section 11 |
anyhow | 909,556,524 | src/error.rs | the same enum, with ? |
toml | 855,052,855 | src/toml.rs | a documented subset that refuses what it cannot read |
walkdir | 588,096,443 | src/walk.rs | std::fs::read_dir and an explicit stack |
glob | 575,867,559 | src/walk.rs | str::ends_with against seven known names |
crossbeam-channel | 565,386,665 | src/main.rs | std::sync::mpsc |
rayon | 516,056,344 | src/main.rs | std::thread::scope |
serde_yaml | 383,697,832 | src/yaml.rs | an indentation-driven subset, two booleans only |
is-terminal | 324,499,410 | src/term.rs | std::io::IsTerminal (1.70) |
owo-colors | 156,700,441 | src/term.rs | sixteen-colour SGR, written out |
comfy-table | 94,630,283 | src/term.rs | column widths measured from content |
13,459,345,853 all-time downloads across those seventeen, and 14,686,394,360 with the nomination. The bonus asks for ten entries.
Three of these were free, and saying so is the point
itoa → NumBuffer::format_into and once_cell → LazyLock are substitutions
the toolchain made for me. LazyLock landed in 1.80 and format_into in 1.98,
eight days before the window opened. Neither cost an hour, and claiming them as
craft would be claiming a stable release as personal work.
is-terminal is the third and it is the interesting one, because avoiding it
avoided unsafe. The usual replacement is an FFI call to libc::isatty, which
needs an unsafe block and would have broken #![forbid(unsafe_code)] at both
crate roots. std::io::IsTerminal has done it safely since 1.70.
What is not claimed
indicatif. Nothing here draws a progress bar: the largest fixture scans in under
half a second, and a bar that finishes before it renders is a dependency bought for
one frame. Writing a spinner so that a spinner crate could be crossed off is
padding rather than substitution.
The rule the log enforces on itself is that an unwritten module gets no entry. That
is why src/semver.rs is in the table. It has ten tests, and for one day it was
also the one module nothing called — which the log said out loud rather than
implying it was load-bearing. Version drift sorts with it now.
Data that is not code, disclosed anyway
corpus/ holds 160,066 package names across three registries, fetched once with
curl at development time on 2026-08-28. fixtures/ holds fourteen real lockfiles
from public projects plus two poisoned by hand.
Neither is code — the corpora are embedded with include_str! and read as text,
and the fixtures are test input. The hackathon's rule is that anything not written
during the window is disclosed in STDLIB.md or it scores against you, and a name
list is exactly the sort of thing it would be convenient to forget. Full provenance
is in corpus/PROVENANCE.md and fixtures/README.md.
$ wc -l corpus/*.txt
5000 corpus/crates-io.txt
140066 corpus/npm.txt
15000 corpus/pypi.txt
160066 total
Troubleshooting
"no lockfile in ." on a project that has one
Discovery recurses from the directory you named and matches filenames ending in one of seven known names. A file renamed at the front still reads; one renamed at the back does not.
$ rm -rf /tmp/project && mkdir -p /tmp/project
$ ./target/release/stranger scan /tmp/project
no lockfile stranger reads in /tmp/project
looked for: package-lock.json, pnpm-lock.yaml, Cargo.lock, requirements.txt, poetry.lock, uv.lock, go.mod, yarn.lock
The usual cause is a name like requirements-dev.txt, which ends in .txt and
not in requirements.txt. Nothing reads file contents to second-guess the name,
so pointing straight at it does not help either — that is exit 2, "not a lockfile
stranger knows". Copy or symlink it to a name in the list.
The other cause is a lockfile inside node_modules, target, .venv or one of
the ten other skipped directories, or deeper than six levels down.
Point at a file to skip the walk:
$ ./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 92ms third-party deps used to compute this: 0
Matching on a file path is by suffix, so npm-s.package-lock.json and
old.package-lock.json both read.
Exit code here is 0, not an error. A repository of mixed languages should not turn red on the directories the tool has nothing to say about.
"lockfileVersion 1 is not supported"
$ 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.
Do what it says. npm 7 and later write version 2 or 3, which have the packages
map this reader needs. Version 1 kept the tree in a nested dependencies object
and cannot be read the same way.
"not a lockfile stranger 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
Seven formats in this build, and the error lists them from the same constant discovery uses, so the message cannot drift out of date the way this page did.
You asserted the file was a lockfile and it does not match any name, so this is
exit 2 rather than the exit 0 a directory scan gives. Renaming it to
requirements.txt reads it — the name is the whole test, and nothing looks
inside to confirm.
"no packages map"
The file parsed as JSON and had a lockfileVersion of 2 or more, but no
packages object. Either it is truncated or it is not a package-lock.
A syntax error with a line and column
$ mkdir -p /tmp/bad
$ echo '{"lockfileVersion":3, "packages" 1}' > /tmp/bad/package-lock.json
$ ./target/release/stranger scan /tmp/bad/package-lock.json
stranger: /tmp/bad/package-lock.json: expected ':' at 1:34
pip errors quote the fragment as well as the position:
$ printf 'flask[async>=3.0\n' > /tmp/bad/requirements.txt
$ ./target/release/stranger scan /tmp/bad/requirements.txt
stranger: /tmp/bad/requirements.txt: `flask[async>=3.0` has an unclosed `[` in its extras at 1:1
Line and column are 1-based and the column counts characters, not bytes, so it lines up with what an editor shows you. A lockfile that fails to parse is usually a merge conflict marker or a truncated download.
Nothing found on a lockfile you expect findings in
Work through the three clauses of the detection rule in order. It fires only when all three hold.
- The name is in the corpus. 140,066 npm names and 15,000 PyPI names are compiled in; if the name is one of them the rule stops immediately. This is the case for a typosquat that actually got registered.
- No corpus name is within edit distance 2. A hallucinated name that is not a
near-miss of a real one has no parent for clause 2 to find. Check the range is
really empty before concluding this —
requests-httpwas assumed to be in this category and turned out to be two edits fromrequests-html. - Something depends on it. If any third-party package in the tree lists the
name, the rule goes quiet by design. Edges out of the root manifest and out of
workspace members do not count for this; edges out of any other package do. On a
requirements.txtthere are no edges at all, so this clause never suppresses anything.
Also check the package is not first-party. Workspace directories and link: true
entries are skipped by every rule before any clause runs.
Only the critical block is listed
By design. Non-critical rules collapse to a count and a reason, because a
1,390-package tree produces 76 drift findings and 29 trivial ones. -v expands
them:
$ ./target/release/stranger scan -v fixtures/npm-xs.package-lock.json
npm-xs.package-lock.json 37 packages (1 direct · 36 transitive)
⚠ TRIVIAL 4 (10.8% of third-party)
es-errors@1.3.0 one expression, one publisher · inlining it removes an account from your build
gopd@1.2.0 one expression, one publisher · inlining it removes an account from your build
has-symbols@1.1.0 predicate-shaped, resolves nothing · size not measured, see rule docs
hasown@2.0.4 one expression, one publisher · inlining it removes an account from your build
· UNPINNED — no signal in this format
risk 9/100 9ms third-party deps used to compute this: 0
--format json is never collapsed — it emits every finding whether or not you
passed -v.
Findings you believe are wrong
Most likely the package is real and newer than the corpus snapshot of 2026-08-28,
or it fell off a popularity ranking. tensorflow-gpu in
fixtures/reqs-xs.requirements.txt is exactly that, and it is shipped as a
fixture rather than hidden. False positives covers the shape of it
and the ablation table puts numbers on it.
The nearest name in detail is the closest corpus entry, which is not always the
name you would have guessed, and gets less reliable as the corpus ages. Treat it as
the rule showing its working.
For TRIVIAL hits specifically: that rule cannot see how long a file is, and
reports a good share of packages that are not one-liners.
Trivial packages says which and why.
No colour
Colour is on when stdout is a TTY. Four inputs decide, highest priority first:
--no-color, then NO_COLOR, then CLICOLOR_FORCE, then TTY. Off beats on at
every tie.
If a CI log renders ANSI and you want colour there, set CLICOLOR_FORCE=1. If you
are getting escape codes where you do not want them, --no-color beats everything
else. --format json never carries colour under any setting.
A closed pipe prints nothing and exits 0
$ ./target/release/stranger scan fixtures/poisoned.package-lock.json | head -3
poisoned.package-lock.json 757 packages (35 direct · 722 transitive)
$ echo $?
0
EPIPE is the shell working correctly, not a failure, so it is silent. If a piped run reports an error, the error is real and came from somewhere else.
"scan takes one path"
$ ./target/release/stranger scan a/package-lock.json b/package-lock.json
stranger: scan takes one path; got a second, `b/package-lock.json`
One path per run, on purpose — a second argument being silently ignored is worse.
Loop in the shell. A directory containing both a package-lock.json and a
requirements.txt is one path and scans both.
$ ./target/release/stranger scan -v fixtures/poisoned.requirements.txt