use serde::{Deserialize, Serialize};
use cleanlib_client::types::{PolicyDecision, Verdict};
use cleanlib_client::CustomerState;
use super::output::decision_tier_str_with_severity;
use super::sanitize::mask_engine_tag;
pub const SARIF_SCHEMA_URI: &str =
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
pub const SARIF_VERSION: &str = "2.1.0";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifLog {
#[serde(rename = "$schema")]
pub schema: String,
pub version: String,
pub runs: Vec<SarifRun>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifRun {
pub tool: SarifTool,
pub results: Vec<SarifResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifTool {
pub driver: SarifDriver,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifDriver {
pub name: String,
pub version: String,
#[serde(rename = "informationUri", skip_serializing_if = "Option::is_none")]
pub information_uri: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifResult {
#[serde(rename = "ruleId")]
pub rule_id: String,
pub level: String,
pub message: SarifMessage,
pub locations: Vec<SarifLocation>,
pub properties: SarifResultProperties,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifMessage {
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifLocation {
#[serde(rename = "logicalLocations")]
pub logical_locations: Vec<SarifLogicalLocation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifLogicalLocation {
pub name: String,
#[serde(rename = "fullyQualifiedName")]
pub fully_qualified_name: String,
pub kind: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifResultProperties {
pub verdict: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub composite_score: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verdict_id: Option<String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub state: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub state_label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub audit_record_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audit_record_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attestation: Option<serde_json::Value>,
}
pub fn decision_to_sarif_level(decision: &str) -> &'static str {
match decision {
"ALLOW" => "note",
"WARN" | "RISK_ACCEPTANCE_REQUIRED" | "INSUFFICIENT_DATA" => "warning",
"DENY" => "error",
_ => "warning",
}
}
pub fn verdict_to_sarif(
verdict: &Verdict,
ecosystem: &str,
package: &str,
version: &str,
cli_version: &str,
) -> SarifLog {
let decision = decision_tier_str_with_severity(
&verdict.verdict,
verdict.severity.as_deref(),
verdict.composite_score,
);
let level = decision_to_sarif_level(decision).to_string();
let coord_fqn = format!("pkg:{ecosystem}/{package}@{version}");
let masked_verdict_label = mask_engine_tag(&verdict.verdict);
let message_text = format!(
"{decision}: {ecosystem}/{package}@{version} — {verdict_label}. {reasoning}",
ecosystem = ecosystem,
package = package,
version = version,
decision = decision,
verdict_label = masked_verdict_label,
reasoning = verdict.reasoning,
);
let state = CustomerState::from_wire(&verdict.source);
let result = SarifResult {
rule_id: if verdict.verdict.is_empty() {
"UNKNOWN".to_string()
} else {
verdict.verdict.clone()
},
level,
message: SarifMessage {
text: message_text,
},
locations: vec![SarifLocation {
logical_locations: vec![SarifLogicalLocation {
name: package.to_string(),
fully_qualified_name: coord_fqn,
kind: "package".to_string(),
}],
}],
properties: SarifResultProperties {
verdict: verdict.verdict.clone(),
severity: verdict.severity.clone(),
composite_score: Some(verdict.composite_score),
verdict_id: if verdict.verdict_id.is_empty() {
None
} else {
Some(verdict.verdict_id.clone())
},
state: state.as_str().to_string(),
state_label: state.label().to_string(),
audit_record_id: verdict.audit_record_id.clone(),
audit_record_hash: verdict.audit_record_hash.clone(),
attestation: verdict.attestation.clone(),
},
};
build_log(vec![result], cli_version)
}
pub fn decisions_to_sarif(decisions: &[PolicyDecision], cli_version: &str) -> SarifLog {
let results = decisions
.iter()
.map(|d| policy_decision_to_result(d))
.collect();
build_log(results, cli_version)
}
fn policy_decision_to_result(d: &PolicyDecision) -> SarifResult {
let level = decision_to_sarif_level(&d.decision).to_string();
let coord_fqn = format!("pkg:{}/{}@{}", d.ecosystem, d.package, d.version);
let masked_decision = mask_engine_tag(&d.decision);
let masked_reason = mask_engine_tag(&d.reason);
let message_text = format!(
"{}: {}/{}@{} — {}",
masked_decision, d.ecosystem, d.package, d.version, masked_reason
);
SarifResult {
rule_id: if d.decision.is_empty() {
"UNKNOWN".to_string()
} else {
d.decision.clone()
},
level,
message: SarifMessage {
text: message_text,
},
locations: vec![SarifLocation {
logical_locations: vec![SarifLogicalLocation {
name: d.package.clone(),
fully_qualified_name: coord_fqn,
kind: "package".to_string(),
}],
}],
properties: SarifResultProperties {
verdict: d.decision.clone(),
severity: None,
composite_score: None,
verdict_id: d.verdict_id.clone(),
state: String::new(),
state_label: String::new(),
audit_record_id: None,
audit_record_hash: None,
attestation: None,
},
}
}
fn build_log(results: Vec<SarifResult>, cli_version: &str) -> SarifLog {
SarifLog {
schema: SARIF_SCHEMA_URI.to_string(),
version: SARIF_VERSION.to_string(),
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifDriver {
name: "cleanlib".to_string(),
version: cli_version.to_string(),
information_uri: Some("https://cleanlibrary.clnstrt.dev".to_string()),
},
},
results,
}],
}
}
pub fn to_json_pretty(log: &SarifLog) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(log)
}
pub fn print_verdict_sarif(
verdict: &Verdict,
ecosystem: &str,
package: &str,
version: &str,
cli_version: &str,
) -> Result<(), serde_json::Error> {
let log = verdict_to_sarif(verdict, ecosystem, package, version, cli_version);
println!("{}", to_json_pretty(&log)?);
Ok(())
}
pub fn print_decisions_sarif(
decisions: &[PolicyDecision],
cli_version: &str,
) -> Result<(), serde_json::Error> {
let log = decisions_to_sarif(decisions, cli_version);
println!("{}", to_json_pretty(&log)?);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use cleanlib_client::types::AvailabilityBlock;
fn make_verdict(verdict_label: &str, composite_score: u8) -> Verdict {
Verdict {
verdict_id: "vrd-test-001".to_string(),
verdict: verdict_label.to_string(),
source: "npm".to_string(),
confidence: 0.85,
composite_score,
reasoning: "Test reasoning.".to_string(),
similar_to: vec![],
evidence_gaps: vec![],
suggested_actions: vec![],
data_freshness_at: None,
data_oldest_signal_at: None,
stale_since_at: None,
staleness_reason: None,
computed_at: None,
severity: None,
decision: None,
previous_verdict: None,
availability: AvailabilityBlock::default(),
..Default::default()
}
}
#[test]
fn allow_maps_to_note() {
assert_eq!(decision_to_sarif_level("ALLOW"), "note");
}
#[test]
fn warn_maps_to_warning() {
assert_eq!(decision_to_sarif_level("WARN"), "warning");
}
#[test]
fn deny_maps_to_error() {
assert_eq!(decision_to_sarif_level("DENY"), "error");
}
#[test]
fn risk_acceptance_required_maps_to_warning() {
assert_eq!(decision_to_sarif_level("RISK_ACCEPTANCE_REQUIRED"), "warning");
}
#[test]
fn unknown_decision_maps_to_warning_faildown() {
assert_eq!(decision_to_sarif_level("MYSTERY_BAND"), "warning");
}
#[test]
fn verdict_sarif_has_canonical_schema_and_version() {
let v = make_verdict("ALLOWED_NO_FINDINGS", 30);
let log = verdict_to_sarif(&v, "npm", "cors", "2.8.5", "0.1.4");
assert_eq!(log.schema, SARIF_SCHEMA_URI);
assert_eq!(log.version, "2.1.0");
}
#[test]
fn verdict_sarif_carries_driver_identity() {
let v = make_verdict("ALLOWED_NO_FINDINGS", 30);
let log = verdict_to_sarif(&v, "npm", "cors", "2.8.5", "0.1.4");
assert_eq!(log.runs.len(), 1);
assert_eq!(log.runs[0].tool.driver.name, "cleanlib");
assert_eq!(log.runs[0].tool.driver.version, "0.1.4");
}
#[test]
fn verdict_sarif_emits_one_result_per_verdict() {
let v = make_verdict("VECTOR_VERDICT", 85);
let log = verdict_to_sarif(&v, "npm", "cors", "2.8.5", "0.1.4");
assert_eq!(log.runs[0].results.len(), 1);
}
#[test]
fn verdict_sarif_result_rule_id_matches_verdict_label() {
let v = make_verdict("DM_THRESHOLD_BLOCK", 90);
let log = verdict_to_sarif(&v, "npm", "malicious-pkg", "1.0.0", "0.1.4");
assert_eq!(log.runs[0].results[0].rule_id, "DM_THRESHOLD_BLOCK");
}
#[test]
fn verdict_sarif_deny_maps_result_level_to_error() {
let v = make_verdict("DM_THRESHOLD_BLOCK", 90);
let log = verdict_to_sarif(&v, "npm", "malicious-pkg", "1.0.0", "0.1.4");
assert_eq!(log.runs[0].results[0].level, "error");
}
#[test]
fn verdict_sarif_allow_maps_result_level_to_note() {
let v = make_verdict("ALLOWED_NO_FINDINGS", 30);
let log = verdict_to_sarif(&v, "npm", "cors", "2.8.5", "0.1.4");
assert_eq!(log.runs[0].results[0].level, "note");
}
#[test]
fn verdict_sarif_vector_high_sev_maps_to_error() {
let mut v = make_verdict("VECTOR_VERDICT", 85);
v.severity = Some("HIGH".to_string());
let log = verdict_to_sarif(&v, "npm", "cors", "2.8.5", "0.1.4");
assert_eq!(log.runs[0].results[0].level, "error");
}
#[test]
fn verdict_sarif_vector_medium_sev_maps_to_warning() {
let mut v = make_verdict("VECTOR_VERDICT", 85);
v.severity = Some("MEDIUM".to_string());
let log = verdict_to_sarif(&v, "npm", "cors", "2.8.5", "0.1.4");
assert_eq!(log.runs[0].results[0].level, "warning");
}
#[test]
fn verdict_sarif_locations_carry_package_coordinate() {
let v = make_verdict("ALLOWED_NO_FINDINGS", 30);
let log = verdict_to_sarif(&v, "npm", "cors", "2.8.5", "0.1.4");
let loc = &log.runs[0].results[0].locations[0].logical_locations[0];
assert_eq!(loc.name, "cors");
assert_eq!(loc.fully_qualified_name, "pkg:npm/cors@2.8.5");
assert_eq!(loc.kind, "package");
}
#[test]
fn verdict_sarif_properties_carry_severity_and_composite_score() {
let mut v = make_verdict("VECTOR_VERDICT", 85);
v.severity = Some("HIGH".to_string());
let log = verdict_to_sarif(&v, "npm", "cors", "2.8.5", "0.1.4");
let props = &log.runs[0].results[0].properties;
assert_eq!(props.verdict, "VECTOR_VERDICT");
assert_eq!(props.severity.as_deref(), Some("HIGH"));
assert_eq!(props.composite_score, Some(85));
assert_eq!(props.verdict_id.as_deref(), Some("vrd-test-001"));
}
fn make_pd(name: &str, version: &str, decision: &str) -> PolicyDecision {
PolicyDecision {
ecosystem: "npm".to_string(),
package: name.to_string(),
version: version.to_string(),
decision: decision.to_string(),
reason: format!("test-reason-{decision}"),
verdict_id: Some(format!("vrd-{name}")),
policy_rule_id: None,
}
}
#[test]
fn decisions_sarif_emits_one_result_per_decision() {
let decisions = vec![
make_pd("cors", "2.8.5", "ALLOW"),
make_pd("malicious-pkg", "1.0.0", "DENY"),
make_pd("stale-pkg", "0.9.0", "WARN"),
];
let log = decisions_to_sarif(&decisions, "0.1.4");
assert_eq!(log.runs[0].results.len(), 3);
}
#[test]
fn decisions_sarif_levels_match_decisions() {
let decisions = vec![
make_pd("cors", "2.8.5", "ALLOW"),
make_pd("malicious-pkg", "1.0.0", "DENY"),
make_pd("stale-pkg", "0.9.0", "WARN"),
];
let log = decisions_to_sarif(&decisions, "0.1.4");
let levels: Vec<&str> = log.runs[0]
.results
.iter()
.map(|r| r.level.as_str())
.collect();
assert_eq!(levels, vec!["note", "error", "warning"]);
}
#[test]
fn sarif_top_level_shape_conforms_to_v210() {
let v = make_verdict("ALLOWED_NO_FINDINGS", 30);
let log = verdict_to_sarif(&v, "npm", "cors", "2.8.5", "0.1.4");
let value = serde_json::to_value(&log).unwrap();
assert!(value.get("$schema").is_some(), "$schema is required");
assert!(value.get("version").is_some(), "version is required");
assert!(value.get("runs").is_some(), "runs is required");
assert_eq!(value["version"], "2.1.0");
let run = &value["runs"][0];
assert!(run.get("tool").is_some(), "run.tool is required");
assert!(run.get("results").is_some(), "run.results is required");
let tool = &run["tool"];
assert!(tool.get("driver").is_some(), "tool.driver is required");
let driver = &tool["driver"];
assert!(driver.get("name").is_some(), "driver.name is required");
}
#[test]
fn sarif_result_shape_conforms_to_v210() {
let v = make_verdict("VECTOR_VERDICT", 85);
let log = verdict_to_sarif(&v, "npm", "cors", "2.8.5", "0.1.4");
let value = serde_json::to_value(&log).unwrap();
let result = &value["runs"][0]["results"][0];
assert!(result.get("ruleId").is_some(), "result.ruleId is expected");
assert!(result.get("level").is_some(), "result.level is expected");
assert!(result.get("message").is_some(), "result.message is required");
let msg = &result["message"];
assert!(msg.get("text").is_some(), "message.text is required");
let level = result["level"].as_str().unwrap();
assert!(
matches!(level, "none" | "note" | "warning" | "error"),
"level must be one of SARIF's closed vocab; got {level}"
);
}
#[test]
fn sarif_empty_decisions_still_produces_valid_run() {
let log = decisions_to_sarif(&[], "0.1.4");
assert_eq!(log.runs[0].results.len(), 0);
let s = to_json_pretty(&log).unwrap();
assert!(s.contains("\"version\": \"2.1.0\""));
assert!(s.contains("\"results\": []"));
}
#[test]
fn verdict_sarif_properties_carry_state_and_state_label() {
let mut v = make_verdict("VECTOR_VERDICT", 85);
v.source = "CVE_FINDING_ON_KEV".to_string();
v.severity = Some("HIGH".to_string());
let log = verdict_to_sarif(&v, "npm", "cors", "2.8.5", "0.1.4");
let value = serde_json::to_value(&log).unwrap();
let props = &value["runs"][0]["results"][0]["properties"];
assert_eq!(props["state"], "actively_exploited");
assert_eq!(props["state_label"], "Actively exploited");
}
#[test]
fn verdict_sarif_properties_carry_not_yet_assessed_for_insufficient_data() {
let mut v = make_verdict("INSUFFICIENT_DATA", 30);
v.source = "INSUFFICIENT_DATA".to_string();
let log = verdict_to_sarif(&v, "npm", "cors", "2.8.5", "0.1.4");
let value = serde_json::to_value(&log).unwrap();
let props = &value["runs"][0]["results"][0]["properties"];
assert_eq!(props["state"], "not_yet_assessed");
assert_eq!(props["state_label"], "Not yet assessed");
}
#[test]
fn verdict_sarif_message_masks_insufficient_data_codename() {
let mut v = make_verdict("INSUFFICIENT_DATA", 30);
v.source = "INSUFFICIENT_DATA".to_string();
v.reasoning = "no fresh evidence".to_string();
let log = verdict_to_sarif(&v, "npm", "cors", "2.8.5", "0.1.4");
let text = &log.runs[0].results[0].message.text;
assert!(
!text.contains("INSUFFICIENT_DATA"),
"SARIF message.text leaked raw codename: {}",
text
);
assert!(
text.contains("Not yet assessed"),
"SARIF message.text missing canonical customer label: {}",
text
);
}
#[test]
fn decisions_sarif_state_fields_omit_when_no_source() {
let pd = PolicyDecision {
ecosystem: "npm".to_string(),
package: "cors".to_string(),
version: "2.8.5".to_string(),
decision: "ALLOW".to_string(),
reason: "no findings".to_string(),
verdict_id: Some("vrd-1".to_string()),
policy_rule_id: None,
};
let log = decisions_to_sarif(&[pd], "0.1.4");
let value = serde_json::to_value(&log).unwrap();
let props = &value["runs"][0]["results"][0]["properties"];
assert!(
props.get("state").is_none(),
"state must be omitted when PolicyDecision carries no source"
);
assert!(
props.get("state_label").is_none(),
"state_label must be omitted when PolicyDecision carries no source"
);
}
#[test]
fn verdict_sarif_omits_verdict_id_when_empty() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.verdict_id = String::new();
let log = verdict_to_sarif(&v, "npm", "cors", "2.8.5", "0.1.4");
let value = serde_json::to_value(&log).unwrap();
let props = &value["runs"][0]["results"][0]["properties"];
assert!(
props.get("verdict_id").is_none(),
"verdict_id must be omitted when empty (not serialized as empty string)"
);
}
}