Skip to main content

deprot_core/
typosquat.rs

1//! Typosquat / dependency-confusion detection.
2//!
3//! A pure check: each dependency name is compared by edit distance against a bundled list of very
4//! popular packages. A name that is 1–2 edits away from a popular package — but is not itself that
5//! package — is a classic typosquat (`lodahs` for `lodash`, `expres` for `express`). No network,
6//! deterministic, testable.
7
8use crate::facts::Ecosystem;
9
10/// A dependency name that looks like a typo of a popular package.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Suspect {
13    /// The suspicious dependency name.
14    pub name: String,
15    /// The popular package it closely resembles.
16    pub nearest: String,
17    /// Edit distance between them.
18    pub distance: usize,
19}
20
21/// Popular npm packages (abbreviated but representative).
22const POPULAR_NPM: &[&str] = &[
23    "react",
24    "lodash",
25    "express",
26    "chalk",
27    "axios",
28    "webpack",
29    "commander",
30    "request",
31    "moment",
32    "debug",
33    "async",
34    "bluebird",
35    "underscore",
36    "jquery",
37    "typescript",
38    "eslint",
39    "prettier",
40    "vue",
41    "angular",
42    "next",
43    "dotenv",
44    "uuid",
45    "cors",
46    "body-parser",
47    "mongoose",
48    "redux",
49    "jest",
50    "mocha",
51    "yargs",
52    "glob",
53    "semver",
54    "colors",
55    "node-fetch",
56    "ws",
57    "socket.io",
58    "rxjs",
59];
60
61/// Popular crates.io crates (abbreviated but representative).
62const POPULAR_CARGO: &[&str] = &[
63    "serde",
64    "tokio",
65    "rand",
66    "log",
67    "clap",
68    "anyhow",
69    "thiserror",
70    "regex",
71    "syn",
72    "quote",
73    "libc",
74    "reqwest",
75    "hyper",
76    "futures",
77    "chrono",
78    "itertools",
79    "bytes",
80    "tracing",
81    "async-trait",
82    "rayon",
83    "base64",
84    "url",
85    "uuid",
86    "once_cell",
87    "lazy_static",
88    "bitflags",
89    "cfg-if",
90    "proc-macro2",
91    "serde_json",
92];
93
94/// Popular PyPI packages (abbreviated but representative).
95const POPULAR_PYPI: &[&str] = &[
96    "requests",
97    "numpy",
98    "pandas",
99    "flask",
100    "django",
101    "pytest",
102    "pillow",
103    "scipy",
104    "boto3",
105    "urllib3",
106    "click",
107    "jinja2",
108    "setuptools",
109    "wheel",
110    "six",
111    "certifi",
112    "idna",
113    "cryptography",
114];
115
116/// Popular Go modules (abbreviated but representative).
117const POPULAR_GO: &[&str] = &[
118    "github.com/gin-gonic/gin",
119    "github.com/gorilla/mux",
120    "github.com/stretchr/testify",
121    "github.com/spf13/cobra",
122    "github.com/spf13/viper",
123    "github.com/sirupsen/logrus",
124    "github.com/pkg/errors",
125    "github.com/google/uuid",
126    "github.com/prometheus/client_golang",
127    "github.com/aws/aws-sdk-go",
128    "golang.org/x/crypto",
129    "golang.org/x/net",
130    "golang.org/x/sys",
131    "google.golang.org/grpc",
132    "google.golang.org/protobuf",
133    "go.uber.org/zap",
134    "gorm.io/gorm",
135];
136
137const POPULAR_RUBY: &[&str] = &[
138    "rails",
139    "rake",
140    "bundler",
141    "rspec",
142    "puma",
143    "sinatra",
144    "nokogiri",
145    "devise",
146    "sidekiq",
147    "pg",
148    "mysql2",
149    "redis",
150    "faraday",
151    "rubocop",
152    "activerecord",
153    "actionpack",
154    "activesupport",
155    "json",
156    "minitest",
157    "capybara",
158    "webmock",
159    "kaminari",
160    "jbuilder",
161    "dotenv",
162    "httparty",
163    "pry",
164];
165
166const POPULAR_PHP: &[&str] = &[
167    "symfony/console",
168    "symfony/http-foundation",
169    "guzzlehttp/guzzle",
170    "monolog/monolog",
171    "laravel/framework",
172    "phpunit/phpunit",
173    "doctrine/orm",
174    "psr/log",
175    "twig/twig",
176    "ramsey/uuid",
177    "league/flysystem",
178    "fakerphp/faker",
179    "phpstan/phpstan",
180    "nikic/php-parser",
181];
182
183const POPULAR_MAVEN: &[&str] = &[
184    "com.google.guava:guava",
185    "org.apache.commons:commons-lang3",
186    "com.fasterxml.jackson.core:jackson-databind",
187    "org.slf4j:slf4j-api",
188    "junit:junit",
189    "org.springframework:spring-core",
190    "org.springframework.boot:spring-boot",
191    "com.squareup.okhttp3:okhttp",
192    "org.apache.logging.log4j:log4j-core",
193    "commons-io:commons-io",
194    "org.projectlombok:lombok",
195    "org.mockito:mockito-core",
196    "ch.qos.logback:logback-classic",
197];
198
199const POPULAR_NUGET: &[&str] = &[
200    "Newtonsoft.Json",
201    "Serilog",
202    "AutoMapper",
203    "Dapper",
204    "xunit",
205    "NUnit",
206    "Moq",
207    "FluentValidation",
208    "Polly",
209    "MediatR",
210    "Swashbuckle.AspNetCore",
211    "Microsoft.EntityFrameworkCore",
212    "NLog",
213    "RestSharp",
214    "FluentAssertions",
215];
216
217fn popular_for(eco: Ecosystem) -> &'static [&'static str] {
218    match eco {
219        Ecosystem::Npm => POPULAR_NPM,
220        Ecosystem::Cargo => POPULAR_CARGO,
221        Ecosystem::PyPI => POPULAR_PYPI,
222        Ecosystem::Go => POPULAR_GO,
223        Ecosystem::Ruby => POPULAR_RUBY,
224        Ecosystem::Php => POPULAR_PHP,
225        Ecosystem::Maven => POPULAR_MAVEN,
226        Ecosystem::NuGet => POPULAR_NUGET,
227    }
228}
229
230/// Levenshtein edit distance between two strings.
231fn edit_distance(a: &str, b: &str) -> usize {
232    let a: Vec<char> = a.chars().collect();
233    let b: Vec<char> = b.chars().collect();
234    let mut prev: Vec<usize> = (0..=b.len()).collect();
235    let mut cur = vec![0usize; b.len() + 1];
236    for (i, &ca) in a.iter().enumerate() {
237        cur[0] = i + 1;
238        for (j, &cb) in b.iter().enumerate() {
239            let cost = if ca == cb { 0 } else { 1 };
240            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
241        }
242        std::mem::swap(&mut prev, &mut cur);
243    }
244    prev[b.len()]
245}
246
247/// Scan dependency names for likely typosquats of popular packages in the same ecosystem.
248pub fn scan<'a>(names: impl IntoIterator<Item = &'a str>, ecosystem: Ecosystem) -> Vec<Suspect> {
249    let popular = popular_for(ecosystem);
250    let mut out = Vec::new();
251    for name in names {
252        let lname = name.to_lowercase();
253        // A name that IS popular is fine.
254        if popular.iter().any(|p| *p == lname) {
255            continue;
256        }
257        // Find the closest popular package.
258        if let Some((nearest, dist)) = popular
259            .iter()
260            .map(|p| (*p, edit_distance(&lname, p)))
261            .min_by_key(|(_, d)| *d)
262        {
263            // 1–2 edits from a popular name, and long enough that a small edit is meaningful.
264            if (1..=2).contains(&dist) && lname.len() >= 4 {
265                out.push(Suspect {
266                    name: name.to_string(),
267                    nearest: nearest.to_string(),
268                    distance: dist,
269                });
270            }
271        }
272    }
273    out
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn flags_close_typosquats() {
282        let s = scan(["lodahs", "expres", "reqwests"], Ecosystem::Npm);
283        assert!(s
284            .iter()
285            .any(|x| x.name == "lodahs" && x.nearest == "lodash"));
286        assert!(s
287            .iter()
288            .any(|x| x.name == "expres" && x.nearest == "express"));
289    }
290
291    #[test]
292    fn does_not_flag_exact_popular_names() {
293        assert!(scan(["lodash", "express", "react"], Ecosystem::Npm).is_empty());
294    }
295
296    #[test]
297    fn does_not_flag_unrelated_names() {
298        // A genuinely different, distant name should not trip the 1–2 edit window.
299        assert!(scan(["my-company-internal-widget"], Ecosystem::Npm).is_empty());
300    }
301
302    #[test]
303    fn cargo_ecosystem_uses_crate_list() {
304        let s = scan(["tokjo", "serde"], Ecosystem::Cargo);
305        assert!(s.iter().any(|x| x.name == "tokjo" && x.nearest == "tokio"));
306        // "serde" is itself popular → not flagged.
307        assert!(!s.iter().any(|x| x.name == "serde"));
308    }
309}