npm Flag Typosquatting: How Missing Dashes Install Malware

Key takeaways
- Dropping the two dashes from an npm flag (
--save-dev→save-dev) makes a valid package name npm installs with no warning, prompt, or typo on the package name itself. - npm 12.0.2 exposes 191 config flags; we published empty placeholder packages for the claimable flag names across three accounts.
save-dev, an empty "security holding" package, is still pulled ~19,846 times a week — evidence the mistake happens at scale, not in theory.- Supply-chain scanners found nothing on the empty placeholders; a package with a real payload (
simple-toolbox-pkg) scored 100/100 while still executing the moment it was imported. - The typo is live in public code: 55 verified instances on GitHub, 10 of them in files that run on every build.
- The blast radius is worst in CI runners, container builds, security scanners, and AI coding agents that run install commands unattended.
Where this npm flag attack started: the save-dev package
it started with a package that already existed.

that is save-dev on npm. version 0.0.1-security, published seven years ago, readme says "Security holding package" and explains that npm is hanging on to the name so it cannot be used maliciously. there is no code in it.
now look at the right hand side. 386 dependents. the screenshot says 18,027 weekly downloads; rechecked on 2026-08-16 it is 19,846 a week and 140,841 a month.
a package with nothing in it, that nobody would ever choose to install, is being pulled twenty thousand times a week and sits in the dependency list of 386 published packages. all of that traffic is one thing:

drop the two dashes and npm stops seeing a flag and starts seeing a package name. it does not warn you, it just goes and gets it.
someone worked this out back in 2019, took the name and handed it to npm to sit on. and then it seems everyone moved on, because save-dev is one flag and npm has a hundred and eighty of them.
that was the question. if that one name was worth protecting, what about all the others?
Does npm really install a flag name? Testing it locally
before trusting the story i wanted to watch it happen. empty directory, npm init, then ask for one real package and one flag word in the same command.

added 2 packages. ms is the real library, strict-ssl is a flag name. npm did not flag it, did not ask, did not treat it differently in any way. you asked for two packages so it gave you two packages, and the second one is now sitting in node_modules on your disk.
no typo needed on the package name itself. no lookalike domain, no unicode trick, no letter swap. the entire attack is one missing pair of dashes.
Claiming npm flag names on the public registry
local proof is nice but the question that matters is whether you can actually own these names on the public registry. so we started publishing.

that is strict-ssl live on npm. ours. published, indexed, installable by anyone who fat fingers an ssl flag.
Publishing flag-name packages across three npm accounts
npm throttles how many brand new packages one account can create, and the list was well over a hundred names. one account hits the wall fast.
so the work got split across three - alexperrera, juliomartines and mcdonaldsnuggets7. each one takes a slice of the list and publishes it.

38 packages under that one account, every single one the same placeholder. the final split, counting live packages:
| account | packages | what they are |
|---|---|---|
| alexperrera | 38 | flag placeholders |
| mcdonaldsnuggets7 | 21 | 20 flag placeholders + enjoy-by |
| juliomartines | 20 | flag placeholders |
| total | 79 | 78 of them are npm 12 config flags |
Which npm commands and flags actually pose a risk
next problem. a flag is only dangerous if it sits on a command that downloads things. npm config get proxy is not going to hurt anyone.
rather than read the docs, we read npm. every config key lives in one object inside @npmcli/config, and every command class carries a static params list of what it accepts. that is the exact data npm uses to print its own help, so it is the source of truth and it tracks whatever version is installed.

the count is version specific, and that matters more than it sounds. npm 11.16 gives 180 config keys, 11.17 gives 181, npm 12.0.2 gives 191. resolving a different npm than the one on PATH silently reads a different namespace, so the version has to be pinned before any number means anything.
everything below is npm 12.0.2, the current latest, recomputed against a clean side-by-side install rather than carried over from the 11.17 pass. on 12.0.2:
| what | npm 12.0.2 |
|---|---|
| config keys in definitions | 191 |
| shorthands (15 of them not config keys) | 40 |
| command files / with a params list | 66 / 65 |
| keys declared by at least one command | 119 |
| keys declared by no command, accepted by all | 67 |
| reachable as a bare positional on a fetching command | 69 |
| the same, counting the 67 global ones | 136 |
69 if reachable means the word lands in argument position on a command that fetches, 136 if it also counts the keys every command accepts because npm config is global.
Where npm's flag names actually live (@npmcli/config)
worth writing the paths down, because we resolved the wrong npm twice and both times it silently produced a different namespace.
on disk. you already have this. it ships inside npm, it is not something you install and you cannot uninstall it:

