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