//! 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, /// Recent downloads, whatever window the registry reports. pub downloads: Option, } /// 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 = a.chars().collect(); let b: Vec = 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 = (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 { 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, dl: Option) -> 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"); } }