note @npmcli/config is nested inside npm, so a bare require('@npmcli/config') will not find it - it is not on require's search path. that is why every snippet here resolves npm root -g first. npm 12.0.2 bundles version 11.0.1 of it.
online. two places, and only one of them is useful. the registry copy at npmjs.com/package/@npmcli/config is whatever npm published most recently, which is not necessarily what your npm is running. the source of truth is the monorepo:

same three files. the command classes are separate, at github.com/npm/cli/tree/latest/lib/commands.
the 191 config keys are the keys of one object literal in definitions.js, which is essentially the whole file:

the name appears twice per entry, once as the key and once as the first argument. counting keys in the file gives 191, same as Object.keys(definitions).length.
the save-* family hides in a literal usage: string on the save entry in the same file:

only three entries in the whole file override usage this way - save, color and description. every other flag's usage string is generated from short + key by definition.js, which is why save-dev reports -D|--save-dev at runtime even though that string appears nowhere in the file.
to be precise about what this string is and is not. all seven save names are real keys in definitions, so walking that object finds them:

the trap is not the namespace, it is the reachability mapping. install.params declares only save and save-exact, so that usage string is the sole record that the other five belong to install at all. more on what that broke below.
the 15 shorthand words live in a completely separate hand-written object in definitions/index.js. nothing in definitions.js references it, which is why reading that object can never surface them:

that spread at the end is where the other 25 come from. definitionProps, defined just above it in the same file, walks every definition and harvests its short: field, so D, S, g, q and the rest are generated rather than typed:

40 shorthands total: 25 generated letters, and the 15 hand-typed words above, which are the ones that squat. ws and no are the two to look at - both are ordinary English words and both expand to something a developer types constantly.
How we mapped each flag to the command that installs it
a flag name has no severity of its own. what decides the damage is the command it lands in - npm install strict-ssl writes to node_modules, npm view strict-ssl only prints metadata. so each command gets two questions, and it is only dangerous if both answers come back yes.
one: does a stray word become a package name? static usage on the command class says:

run across the commands that matter on 12.0.2:
| command | static usage | a stray word becomes |
|---|---|---|
| install | [<package-spec> ...] | a package to install |
| exec / npx | -- <pkg>[@<version>] [args...] | a package to run |
| init | <package-spec> | a create-* package to run |
| patch | <pkg>[@<version>] | a package to fetch, version required |
| pack / view / docs | <package-spec> | a package to fetch |
| search | <search term> ... | a search term, harmless |
| audit | [fix|signatures] | rejected |
| ci / dedupe / find-dupes / prune / sbom | undefined | nothing, no positional exists |
for the last group there is a blunter proof than the usage string. the method signature takes no argument, so the word never reaches the code:

two: where does the command send that name? the require list at the top of each file answers it. there are exactly three destinations, and they are the three tiers:

