Skip to main content

jhol_core/
audit.rs

1//! Audit (vulnerabilities) and SBOM generation.
2
3use std::path::Path;
4
5use crate::lockfile;
6use crate::osv;
7use crate::utils;
8
9/// Run native audit via OSV and return vulnerability list.
10pub fn native_audit() -> Result<Vec<osv::VulnRecord>, String> {
11    let resolved = lockfile::read_resolved_from_dir(Path::new("."))
12        .ok_or("No package-lock.json or bun.lock found. Run install first.")?;
13    let mut all = Vec::new();
14    for (name, version) in &resolved {
15        match osv::query_vulnerabilities(name, version) {
16            Ok(vulns) => all.extend(vulns),
17            Err(_) => continue,
18        }
19    }
20    Ok(all)
21}
22
23/// Run audit and return raw JSON bytes (for --json output). Uses native OSV.
24pub fn run_audit_raw(_backend: crate::backend::Backend) -> Result<Vec<u8>, String> {
25    let vulns = native_audit()?;
26    let arr: Vec<serde_json::Value> = vulns
27        .into_iter()
28        .map(|v| {
29            serde_json::json!({
30                "id": v.id,
31                "summary": v.summary,
32                "severity": v.severity,
33                "package": v.package_name,
34                "version": v.package_version,
35            })
36        })
37        .collect();
38    let out = serde_json::json!({ "vulnerabilities": arr });
39    serde_json::to_vec(&out).map_err(|e| e.to_string())
40}
41
42/// Run audit and print summary. Uses native OSV.
43pub fn run_audit(_backend: crate::backend::Backend, quiet: bool) -> Result<(), String> {
44    if !Path::new("package.json").exists() {
45        return Err("No package.json found in current directory.".to_string());
46    }
47    utils::log("Running audit...");
48    let vulns = native_audit()?;
49    if quiet {
50        if !vulns.is_empty() {
51            return Err(format!("{} vulnerability(ies) found.", vulns.len()));
52        }
53        return Ok(());
54    }
55    if vulns.is_empty() {
56        println!("No vulnerabilities found.");
57        return Ok(());
58    }
59    println!("Found {} vulnerability(ies):", vulns.len());
60    for v in &vulns {
61        let sev = v.severity.as_deref().unwrap_or("unknown");
62        println!("  {}@{} ({}): {} - {}", v.package_name, v.package_version, sev, v.id, v.summary);
63    }
64    Ok(())
65}
66
67/// Run audit fix: print upgrade suggestions (no backend). No automatic fix.
68pub fn run_audit_fix(_backend: crate::backend::Backend, quiet: bool) -> Result<(), String> {
69    if !Path::new("package.json").exists() {
70        return Err("No package.json found in current directory.".to_string());
71    }
72    utils::log("Running audit fix...");
73    let vulns = native_audit()?;
74    if vulns.is_empty() {
75        if !quiet {
76            println!("No vulnerabilities to fix.");
77        }
78        return Ok(());
79    }
80    if !quiet {
81        println!("Vulnerable packages (update manually or run jhol install <pkg>@latest):");
82        let mut seen = std::collections::HashSet::new();
83        for v in &vulns {
84            let key = (v.package_name.as_str(), v.package_version.as_str());
85            if seen.insert(key) {
86                println!("  {}@{} - {}", v.package_name, v.package_version, v.id);
87            }
88        }
89    }
90    Ok(())
91}
92
93/// Run audit and fail if vulnerabilities are found. Useful for CI gating.
94pub fn run_audit_gate(_backend: crate::backend::Backend) -> Result<(), String> {
95    let vulns = native_audit()?;
96    if vulns.is_empty() {
97        return Ok(());
98    }
99    Err(format!("audit gate failed: {} vulnerability(ies) found", vulns.len()))
100}
101
102/// SBOM format.
103#[derive(Clone, Copy, Debug, Eq, PartialEq)]
104pub enum SbomFormat {
105    CycloneDx,
106    Simple,
107}
108
109/// Generate SBOM from package.json + lockfile. Returns JSON string.
110pub fn generate_sbom(format: SbomFormat) -> Result<String, String> {
111    let pj = Path::new("package.json");
112    if !pj.exists() {
113        return Err("No package.json in current directory.".to_string());
114    }
115    let deps = lockfile::read_package_json_deps(pj).ok_or("Could not read package.json.")?;
116    let resolved = lockfile::read_resolved_from_dir(Path::new("."));
117    let specs = lockfile::resolve_deps_for_install(&deps, resolved.as_ref());
118    let components: Vec<serde_json::Value> = specs
119        .iter()
120        .map(|s| {
121            let (name, version) = if let Some(i) = s.rfind('@') {
122                (s[..i].to_string(), s[i + 1..].to_string())
123            } else {
124                (s.clone(), "?".to_string())
125            };
126            (name, version)
127        })
128        .map(|(name, version)| {
129            serde_json::json!({
130                "name": name,
131                "version": version,
132            })
133        })
134        .collect();
135    let out = match format {
136        SbomFormat::CycloneDx => {
137            serde_json::json!({
138                "bomFormat": "CycloneDX",
139                "specVersion": "1.4",
140                "version": 1,
141                "components": components,
142            })
143        }
144        SbomFormat::Simple => serde_json::json!(components),
145    };
146    serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
147}