Supply Chain Security

What Is Typosquatting in Package Repositories?

HuntRule Team · · 10 min read

Two nearly identical hanging shop signs in fog, one letter different, lit from below
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. mongose for mongoose. One character gone.

  • Transposition. ffmepg for ffmpeg. Two adjacent characters swapped, which is the single most common typing error and the hardest to see when proofreading.

  • Insertion. sqliter for sqlite, and python3-dateutil for python-dateutil on 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 .js as a file extension rather than part of a package name.

  • Separator drop or swap. crossenv for cross-env, nodesass for node-sass. On npm, foo-bar, foo_bar and foo.bar are three distinct packages.

  • Homoglyphs. In December 2019 PyPI removed jeIlyfish, which is jellyfish with 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, pulled jeIlyfish in 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 @azure scoped name and republished it unscoped. npm install core-tracing instead of npm 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.new

That 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: medium

What 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 versions

Browse the catalog for install-time and package manager coverage at package rules, or start from the Linux rules.

Related articles