use anyhow::{Context, Result, bail};
use colored::*;
use serde::Deserialize;
use std::path::Path;
use std::process::Command;
use strsim::levenshtein;
const POPULAR_CRATES: &[&str] = &[
"actix-web", "anyhow", "async-trait", "axum", "base64", "bytes", "chrono",
"clap", "colored", "console", "crossbeam", "csv", "diesel", "dirs",
"env_logger", "futures", "git2", "glob", "hex", "home", "http", "hyper",
"indicatif", "itertools", "lazy_static", "log", "mime", "once_cell",
"openssl", "parking_lot", "proc-macro2", "quote", "rand", "rayon",
"redis", "regex", "reqwest", "ring", "rocket", "rusqlite", "rustls",
"serde", "serde_json", "sha2", "similar", "sqlx", "strsim", "syn",
"tempfile", "thiserror", "tokio", "toml", "tower", "tracing", "url",
"uuid", "walkdir", "warp", "which",
];
#[derive(Deserialize)]
struct CargoLock {
package: Vec<Package>,
}
#[derive(Deserialize)]
struct Package {
name: String,
version: String,
}
pub fn run_audit(project_path: &Path, typo_only: bool, all: bool) -> Result<()> {
if all {
run_audit_all_worktrees(project_path, typo_only)
} else {
run_audit_single(project_path, typo_only)
}
}
fn run_audit_all_worktrees(repo_path: &Path, typo_only: bool) -> Result<()> {
println!("{}", "====== gwtr audit --all ======".bold());
let worktrees = collect_worktree_paths(repo_path)?;
if worktrees.is_empty() {
println!("No worktrees found.");
return Ok(());
}
let mut total_vuln_errors = 0usize;
let mut total_typo_warnings = 0usize;
for (name, path) in &worktrees {
println!("\n{}", format!("=== worktree: {} ===", name).bold().cyan());
let wt_path = std::path::Path::new(path);
let lockfile = wt_path.join("Cargo.lock");
if !lockfile.exists() {
println!(" {} No Cargo.lock found, skipping.", "SKIP".yellow().bold());
continue;
}
let (vuln_errors, typo_warnings) = audit_single_inner(wt_path, typo_only)?;
total_vuln_errors += vuln_errors;
total_typo_warnings += typo_warnings;
}
println!("\n{}", "====== Summary ======".bold());
println!("Worktrees scanned: {}", worktrees.len());
if total_typo_warnings > 0 {
println!("Typosquatting warnings: {}", total_typo_warnings.to_string().yellow());
}
if total_vuln_errors > 0 {
println!("Vulnerability errors: {}", total_vuln_errors.to_string().red());
}
if total_typo_warnings == 0 && total_vuln_errors == 0 {
println!("{}", "All clear!".green().bold());
}
Ok(())
}
fn run_audit_single(project_path: &Path, typo_only: bool) -> Result<()> {
let lockfile_path = project_path.join("Cargo.lock");
if !lockfile_path.exists() {
bail!("Cargo.lock not found at {:?}", lockfile_path);
}
println!("{}", "====== gwtr audit ======".bold());
println!("Scanning: {}", lockfile_path.display());
let (vuln_errors, typo_warnings) = audit_single_inner(project_path, typo_only)?;
println!("\n{}", "========================".bold());
if typo_warnings > 0 {
println!("Typosquatting warnings: {}", typo_warnings.to_string().yellow());
}
if vuln_errors > 0 {
println!("Vulnerability errors: {}", vuln_errors.to_string().red());
}
if typo_warnings == 0 && vuln_errors == 0 {
println!("{}", "All clear!".green().bold());
}
Ok(())
}
fn audit_single_inner(project_path: &Path, typo_only: bool) -> Result<(usize, usize)> {
let lockfile_path = project_path.join("Cargo.lock");
let content = std::fs::read_to_string(&lockfile_path)
.context("Failed to read Cargo.lock")?;
let lock: CargoLock = toml::from_str(&content)
.context("Failed to parse Cargo.lock")?;
println!(" Packages: {}", lock.package.len());
let vuln_errors = if !typo_only {
run_cargo_audit(project_path)?
} else {
0
};
println!(" {}", "[Typosquatting Check]".bold());
let mut typo_warnings = 0;
for pkg in &lock.package {
if let Some((similar, dist)) = check_typosquatting(&pkg.name) {
println!(
" {} {} {} - similar to \"{}\" (distance: {})",
"WARN".yellow().bold(),
pkg.name.yellow(),
pkg.version,
similar.cyan(),
dist
);
typo_warnings += 1;
}
}
if typo_warnings == 0 {
println!(" {} No suspicious package names found", "OK".green().bold());
}
Ok((vuln_errors, typo_warnings))
}
fn collect_worktree_paths(repo_path: &Path) -> Result<Vec<(String, String)>> {
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(repo_path)
.output()
.context("Failed to list worktrees")?;
if !output.status.success() {
bail!("git worktree list failed");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut result = Vec::new();
let mut i = 0;
let lines: Vec<&str> = stdout.lines().collect();
while i < lines.len() {
if lines[i].starts_with("worktree ") {
let path = lines[i].strip_prefix("worktree ").unwrap_or("").to_string();
let mut name = std::path::Path::new(&path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
if i + 1 < lines.len() && lines[i + 1].starts_with("HEAD ") {
i += 1;
}
if i + 1 < lines.len() && lines[i + 1].starts_with("branch ") {
let branch = lines[i + 1]
.strip_prefix("branch refs/heads/")
.unwrap_or(&name);
name = format!("{} ({})", name, branch);
i += 1;
}
result.push((name, path));
}
i += 1;
}
Ok(result)
}
fn run_cargo_audit(project_path: &Path) -> Result<usize> {
println!(" {}", "[Vulnerability Check (cargo audit)]".bold());
let installed = Command::new("cargo")
.args(["audit", "--version"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !installed {
println!(" {} cargo audit is not installed.", "SKIP".yellow().bold());
println!(" Install: {}", "cargo install cargo-audit".cyan());
return Ok(0);
}
let output = Command::new("cargo")
.args(["audit"])
.current_dir(project_path)
.output()
.context("Failed to run cargo audit")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stdout.lines() {
println!(" {}", line);
}
for line in stderr.lines().filter(|l| !l.is_empty()) {
println!(" {}", line);
}
let errors = if output.status.success() { 0 } else { 1 };
Ok(errors)
}
fn check_typosquatting(name: &str) -> Option<(&'static str, usize)> {
if POPULAR_CRATES.contains(&name) {
return None;
}
if name.len() < 4 {
return None;
}
POPULAR_CRATES
.iter()
.filter_map(|&popular| {
if name.len().abs_diff(popular.len()) > 3 {
return None;
}
let dist = levenshtein(name, popular);
let threshold = if name.len() <= 5 { 1 } else { 2 };
if dist > 0 && dist <= threshold {
Some((popular, dist))
} else {
None
}
})
.min_by_key(|&(_, dist)| dist)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_match_is_not_flagged() {
assert!(check_typosquatting("serde").is_none());
assert!(check_typosquatting("tokio").is_none());
assert!(check_typosquatting("reqwest").is_none());
}
#[test]
fn typosquatting_detected() {
let result = check_typosquatting("reqest");
assert!(result.is_some());
assert_eq!(result.unwrap().0, "reqwest");
}
#[test]
fn short_names_are_skipped() {
assert!(check_typosquatting("cc").is_none());
assert!(check_typosquatting("yok").is_none());
}
#[test]
fn unrelated_crate_is_not_flagged() {
assert!(check_typosquatting("my-custom-crate-xyz").is_none());
assert!(check_typosquatting("completely-different").is_none());
}
#[test]
fn length_difference_too_large_is_skipped() {
assert!(check_typosquatting("se").is_none());
}
}