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>
307 lines
11 KiB
Rust
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");
|
|
}
|
|
}
|