use std::path::{Path, PathBuf};
use tirith_core::package_risk::{
self, ApiProvenance, ApiSignals, ContentSignals, NameVsPopular, PackageSignals, RiskBreakdown,
};
use tirith_core::registry_api::{self, HttpRegistryClient, RegistryClient};
use tirith_core::threatdb::{Ecosystem, ThreatDb};
#[allow(clippy::too_many_arguments)]
pub fn scan(
installed: bool,
lockfile: Option<&Path>,
path: Option<&Path>,
online: bool,
offline: bool,
max_installed_entries: usize,
non_interactive: bool,
json: bool,
) -> i32 {
let (effective_path, effective_installed): (PathBuf, bool) = match (lockfile, installed, path) {
(Some(lock), false, None) => (lock.to_path_buf(), false),
(None, true, None) => (
std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
true,
),
(None, true, Some(p)) => (p.to_path_buf(), true),
(None, false, Some(p)) => (p.to_path_buf(), false),
(None, false, None) => (
std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
true,
),
(Some(_), true, _) => {
eprintln!("tirith package scan: --installed and --lockfile are mutually exclusive.");
return 2;
}
(Some(_), false, Some(_)) => {
eprintln!("tirith package scan: --lockfile and --path are mutually exclusive.");
return 2;
}
};
let effective_path_str = match effective_path.to_str() {
Some(s) => s,
None => {
eprintln!(
"tirith package scan: path {:?} is not valid UTF-8; \
tirith can scan UTF-8 paths only.",
effective_path.display()
);
return 2;
}
};
super::ecosystem::scan(
Some(effective_path_str),
online,
offline,
effective_installed,
max_installed_entries,
non_interactive,
json,
)
}
pub fn risk(
ecosystem: &str,
name: &str,
path: Option<&str>,
online: bool,
offline: bool,
json: bool,
) -> i32 {
run(
ecosystem, name, path, online, offline, json, false,
)
}
pub fn explain(
ecosystem: &str,
name: &str,
path: Option<&str>,
online: bool,
offline: bool,
json: bool,
) -> i32 {
run(
ecosystem, name, path, online, offline, json, true,
)
}
#[allow(clippy::too_many_arguments)]
fn run(
ecosystem: &str,
name: &str,
path: Option<&str>,
online: bool,
offline: bool,
json: bool,
explain: bool,
) -> i32 {
let Some(eco) = Ecosystem::from_name(ecosystem) else {
eprintln!(
"tirith package: unknown ecosystem '{ecosystem}'. \
Known: npm, pypi, rubygems, crates.io, go, maven, nuget, packagist."
);
return 2;
};
let trimmed_name = name.trim();
if trimmed_name.is_empty() {
eprintln!("tirith package: package name must not be empty.");
return 2;
}
let (parsed_name, parsed_version) = package_risk::parse_name_and_version(trimmed_name);
if parsed_name.is_empty() {
eprintln!("tirith package: package name must not be empty.");
return 2;
}
let db = ThreatDb::cached();
let threat_db_missing = db.is_none();
let name_vs_popular = package_risk::classify_name(db.as_deref(), eco, &parsed_name);
let malicious_typosquat_of = db
.as_deref()
.and_then(|db| db.check_typosquat(eco, &parsed_name))
.map(|ts| ts.target_name);
let content_signals = gather_content_signals(eco, &parsed_name, path);
let api = if online {
let client = HttpRegistryClient::new();
gather_api(
&client,
eco,
&parsed_name,
parsed_version.as_deref(),
offline,
)
} else {
ApiSignals::offline()
};
let signals = PackageSignals {
ecosystem: eco,
name: parsed_name,
version: parsed_version,
threat_db_missing,
name_vs_popular,
malicious_typosquat_of,
content_signals,
api,
};
let breakdown = package_risk::score_package(&signals);
if json {
if !print_json(&breakdown, explain) {
return 1;
}
} else {
print_human(&breakdown, explain);
}
0
}
fn gather_api(
client: &dyn RegistryClient,
eco: Ecosystem,
name: &str,
version: Option<&str>,
offline_flag: bool,
) -> ApiSignals {
if offline_flag || super::offline_env_active() {
return ApiSignals::NotComputed {
reason: "offline mode is active (--offline / TIRITH_OFFLINE) — \
registry-API signals were intentionally skipped, scored \
with offline signals only"
.to_string(),
};
}
let (mut signals, existence) = registry_api::gather_api_signals(client, eco, name);
use tirith_core::package_risk::{ApiProvenance, PackageExistence};
let nf = matches!(existence, PackageExistence::NotFound);
if let ApiSignals::Available { provenance } = &mut signals {
provenance.package_existence = existence;
let _ = tirith_core::registry_history::record_snapshot(eco, name, provenance);
if let Some((history, transfer)) =
tirith_core::registry_history::diff_and_transfer_recent(eco, name)
{
provenance.maintainer_change_history = Some(history);
if let Some(t) = transfer {
provenance.ownership_transfer = Some(t);
}
}
if let Some(v) = version {
let result = tirith_core::osv_correlation::for_package_with_state(eco, name, v);
provenance.osv_state = result.state;
if !result.advisories.is_empty() {
provenance.osv_advisories = Some(result.advisories);
}
}
let policy = tirith_core::policy::Policy::discover(None);
let dc = tirith_core::dep_confusion::evaluate(eco, name, &policy);
if dc.risk {
provenance.dep_confusion = Some(dc);
}
if let Some(repo_url) = provenance.repository_url_for_check() {
let rm = tirith_core::repo_mismatch::verify(&repo_url, eco, name);
provenance.repo_mismatch = Some(rm);
}
} else if nf {
let mut prov = ApiProvenance {
source: eco.to_string(),
package_existence: PackageExistence::NotFound,
..Default::default()
};
let policy = tirith_core::policy::Policy::discover(None);
let dc = tirith_core::dep_confusion::evaluate(eco, name, &policy);
if dc.risk {
prov.dep_confusion = Some(dc);
}
let _ = version;
signals = ApiSignals::Available { provenance: prov };
}
signals
}
fn ecosystem_content_root(eco: Ecosystem) -> Option<&'static str> {
match eco {
Ecosystem::Npm => Some("node_modules"),
Ecosystem::PyPI => Some("site-packages"),
_ => None,
}
}
fn gather_content_signals(
eco: Ecosystem,
name: &str,
explicit_path: Option<&str>,
) -> ContentSignals {
let dir: Option<PathBuf> = match explicit_path {
Some(p) => {
let pb = PathBuf::from(p);
if !pb.exists() {
eprintln!(
"tirith package: --path '{p}' does not exist; \
scoring with name signals only."
);
None
} else {
Some(pb)
}
}
None => discover_local_package(eco, name),
};
let Some(dir) = dir else {
return ContentSignals::NotInspected;
};
let (has_install_script, install_script_detail) = detect_install_script(eco, &dir);
let (has_binary_blob, binary_blob_detail) = detect_binary_blob(&dir);
ContentSignals::Inspected {
path: dir.display().to_string(),
has_install_script,
install_script_detail,
has_binary_blob,
binary_blob_detail,
}
}
fn discover_local_package(eco: Ecosystem, name: &str) -> Option<PathBuf> {
let root = ecosystem_content_root(eco)?;
let cwd = std::env::current_dir().ok()?;
let candidate = cwd.join(root).join(name);
if candidate.is_dir() {
Some(candidate)
} else {
None
}
}
fn detect_install_script(eco: Ecosystem, dir: &Path) -> (bool, Option<String>) {
match eco {
Ecosystem::Npm => detect_npm_install_script(dir),
Ecosystem::PyPI => {
if dir.join("setup.py").is_file() {
(
true,
Some("a setup.py (runs arbitrary Python at install time)".to_string()),
)
} else {
(false, None)
}
}
_ => (false, None),
}
}
fn detect_npm_install_script(dir: &Path) -> (bool, Option<String>) {
let manifest = dir.join("package.json");
let Ok(text) = std::fs::read_to_string(&manifest) else {
return (false, None);
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
return (false, None);
};
let Some(scripts) = json.get("scripts").and_then(|s| s.as_object()) else {
return (false, None);
};
let mut hooks: Vec<&str> = Vec::new();
for hook in ["preinstall", "install", "postinstall"] {
if scripts
.get(hook)
.and_then(|v| v.as_str())
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
{
hooks.push(hook);
}
}
if hooks.is_empty() {
(false, None)
} else {
(
true,
Some(format!(
"an npm {} lifecycle script in package.json",
hooks.join(" / ")
)),
)
}
}
const BINARY_BLOB_EXTENSIONS: &[&str] = &[
".so", ".dll", ".dylib", ".node", ".wasm", ".a", ".lib", ".o", ".obj", ".exe", ".bin", ".dex",
".class", ".jar", ".pyd",
];
fn detect_binary_blob(dir: &Path) -> (bool, Option<String>) {
const MAX_ENTRIES: usize = 20_000;
let mut examined = 0usize;
let mut found: Vec<String> = Vec::new();
for entry in walkdir::WalkDir::new(dir)
.max_depth(8)
.into_iter()
.filter_map(Result::ok)
{
examined += 1;
if examined > MAX_ENTRIES {
break;
}
if !entry.file_type().is_file() {
continue;
}
let fname = entry.file_name().to_string_lossy().to_lowercase();
if let Some(ext) = BINARY_BLOB_EXTENSIONS
.iter()
.find(|ext| fname.ends_with(*ext))
{
if !found.iter().any(|f| f == ext) {
found.push((*ext).to_string());
}
if found.len() >= 3 {
break;
}
}
}
if found.is_empty() {
(false, None)
} else {
(
true,
Some(format!("bundled compiled artifacts ({})", found.join(", "))),
)
}
}
fn print_json(breakdown: &RiskBreakdown, explain: bool) -> bool {
#[derive(serde::Serialize)]
struct PackageRiskOutput<'a> {
ecosystem: &'a str,
name: &'a str,
score: u32,
risk_level: &'a str,
threat_db_missing: bool,
name_vs_popular: &'a NameVsPopular,
#[serde(skip_serializing_if = "Option::is_none")]
malicious_typosquat_of: Option<&'a str>,
content_signals: &'a ContentSignals,
api_signals: &'a ApiSignals,
#[serde(skip_serializing_if = "Option::is_none")]
risk_breakdown: Option<&'a RiskBreakdown>,
}
let out = PackageRiskOutput {
ecosystem: &breakdown.ecosystem,
name: &breakdown.name,
score: breakdown.score,
risk_level: breakdown.risk_level,
threat_db_missing: breakdown.threat_db_missing,
name_vs_popular: &breakdown.name_vs_popular,
malicious_typosquat_of: breakdown.malicious_typosquat_of.as_deref(),
content_signals: &breakdown.content_signals,
api_signals: &breakdown.api_signals,
risk_breakdown: if explain { Some(breakdown) } else { None },
};
super::write_json_stdout(&out, "tirith package: failed to write JSON output")
}
fn print_human(breakdown: &RiskBreakdown, explain: bool) {
println!(
"tirith package risk: {} package '{}'",
breakdown.ecosystem, breakdown.name
);
println!(
" risk score: {}/100 ({})",
breakdown.score, breakdown.risk_level
);
match &breakdown.name_vs_popular {
NameVsPopular::KnownPopular => {
println!(" name: known-popular package (recognized)");
}
NameVsPopular::NearPopular {
popular_name,
distance,
} => {
println!(
" name: edit-distance {distance} from popular package '{popular_name}' \
— possible typosquat/slopsquat"
);
}
NameVsPopular::Unknown => {
if breakdown.threat_db_missing {
println!(
" name: unknown — threat DB not installed, \
popular-package comparison skipped"
);
} else {
println!(" name: not a known-popular package, and no near-miss");
}
}
}
if let Some(target) = &breakdown.malicious_typosquat_of {
println!(" threat DB: listed as a known malicious typosquat of '{target}'");
}
match &breakdown.content_signals {
ContentSignals::NotInspected => {
println!(
" content: not inspected (no local package directory — \
pass --path to inspect install scripts and binary blobs)"
);
}
ContentSignals::Inspected {
path,
has_install_script,
install_script_detail,
has_binary_blob,
binary_blob_detail,
} => {
println!(" content: inspected {path}");
match (has_install_script, install_script_detail) {
(true, Some(d)) => println!(" - install script: {d}"),
(true, None) => println!(" - install script: present"),
(false, _) => println!(" - install script: none"),
}
match (has_binary_blob, binary_blob_detail) {
(true, Some(d)) => println!(" - binary blob: {d}"),
(true, None) => println!(" - binary blob: present"),
(false, _) => println!(" - binary blob: none"),
}
}
}
match &breakdown.api_signals {
ApiSignals::NotComputed { reason } => {
println!(" api signals: not computed — {reason}");
}
ApiSignals::Unavailable { reason } => {
println!(" api signals: unavailable — {reason}");
}
ApiSignals::Available { provenance } => {
print_api_provenance_human(provenance);
}
}
if explain {
print_breakdown_human(breakdown);
} else {
println!(
" Run 'tirith package explain {} {}' for the factor-by-factor derivation.",
breakdown.ecosystem, breakdown.name
);
}
}
fn print_api_provenance_human(p: &ApiProvenance) {
println!(" api signals: from the {} registry API", p.source);
match p.package_age_days {
Some(d) => println!(" - package age: {d} day(s) since first publish"),
None => println!(" - package age: unknown (not reported)"),
}
match (&p.latest_version, p.latest_version_age_days) {
(Some(v), Some(d)) => {
println!(" - latest version: {v} ({d} day(s) old)")
}
(Some(v), None) => println!(" - latest version: {v}"),
(None, _) => println!(" - latest version: unknown"),
}
#[allow(deprecated)]
match p.ownership_transferred {
Some(true) => {
println!(" - ownership: no listed owners (established package)")
}
Some(false) => println!(" - ownership: has listed owners"),
None => println!(" - ownership: unknown (registry exposes no owner field)"),
}
match p.version_spike {
Some(true) => println!(" - version jump: abnormal (major-version spike)"),
Some(false) => println!(" - version jump: normal"),
None => println!(" - version jump: unknown (one version only)"),
}
match p.recent_downloads {
Some(dl) => println!(" - downloads: {dl} (recent window)"),
None => println!(" - downloads: unknown (not reported)"),
}
match p.has_source_repo {
Some(true) => println!(" - source repo: listed"),
Some(false) => println!(" - source repo: missing or unusable"),
None => println!(" - source repo: unknown (field not in API)"),
}
if p.yanked_or_deprecated {
println!(" - status: latest version yanked / deprecated");
} else {
println!(" - status: latest version current");
}
}
fn print_breakdown_human(breakdown: &RiskBreakdown) {
let _ = write_breakdown_human(breakdown, &mut std::io::stdout().lock());
}
fn write_breakdown_human(
breakdown: &RiskBreakdown,
w: &mut impl std::io::Write,
) -> std::io::Result<()> {
writeln!(w)?;
writeln!(
w,
" risk breakdown (each factor is fixed and inspectable — no model):"
)?;
let mut running: i32 = 0;
for factor in &breakdown.factors {
running += factor.points;
let sign = if factor.points >= 0 { "+" } else { "" };
writeln!(
w,
" {sign}{:<4} {} (running total: {running})",
factor.points, factor.label
)?;
writeln!(w, " {}", factor.detail)?;
}
writeln!(
w,
" = {} / {} ({}) — sum of every factor above",
breakdown.score,
package_risk::MAX_SCORE,
breakdown.risk_level
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn render(breakdown: &RiskBreakdown) -> String {
let mut buf: Vec<u8> = Vec::new();
write_breakdown_human(breakdown, &mut buf).expect("write to Vec never fails");
String::from_utf8(buf).expect("breakdown output is valid UTF-8")
}
#[test]
fn unknown_ecosystem_is_rejected_with_exit_2() {
assert_eq!(
risk("not-a-real-ecosystem", "react", None, false, false, false),
2
);
}
#[test]
fn empty_name_is_rejected_with_exit_2() {
assert_eq!(risk("npm", " ", None, false, false, false), 2);
}
#[test]
fn detect_npm_install_script_finds_postinstall() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("package.json"),
r#"{"name":"p","scripts":{"postinstall":"node evil.js"}}"#,
)
.unwrap();
let (found, detail) = detect_npm_install_script(dir.path());
assert!(found);
assert!(detail.unwrap().contains("postinstall"));
}
#[test]
fn detect_npm_install_script_ignores_non_lifecycle_scripts() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("package.json"),
r#"{"name":"p","scripts":{"test":"jest","build":"tsc"}}"#,
)
.unwrap();
let (found, _) = detect_npm_install_script(dir.path());
assert!(!found, "test/build scripts are not install hooks");
}
#[test]
fn detect_npm_install_script_ignores_empty_hook() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("package.json"),
r#"{"name":"p","scripts":{"postinstall":" "}}"#,
)
.unwrap();
let (found, _) = detect_npm_install_script(dir.path());
assert!(!found, "an empty postinstall string is not a real hook");
}
#[test]
fn detect_npm_install_script_handles_missing_or_bad_manifest() {
let dir = tempdir().unwrap();
assert!(!detect_npm_install_script(dir.path()).0);
fs::write(dir.path().join("package.json"), "{not json").unwrap();
assert!(!detect_npm_install_script(dir.path()).0);
}
#[test]
fn detect_install_script_pypi_setup_py() {
let dir = tempdir().unwrap();
let (no, _) = detect_install_script(Ecosystem::PyPI, dir.path());
assert!(!no);
fs::write(dir.path().join("setup.py"), "from setuptools import setup").unwrap();
let (yes, detail) = detect_install_script(Ecosystem::PyPI, dir.path());
assert!(yes);
assert!(detail.unwrap().contains("setup.py"));
}
#[test]
fn detect_binary_blob_finds_native_extensions() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("index.js"), "// pure source").unwrap();
assert!(!detect_binary_blob(dir.path()).0);
fs::write(dir.path().join("addon.node"), [0u8, 1, 2, 3]).unwrap();
let (found, detail) = detect_binary_blob(dir.path());
assert!(found);
assert!(detail.unwrap().contains(".node"));
}
#[test]
fn detect_binary_blob_clean_directory() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("index.js"), "// pure source").unwrap();
fs::write(dir.path().join("README.md"), "# docs").unwrap();
let sub = dir.path().join("lib");
fs::create_dir(&sub).unwrap();
fs::write(sub.join("util.js"), "// more source").unwrap();
assert!(!detect_binary_blob(dir.path()).0);
}
#[test]
fn gather_content_signals_missing_explicit_path_is_not_inspected() {
let signals = gather_content_signals(
Ecosystem::Npm,
"whatever",
Some("/definitely/not/a/real/path/xyzzy"),
);
assert!(matches!(signals, ContentSignals::NotInspected));
}
#[test]
fn gather_content_signals_inspects_explicit_path() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("package.json"),
r#"{"name":"p","scripts":{"install":"node build.js"}}"#,
)
.unwrap();
fs::write(dir.path().join("native.so"), [0u8; 4]).unwrap();
let signals =
gather_content_signals(Ecosystem::Npm, "p", Some(dir.path().to_str().unwrap()));
match signals {
ContentSignals::Inspected {
has_install_script,
has_binary_blob,
..
} => {
assert!(has_install_script);
assert!(has_binary_blob);
}
ContentSignals::NotInspected => panic!("explicit path should be inspected"),
}
}
#[test]
fn breakdown_human_renders_known_popular_zero() {
let signals = PackageSignals {
ecosystem: Ecosystem::Npm,
name: "react".to_string(),
version: None,
threat_db_missing: false,
name_vs_popular: NameVsPopular::KnownPopular,
malicious_typosquat_of: None,
content_signals: ContentSignals::NotInspected,
api: ApiSignals::offline(),
};
let breakdown = package_risk::score_package(&signals);
let out = render(&breakdown);
assert!(out.contains("risk breakdown"), "must print header: {out}");
assert!(out.contains("+0"), "known-popular contributes +0: {out}");
assert!(out.contains("= 0 / 100"), "total must read 0/100: {out}");
assert!(out.contains("(low)"), "0 is the low bucket: {out}");
}
#[test]
fn breakdown_human_renders_negative_clamp_factor() {
let signals = PackageSignals {
ecosystem: Ecosystem::Npm,
name: "raect".to_string(),
version: None,
threat_db_missing: false,
name_vs_popular: NameVsPopular::NearPopular {
popular_name: "react".to_string(),
distance: 1,
},
malicious_typosquat_of: Some("react".to_string()),
content_signals: ContentSignals::Inspected {
path: "/tmp/p".to_string(),
has_install_script: true,
install_script_detail: None,
has_binary_blob: true,
binary_blob_detail: None,
},
api: ApiSignals::offline(),
};
let breakdown = package_risk::score_package(&signals);
assert_eq!(breakdown.score, 100);
let out = render(&breakdown);
assert!(out.contains(" -15 "), "clamp must render bare: {out}");
assert!(!out.contains("+-15"), "clamp must not get a '+': {out}");
assert!(
out.contains("= 100 / 100"),
"total must read 100/100: {out}"
);
assert!(
out.contains("(critical)"),
"100 is the critical bucket: {out}"
);
}
use tirith_core::registry_api::{FetchError, RegistryMetadata};
struct FakeClient {
result: Result<RegistryMetadata, FetchError>,
}
impl RegistryClient for FakeClient {
fn fetch(&self, _eco: Ecosystem, _name: &str) -> Result<RegistryMetadata, FetchError> {
self.result.clone()
}
}
struct ExplodingClient;
impl RegistryClient for ExplodingClient {
fn fetch(&self, _eco: Ecosystem, _name: &str) -> Result<RegistryMetadata, FetchError> {
panic!("fetch must not be called when offline mode is active");
}
}
#[test]
fn gather_api_offline_flag_skips_network() {
let sig = gather_api(&ExplodingClient, Ecosystem::Npm, "react", None, true);
match sig {
ApiSignals::NotComputed { reason } => {
assert!(reason.contains("offline"), "reason: {reason}");
}
other => panic!("expected NotComputed for an intentional offline skip, got {other:?}"),
}
}
#[test]
fn gather_api_success_returns_available() {
let meta = RegistryMetadata {
source: "npm".to_string(),
latest_version: Some("1.0.0".to_string()),
..Default::default()
};
let client = FakeClient { result: Ok(meta) };
let sig = gather_api(&client, Ecosystem::Npm, "react", None, false);
assert!(matches!(sig, ApiSignals::Available { .. }));
}
#[test]
fn gather_api_failure_degrades_to_unavailable() {
let client = FakeClient {
result: Err(FetchError::Network("connection refused".to_string())),
};
let sig = gather_api(&client, Ecosystem::Npm, "react", None, false);
assert!(matches!(sig, ApiSignals::Unavailable { .. }));
}
#[test]
fn online_run_offline_flag_still_exits_zero_without_network() {
let code = run(
"npm", "react", None, true, true,
true, false,
);
assert_eq!(code, 0, "an --online --offline run must exit 0 offline");
}
#[test]
fn available_provenance_drives_api_factors_and_human_output() {
#[allow(deprecated)]
let provenance = ApiProvenance {
source: "pypi".to_string(),
package_age_days: Some(2),
latest_version_age_days: Some(1),
ownership_transferred: Some(true),
version_spike: Some(true),
recent_downloads: Some(5),
has_source_repo: Some(false),
yanked_or_deprecated: true,
latest_version: Some("9.9.9".to_string()),
..Default::default()
};
let s = PackageSignals {
ecosystem: Ecosystem::PyPI,
name: "p".to_string(),
version: None,
threat_db_missing: true,
name_vs_popular: NameVsPopular::Unknown,
malicious_typosquat_of: None,
content_signals: ContentSignals::NotInspected,
api: ApiSignals::Available { provenance },
};
let breakdown = package_risk::score_package(&s);
assert!(breakdown.score > 0);
assert!(breakdown.factors.iter().any(|f| f.id.starts_with("api_")));
assert!(matches!(
breakdown.api_signals,
ApiSignals::Available { .. }
));
if let ApiSignals::Available { provenance } = &breakdown.api_signals {
print_api_provenance_human(provenance);
}
}
}