Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["crates/tirith-core", "crates/tirith", "tools/sign-license", "tools/license-server"]
members = ["crates/tirith-core", "crates/tirith", "tools/sign-license", "tools/license-server", "crates/slopsquatscan"]
resolver = "2"

[workspace.package]
Expand Down
19 changes: 19 additions & 0 deletions crates/slopsquatscan/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[package]
name = "slopsquatscan"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Scan installed packages for slopsquatting — hallucinated, typosquatted, or suspicious dependencies"
repository = "https://github.com/sheeki03/tirith"

[[bin]]
name = "slopsquatscan"
path = "src/main.rs"

[dependencies]
clap = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"] }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
235 changes: 235 additions & 0 deletions crates/slopsquatscan/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
mod output;
mod registry;

use clap::Parser;
use registry::{PackageResult, PackageStatus};
use reqwest::blocking::Client;

#[derive(Parser)]
#[command(
name = "slopsquatscan",
version,
about = "Scan installed packages for potential slopsquatting"
)]
struct Cli {
/// Scan pip packages only
#[arg(long)]
pip: bool,

/// Scan npm global packages only
#[arg(long)]
npm: bool,

/// Scan AUR packages only
#[arg(long)]
aur: bool,

/// Scan everything (default if no flags)
#[arg(long)]
all: bool,

/// Show clean packages too
#[arg(long)]
verbose: bool,

/// Output as JSON
#[arg(long)]
json: bool,
}

fn run_scan(
client: &Client,
label: &str,
scanner: fn(&Client) -> Vec<PackageResult>,
json: bool,
verbose: bool,
) -> Vec<PackageResult> {
if !json {
eprintln!("\n{}{}{}", output::BOLD, label, output::RST);
}
let results = scanner(client);
if !json {
print_results(&results, verbose);
}
results
}

fn main() {
let cli = Cli::parse();

let scan_all = cli.all || (!cli.pip && !cli.npm && !cli.aur);
let scan_npm = scan_all || cli.npm;
let scan_pip = scan_all || cli.pip;
let scan_aur = scan_all || cli.aur;

if !cli.json {
output::banner();
eprintln!();
output::thresholds(
registry::npm_threshold(),
registry::pypi_threshold(),
registry::days_threshold(),
);
}

let client = Client::new();
let mut all_results: Vec<PackageResult> = Vec::new();

if scan_npm {
all_results.extend(run_scan(
&client,
"npm (global)",
registry::scan_npm,
cli.json,
cli.verbose,
));
}
if scan_pip {
all_results.extend(run_scan(
&client,
"pip",
registry::scan_pip,
cli.json,
cli.verbose,
));
}
if scan_aur {
all_results.extend(run_scan(
&client,
"AUR (foreign packages)",
registry::scan_aur,
cli.json,
cli.verbose,
));
}

let clean = all_results
.iter()
.filter(|r| matches!(r.status, PackageStatus::Clean { .. }))
.count();
let warnings = all_results
.iter()
.filter(|r| matches!(r.status, PackageStatus::Warning { .. }))
.count();
let suspicious: Vec<_> = all_results
.iter()
.filter(|r| matches!(r.status, PackageStatus::Suspicious { .. }))
.collect();

if cli.json {
print_json(&all_results, clean, warnings, suspicious.len());
} else {
print_summary(clean, warnings, &suspicious);
}

if !suspicious.is_empty() {
std::process::exit(1);
}
}

fn print_summary(clean: usize, warnings: usize, suspicious: &[&PackageResult]) {
eprintln!("\n{}Summary{}", output::BOLD, output::RST);
eprintln!(" {}Clean:{} {clean}", output::GRN, output::RST);
eprintln!(" {}Warnings:{} {warnings}", output::YLW, output::RST);
eprintln!(
" {}Suspicious:{} {}",
output::RED,
output::RST,
suspicious.len()
);

if !suspicious.is_empty() {
eprintln!();
eprintln!(
"{}{}Action required:{} these packages were NOT FOUND on their registry:",
output::RED,
output::BOLD,
output::RST
);
for s in suspicious {
eprintln!(
" {}→{} {}:{}",
output::RED,
output::RST,
s.registry,
s.name
);
}
eprintln!();
eprintln!("This could mean: typosquatted name, removed package, or private package.");
eprintln!("Investigate before continuing to use them.");
} else if warnings > 0 {
eprintln!();
eprintln!(
"{}Some packages have low popularity or are very new — worth a quick check.{}",
output::YLW,
output::RST
);
} else {
eprintln!("\n{}All clear.{}", output::GRN, output::RST);
}
}

