//! SARIF output format for CI integration. //! //! SARIF (Static Analysis Results Interchange Format) v2.1.0 is supported by: //! - GitHub Code Scanning //! - GitLab SAST //! - Azure DevOps //! //! Reference: use super::{object_value_display, verdict_label, ReportFormatter}; use crate::types::{ScanResult, Verdict}; /// SARIF report formatter for CI integration. pub struct SarifReport; impl ReportFormatter for SarifReport { fn format(&self, result: &ScanResult) -> String { // Build SARIF rules from unique conflict types let mut rules = Vec::new(); let mut rule_indices: std::collections::HashMap = std::collections::HashMap::new(); for conflict in &result.conflicts { let rule_id = format!("aphoria/{}", extract_rule_id(&conflict.claim.concept_path)); if !rule_indices.contains_key(&rule_id) { let idx = rules.len(); rule_indices.insert(rule_id.clone(), idx); let level = match conflict.verdict { Verdict::Block => "error", Verdict::Flag | Verdict::Drift => "warning", Verdict::Pass | Verdict::Ack => "note", }; // Generate help URI based on RFC citation if available let help_uri = conflict .conflicts .first() .and_then(|s| s.rfc_citation.as_ref()) .map(|citation| { if citation.starts_with("RFC ") { let rfc_num = citation.strip_prefix("RFC ").unwrap_or(""); format!("https://www.rfc-editor.org/rfc/rfc{}", rfc_num) } else if citation.starts_with("OWASP") { "https://owasp.org/www-project-top-ten/".to_string() } else { format!( "https://github.com/orchard9/aphoria/rules/{}", extract_rule_id(&conflict.claim.concept_path) ) } }) .unwrap_or_else(|| { format!( "https://github.com/orchard9/aphoria/rules/{}", extract_rule_id(&conflict.claim.concept_path) ) }); rules.push(serde_json::json!({ "id": rule_id, "shortDescription": { "text": conflict.claim.description, }, "defaultConfiguration": { "level": level, }, "helpUri": help_uri, })); } } // Build SARIF results let results: Vec = result .conflicts .iter() .map(|conflict| { let rule_id = format!("aphoria/{}", extract_rule_id(&conflict.claim.concept_path)); let rule_index = rule_indices.get(&rule_id).copied().unwrap_or(0); let level = match conflict.verdict { Verdict::Block => "error", Verdict::Flag | Verdict::Drift => "warning", Verdict::Pass | Verdict::Ack => "note", }; // Build message with authoritative source details let source_details: Vec = conflict .conflicts .iter() .map(|s| { let mut detail = format!( "{:?} (Tier {}): {}", s.source_class, s.source_class.tier(), object_value_display(&s.value) ); // Include policy source info if available if let Some(ps) = &s.policy_source { let signer = ps.signer_name.as_deref().unwrap_or(&ps.issuer_hex); detail.push_str(&format!( " [Source: {} v{} ({})", ps.pack_name, ps.pack_version, signer )); if let Some(contact) = &ps.contact { detail.push_str(&format!(", Contact: {}", contact)); } detail.push(']'); } detail }) .collect(); let message = format!( "{}\nYour code: {} = {}\nAuthoritative: {}", conflict.claim.description, conflict.claim.predicate, object_value_display(&conflict.claim.value), source_details.join("; ") ); serde_json::json!({ "ruleId": rule_id, "ruleIndex": rule_index, "level": level, "message": { "text": message, }, "locations": [{ "physicalLocation": { "artifactLocation": { "uri": conflict.claim.file, "uriBaseId": "%SRCROOT%", }, "region": { "startLine": conflict.claim.line, } } }], "properties": { "conflict_score": conflict.conflict_score, "verdict": verdict_label(conflict.verdict), } }) }) .collect(); // Add drift rules and results for drift in &result.drifts { let rule_id = format!("aphoria/drift/{}", extract_rule_id(&drift.claim.concept_path)); if !rule_indices.contains_key(&rule_id) { let idx = rules.len(); rule_indices.insert(rule_id.clone(), idx); rules.push(serde_json::json!({ "id": rule_id, "shortDescription": { "text": format!("Value drift detected for {}", drift.claim.concept_path), }, "defaultConfiguration": { "level": "warning", }, "helpUri": "https://github.com/orchard9/aphoria/docs/drift", })); } } // Add drift results let drift_results: Vec = result .drifts .iter() .map(|drift| { let rule_id = format!("aphoria/drift/{}", extract_rule_id(&drift.claim.concept_path)); let rule_index = rule_indices.get(&rule_id).copied().unwrap_or(0); let message = format!( "Value changed from prior observation.\nCurrent: {}\nPrior: {} (recorded at {}:{})", object_value_display(&drift.claim.value), object_value_display(&drift.prior.value), drift.prior.file, drift.prior.line ); serde_json::json!({ "ruleId": rule_id, "ruleIndex": rule_index, "level": "warning", "message": { "text": message, }, "locations": [{ "physicalLocation": { "artifactLocation": { "uri": drift.claim.file, "uriBaseId": "%SRCROOT%", }, "region": { "startLine": drift.claim.line, } } }], "properties": { "verdict": verdict_label(drift.verdict), "prior_value": object_value_display(&drift.prior.value), "prior_timestamp": drift.prior.timestamp, } }) }) .collect(); // Add deprecated usage rules and results for usage in &result.deprecated_usages { let rule_id = format!("aphoria/deprecated/{}", usage.pattern_name); if !rule_indices.contains_key(&rule_id) { let idx = rules.len(); rule_indices.insert(rule_id.clone(), idx); let level = match usage.severity() { "OVERDUE" => "error", "URGENT" => "warning", _ => "note", }; rules.push(serde_json::json!({ "id": rule_id, "shortDescription": { "text": format!("Deprecated pattern: {}", usage.pattern_name), }, "fullDescription": { "text": usage.reason.clone(), }, "defaultConfiguration": { "level": level, }, "helpUri": usage.migration_guide.clone().unwrap_or_else(|| { "https://github.com/orchard9/aphoria/docs/deprecation".to_string() }), })); } } // Add deprecated usage results let deprecated_results: Vec = result .deprecated_usages .iter() .map(|usage| { let rule_id = format!("aphoria/deprecated/{}", usage.pattern_name); let rule_index = rule_indices.get(&rule_id).copied().unwrap_or(0); let level = match usage.severity() { "OVERDUE" => "error", "URGENT" => "warning", _ => "note", }; let mut message = format!( "Deprecated pattern '{}' detected.\nReason: {}", usage.pattern_name, usage.reason ); if let Some(ref replacement) = usage.superseded_by { message.push_str(&format!("\nReplace with: {}", replacement)); } if let Some(days) = usage.days_until_sunset { if days < 0 { message.push_str(&format!("\nSunset: OVERDUE by {} days", -days)); } else { message.push_str(&format!("\nSunset: {} days remaining", days)); } } serde_json::json!({ "ruleId": rule_id, "ruleIndex": rule_index, "level": level, "message": { "text": message, }, "locations": [{ "physicalLocation": { "artifactLocation": { "uri": usage.file_path, "uriBaseId": "%SRCROOT%", }, "region": { "startLine": usage.line, } } }], "properties": { "pattern_id": usage.pattern_id.to_string(), "severity": usage.severity(), "days_until_sunset": usage.days_until_sunset, } }) }) .collect(); // Add claims if present (as informational-level results) let claims_results: Vec = if let Some(claims) = &result.claims { // Add a single rule for all claims if !claims.is_empty() && !rule_indices.contains_key("aphoria/claim") { let idx = rules.len(); rule_indices.insert("aphoria/claim".to_string(), idx); rules.push(serde_json::json!({ "id": "aphoria/claim", "shortDescription": { "text": "Extracted claim (no conflict detected)", }, "defaultConfiguration": { "level": "note", }, "helpUri": "https://github.com/orchard9/aphoria/docs/claims", })); } claims .iter() .map(|claim| { let rule_index = rule_indices.get("aphoria/claim").copied().unwrap_or(0); let message = format!( "{}\n{} = {}", claim.description, claim.predicate, object_value_display(&claim.value) ); serde_json::json!({ "ruleId": "aphoria/claim", "ruleIndex": rule_index, "level": "note", "message": { "text": message, }, "locations": [{ "physicalLocation": { "artifactLocation": { "uri": claim.file, "uriBaseId": "%SRCROOT%", }, "region": { "startLine": claim.line, } } }], "properties": { "concept_path": claim.concept_path, "confidence": claim.confidence, } }) }) .collect() } else { Vec::new() }; // Combine all results let mut all_results = results; all_results.extend(drift_results); all_results.extend(deprecated_results); all_results.extend(claims_results); let sarif = serde_json::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": "aphoria", "version": env!("CARGO_PKG_VERSION"), "informationUri": "https://github.com/orchard9/aphoria", "rules": rules, } }, "results": all_results, "invocations": [{ "executionSuccessful": true, "properties": { "scan_id": result.scan_id, "files_scanned": result.files_scanned, "claims_extracted": result.claims_extracted, "drifts_detected": result.drift_count(), "deprecated_usages": result.deprecated_usage_count(), } }] }] }); serde_json::to_string_pretty(&sarif).unwrap_or_else(|_| sarif.to_string()) } } /// Extract a rule ID from a concept path. /// /// e.g., `code://rust/myapp/tls/cert_verification` -> `tls/cert_verification` fn extract_rule_id(concept_path: &str) -> String { // Strip the scheme and project prefix, keep the meaningful tail if let Some(after_scheme) = concept_path.split("://").nth(1) { // Skip language and project segments (first two after scheme) let segments: Vec<&str> = after_scheme.split('/').collect(); if segments.len() > 2 { segments[2..].join("/") } else { after_scheme.to_string() } } else { concept_path.to_string() } } #[cfg(test)] mod tests { use super::*; use crate::types::{ConflictResult, ConflictingSource, ExtractedClaim}; use stemedb_core::types::{ObjectValue, SourceClass}; #[test] fn test_sarif_structure() { let formatter = SarifReport; let result = ScanResult { project: "testproject".to_string(), scan_id: "scan-789".to_string(), files_scanned: 42, claims_extracted: 5, conflicts: vec![ConflictResult { claim: ExtractedClaim { concept_path: "code://rust/testproject/tls/cert_verification".to_string(), predicate: "enabled".to_string(), value: ObjectValue::Boolean(false), file: "src/client.rs".to_string(), line: 23, matched_text: "danger_accept_invalid_certs(true)".to_string(), confidence: 1.0, description: "TLS certificate verification disabled".to_string(), }, conflicts: vec![ConflictingSource { path: "rfc://5246/tls/cert_verification".to_string(), source_class: SourceClass::Regulatory, value: ObjectValue::Boolean(true), confidence: 1.0, rfc_citation: Some("RFC 5246".to_string()), policy_source: None, }], conflict_score: 0.92, verdict: Verdict::Block, acknowledged: None, trace: None, }], drifts: vec![], format: "sarif".to_string(), debug: false, observations_recorded: 0, timing: None, claims: None, deprecated_usages: vec![], }; let output = formatter.format(&result); let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid json"); // SARIF version assert_eq!(parsed["version"], "2.1.0"); // Tool info assert_eq!(parsed["runs"][0]["tool"]["driver"]["name"], "aphoria"); // Rules let rules = parsed["runs"][0]["tool"]["driver"]["rules"].as_array().expect("rules array"); assert_eq!(rules.len(), 1); assert_eq!(rules[0]["id"], "aphoria/tls/cert_verification"); // Results let results = parsed["runs"][0]["results"].as_array().expect("results array"); assert_eq!(results.len(), 1); assert_eq!(results[0]["level"], "error"); assert_eq!( results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"], "src/client.rs" ); assert_eq!(results[0]["locations"][0]["physicalLocation"]["region"]["startLine"], 23); } #[test] fn test_sarif_empty() { let formatter = SarifReport; let result = ScanResult::stub(&std::path::PathBuf::from("."), "sarif"); let output = formatter.format(&result); let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid json"); assert_eq!(parsed["version"], "2.1.0"); assert_eq!(parsed["runs"][0]["results"].as_array().map(|a| a.len()), Some(0)); } #[test] fn test_extract_rule_id() { assert_eq!( extract_rule_id("code://rust/myapp/tls/cert_verification"), "tls/cert_verification" ); assert_eq!( extract_rule_id("code://go/myapp/jwt/audience_validation"), "jwt/audience_validation" ); assert_eq!(extract_rule_id("simple"), "simple"); } }