1use std::collections::HashMap;
5use std::io::Write;
6
7use crate::{MatchLocation, Severity, VerifiedFinding};
8
9use super::{ReportError, Reporter};
10
11pub struct SarifReporter<W: Write> {
22 writer: W,
23 findings: Vec<VerifiedFinding>,
24 rules: HashMap<String, SarifRule>,
25}
26
27#[derive(Debug, Clone, serde::Serialize)]
29#[serde(rename_all = "camelCase")]
30struct SarifRule {
31 id: String,
32 name: String,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 short_description: Option<SarifMessage>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 full_description: Option<SarifMessage>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 help: Option<SarifMessage>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 properties: Option<serde_json::Map<String, serde_json::Value>>,
41}
42
43#[derive(Debug, Clone, serde::Serialize)]
44#[serde(rename_all = "camelCase")]
45struct SarifMessage {
46 text: String,
47}
48
49#[derive(Debug, Clone, serde::Serialize)]
50#[serde(rename_all = "camelCase")]
51struct SarifRun {
52 tool: SarifTool,
53 results: Vec<SarifResult>,
54}
55
56#[derive(Debug, Clone, serde::Serialize)]
57#[serde(rename_all = "camelCase")]
58struct SarifTool {
59 driver: SarifToolDriver,
60}
61
62#[derive(Debug, Clone, serde::Serialize)]
63#[serde(rename_all = "camelCase")]
64struct SarifToolDriver {
65 name: String,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 version: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 information_uri: Option<String>,
70 rules: Vec<SarifRule>,
71}
72
73#[derive(Debug, Clone, serde::Serialize)]
74#[serde(rename_all = "camelCase")]
75struct SarifResult {
76 rule_id: String,
77 level: String,
78 message: SarifMessage,
79 locations: Vec<SarifLocation>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 properties: Option<serde_json::Map<String, serde_json::Value>>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 related_locations: Option<Vec<SarifLocation>>,
84}
85
86#[derive(Debug, Clone, serde::Serialize)]
87#[serde(rename_all = "camelCase")]
88struct SarifLocation {
89 physical_location: SarifPhysicalLocation,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 logical_locations: Option<Vec<SarifLogicalLocation>>,
92}
93
94#[derive(Debug, Clone, serde::Serialize)]
95#[serde(rename_all = "camelCase")]
96struct SarifPhysicalLocation {
97 #[serde(skip_serializing_if = "Option::is_none")]
98 artifact_location: Option<SarifArtifactLocation>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 region: Option<SarifRegion>,
101}
102
103#[derive(Debug, Clone, serde::Serialize)]
104#[serde(rename_all = "camelCase")]
105struct SarifArtifactLocation {
106 uri: String,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 uri_base_id: Option<String>,
109}
110
111#[derive(Debug, Clone, serde::Serialize)]
112#[serde(rename_all = "camelCase")]
113struct SarifRegion {
114 #[serde(skip_serializing_if = "Option::is_none")]
115 start_line: Option<usize>,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 start_column: Option<usize>,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 end_line: Option<usize>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 end_column: Option<usize>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 snippet: Option<SarifSnippet>,
124}
125
126#[derive(Debug, Clone, serde::Serialize)]
127#[serde(rename_all = "camelCase")]
128struct SarifSnippet {
129 text: String,
130}
131
132#[derive(Debug, Clone, serde::Serialize)]
133#[serde(rename_all = "camelCase")]
134struct SarifLogicalLocation {
135 name: String,
136 kind: String,
137}
138
139#[derive(Debug, Clone, serde::Serialize)]
140#[serde(rename_all = "camelCase")]
141struct SarifLog {
142 version: String,
143 #[serde(rename = "$schema")]
144 schema: String,
145 runs: Vec<SarifRun>,
146}
147
148impl<W: Write> SarifReporter<W> {
149 pub fn new(writer: W) -> Self {
160 Self {
161 writer,
162 findings: Vec::new(),
163 rules: HashMap::new(),
164 }
165 }
166
167 fn severity_to_level(severity: Severity) -> &'static str {
168 match severity {
169 Severity::Critical => "error",
170 Severity::High => "error",
171 Severity::Medium => "warning",
172 Severity::Low => "note",
173 Severity::Info => "note",
174 }
175 }
176
177 fn build_rule(finding: &VerifiedFinding) -> SarifRule {
178 SarifRule {
179 id: finding.detector_id.clone(),
180 name: finding.detector_name.clone(),
181 short_description: Some(SarifMessage {
182 text: format!("{} secret detected", finding.service),
183 }),
184 full_description: Some(SarifMessage {
185 text: format!(
186 "A {} secret was detected by the {} detector",
187 finding.service, finding.detector_name
188 ),
189 }),
190 help: Some(SarifMessage {
191 text: format!(
192 "Review and rotate the exposed {} credential.",
193 finding.service
194 ),
195 }),
196 properties: Some({
197 let mut props = serde_json::Map::new();
198 props.insert(
199 "service".to_string(),
200 serde_json::Value::String(finding.service.clone()),
201 );
202 props.insert(
203 "severity".to_string(),
204 serde_json::Value::String(finding.severity.to_string()),
205 );
206 props
207 }),
208 }
209 }
210
211 fn location_to_sarif(loc: &MatchLocation) -> SarifLocation {
212 let artifact_location = Some(SarifArtifactLocation {
213 uri: loc.file_path.clone().unwrap_or_else(|| "stdin".to_string()),
214 uri_base_id: None,
215 });
216
217 let region = loc.line.map(|line| SarifRegion {
218 start_line: Some(line),
219 start_column: None,
220 end_line: None,
221 end_column: None,
222 snippet: None,
223 });
224
225 let mut logical_locations = Vec::new();
226
227 if let Some(commit) = &loc.commit {
228 logical_locations.push(SarifLogicalLocation {
229 name: commit.clone(),
230 kind: "commit".to_string(),
231 });
232 }
233
234 if let Some(author) = &loc.author {
235 logical_locations.push(SarifLogicalLocation {
236 name: author.clone(),
237 kind: "author".to_string(),
238 });
239 }
240
241 if let Some(date) = &loc.date {
242 logical_locations.push(SarifLogicalLocation {
243 name: date.clone(),
244 kind: "date".to_string(),
245 });
246 }
247
248 SarifLocation {
249 physical_location: SarifPhysicalLocation {
250 artifact_location,
251 region,
252 },
253 logical_locations: if logical_locations.is_empty() {
254 None
255 } else {
256 Some(logical_locations)
257 },
258 }
259 }
260}
261
262impl<W: Write> Reporter for SarifReporter<W> {
263 fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError> {
264 if !self.rules.contains_key(&finding.detector_id) {
265 let rule = Self::build_rule(finding);
266 self.rules.insert(finding.detector_id.clone(), rule);
267 }
268
269 self.findings.push(finding.clone());
270 Ok(())
271 }
272
273 fn finish(&mut self) -> Result<(), ReportError> {
274 let results: Vec<SarifResult> = self
275 .findings
276 .iter()
277 .map(|finding| {
278 let locations = vec![Self::location_to_sarif(&finding.location)];
279
280 let related_locations: Vec<SarifLocation> = finding
281 .additional_locations
282 .iter()
283 .map(Self::location_to_sarif)
284 .collect();
285
286 let mut properties = serde_json::Map::new();
287 properties.insert(
288 "verification".to_string(),
289 serde_json::Value::String(format!("{:?}", finding.verification).to_lowercase()),
290 );
291
292 if let Some(confidence) = finding.confidence {
293 properties.insert(
294 "confidence".to_string(),
295 serde_json::Value::Number(
296 serde_json::Number::from_f64(confidence).unwrap_or_else(|| 0.into()),
297 ),
298 );
299 }
300
301 for (key, value) in &finding.metadata {
302 properties.insert(
303 format!("metadata.{}", key),
304 serde_json::Value::String(value.clone()),
305 );
306 }
307
308 SarifResult {
309 rule_id: finding.detector_id.clone(),
310 level: Self::severity_to_level(finding.severity).to_string(),
311 message: SarifMessage {
312 text: format!(
313 "{} secret detected: {}",
314 finding.service, finding.credential_redacted
315 ),
316 },
317 locations,
318 properties: Some(properties),
319 related_locations: if related_locations.is_empty() {
320 None
321 } else {
322 Some(related_locations)
323 },
324 }
325 })
326 .collect();
327
328 let rules: Vec<SarifRule> = self.rules.values().cloned().collect();
329
330 let sarif_log = SarifLog {
331 version: "2.1.0".to_string(),
332 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1.0/sarif-schema-2.1.0.json".to_string(),
333 runs: vec![SarifRun {
334 tool: SarifTool {
335 driver: SarifToolDriver {
336 name: "keyhog".to_string(),
337 version: Some(env!("CARGO_PKG_VERSION").to_string()),
338 information_uri: Some("https://github.com/keyhog/keyhog".to_string()),
339 rules,
340 },
341 },
342 results,
343 }],
344 };
345
346 serde_json::to_writer_pretty(&mut self.writer, &sarif_log)?;
347 writeln!(self.writer)?;
348 Ok(())
349 }
350}