npm Flag Typosquatting: How Missing Dashes Install Malware

Yuval Saggie
Yuval Saggie
Oct 26, 2026 | 28 Minutes
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.

npm package page for save-dev: version 0.0.1-security, a 'Security holding package' with no code, 386 dependents and 18,027 weekly downloads

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:

npm install --save-dev typescript (what they meant to type) next to npm install save-dev typescript (what they actually typed)

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.

Terminal: npm install ms strict-ssl adds 2 packages; node_modules/strict-ssl/package.json is the placeholder package

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.

npm package page for strict-ssl 1.0.0, our placeholder package reserving the name, live on the public registry

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.

npm profile of alexperrera listing placeholder packages such as allow-git, allow-directory, rebuild-bundle and allow-file

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.

JavaScript that resolves the global npm install and reads its config definitions, shorthands and the install command's params

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:

Path to npm's bundled @npmcli/config definitions folder: definitions.js, index.js and definition.js

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:

github.com/npm/cli/tree/latest/workspaces/config/lib/definitions

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 definitions object literal in definitions.js, showing the save-dev entry with short 'D' and its exclusive list

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:

The save entry in definitions.js with a literal usage string listing --save-prod, --save-dev, --save-optional, --save-peer and --save-bundle

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:

All seven save-* names are real keys in definitions, each with its usage string

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:

The hand-written shorthands object in definitions/index.js, including enjoy-by, quiet, silent, verbose, local, no, reg, iwr and ws

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:

definitionProps reduce that generates the single-letter shorthands from each definition's short field

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:

Reading static usage and params from npm's install command class

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:

ci.js and dedupe.js both define async exec () with no argument

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:

The three destinations: libnpmexec downloads and runs, arb.reify() writes node_modules, pacote only fetches metadata or a tarball

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:

grep for .reify( in lib/commands returns ci, install, link, patch, uninstall and update

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:

Loop that inverts each command's params into a flag-to-commands index

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:

Output: save-dev, save-prod, save-optional, save-peer and save-bundle show as declared by no command

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:

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, npx and npm init, the three that import libnpmexec. 19 flags. this is the remote code execution tier. 13 of the 19 reach nothing else, because they come from init rather than install - package, call, force, scope, yes, workspaces-update and the whole init-* 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 call arb.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.js and 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. cache and the new install-scripts both demand a recognised subcommand first, so a bare flag word is a usage error rather than a fetch. diff takes 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:

Terminal: npm ci strict-ssl prints 'up to date, found 0 vulnerabilities', yet node_modules does not exist

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.

Python that writes a placeholder package.json and README for every flag name

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.

publish_all.py building the npm publish command with a throwaway userconfig

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:

Python classify_failure function mapping npm publish errors to already-exists, too-similar, forbidden and 2FA outcomes

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. call landed 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, unicode and versions all 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:

npm error E403: package name init.module too similar to existing packages init-module, initmodule

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:

validate-npm-package-name rejects --legacy-peer-deps with the error 'name cannot start with a hyphen'

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:

Names refused by npm's similarity filter: globalconfig, init.author.name, dry-run, auth-type and cafile, with the package that blocked each

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-links and node-gyp belong to npm-cli-ops and the nodejs foundation. heading is owned by the literal npm account at 0.0.1-security, same pattern as save-dev.
  • big real packages where a dropped dash installs genuinely working code - diff at 9.0.0, color at 5.0.3, unicode at 14.0.0, update-notifier at 7.3.1, progress at 2.0.3.
  • abandoned stubs from 2011 to 2013 sitting at 0.0.1 or 0.1.x and untouched since. force went in 2013, save and link in 2012, global in 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-scripts is owned by dominykas and 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-scripts config 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.

Socket overview of the min-release-age placeholder: supply chain 51, vulnerability 100, quality 51, maintenance 86, license 100
Socket alerts tab for the min-release-age placeholder package showing 'No alerts'

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.

Socket overview of simple-toolbox-pkg scoring 100 on supply chain security, vulnerability, quality, maintenance and license

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, ms and picocolors, 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:

src/send.mjs: reportEnvironment collects username, working directory, hostname and platform

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.

npm code view of simple-toolbox-pkg/src/send.mjs showing the payload and the commented-out fetch to the webhook

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.

src/index.js: importing reportEnvironment from ./send.mjs and calling it with top-level await

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:

Day-by-day download sparkline for strict-ssl: total 237, 23 days with traffic, max 143 in one day

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\b matches inside all-animation.
  • nowhere in the surrounding context does the correct --flag form 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:

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-quasar config/envparser.js - inside a /* */ block comment
  • raizul8/raz_IT Notes_IT/ELM/elm1.js - a notes file; the line is shell, in a .js file, and would not parse as javascript
  • STR7ANGER/Learning-Habits frontend/.npmrc - the entire file is the single line npm i legacy-peer-deps=true. somebody pasted a shell command into a config file. npm reads .npmrc as key=value, so this sets a key called npm i legacy-peer-deps and 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:

Terminal: npm patch strict-ssl fails with EPATCHNOTINSTALLED

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

Terminal: npm patch strict-ssl@1.0.0 fetches the package and opens an edit directory

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-git and allow-remote both flip default from all to none. git-ref and tarball-url dependencies stop resolving unless you opt back in.
  • adduser, star, stars, unstar and shrinkwrap are gone as commands, and two arrive - patch and install-scripts. 69 down to 66. install-scripts is the front end for the npm 12 script-approval policy and it is not exploitable: it requires one of approve/deny/ls/prune first, so npm install-scripts strict-ssl is 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:

The two GitHub queries sent per name: "npm install <name>" and "npm i <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:

Terminal: npm add --global ms adds 1 package, while npm add global ms adds 5 packages including global 4.4.0

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. also produced 27 hits, every one of them the phrase "npm install also works" or "npm install also failed". same for all, before, access. these need a stoplist or a rule about what follows the word, not a smarter query.
  • max_raw=300 drops 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=1 sees 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 verified column is not verified. it is pre-exclusion candidates. on npm exec it read 24 while the true count was 0. it should be renamed candidates, 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.

Read now →

Ready to eliminate vulnerabilities at the source?