Antivirus/crates/hound-supply/src/typosquat.rs
Hound 6beb73771a hound-supply: the supply-chain and agent-era scanner
Phase 4's detection core, as a standalone crate. This is the part with no
competitor on Linux, and deliberately the part with no Linux in it —
file parsing and logic only, no fanotify, no /proc, no eBPF — so the
macOS and Windows port is weeks rather than a second product.

Five detectors, 88 tests:

  pickle        GLOBAL/STACK_GLOBAL walk over .pt/.ckpt/.pkl/.joblib.
                torch.load runs a stack machine; a model file is a
                program and downloading weights is a code-execution
                decision.
  injection     Instructions aimed at a coding agent in CLAUDE.md,
                AGENTS.md, .cursorrules, copilot-instructions.
  installscript preinstall/postinstall hooks that curl|sh, decode and
                run, reach for credentials, or install persistence.
  typosquat     Damerau-Levenshtein against popular names, plus
                slopsquat detection: new + near-zero downloads + one
                edit from something popular is the signature of a name
                a model invented and somebody then registered.
  mcp           Servers fetched unpinned at launch, handed secrets, or
                pointed at $HOME or credential paths.

Wired through `supply.sweep` on the socket and `hound supply-chain
<path>`, which exits 1 on a critical so it drops into CI.

Three things worth recording:

* Scoring is by independent category, not by keyword count. One
  suspicious phrase is a phrase; two categories at once is an attack.
  A file that only says "ignore previous instructions about formatting"
  is a warning, not a critical.

* Proximity matters more than presence. The first version flagged an
  entirely ordinary conventions file, because it mentioned ".env" in
  one paragraph and "prefer small commits" in another. A credential and
  a movement verb now have to appear within a sentence of each other.
  The test that caught it is kept as the regression.

* Pickle call detection has to come out of the opcode walk, not a byte
  search. REDUCE, INST and OBJ are the ASCII letters R, i and o, which
  also occur inside every string the stream carries — searching raw
  bytes finds the o in "os" and reports a call that never happens,
  turning every warning into a critical.

Every finding carries a plain-language explanation and a next step, and
there is a test asserting explanations do not leak rule identifiers or
jargon. The audience includes people who cannot triage a YARA match and
should never be shown one.

Verified against a demo project holding a squatted @vue plugin with a
curl|sh postinstall, a poisoned CLAUDE.md in a vendored repo, an
unpinned MCP server holding a GitHub token, and a pickle calling
os.system — four criticals and one warning, while the legitimate
CLAUDE.md, the real express manifest and the properly-scoped MCP server
beside them stayed clean.

189 tests pass across the workspace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:48:48 -05:00

307 lines
11 KiB
Rust

