use std::collections::HashMap;
use std::io::Write;
use crate::{MatchLocation, Severity, VerifiedFinding};
use super::{ReportError, Reporter};
pub struct SarifReporter<W: Write> {
writer: W,
findings: Vec<VerifiedFinding>,
rules: HashMap<String, SarifRule>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifRule {
id: String,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
short_description: Option<SarifMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
full_description: Option<SarifMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
help: Option<SarifMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
properties: Option<serde_json::Map<String, serde_json::Value>>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifMessage {
text: String,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifRun {
tool: SarifTool,
results: Vec<SarifResult>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifTool {
driver: SarifToolDriver,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifToolDriver {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
information_uri: Option<String>,
rules: Vec<SarifRule>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifResult {
rule_id: String,
level: String,
message: SarifMessage,
locations: Vec<SarifLocation>,
#[serde(skip_serializing_if = "Option::is_none")]
properties: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
related_locations: Option<Vec<SarifLocation>>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifLocation {
physical_location: SarifPhysicalLocation,
#[serde(skip_serializing_if = "Option::is_none")]
logical_locations: Option<Vec<SarifLogicalLocation>>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifPhysicalLocation {
#[serde(skip_serializing_if = "Option::is_none")]
artifact_location: Option<SarifArtifactLocation>,
#[serde(skip_serializing_if = "Option::is_none")]
region: Option<SarifRegion>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifArtifactLocation {
uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
uri_base_id: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifRegion {
#[serde(skip_serializing_if = "Option::is_none")]
start_line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
start_column: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
end_line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
end_column: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
snippet: Option<SarifSnippet>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifSnippet {
text: String,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifLogicalLocation {
name: String,
kind: String,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifLog {
version: String,
#[serde(rename = "$schema")]
schema: String,
runs: Vec<SarifRun>,
}
impl<W: Write> SarifReporter<W> {
pub fn new(writer: W) -> Self {
Self {
writer,
findings: Vec::new(),
rules: HashMap::new(),
}
}
fn severity_to_level(severity: Severity) -> &'static str {
match severity {
Severity::Critical => "error",
Severity::High => "error",
Severity::Medium => "warning",
Severity::Low => "note",
Severity::Info => "note",
}
}
fn build_rule(finding: &VerifiedFinding) -> SarifRule {
SarifRule {
id: finding.detector_id.clone(),
name: finding.detector_name.clone(),
short_description: Some(SarifMessage {
text: format!("{} secret detected", finding.service),
}),
full_description: Some(SarifMessage {
text: format!(
"A {} secret was detected by the {} detector",
finding.service, finding.detector_name
),
}),
help: Some(SarifMessage {
text: format!(
"Review and rotate the exposed {} credential.",
finding.service
),
}),
properties: Some({
let mut props = serde_json::Map::new();
props.insert(
"service".to_string(),
serde_json::Value::String(finding.service.clone()),
);
props.insert(
"severity".to_string(),
serde_json::Value::String(finding.severity.to_string()),
);
props
}),
}
}
fn location_to_sarif(loc: &MatchLocation) -> SarifLocation {
let artifact_location = Some(SarifArtifactLocation {
uri: loc.file_path.clone().unwrap_or_else(|| "stdin".to_string()),
uri_base_id: None,
});
let region = loc.line.map(|line| SarifRegion {
start_line: Some(line),
start_column: None,
end_line: None,
end_column: None,
snippet: None,
});
let mut logical_locations = Vec::new();
if let Some(commit) = &loc.commit {
logical_locations.push(SarifLogicalLocation {
name: commit.clone(),
kind: "commit".to_string(),
});
}
if let Some(author) = &loc.author {
logical_locations.push(SarifLogicalLocation {
name: author.clone(),
kind: "author".to_string(),
});
}
if let Some(date) = &loc.date {
logical_locations.push(SarifLogicalLocation {
name: date.clone(),
kind: "date".to_string(),
});
}
SarifLocation {
physical_location: SarifPhysicalLocation {
artifact_location,
region,
},
logical_locations: if logical_locations.is_empty() {
None
} else {
Some(logical_locations)
},
}
}
}
impl<W: Write> Reporter for SarifReporter<W> {
fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError> {
if !self.rules.contains_key(&finding.detector_id) {
let rule = Self::build_rule(finding);
self.rules.insert(finding.detector_id.clone(), rule);
}
self.findings.push(finding.clone());
Ok(())
}
fn finish(&mut self) -> Result<(), ReportError> {
let results: Vec<SarifResult> = self
.findings
.iter()
.map(|finding| {
let locations = vec![Self::location_to_sarif(&finding.location)];
let related_locations: Vec<SarifLocation> = finding
.additional_locations
.iter()
.map(Self::location_to_sarif)
.collect();
let mut properties = serde_json::Map::new();
properties.insert(
"verification".to_string(),
serde_json::Value::String(format!("{:?}", finding.verification).to_lowercase()),
);
if let Some(confidence) = finding.confidence {
properties.insert(
"confidence".to_string(),
serde_json::Value::Number(
serde_json::Number::from_f64(confidence).unwrap_or_else(|| 0.into()),
),
);
}
for (key, value) in &finding.metadata {
properties.insert(
format!("metadata.{}", key),
serde_json::Value::String(value.clone()),
);
}
SarifResult {
rule_id: finding.detector_id.clone(),
level: Self::severity_to_level(finding.severity).to_string(),
message: SarifMessage {
text: format!(
"{} secret detected: {}",
finding.service, finding.credential_redacted
),
},
locations,
properties: Some(properties),
related_locations: if related_locations.is_empty() {
None
} else {
Some(related_locations)
},
}
})
.collect();
let rules: Vec<SarifRule> = self.rules.values().cloned().collect();
let sarif_log = SarifLog {
version: "2.1.0".to_string(),
schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1.0/sarif-schema-2.1.0.json".to_string(),
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifToolDriver {
name: "keyhog".to_string(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
information_uri: Some("https://github.com/keyhog/keyhog".to_string()),
rules,
},
},
results,
}],
};
serde_json::to_writer_pretty(&mut self.writer, &sarif_log)?;
writeln!(self.writer)?;
Ok(())
}
}