use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::types::{Confidence, SecurityCategory, SecurityFinding, SecurityReport, Severity};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifLog {
#[serde(rename = "$schema")]
pub schema: String,
pub version: String,
pub runs: Vec<SarifRun>,
}
impl SarifLog {
#[must_use]
pub fn new(run: SarifRun) -> Self {
Self {
schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
version: "2.1.0".to_string(),
runs: vec![run],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifRun {
pub tool: SarifTool,
pub results: Vec<SarifResult>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub artifacts: Vec<SarifArtifact>,
#[serde(skip_serializing_if = "Option::is_none")]
pub invocations: Option<Vec<SarifInvocation>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifTool {
pub driver: SarifToolComponent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifToolComponent {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub semantic_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub information_uri: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<SarifReportingDescriptor>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifReportingDescriptor {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub short_description: Option<SarifMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_description: Option<SarifMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub help: Option<SarifMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub help_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_configuration: Option<SarifReportingConfiguration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<SarifPropertyBag>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifReportingConfiguration {
pub level: SarifLevel,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SarifLevel {
Error,
Warning,
Note,
None,
}
impl From<Severity> for SarifLevel {
fn from(sev: Severity) -> Self {
match sev {
Severity::Critical | Severity::High => Self::Error,
Severity::Medium => Self::Warning,
Severity::Low | Severity::Info => Self::Note,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub markdown: Option<String>,
}
impl SarifMessage {
#[must_use]
pub fn text(s: impl Into<String>) -> Self {
Self {
text: Some(s.into()),
markdown: None,
}
}
#[must_use]
pub fn markdown(s: impl Into<String>) -> Self {
Self {
text: None,
markdown: Some(s.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifResult {
pub rule_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_index: Option<usize>,
pub level: SarifLevel,
pub message: SarifMessage,
pub locations: Vec<SarifLocation>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub code_flows: Vec<SarifCodeFlow>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub related_locations: Vec<SarifLocation>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub fixes: Vec<SarifFix>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub fingerprints: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<SarifPropertyBag>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub suppressions: Vec<SarifSuppression>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifLocation {
pub physical_location: SarifPhysicalLocation,
#[serde(skip_serializing_if = "Option::is_none")]
pub logical_locations: Option<Vec<SarifLogicalLocation>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifPhysicalLocation {
pub artifact_location: SarifArtifactLocation,
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<SarifRegion>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_region: Option<SarifRegion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifArtifactLocation {
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub uri_base_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub index: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifRegion {
#[serde(skip_serializing_if = "Option::is_none")]
pub start_line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_column: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_column: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snippet: Option<SarifArtifactContent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifArtifactContent {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub binary: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifLogicalLocation {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fully_qualified_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifCodeFlow {
pub thread_flows: Vec<SarifThreadFlow>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifThreadFlow {
pub locations: Vec<SarifThreadFlowLocation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifThreadFlowLocation {
pub location: SarifLocation,
#[serde(skip_serializing_if = "Option::is_none")]
pub importance: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifFix {
pub description: SarifMessage,
pub artifact_changes: Vec<SarifArtifactChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifArtifactChange {
pub artifact_location: SarifArtifactLocation,
pub replacements: Vec<SarifReplacement>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifReplacement {
pub deleted_region: SarifRegion,
#[serde(skip_serializing_if = "Option::is_none")]
pub inserted_content: Option<SarifArtifactContent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifArtifact {
pub location: SarifArtifactLocation,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub length: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifInvocation {
pub execution_successful: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_time_utc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_time_utc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub working_directory: Option<SarifArtifactLocation>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifPropertyBag {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub cwe: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "security-severity")]
pub security_severity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<String>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifSuppression {
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub justification: Option<String>,
}
impl SecurityReport {
#[must_use]
pub fn to_sarif(&self) -> SarifLog {
let mut rules: Vec<SarifReportingDescriptor> = Vec::new();
let mut rule_index_map: HashMap<String, usize> = HashMap::new();
for finding in &self.findings {
if !rule_index_map.contains_key(&finding.id) {
rule_index_map.insert(finding.id.clone(), rules.len());
rules.push(finding_to_rule(finding));
}
}
let results: Vec<SarifResult> = self
.findings
.iter()
.map(|f| finding_to_result(f, rule_index_map.get(&f.id).copied()))
.collect();
let mut artifacts: Vec<SarifArtifact> = Vec::new();
let mut seen_files: HashMap<String, usize> = HashMap::new();
for finding in &self.findings {
if !seen_files.contains_key(&finding.location.file) {
seen_files.insert(finding.location.file.clone(), artifacts.len());
artifacts.push(SarifArtifact {
location: SarifArtifactLocation {
uri: finding.location.file.clone(),
uri_base_id: Some("%SRCROOT%".to_string()),
index: Some(artifacts.len()),
},
mime_type: guess_mime_type(&finding.location.file),
length: None,
});
}
}
let tool = SarifTool {
driver: SarifToolComponent {
name: "brrr-security".to_string(),
version: Some(self.scanner_version.clone()),
semantic_version: Some(self.scanner_version.clone()),
information_uri: Some(
"https://github.com/GrigoryEvko/go-brrr".to_string(),
),
rules,
},
};
let run = SarifRun {
tool,
results,
artifacts,
invocations: Some(vec![SarifInvocation {
execution_successful: true,
start_time_utc: Some(self.timestamp.clone()),
end_time_utc: Some(self.timestamp.clone()),
working_directory: None,
}]),
};
SarifLog::new(run)
}
pub fn to_sarif_json(&self) -> Result<String, serde_json::Error> {
let sarif = self.to_sarif();
serde_json::to_string_pretty(&sarif)
}
}
fn finding_to_rule(finding: &SecurityFinding) -> SarifReportingDescriptor {
let mut properties = SarifPropertyBag::default();
if let Some(cwe) = finding.cwe_id {
properties.cwe.push(format!("CWE-{cwe}"));
}
properties.security_severity = Some(format!("{:.1}", finding.severity.cvss_score()));
let tag = match &finding.category {
SecurityCategory::Injection(t) => format!("injection/{t:?}").to_lowercase(),
SecurityCategory::SecretsExposure => "secrets".to_string(),
SecurityCategory::WeakCrypto => "crypto".to_string(),
SecurityCategory::UnsafeDeserialization => "deserialization".to_string(),
SecurityCategory::ReDoS => "redos".to_string(),
SecurityCategory::InsecureConfig => "config".to_string(),
SecurityCategory::AuthIssue => "auth".to_string(),
SecurityCategory::InfoDisclosure => "disclosure".to_string(),
SecurityCategory::Other(s) => s.clone(),
};
properties.tags.push(tag);
if let Some(owasp) = finding.category.owasp_category() {
properties.tags.push(owasp.to_string());
}
let help_uri = finding
.cwe_id
.map(|cwe| format!("https://cwe.mitre.org/data/definitions/{cwe}.html"));
SarifReportingDescriptor {
id: finding.id.clone(),
short_description: Some(SarifMessage::text(&finding.title)),
full_description: Some(SarifMessage::text(&finding.description)),
help: if finding.remediation.is_empty() {
None
} else {
Some(SarifMessage::markdown(&finding.remediation))
},
help_uri,
default_configuration: Some(SarifReportingConfiguration {
level: SarifLevel::from(finding.severity),
enabled: Some(true),
}),
properties: Some(properties),
}
}
fn finding_to_result(finding: &SecurityFinding, rule_index: Option<usize>) -> SarifResult {
let location = SarifLocation {
physical_location: SarifPhysicalLocation {
artifact_location: SarifArtifactLocation {
uri: finding.location.file.clone(),
uri_base_id: Some("%SRCROOT%".to_string()),
index: None,
},
region: Some(SarifRegion {
start_line: Some(finding.location.start_line),
start_column: Some(finding.location.start_column),
end_line: Some(finding.location.end_line),
end_column: Some(finding.location.end_column),
snippet: if finding.code_snippet.is_empty() {
None
} else {
Some(SarifArtifactContent {
text: Some(finding.code_snippet.clone()),
binary: None,
})
},
}),
context_region: None,
},
logical_locations: None,
};
let mut fingerprints = HashMap::new();
fingerprints.insert(
"primaryLocationLineHash".to_string(),
finding.fingerprint(),
);
let mut properties = SarifPropertyBag::default();
properties.confidence = Some(finding.confidence.to_string());
for (k, v) in &finding.metadata {
properties
.extra
.insert(k.clone(), serde_json::Value::String(v.clone()));
}
let suppressions = if finding.suppressed {
vec![SarifSuppression {
kind: "inSource".to_string(),
justification: Some("Suppressed via inline comment".to_string()),
}]
} else {
Vec::new()
};
SarifResult {
rule_id: finding.id.clone(),
rule_index,
level: SarifLevel::from(finding.severity),
message: SarifMessage::text(&finding.description),
locations: vec![location],
code_flows: Vec::new(),
related_locations: Vec::new(),
fixes: Vec::new(),
fingerprints,
properties: Some(properties),
suppressions,
}
}
fn guess_mime_type(path: &str) -> Option<String> {
let ext = path.rsplit('.').next()?;
let mime = match ext {
"py" => "text/x-python",
"js" => "text/javascript",
"ts" => "text/typescript",
"tsx" | "jsx" => "text/jsx",
"rs" => "text/x-rust",
"go" => "text/x-go",
"java" => "text/x-java",
"c" | "h" => "text/x-c",
"cpp" | "cc" | "cxx" | "hpp" => "text/x-c++src",
"rb" => "text/x-ruby",
"php" => "text/x-php",
_ => return None,
};
Some(mime.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::security::types::{InjectionType, Location};
#[test]
fn test_sarif_generation() {
let finding = SecurityFinding::new(
"SQLI-001",
SecurityCategory::Injection(InjectionType::Sql),
Severity::High,
Confidence::High,
Location::new("src/api.py", 42, 5, 42, 50),
"SQL Injection in query",
"User input is concatenated into SQL query without sanitization",
)
.with_remediation("Use parameterized queries")
.with_code_snippet("cursor.execute(f\"SELECT * FROM users WHERE id = {user_id}\")");
let report = SecurityReport::new(vec![finding], 10);
let sarif = report.to_sarif();
assert_eq!(sarif.version, "2.1.0");
assert_eq!(sarif.runs.len(), 1);
let run = &sarif.runs[0];
assert_eq!(run.tool.driver.name, "brrr-security");
assert_eq!(run.results.len(), 1);
assert_eq!(run.tool.driver.rules.len(), 1);
let result = &run.results[0];
assert_eq!(result.rule_id, "SQLI-001");
assert!(matches!(result.level, SarifLevel::Error));
}
#[test]
fn test_sarif_json_output() {
let finding = SecurityFinding::new(
"CMD-001",
SecurityCategory::Injection(InjectionType::Command),
Severity::Critical,
Confidence::High,
Location::new("app.py", 10, 1, 10, 30),
"Command Injection",
"os.system called with user input",
);
let report = SecurityReport::new(vec![finding], 1);
let json = report.to_sarif_json().expect("SARIF JSON serialization");
assert!(json.contains("\"version\": \"2.1.0\""));
assert!(json.contains("CMD-001"));
assert!(json.contains("error")); }
}