use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use colored::Colorize;
use crate::analyzers::{self, Analyzer};
use crate::cache;
use crate::registry;
use crate::rules;
use crate::scoring;
use crate::types::{AnalysisContext, AnalysisReport};
use registry::tarball;
use rules::loader::load_default_rules;
use scoring::calculator;
pub 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 ctx = AnalysisContext {
name,
version: resolved_version,
files: &files,
package_json: &package_json,
metadata: &metadata,
package_dir: &package_dir,
};
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(analyzers::dataflow::DataFlowAnalyzer),
Box::new(rules::engine::RulesEngine::new(all_rules)),
Box::new(analyzers::binary::BinaryAnalyzer),
Box::new(analyzers::maintainer::MaintainerAnalyzer),
Box::new(analyzers::hallucination::HallucinationAnalyzer::new()),
];
let mut findings = Vec::new();
for a in &all_analyzers {
findings.extend(a.analyze(&ctx));
}
let cve_checker = analyzers::cve::CveChecker::new();
findings.extend(cve_checker.check_ctx(&ctx).await);
let provenance_analyzer = analyzers::provenance::ProvenanceAnalyzer::new();
findings.extend(provenance_analyzer.analyze_ctx(&ctx).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)
}