mod analyzers;
mod cache;
mod output;
mod registry;
mod rules;
mod scoring;
mod types;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use colored::Colorize;
use analyzers::Analyzer;
use registry::tarball;
use rules::loader::load_default_rules;
use scoring::calculator;
use types::{AnalysisReport, RiskLabel};
#[derive(Parser)]
#[command(
name = "aegis-scan",
about = "Supply-chain security scanner for npm packages",
version,
after_help = "Examples:\n aegis check axios\n aegis check axios@1.7.0\n aegis check @scope/pkg@1.0.0\n aegis check lodash --json\n aegis scan .\n aegis scan ./my-project --skip-dev\n aegis install axios express\n aegis install --force\n aegis install"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(long, global = true, conflicts_with = "sarif")]
json: bool,
#[arg(long, global = true, conflicts_with = "json")]
sarif: bool,
#[arg(long, short, global = true)]
verbose: bool,
#[arg(long, global = true)]
no_cache: bool,
#[arg(long, global = true)]
rules: Option<PathBuf>,
}
#[derive(Subcommand)]
enum Commands {
Check {
package: String,
#[arg(long)]
compare: Option<String>,
#[arg(long)]
deep: bool,
},
Scan {
path: PathBuf,
#[arg(long)]
skip_dev: bool,
},
Install {
packages: Vec<String>,
#[arg(long)]
force: bool,
#[arg(long)]
skip_dev: bool,
},
Cache {
#[command(subcommand)]
action: CacheCommands,
},
}
#[derive(Subcommand)]
enum CacheCommands {
Clear,
}
fn parse_package_specifier(spec: &str) -> (String, Option<String>) {
if let Some(scoped) = spec.strip_prefix('@') {
if let Some(at_pos) = scoped.find('@') {
if scoped[..at_pos].contains('/') {
let name = format!("@{}", &scoped[..at_pos]);
let version = scoped[at_pos + 1..].to_string();
return (name, Some(version));
}
}
(spec.to_string(), None)
} else {
match spec.split_once('@') {
Some((name, version)) => (name.to_string(), Some(version.to_string())),
None => (spec.to_string(), None),
}
}
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let filter = if cli.verbose { "debug" } else { "warn" };
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)),
)
.with_target(false)
.init();
let result = match &cli.command {
Commands::Check {
package,
compare,
deep,
} => {
run_check(
package,
cli.json,
cli.sarif,
cli.no_cache,
compare.as_deref(),
*deep,
cli.rules.as_deref(),
)
.await
}
Commands::Scan { path, skip_dev } => {
run_scan(
path,
*skip_dev,
cli.json,
cli.sarif,
cli.no_cache,
cli.rules.as_deref(),
)
.await
}
Commands::Install {
packages,
force,
skip_dev,
} => run_install(packages, *force, *skip_dev, cli.no_cache).await,
Commands::Cache { action } => match action {
CacheCommands::Clear => cache::clear_cache(),
},
};
if let Err(err) = result {
eprintln!("{} {:#}", "Error:".red().bold(), err);
process::exit(2);
}
}
async fn analyze_package(
name: &str,
version: Option<&str>,
use_cache: bool,
progress_prefix: &str,
custom_rules_dir: Option<&Path>,
) -> Result<AnalysisReport> {
let display_version = version.unwrap_or("latest");
eprintln!(
"{}Fetching {}@{} from npm registry...",
progress_prefix,
name.bold(),
display_version
);
let metadata = registry::client::fetch_package_metadata(name, version)
.await
.with_context(|| format!("could not fetch metadata for '{}'", name))?;
let resolved_version = version
.or_else(|| metadata.latest_version())
.unwrap_or("0.0.0");
if use_cache {
if let Some(cached) = cache::get_cached(name, resolved_version) {
eprintln!(
"{}Using cached result for {}@{}",
progress_prefix,
name.bold(),
resolved_version
);
return Ok(cached);
}
}
let version_info = metadata.versions.get(resolved_version).with_context(|| {
format!(
"version '{}' not found in registry data for '{}'",
resolved_version, name
)
})?;
let tarball_url = version_info
.dist
.as_ref()
.and_then(|d| d.tarball.as_deref())
.with_context(|| format!("no tarball URL found for {}@{}", name, resolved_version))?;
let (_tmp_dir, package_dir) = tarball::download_and_extract_temp(tarball_url)
.await
.context("failed to download/extract tarball")?;
let js_paths = tarball::collect_js_files(&package_dir);
let files: Vec<(PathBuf, String)> = js_paths
.into_iter()
.filter_map(|path| {
let content = std::fs::read_to_string(&path).ok()?;
let rel = path
.strip_prefix(&package_dir)
.unwrap_or(&path)
.to_path_buf();
Some((rel, content))
})
.collect();
let package_json_path = package_dir.join("package.json");
let package_json: serde_json::Value = if package_json_path.exists() {
let raw =
std::fs::read_to_string(&package_json_path).context("failed to read package.json")?;
serde_json::from_str(&raw).context("failed to parse package.json")?
} else {
serde_json::Value::Object(serde_json::Map::new())
};
let mut all_rules = load_default_rules();
if let Some(rules_dir) = custom_rules_dir {
match rules::loader::load_rules(rules_dir) {
Ok(custom) => all_rules.extend(custom),
Err(e) => tracing::warn!("failed to load custom rules: {:#}", e),
}
}
let all_analyzers: Vec<Box<dyn Analyzer>> = vec![
Box::new(analyzers::static_code::StaticCodeAnalyzer),
Box::new(analyzers::install_scripts::InstallScriptAnalyzer),
Box::new(analyzers::obfuscation::ObfuscationAnalyzer),
Box::new(analyzers::ast::AstAnalyzer),
Box::new(rules::engine::RulesEngine::new(all_rules)),
];
let mut findings = Vec::new();
for a in &all_analyzers {
findings.extend(a.analyze(&files, &package_json));
}
let maintainer_analyzer = analyzers::maintainer::MaintainerAnalyzer;
findings.extend(maintainer_analyzer.analyze(&metadata));
let hallucination_analyzer = analyzers::hallucination::HallucinationAnalyzer::new();
findings.extend(hallucination_analyzer.analyze(&metadata));
let cve_checker = analyzers::cve::CveChecker::new();
findings.extend(cve_checker.check(name, resolved_version).await);
findings.sort_by(|a, b| b.severity.cmp(&a.severity));
let report = calculator::build_report(name, resolved_version, findings);
if use_cache {
if let Err(e) = cache::save_cache(&report) {
tracing::warn!("failed to save cache: {:#}", e);
}
}
drop(_tmp_dir);
Ok(report)
}
async fn run_check(
package: &str,
json_output: bool,
sarif_output: bool,
no_cache: bool,
compare_version: Option<&str>,
deep: bool,
custom_rules_dir: Option<&Path>,
) -> Result<()> {
let (name, version) = parse_package_specifier(package);
let prefix = " \u{1f50d} ";
let mut report = analyze_package(
&name,
version.as_deref(),
!no_cache,
prefix,
custom_rules_dir,
)
.await?;
if let Some(old_ver) = compare_version {
eprintln!(
" \u{1f50d} Comparing {}@{} against {}@{}...",
name, report.version, name, old_ver
);
let old_tarball_url = {
let old_meta = registry::client::fetch_package_metadata(&name, Some(old_ver))
.await
.with_context(|| format!("could not fetch metadata for '{}@{}'", name, old_ver))?;
let old_vi = old_meta
.versions
.get(old_ver)
.with_context(|| format!("version '{}' not found for '{}'", old_ver, name))?;
old_vi
.dist
.as_ref()
.and_then(|d| d.tarball.clone())
.with_context(|| format!("no tarball URL for {}@{}", name, old_ver))?
};
let new_tarball_url = {
let new_meta = registry::client::fetch_package_metadata(&name, Some(&report.version))
.await
.with_context(|| {
format!("could not fetch metadata for '{}@{}'", name, report.version)
})?;
let new_vi = new_meta.versions.get(&report.version).with_context(|| {
format!("version '{}' not found for '{}'", report.version, name)
})?;
new_vi
.dist
.as_ref()
.and_then(|d| d.tarball.clone())
.with_context(|| format!("no tarball URL for {}@{}", name, report.version))?
};
let (_old_tmp, old_dir) = tarball::download_and_extract_temp(&old_tarball_url)
.await
.context("failed to download old version tarball")?;
let (_new_tmp, new_dir) = tarball::download_and_extract_temp(&new_tarball_url)
.await
.context("failed to download new version tarball")?;
let diff_findings = analyzers::diff::DiffAnalyzer::analyze_diff(
&old_dir,
&new_dir,
old_ver,
&report.version,
);
report.findings.extend(diff_findings);
report.findings.sort_by(|a, b| b.severity.cmp(&a.severity));
}
if deep {
eprintln!(
" \u{1f50d} Running deep dependency tree analysis for {}@{}...",
name, report.version
);
let tree_findings = analyzers::deptree::DepTreeAnalyzer::new()
.analyze(&name, &report.version, None)
.await;
report.findings.extend(tree_findings);
report.findings.sort_by(|a, b| b.severity.cmp(&a.severity));
}
if compare_version.is_some() || deep {
report = scoring::calculator::build_report(
&report.package_name,
&report.version,
report.findings,
);
}
if sarif_output {
let sarif = output::sarif::generate_sarif(std::slice::from_ref(&report));
println!("{}", serde_json::to_string_pretty(&sarif).unwrap());
} else if json_output {
output::json::print_json(&report);
} else {
output::terminal::print_report(&report);
println!();
}
let exit_high = matches!(report.risk_label, RiskLabel::High | RiskLabel::Critical);
if exit_high {
process::exit(1);
}
Ok(())
}
fn collect_dependencies(project_path: &Path, skip_dev: bool) -> Result<Vec<(String, String)>> {
let pkg_path = project_path.join("package.json");
let raw = std::fs::read_to_string(&pkg_path)
.with_context(|| format!("could not read {}", pkg_path.display()))?;
let pkg: serde_json::Value =
serde_json::from_str(&raw).context("failed to parse package.json")?;
let mut deps: Vec<(String, String)> = Vec::new();
if let Some(obj) = pkg.get("dependencies").and_then(|v| v.as_object()) {
for (name, ver) in obj {
deps.push((name.clone(), ver.as_str().unwrap_or("latest").to_string()));
}
}
if !skip_dev {
if let Some(obj) = pkg.get("devDependencies").and_then(|v| v.as_object()) {
for (name, ver) in obj {
deps.push((name.clone(), ver.as_str().unwrap_or("latest").to_string()));
}
}
}
deps.sort_by(|a, b| a.0.cmp(&b.0));
Ok(deps)
}
fn clean_version_spec(spec: &str) -> Option<String> {
let trimmed = spec
.trim_start_matches('^')
.trim_start_matches('~')
.trim_start_matches(">=")
.trim_start_matches('=')
.trim();
if trimmed.is_empty() || trimmed == "*" || trimmed.contains("||") || trimmed.contains(' ') {
return None;
}
Some(trimmed.to_string())
}
async fn run_scan(
project_path: &Path,
skip_dev: bool,
json_output: bool,
sarif_output: bool,
no_cache: bool,
custom_rules_dir: Option<&Path>,
) -> Result<()> {
let deps = collect_dependencies(project_path, skip_dev)?;
let total = deps.len();
if total == 0 {
println!("No dependencies found in {}", project_path.display());
return Ok(());
}
eprintln!("\n\u{1f4e6} Scanning {} dependencies...\n", total);
let use_cache = !no_cache;
let mut reports: Vec<AnalysisReport> = Vec::new();
let mut errors: Vec<(String, String)> = Vec::new();
for (i, (name, version_spec)) in deps.iter().enumerate() {
let idx = i + 1;
let version_hint = clean_version_spec(version_spec);
let display_ver = version_hint.as_deref().unwrap_or("latest");
let prefix = format!(" [{}/{}] ", idx, total);
eprintln!(
" [{}/{}] Checking {}@{}...",
idx,
total,
name.bold(),
display_ver
);
match analyze_package(
name,
version_hint.as_deref(),
use_cache,
&prefix,
custom_rules_dir,
)
.await
{
Ok(report) => reports.push(report),
Err(e) => {
eprintln!(
" [{}/{}] \u{274c} Failed to analyze {}: {:#}",
idx, total, name, e
);
errors.push((name.clone(), format!("{:#}", e)));
}
}
}
if sarif_output {
let sarif = output::sarif::generate_sarif(&reports);
println!(
"{}",
serde_json::to_string_pretty(&sarif).context("failed to serialize SARIF output")?
);
} else if json_output {
let json =
serde_json::to_string_pretty(&reports).context("failed to serialize scan results")?;
println!("{}", json);
} else {
print_scan_summary(&reports, &errors);
}
let has_high = reports
.iter()
.any(|r| matches!(r.risk_label, RiskLabel::High | RiskLabel::Critical));
if has_high {
process::exit(1);
}
Ok(())
}
struct RiskBuckets {
critical: Vec<AnalysisReport>,
high: Vec<AnalysisReport>,
medium: Vec<AnalysisReport>,
clean: Vec<AnalysisReport>, }
fn bucket_reports(reports: &[AnalysisReport]) -> RiskBuckets {
let mut b = RiskBuckets {
critical: Vec::new(),
high: Vec::new(),
medium: Vec::new(),
clean: Vec::new(),
};
for r in reports {
match r.risk_label {
RiskLabel::Critical => b.critical.push(r.clone()),
RiskLabel::High => b.high.push(r.clone()),
RiskLabel::Medium => b.medium.push(r.clone()),
RiskLabel::Low | RiskLabel::Clean => b.clean.push(r.clone()),
}
}
b
}
fn print_scan_summary(reports: &[AnalysisReport], errors: &[(String, String)]) {
let b = bucket_reports(reports);
println!();
if !b.critical.is_empty() {
println!(
" \u{26d4} {} critical",
b.critical.len().to_string().red().bold()
);
}
if !b.high.is_empty() {
println!(
" \u{26a0}\u{fe0f} {} high risk",
b.high.len().to_string().red()
);
}
if !b.medium.is_empty() {
println!(
" \u{26a1} {} medium risk",
b.medium.len().to_string().yellow()
);
}
println!(" \u{2705} {} clean", b.clean.len().to_string().green());
if !errors.is_empty() {
println!(" \u{274c} {} failed", errors.len().to_string().red());
}
println!();
if !b.critical.is_empty() {
println!(" {}:", "CRITICAL".red().bold());
print_report_list(&b.critical);
println!();
}
if !b.high.is_empty() {
println!(" {}:", "HIGH".red());
print_report_list(&b.high);
println!();
}
if !b.medium.is_empty() {
println!(" {}:", "MEDIUM".yellow());
print_report_list(&b.medium);
println!();
}
if !errors.is_empty() {
println!(" {}:", "ERRORS".red());
for (i, (name, err)) in errors.iter().enumerate() {
let connector = if i == errors.len() - 1 {
"\u{2514}\u{2500}"
} else {
"\u{251c}\u{2500}"
};
println!(" {} {} \u{2014} {}", connector, name.bold(), err.dimmed());
}
println!();
}
println!(
" Full results: {} for details",
"aegis check <package>".bold()
);
println!();
}
fn print_report_list(reports: &[AnalysisReport]) {
for (i, r) in reports.iter().enumerate() {
let connector = if i == reports.len() - 1 {
"\u{2514}\u{2500}"
} else {
"\u{251c}\u{2500}"
};
let desc = if r.findings.is_empty() {
"no details".to_string()
} else {
let summaries: Vec<&str> = r
.findings
.iter()
.take(2)
.map(|f| f.title.as_str())
.collect();
summaries.join(", ")
};
println!(
" {} {}@{} \u{2014} {} ({:.1}/10)",
connector,
r.package_name.bold(),
r.version,
desc,
r.risk_score
);
}
}
fn confirm(prompt: &str) -> bool {
eprint!("{}", prompt);
std::io::stderr().flush().ok();
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return false;
}
matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
}
fn run_npm_install(packages: &[String]) -> Result<()> {
let mut cmd = std::process::Command::new("npm");
cmd.arg("install");
for pkg in packages {
cmd.arg(pkg);
}
eprintln!("\n\u{1f4e6} Running: npm install {}\n", packages.join(" "));
let status = cmd
.status()
.context("failed to run `npm install` — is npm installed and on PATH?")?;
if !status.success() {
anyhow::bail!("`npm install` exited with status {}", status);
}
Ok(())
}
async fn run_install(
packages: &[String],
force: bool,
skip_dev: bool,
no_cache: bool,
) -> Result<()> {
let use_cache = !no_cache;
if packages.is_empty() {
let project_path = PathBuf::from(".");
let deps = collect_dependencies(&project_path, skip_dev)?;
let total = deps.len();
if total == 0 {
eprintln!("No dependencies found in package.json — running npm install directly.");
return run_npm_install(&[]);
}
eprintln!(
"\n\u{1f50d} Scanning {} dependencies before install...\n",
total
);
let mut reports: Vec<AnalysisReport> = Vec::new();
let mut errors: Vec<(String, String)> = Vec::new();
for (i, (name, version_spec)) in deps.iter().enumerate() {
let idx = i + 1;
let version_hint = clean_version_spec(version_spec);
let display_ver = version_hint.as_deref().unwrap_or("latest");
let prefix = format!(" [{}/{}] ", idx, total);
eprintln!(
" [{}/{}] Checking {}@{}...",
idx,
total,
name.bold(),
display_ver
);
match analyze_package(name, version_hint.as_deref(), use_cache, &prefix, None).await {
Ok(report) => reports.push(report),
Err(e) => {
eprintln!(
" [{}/{}] \u{274c} Failed to analyze {}: {:#}",
idx, total, name, e
);
errors.push((name.clone(), format!("{:#}", e)));
}
}
}
print_scan_summary(&reports, &errors);
let risky: Vec<&AnalysisReport> = reports
.iter()
.filter(|r| matches!(r.risk_label, RiskLabel::High | RiskLabel::Critical))
.collect();
if !risky.is_empty() && !force {
eprintln!(
"\u{26a0}\u{fe0f} {} package(s) rated HIGH or CRITICAL risk:",
risky.len()
);
for r in &risky {
eprintln!(
" - {}@{} ({}, {:.1}/10)",
r.package_name.bold(),
r.version,
r.risk_label,
r.risk_score
);
}
eprintln!();
if !confirm("Proceed with npm install anyway? [y/N] ") {
eprintln!("Aborted.");
process::exit(1);
}
}
run_npm_install(&[])
} else {
let mut approved: Vec<String> = Vec::new();
let prefix = " \u{1f50d} ";
for spec in packages {
let (name, version) = parse_package_specifier(spec);
eprintln!("\n\u{1f50d} Checking {} before install...\n", spec.bold());
let report =
match analyze_package(&name, version.as_deref(), use_cache, prefix, None).await {
Ok(r) => r,
Err(e) => {
eprintln!("\u{274c} Failed to analyze {}: {:#}", spec, e);
if force
|| confirm(&format!(
"Could not analyze {}. Install anyway? [y/N] ",
spec
))
{
approved.push(spec.clone());
}
continue;
}
};
output::terminal::print_report(&report);
let is_risky = matches!(report.risk_label, RiskLabel::High | RiskLabel::Critical);
if is_risky && !force {
let prompt = format!(
"\n\u{26a0}\u{fe0f} {} has {} ({:.1}/10). Install anyway? [y/N] ",
spec.bold(),
report.risk_label.to_string().red(),
report.risk_score
);
if !confirm(&prompt) {
eprintln!("Skipping {}.", spec);
continue;
}
}
approved.push(spec.clone());
}
if approved.is_empty() {
eprintln!("No packages approved for installation.");
return Ok(());
}
run_npm_install(&approved)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_unscoped_no_version() {
let (name, ver) = parse_package_specifier("axios");
assert_eq!(name, "axios");
assert_eq!(ver, None);
}
#[test]
fn parse_unscoped_with_version() {
let (name, ver) = parse_package_specifier("axios@1.7.0");
assert_eq!(name, "axios");
assert_eq!(ver, Some("1.7.0".to_string()));
}
#[test]
fn parse_scoped_no_version() {
let (name, ver) = parse_package_specifier("@scope/pkg");
assert_eq!(name, "@scope/pkg");
assert_eq!(ver, None);
}
#[test]
fn parse_scoped_with_version() {
let (name, ver) = parse_package_specifier("@scope/pkg@1.0.0");
assert_eq!(name, "@scope/pkg");
assert_eq!(ver, Some("1.0.0".to_string()));
}
#[test]
fn clean_version_spec_caret() {
assert_eq!(clean_version_spec("^4.18.0"), Some("4.18.0".to_string()));
}
#[test]
fn clean_version_spec_tilde() {
assert_eq!(clean_version_spec("~1.2.3"), Some("1.2.3".to_string()));
}
#[test]
fn clean_version_spec_star() {
assert_eq!(clean_version_spec("*"), None);
}
#[test]
fn clean_version_spec_range() {
assert_eq!(clean_version_spec(">=1.0.0 <2.0.0"), None);
}
#[test]
fn clean_version_spec_exact() {
assert_eq!(clean_version_spec("1.0.0"), Some("1.0.0".to_string()));
}
}