use std::path::{Path, PathBuf};
#[cfg(test)]
use tirith_core::ecosystem_scan::DEFAULT_MAX_INSTALLED_ENTRIES;
use tirith_core::ecosystem_scan::{
self, DependencyAssessment, EcosystemScanReport, OnlineMode, ScanMode, ScanRequest,
};
use tirith_core::package_risk::ApiSignals;
use tirith_core::policy::Policy;
use tirith_core::registry_api::{self, HttpRegistryClient};
use tirith_core::threatdb::{Ecosystem, ThreatDb};
use tirith_core::verdict::Action;
pub const MIN_INSTALLED_ENTRIES: usize = 100;
pub const MAX_INSTALLED_ENTRIES: usize = 200_000;
pub const ONLINE_PROMPT_THRESHOLD: usize = 100;
#[allow(clippy::too_many_arguments)]
pub fn scan(
path: Option<&str>,
online: bool,
offline: bool,
installed: bool,
max_installed_entries: usize,
non_interactive: bool,
json: bool,
) -> i32 {
let scan_root: PathBuf = path
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
if !scan_root.exists() {
eprintln!(
"tirith ecosystem scan: path not found: {}",
scan_root.display()
);
eprintln!(" try: tirith ecosystem scan ./ (scan the current directory)");
return 2;
}
if max_installed_entries != 0
&& !(MIN_INSTALLED_ENTRIES..=MAX_INSTALLED_ENTRIES).contains(&max_installed_entries)
{
eprintln!(
"tirith ecosystem scan: --max-installed-entries must be 0 (unbounded) \
or between {MIN_INSTALLED_ENTRIES} and {MAX_INSTALLED_ENTRIES}; got \
{max_installed_entries}."
);
return 2;
}
let installed_effective = installed || force_installed_for_tests();
let mode = pick_mode(&scan_root, installed_effective);
let db = ThreatDb::cached();
let policy = Policy::discover(scan_root.to_str());
let is_allowlisted = |eco: Ecosystem, name: &str| package_allowlisted(&policy, eco, name);
let use_online = online && !offline && !super::offline_env_active();
let http_client = HttpRegistryClient::new();
let resolver = |eco: Ecosystem, name: &str| -> ApiSignals {
let (mut signals, existence) = registry_api::gather_api_signals(&http_client, eco, name);
use tirith_core::package_risk::{ApiProvenance, PackageExistence};
match &mut signals {
ApiSignals::Available { provenance } => {
provenance.package_existence = existence;
let dc = tirith_core::dep_confusion::evaluate(eco, name, &policy);
if dc.risk {
provenance.dep_confusion = Some(dc);
}
}
ApiSignals::Unavailable { .. } if matches!(existence, PackageExistence::NotFound) => {
let mut prov = ApiProvenance {
source: eco.to_string(),
package_existence: PackageExistence::NotFound,
..Default::default()
};
let dc = tirith_core::dep_confusion::evaluate(eco, name, &policy);
if dc.risk {
prov.dep_confusion = Some(dc);
}
signals = ApiSignals::Available { provenance: prov };
}
_ => {}
}
signals
};
if use_online && matches!(mode, ScanMode::Installed) && !non_interactive {
let estimate = estimate_installed_entries(&scan_root, max_installed_entries);
if estimate > ONLINE_PROMPT_THRESHOLD
&& !super::confirm(
&format!(
"tirith ecosystem scan: --installed --online would make ~{estimate} \
network calls against package registries; proceed?"
),
false,
)
{
eprintln!("tirith ecosystem scan: aborted by operator at confirmation prompt.");
return 2;
}
}
let online_mode = if use_online {
OnlineMode::Resolver(&resolver)
} else {
OnlineMode::Off
};
let request = ScanRequest {
root: &scan_root,
db: db.as_deref(),
online: online_mode,
is_allowlisted: &is_allowlisted,
mode: mode.clone(),
installed_max_entries: max_installed_entries,
policy: Some(&policy),
};
let mut report = ecosystem_scan::scan(&request);
let interactive = is_terminal::is_terminal(std::io::stderr());
report.verdict.agent_origin = Some(tirith_core::agent_origin::resolve_cli_origin(interactive));
if !report.verdict.bypass_honored {
tirith_core::escalation::apply_agent_rules(&mut report.verdict, &policy);
}
if let Err(e) = tirith_core::audit::log_verdict(
&report.verdict,
&format!("ecosystem scan ({}) {}", report.mode, report.scan_root),
None,
None,
&policy.dlp_custom_patterns,
) {
if !json {
eprintln!("tirith ecosystem scan: audit log not written (non-fatal): {e}");
}
}
if json {
if !print_json(&report) {
let code = exit_code(report.action());
return if code == 0 { 1 } else { code };
}
} else {
print_human(&report);
}
exit_code(report.action())
}
fn exit_code(action: Action) -> i32 {
match action {
Action::Block => 1,
Action::Warn | Action::WarnAck => 2,
Action::Allow => 0,
}
}
fn package_allowlisted(policy: &Policy, eco: Ecosystem, name: &str) -> bool {
let bare = name.to_lowercase();
let qualified = format!("{}:{}", eco, bare);
let matches_entry = |entry: &str| {
let e = entry.trim().to_lowercase();
e == bare || e == qualified
};
if policy.allowlist.iter().any(|e| matches_entry(e)) {
return true;
}
for rule in &policy.allowlist_rules {
let scoped = matches!(
rule.rule_id.to_lowercase().as_str(),
"threat_malicious_package"
| "threat_package_typosquat"
| "threat_package_similar_name"
| "threat_suspicious_package"
);
if scoped && rule.patterns.iter().any(|p| matches_entry(p)) {
return true;
}
}
false
}
fn print_json(report: &EcosystemScanReport) -> bool {
#[derive(serde::Serialize)]
struct JsonOut<'a> {
schema_version: u32,
#[serde(flatten)]
report: &'a EcosystemScanReport,
}
let out = JsonOut {
schema_version: 1,
report,
};
super::write_json_stdout(&out, "tirith ecosystem scan: failed to write JSON output")
}
fn print_human(report: &EcosystemScanReport) {
let finding_count = report.verdict.findings.len();
if report.manifests.is_empty() {
eprintln!(
"tirith ecosystem scan: {} — no dependency manifests found",
report.scan_root
);
} else {
eprintln!(
"tirith ecosystem scan: {} — {} manifest(s), {} dependencies, {} finding(s)",
report.scan_root,
report.manifests.len(),
report.dependency_count,
finding_count,
);
}
if !report.manifests.is_empty() {
eprintln!();
eprintln!(" manifests:");
for m in &report.manifests {
eprintln!(" - {m}");
}
}
if !report.notes.is_empty() {
eprintln!();
eprintln!(" notes:");
for note in &report.notes {
match ¬e.manifest {
Some(m) => eprintln!(" - [{m}] {}", note.note),
None => eprintln!(" - {}", note.note),
}
}
}
if finding_count == 0 {
eprintln!();
if report.dependency_count == 0 {
eprintln!(" no dependencies to assess.");
} else {
eprintln!(
" no supply-chain risks found across {} dependencies.",
report.dependency_count
);
}
} else {
println!();
println!("Supply-chain findings:");
for finding in &report.verdict.findings {
let sev = tirith_core::style::severity_label(
&finding.severity,
tirith_core::style::Stream::Stdout,
);
println!(" {} {} — {}", sev, finding.rule_id, finding.title);
println!(" {}", finding.description);
}
}
let allowlisted = report.allowlisted_count();
if allowlisted > 0 {
eprintln!();
eprintln!(" {allowlisted} dependency/dependencies suppressed by policy allowlist.");
}
if report.dependency_count > 0 {
eprintln!();
let highest = highest_risk_dependency(report);
if let Some(dep) = highest {
eprintln!(
" highest risk: {} {} ({}/100, {}). \
Run 'tirith package explain {} {}' for the factor breakdown.",
dep.dependency.ecosystem,
dep.dependency.name,
dep.risk.score,
dep.risk.risk_level,
dep.dependency.ecosystem,
dep.dependency.name,
);
}
if !report.online {
eprintln!(
" (offline scan — re-run with --online to add registry-API \
provenance signals)"
);
}
}
}
fn highest_risk_dependency(report: &EcosystemScanReport) -> Option<&DependencyAssessment> {
report
.assessments
.iter()
.max_by_key(|a| a.risk.score)
.filter(|a| a.risk.score > 0)
}
pub(crate) fn pick_mode(scan_root: &Path, installed: bool) -> ScanMode {
if installed {
return ScanMode::Installed;
}
if scan_root.is_file() {
if let Some(name) = scan_root.file_name().and_then(|n| n.to_str()) {
if ecosystem_scan::ManifestKind::from_file_name(name).is_some() {
return ScanMode::SpecificLockfile(scan_root.to_path_buf());
}
}
}
ScanMode::Manifests
}
fn force_installed_for_tests() -> bool {
if !cfg!(debug_assertions) {
return false;
}
std::env::var("TIRITH_FORCE_INSTALLED")
.ok()
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
}
pub(crate) fn estimate_installed_entries(root: &Path, cap: usize) -> usize {
let mut count = 0usize;
let nm = root.join("node_modules");
if let Ok(rd) = std::fs::read_dir(&nm) {
for e in rd.flatten() {
if !e.file_type().map(|f| f.is_dir()).unwrap_or(false) {
continue;
}
let Some(name) = e.file_name().to_str().map(str::to_string) else {
continue;
};
if name.starts_with('@') {
if let Ok(sub) = std::fs::read_dir(e.path()) {
count += sub.count();
}
} else {
count += 1;
}
if cap > 0 && count > cap {
return cap;
}
}
}
for sp in find_site_packages_for_estimate(root) {
if let Ok(rd) = std::fs::read_dir(sp) {
count += rd
.flatten()
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.ends_with(".dist-info"))
})
.count();
}
if cap > 0 && count > cap {
return cap;
}
}
count
}
fn find_site_packages_for_estimate(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let cands = [
root.join("site-packages"),
root.join("Lib").join("site-packages"),
];
for c in cands {
if c.is_dir() {
out.push(c);
}
}
let lib = root.join("lib");
if let Ok(rd) = std::fs::read_dir(&lib) {
for e in rd.flatten() {
let Some(name) = e.file_name().to_str().map(str::to_string) else {
continue;
};
if !name.starts_with("python") {
continue;
}
let sp = e.path().join("site-packages");
if sp.is_dir() {
out.push(sp);
}
}
}
out
}
#[cfg(test)]
fn has_any_manifest(root: &std::path::Path) -> bool {
!ecosystem_scan::discover_manifests(root).is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
use tirith_core::policy::{AllowlistRule, Policy};
#[test]
fn exit_code_maps_actions() {
assert_eq!(exit_code(Action::Allow), 0);
assert_eq!(exit_code(Action::Block), 1);
assert_eq!(exit_code(Action::Warn), 2);
assert_eq!(exit_code(Action::WarnAck), 2);
}
#[test]
fn package_allowlisted_matches_bare_name() {
let policy = Policy {
allowlist: vec!["my-internal-pkg".to_string()],
..Default::default()
};
assert!(package_allowlisted(
&policy,
Ecosystem::Npm,
"my-internal-pkg"
));
assert!(!package_allowlisted(&policy, Ecosystem::Npm, "other-pkg"));
}
#[test]
fn package_allowlisted_matches_qualified_name() {
let policy = Policy {
allowlist: vec!["npm:scoped-thing".to_string()],
..Default::default()
};
assert!(package_allowlisted(&policy, Ecosystem::Npm, "scoped-thing"));
assert!(!package_allowlisted(
&policy,
Ecosystem::PyPI,
"scoped-thing"
));
}
#[test]
fn package_allowlisted_is_exact_not_substring() {
let policy = Policy {
allowlist: vec!["react".to_string()],
..Default::default()
};
assert!(package_allowlisted(&policy, Ecosystem::Npm, "react"));
assert!(
!package_allowlisted(&policy, Ecosystem::Npm, "react-dom"),
"an exact entry must not match by substring"
);
}
#[test]
fn package_allowlisted_honors_rule_scoped_entry() {
let policy = Policy {
allowlist_rules: vec![AllowlistRule {
rule_id: "threat_suspicious_package".to_string(),
patterns: vec!["python-data-helper".to_string()],
}],
..Default::default()
};
assert!(package_allowlisted(
&policy,
Ecosystem::PyPI,
"python-data-helper"
));
}
#[test]
fn package_allowlisted_ignores_unrelated_rule_scope() {
let policy = Policy {
allowlist_rules: vec![AllowlistRule {
rule_id: "curl_pipe_shell".to_string(),
patterns: vec!["some-pkg".to_string()],
}],
..Default::default()
};
assert!(!package_allowlisted(&policy, Ecosystem::Npm, "some-pkg"));
}
#[test]
fn has_any_manifest_detects_project() {
let dir = tempdir().unwrap();
assert!(!has_any_manifest(dir.path()));
fs::write(dir.path().join("Cargo.toml"), "[dependencies]\n").unwrap();
assert!(has_any_manifest(dir.path()));
}
#[test]
fn scan_of_missing_path_exits_2() {
let code = scan(
Some("/definitely/not/a/real/path/xyzzy-ecosystem"),
false,
false,
false,
DEFAULT_MAX_INSTALLED_ENTRIES,
true,
true,
);
assert_eq!(code, 2);
}
#[test]
fn scan_of_clean_temp_project_exits_0() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[dependencies]\nmy-unique-internal-crate = \"1.0\"\n",
)
.unwrap();
let code = scan(
dir.path().to_str(),
false,
false,
false,
DEFAULT_MAX_INSTALLED_ENTRIES,
true,
true,
);
assert_eq!(code, 0, "a project with no flagged deps must exit 0");
}
#[test]
fn scan_discovers_policy_from_scan_target_not_cwd() {
let target = tempdir().unwrap();
let cwd = tempdir().unwrap();
fs::create_dir_all(target.path().join(".git")).unwrap();
fs::create_dir_all(cwd.path().join(".git")).unwrap();
fs::create_dir_all(target.path().join(".tirith")).unwrap();
fs::write(
target.path().join(".tirith").join("policy.yaml"),
"blocklist:\n - my-internal-pkg\n",
)
.unwrap();
fs::write(
target.path().join("Cargo.toml"),
"[dependencies]\nmy-internal-pkg = \"1.0\"\n",
)
.unwrap();
let from_target = Policy::discover(target.path().to_str());
assert!(
from_target.blocklist.iter().any(|e| e == "my-internal-pkg"),
"policy discovered from the scan target must carry its blocklist: \
{:?}",
from_target.blocklist,
);
let from_cwd = Policy::discover(cwd.path().to_str());
assert!(
!from_cwd.blocklist.iter().any(|e| e == "my-internal-pkg"),
"policy discovered from an unrelated cwd must NOT carry the scan \
target's blocklist: {:?}",
from_cwd.blocklist,
);
let code = scan(
target.path().to_str(),
false,
false,
false,
DEFAULT_MAX_INSTALLED_ENTRIES,
true,
true,
);
assert_eq!(
code, 0,
"ecosystem scan exits 0 (policy discovery from scan target; the \
blocklist marker does not gate packages)"
);
}
#[test]
fn scan_max_installed_entries_out_of_range_is_usage_error() {
let dir = tempdir().unwrap();
let too_low = scan(dir.path().to_str(), false, false, true, 10, true, true);
assert_eq!(too_low, 2, "below-min --max-installed-entries must exit 2");
let too_high = scan(dir.path().to_str(), false, false, true, 300_000, true, true);
assert_eq!(too_high, 2, "above-max --max-installed-entries must exit 2");
}
#[test]
fn pick_mode_recognizes_lockfile_path_arg() {
let dir = tempdir().unwrap();
let lock = dir.path().join("package-lock.json");
fs::write(&lock, "{}").unwrap();
let mode = pick_mode(&lock, false);
assert!(
matches!(mode, ScanMode::SpecificLockfile(_)),
"a single-file path-arg recognized as a lockfile must become SpecificLockfile, got {mode:?}"
);
let installed_wins = pick_mode(&lock, true);
assert!(
matches!(installed_wins, ScanMode::Installed),
"--installed must win over a single-file path-arg, got {installed_wins:?}"
);
}
#[test]
fn pick_mode_defaults_to_manifests_for_directory() {
let dir = tempdir().unwrap();
let mode = pick_mode(dir.path(), false);
assert!(
matches!(mode, ScanMode::Manifests),
"a directory must default to manifests mode, got {mode:?}"
);
}
}