///|
pub(all) struct TyposquatResult {
suspect : String
similar_to : String
distance : Int
attack_type : String
}
///|
pub fn detect_typosquat(
package_name : String,
known_packages : Array[String],
) -> Array[TyposquatResult] {
let results : Array[TyposquatResult] = []
for i = 0; i < known_packages.length(); i = i + 1 {
let known = known_packages[i]
if package_name == known {
continue i + 1
}
if has_separator_trick(package_name, known) {
results.push(TyposquatResult::{
suspect: package_name,
similar_to: known,
distance: 0,
attack_type: "separator-swap",
})
} else {
let dist = levenshtein(package_name, known)
if dist > 0 && dist <= 2 {
results.push(TyposquatResult::{
suspect: package_name,
similar_to: known,
distance: dist,
attack_type: classify_attack(package_name, known),
})
}
}
}
results
}
///|
/// Stronger variant that also flags look-alike Unicode / ASCII substitution
/// (`1` vs `l`, `0` vs `o`, `rn` vs `m`) which classic Levenshtein misses.
pub fn detect_typosquat_strict(
package_name : String,
known_packages : Array[String],
) -> Array[TyposquatResult] {
let results : Array[TyposquatResult] = []
for i = 0; i < known_packages.length(); i = i + 1 {
let known = known_packages[i]
if package_name == known {
continue i + 1
}
if has_separator_trick(package_name, known) {
results.push(TyposquatResult::{
suspect: package_name,
similar_to: known,
distance: 0,
attack_type: "separator-swap",
})
continue i + 1
}
if has_homoglyph_substitution(package_name, known) {
results.push(TyposquatResult::{
suspect: package_name,
similar_to: known,
distance: 1,
attack_type: "homoglyph",
})
continue i + 1
}
let dist = levenshtein(package_name, known)
if dist > 0 && dist <= 2 {
results.push(TyposquatResult::{
suspect: package_name,
similar_to: known,
distance: dist,
attack_type: classify_attack(package_name, known),
})
}
}
results
}
///|
pub fn batch_typosquat_check(
packages : Array[String],
known_good : Array[String],
) -> Array[TyposquatResult] {
let all_results : Array[TyposquatResult] = []
for i = 0; i < packages.length(); i = i + 1 {
let hits = detect_typosquat(packages[i], known_good)
for j = 0; j < hits.length(); j = j + 1 {
all_results.push(hits[j])
}
}
all_results
}
///|
/// Aggregate batch detector: per-package hit counts grouped by suspect.
pub fn typosquat_summary(
packages : Array[String],
known_good : Array[String],
) -> Array[@report.TyposquatSummaryEntry] {
let all = batch_typosquat_check(packages, known_good)
let result : Array[@report.TyposquatSummaryEntry] = []
// Collect every distinct suspect we saw at least one hit for.
let suspects : Array[String] = []
let mut i = 0
while i < all.length() {
let s = all[i].suspect
let mut seen = false
let mut j = 0
while j < suspects.length() {
if suspects[j] == s {
seen = true
break
}
j = j + 1
}
if !seen {
suspects.push(s)
}
i = i + 1
}
// For each suspect, count how many known packages it imitates.
let mut k = 0
while k < suspects.length() {
let suspect = suspects[k]
let mut hit_count = 0
let mut first_similar_to = ""
let mut first_attack = ""
let mut m = 0
while m < all.length() {
if all[m].suspect == suspect {
hit_count = hit_count + 1
if first_similar_to == "" {
first_similar_to = all[m].similar_to
first_attack = all[m].attack_type
}
}
m = m + 1
}
result.push(@report.TyposquatSummaryEntry::{
suspect,
hit_count,
first_similar_to,
first_attack,
})
k = k + 1
}
result
}
///|
fn levenshtein(a : String, b : String) -> Int {
let m = a.length()
let n = b.length()
if m == 0 {
return n
}
if n == 0 {
return m
}
let prev : Array[Int] = Array::make(n + 1, 0)
let curr : Array[Int] = Array::make(n + 1, 0)
for j = 0; j <= n; j = j + 1 {
prev[j] = j
}
for i = 1; i <= m; i = i + 1 {
curr[0] = i
for j = 1; j <= n; j = j + 1 {
let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 }
let del = prev[j] + 1
let ins = curr[j - 1] + 1
let sub = prev[j - 1] + cost
curr[j] = min3(del, ins, sub)
}
for j = 0; j <= n; j = j + 1 {
prev[j] = curr[j]
}
}
prev[n]
}
///|
fn min3(a : Int, b : Int, c : Int) -> Int {
let ab = if a < b { a } else { b }
if ab < c {
ab
} else {
c
}
}
///|
fn classify_attack(suspect : String, known : String) -> String {
if suspect.length() == known.length() {
"character-swap"
} else if suspect.length() > known.length() {
"extra-character"
} else {
"missing-character"
}
}
///|
fn has_separator_trick(suspect : String, known : String) -> Bool {
let s = normalize_separators(suspect)
let k = normalize_separators(known)
s == k && suspect != known
}
///|
fn normalize_separators(name : String) -> String {
let mut result = ""
for i = 0; i < name.length(); i = i + 1 {
let c = name[i]
if c == 45 || c == 95 || c == 46 {
result = result + "-"
} else {
result = result + c.unsafe_to_char().to_string()
}
}
result
}
///|
/// Detect common look-alike substitutions that survive separator normalization
/// but differ character-by-character. The set covers the typical "1odash"
/// / "reactt" / "expres" style tricks published-package attackers use.
fn has_homoglyph_substitution(suspect : String, known : String) -> Bool {
if suspect.length() != known.length() {
return false
}
let mut diffs = 0
let mut i = 0
while i < suspect.length() {
let sc = suspect[i].to_int()
let kc = known[i].to_int()
if sc != kc {
if !are_homoglyphs(sc, kc) {
return false
}
diffs = diffs + 1
}
i = i + 1
}
diffs > 0 && diffs <= 3
}
///|
fn are_homoglyphs(a : Int, b : Int) -> Bool {
// Common look-alike pairs that copy-paste through code review.
if a == b {
return true
}
pairs().iter().any(fn(p) { p == (a, b) })
}
///|
/// Hard-coded substitution table. Returning `(Int, Int)` pairs keeps the
/// MoonBit type signature simple — the caller matches either order.
fn pairs() -> Array[(Int, Int)] {
[
(49, 108), // 1 ↔ l
(108, 49),
(79, 48),
(111, 48), // O/o ↔ 0
(48, 79),
(48, 111),
(73, 108),
(105, 108), // I/i ↔ l
(108, 73),
(108, 105),
(114, 110), // rn ↔ m via glyph combo
(110, 109),
(109, 110),
(115, 53), // s ↔ 5
(53, 115),
(83, 53),
(53, 83),
(98, 54), // B ↔ 6 (when both used)
(54, 98),
(66, 54),
(54, 66),
]
}