use hex;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::types::Finding;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifReport {
pub version: String,
#[serde(rename = "$schema")]
pub schema: 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,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "informationUri")]
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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fingerprints: Option<SarifFingerprints>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifMessage {
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifLocation {
#[serde(rename = "physicalLocation")]
pub physical_location: SarifPhysicalLocation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifPhysicalLocation {
#[serde(rename = "artifactLocation")]
pub artifact_location: SarifArtifactLocation,
pub region: SarifRegion,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifArtifactLocation {
pub uri: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifRegion {
#[serde(rename = "startLine")]
pub start_line: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarifFingerprints {
#[serde(rename = "primaryLocationLineHash")]
pub primary_location_line_hash: String,
}
impl From<Vec<Finding>> for SarifReport {
fn from(findings: Vec<Finding>) -> Self {
let results: Vec<SarifResult> = findings.into_iter().map(SarifResult::from).collect();
SarifReport {
version: "2.1.0".to_string(),
schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifDriver {
name: "aptu-security-scanner".to_string(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
information_uri: Some("https://github.com/clouatre-labs/aptu".to_string()),
},
},
results,
}],
}
}
}
impl From<Finding> for SarifResult {
fn from(finding: Finding) -> Self {
let level = match finding.severity {
super::types::Severity::Critical | super::types::Severity::High => "error",
super::types::Severity::Medium => "warning",
super::types::Severity::Low => "note",
};
let fingerprint_input = format!(
"{}:{}:{}",
finding.file_path, finding.line_number, finding.pattern_id
);
let mut hasher = Sha256::new();
hasher.update(fingerprint_input.as_bytes());
let fingerprint = hex::encode(hasher.finalize());
SarifResult {
rule_id: finding.pattern_id,
level: level.to_string(),
message: SarifMessage {
text: finding.description,
},
locations: vec![SarifLocation {
physical_location: SarifPhysicalLocation {
artifact_location: SarifArtifactLocation {
uri: finding.file_path,
},
region: SarifRegion {
start_line: finding.line_number,
},
},
}],
fingerprints: Some(SarifFingerprints {
primary_location_line_hash: fingerprint,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::security::types::{Confidence, Severity};
#[test]
fn test_sarif_report_structure() {
let findings = vec![Finding {
pattern_id: "hardcoded-secret".to_string(),
description: "Hardcoded API key detected".to_string(),
severity: Severity::Critical,
confidence: Confidence::High,
file_path: "src/config.rs".to_string(),
line_number: 42,
matched_text: "api_key = \"sk-1234567890\"".to_string(),
cwe: Some("CWE-798".to_string()),
}];
let report = SarifReport::from(findings);
assert_eq!(report.version, "2.1.0");
assert_eq!(report.runs.len(), 1);
assert_eq!(report.runs[0].results.len(), 1);
assert_eq!(report.runs[0].tool.driver.name, "aptu-security-scanner");
}
#[test]
fn test_severity_mapping() {
let critical = Finding {
pattern_id: "test".to_string(),
description: "Test".to_string(),
severity: Severity::Critical,
confidence: Confidence::High,
file_path: "test.rs".to_string(),
line_number: 1,
matched_text: "test".to_string(),
cwe: None,
};
let result = SarifResult::from(critical.clone());
assert_eq!(result.level, "error");
let medium = Finding {
severity: Severity::Medium,
..critical.clone()
};
let result = SarifResult::from(medium);
assert_eq!(result.level, "warning");
let low = Finding {
severity: Severity::Low,
..critical
};
let result = SarifResult::from(low);
assert_eq!(result.level, "note");
}
#[test]
fn test_fingerprint_stability() {
let finding = Finding {
pattern_id: "test-pattern".to_string(),
description: "Test finding".to_string(),
severity: Severity::High,
confidence: Confidence::Medium,
file_path: "src/main.rs".to_string(),
line_number: 10,
matched_text: "test code".to_string(),
cwe: None,
};
let result1 = SarifResult::from(finding.clone());
let result2 = SarifResult::from(finding);
assert_eq!(
result1
.fingerprints
.as_ref()
.unwrap()
.primary_location_line_hash,
result2
.fingerprints
.as_ref()
.unwrap()
.primary_location_line_hash
);
}
#[test]
fn test_fingerprint_uniqueness() {
let finding1 = Finding {
pattern_id: "pattern1".to_string(),
description: "Test".to_string(),
severity: Severity::High,
confidence: Confidence::High,
file_path: "src/main.rs".to_string(),
line_number: 10,
matched_text: "test".to_string(),
cwe: None,
};
let finding2 = Finding {
pattern_id: "pattern2".to_string(),
..finding1.clone()
};
let result1 = SarifResult::from(finding1);
let result2 = SarifResult::from(finding2);
assert_ne!(
result1
.fingerprints
.as_ref()
.unwrap()
.primary_location_line_hash,
result2
.fingerprints
.as_ref()
.unwrap()
.primary_location_line_hash
);
}
#[test]
fn test_sarif_serialization() {
let findings = vec![Finding {
pattern_id: "test-pattern".to_string(),
description: "Test finding".to_string(),
severity: Severity::High,
confidence: Confidence::Medium,
file_path: "src/test.rs".to_string(),
line_number: 5,
matched_text: "test".to_string(),
cwe: Some("CWE-123".to_string()),
}];
let report = SarifReport::from(findings);
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"version\":\"2.1.0\""));
assert!(json.contains("\"ruleId\":\"test-pattern\""));
assert!(json.contains("\"level\":\"error\""));
}
}