use crate::build::{central_repos, extra_repos};
use crate::descriptor::{self, Descriptor};
use anyhow::{bail, Context, Result};
use curie_deps::{DepEntry, ResolveOptions};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct AuditOptions {
pub include_test: bool,
pub offline: bool,
pub full: bool,
pub severity: f32,
pub output: Option<std::path::PathBuf>,
}
impl Default for AuditOptions {
fn default() -> Self {
AuditOptions {
include_test: false,
offline: false,
full: true,
severity: 7.0,
output: None,
}
}
}
#[derive(Debug, Clone)]
pub struct Finding {
pub purl: String,
pub id: String,
pub summary: Option<String>,
pub fixed: Vec<String>,
pub score: Option<f32>,
}
#[derive(Debug)]
pub struct AuditReport {
#[allow(dead_code)]
pub sbom_path: std::path::PathBuf,
pub findings: Vec<Finding>,
pub max_score: Option<f32>,
}
pub fn run_audit(project_root: &Path, opts: &AuditOptions) -> Result<AuditReport> {
let desc = descriptor::load(project_root)?;
if desc.is_workspace() {
bail!("`curie audit` cannot run on a workspace root; target a member with --project");
}
run_audit_with_desc(project_root, &desc, opts)
}
pub fn run_audit_with_desc(
project_root: &Path,
desc: &Descriptor,
opts: &AuditOptions,
) -> Result<AuditReport> {
crate::parallel::emit(&crate::style::headline(
"Auditing", desc.buildable_name(), desc.buildable_version(),
));
let components = resolve_components(project_root, desc, opts)?;
let sbom_path = opts.output.clone().unwrap_or_else(|| {
project_root.join("target").join("sbom.cdx.json")
});
if let Some(parent) = sbom_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create directory {}", parent.display()))?;
}
let bom = build_bom(desc, &components);
let json = serde_json::to_string_pretty(&bom).context("failed to serialise SBOM")?;
std::fs::write(&sbom_path, json)
.with_context(|| format!("failed to write SBOM to {}", sbom_path.display()))?;
crate::parallel::emit(&crate::style::audit_step(
"SBOM",
&sbom_path.strip_prefix(project_root).unwrap_or(&sbom_path).display().to_string(),
));
if opts.offline || components.is_empty() {
return Ok(AuditReport { sbom_path, findings: vec![], max_score: None });
}
let raw_findings = osv_querybatch(&components)?;
if raw_findings.is_empty() {
crate::parallel::emit(&crate::style::audit_step("Audit", "no vulnerabilities found"));
return Ok(AuditReport { sbom_path, findings: vec![], max_score: None });
}
let findings = if opts.full {
enrich_findings(raw_findings)?
} else {
raw_findings
};
print_findings(&findings, opts.full);
let max_score = findings.iter().filter_map(|f| f.score).reduce(f32::max);
Ok(AuditReport { sbom_path, findings, max_score })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DepScope {
Required, Optional, }
#[derive(Debug, Clone)]
struct Component {
group: String,
artifact: String,
version: String,
scope: DepScope,
}
impl Component {
fn purl(&self) -> String {
format!(
"pkg:maven/{}/{}@{}",
self.group, self.artifact, self.version
)
}
}
fn resolve_components(
_project_root: &Path,
desc: &Descriptor,
opts: &AuditOptions,
) -> Result<Vec<Component>> {
let opts_prod = ResolveOptions {
default_repos: central_repos(),
named_repos: extra_repos(desc),
progress: false,
bom_imports: desc.prod_bom_gavs()?,
offline: opts.offline,
};
let prod_entries: Vec<DepEntry> = desc
.dependencies
.iter()
.map(|(k, v)| DepEntry { key: k, version: v.version(), repo_id: v.repository() })
.collect();
let mut components: Vec<Component> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
if !prod_entries.is_empty() {
let tree = curie_deps::resolve_tree(&prod_entries, &opts_prod)
.context("failed to resolve production dependencies")?;
for dep in tree.resolved {
let key = format!("{}:{}", dep.gav.group, dep.gav.artifact);
if seen.insert(key) {
components.push(Component {
group: dep.gav.group,
artifact: dep.gav.artifact,
version: dep.gav.version,
scope: DepScope::Required,
});
}
}
}
if opts.include_test {
let opts_test = ResolveOptions {
default_repos: central_repos(),
named_repos: extra_repos(desc),
progress: false,
bom_imports: desc.test_bom_gavs()?,
offline: opts.offline,
};
let test_entries: Vec<DepEntry> = desc
.test_dependencies
.iter()
.map(|(k, v)| DepEntry { key: k, version: v.version(), repo_id: v.repository() })
.collect();
if !test_entries.is_empty() {
let tree = curie_deps::resolve_tree(&test_entries, &opts_test)
.context("failed to resolve test dependencies")?;
for dep in tree.resolved {
let key = format!("{}:{}", dep.gav.group, dep.gav.artifact);
if seen.insert(key) {
components.push(Component {
group: dep.gav.group,
artifact: dep.gav.artifact,
version: dep.gav.version,
scope: DepScope::Optional,
});
}
}
}
}
Ok(components)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Bom {
bom_format: &'static str,
spec_version: &'static str,
serial_number: String,
version: u32,
metadata: BomMetadata,
components: Vec<BomComponent>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct BomMetadata {
timestamp: String,
tools: BomTools,
#[serde(skip_serializing_if = "Option::is_none")]
component: Option<BomComponent>,
}
#[derive(Serialize)]
struct BomTools {
components: Vec<BomToolEntry>,
}
#[derive(Serialize)]
struct BomToolEntry {
#[serde(rename = "type")]
kind: &'static str,
name: &'static str,
version: &'static str,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct BomComponent {
#[serde(rename = "type")]
kind: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
group: Option<String>,
name: String,
version: String,
purl: String,
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<&'static str>,
}
fn build_bom(desc: &Descriptor, components: &[Component]) -> Bom {
let serial = format!("urn:uuid:{}", uuid::Uuid::new_v4());
let timestamp = chrono_utc_now();
let meta_component: Option<BomComponent> = desc.group_id().map(|gid| BomComponent {
kind: if desc.is_library() { "library" } else { "application" },
group: Some(gid.to_string()),
name: desc.buildable_name().to_string(),
version: desc.buildable_version().to_string(),
purl: format!(
"pkg:maven/{}/{}@{}",
gid,
desc.buildable_name(),
desc.buildable_version(),
),
scope: None,
});
let bom_components: Vec<BomComponent> = components
.iter()
.map(|c| BomComponent {
kind: "library",
group: Some(c.group.clone()),
name: c.artifact.clone(),
version: c.version.clone(),
purl: c.purl(),
scope: Some(match c.scope {
DepScope::Required => "required",
DepScope::Optional => "optional",
}),
})
.collect();
Bom {
bom_format: "CycloneDX",
spec_version: "1.6",
serial_number: serial,
version: 1,
metadata: BomMetadata {
timestamp,
tools: BomTools {
components: vec![BomToolEntry {
kind: "application",
name: "curie",
version: env!("CARGO_PKG_VERSION"),
}],
},
component: meta_component,
},
components: bom_components,
}
}
fn chrono_utc_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let s = secs;
let min = s / 60;
let hr = min / 60;
let days = hr / 24;
let sec = s % 60;
let min = min % 60;
let hr = hr % 24;
let (y, mo, d) = days_to_ymd(days);
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, hr, min, sec)
}
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
let z = days + 719468;
let era = z / 146097;
let doe = z % 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
const OSV_QUERYBATCH: &str = "https://api.osv.dev/v1/querybatch";
const OSV_VULNS: &str = "https://api.osv.dev/v1/vulns";
const OSV_BATCH_SIZE: usize = 1000;
#[derive(Serialize)]
struct OsvBatchRequest {
queries: Vec<OsvQuery>,
}
#[derive(Serialize)]
struct OsvQuery {
package: OsvPackage,
}
#[derive(Serialize)]
struct OsvPackage {
purl: String,
}
#[derive(Deserialize)]
struct OsvBatchResponse {
#[serde(default)]
results: Vec<OsvQueryResult>,
}
#[derive(Deserialize)]
struct OsvQueryResult {
#[serde(default)]
vulns: Vec<OsvVulnRef>,
}
#[derive(Deserialize)]
struct OsvVulnRef {
id: String,
}
#[derive(Deserialize)]
struct OsvFullVuln {
#[allow(dead_code)]
id: String,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
affected: Vec<OsvAffected>,
#[serde(default)]
database_specific: BTreeMap<String, serde_json::Value>,
}
#[derive(Deserialize)]
struct OsvAffected {
#[serde(default)]
ranges: Vec<OsvRange>,
}
#[derive(Deserialize)]
struct OsvRange {
#[serde(default)]
events: Vec<OsvEvent>,
}
#[derive(Deserialize)]
struct OsvEvent {
#[serde(default)]
fixed: Option<String>,
}
fn osv_querybatch(components: &[Component]) -> Result<Vec<Finding>> {
let client = reqwest::blocking::Client::builder()
.user_agent("curie-audit/0.1")
.timeout(std::time::Duration::from_secs(30))
.build()
.context("failed to build HTTP client")?;
let mut findings: Vec<Finding> = Vec::new();
for chunk in components.chunks(OSV_BATCH_SIZE) {
let queries: Vec<OsvQuery> = chunk
.iter()
.map(|c| OsvQuery {
package: OsvPackage { purl: c.purl() },
})
.collect();
let body = serde_json::to_string(&OsvBatchRequest { queries })
.context("failed to serialise OSV request")?;
let resp = client
.post(OSV_QUERYBATCH)
.header("Content-Type", "application/json")
.body(body)
.send()
.context("OSV querybatch request failed")?;
if !resp.status().is_success() {
bail!("OSV querybatch returned HTTP {}", resp.status());
}
let text = resp.text().context("failed to read OSV response body")?;
let parsed: OsvBatchResponse =
serde_json::from_str(&text).context("failed to parse OSV querybatch response")?;
for (component, result) in chunk.iter().zip(parsed.results.iter()) {
for vuln in &result.vulns {
findings.push(Finding {
purl: component.purl(),
id: vuln.id.clone(),
summary: None,
fixed: vec![],
score: None,
});
}
}
}
Ok(findings)
}
fn enrich_findings(raw: Vec<Finding>) -> Result<Vec<Finding>> {
let client = reqwest::blocking::Client::builder()
.user_agent("curie-audit/0.1")
.timeout(std::time::Duration::from_secs(30))
.build()
.context("failed to build HTTP client")?;
let mut enriched: Vec<Finding> = Vec::new();
for mut f in raw {
let url = format!("{}/{}", OSV_VULNS, f.id);
let resp = client
.get(&url)
.send()
.with_context(|| format!("failed to fetch OSV vuln {}", f.id))?;
if !resp.status().is_success() {
enriched.push(f);
continue;
}
let text = resp.text().context("failed to read OSV vuln body")?;
if let Ok(detail) = serde_json::from_str::<OsvFullVuln>(&text) {
f.summary = detail.summary;
f.score = extract_score(&detail.database_specific);
f.fixed = detail
.affected
.iter()
.flat_map(|a| a.ranges.iter())
.flat_map(|r| r.events.iter())
.filter_map(|e| e.fixed.clone())
.collect();
f.fixed.sort();
f.fixed.dedup();
}
enriched.push(f);
}
Ok(enriched)
}
fn extract_score(db: &BTreeMap<String, serde_json::Value>) -> Option<f32> {
if let Some(serde_json::Value::String(s)) = db.get("severity") {
return Some(match s.to_uppercase().as_str() {
"CRITICAL" => 9.0,
"HIGH" => 7.0,
"MEDIUM" => 4.0,
"LOW" => 1.0,
_ => return None,
});
}
None
}
fn vuln_link(id: &str) -> String {
let url = if id.starts_with("GHSA-") {
format!("https://github.com/advisories/{}", id)
} else if id.starts_with("CVE-") {
format!("https://www.cve.org/CVERecord?id={}", id)
} else {
format!("https://osv.dev/vulnerability/{}", id)
};
format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", url, id)
}
fn use_color() -> bool {
crate::term::use_color()
}
fn severity_color(score: Option<f32>) -> (&'static str, &'static str) {
if !use_color() {
return ("", "");
}
let reset = "\x1b[0m";
let color = match score {
Some(s) if s >= 9.0 => "\x1b[91m", Some(s) if s >= 7.0 => "\x1b[31m", Some(s) if s >= 4.0 => "\x1b[33m", Some(_) => "\x1b[36m", None => "\x1b[33m", };
(color, reset)
}
fn print_findings(findings: &[Finding], full: bool) {
let mut by_purl: Vec<(&str, Vec<&Finding>)> = Vec::new();
for f in findings {
if let Some(entry) = by_purl.iter_mut().find(|(p, _)| *p == f.purl.as_str()) {
entry.1.push(f);
} else {
by_purl.push((f.purl.as_str(), vec![f]));
}
}
let total = findings.len();
let artifacts = by_purl.len();
println!(
" {} vulnerability finding(s) across {} artifact(s):",
total, artifacts
);
for (purl, vulns) in &by_purl {
println!(" {}", purl);
for f in vulns {
let id = vuln_link(&f.id);
let (color, reset) = severity_color(f.score);
if full {
let score_str = f
.score
.map(|s| format!(" CVSS {:.1}", s))
.unwrap_or_default();
let summary = f.summary.as_deref().unwrap_or("no summary");
println!(" {}[{}]{}{} — {}", color, id, score_str, reset, summary);
if !f.fixed.is_empty() {
println!(" Fixed in: {}", f.fixed.join(", "));
}
} else {
println!(" {}[{}]{}", color, id, reset);
}
}
}
}
pub fn should_exit_nonzero(report: &AuditReport, opts: &AuditOptions) -> bool {
if report.findings.is_empty() {
return false;
}
if !opts.full {
return true;
}
match report.max_score {
Some(s) => s >= opts.severity,
None => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn days_to_ymd_epoch() {
assert_eq!(days_to_ymd(0), (1970, 1, 1));
}
#[test]
fn days_to_ymd_known_date() {
assert_eq!(days_to_ymd(10957), (2000, 1, 1));
assert_eq!(days_to_ymd(19723), (2024, 1, 1));
}
#[test]
fn purl_format() {
let c = Component {
group: "org.example".into(),
artifact: "my-lib".into(),
version: "1.2.3".into(),
scope: DepScope::Required,
};
assert_eq!(c.purl(), "pkg:maven/org.example/my-lib@1.2.3");
}
#[test]
fn bom_has_correct_format_and_spec() {
let desc = make_desc(Some("com.example"), "my-app", "1.0.0");
let bom = build_bom(&desc, &[]);
assert_eq!(bom.bom_format, "CycloneDX");
assert_eq!(bom.spec_version, "1.6");
assert!(bom.serial_number.starts_with("urn:uuid:"));
}
#[test]
fn bom_metadata_component_present_when_group_id_known() {
let desc = make_desc(Some("com.example"), "my-app", "1.0.0");
let bom = build_bom(&desc, &[]);
let meta = bom.metadata.component.expect("should have metadata component");
assert_eq!(meta.group.as_deref(), Some("com.example"));
assert_eq!(meta.name, "my-app");
assert_eq!(meta.version, "1.0.0");
assert_eq!(meta.purl, "pkg:maven/com.example/my-app@1.0.0");
}
#[test]
fn bom_metadata_component_absent_when_no_group_id() {
let desc = make_desc(None, "my-lib", "0.1.0");
let bom = build_bom(&desc, &[]);
assert!(bom.metadata.component.is_none());
}
#[test]
fn bom_components_include_scope() {
let desc = make_desc(Some("com.example"), "app", "1.0.0");
let components = vec![
Component {
group: "org.foo".into(),
artifact: "bar".into(),
version: "2.0".into(),
scope: DepScope::Required,
},
Component {
group: "org.test".into(),
artifact: "junit".into(),
version: "5.0".into(),
scope: DepScope::Optional,
},
];
let bom = build_bom(&desc, &components);
assert_eq!(bom.components.len(), 2);
assert_eq!(bom.components[0].scope, Some("required"));
assert_eq!(bom.components[1].scope, Some("optional"));
}
#[test]
fn bom_serialises_to_valid_json() {
let desc = make_desc(Some("com.example"), "app", "1.0.0");
let bom = build_bom(&desc, &[]);
let json = serde_json::to_string(&bom).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["bomFormat"], "CycloneDX");
assert_eq!(v["specVersion"], "1.6");
assert!(v["serialNumber"].as_str().unwrap().starts_with("urn:uuid:"));
}
#[test]
fn extract_score_critical() {
let mut m = BTreeMap::new();
m.insert("severity".into(), serde_json::Value::String("CRITICAL".into()));
assert_eq!(extract_score(&m), Some(9.0));
}
#[test]
fn extract_score_high() {
let mut m = BTreeMap::new();
m.insert("severity".into(), serde_json::Value::String("HIGH".into()));
assert_eq!(extract_score(&m), Some(7.0));
}
#[test]
fn extract_score_medium() {
let mut m = BTreeMap::new();
m.insert("severity".into(), serde_json::Value::String("MEDIUM".into()));
assert_eq!(extract_score(&m), Some(4.0));
}
#[test]
fn extract_score_low() {
let mut m = BTreeMap::new();
m.insert("severity".into(), serde_json::Value::String("LOW".into()));
assert_eq!(extract_score(&m), Some(1.0));
}
#[test]
fn extract_score_unknown_returns_none() {
let mut m = BTreeMap::new();
m.insert("severity".into(), serde_json::Value::String("MODERATE".into()));
assert_eq!(extract_score(&m), None);
}
#[test]
fn no_findings_never_exits_nonzero() {
let report = AuditReport {
sbom_path: std::path::PathBuf::from("sbom.cdx.json"),
findings: vec![],
max_score: None,
};
let opts = AuditOptions { full: true, severity: 7.0, ..Default::default() };
assert!(!should_exit_nonzero(&report, &opts));
}
#[test]
fn finding_without_full_always_exits_nonzero() {
let report = AuditReport {
sbom_path: std::path::PathBuf::from("sbom.cdx.json"),
findings: vec![Finding {
purl: "pkg:maven/a/b@1".into(),
id: "GHSA-xxxx".into(),
summary: None,
fixed: vec![],
score: None,
}],
max_score: None,
};
let opts = AuditOptions { full: false, severity: 7.0, ..Default::default() };
assert!(should_exit_nonzero(&report, &opts));
}
#[test]
fn finding_below_threshold_with_full_does_not_exit() {
let report = AuditReport {
sbom_path: std::path::PathBuf::from("sbom.cdx.json"),
findings: vec![Finding {
purl: "pkg:maven/a/b@1".into(),
id: "GHSA-xxxx".into(),
summary: None,
fixed: vec![],
score: Some(4.0),
}],
max_score: Some(4.0),
};
let opts = AuditOptions { full: true, severity: 7.0, ..Default::default() };
assert!(!should_exit_nonzero(&report, &opts));
}
#[test]
fn finding_at_threshold_exits_nonzero() {
let report = AuditReport {
sbom_path: std::path::PathBuf::from("sbom.cdx.json"),
findings: vec![Finding {
purl: "pkg:maven/a/b@1".into(),
id: "GHSA-xxxx".into(),
summary: None,
fixed: vec![],
score: Some(7.0),
}],
max_score: Some(7.0),
};
let opts = AuditOptions { full: true, severity: 7.0, ..Default::default() };
assert!(should_exit_nonzero(&report, &opts));
}
fn make_desc(group_id: Option<&str>, name: &str, version: &str) -> Descriptor {
use crate::descriptor::*;
use std::collections::BTreeMap;
Descriptor {
kind: DescriptorKind::Library(Library {
name: name.into(),
version: version.into(),
group_id: group_id.map(String::from),
}),
java: Java::default(),
test: Test::default(),
kotlin: Kotlin::default(),
groovy: Groovy::default(),
spock: Spock::default(),
native_image: NativeImage::default(),
docker: Docker::default(),
build_info: BuildInfo::default(),
dependencies: BTreeMap::new(),
test_dependencies: BTreeMap::new(),
repositories: vec![],
bom_imports: BTreeMap::new(),
test_bom_imports: BTreeMap::new(),
inherited_bom_imports: BTreeMap::new(),
inherited_test_bom_imports: BTreeMap::new(),
workspace_dependencies: BTreeMap::new(),
annotation_processors: BTreeMap::new(),
test_annotation_processors: BTreeMap::new(),
inherited_annotation_processors: BTreeMap::new(),
inherited_test_annotation_processors: BTreeMap::new(),
annotation_processor_options: BTreeMap::new(),
test_annotation_processor_options: BTreeMap::new(),
inherited_annotation_processor_options: BTreeMap::new(),
inherited_test_annotation_processor_options: BTreeMap::new(),
publish: PublishConfig::default(),
}
}
}