fn print_results(results: &[PackageResult], verbose: bool) {
if results.is_empty() {
eprintln!(" {}no packages found{}", output::DIM, output::RST);
return;
}
for r in results {
match &r.status {
PackageStatus::Suspicious { reason } => output::log_sus(&r.name, reason),
PackageStatus::Warning { reason } => output::log_warn(&r.name, reason),
PackageStatus::Clean { detail } => output::log_ok(&r.name, detail, verbose),
}
}
}

fn print_json(results: &[PackageResult], clean: usize, warnings: usize, suspicious: usize) {
#[derive(serde::Serialize)]
struct JsonOutput {
summary: JsonSummary,
packages: Vec<JsonPackage>,
}
#[derive(serde::Serialize)]
struct JsonSummary {
clean: usize,
warnings: usize,
suspicious: usize,
}
#[derive(serde::Serialize)]
struct JsonPackage {
registry: String,
name: String,
status: String,
detail: String,
}

let packages: Vec<JsonPackage> = results
.iter()
.map(|r| {
let (status, detail) = match &r.status {
PackageStatus::Clean { detail } => ("clean", detail.clone()),
PackageStatus::Warning { reason } => ("warning", reason.clone()),
PackageStatus::Suspicious { reason } => ("suspicious", reason.clone()),
};
JsonPackage {
registry: r.registry.to_string(),
name: r.name.clone(),
status: status.to_string(),
detail,
}
})
.collect();

let out = JsonOutput {
summary: JsonSummary {
clean,
warnings,
suspicious,
},
packages,
};

let _ = serde_json::to_writer_pretty(std::io::stdout().lock(), &out);
println!();
}
38 changes: 38 additions & 0 deletions crates/slopsquatscan/src/output.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
pub const RED: &str = "\x1b[0;31m";
pub const YLW: &str = "\x1b[0;33m";
pub const GRN: &str = "\x1b[0;32m";
pub const DIM: &str = "\x1b[0;90m";
pub const RST: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m";

pub fn log_sus(name: &str, reason: &str) {
eprintln!(" {RED}\u{2717}{RST} {name:<30} {RED}{reason}{RST}");
}

pub fn log_warn(name: &str, reason: &str) {
eprintln!(" {YLW}!{RST} {name:<30} {YLW}{reason}{RST}");
}

pub fn log_ok(name: &str, detail: &str, verbose: bool) {
if verbose {
eprintln!(" {GRN}\u{2713}{RST} {name:<30} {DIM}{detail}{RST}");
}
}

pub fn banner() {
eprintln!(
"{RED}\
_____ _ _____ _ _____
/ ___| | / ___| | | / ___|
\\ `--.| | ___ _ __ \\ `--. __ _ _ _ __ _| |_\\ `--. ___ __ _ _ __
`--. \\ |/ _ \\| '_ \\ `--. \\/ _` | | | |/ _` | __|`--. \\/ __/ _` | '_ \\
/\\__/ / | (_) | |_) /\\__/ / (_| | |_| | (_| | |_/\\__/ / (_| (_| | | | |
\\____/|_|\\___/| .__/\\____/ \\__, |\\__,_|\\__,_|\\__\\____/ \\___\\__,_|_| |_|
| | | |
|_| |_|{RST}"
);
}

pub fn thresholds(npm_weekly: u64, pypi_weekly: u64, days_new: i64) {
eprintln!("{DIM}thresholds: <{npm_weekly} dl/week (npm), <{pypi_weekly} dl/week (pypi), <{days_new}d old = warning{RST}");
}
Loading