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:

rulenpmpnpmcargopoetry / uvrequirements.txtgo.mod
slopsquatyesyesregistry crates onlyyesyes, weakenednever — no corpus
install-scriptyesnevernevernevernevernever
trivialyesyeseffectively nevereffectively nevereffectively nevereffectively never
driftyesyesyesyesnot on a well-formed filenever
pinningneverneverneverneveryesnever

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