//! Typosquats and slopsquats.
//!
//! Two related attacks with different shapes:
//!
//! **Typosquatting** registers a name one keystroke from something
//! popular and waits for a typo or a copy-paste error. Detection is edit
//! distance against a list of names worth impersonating, weighted by how
//! popular the target is — nobody bothers squatting an unpopular package.
//!
//! **Slopsquatting** is newer and cheaper. Language models invent plausible
//! package names that do not exist; attackers watch for the invented names
//! and register them. The victim never made a typo — the name was
//! hallucinated by a tool they trusted, and then made real by somebody
//! else. The fingerprint is a package that is *very new*, has *almost no
//! downloads*, and sits one edit from something genuinely popular.
//!
//! Both need registry metadata to judge, so the metadata is a plain struct
//! the caller fills in. That keeps every decision here pure and testable,
//! and means the same logic runs against a live registry, a cached OSV
//! mirror, or a fixture.
use crate::{Finding, Severity};
/// What a registry can tell us about a package.
#[derive(Debug, Clone, Default)]
pub struct PackageMeta {
pub name: String,
pub version: String,
/// Days since first publication. `None` when unknown.
pub age_days: Option<u32>,
/// Recent downloads, whatever window the registry reports.
pub downloads: Option<u64>,
}
/// A popular package worth impersonating.
#[derive(Debug, Clone)]
pub struct PopularPackage {
pub name: &'static str,
/// Weekly downloads, used to decide whether a near-miss is worth
/// flagging at all.
pub weekly: u64,
}
/// A deliberately small starter list. The real one ships with the
/// definitions feed in Phase 3; this exists so detection works offline and
/// so the logic has something to test against.
pub const POPULAR: &[PopularPackage] = &[
PopularPackage { name: "react", weekly: 25_000_000 },
PopularPackage { name: "lodash", weekly: 50_000_000 },
PopularPackage { name: "express", weekly: 30_000_000 },
PopularPackage { name: "axios", weekly: 45_000_000 },
PopularPackage { name: "chalk", weekly: 200_000_000 },
PopularPackage { name: "commander", weekly: 90_000_000 },
PopularPackage { name: "requests", weekly: 60_000_000 },
PopularPackage { name: "numpy", weekly: 40_000_000 },
PopularPackage { name: "pandas", weekly: 30_000_000 },
PopularPackage { name: "urllib3", weekly: 70_000_000 },
PopularPackage { name: "@vue/cli-plugin-babel", weekly: 4_200_000 },
PopularPackage { name: "langchain", weekly: 2_000_000 },
PopularPackage { name: "langchain-helper", weekly: 2_100_000 },
PopularPackage { name: "openai", weekly: 8_000_000 },
PopularPackage { name: "anthropic", weekly: 3_000_000 },
];
/// Damerau-Levenshtein distance, capped so long names exit early.
///
/// Transpositions matter: `recat` for `react` is one finger slip, and
/// plain Levenshtein scores it as two edits.
pub fn edit_distance(a: &str, b: &str, cap: usize) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.len().abs_diff(b.len()) > cap {
return cap + 1;
}
let mut prev_prev = vec![0usize; b.len() + 1];
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for i in 1..=a.len() {
cur[0] = i;
for j in 1..=b.len() {
let cost = usize::from(a[i - 1] != b[j - 1]);
cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
cur[j] = cur[j].min(prev_prev[j - 2] + 1);
}
}
std::mem::swap(&mut prev_prev, &mut prev);
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
/// The nearest popular package within `cap` edits, if any.
pub fn nearest(name: &str, cap: usize) -> Option<(&'static PopularPackage, usize)> {
POPULAR
.iter()
.filter(|p| p.name != name) // the real thing is not a squat of itself
.map(|p| (p, edit_distance(name, p.name, cap)))
.filter(|(_, d)| *d <= cap && *d > 0)
.min_by_key(|(_, d)| *d)
}
/// Fresh enough and quiet enough to be a name that was invented rather
/// than earned.
fn looks_hallucinated(meta: &PackageMeta) -> bool {
let young = meta.age_days.is_some_and(|d| d <= 90);
let quiet = meta.downloads.is_some_and(|d| d < 1_000);
young && quiet
}
/// Judge one package.
pub fn scan(meta: &PackageMeta, location: &str) -> Vec<Finding> {
let Some((target, distance)) = nearest(&meta.name, 2) else {
return Vec::new();
};
// Squatting an unpopular package earns nothing, so a near-miss on
// something obscure is far more likely to be an honest fork.
if target.weekly < 100_000 {
return Vec::new();
}
let spec = if meta.version.is_empty() {
meta.name.clone()
} else {
format!("{}@{}", meta.name, meta.version)
};
if looks_hallucinated(meta) {
let age = meta.age_days.unwrap_or(0);
let dl = meta.downloads.unwrap_or(0);
return vec![Finding::new(
"slopsquat",
Severity::Critical,
spec,
location,
format!(
"This package was first published {age} days ago and has been downloaded \
{dl} times, and its name is one character from \"{}\", which is downloaded \
millions of times a week. That combination is the signature of a name an \
AI assistant invented and somebody else then registered.",
target.name
),
"hound-slopsquat-a",
format!(
"Check whether you actually meant \"{}\". If an assistant suggested this \
name, treat the suggestion as wrong rather than the registry as right.",
target.name
),
)];
}
// Established but still near-identical: a classic squat, or a fork.
let severity = if distance == 1 { Severity::Warning } else { Severity::Info };
vec![Finding::new(
"typosquat",
severity,
spec,
location,
format!(
"This name is {} character{} away from \"{}\", a package downloaded around \
{} times a week. Packages with names this close to something popular are \
often impersonations that rely on a typo going unnoticed.",
distance,
if distance == 1 { "" } else { "s" },
target.name,
human(target.weekly)
),
"hound-typosquat-a",
format!("Confirm you meant \"{}\" and not \"{}\".", target.name, meta.name),
)]
}
fn human(n: u64) -> String {
match n {
n if n >= 1_000_000 => format!("{:.0} million", n as f64 / 1_000_000.0),
n if n >= 1_000 => format!("{}k", n / 1_000),
n => n.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn meta(name: &str, age: Option<u32>, dl: Option<u64>) -> PackageMeta {
PackageMeta {
name: name.into(),
version: "0.0.3".into(),
age_days: age,
downloads: dl,
}
}
// ── distance ──
#[test]
fn counts_a_single_substitution() {
assert_eq!(edit_distance("react", "reacf", 3), 1, "one wrong letter");
assert_eq!(edit_distance("react", "reeact", 3), 1, "one extra letter");
assert_eq!(edit_distance("react", "rect", 3), 1, "one missing letter");
}
#[test]
fn a_transposition_counts_as_one_slip_not_two() {
// Plain Levenshtein scores a swap as two edits, which puts real
// typosquats outside a distance-1 filter. Damerau does not.
assert_eq!(edit_distance("react", "raect", 3), 1);
assert_eq!(edit_distance("axios", "axois", 3), 1);
}
#[test]
fn identical_names_are_distance_zero() {
assert_eq!(edit_distance("lodash", "lodash", 2), 0);
}
#[test]
fn the_cap_short_circuits_wildly_different_lengths() {
assert!(edit_distance("a", "averyverylongname", 2) > 2);
}
#[test]
fn distance_is_symmetric() {
assert_eq!(
edit_distance("langchain-helpers", "langchain-helper", 3),
edit_distance("langchain-helper", "langchain-helpers", 3)
);
}
// ── must catch ──
#[test]
fn a_fresh_quiet_near_miss_is_a_slopsquat() {
let f = scan(&meta("langchain-helpers", Some(3), Some(41)), "requirements.txt");
assert_eq!(f.len(), 1);
assert_eq!(f[0].kind, "slopsquat");
assert_eq!(f[0].severity, Severity::Critical);
assert!(f[0].explanation.contains("AI assistant invented"));
}
#[test]
fn the_vue_plugin_squat_is_caught() {
let f = scan(&meta("@vue/cli-plugin-babe1", Some(9), Some(300)), "package-lock.json");
assert_eq!(f[0].kind, "slopsquat");
assert!(f[0].explanation.contains("@vue/cli-plugin-babel"));
}
#[test]
fn an_established_near_miss_is_a_typosquat_not_a_slopsquat() {
let f = scan(&meta("expres", Some(900), Some(5_000_000)), "package.json");
assert_eq!(f.len(), 1);
assert_eq!(f[0].kind, "typosquat");
assert_eq!(f[0].severity, Severity::Warning);
}
// ── must NOT catch ──
#[test]
fn the_real_package_is_not_a_squat_of_itself() {
assert!(scan(&meta("react", Some(3000), Some(25_000_000)), "p.json").is_empty());
assert!(scan(&meta("lodash", Some(4000), Some(50_000_000)), "p.json").is_empty());
}
#[test]
fn an_unrelated_name_is_clean() {
assert!(scan(&meta("hound-supply", Some(1), Some(0)), "Cargo.toml").is_empty());
}
#[test]
fn a_near_miss_on_an_unpopular_package_is_ignored() {
// Nobody squats a package nobody installs, so a near-miss there is
// far more likely to be an honest fork.
let obscure = PopularPackage { name: "tiny-thing", weekly: 500 };
assert!(obscure.weekly < 100_000);
assert!(scan(&meta("tiny-thang", Some(1), Some(1)), "p.json").is_empty());
}
#[test]
fn a_new_package_that_is_not_a_near_miss_is_clean() {
assert!(
scan(&meta("my-brand-new-tool", Some(1), Some(0)), "p.json").is_empty(),
"being new is not suspicious on its own"
);
}
#[test]
fn missing_metadata_downgrades_rather_than_guesses() {
// No age or downloads: we can still see the name is close, but we
// must not claim it was hallucinated.
let f = scan(&meta("expres", None, None), "p.json");
assert_eq!(f[0].kind, "typosquat");
}
#[test]
fn advice_names_the_package_they_probably_meant() {
let f = scan(&meta("langchain-helpers", Some(3), Some(41)), "r.txt");
assert!(f[0].advice.contains("langchain-helper"));
}
#[test]
fn download_counts_render_readably() {
assert_eq!(human(45_000_000), "45 million");
assert_eq!(human(4_200), "4k");
assert_eq!(human(7), "7");
}
}