use std::collections::HashMap; use anyhow::{Context, Result}; use serde::Deserialize; use super::{NormalizedFinding, ParsedReport, ScannerParser, Severity}; pub struct SarifParser; impl ScannerParser for SarifParser { fn format_id(&self) -> &str { "sarif" } fn description(&self) -> &str { "SARIF v2.1.0 (Static Analysis Results Interchange Format)" } fn parse(&self, data: &[u8]) -> Result { let report: SarifReport = serde_json::from_slice(data).context("Failed to parse SARIF JSON")?; let mut findings = Vec::new(); for run in &report.runs { let tool_name = &run.tool.driver.name; let rules = &run.tool.driver.rules; for result in &run.results { let rule_id = result.rule_id.clone().unwrap_or_default(); let rule_meta = rules .as_ref() .and_then(|r| r.iter().find(|rule| rule.id == rule_id)); let title = result .message .text .clone() .unwrap_or_else(|| rule_id.clone()); let description = rule_meta .and_then(|r| r.short_description.as_ref()) .and_then(|d| d.text.clone()) .unwrap_or_default(); let severity = resolve_severity(result.level.as_deref(), rule_meta); let (file_path, line_start, line_end) = extract_location(result); let details_url = rule_meta.and_then(|r| r.help_uri.clone()); let tags = rule_meta .and_then(|r| r.properties.as_ref()) .and_then(|p| p.tags.clone()) .unwrap_or_default(); let cwe = extract_cwe(&tags); let scanner_fingerprint = extract_scanner_fingerprint(result); findings.push(NormalizedFinding { title, description, severity, scanner: tool_name.clone(), rule_id, file_path, line_start, line_end, cwe, cve: None, cvss_score: None, package_name: None, package_version: None, fixed_version: None, details_url, tags, scanner_fingerprint, }); } } Ok(ParsedReport { scanner_name: report .runs .first() .map(|r| r.tool.driver.name.clone()) .unwrap_or_else(|| "unknown".to_string()), findings, }) } } fn resolve_severity(level: Option<&str>, rule_meta: Option<&SarifRule>) -> Severity { let level = level.or_else(|| { rule_meta .and_then(|r| r.default_configuration.as_ref()) .and_then(|c| c.level.as_deref()) }); match level { Some("error") => Severity::High, Some("warning") => Severity::Medium, Some("note") => Severity::Low, Some("none") => Severity::Info, _ => Severity::Medium, } } fn extract_location(result: &SarifResult) -> (Option, Option, Option) { let location = match result.locations.as_ref().and_then(|l| l.first()) { Some(loc) => loc, None => return (None, None, None), }; let artifact = match &location.physical_location { Some(pl) => pl, None => return (None, None, None), }; let file_path = artifact .artifact_location .as_ref() .and_then(|al| al.uri.clone()); let line_start = artifact.region.as_ref().and_then(|r| r.start_line); let line_end = artifact.region.as_ref().and_then(|r| r.end_line); (file_path, line_start, line_end) } fn extract_cwe(tags: &[String]) -> Option { tags.iter() .find(|t| t.starts_with("CWE-") || t.starts_with("cwe-")) .cloned() } /// Extract a stable fingerprint from SARIF partialFingerprints or fingerprints. /// Scanners like Semgrep, CodeQL, and Checkov provide content-based hashes here /// that survive line shifts. fn extract_scanner_fingerprint(result: &SarifResult) -> Option { if let Some(ref fps) = result.partial_fingerprints { // Prefer primaryLocationLineHash (most common), then any first value if let Some(v) = fps.get("primaryLocationLineHash") { return Some(v.clone()); } if let Some(v) = fps.values().next() { return Some(v.clone()); } } if let Some(ref fps) = result.fingerprints && let Some(v) = fps.values().next() { return Some(v.clone()); } None } // SARIF v2.1.0 schema (subset relevant to findings) #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SarifReport { #[serde(default)] runs: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SarifRun { tool: SarifTool, #[serde(default)] results: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SarifTool { driver: SarifDriver, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SarifDriver { name: String, #[serde(default)] rules: Option>, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SarifRule { id: String, short_description: Option, help_uri: Option, default_configuration: Option, properties: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SarifConfiguration { level: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SarifRuleProperties { #[serde(default)] tags: Option>, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SarifResult { rule_id: Option, level: Option, message: SarifMessage, locations: Option>, partial_fingerprints: Option>, fingerprints: Option>, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SarifMessage { text: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SarifLocation { physical_location: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SarifPhysicalLocation { artifact_location: Option, region: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SarifArtifactLocation { uri: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SarifRegion { start_line: Option, end_line: Option, } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_minimal_sarif() { let sarif = r#"{ "$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": "TestScanner", "rules": [{ "id": "TEST-001", "shortDescription": { "text": "Test rule description" }, "defaultConfiguration": { "level": "error" } }] } }, "results": [{ "ruleId": "TEST-001", "level": "error", "message": { "text": "Found a vulnerability" }, "locations": [{ "physicalLocation": { "artifactLocation": { "uri": "src/main.rs" }, "region": { "startLine": 42, "endLine": 42 } } }] }] }] }"#; let parser = SarifParser; let report = parser.parse(sarif.as_bytes()).unwrap(); assert_eq!(report.scanner_name, "TestScanner"); assert_eq!(report.findings.len(), 1); let finding = &report.findings[0]; assert_eq!(finding.title, "Found a vulnerability"); assert_eq!(finding.rule_id, "TEST-001"); assert_eq!(finding.severity, Severity::High); assert_eq!(finding.file_path.as_deref(), Some("src/main.rs")); assert_eq!(finding.line_start, Some(42)); } #[test] fn test_parse_sarif_no_locations() { let sarif = r#"{ "version": "2.1.0", "runs": [{ "tool": { "driver": { "name": "Scanner" } }, "results": [{ "ruleId": "RULE-1", "message": { "text": "Global issue" } }] }] }"#; let parser = SarifParser; let report = parser.parse(sarif.as_bytes()).unwrap(); assert_eq!(report.findings.len(), 1); assert!(report.findings[0].file_path.is_none()); } }