grep for the .reify( call, not for the word arborist. that was our fourth wrong conclusion: ls, explain, fund, outdated, rebuild and cache all load Arborist just to read the tree with loadActual(), so a loose match grades every one of them as an install:

you need both answers, and npm dedupe is the case that proves it. dedupe calls the same reify function install does, but async exec () takes no argument, so nothing can be confused into it. judge on the imports alone and you flag dedupe wrongly. judge on the positional alone and you clear npm view, which does fetch. uninstall is the mirror case - it reifies and it takes a positional, but it removes rather than fetches.
with both answers you invert params into flag -> commands and give each flag the worst tier any command that declares it earns:

that gives the tier counts below, plus one number worth stopping on - 67 of the 191 keys are declared by no command at all. those are the registry and auth configs: --registry, --proxy, --cafile, the fetch-retry family. every command accepts them anyway, and they are the ones that decide where your packages come from.
the loop as written gets save-dev wrong, which matters because save-dev is the reason this research exists. run it and five keys come back declared by nothing:

all five are real keys in definitions, so enumerating the namespace finds them fine. the reverse index is what loses them: no command's params mentions them, and the only record that they belong to install is the usage string on the save entry. so the loop needs a second pass that expands each declared flag's usage string back into its sibling keys:

that recovers exactly those five and nothing else - the other two literal usage overrides, color and description, only spell out negations like --no-color, which are not separate keys. the correction moves 5 flags out of the undeclared bucket and into the install tier:
| naive loop | with usage expansion | |
|---|---|---|
| tier 2 installs | 37 | 42 |
| declared by no command | 72 | 67 |
| reachable as a bare positional | 64 | 69 |
the totals barely move. which flag moves is the point: the naive run files save-dev - 19,846 downloads a week, 386 dependents - as reachable from no command at all.
the commands sort into tiers, and the tier decides how bad a mistake is:
- fetches and executes -
npm exec,npxandnpm init, the three that importlibnpmexec. 19 flags. this is the remote code execution tier. 13 of the 19 reach nothing else, because they come frominitrather than install -package,call,force,scope,yes,workspaces-updateand the wholeinit-*family. we had all of them a tier too low. - installs - install, install-test, install-ci-test, update, link, and new in npm 12,
patch. these are the six that callarb.reify()and take a package spec. 42 flags. lifecycle scripts may run. - fetches only - pack, view, outdated, fund, plus docs/bugs/repo which all extend
package-url-cmd.jsand pull a manifest through pacote to find the url. 8 flags. a tarball or some metadata comes down, nothing runs. - not exploitable at all - ci, dedupe, find-dupes, prune and sbom take no positional whatsoever; their signature is literally
async exec ()with no parameter. search takes a search term, audit only accepts fix or signatures.cacheand the newinstall-scriptsboth demand a recognised subcommand first, so a bare flag word is a usage error rather than a fetch.difftakes paths, not specs.
"not exploitable" does not mean the developer finds out. a stray word on ci/dedupe/find-dupes/sbom is not an error - it is swallowed silently, exit 0, in a directory with nothing declared:

nothing installs, and nothing tells the developer they mistyped either. one warning from running this test: our first attempt looked like npm ci had installed the package. it had not - an earlier npm install strict-ssl in the same directory had already written it into package.json, and the lockfile did the work. test every tier claim in a directory with nothing declared, or the lockfile takes the credit for the typo.
the fifteen shorthand words squat the same way, and ws is the sharpest case in the whole writeup. --ws is the shorthand for --workspaces. ws on the registry is the websocket library at 8.21.3, one of the most downloaded packages on npm. so mean --workspaces, type ws, and you get neither a placeholder nor an error - you get a large, real, working dependency, and it looks completely unremarkable sitting in a lockfile. same for local, reg, help and dd.
all of it went into flags_master.csv, one row per flag with its type, the worst command that reaches it and a severity grade.
Building the placeholder packages
with the list in hand the next step was mechanical. one directory per flag name, each holding the most boring package that can possibly exist.

no dependencies, no lifecycle scripts, no bin entry, no main. two files, 865 bytes unpacked. installing one does literally nothing, and the readme says what it is and offers the name to anyone with a real use for it.
Publishing the packages and classifying npm's errors
then a script to walk every directory and publish, with the token written to a throwaway .npmrc so the real one never gets touched.

the interesting half is not the publishing, it is the failure handling. npm's errors are noisy and mean very different things, so each one gets classified:

run it and you get a colour map of the whole namespace in one pass. it took three rewrites to get right and every rewrite came from a wrong conclusion, so it is worth saying what broke:
- collapsing every 403 into "name taken" hid the similarity filter completely, which is the single most important control in this whole writeup.
- treating an unrecognised 403 as "npm must be reserving this" produced a finding that was simply false.
calllanded in that bucket and it is just hapi's router package, published in 2012. - reading "previously published version X is higher" as "we own it, wrong version" was wrong too.
json,save,unicodeandversionsall hit that branch and none of them are ours.
the lesson is narrow but it cost real time: npm's publish errors describe what npm refused to do, never who owns the name. ownership only comes from the registry maintainer list. anything else is a guess dressed up as a verdict.
The flag names npm refused: similarity filtering
this is where it got interesting, because npm pushes back in two completely different ways and only one of them is documented.
too similar. npm strips punctuation before comparing names, so anything with a dot collapses onto its hyphenated twin:

that one turned out to be a defence we get for free. npm still accepts the old dot spellings like --init.author.name and --init.version on the command line, they only print a deprecation warning, so on paper they are attack surface. but nobody can register them while the hyphenated twin exists, and we already hold five of those six. the sixth belongs to an ordinary developer since 2016.
not reserved after all. the first version of this section said npm was silently holding ignore-scripts, install-links, legacy-peer-deps, prefer-offline and save-bundle, because all five 403 with no conflicting package named. that was wrong, and the corrected classifier caught it.
each of those five is blocked because a package literally named --<flag> exists, dashes included, and collapses onto the undashed name once punctuation is stripped:
| blocked flag | actually blocked by | owner | created |
|---|---|---|---|
| ignore-scripts | --ignore-scripts | realpeha | 2022-03-18 |
| prefer-offline | --prefer-offline | realpeha | 2022-03-18 |
| save-bundle | --save-bundle | realpeha | 2022-03-18 |
| legacy-peer-deps | --legacy-peer-deps | npm | 2022-09-07 |
| install-links | --install-links | jfskfos | 2024-04-15 |
there is no secret npm reserved list. what blocks these is a second spelling of the name - the flag with its dashes - and 6 of 191 are registered that way. npm holds one of them, --legacy-peer-deps, as a security holding package, so they know about the vector. two other researchers got here first: realpeha took three in a single day in march 2022, jfskfos added one in april 2024.
correction to the first version of this section, which called it "a whole second namespace we never scanned". it is not scannable surface, because you cannot register a dashed name any more. npm's own validator rejects it before the registry is ever contacted:

so all six are historical registrations from before that rule was enforced, and the window has since closed - realpeha and jfskfos reached through a gap nobody can reach through today. the practical effect is the opposite of attack surface: six old squats now sit as permanent roadblocks in front of six flag names, defending them more durably than our own placeholders do, since ours could in principle be unpublished or transferred and theirs cannot be recreated by anyone.
the similarity filter is stricter than punctuation-stripping. that also needs correcting. it refused 16 of 191 names, and not all of them are normalisation collisions:

auth-type being refused because path-type exists is a real edit-distance check on short names. this is the control actually holding the namespace shut, and it is doing more work than anything else in this writeup.
a 404 still does not mean a name is free, but the mechanism is more specific than we thought. a name that was published and then unpublished keeps its packument at GET /<name> with zero versions and a time.unpublished record, while GET /<name>/latest returns 404. every availability checker built on /latest calls those names free. they are not - npm refuses to let them be published again. commit-hooks is in this state and lists alexperrera as prior maintainer, meaning we unpublished it ourselves and can no longer use it.
The flag names already taken (and by whom)
we attempted a publish against all 191. 93 already belong to somebody else. but "taken" turned out to be four different situations wearing the same label:
- npm and node themselves -
which,bin-linksandnode-gypbelong to npm-cli-ops and the nodejs foundation.headingis owned by the literalnpmaccount at 0.0.1-security, same pattern as save-dev. - big real packages where a dropped dash installs genuinely working code -
diffat 9.0.0,colorat 5.0.3,unicodeat 14.0.0,update-notifierat 7.3.1,progressat 2.0.3. - abandoned stubs from 2011 to 2013 sitting at 0.0.1 or 0.1.x and untouched since.
forcewent in 2013,saveandlinkin 2012,globalin 2011. nominally defended, but one account takeover or one lapsed maintainer email away from being live attack surface. - one where the collision runs backwards -
allow-scriptsis owned bydominykasand was published in january 2019, as a real working tool for running allowlisted lifecycle scripts. it has a bin entry, a repo and a maintained version line. npm did not have an--allow-scriptsconfig key until the lifecycle-script approval work in npm 11/12, so nobody claimed that name defensively - npm named a flag after a package that already existed. filed here originally as "other people doing exactly what we are doing", which was wrong in both directions: it is not a defensive squat, and it predates the flag it supposedly squats.
so the reassuring headline is real but narrow. every high impact name was taken years ago by ordinary developers who had no idea they were defending anything. it was luck, not a control.
What supply chain scanners say about empty packages
socket looks at the placeholders and finds nothing at all.


no package alerts, no dependency alerts. the overall score sits at 51 and that comes purely from generic heuristics, things like being new and having no repository. not one alert fires, because there is genuinely nothing in the package to fire on.
which is the uncomfortable part. an empty package that only holds a name is indistinguishable from an attacker's first publish. the payload does not have to be there on day one. it arrives as 1.0.1 weeks later, after the name has cleared review and after somebody's lockfile already points at it.
simple-toolbox-pkg: what scanners miss when there is a payload
so we published the other half of the experiment. a package that carries the exfiltration we would actually have used.
simple-toolbox-pkg - unpublished on 2026-08-16, so the link above now 404s and the screenshots below are the record. it was never a flag name - it was the socket-evasion test - so it does not affect any count in this writeup except mcdonaldsnuggets7's non-flag total.

100 on supply chain security. 100 on vulnerability. 100 on quality. 100 on maintenance. 100 on license.
here is everything that went into looking harmless:
- six real utility functions that do exactly what the readme claims - capitalize, slugify, chunk, unique, humanize, success. all of them genuinely work.
- two dependencies everyone already trusts,
msandpicocolors, both with zero sub dependencies so the tree stays flat and easy to eyeball - typescript declarations, an exports map, an engines field, sideEffects false, a files allowlist
- a readme with badges and a full api section, a changelog following semver, an MIT license
- a test file,
"test": "node --test", and a prepublishOnly hook that runs the tests before every publish
and my favourite part. the readme has a design principles section that says out loud - no lifecycle scripts, pure functions, no network, no filesystem, no child_process, readable source, nothing minified. it reads like a careful maintainer explaining why you can relax. every one of those claims is true except the network one.
the payload is deliberately small and boring:

username, working directory, hostname, platform. enough to fingerprint a laptop or a build agent and decide whether the machine deserves a second stage. on the registry the actual fetch is commented out so nothing leaves anyone's machine, but the collection and the shape of the call are exactly what would ship.

How the payload runs without an install script
the trigger is one line at the top of the entry point:
there is no install script. none. that is the point, because npm v12 clamped down on lifecycle scripts and everybody is watching preinstall and postinstall now.

top level await in an esm module. it is not a hook, it is just a module doing module things, and it fires the moment anything imports the package. one import { capitalize } from 'simple-toolbox-pkg' anywhere in the tree and it has already run.
the other route is bin linking. a package declares a bin entry and npm drops the shortcut into ./node_modules/.bin. it does not replace anything global, typing git still gets you git, but npm puts that directory at the front of PATH during npm run <script> and everything it spawns, during npx, and during lifecycle scripts like prepare. so a name matching a command people already call inside scripts gets picked up first and nobody types anything unusual. simple-toolbox-pkg does not use this route, it uses the import trigger. they are alternatives and the bin one only matters once you own a name a script is going to call.
put the two halves together and that is the finding. the name can be held, and the payload behind it scores a perfect 100 with a scanner staring straight at it.
Who is actually downloading these npm flag packages
recounted on 2026-08-16 against api.npmjs.org/downloads, over the full window from the first publish on 3 july: 9,384 downloads across all 81 packages, 2,341 of them in the last week. the busiest names are strict-ssl at 245, foreground-scripts and replace-registry-host at 180 each, and sign-git-tag at 174.
the shape matters more than the total. here is strict-ssl day by day, where # is a spike over twenty and a dot is nothing:

one big spike the day after publishing, then one to three a day for weeks. that spike is registry mirrors and security scanners crawling anything new, not people. the august batch makes it plainer - those packages all sit at 118-125 downloads and their last-week and last-month totals are identical, so every download they have ever had arrived within days of publishing. that is a crawl signature. real typo traffic would keep trickling in.
so i would not stand behind "nine thousand mistakes" - most of that number is machines. what does survive is the tail: a package that does nothing, is linked from nowhere and is advertised in no readme still gets pulled a couple of times a day, six weeks after it went up.
for real evidence of humans getting it wrong, look at the names that were taken long before we showed up:
| name | weekly downloads | what it is |
|---|---|---|
| save-dev | 19,846 | security holding package, no code, 386 dependents |
| package-lock-only | 12,667 | tiny package nobody depends on deliberately |
| no-save | 100 | same |
| save-prod | 13 | same |
| save-exact | 7 | same |
save-dev is the one that settles it. no code, nothing to use, and 386 published packages carry it in their dependency list. those did not get there on purpose. somebody typed npm install save-dev something, npm wrote save-dev into package.json, and it shipped.
Finding the npm install typo in public GitHub code
last question - can you find the mistake sitting in public code right now. yes, but the first attempt at counting it was badly wrong and the correction is the interesting part.
the original method was four hand typed github queries and a count of what came back. that number, 53, does not survive contact with the actual results. github's code search matches substrings and has no idea what an npm command line is, so "npm install save-dev" happily returns the string npm install --save-dev, prose that mentions the flag, commented out lines, and quoted labels inside yaml. searching for all returned 1,233 files, essentially none of which are typos.
so it got rebuilt as a script - scripts/github_typo_scan.py - that runs every package name we hold and verifies each hit against the raw file fragment instead of trusting the count. a hit only survives if:
- the flag word appears with no leading dashes, checked with
(?<![-\w./@])name(?![-\w./@])rather than\b. word boundaries are useless here,\ball\bmatches insideall-animation. - nowhere in the surrounding context does the correct
--flagform appear. if it does, the file is documenting the flag, not fumbling it. - the line is an actual npm invocation, in argument position, not commented out and not a quoted string being used as a label.
run across all 203 names:
| stage | count |
|---|---|
| raw github hits | 230,785 |
| survive verification | 538 |
| on names that are real packages - excluded | 465 |
| test fixtures - excluded | 20 |
| actual typos | 49 |
two exclusions are doing the heavy lifting and both are honest ones.
real packages. 465 of the 540 verified hits are things like npm install node-gyp, npm install long, npm install update-notifier. those are correct commands for real libraries that happen to share a name with an npm flag. update-notifier alone accounts for 84. counting them would have inflated the headline by ten times, and every single one would have been wrong.
test fixtures. all three strict-ssl hits are the same file - checkov ships a Dockerfile containing npm install strict-ssl so its own scanner has something to match on, and two other repos vendored it. one fixture, three hits, zero mistakes. that drops strict-ssl to nothing and the classifier now subtracts fixtures rather than labelling them.
what is left is 50 typos across three names:
| flag | typos | what is at the name |
|---|---|---|
| save-dev | 36 | npm's own security holding package, no code |
| dry-run | 9 | nothing - the install 404s |
| legacy-peer-deps | 4 | nothing - blocked by --legacy-peer-deps |
none of the three names are ours. save-dev is npm's own holding package, and dry-run and legacy-peer-deps are both names we tried to register and were refused - dry-run collides with dryrun, legacy-peer-deps with --legacy-peer-deps. so nothing is published at either, and both typos 404 today.
that is worth stating plainly, because it is the strongest evidence in the writeup and it cuts against us. across all 79 names we hold, verified typos in public code: zero. the only hits on one of our names were the three checkov fixtures on strict-ssl, and those are not people. every real mistake we found lands on a name somebody else took years ago, which is exactly the pattern from the download numbers - our traffic is crawlers, the real traffic is on save-dev.
and where they live is still the point. 39 are documentation, 4 are config or source files, and 6 are in files that run - all of them opened and read line by line:
- codestates/Cogether
scripts/initialize.sh:5-npm install save-dev seqeulize-cli(noteseqeulizeis misspelled too) - quike/action-semantic-release
Makefile:16-... | xargs npm install save-dev --verbose - Matys98/Comparative-analysis-...
Bash_Scripts/tiquet.sh:93-sudo npm install save-dev webpack-cli -g - Matys98/Comparative-analysis-...
Fabric/tiquet/fabfile.py:41-c.run('sudo npm install save-dev webpack-cli -g'), the Fabric twin of the line above, executed on the remote host - Shurtu-gal/dotfiles
.config/fish/config.fish:67-npm i save-dev prisma@4.8.1 - mani7lall/Holistic-Wellness-Website
.github/workflows/static.yml:44-run: npm install legacy-peer-deps
the Makefile one is the most instructive. it pipes the real dependency list through xargs into npm install save-dev --verbose, so save-dev is installed alongside every genuine dependency, on every run, in a release action. it has been passing for years.
the fabfile is the second correction to this section. it was filed as "other" because the classifier keyed on file extension and .py is not a shell script - but c.run('sudo npm install save-dev webpack-cli -g') is Fabric, so it executes on a remote host, as root, globally. extension is not what decides whether a line runs. the classifier now promotes any shell-out wrapper - run(, system(, execSync(, subprocess.check_call( - regardless of the file it sits in.
the other three non-doc hits do not run, checked individually:
silv4b/kit-auth-supabase-quasarconfig/envparser.js- inside a/* */block commentraizul8/raz_ITNotes_IT/ELM/elm1.js- a notes file; the line is shell, in a .js file, and would not parse as javascriptSTR7ANGER/Learning-Habitsfrontend/.npmrc- the entire file is the single linenpm i legacy-peer-deps=true. somebody pasted a shell command into a config file. npm reads .npmrc askey=value, so this sets a key callednpm i legacy-peer-depsand installs nothing at all. a mistake, but an inert one.
readmes get copy pasted by every person who lands on that repo. the scripts and the workflow are worse, because those run, and nobody reads an install line in a build step that is already green.
a sixth one was thrown out on inspection, and it is worth keeping the correction visible. a PowerShell script matched with $errorLog += "npm install dry-run falhou". that is an error message, in Portuguese, about a line twelve rows earlier that used --dry-run perfectly correctly. the scanner's "does the context show the correct --form" guard could not see that line, because github's text-match fragment is a window, not the file. so any rule that leans on context is only as good as the window, and the scanner now also rejects a command quoted inside a string being appended to a log variable. run across all 538 verified hits that rule catches exactly two, which is the right size for a fix.
49 is a much smaller number than 53 and it is a far stronger one, because every one of them has been looked at. and note what it is a floor on rather than a measure of: github code search only indexes public repositories, only default branches, and truncates large result sets. these are the mistakes that were committed, pushed publicly and never noticed. the ones people caught before pushing, and every private repo and every CI log, are not in this number at all.
How npm 12 expanded the attack surface
the namespace is not fixed. it grows every time npm ships, and each new flag is a fresh unclaimed name with a head start on whoever is watching.
diffing the config definitions across three versions:
| npm | config keys | delta |
|---|---|---|
| 11.16.0 | 180 | baseline |
| 11.17.0 | 181 | +min-release-age-exclude |
| 12.0.2 | 191 | +11, -1 (shrinkwrap, already deprecated) |
the eleven new names in npm 12, and where each one is reachable from:
| flag | type | reachable from |
|---|---|---|
| patches-dir | value | npm patch |
| edit-dir | value | npm patch |
| to | value | npm patch |
| allow-unused-patches | bool | npm patch |
| ignore-patch-failures | bool | npm patch |
| ignore-existing | bool | npm patch |
| keep-edit-dir | bool | npm patch |
| extension-file | value | no command declares it |
| ignore-extension | bool | no command declares it |
| global-ignore-file | value | no command declares it |
| strict-npmrc | bool | no command declares it |
seven of them arrive with npm patch, a brand new command that takes a package spec as its first positional. it is a new row in the tier table and it did not exist when the old 112 number was computed.
but npm patch is much weaker than it first looks, and the test is worth keeping. a bare flag word is refused, because patch wants something already in your tree:

it only reaches the registry once you name a version, and then it does fetch and unpack:

so patch is real attack surface but not typo-shaped. nobody fat fingers a flag and supplies a version at the same time. graded on the bare form, npm patch is not exploitable at all; graded on the explicit-version form it fetches and unpacks but runs nothing. the seven patch flags are on a command that only fires with input a typo will not produce.
the other four are the second trap again - declared by no command, accepted by all of them, because npm config is global.
install itself did not change. its params list is 35 on both 11.17 and 12.0.2, so none of the new flags are typed on the command with the most traffic. that caps the realistic exposure here well below the save-dev tier, and none of these should be graded until reachability is actually recomputed against 12.
two behaviour changes in npm 12 that touch this research directly:
allow-gitandallow-remoteboth flip default fromalltonone. git-ref and tarball-url dependencies stop resolving unless you opt back in.adduser,star,stars,unstarandshrinkwrapare gone as commands, and two arrive -patchandinstall-scripts. 69 down to 66.install-scriptsis the front end for the npm 12 script-approval policy and it is not exploitable: it requires one of approve/deny/ls/prune first, sonpm install-scripts strict-sslis a usage error.
now recomputed against 12.0.2 - the config key count, the shorthand set, the command inventory and the whole tier map, all against a clean local 12.0.2 install. the old 112 reachable count is retired and replaced by 64 / 136 with their definitions stated.
still 11.17 numbers - github_hits and every exposure_score in flags_master.csv. those need a rerun over the 206-name npm 12 list, which is 3 names wider than the 203 the github scan used.
the registry side has now been recomputed. a publish was attempted against all 191 names, so these are verdicts rather than lookups:
| status | count |
|---|---|
| owned by third parties | 93 |
| held by our three accounts | 78 |
| blocked by the similarity filter | 16 |
| blocked by an unpublish tombstone | 2 |
| invalid npm names | 2 |
| still registerable | 0 |
nothing in the flag namespace is claimable any more. the split across accounts is alexperrera 38, mcdonaldsnuggets7 20, juliomartines 20.
What npm flag typosquatting adds up to
you do not need a clever name. no lookalike, no unicode, no letter swap. you need a flag people already type thousands of times a day, and you need them to miss two dashes.
that mistake is already happening at scale, provably, on the one name where somebody bothered to check. 19,846 downloads a week on an empty package is not a theoretical risk, it is a measurement.
and where it lands is the part that should worry people. a developer laptop is one thing. but the same line sits in CI runners with cloud credentials in the environment, in container builds, in the security scanners that go and fetch packages to look at them, and increasingly in ai coding agents that run install commands nobody reads first.
Rerun, 8 September 2026: re-checking the numbers
everything above was rescanned three weeks later. two things came out of it - the numbers held, and the method had a hole in it.
What was re-scanned
| run | names | flagged | genuine typos |
|---|---|---|---|
| install • the 79 names we hold | 79 | 3 | 0 • four raw hits total, three of them the same checkov fixture |
| install • the other 124 | 124 | 535 | 49 • save-dev 35, dry-run 10, legacy-peer-deps 4 |
| npx | 203 | 10 | 0 |
| npm init | 203 | 4 | 0 |
| npm exec | 203 | 34 | 0 |
| npm add | 203 | 32 | 6 |
the 49 reproduced exactly - same three names, same 39 docs / 6 executing split, same six repos, all still live. the per-name distribution shifted by one (save-dev 35 rather than 36, dry-run 10 rather than 9), which is ordinary churn in github's index.
The flaw: the search queries never matched the verifier
the scanner only ever sent two queries per name:

but verify() accepts eleven verbs - install|i|in|ins|add|isntall|update|up|exec|pack|view|link. so the recogniser was always broader than the search, and nothing outside those two forms could ever be found. the entire fetch-and-execute tier had never been searched at all.
adding npx, npm init and npm exec returned nothing, which is a real result rather than a null one: every hit was somebody correctly running a real tool, 25 of them allow-scripts. --save-dev is typed far more often than any flag on npx, and the evidence matches.
npm add did not come back empty.
npm add global: one more alias, one more typo
add is a plain alias for install in lib/utils/cmd-list.js, so the same dropped-dash mistake applies:

one package becomes five, because global is a real maintained library (21 versions, latest 4.4.0, created 2011 but modified 2022) and it brings dependencies with it.
worth correcting an earlier line in this writeup on the back of that. global is listed above under "abandoned stubs from 2011 to 2013 sitting at 0.0.1 and untouched since". that is wrong - it was created in 2011 but it is a working package with 21 versions. it belongs in the "big real packages" bucket with diff, color and unicode.
six repos have the typo committed, four of them in files that run on every build:
| repo | file | line |
|---|---|---|
| yobysheva/Financity | frontend/Dockerfile | RUN npm add global @vue/cli |
| AndrewwSSS/Comments-SPA | frontend/Dockerfile | RUN npm add global @vue/cli \ |
| baranero/portfolio | .github/workflows/lint-pr.yml | run: npm add global @commitlint/cli @commitlint/config-conventional |
| baranero/freelance | .github/workflows/lint-pr.yml | same line, second repo |
the other two are teaching material - jakubszpil.github.io content/courses/wprowadzenie-do-typescript.md listing "npm add global typescript", and a lesson json in vophamthanhan/mern-learning with "npm add global yarn". which is the readme problem again, except now it is being taught.
Making the real-package exclusion mechanical
the first pass removed 465 of 538 hits by hand, on the judgement "this is a real library someone installed on purpose". that judgement is now a registry lookup per name:
| verdict | test | a hit means |
|---|---|---|
| REAL | has a bin or main, or more than two versions | correct usage - exclude |
| HOLDING | version contains -security, or description says "security holding" | typo |
| MISSING | 404 on the registry | typo, and it fails |
| OURS | description is our placeholder string | typo |
run over the 535 rows it removed 470, against 465 removed by hand. two independent methods landing five apart is the best evidence the original number was not a judgement call dressed up as a measurement.
Four more issues the rerun exposed
- generic english words are unfixable at query level.
alsoproduced 27 hits, every one of them the phrase "npm install also works" or "npm install also failed". same forall,before,access. these need a stoplist or a rule about what follows the word, not a smarter query. max_raw=300drops a name silently. if a query returns more hits than that, the name is discarded entirely rather than sampled, and nothing in the output says so. it should be reported as "N names unmeasurable" alongside the count.pages=1sees only the first 100 results of any set, so anything between 100 and 300 raw hits is partially examined and reported as if it were complete.- the
verifiedcolumn is not verified. it is pre-exclusion candidates. onnpm execit read 24 while the true count was 0. it should be renamedcandidates, with a real verdict column written only after the registry pass.
Revised totals: 55 verified typos
55 verified typos, 10 of them in files that execute. the 49 stands for the method as originally published; the extra 6 exist only because the command forms were widened.
Frontier models can now find and exploit zero days in your code
Find out what to do when there's no CVE, scanner alert or fix available yet.
What are the 7 blind spots in your vulnerability scans?
Discover when "0 vulnerabilities" doesn't actually mean you're clean.
.png)




.avif)
.avif)