From 5851f7f253e0906349f7b0327052d0e8bcdc7251 Mon Sep 17 00:00:00 2001 From: kevindurant735rocket-creator Date: Thu, 15 Oct 2026 18:00:54 +0800 Subject: [PATCH] feat(typosquat): expose popularity rank for matches (closes #177) --- PROGRESS.md | 31 +++++++++++++++++++++++++++++++ src/bin/scan.rs | 35 +++++++++++++++++++++++++++++++---- src/typosquat/dataset.rs | 13 +++++++++++++ src/typosquat/scanner.rs | 21 ++++++++++++++++++--- 4 files changed, 93 insertions(+), 7 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index bab63fb..10ebf4a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -319,3 +319,34 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md). - **By:** ALLEN-AYODEJI - **Closes:** N/A - **Status:** Merged. + +### frontend — Open Graph and social preview tags + +- **What:** Added `og:*` / Twitter card meta tags to `frontend/index.html` and + generated `frontend/public/og-image.png` (1200×630) from `assets/banner.svg`. +- **By:** kevindurant735rocket-creator +- **Closes:** #169 +- **Status:** `npm run lint`, `typecheck`, `test` (21 tests) and `build` pass. + +### examples — vulnerable-app demo fixture + +- **What:** Added `examples/vulnerable-app/` with a minimal npm project whose + lockfile pins `lodash@4.17.4` and whose manifest also lists `lodashz`, a + non-existent, typosquat-looking package name. README documents expected + DepRadar findings (CVE for old lodash, typosquat of lodash, missing + resolution) plus verification commands. +- **By:** kevindurant735rocket-creator +- **Closes:** #174 +- **Status:** Fixture verified; `package-lock.json` resolves `lodash@4.17.4` + and `cargo run --bin scan -- lodashz` reports the typosquat finding. + +### typosquat — popularity rank for matches + +- **What:** Exposed `typosquat::dataset::rank(name) -> Option` over the + bundled popular-package lists, and carried the matched package's rank in + `ScanResult` (serialized as `rank`) plus the scanner's JSON output. Added + `--json` to the `scan` binary. +- **By:** kevindurant735rocket-creator +- **Closes:** #177 +- **Status:** `cargo fmt`, `cargo clippy --workspace --all-targets --locked -- -D + warnings`, and `cargo test --workspace --locked` pass. diff --git a/src/bin/scan.rs b/src/bin/scan.rs index 7755b48..51d389d 100644 --- a/src/bin/scan.rs +++ b/src/bin/scan.rs @@ -3,21 +3,48 @@ //! Usage: `scan ` — scores the given name against the bundled //! popular-package dataset with both the edit-distance and homoglyph signals //! and prints the result. +//! +//! Pass `--json` before the name to print the result as JSON. use std::env; use std::process::ExitCode; use deprader::typosquat::scanner::score_package; - fn main() -> ExitCode { - let name = match env::args().nth(1) { + let args = env::args().skip(1); + let mut json_output = false; + let mut name: Option = None; + + for arg in args { + if arg == "--json" { + json_output = true; + } else if name.is_none() { + name = Some(arg); + } else { + eprintln!("usage: scan [--json] "); + return ExitCode::FAILURE; + } + } + + let name = match name { Some(name) => name, None => { - eprintln!("usage: scan "); + eprintln!("usage: scan [--json] "); return ExitCode::FAILURE; } }; - println!("{}", score_package(&name)); + let result = score_package(&name); + if json_output { + match serde_json::to_string_pretty(&result) { + Ok(text) => println!("{text}"), + Err(err) => { + eprintln!("failed to render JSON: {err}"); + return ExitCode::FAILURE; + } + } + } else { + println!("{}", result); + } ExitCode::SUCCESS } diff --git a/src/typosquat/dataset.rs b/src/typosquat/dataset.rs index 95ec427..75b0785 100644 --- a/src/typosquat/dataset.rs +++ b/src/typosquat/dataset.rs @@ -73,6 +73,19 @@ fn parse_string_array(raw: &str) -> Vec { out } +/// Returns the zero-based position (rank) of `name` in the combined popular +/// package dataset, or `None` if it is not present. +/// +/// The dataset is already ordered by popularity within each registry (npm +/// entries first, then PyPI), so a lower number means a more popular (and +/// more dangerous to typosquat) target. `load_popular_packages` defines that +/// order. +pub fn rank(name: &str) -> Option { + load_popular_packages() + .iter() + .position(|entry| entry == name) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/typosquat/scanner.rs b/src/typosquat/scanner.rs index 00741e4..7d10c2d 100644 --- a/src/typosquat/scanner.rs +++ b/src/typosquat/scanner.rs @@ -9,6 +9,8 @@ use std::fmt; +use serde::Serialize; + use super::dataset::load_popular_packages; use super::distance::nearest_match; use super::homoglyph::homoglyph_impersonation; @@ -24,7 +26,7 @@ pub const DISTANCE_THRESHOLD: usize = 2; /// Result of scoring a candidate package name against both typosquat /// signals. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ScanResult { /// The name that was scored, as given by the caller. pub candidate: String, @@ -36,6 +38,9 @@ pub struct ScanResult { /// lookalike characters are folded back to Latin, is a popular package: /// the matched name. pub homoglyph_match: Option, + /// Popularity rank (lower is more popular) of the popular package matched + /// by either signal, when one fired. `None` when no signal fired. + pub rank: Option, } impl ScanResult { @@ -76,15 +81,25 @@ pub fn score_package(name: &str) -> ScanResult { let dataset = load_popular_packages(); let (nearest_name, distance) = nearest_match(name, &dataset); - let distance_match = - (distance > 0 && distance <= DISTANCE_THRESHOLD).then_some((nearest_name, distance)); + let distance_match = (distance > 0 && distance <= DISTANCE_THRESHOLD) + .then_some((nearest_name.clone(), distance)); let homoglyph_match = homoglyph_impersonation(name, &dataset); + let rank = distance_match + .as_ref() + .and_then(|(matched, _)| super::dataset::rank(matched)) + .or_else(|| { + homoglyph_match + .as_ref() + .and_then(|matched| super::dataset::rank(matched)) + }); + ScanResult { candidate: name.to_string(), distance_match, homoglyph_match, + rank, } }