use std::collections::BTreeMap;
use serde_json::{json, Value};
use crate::api::{severity_rank, BatchResults, ThreatEntry};
fn level(sev: Option<&str>) -> &'static str {
match severity_rank(sev) {
4 | 3 => "error",
2 => "warning",
_ => "note",
}
}
fn push_entry(rules: &mut BTreeMap<String, Value>, results: &mut Vec<Value>, asset: &str, t: &ThreatEntry) {
let id = t.cve_id.clone().unwrap_or_else(|| "UNKNOWN".to_string());
let rule = rules.entry(id.clone()).or_insert_with(|| {
let mut rule = json!({
"id": id,
"name": id,
"shortDescription": { "text": t.title.clone().unwrap_or_else(|| id.clone()) },
});
if let Some(url) = t.radar_url.as_ref().or_else(|| t.references.first()) {
rule["helpUri"] = json!(url);
}
rule
});
if let Some(score) = t.cvss_score.as_ref().and_then(|v| v.as_f64()) {
let prev = rule["properties"]["security-severity"]
.as_str()
.and_then(|s| s.parse::<f64>().ok());
if prev.is_none_or(|p| score > p) {
rule["properties"]["security-severity"] = json!(score.to_string());
}
}
let mut text = format!(
"{asset} is affected by {id} ({})",
t.severity.clone().unwrap_or_else(|| "unknown".to_string())
);
if !t.fixed_versions.is_empty() {
text.push_str(&format!(". Fixed in {}", t.fixed_versions.join(", ")));
}
if let Some(rem) = &t.remediation {
text.push_str(&format!(". {rem}"));
}
let mut props = json!({
"kev": t.kev,
"epss": t.epss,
"confirmed": t.confirmed,
"matchBasis": t.match_basis,
});
if let Some(decision) = &t.decision {
props["decision"] = json!(decision);
}
if let Some(score) = t.risk_score {
props["riskScore"] = json!(score);
}
if !t.cwes.is_empty() {
props["cwe"] = json!(t.cwes);
}
if !t.fixed_versions.is_empty() {
props["fixedVersions"] = json!(t.fixed_versions);
}
let mut result = json!({
"ruleId": id,
"level": level(t.severity.as_deref()),
"message": { "text": text },
"locations": [{
"logicalLocations": [{ "fullyQualifiedName": asset, "kind": "module" }]
}],
"properties": props,
});
if let Some(rem) = &t.remediation {
result["fixes"] = json!([{ "description": { "text": rem } }]);
}
results.push(result);
}
pub fn to_sarif(results: &BatchResults) -> String {
let mut rules: BTreeMap<String, Value> = BTreeMap::new();
let mut out: Vec<Value> = Vec::new();
for (asset, entries) in &results.services {
for t in entries {
push_entry(&mut rules, &mut out, asset, t);
}
}
let doc = json!({
"version": "2.1.0",
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"runs": [{
"tool": { "driver": {
"name": "threat-finder",
"version": env!("CARGO_PKG_VERSION"),
"informationUri": "https://radar.offseq.com",
"rules": rules.into_values().collect::<Vec<_>>(),
}},
"results": out,
}]
});
serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string())
}