Skip to main content

scour_secrets/
report.rs

1//! Structured reporting for sanitization runs.
2//!
3//! Generates a JSON report summarising what the sanitization tool did
4//! without ever including original secret values. The report captures:
5//!
6//! - **Metadata**: tool version, CLI flags, timestamp.
7//! - **Per-file details**: matches found, replacements applied, bytes
8//!   processed, and per-pattern match counts.
9//! - **Aggregated summary**: totals across all files plus wall-clock
10//!   duration.
11//! - **Log context** (optional): keyword-matched lines with surrounding
12//!   context windows, populated when `--extract-context` is used.
13//!
14//! # Thread Safety
15//!
16//! [`ReportBuilder`] is `Send + Sync`. Multiple threads can record file
17//! results concurrently via [`ReportBuilder::record_file`], which takes
18//! an internal `Mutex` only long enough to push a single entry.
19//!
20//! # Example
21//!
22//! ```rust
23//! use scour_secrets::log_context::{extract_context, LogContextConfig};
24//! use scour_secrets::report::{FileReport, ReportBuilder, ReportMetadata};
25//! use std::collections::HashMap;
26//!
27//! let meta = ReportMetadata::new("0.4.0", "2026-03-01T00:00:00Z")
28//!     .with_deterministic(true)
29//!     .with_chunk_size(1_048_576)
30//!     .with_threads(Some(4))
31//!     .with_secrets_file(Some("secrets.enc".into()));
32//!
33//! let builder = ReportBuilder::new(meta);
34//!
35//! let mut file_report = FileReport::new("data.log", "scanner");
36//! file_report.matches = 42;
37//! file_report.replacements = 42;
38//! file_report.bytes_processed = 10_000;
39//! file_report.bytes_output = 10_200;
40//! file_report.pattern_counts = HashMap::from([("email".into(), 30), ("ipv4".into(), 12)]);
41//! builder.record_file(file_report);
42//!
43//! // Optionally attach per-file log context (populated by --extract-context).
44//! let sanitized_output = "INFO ok\nERROR disk full\nINFO retrying";
45//! let ctx = extract_context(sanitized_output, &LogContextConfig::new().with_context_lines(1));
46//! builder.set_file_log_context("data.log", ctx);
47//!
48//! let report = builder.finish();
49//! let json = report.to_json_pretty().unwrap();
50//! assert!(json.contains("\"total_matches\": 42"));
51//! assert!(json.contains("\"log_context\""));
52//! assert!(json.contains("\"keyword\": \"error\""));
53//! ```
54
55use serde::Serialize;
56use std::collections::HashMap;
57use std::sync::Mutex;
58use std::time::Instant;
59
60use crate::log_context::LogContextResult;
61use crate::scanner::{MatchLocation, ScanStats};
62
63// ---------------------------------------------------------------------------
64// Report structures
65// ---------------------------------------------------------------------------
66
67/// Top-level sanitization report.
68///
69/// Serialized to JSON via [`Self::to_json`] / [`Self::to_json_pretty`].
70/// Never contains original secret values.
71#[derive(Debug, Clone, Serialize)]
72#[non_exhaustive]
73pub struct SanitizeReport {
74    /// Tool metadata and flags.
75    pub metadata: ReportMetadata,
76    /// Aggregated summary across all files.
77    pub summary: ReportSummary,
78    /// Per-file details. Each entry may include `log_context` when
79    /// `--extract-context` was used.
80    pub files: Vec<FileReport>,
81}
82
83impl SanitizeReport {
84    /// Serialize the report as compact JSON.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`serde_json::Error`] if serialization fails.
89    pub fn to_json(&self) -> serde_json::Result<String> {
90        serde_json::to_string(self)
91    }
92
93    /// Serialize the report as pretty-printed JSON.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`serde_json::Error`] if serialization fails.
98    pub fn to_json_pretty(&self) -> serde_json::Result<String> {
99        serde_json::to_string_pretty(self)
100    }
101
102    /// Serialize the report as SARIF 2.1.0 JSON.
103    ///
104    /// SARIF (Static Analysis Results Interchange Format) is consumed natively
105    /// by GitHub Advanced Security, VS Code Problems panel, and most SIEM
106    /// tooling. Results are file-level (no line numbers — the sanitize engine
107    /// operates on byte streams and does not record source positions).
108    ///
109    /// # Errors
110    ///
111    /// Returns [`serde_json::Error`] if serialization fails.
112    pub fn to_sarif(&self) -> serde_json::Result<String> {
113        use serde_json::json;
114
115        let rules = sarif_rules(self);
116        let results = sarif_results(self);
117        let artifacts: Vec<serde_json::Value> = self
118            .files
119            .iter()
120            .map(|f| {
121                let uri = path_to_sarif_uri(&f.path);
122                json!({ "location": { "uri": uri, "uriBaseId": "%SRCROOT%" } })
123            })
124            .collect();
125
126        let sarif = json!({
127            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
128            "version": "2.1.0",
129            "runs": [{
130                "tool": {
131                    "driver": {
132                        "name": "scour-secrets",
133                        "version": self.metadata.version,
134                        "informationUri": "https://github.com/kayelohbyte/scour-secrets",
135                        "rules": rules
136                    }
137                },
138                "invocations": [{
139                    "executionSuccessful": true,
140                    "endTimeUtc": self.metadata.timestamp
141                }],
142                "results": results,
143                "artifacts": artifacts
144            }]
145        });
146
147        serde_json::to_string_pretty(&sarif)
148    }
149
150    /// Render the report as a self-contained HTML document.
151    ///
152    /// The output has no external dependencies (no CDN, no external fonts).
153    /// Includes a summary dashboard, per-pattern totals, and a per-file table.
154    /// Dark mode is supported via `prefers-color-scheme`.
155    #[must_use]
156    pub fn to_html(&self) -> String {
157        let s = &self.summary;
158        let m = &self.metadata;
159        let has_locations = self.files.iter().any(|f| f.match_locations.is_some());
160
161        let cards = html_summary_cards(s);
162        let patterns_section = html_patterns_section(s);
163        let file_rows: String = self
164            .files
165            .iter()
166            .map(|f| html_file_row(f, has_locations))
167            .collect();
168        let first_line_header = if has_locations {
169            "<th>First match</th>"
170        } else {
171            ""
172        };
173
174        format!(
175            r#"<!DOCTYPE html>
176<html lang="en">
177<head>
178<meta charset="utf-8">
179<meta name="viewport" content="width=device-width,initial-scale=1">
180<title>scour-secrets report</title>
181<style>
182:root{{--bg:#f8f9fa;--surface:#fff;--border:#dee2e6;--text:#212529;--muted:#6c757d;--accent:#0d6efd;--danger:#dc3545;--warn-col:#fd7e14;--success:#198754;--badge:#e9ecef;--code-bg:#f1f3f4}}
183@media(prefers-color-scheme:dark){{:root{{--bg:#0d1117;--surface:#161b22;--border:#30363d;--text:#e6edf3;--muted:#8b949e;--accent:#58a6ff;--danger:#f85149;--warn-col:#d29922;--success:#3fb950;--badge:#21262d;--code-bg:#1c2128}}}}
184*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
185body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);line-height:1.5;font-size:14px}}
186.container{{max-width:1100px;margin:0 auto;padding:24px 16px}}
187header{{margin-bottom:24px;padding-bottom:16px;border-bottom:1px solid var(--border)}}
188h1{{font-size:1.4rem;font-weight:600}}
189.meta{{font-size:.8rem;color:var(--muted);margin-top:4px}}
190.section{{margin-bottom:28px}}
191h2{{font-size:.95rem;font-weight:600;margin-bottom:10px}}
192.cards{{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px}}
193.card{{background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:14px}}
194.card-label{{font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}}
195.card-value{{font-size:1.4rem;font-weight:600;margin-top:2px}}
196.table-wrap{{overflow-x:auto}}
197table{{width:100%;border-collapse:collapse;background:var(--surface);border:1px solid var(--border);border-radius:6px;font-size:.85rem}}
198th{{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);font-weight:600;color:var(--muted);white-space:nowrap}}
199td{{padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}}
200tr:last-child td{{border-bottom:none}}
201tr:hover td{{background:var(--badge)}}
202code{{background:var(--code-bg);border-radius:3px;padding:1px 4px;font-size:.8rem;word-break:break-all}}
203.badge{{display:inline-block;padding:1px 7px;border-radius:12px;font-size:.72rem;font-weight:500;background:var(--badge);margin:1px}}
204.badge-pii{{background:rgba(220,53,69,.12);color:var(--danger)}}
205.badge-warn{{background:rgba(253,126,20,.12);color:var(--warn-col)}}
206.count-zero{{color:var(--muted)}}
207.count-positive{{font-weight:600}}
208footer{{margin-top:40px;padding-top:16px;border-top:1px solid var(--border);font-size:.75rem;color:var(--muted)}}
209</style>
210</head>
211<body>
212<div class="container">
213<header>
214<h1>scour-secrets report</h1>
215<div class="meta">version {version}&nbsp;·&nbsp;{timestamp}&nbsp;·&nbsp;{duration_ms} ms total</div>
216</header>
217{cards}
218{patterns_section}
219<div class="section">
220<h2>Files</h2>
221<div class="table-wrap"><table>
222<thead><tr><th>Path</th><th>Matches</th><th>Method</th>{first_line_header}<th>Patterns</th></tr></thead>
223<tbody>{file_rows}</tbody>
224</table></div></div>
225<footer>Generated by <strong>scour-secrets {version}</strong> on {timestamp}</footer>
226</div>
227</body>
228</html>"#,
229            version = html_escape(&m.version),
230            timestamp = html_escape(&m.timestamp),
231            duration_ms = s.duration_ms,
232            cards = cards,
233            patterns_section = patterns_section,
234            first_line_header = first_line_header,
235            file_rows = file_rows,
236        )
237    }
238}
239
240// ---------------------------------------------------------------------------
241// SARIF helpers
242// ---------------------------------------------------------------------------
243
244/// Build the SARIF `rules` array: one entry per named pattern plus an optional
245/// synthetic `"sensitive_value"` rule for files whose matches have no named breakdown.
246fn sarif_rules(report: &SanitizeReport) -> Vec<serde_json::Value> {
247    use serde_json::json;
248
249    let needs_generic = report
250        .files
251        .iter()
252        .any(|f| f.matches > 0 && f.pattern_counts.is_empty());
253
254    let mut rule_ids: Vec<&str> = report
255        .summary
256        .pattern_counts
257        .keys()
258        .map(String::as_str)
259        .collect();
260    rule_ids.sort_unstable();
261    if needs_generic {
262        rule_ids.push("sensitive_value");
263    }
264
265    rule_ids
266        .iter()
267        .map(|&id| {
268            let (short, full) = if id == "sensitive_value" {
269                (
270                    "Sensitive value detected".to_owned(),
271                    "One or more sensitive values were detected during sanitization and \
272                     replaced with safe substitutes. No original values are stored. \
273                     Run with a secrets file for per-pattern breakdown."
274                        .to_owned(),
275                )
276            } else {
277                (
278                    format!("Sensitive value of type '{}' detected", id),
279                    format!(
280                        "A sensitive value of type '{}' was detected during sanitization \
281                         and replaced with a safe substitute. No original value is stored.",
282                        id
283                    ),
284                )
285            };
286            json!({
287                "id": id,
288                "name": sarif_rule_name(id),
289                "shortDescription": { "text": short },
290                "fullDescription": { "text": full },
291                "defaultConfiguration": { "level": sarif_level(id) },
292                "properties": { "tags": ["security"] }
293            })
294        })
295        .collect()
296}
297
298/// Build the SARIF `results` array: one entry per (file, pattern) pair with a
299/// non-zero count, including the first known line number when available.
300fn sarif_results(report: &SanitizeReport) -> Vec<serde_json::Value> {
301    use serde_json::json;
302
303    let mut results = Vec::new();
304    for f in &report.files {
305        let uri = path_to_sarif_uri(&f.path);
306        let file_location = json!([{
307            "physicalLocation": {
308                "artifactLocation": { "uri": &uri, "uriBaseId": "%SRCROOT%" }
309            }
310        }]);
311
312        if f.matches > 0 && f.pattern_counts.is_empty() {
313            results.push(json!({
314                "ruleId": "sensitive_value",
315                "level": "warning",
316                "message": {
317                    "text": format!("{} sensitive value(s) detected and sanitized.", f.matches)
318                },
319                "locations": file_location
320            }));
321            continue;
322        }
323
324        for (pattern, &count) in &f.pattern_counts {
325            if count == 0 {
326                continue;
327            }
328            // Use startLine when we have location data for this pattern.
329            let first_line = f.match_locations.as_ref().and_then(|ml| {
330                ml.locations
331                    .iter()
332                    .find(|loc| loc.pattern == *pattern)
333                    .map(|loc| loc.line)
334            });
335            let loc = match first_line {
336                Some(line) => json!([{
337                    "physicalLocation": {
338                        "artifactLocation": { "uri": &uri, "uriBaseId": "%SRCROOT%" },
339                        "region": { "startLine": line }
340                    }
341                }]),
342                None => file_location.clone(),
343            };
344            results.push(json!({
345                "ruleId": pattern,
346                "level": sarif_level(pattern),
347                "message": {
348                    "text": format!(
349                        "{} sensitive value(s) of type '{}' detected and sanitized.",
350                        count, pattern
351                    )
352                },
353                "locations": loc
354            }));
355        }
356    }
357    results
358}
359
360// ---------------------------------------------------------------------------
361// HTML helpers
362// ---------------------------------------------------------------------------
363
364/// Render the five summary metric cards (files, matches, replacements, input, duration).
365fn html_summary_cards(s: &crate::report::ReportSummary) -> String {
366    format!(
367        r#"<div class="cards">
368  <div class="card"><div class="card-label">Files</div><div class="card-value">{}</div></div>
369  <div class="card"><div class="card-label">Matches</div><div class="card-value">{}</div></div>
370  <div class="card"><div class="card-label">Replacements</div><div class="card-value">{}</div></div>
371  <div class="card"><div class="card-label">Input</div><div class="card-value">{}</div></div>
372  <div class="card"><div class="card-label">Duration</div><div class="card-value">{} ms</div></div>
373</div>"#,
374        s.total_files,
375        s.total_matches,
376        s.total_replacements,
377        fmt_bytes(s.total_bytes_processed),
378        s.duration_ms,
379    )
380}
381
382/// Render the per-pattern breakdown table, sorted by match count descending.
383/// Returns an empty string when there are no matches (section is omitted).
384fn html_patterns_section(s: &crate::report::ReportSummary) -> String {
385    if s.total_matches == 0 {
386        return String::new();
387    }
388    let mut sorted: Vec<(&String, &u64)> = s.pattern_counts.iter().collect();
389    sorted.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
390    let rows: String = sorted.iter().fold(String::new(), |mut s, (pat, count)| {
391        use std::fmt::Write;
392        let _ = writeln!(
393            s,
394            "<tr><td>{}</td><td>{}</td></tr>",
395            html_escape(pat),
396            count
397        );
398        s
399    });
400    format!(
401        r#"<div class="section">
402<h2>Patterns detected</h2>
403<div class="table-wrap"><table>
404<thead><tr><th>Pattern</th><th>Total matches</th></tr></thead>
405<tbody>{}</tbody>
406</table></div></div>"#,
407        rows
408    )
409}
410
411/// Render a single `<tr>` for the per-file table.
412/// `has_locations` controls whether the "First match" column is included.
413fn html_file_row(f: &FileReport, has_locations: bool) -> String {
414    let badges: String = {
415        let mut pairs: Vec<(&String, &u64)> = f.pattern_counts.iter().collect();
416        pairs.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
417        pairs
418            .iter()
419            .filter(|(_, &c)| c > 0)
420            .fold(String::new(), |mut s, (pat, count)| {
421                use std::fmt::Write;
422                let _ = write!(
423                    s,
424                    r#"<span class="badge {}">{}: {}</span>"#,
425                    sarif_badge_class(pat),
426                    html_escape(pat),
427                    count,
428                );
429                s
430            })
431    };
432    let match_class = if f.matches > 0 {
433        "count-positive"
434    } else {
435        "count-zero"
436    };
437    let first_line_cell = if has_locations {
438        match f
439            .match_locations
440            .as_ref()
441            .and_then(|ml| ml.locations.first())
442        {
443            Some(loc) => {
444                let truncated = if f.match_locations.as_ref().is_some_and(|ml| ml.truncated) {
445                    r#"<span title="more matches not shown">…</span>"#
446                } else {
447                    ""
448                };
449                format!(
450                    "<td class=\"count-positive\">L{}{}</td>",
451                    loc.line, truncated
452                )
453            }
454            None => "<td class=\"count-zero\">—</td>".to_owned(),
455        }
456    } else {
457        String::new()
458    };
459    format!(
460        "<tr><td><code>{}</code></td><td class=\"{}\">{}</td><td>{}</td>{}<td>{}</td></tr>\n",
461        html_escape(&f.path),
462        match_class,
463        f.matches,
464        html_escape(&f.method),
465        first_line_cell,
466        badges,
467    )
468}
469
470// ---------------------------------------------------------------------------
471// Private helpers
472// ---------------------------------------------------------------------------
473
474fn is_pii_category(pattern: &str) -> bool {
475    matches!(
476        pattern,
477        "email" | "name" | "phone" | "credit_card" | "ssn" | "auth_token" | "jwt"
478    )
479}
480
481/// Map a pattern name to a SARIF severity level.
482/// PII and credential categories → "error"; everything else → "warning".
483fn sarif_level(pattern: &str) -> &'static str {
484    if is_pii_category(pattern) {
485        "error"
486    } else {
487        "warning"
488    }
489}
490
491/// Convert a pattern name to a CamelCase SARIF rule name.
492/// e.g. "auth_token" → "AuthToken", "custom:password" → "CustomPassword"
493fn sarif_rule_name(pattern: &str) -> String {
494    pattern
495        .split(['_', ':', '-'])
496        .map(|word| {
497            let mut chars = word.chars();
498            match chars.next() {
499                None => String::new(),
500                Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
501            }
502        })
503        .collect()
504}
505
506/// Convert a file path to a SARIF URI (forward slashes, no percent-encoding).
507fn path_to_sarif_uri(path: &str) -> String {
508    path.replace('\\', "/")
509}
510
511/// CSS badge class for a pattern in the HTML report.
512fn sarif_badge_class(pattern: &str) -> &'static str {
513    if is_pii_category(pattern) {
514        "badge-pii"
515    } else {
516        "badge-warn"
517    }
518}
519
520/// Format a byte count as a human-readable string.
521#[allow(clippy::cast_precision_loss)]
522fn fmt_bytes(bytes: u64) -> String {
523    const KIB: u64 = 1024;
524    const MIB: u64 = 1024 * KIB;
525    const GIB: u64 = 1024 * MIB;
526    if bytes >= GIB {
527        format!("{:.1} GiB", bytes as f64 / GIB as f64)
528    } else if bytes >= MIB {
529        format!("{:.1} MiB", bytes as f64 / MIB as f64)
530    } else if bytes >= KIB {
531        format!("{:.1} KiB", bytes as f64 / KIB as f64)
532    } else {
533        format!("{bytes} B")
534    }
535}
536
537/// Escape HTML special characters to prevent injection in the HTML report.
538fn html_escape(s: &str) -> String {
539    s.replace('&', "&amp;")
540        .replace('<', "&lt;")
541        .replace('>', "&gt;")
542        .replace('"', "&quot;")
543}
544
545/// Tool metadata embedded in every report.
546#[derive(Debug, Clone, Serialize)]
547#[non_exhaustive]
548pub struct ReportMetadata {
549    /// Crate / binary version (from `Cargo.toml`).
550    pub version: String,
551    /// ISO-8601 timestamp when the run started.
552    pub timestamp: String,
553    /// Whether `--deterministic` was used.
554    pub deterministic: bool,
555    /// Whether `--dry-run` was used.
556    pub dry_run: bool,
557    /// Whether `--strict` was used.
558    pub strict: bool,
559    /// Chunk size in bytes (`--chunk-size`).
560    pub chunk_size: usize,
561    /// Thread count (`--threads`), if specified.
562    pub threads: Option<usize>,
563    /// Path to the secrets file, if provided.
564    pub secrets_file: Option<String>,
565}
566
567impl ReportMetadata {
568    /// Create metadata with the given version and timestamp; boolean flags
569    /// default to `false`, sizes to `0`, optional fields to `None`. The struct
570    /// is `#[non_exhaustive]`, so this is how it is built outside the crate.
571    #[must_use]
572    pub fn new(version: impl Into<String>, timestamp: impl Into<String>) -> Self {
573        Self {
574            version: version.into(),
575            timestamp: timestamp.into(),
576            deterministic: false,
577            dry_run: false,
578            strict: false,
579            chunk_size: 0,
580            threads: None,
581            secrets_file: None,
582        }
583    }
584
585    /// Set the `--deterministic` flag (builder style).
586    #[must_use]
587    pub fn with_deterministic(mut self, v: bool) -> Self {
588        self.deterministic = v;
589        self
590    }
591
592    /// Set the `--dry-run` flag (builder style).
593    #[must_use]
594    pub fn with_dry_run(mut self, v: bool) -> Self {
595        self.dry_run = v;
596        self
597    }
598
599    /// Set the `--strict` flag (builder style).
600    #[must_use]
601    pub fn with_strict(mut self, v: bool) -> Self {
602        self.strict = v;
603        self
604    }
605
606    /// Set the chunk size in bytes (builder style).
607    #[must_use]
608    pub fn with_chunk_size(mut self, v: usize) -> Self {
609        self.chunk_size = v;
610        self
611    }
612
613    /// Set the thread count (builder style).
614    #[must_use]
615    pub fn with_threads(mut self, v: Option<usize>) -> Self {
616        self.threads = v;
617        self
618    }
619
620    /// Set the secrets file path (builder style).
621    #[must_use]
622    pub fn with_secrets_file(mut self, v: Option<String>) -> Self {
623        self.secrets_file = v;
624        self
625    }
626}
627
628/// Aggregated summary across all processed files.
629#[derive(Debug, Clone, Serialize)]
630#[non_exhaustive]
631pub struct ReportSummary {
632    /// Number of files processed.
633    pub total_files: u64,
634    /// Total pattern matches found.
635    pub total_matches: u64,
636    /// Total replacements applied.
637    pub total_replacements: u64,
638    /// Total bytes read from input(s).
639    pub total_bytes_processed: u64,
640    /// Total bytes written to output(s).
641    pub total_bytes_output: u64,
642    /// Wall-clock duration of processing in milliseconds.
643    pub duration_ms: u64,
644    /// Aggregate per-pattern match counts.
645    pub pattern_counts: HashMap<String, u64>,
646}
647
648/// Per-match line-number results for a file, populated when
649/// `--max-match-locations` is non-zero and the scanner path is used.
650#[derive(Debug, Clone, Serialize)]
651#[non_exhaustive]
652pub struct MatchLocationsResult {
653    /// Individual match locations in document order.
654    pub locations: Vec<MatchLocation>,
655    /// `true` when the cap was hit and additional matches exist beyond
656    /// what is listed in `locations`.
657    pub truncated: bool,
658}
659
660/// Per-file result details.
661///
662/// Does **not** contain any original secret values — only counts,
663/// byte sizes, pattern labels, and the processing method used.
664#[derive(Debug, Clone, Serialize)]
665#[non_exhaustive]
666pub struct FileReport {
667    /// File path (relative or archive entry name).
668    pub path: String,
669    /// Number of matches found in this file.
670    pub matches: u64,
671    /// Number of replacements applied.
672    pub replacements: u64,
673    /// Bytes read from this file.
674    pub bytes_processed: u64,
675    /// Bytes written for this file.
676    pub bytes_output: u64,
677    /// Per-pattern match counts for this file.
678    pub pattern_counts: HashMap<String, u64>,
679    /// Processing method: `"scanner"`, `"structured:json"`, etc.
680    pub method: String,
681    /// Log context extraction results for this file, present when
682    /// `--extract-context` was used.
683    #[serde(skip_serializing_if = "Option::is_none")]
684    pub log_context: Option<LogContextResult>,
685    /// Per-match line numbers and byte offsets, present when
686    /// `--max-match-locations` is non-zero and the scanner path is used.
687    /// Structured-processor paths do not populate this field.
688    #[serde(skip_serializing_if = "Option::is_none")]
689    pub match_locations: Option<MatchLocationsResult>,
690}
691
692impl FileReport {
693    /// Create an empty report (all counters zero) for `path` with the given
694    /// processing `method`. Use when a file was routed but produced no scan
695    /// stats. The struct is `#[non_exhaustive]`, so this or
696    /// [`from_scan_stats`](Self::from_scan_stats) is how it is built outside
697    /// the crate.
698    #[must_use]
699    pub fn new(path: impl Into<String>, method: impl Into<String>) -> Self {
700        Self {
701            path: path.into(),
702            matches: 0,
703            replacements: 0,
704            bytes_processed: 0,
705            bytes_output: 0,
706            pattern_counts: HashMap::new(),
707            method: method.into(),
708            log_context: None,
709            match_locations: None,
710        }
711    }
712
713    /// Build a `FileReport` from scanner [`ScanStats`].
714    #[must_use]
715    pub fn from_scan_stats(
716        path: impl Into<String>,
717        stats: &ScanStats,
718        method: impl Into<String>,
719    ) -> Self {
720        Self {
721            path: path.into(),
722            matches: stats.matches_found,
723            replacements: stats.replacements_applied,
724            bytes_processed: stats.bytes_processed,
725            bytes_output: stats.bytes_output,
726            pattern_counts: stats.pattern_counts.clone(),
727            method: method.into(),
728            log_context: None,
729            match_locations: None,
730        }
731    }
732
733    /// Attach per-match location data collected via
734    /// [`crate::scanner::StreamScanner::scan_reader_with_callbacks`].
735    ///
736    /// No-ops when `locations` is empty and `truncated` is false, keeping
737    /// the JSON output clean for files with no scanner matches.
738    #[must_use]
739    pub fn with_match_locations(mut self, locations: Vec<MatchLocation>, truncated: bool) -> Self {
740        if !locations.is_empty() || truncated {
741            self.match_locations = Some(MatchLocationsResult {
742                locations,
743                truncated,
744            });
745        }
746        self
747    }
748}
749
750// ---------------------------------------------------------------------------
751// Thread-safe report builder
752// ---------------------------------------------------------------------------
753
754/// Thread-safe builder that accumulates per-file results and produces
755/// a final [`SanitizeReport`].
756///
757/// Designed for concurrent use: wrap in `Arc` and share across threads.
758/// The internal `Mutex` is held only for the duration of a single
759/// `Vec::push`, so contention is negligible even at high thread counts.
760#[derive(Debug)]
761pub struct ReportBuilder {
762    metadata: ReportMetadata,
763    files: Mutex<Vec<FileReport>>,
764    start: Instant,
765}
766
767// All fields are Send + Sync natively (Mutex<Vec<_>>, Instant, owned structs),
768// so ReportBuilder auto-derives Send + Sync without unsafe.
769const _: fn() = || {
770    fn assert_send<T: Send>() {}
771    fn assert_sync<T: Sync>() {}
772    assert_send::<ReportBuilder>();
773    assert_sync::<ReportBuilder>();
774};
775
776impl ReportBuilder {
777    /// Create a new builder with the given metadata.
778    ///
779    /// The wall-clock timer starts now.
780    #[must_use]
781    pub fn new(metadata: ReportMetadata) -> Self {
782        Self {
783            metadata,
784            files: Mutex::new(Vec::new()),
785            start: Instant::now(),
786        }
787    }
788
789    /// Attach log context extraction results to the [`FileReport`] identified
790    /// by `path`. The file must already have been recorded via
791    /// [`Self::record_file`]. Thread-safe.
792    pub fn set_file_log_context(&self, path: &str, result: LogContextResult) {
793        let mut files = self.files.lock().expect("report mutex poisoned");
794        if let Some(file) = files.iter_mut().find(|f| f.path == path) {
795            file.log_context = Some(result);
796        }
797    }
798
799    /// Record the result for a single file. Thread-safe.
800    pub fn record_file(&self, file_report: FileReport) {
801        let mut files = self.files.lock().expect("report mutex poisoned");
802        files.push(file_report);
803    }
804
805    /// Record multiple file results at once (e.g., from archive processing).
806    pub fn record_files(&self, reports: impl IntoIterator<Item = FileReport>) {
807        let mut files = self.files.lock().expect("report mutex poisoned");
808        files.extend(reports);
809    }
810
811    /// Consume the builder and produce the final report.
812    ///
813    /// The duration is measured from builder creation to this call.
814    pub fn finish(self) -> SanitizeReport {
815        #[allow(clippy::cast_possible_truncation)] // duration in ms won't exceed u64
816        let duration_ms = self.start.elapsed().as_millis() as u64;
817        let files = self.files.into_inner().expect("report mutex poisoned");
818
819        // Aggregate summary.
820        let mut total_matches: u64 = 0;
821        let mut total_replacements: u64 = 0;
822        let mut total_bytes_processed: u64 = 0;
823        let mut total_bytes_output: u64 = 0;
824        let mut pattern_counts: HashMap<String, u64> = HashMap::new();
825
826        for f in &files {
827            total_matches += f.matches;
828            total_replacements += f.replacements;
829            total_bytes_processed += f.bytes_processed;
830            total_bytes_output += f.bytes_output;
831            for (pat, count) in &f.pattern_counts {
832                *pattern_counts.entry(pat.clone()).or_insert(0) += count;
833            }
834        }
835
836        let summary = ReportSummary {
837            total_files: files.len() as u64,
838            total_matches,
839            total_replacements,
840            total_bytes_processed,
841            total_bytes_output,
842            duration_ms,
843            pattern_counts,
844        };
845
846        SanitizeReport {
847            metadata: self.metadata,
848            summary,
849            files,
850        }
851    }
852}
853
854// ---------------------------------------------------------------------------
855// Unit tests
856// ---------------------------------------------------------------------------
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861
862    fn sample_metadata() -> ReportMetadata {
863        ReportMetadata {
864            version: "0.2.0".into(),
865            timestamp: "2026-03-01T00:00:00Z".into(),
866            deterministic: false,
867            dry_run: false,
868            strict: false,
869            chunk_size: 1_048_576,
870            threads: None,
871            secrets_file: None,
872        }
873    }
874
875    fn sample_file_report(path: &str, matches: u64, pattern: &str) -> FileReport {
876        FileReport {
877            path: path.into(),
878            matches,
879            replacements: matches,
880            bytes_processed: matches * 100,
881            bytes_output: matches * 110,
882            pattern_counts: HashMap::from([(pattern.into(), matches)]),
883            method: "scanner".into(),
884            log_context: None,
885            match_locations: None,
886        }
887    }
888
889    // ---- Basic construction ----
890
891    #[test]
892    fn empty_report() {
893        let builder = ReportBuilder::new(sample_metadata());
894        let report = builder.finish();
895        assert_eq!(report.summary.total_files, 0);
896        assert_eq!(report.summary.total_matches, 0);
897        assert!(report.files.is_empty());
898    }
899
900    #[test]
901    fn single_file_report() {
902        let builder = ReportBuilder::new(sample_metadata());
903        builder.record_file(sample_file_report("data.log", 10, "email"));
904        let report = builder.finish();
905
906        assert_eq!(report.summary.total_files, 1);
907        assert_eq!(report.summary.total_matches, 10);
908        assert_eq!(report.summary.total_replacements, 10);
909        assert_eq!(report.summary.total_bytes_processed, 1000);
910        assert_eq!(report.summary.total_bytes_output, 1100);
911        assert_eq!(*report.summary.pattern_counts.get("email").unwrap(), 10);
912        assert_eq!(report.files[0].path, "data.log");
913    }
914
915    #[test]
916    fn multiple_files_aggregated() {
917        let builder = ReportBuilder::new(sample_metadata());
918        builder.record_file(sample_file_report("a.log", 5, "email"));
919        builder.record_file(sample_file_report("b.log", 3, "ipv4"));
920        builder.record_file(sample_file_report("c.log", 7, "email"));
921        let report = builder.finish();
922
923        assert_eq!(report.summary.total_files, 3);
924        assert_eq!(report.summary.total_matches, 15);
925        assert_eq!(*report.summary.pattern_counts.get("email").unwrap(), 12);
926        assert_eq!(*report.summary.pattern_counts.get("ipv4").unwrap(), 3);
927    }
928
929    // ---- JSON serialization ----
930
931    #[test]
932    fn json_serialization_no_secrets() {
933        let builder = ReportBuilder::new(sample_metadata());
934        builder.record_file(FileReport {
935            path: "config.yaml".into(),
936            matches: 2,
937            replacements: 2,
938            bytes_processed: 500,
939            bytes_output: 520,
940            pattern_counts: HashMap::from([("hostname".into(), 2)]),
941            method: "structured:yaml".into(),
942            log_context: None,
943            match_locations: None,
944        });
945        let report = builder.finish();
946        let json = report.to_json_pretty().unwrap();
947
948        // Must contain expected fields.
949        assert!(json.contains("\"total_matches\": 2"));
950        assert!(json.contains("\"version\": \"0.2.0\""));
951        assert!(json.contains("\"hostname\": 2"));
952        assert!(json.contains("\"method\": \"structured:yaml\""));
953        assert!(json.contains("\"duration_ms\""));
954
955        // Must NOT contain any original secret values — we only ever
956        // store counts and labels, never pattern text or matched text.
957        // This is a structural guarantee; verify that deserializing
958        // back produces the same data without secret leakage.
959        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
960        assert_eq!(parsed["files"][0]["path"].as_str(), Some("config.yaml"));
961        // No field named "secret", "original", or "value" at any level.
962        let flat = json.to_lowercase();
963        assert!(!flat.contains("\"original\""));
964        assert!(!flat.contains("\"secret_value\""));
965    }
966
967    #[test]
968    fn compact_json() {
969        let builder = ReportBuilder::new(sample_metadata());
970        let report = builder.finish();
971        let json = report.to_json().unwrap();
972        // Compact JSON has no pretty indentation.
973        assert!(!json.contains("  "));
974    }
975
976    // ---- Metadata flags ----
977
978    #[test]
979    fn metadata_flags_preserved() {
980        let meta = ReportMetadata {
981            version: "0.8.0".into(),
982            timestamp: "2026-06-15T12:00:00Z".into(),
983            deterministic: true,
984            dry_run: true,
985            strict: true,
986            chunk_size: 262_144,
987            threads: Some(8),
988            secrets_file: Some("secrets.enc".into()),
989        };
990        let builder = ReportBuilder::new(meta);
991        let report = builder.finish();
992        assert!(report.metadata.deterministic);
993        assert!(report.metadata.dry_run);
994        assert!(report.metadata.strict);
995        assert_eq!(report.metadata.chunk_size, 262_144);
996        assert_eq!(report.metadata.threads, Some(8));
997        assert_eq!(report.metadata.secrets_file.as_deref(), Some("secrets.enc"));
998    }
999
1000    // ---- Duration tracking ----
1001
1002    #[test]
1003    fn duration_is_positive() {
1004        let builder = ReportBuilder::new(sample_metadata());
1005        // Do a tiny amount of work.
1006        builder.record_file(sample_file_report("x.txt", 1, "email"));
1007        let report = builder.finish();
1008        // Duration should be ≥ 0 (it will be 0 or 1 on fast machines).
1009        assert!(report.summary.duration_ms < 5_000); // sanity ceiling
1010    }
1011
1012    // ---- Thread-safe concurrent recording ----
1013
1014    #[test]
1015    fn concurrent_recording() {
1016        use std::sync::Arc;
1017        use std::thread;
1018
1019        let builder = Arc::new(ReportBuilder::new(sample_metadata()));
1020        let mut handles = Vec::new();
1021
1022        for i in 0_u64..16 {
1023            let b = Arc::clone(&builder);
1024            handles.push(thread::spawn(move || {
1025                b.record_file(sample_file_report(&format!("file_{i}.log"), i + 1, "email"));
1026            }));
1027        }
1028
1029        for h in handles {
1030            h.join().unwrap();
1031        }
1032
1033        // We need to unwrap the Arc to call finish().
1034        let builder = Arc::try_unwrap(builder).expect("other refs still held");
1035        let report = builder.finish();
1036
1037        assert_eq!(report.summary.total_files, 16);
1038        // Sum of 1..=16 = 136.
1039        assert_eq!(report.summary.total_matches, 136);
1040    }
1041
1042    // ---- FileReport::from_scan_stats ----
1043
1044    #[test]
1045    fn file_report_from_scan_stats() {
1046        let stats = ScanStats {
1047            bytes_processed: 2048,
1048            bytes_output: 2100,
1049            matches_found: 5,
1050            replacements_applied: 5,
1051            pattern_counts: HashMap::from([("email".into(), 3), ("ipv4".into(), 2)]),
1052        };
1053        let fr = FileReport::from_scan_stats("test.log", &stats, "scanner");
1054        assert_eq!(fr.path, "test.log");
1055        assert_eq!(fr.matches, 5);
1056        assert_eq!(fr.bytes_processed, 2048);
1057        assert_eq!(*fr.pattern_counts.get("email").unwrap(), 3);
1058        assert_eq!(fr.method, "scanner");
1059    }
1060
1061    // ---- Large-file simulation ----
1062
1063    #[test]
1064    fn large_file_report() {
1065        let builder = ReportBuilder::new(sample_metadata());
1066        // Simulate a 10 GB file processed in chunks.
1067        builder.record_file(FileReport {
1068            path: "huge.log".into(),
1069            matches: 1_000_000,
1070            replacements: 1_000_000,
1071            bytes_processed: 10_737_418_240, // 10 GiB
1072            bytes_output: 10_900_000_000,
1073            pattern_counts: HashMap::from([("email".into(), 600_000), ("ipv4".into(), 400_000)]),
1074            method: "scanner".into(),
1075            log_context: None,
1076            match_locations: None,
1077        });
1078        let report = builder.finish();
1079        assert_eq!(report.summary.total_matches, 1_000_000);
1080        assert_eq!(report.summary.total_bytes_processed, 10_737_418_240);
1081
1082        // JSON serialization still works for large numbers.
1083        let json = report.to_json().unwrap();
1084        assert!(json.contains("10737418240"));
1085    }
1086
1087    // ---- record_files bulk insert ----
1088
1089    #[test]
1090    fn record_files_bulk() {
1091        let builder = ReportBuilder::new(sample_metadata());
1092        let files: Vec<FileReport> = (0..5)
1093            .map(|i| sample_file_report(&format!("entry_{i}.txt"), 2, "ssn"))
1094            .collect();
1095        builder.record_files(files);
1096        let report = builder.finish();
1097        assert_eq!(report.summary.total_files, 5);
1098        assert_eq!(report.summary.total_matches, 10);
1099    }
1100
1101    // ---- SARIF output ----
1102
1103    fn rich_report() -> SanitizeReport {
1104        let builder = ReportBuilder::new(sample_metadata());
1105        builder.record_file(FileReport {
1106            path: "config.yaml".into(),
1107            matches: 3,
1108            replacements: 3,
1109            bytes_processed: 1024,
1110            bytes_output: 1100,
1111            pattern_counts: HashMap::from([("auth_token".into(), 2u64), ("email".into(), 1u64)]),
1112            method: "structured:yaml".into(),
1113            log_context: None,
1114            match_locations: None,
1115        });
1116        builder.record_file(FileReport {
1117            path: "logs/app.log".into(),
1118            matches: 0,
1119            replacements: 0,
1120            bytes_processed: 512,
1121            bytes_output: 512,
1122            pattern_counts: HashMap::new(),
1123            method: "scanner".into(),
1124            log_context: None,
1125            match_locations: None,
1126        });
1127        builder.finish()
1128    }
1129
1130    #[test]
1131    fn sarif_is_valid_json() {
1132        let sarif = rich_report().to_sarif().unwrap();
1133        let v: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1134        assert_eq!(v["version"], "2.1.0");
1135        assert_eq!(
1136            v["$schema"],
1137            "https://json.schemastore.org/sarif-2.1.0.json"
1138        );
1139    }
1140
1141    #[test]
1142    fn sarif_contains_one_run() {
1143        let v: serde_json::Value =
1144            serde_json::from_str(&rich_report().to_sarif().unwrap()).unwrap();
1145        assert_eq!(v["runs"].as_array().unwrap().len(), 1);
1146    }
1147
1148    #[test]
1149    fn sarif_driver_name_and_version() {
1150        let v: serde_json::Value =
1151            serde_json::from_str(&rich_report().to_sarif().unwrap()).unwrap();
1152        let driver = &v["runs"][0]["tool"]["driver"];
1153        assert_eq!(driver["name"], "scour-secrets");
1154        assert_eq!(driver["version"], "0.2.0");
1155    }
1156
1157    #[test]
1158    fn sarif_rules_one_per_pattern() {
1159        let v: serde_json::Value =
1160            serde_json::from_str(&rich_report().to_sarif().unwrap()).unwrap();
1161        let rules = v["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
1162        // Two patterns: auth_token, email.
1163        assert_eq!(rules.len(), 2);
1164        let ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1165        assert!(ids.contains(&"auth_token"));
1166        assert!(ids.contains(&"email"));
1167    }
1168
1169    #[test]
1170    fn sarif_results_only_for_nonzero_counts() {
1171        let v: serde_json::Value =
1172            serde_json::from_str(&rich_report().to_sarif().unwrap()).unwrap();
1173        let results = v["runs"][0]["results"].as_array().unwrap();
1174        // logs/app.log has 0 matches → 0 results for it; config.yaml has 2 patterns.
1175        assert_eq!(results.len(), 2);
1176    }
1177
1178    #[test]
1179    fn sarif_result_level_pii_is_error() {
1180        let v: serde_json::Value =
1181            serde_json::from_str(&rich_report().to_sarif().unwrap()).unwrap();
1182        let results = v["runs"][0]["results"].as_array().unwrap();
1183        let email_result = results
1184            .iter()
1185            .find(|r| r["ruleId"] == "email")
1186            .expect("email result missing");
1187        assert_eq!(email_result["level"], "error");
1188    }
1189
1190    #[test]
1191    fn sarif_result_has_file_uri() {
1192        let v: serde_json::Value =
1193            serde_json::from_str(&rich_report().to_sarif().unwrap()).unwrap();
1194        let results = v["runs"][0]["results"].as_array().unwrap();
1195        for result in results {
1196            let uri = result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
1197                .as_str()
1198                .unwrap();
1199            assert_eq!(uri, "config.yaml");
1200        }
1201    }
1202
1203    #[test]
1204    fn sarif_artifacts_all_files() {
1205        let v: serde_json::Value =
1206            serde_json::from_str(&rich_report().to_sarif().unwrap()).unwrap();
1207        let artifacts = v["runs"][0]["artifacts"].as_array().unwrap();
1208        assert_eq!(artifacts.len(), 2);
1209        let uris: Vec<&str> = artifacts
1210            .iter()
1211            .map(|a| a["location"]["uri"].as_str().unwrap())
1212            .collect();
1213        assert!(uris.contains(&"config.yaml"));
1214        assert!(uris.contains(&"logs/app.log"));
1215    }
1216
1217    #[test]
1218    fn sarif_windows_paths_use_forward_slash() {
1219        let builder = ReportBuilder::new(sample_metadata());
1220        builder.record_file(FileReport {
1221            path: r"src\secrets\config.json".into(),
1222            matches: 1,
1223            replacements: 1,
1224            bytes_processed: 100,
1225            bytes_output: 110,
1226            pattern_counts: HashMap::from([("auth_token".into(), 1u64)]),
1227            method: "structured:json".into(),
1228            log_context: None,
1229            match_locations: None,
1230        });
1231        let report = builder.finish();
1232        let v: serde_json::Value = serde_json::from_str(&report.to_sarif().unwrap()).unwrap();
1233        let uri = v["runs"][0]["results"][0]["locations"][0]["physicalLocation"]
1234            ["artifactLocation"]["uri"]
1235            .as_str()
1236            .unwrap();
1237        assert_eq!(uri, "src/secrets/config.json");
1238    }
1239
1240    // ---- HTML output ----
1241
1242    #[test]
1243    fn html_is_valid_document() {
1244        let html = rich_report().to_html();
1245        assert!(html.starts_with("<!DOCTYPE html>"));
1246        assert!(html.contains("</html>"));
1247        assert!(html.contains("<title>scour-secrets report</title>"));
1248    }
1249
1250    #[test]
1251    fn html_contains_summary_stats() {
1252        let html = rich_report().to_html();
1253        // 1 file with matches + 1 clean file = 2 files total.
1254        assert!(html.contains(">2<"), "file count missing");
1255        // 3 total matches.
1256        assert!(html.contains(">3<"), "match count missing");
1257    }
1258
1259    #[test]
1260    fn html_contains_file_paths() {
1261        let html = rich_report().to_html();
1262        assert!(html.contains("config.yaml"));
1263        assert!(html.contains("logs/app.log"));
1264    }
1265
1266    #[test]
1267    fn html_escapes_special_chars() {
1268        let builder = ReportBuilder::new(sample_metadata());
1269        builder.record_file(FileReport {
1270            path: "<script>alert(1)</script>".into(),
1271            matches: 0,
1272            replacements: 0,
1273            bytes_processed: 0,
1274            bytes_output: 0,
1275            pattern_counts: HashMap::new(),
1276            method: "scanner".into(),
1277            log_context: None,
1278            match_locations: None,
1279        });
1280        let html = builder.finish().to_html();
1281        assert!(!html.contains("<script>alert(1)</script>"));
1282        assert!(html.contains("&lt;script&gt;"));
1283    }
1284
1285    #[test]
1286    fn html_no_external_resources() {
1287        let html = rich_report().to_html();
1288        // No CDN links, no external stylesheets, no external scripts.
1289        assert!(!html.contains("http://") || !html.contains("https://json.schemastore.org"));
1290        assert!(!html.contains("cdn."));
1291        assert!(!html.contains("src=\"http"));
1292        assert!(!html.contains("href=\"http"));
1293    }
1294
1295    // ---- helpers ----
1296
1297    #[test]
1298    fn sarif_rule_name_camel_case() {
1299        assert_eq!(sarif_rule_name("auth_token"), "AuthToken");
1300        assert_eq!(sarif_rule_name("email"), "Email");
1301        assert_eq!(sarif_rule_name("custom:password"), "CustomPassword");
1302        assert_eq!(sarif_rule_name("aws_arn"), "AwsArn");
1303    }
1304
1305    #[test]
1306    fn fmt_bytes_human_readable() {
1307        assert_eq!(fmt_bytes(512), "512 B");
1308        assert_eq!(fmt_bytes(1024), "1.0 KiB");
1309        assert_eq!(fmt_bytes(1536), "1.5 KiB");
1310        assert_eq!(fmt_bytes(1024 * 1024), "1.0 MiB");
1311        assert_eq!(fmt_bytes(1024 * 1024 * 1024), "1.0 GiB");
1312    }
1313
1314    #[test]
1315    fn html_escape_special_chars() {
1316        assert_eq!(html_escape("a&b"), "a&amp;b");
1317        assert_eq!(html_escape("<tag>"), "&lt;tag&gt;");
1318        assert_eq!(html_escape("\"quote\""), "&quot;quote&quot;");
1319        assert_eq!(html_escape("normal"), "normal");
1320    }
1321}