use serde::Serialize;
use crate::models::{Finding, Severity};
pub struct SarifFormatter;
impl SarifFormatter {
pub fn new() -> Self {
Self
}
pub fn format(&self, findings: &[Finding]) -> String {
let results: Vec<SarifResult> = findings.iter().map(SarifResult::from).collect();
let report = SarifReport {
schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
version: "2.1.0",
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifDriver {
name: "secretrace",
information_uri: "https://github.com/yourusername/secretrace",
version: env!("CARGO_PKG_VERSION").to_string(),
rules: vec![
SarifRule {
id: "hardcoded-secret".into(),
name: "Hardcoded Secret".into(),
short_description: SarifMessage {
text: "Detects hardcoded secrets in source code".into(),
},
default_configuration: SarifConfiguration {
level: "error".into(),
},
},
SarifRule {
id: "suspicious-constant".into(),
name: "Suspicious Constant".into(),
short_description: SarifMessage {
text: "Detects potentially suspicious constant values".into(),
},
default_configuration: SarifConfiguration {
level: "warning".into(),
},
},
],
},
},
results,
}],
};
serde_json::to_string_pretty(&report).unwrap_or_default()
}
}
impl Default for SarifFormatter {
fn default() -> Self {
Self::new()
}
}
#[derive(Serialize)]
struct SarifReport {
#[serde(rename = "$schema")]
schema: &'static str,
version: &'static str,
runs: Vec<SarifRun>,
}
#[derive(Serialize)]
struct SarifRun {
tool: SarifTool,
results: Vec<SarifResult>,
}
#[derive(Serialize)]
struct SarifTool {
driver: SarifDriver,
}
#[derive(Serialize)]
struct SarifDriver {
name: &'static str,
#[serde(rename = "informationUri")]
information_uri: &'static str,
version: String,
rules: Vec<SarifRule>,
}
#[derive(Serialize)]
struct SarifRule {
id: String,
name: String,
#[serde(rename = "shortDescription")]
short_description: SarifMessage,
#[serde(rename = "defaultConfiguration")]
default_configuration: SarifConfiguration,
}
#[derive(Serialize)]
struct SarifConfiguration {
level: String,
}
#[derive(Serialize)]
struct SarifResult {
#[serde(rename = "ruleId")]
rule_id: String,
level: String,
message: SarifMessage,
locations: Vec<SarifLocation>,
}
#[derive(Serialize)]
struct SarifMessage {
text: String,
}
#[derive(Serialize)]
struct SarifLocation {
#[serde(rename = "physicalLocation")]
physical_location: SarifPhysicalLocation,
}
#[derive(Serialize)]
struct SarifPhysicalLocation {
#[serde(rename = "artifactLocation")]
artifact_location: SarifArtifactLocation,
region: SarifRegion,
}
#[derive(Serialize)]
struct SarifArtifactLocation {
uri: String,
}
#[derive(Serialize)]
struct SarifRegion {
#[serde(rename = "startLine")]
start_line: u32,
#[serde(rename = "startColumn")]
start_column: u32,
}
impl From<&Finding> for SarifResult {
fn from(f: &Finding) -> Self {
let (rule_id, level) = match f.score.severity {
Severity::Critical | Severity::High => ("hardcoded-secret", "error"),
Severity::Medium => ("suspicious-constant", "warning"),
_ => ("suspicious-constant", "note"),
};
SarifResult {
rule_id: rule_id.into(),
level: level.into(),
message: SarifMessage {
text: f.explanation.clone(),
},
locations: vec![SarifLocation {
physical_location: SarifPhysicalLocation {
artifact_location: SarifArtifactLocation {
uri: f.location.file.to_string_lossy().into(),
},
region: SarifRegion {
start_line: f.location.line,
start_column: f.location.column,
},
},
}],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{FindingId, Score, SourceLocation, SuspectValue};
use std::path::PathBuf;
fn create_test_finding(severity_score: i32) -> Finding {
Finding {
id: FindingId::new("test-finding-1"),
suspect: SuspectValue::Constant {
name: "API_KEY".to_string(),
value: "sk_live_12345".to_string(),
type_annotation: Some("&str".to_string()),
},
location: SourceLocation::new(PathBuf::from("src/main.rs"), 10, 5),
usage: None,
context: Default::default(),
score: Score::from_total(severity_score),
explanation: "Test explanation".to_string(),
remediation: Some("Test remediation".to_string()),
metadata: std::collections::HashMap::new(),
}
}
#[test]
fn test_sarif_format_empty() {
let formatter = SarifFormatter::new();
let output = formatter.format(&[]);
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(parsed["version"], "2.1.0");
assert!(parsed["runs"][0]["results"].as_array().unwrap().is_empty());
}
#[test]
fn test_sarif_format_high_severity() {
let formatter = SarifFormatter::new();
let finding = create_test_finding(90); let output = formatter.format(&[finding]);
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
let result = &parsed["runs"][0]["results"][0];
assert_eq!(result["ruleId"], "hardcoded-secret");
assert_eq!(result["level"], "error");
}
#[test]
fn test_sarif_format_medium_severity() {
let formatter = SarifFormatter::new();
let finding = create_test_finding(60); let output = formatter.format(&[finding]);
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
let result = &parsed["runs"][0]["results"][0];
assert_eq!(result["ruleId"], "suspicious-constant");
assert_eq!(result["level"], "warning");
}
#[test]
fn test_sarif_has_schema() {
let formatter = SarifFormatter::new();
let output = formatter.format(&[]);
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(parsed["$schema"].as_str().unwrap().contains("sarif-schema-2.1.0"));
}
#[test]
fn test_sarif_location() {
let formatter = SarifFormatter::new();
let finding = create_test_finding(85);
let output = formatter.format(&[finding]);
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
let location = &parsed["runs"][0]["results"][0]["locations"][0]["physicalLocation"];
assert_eq!(location["region"]["startLine"], 10);
assert_eq!(location["region"]["startColumn"], 5);
}
}