What Is Typosquatting in Package Repositories?
HuntRule Team · · 10 min read

On this page
On 19 July 2017 an npm user named hacktask published a package called crossenv. The popular package is cross-env. The squat read the environment variables of whatever machine installed it and sent them to npm.hacktask.net. npm removed it on 1 August after a report on Twitter, along with about 40 sibling packages.
Nothing in that attack broke a resolver. npm install crossenv resolved exactly the name that was typed, verified the tarball integrity hash correctly, and installed it. The vulnerability was in the developer's fingers.
That is why typosquatting stays cheap. No exploit to write, no maintainer account to phish, no CI pipeline to poison. Just a registry that accepts new names for free and developers who type from memory, copy from a blog post, or accept a package name suggested by a model that hallucinated it. MITRE tracks the family as T1195.001, Compromise Software Dependencies and Development Tools.
The name-similarity techniques
The published hacktask package list is a usable field guide because every name in it maps to a mechanical transformation of a real one.
Omission.
mongoseformongoose. One character gone.Transposition.
ffmepgforffmpeg. Two adjacent characters swapped, which is the single most common typing error and the hardest to see when proofreading.Insertion.
sqliterforsqlite, andpython3-dateutilforpython-dateutilon PyPI. The insertion often looks like a plausible variant rather than an error.Suffix and extension bait.
jquery.js,d3.js,mssql.js,proxy.js. The reader's brain treats.jsas a file extension rather than part of a package name.Separator drop or swap.
crossenvforcross-env,nodesassfornode-sass. On npm,foo-bar,foo_barandfoo.barare three distinct packages.Homoglyphs. In December 2019 PyPI removed
jeIlyfish, which isjellyfishwith a capital letter I in place of the lowercase L. In most sans-serif fonts, including the one your terminal probably uses, those glyphs are identical. Snyk reported the package had been on PyPI for nearly a year and that it appeared to steal SSH and GPG keys. A second package,python3-dateutil, pulledjeIlyfishin as a dependency, so the malicious name never had to appear in anyone's requirements file.Scope drop. In March 2022 JFrog reported at least 218 npm packages that took an
@azurescoped name and republished it unscoped.npm install core-tracinginstead ofnpm install @azure/core-tracing. Scoped packages feel safe because the scope is a namespace under one owner. The unscoped shadow of that namespace is unowned by default.
The @azure set also carried version numbers like 99.10.9, which is the signature of dependency confusion rather than typosquatting. One package can carry both. See dependency confusion for that mechanism.
What actually runs
The name gets the package onto disk. Two execution surfaces turn that into code execution.
The first is install lifecycle scripts. In npm, preinstall, install and postinstall in package.json run during npm install, before your application ever imports anything. In Python, a source distribution runs setup.py at build and install time. Wheels do not, which is why the payload in a wheel-only attack has to sit in import-time code instead, typically at the top of __init__.py, where it executes on the first import.
JFrog published the collection object from the @azure squats. The payload ran automatically on install and gathered the following.
const td = {
p: package,
c: __dirname,
hd: os.homedir(),
hn: os.hostname(),
un: os.userInfo().username,
dns: JSON.stringify(dns.getServers()),
ip: JSON.stringify(gethttpips()),
dirs: JSON.stringify(getFiles(["C:\\","D:\\","/","/home"])),
}That is T1082 and T1016 in one object, plus a non-recursive listing of C:\, D:\, / and /home. Exfiltration used two channels: an HTTPS POST to the hardcoded host 425a2.rt11.ml, and a DNS query to <HEXSTR>.425a2.rt11.ml where the collected fields were hex encoded into the subdomain.
Note what that payload does not do. It spawns no shell, drops no binary, touches no registry key. It is JavaScript running inside the node process that npm already started, using dns and https from the standard library. Any detection built purely on child process creation misses it.
Starjacking and brandjacking
Squatting the name gets installs by accident. Copying the trust signals gets installs on purpose.
Checkmarx named the first technique StarJacking in 2022, after finding a malicious PyPI package with more than 70,000 downloads using it. The repository URL in package metadata is publisher-controlled and unverified. PyPI, npm and Yarn all pull statistics from whatever GitHub repository the publisher names and render them on the package page. Point your malicious package at github.com/psf/requests and it inherits that project's stars, issues and pull request counts.
Brandjacking is the wider version: copy the README verbatim, reuse the logo and description, adopt an author name close to the real maintainer's. The 2019 PyPI pair did the equivalent at the code level. Both shipped the complete working functionality of the packages they impersonated, so nothing broke and nobody investigated. Stars, download badges and READMEs are attacker-controlled fields. They are not provenance.
Registry defences, and where they stop
PyPI removes an entire class of squats by normalization. Per the packaging specification, a project name is lowercased and every run of ., - or _ collapses to a single -.
import re
def normalize(name):
return re.sub(r"[-_.]+", "-", name).lower()So python_dateutil, Python-DateUtil and python.dateutil are the same project on PyPI and cannot be registered separately. npm has no equivalent collapse, which is why crossenv and cross-env coexist. npm's package name guidelines ask publishers to avoid names spelled similarly to an existing package and names that confuse authorship, but that page reads as guidance to publishers. We could not confirm from public npm documentation which near-miss names the registry actually rejects at publish time.
Takedown is the other defence, and it is reactive by construction. crossenv lived 13 days. jeIlyfish lived close to a year. Defensive registration does not scale either, since a squatter automated 218 names against one scope in a few days.
Detection
Three checks, in increasing order of how much they cost you.
The cheapest is a lockfile diff that lists package names that are new in this commit. Names, not versions.
git show HEAD~1:package-lock.json \
| jq -r '.packages | keys[]' | sed 's|^node_modules/||' | grep . | sort -u > /tmp/pkgs.old
jq -r '.packages | keys[]' package-lock.json \
| sed 's|^node_modules/||' | grep . | sort -u > /tmp/pkgs.new
comm -13 /tmp/pkgs.old /tmp/pkgs.newThat reads lockfileVersion 2 and 3. For version 1 lockfiles, use .dependencies instead of .packages. The output is usually empty, and when it is not, it is short enough to read.
Feed that output into a similarity check against your known-good dependency list. Plain Levenshtein catches the single-character cases, and normalizing separators and scope first catches the rest.
#!/usr/bin/env python3
# usage: nearmiss.py known_good.txt new_names.txt
import re, sys
def norm(name):
name, scope = name.lower(), ""
if name.startswith("@") and "/" in name:
scope, name = name.split("/", 1)
return scope, re.sub(r"[-_.]+", "", name)
def lev(a, b):
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i]
for j, cb in enumerate(b, 1):
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
prev = cur
return prev[-1]
known = [l.strip() for l in open(sys.argv[1]) if l.strip()]
for line in open(sys.argv[2]):
cand = line.strip()
if not cand or cand in known:
continue
_, cb = norm(cand)
for k in known:
_, kb = norm(k)
d = lev(cb, kb)
if d == 0:
print(f"COLLISION AFTER NORMALIZATION {cand} ~ {k}")
elif d <= 2:
print(f"DISTANCE {d} {cand} ~ {k}")crossenv against cross-env and core-tracing against @azure/core-tracing both normalize to distance 0 and print as collisions. jeIlyfish against jellyfish is distance 1. ffmepg against ffmpeg is distance 2, because plain Levenshtein charges a transposition as two edits. Damerau-Levenshtein scores it 1 and lets you tighten the threshold. At distance 2 on a large list you will hit real pairs that are simply similar, react-dom against react-dnd for example, so keep an allowlist of confirmed pairs rather than raising the threshold.
The name checks fail whenever the attacker gets a name you never compare against. Process lineage does not care about the name.
title: Shell or download utility spawned by a package manager install
id: 91de3867-1184-4e67-92c0-0a329111ffa0
status: experimental
description: Detects a shell, download utility or interpreter started as a child of a package manager process, which is the usual shape of an npm lifecycle script or a setup.py payload.
references:
- https://attack.mitre.org/techniques/T1195/001/
author: HuntRule
date: 2026/07/31
tags:
- attack.initial-access
- attack.execution
- attack.t1195.001
logsource:
category: process_creation
product: linux
detection:
selection_parent:
ParentImage|endswith:
- '/npm'
- '/npx'
- '/yarn'
- '/pnpm'
- '/node'
- '/pip'
- '/pip3'
- '/uv'
selection_child:
Image|endswith:
- '/sh'
- '/bash'
- '/zsh'
- '/curl'
- '/wget'
- '/nc'
- '/base64'
- '/chmod'
- '/ssh'
condition: all of selection_*
falsepositives:
- Native module builds. node-gyp legitimately spawns sh, make and cc on every install of a package with a binding.gyp
- Binary-downloading postinstall scripts in widely used packages such as playwright, sharp and esbuild
- Developer laptops where the whole install runs under a shell wrapper
level: mediumWhat it catches: the common case, where a lifecycle script shells out for a second stage. What it misses: the @azure payload, which never leaves the node process. Pair the rule with DNS and egress telemetry from build hosts. Long single labels of even-length hex under one registered domain, from a host that has just run an install, are the shape of the <HEXSTR>.425a2.rt11.ml channel. Alert also on a first-seen destination domain contacted by node or python within 60 seconds of a package manager starting. We have not measured that window's false positive rate in a production CI fleet, so treat it as a starting point.
npm ci --ignore-scripts removes the install-script surface entirely and is worth enabling in CI. It does not remove import-time execution, so it protects the build and not the runtime.
Summary
Typosquatting works because package resolution is exact and human recall is not. The techniques are mechanical, which is what makes them detectable: omission, transposition, insertion, separator drops, homoglyphs and scope drops are all computable against a known-good name list, and every real case above falls out of a distance check at threshold 2 or less. Starjacking and brandjacking mean the package page tells you nothing you can trust. Registry defences help asymmetrically, since PyPI normalization kills separator squats outright while npm leaves that space open, and both registries can only remove a package after somebody notices it. Detection that survives all of it watches what happens at install time rather than what the package is called.
Technique | ID | Where it shows up |
|---|---|---|
Compromise Software Dependencies and Development Tools | T1195.001 | The squatted package itself |
Command and Scripting Interpreter: JavaScript | T1059.007 | npm lifecycle script payload |
Command and Scripting Interpreter: Python | T1059.006 | setup.py or import-time payload |
System Information Discovery | T1082 | hostname, username, home directory |
System Network Configuration Discovery | T1016 | interface IPs, configured DNS servers |
Unsecured Credentials: Credentials In Files | T1552.001 | SSH and GPG key theft, environment variables |
Application Layer Protocol: DNS | T1071.004 | hex-encoded subdomain exfiltration |
Collect these on build agents and developer endpoints before you need them.
process_creation with ParentImage and CommandLine (npm, node, pip, python, uv)
dns_query from build agents, with the full query name retained
network_connection from node and python processes, destination domain and first-seen flag
file_event on ~/.ssh, ~/.gnupg and .npmrc read by a non-interactive process
lockfile diff artifact per commit: package names added, not versionsBrowse the catalog for install-time and package manager coverage at package rules, or start from the Linux rules.
Related articles

What Is a Build Pipeline Compromise?
SolarWinds.Orion.Core.BusinessLayer.dll shipped with a valid SolarWinds Authenticode signature and a backdoor inside the same assembly. The git history was clean. The certificate was the real one.…
2026-06-149 min read

What is CI/CD security?
On 14 and 15 March 2025 the tags v1 through v45.0.7 of tj-actions/changed-files were repointed to commit 0e58ed8. The injected updateFeatures code read secrets out of the runner process and printed…
2026-05-2712 min read

What Is Dependency Confusion?
pip install --extra-index-url https://pypi.internal.example.com/simple internal-auth-lib queries two indexes at once. pip's own documentation says what happens next. There is no priority in the…
2026-05-1111 min read