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::{ReportError, Reporter};
10
11/// SARIF v2.1.0 reporter for integration with GitHub, Azure DevOps, and IDEs.
12///
13/// # Examples
14///
15/// ```rust
16/// use keyhog_core::SarifReporter;
17///
18/// let reporter = SarifReporter::new(Vec::new());
19/// let _ = reporter;
20/// ```
21pub struct SarifReporter<W: Write> {
22    writer: W,
23    findings: Vec<VerifiedFinding>,
24    rules: HashMap<String, SarifRule>,
25}
26
27/// A SARIF rule (tool component rule).
28#[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    /// Create a SARIF reporter.
150    ///
151    /// # Examples
152    ///
153    /// ```rust
154    /// use keyhog_core::SarifReporter;
155    ///
156    /// let reporter = SarifReporter::new(Vec::new());
157    /// let _ = reporter;
158    /// ```
159    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}