use std::collections::BTreeMap;
use serde_json::{json, Value};
use crate::types::{AnalysisReport, FindingCategory, Severity};
pub fn generate_sarif(reports: &[AnalysisReport]) -> Value {
let mut all_findings = Vec::new();
for report in reports {
for finding in &report.findings {
all_findings.push((report, finding));
}
}
let mut rules_map: BTreeMap<String, Value> = BTreeMap::new();
for (_report, finding) in &all_findings {
let rule_id = category_to_rule_id(&finding.category);
rules_map.entry(rule_id.clone()).or_insert_with(|| {
json!({
"id": rule_id,
"shortDescription": {
"text": finding.category.to_string()
},
"defaultConfiguration": {
"level": severity_to_level(&finding.severity)
}
})
});
}
let rules: Vec<Value> = rules_map.values().cloned().collect();
let results: Vec<Value> = all_findings
.iter()
.map(|(report, finding)| {
let rule_id = category_to_rule_id(&finding.category);
let level = severity_to_level(&finding.severity);
let fallback_uri = format!("{}@{}", report.package_name, report.version);
let uri = finding.file.as_deref().unwrap_or(&fallback_uri);
let start_line = finding.line.unwrap_or(1);
json!({
"ruleId": rule_id,
"level": level,
"message": {
"text": finding.description
},
"locations": [{
"physicalLocation": {
"artifactLocation": {
"uri": uri
},
"region": {
"startLine": start_line
}
}
}]
})
})
.collect();
json!({
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [{
"tool": {
"driver": {
"name": "aegis-scan",
"version": env!("CARGO_PKG_VERSION"),
"informationUri": "https://github.com/z8run/aegis",
"rules": rules
}
},
"results": results
}]
})
}
fn category_to_rule_id(category: &FindingCategory) -> String {
match category {
FindingCategory::CodeExecution => "aegis/code-execution".to_string(),
FindingCategory::NetworkAccess => "aegis/network-access".to_string(),
FindingCategory::ProcessSpawn => "aegis/process-spawn".to_string(),
FindingCategory::FileSystemAccess => "aegis/filesystem-access".to_string(),
FindingCategory::Obfuscation => "aegis/obfuscation".to_string(),
FindingCategory::InstallScript => "aegis/install-script".to_string(),
FindingCategory::EnvAccess => "aegis/env-access".to_string(),
FindingCategory::Suspicious => "aegis/suspicious".to_string(),
FindingCategory::MaintainerChange => "aegis/maintainer-change".to_string(),
FindingCategory::HallucinatedPackage => "aegis/hallucinated-package".to_string(),
FindingCategory::KnownVulnerability => "aegis/known-vulnerability".to_string(),
FindingCategory::DependencyRisk => "aegis/dependency-risk".to_string(),
FindingCategory::Provenance => "aegis/provenance".to_string(),
FindingCategory::BinaryFile => "aegis/binary-file".to_string(),
FindingCategory::DataFlow => "aegis/data-flow".to_string(),
}
}
fn severity_to_level(severity: &Severity) -> &'static str {
match severity {
Severity::Critical | Severity::High => "error",
Severity::Medium => "warning",
Severity::Low | Severity::Info => "note",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Finding, RiskLabel};
fn make_report(findings: Vec<Finding>) -> AnalysisReport {
AnalysisReport {
package_name: "test-pkg".to_string(),
version: "1.0.0".to_string(),
findings,
risk_score: 5.0,
risk_label: RiskLabel::Medium,
}
}
#[test]
fn sarif_structure_valid() {
let report = make_report(vec![Finding {
severity: Severity::Critical,
category: FindingCategory::CodeExecution,
title: "eval detected".to_string(),
description: "Dynamic eval usage".to_string(),
file: Some("index.js".to_string()),
line: Some(42),
snippet: Some("eval(x)".to_string()),
}]);
let sarif = generate_sarif(&[report]);
assert_eq!(sarif["version"], "2.1.0");
assert!(sarif["$schema"]
.as_str()
.unwrap()
.contains("sarif-schema-2.1.0"));
let runs = sarif["runs"].as_array().unwrap();
assert_eq!(runs.len(), 1);
let driver = &runs[0]["tool"]["driver"];
assert_eq!(driver["name"], "aegis-scan");
assert_eq!(driver["version"], env!("CARGO_PKG_VERSION"));
let rules = driver["rules"].as_array().unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0]["id"], "aegis/code-execution");
let results = runs[0]["results"].as_array().unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0]["ruleId"], "aegis/code-execution");
assert_eq!(results[0]["level"], "error");
assert_eq!(results[0]["message"]["text"], "Dynamic eval usage");
let loc = &results[0]["locations"][0]["physicalLocation"];
assert_eq!(loc["artifactLocation"]["uri"], "index.js");
assert_eq!(loc["region"]["startLine"], 42);
}
#[test]
fn sarif_empty_reports() {
let sarif = generate_sarif(&[]);
let results = sarif["runs"][0]["results"].as_array().unwrap();
assert!(results.is_empty());
}
#[test]
fn sarif_multiple_reports() {
let r1 = make_report(vec![Finding {
severity: Severity::High,
category: FindingCategory::NetworkAccess,
title: "fetch call".to_string(),
description: "Network access detected".to_string(),
file: Some("lib.js".to_string()),
line: Some(10),
snippet: None,
}]);
let r2 = make_report(vec![Finding {
severity: Severity::Low,
category: FindingCategory::Obfuscation,
title: "obfuscated code".to_string(),
description: "Obfuscation detected".to_string(),
file: None,
line: None,
snippet: None,
}]);
let sarif = generate_sarif(&[r1, r2]);
let results = sarif["runs"][0]["results"].as_array().unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0]["level"], "error");
assert_eq!(results[1]["level"], "note");
}
#[test]
fn severity_levels_correct() {
assert_eq!(severity_to_level(&Severity::Critical), "error");
assert_eq!(severity_to_level(&Severity::High), "error");
assert_eq!(severity_to_level(&Severity::Medium), "warning");
assert_eq!(severity_to_level(&Severity::Low), "note");
assert_eq!(severity_to_level(&Severity::Info), "note");
}
#[test]
fn sarif_no_file_uses_package_fallback() {
let report = make_report(vec![Finding {
severity: Severity::Medium,
category: FindingCategory::MaintainerChange,
title: "maintainer changed".to_string(),
description: "Ownership changed".to_string(),
file: None,
line: None,
snippet: None,
}]);
let sarif = generate_sarif(&[report]);
let uri = sarif["runs"][0]["results"][0]["locations"][0]["physicalLocation"]
["artifactLocation"]["uri"]
.as_str()
.unwrap();
assert_eq!(uri, "test-pkg@1.0.0");
}
}