1use 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#[derive(Debug, Clone, Serialize)]
72#[non_exhaustive]
73pub struct SanitizeReport {
74 pub metadata: ReportMetadata,
76 pub summary: ReportSummary,
78 pub files: Vec<FileReport>,
81}
82
83impl SanitizeReport {
84 pub fn to_json(&self) -> serde_json::Result<String> {
90 serde_json::to_string(self)
91 }
92
93 pub fn to_json_pretty(&self) -> serde_json::Result<String> {
99 serde_json::to_string_pretty(self)
100 }
101
102 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 #[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} · {timestamp} · {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
240fn 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
298fn 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 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
360fn 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
382fn 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
411fn 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
470fn is_pii_category(pattern: &str) -> bool {
475 matches!(
476 pattern,
477 "email" | "name" | "phone" | "credit_card" | "ssn" | "auth_token" | "jwt"
478 )
479}
480
481fn sarif_level(pattern: &str) -> &'static str {
484 if is_pii_category(pattern) {
485 "error"
486 } else {
487 "warning"
488 }
489}
490
491fn 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
506fn path_to_sarif_uri(path: &str) -> String {
508 path.replace('\\', "/")
509}
510
511fn 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#[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
537fn html_escape(s: &str) -> String {
539 s.replace('&', "&")
540 .replace('<', "<")
541 .replace('>', ">")
542 .replace('"', """)
543}
544
545#[derive(Debug, Clone, Serialize)]
547#[non_exhaustive]
548pub struct ReportMetadata {
549 pub version: String,
551 pub timestamp: String,
553 pub deterministic: bool,
555 pub dry_run: bool,
557 pub strict: bool,
559 pub chunk_size: usize,
561 pub threads: Option<usize>,
563 pub secrets_file: Option<String>,
565}
566
567impl ReportMetadata {
568 #[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 #[must_use]
587 pub fn with_deterministic(mut self, v: bool) -> Self {
588 self.deterministic = v;
589 self
590 }
591
592 #[must_use]
594 pub fn with_dry_run(mut self, v: bool) -> Self {
595 self.dry_run = v;
596 self
597 }
598
599 #[must_use]
601 pub fn with_strict(mut self, v: bool) -> Self {
602 self.strict = v;
603 self
604 }
605
606 #[must_use]
608 pub fn with_chunk_size(mut self, v: usize) -> Self {
609 self.chunk_size = v;
610 self
611 }
612
613 #[must_use]
615 pub fn with_threads(mut self, v: Option<usize>) -> Self {
616 self.threads = v;
617 self
618 }
619
620 #[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#[derive(Debug, Clone, Serialize)]
630#[non_exhaustive]
631pub struct ReportSummary {
632 pub total_files: u64,
634 pub total_matches: u64,
636 pub total_replacements: u64,
638 pub total_bytes_processed: u64,
640 pub total_bytes_output: u64,
642 pub duration_ms: u64,
644 pub pattern_counts: HashMap<String, u64>,
646}
647
648#[derive(Debug, Clone, Serialize)]
651#[non_exhaustive]
652pub struct MatchLocationsResult {
653 pub locations: Vec<MatchLocation>,
655 pub truncated: bool,
658}
659
660#[derive(Debug, Clone, Serialize)]
665#[non_exhaustive]
666pub struct FileReport {
667 pub path: String,
669 pub matches: u64,
671 pub replacements: u64,
673 pub bytes_processed: u64,
675 pub bytes_output: u64,
677 pub pattern_counts: HashMap<String, u64>,
679 pub method: String,
681 #[serde(skip_serializing_if = "Option::is_none")]
684 pub log_context: Option<LogContextResult>,
685 #[serde(skip_serializing_if = "Option::is_none")]
689 pub match_locations: Option<MatchLocationsResult>,
690}
691
692impl FileReport {
693 #[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 #[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 #[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#[derive(Debug)]
761pub struct ReportBuilder {
762 metadata: ReportMetadata,
763 files: Mutex<Vec<FileReport>>,
764 start: Instant,
765}
766
767const _: 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 #[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 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 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 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 pub fn finish(self) -> SanitizeReport {
815 #[allow(clippy::cast_possible_truncation)] let duration_ms = self.start.elapsed().as_millis() as u64;
817 let files = self.files.into_inner().expect("report mutex poisoned");
818
819 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#[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 #[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 #[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 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 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
960 assert_eq!(parsed["files"][0]["path"].as_str(), Some("config.yaml"));
961 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 assert!(!json.contains(" "));
974 }
975
976 #[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 #[test]
1003 fn duration_is_positive() {
1004 let builder = ReportBuilder::new(sample_metadata());
1005 builder.record_file(sample_file_report("x.txt", 1, "email"));
1007 let report = builder.finish();
1008 assert!(report.summary.duration_ms < 5_000); }
1011
1012 #[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 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 assert_eq!(report.summary.total_matches, 136);
1040 }
1041
1042 #[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 #[test]
1064 fn large_file_report() {
1065 let builder = ReportBuilder::new(sample_metadata());
1066 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, 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 let json = report.to_json().unwrap();
1084 assert!(json.contains("10737418240"));
1085 }
1086
1087 #[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 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 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 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 #[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 assert!(html.contains(">2<"), "file count missing");
1255 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("<script>"));
1283 }
1284
1285 #[test]
1286 fn html_no_external_resources() {
1287 let html = rich_report().to_html();
1288 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 #[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&b");
1317 assert_eq!(html_escape("<tag>"), "<tag>");
1318 assert_eq!(html_escape("\"quote\""), ""quote"");
1319 assert_eq!(html_escape("normal"), "normal");
1320 }
1321}