Skip to main content

keyhog_core/report/
sarif.rs

1//! SARIF reporter for code-scanning platforms such as GitHub code scanning,
2//! Azure DevOps, and IDE integrations.
3
4use std::collections::HashMap;
5use std::io::Write;
6
7use crate::{MatchLocation, Severity, VerifiedFinding};
8
9use super::{BufferedFindingReporter, ReportError, Reporter, WriterBackedReporter};
10
11/// SARIF v2.1.0 reporter for integration with GitHub, Azure DevOps, and IDEs.
12pub struct SarifReporter<W: Write + Send> {
13    writer: W,
14    findings: Vec<VerifiedFinding>,
15    rules: HashMap<String, SarifRule>,
16}
17
18/// A SARIF rule (tool component rule).
19#[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}