Skip to main content

keyhog_core/
report.rs

1//! Report formatters: text, JSON, JSONL, and SARIF output for scanner findings.
2
3/// Animated ASCII-art banner with true-color gradient rendering.
4pub mod banner;
5mod json;
6mod sarif;
7mod text;
8
9use std::collections::HashMap;
10use std::sync::{OnceLock, RwLock};
11
12use thiserror::Error;
13
14pub use json::{JsonReporter, JsonlReporter};
15pub use sarif::SarifReporter;
16pub use text::TextReporter;
17
18/// Errors emitted while writing scanner reports.
19///
20/// # Examples
21///
22/// ```rust
23/// use keyhog_core::ReportError;
24///
25/// let error = ReportError::Io(std::io::Error::other("disk full"));
26/// assert!(error.to_string().contains("Fix"));
27/// ```
28#[derive(Debug, Error)]
29pub enum ReportError {
30    #[error("failed to write report: {0}. Fix: choose a writable output path or write to stdout")]
31    Io(#[from] std::io::Error),
32    #[error(
33        "failed to serialize report: {0}. Fix: switch to a simpler format or report this as a serialization bug"
34    )]
35    Serialize(#[from] serde_json::Error),
36}
37
38/// Trait implemented by all finding reporters.
39///
40/// # Examples
41///
42/// ```rust
43/// use keyhog_core::{JsonlReporter, Reporter};
44///
45/// let mut out = Vec::new();
46/// let mut reporter = JsonlReporter::new(&mut out);
47/// reporter.finish().unwrap();
48/// ```
49pub trait Reporter {
50    /// Emit one finding into the report stream.
51    fn report(&mut self, finding: &crate::VerifiedFinding) -> Result<(), ReportError>;
52    /// Flush and finalize the report output.
53    fn finish(&mut self) -> Result<(), ReportError>;
54}
55
56/// Factory used to build dynamically registered reporters.
57///
58/// # Examples
59///
60/// ```rust
61/// use keyhog_core::{JsonReporter, ReporterFactory};
62///
63/// let _factory: ReporterFactory = Box::new(|writer| Box::new(JsonReporter::new(writer)));
64/// ```
65pub type ReporterFactory =
66    Box<dyn Fn(Box<dyn std::io::Write + Send + 'static>) -> Box<dyn Reporter> + Send + Sync>;
67
68static REPORTER_REGISTRY: OnceLock<RwLock<HashMap<String, ReporterFactory>>> = OnceLock::new();
69
70/// Register a named reporter factory for custom output formats.
71///
72/// # Examples
73///
74/// ```rust
75/// use keyhog_core::{JsonReporter, register_reporter};
76///
77/// register_reporter("json-copy", Box::new(|writer| Box::new(JsonReporter::new(writer))));
78/// ```
79pub fn register_reporter(name: &str, factory: ReporterFactory) {
80    let Ok(mut registry) = REPORTER_REGISTRY
81        .get_or_init(|| RwLock::new(HashMap::new()))
82        .write()
83    else {
84        tracing::error!("failed to access reporter registry: cannot register '{name}'");
85        return;
86    };
87    registry.insert(name.to_string(), factory);
88}
89
90/// Build a previously registered custom reporter by name.
91///
92/// # Examples
93///
94/// ```rust
95/// use keyhog_core::{JsonReporter, make_custom_reporter, register_reporter};
96///
97/// register_reporter("json-copy-lookup", Box::new(|writer| Box::new(JsonReporter::new(writer))));
98/// let reporter = make_custom_reporter("json-copy-lookup", Box::new(Vec::new()));
99/// assert!(reporter.is_some());
100/// ```
101pub fn make_custom_reporter(
102    name: &str,
103    w: Box<dyn std::io::Write + Send + 'static>,
104) -> Option<Box<dyn Reporter>> {
105    let Ok(registry) = REPORTER_REGISTRY
106        .get_or_init(|| RwLock::new(HashMap::new()))
107        .read()
108    else {
109        tracing::error!("failed to access reporter registry: cannot look up '{name}'");
110        return None;
111    };
112    registry.get(name).map(|factory| factory(w))
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::{MatchLocation, Severity, VerificationResult, VerifiedFinding};
119    use std::collections::HashMap;
120
121    fn sample_finding() -> VerifiedFinding {
122        VerifiedFinding {
123            detector_id: "slack-bot-token".into(),
124            detector_name: "Slack Bot Token".into(),
125            service: "slack".into(),
126            severity: Severity::Critical,
127            credential_redacted: "xoxb***************".into(),
128            location: MatchLocation {
129                source: "filesystem".into(),
130                file_path: Some("config.py".into()),
131                line: Some(42),
132                offset: 0,
133                commit: None,
134                author: None,
135                date: None,
136            },
137            verification: VerificationResult::Live,
138            metadata: HashMap::from([("team".into(), "acme".into())]),
139            additional_locations: vec![],
140            confidence: Some(0.85),
141        }
142    }
143
144    #[test]
145    fn text_reporter_output() {
146        let mut buf = Vec::new();
147        let mut reporter = TextReporter::new(&mut buf);
148        reporter.report(&sample_finding()).unwrap();
149        reporter.finish().unwrap();
150        let output = String::from_utf8(buf).unwrap();
151        assert!(output.contains("LIVE"));
152        assert!(output.contains("Slack Bot Token"));
153        assert!(output.contains("config.py:42"));
154    }
155
156    #[test]
157    fn jsonl_reporter_output() {
158        let mut buf = Vec::new();
159        let mut reporter = JsonlReporter::new(&mut buf);
160        reporter.report(&sample_finding()).unwrap();
161        reporter.finish().unwrap();
162        let output = String::from_utf8(buf).unwrap();
163        let parsed: serde_json::Value = serde_json::from_str(output.trim()).unwrap();
164        assert_eq!(parsed["service"], "slack");
165    }
166
167    #[test]
168    fn sarif_reporter_basic_structure() {
169        let mut buf = Vec::new();
170        let mut reporter = SarifReporter::new(&mut buf);
171        reporter.report(&sample_finding()).unwrap();
172        reporter.finish().unwrap();
173        let output = String::from_utf8(buf).unwrap();
174        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
175
176        assert_eq!(parsed["version"], "2.1.0");
177        assert!(
178            parsed["$schema"]
179                .as_str()
180                .unwrap()
181                .contains("sarif-schema-2.1.0.json")
182        );
183
184        let runs = parsed["runs"].as_array().unwrap();
185        assert_eq!(runs.len(), 1);
186
187        let tool = &runs[0]["tool"]["driver"];
188        assert_eq!(tool["name"], "keyhog");
189        assert!(tool["version"].is_string());
190
191        let rules = tool["rules"].as_array().unwrap();
192        assert_eq!(rules.len(), 1);
193        assert_eq!(rules[0]["id"], "slack-bot-token");
194        assert_eq!(rules[0]["name"], "Slack Bot Token");
195        assert!(rules[0]["properties"]["service"].is_string());
196
197        let results = runs[0]["results"].as_array().unwrap();
198        assert_eq!(results.len(), 1);
199        assert_eq!(results[0]["ruleId"], "slack-bot-token");
200        assert_eq!(results[0]["level"], "error");
201        assert!(
202            results[0]["message"]["text"]
203                .as_str()
204                .unwrap()
205                .contains("slack")
206        );
207
208        let location = &results[0]["locations"][0];
209        assert_eq!(
210            location["physicalLocation"]["artifactLocation"]["uri"],
211            "config.py"
212        );
213        assert_eq!(location["physicalLocation"]["region"]["startLine"], 42);
214
215        let props = &results[0]["properties"];
216        assert_eq!(props["verification"], "live");
217        assert_eq!(props["confidence"], 0.85);
218        assert_eq!(props["metadata.team"], "acme");
219    }
220
221    #[test]
222    fn sarif_reporter_severity_mapping() {
223        let severities = vec![
224            (Severity::Critical, "error"),
225            (Severity::High, "error"),
226            (Severity::Medium, "warning"),
227            (Severity::Low, "note"),
228            (Severity::Info, "note"),
229        ];
230
231        for (sev, expected_level) in severities {
232            let mut finding = sample_finding();
233            finding.severity = sev;
234            finding.detector_id = format!("test-{}", expected_level);
235
236            let mut buf = Vec::new();
237            let mut reporter = SarifReporter::new(&mut buf);
238            reporter.report(&finding).unwrap();
239            reporter.finish().unwrap();
240
241            let output = String::from_utf8(buf).unwrap();
242            let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
243            let results = parsed["runs"][0]["results"].as_array().unwrap();
244            assert_eq!(
245                results[0]["level"], expected_level,
246                "severity {:?} should map to level {}",
247                sev, expected_level
248            );
249        }
250    }
251
252    #[test]
253    fn sarif_reporter_multiple_findings() {
254        let mut buf = Vec::new();
255        let mut reporter = SarifReporter::new(&mut buf);
256
257        let finding1 = sample_finding();
258        let mut finding2 = sample_finding();
259        finding2.detector_id = "github-token".into();
260        finding2.detector_name = "GitHub Token".into();
261        finding2.service = "github".into();
262        finding2.location.file_path = Some(".env".into());
263        finding2.location.line = Some(10);
264
265        reporter.report(&finding1).unwrap();
266        reporter.report(&finding2).unwrap();
267        reporter.finish().unwrap();
268
269        let output = String::from_utf8(buf).unwrap();
270        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
271
272        let rules = parsed["runs"][0]["tool"]["driver"]["rules"]
273            .as_array()
274            .unwrap();
275        assert_eq!(rules.len(), 2);
276
277        let results = parsed["runs"][0]["results"].as_array().unwrap();
278        assert_eq!(results.len(), 2);
279    }
280
281    #[test]
282    fn sarif_reporter_git_location() {
283        let mut finding = sample_finding();
284        finding.location.commit = Some("abc123".into());
285        finding.location.author = Some("developer".into());
286        finding.location.date = Some("2026-03-20T12:00:00Z".into());
287
288        let mut buf = Vec::new();
289        let mut reporter = SarifReporter::new(&mut buf);
290        reporter.report(&finding).unwrap();
291        reporter.finish().unwrap();
292
293        let output = String::from_utf8(buf).unwrap();
294        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
295
296        let location = &parsed["runs"][0]["results"][0]["locations"][0];
297        let logical_locs = location["logicalLocations"].as_array().unwrap();
298
299        assert_eq!(logical_locs.len(), 3);
300        assert_eq!(logical_locs[0]["kind"], "commit");
301        assert_eq!(logical_locs[0]["name"], "abc123");
302        assert_eq!(logical_locs[1]["kind"], "author");
303        assert_eq!(logical_locs[1]["name"], "developer");
304        assert_eq!(logical_locs[2]["kind"], "date");
305        assert_eq!(logical_locs[2]["name"], "2026-03-20T12:00:00Z");
306    }
307
308    #[test]
309    fn sarif_reporter_related_locations() {
310        let mut finding = sample_finding();
311        finding.additional_locations = vec![MatchLocation {
312            source: "filesystem".into(),
313            file_path: Some("backup.py".into()),
314            line: Some(100),
315            offset: 0,
316            commit: None,
317            author: None,
318            date: None,
319        }];
320
321        let mut buf = Vec::new();
322        let mut reporter = SarifReporter::new(&mut buf);
323        reporter.report(&finding).unwrap();
324        reporter.finish().unwrap();
325
326        let output = String::from_utf8(buf).unwrap();
327        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
328
329        let related = parsed["runs"][0]["results"][0]["relatedLocations"]
330            .as_array()
331            .unwrap();
332        assert_eq!(related.len(), 1);
333        assert_eq!(
334            related[0]["physicalLocation"]["artifactLocation"]["uri"],
335            "backup.py"
336        );
337        assert_eq!(related[0]["physicalLocation"]["region"]["startLine"], 100);
338    }
339}