1pub 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#[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
38pub trait Reporter {
50 fn report(&mut self, finding: &crate::VerifiedFinding) -> Result<(), ReportError>;
52 fn finish(&mut self) -> Result<(), ReportError>;
54}
55
56pub 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
70pub 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
90pub 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}