1pub mod config;
17pub mod entropy;
18pub mod paths;
19
20use std::path::Path;
21
22use regex::Regex;
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25
26use crate::error::Result;
27
28pub use config::{BuiltinsMode, ReportConfig, ScrubConfig};
29pub use entropy::EntropyConfig;
30pub use paths::PathRules;
31
32#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(rename_all = "lowercase")]
34pub enum Severity {
35 Low,
36 Medium,
37 #[default]
38 High,
39 Critical,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Pattern {
44 pub id: String,
45 pub regex: String,
46 #[serde(default = "default_category")]
47 pub category: String,
48 #[serde(default)]
49 pub replacement: Option<String>,
50 #[serde(default)]
51 pub severity: Severity,
52}
53
54fn default_category() -> String {
55 "generic".into()
56}
57
58#[derive(Debug)]
59struct CompiledPattern {
60 id: String,
61 category: String,
62 regex: Regex,
63 replacement: String,
64 severity: Severity,
65}
66
67#[derive(Debug, Default)]
70pub(crate) struct AllowList {
71 pub(crate) exact: Vec<String>,
72 pub(crate) regex: Vec<Regex>,
73}
74
75impl AllowList {
76 pub(crate) fn is_allowed(&self, s: &str) -> bool {
77 self.exact.iter().any(|e| e == s) || self.regex.iter().any(|r| r.is_match(s))
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct Redaction {
84 pub rule_id: String,
85 pub category: String,
86 pub severity: Severity,
87 pub hash4: String,
88}
89
90#[derive(Debug, Default, Clone, Serialize, Deserialize)]
92pub struct ScrubReport {
93 pub redactions: Vec<Redaction>,
94}
95
96impl ScrubReport {
97 pub fn is_empty(&self) -> bool {
98 self.redactions.is_empty()
99 }
100
101 pub fn count_by_category(&self) -> std::collections::BTreeMap<String, usize> {
102 let mut out: std::collections::BTreeMap<String, usize> = Default::default();
103 for r in &self.redactions {
104 *out.entry(r.category.clone()).or_default() += 1;
105 }
106 out
107 }
108
109 pub fn has_severity_at_least(&self, min: Severity) -> bool {
110 let rank = |s: Severity| match s {
111 Severity::Low => 0,
112 Severity::Medium => 1,
113 Severity::High => 2,
114 Severity::Critical => 3,
115 };
116 self.redactions
117 .iter()
118 .any(|r| rank(r.severity) >= rank(min))
119 }
120
121 pub fn summary(&self) -> String {
123 if self.redactions.is_empty() {
124 return "0 redacted".into();
125 }
126 let groups = self.count_by_category();
127 let parts: Vec<String> = groups.iter().map(|(k, v)| format!("{k}:{v}")).collect();
128 format!("{} redacted ({})", self.redactions.len(), parts.join(", "))
129 }
130}
131
132#[derive(Debug, Default)]
133pub struct Scrubber {
134 patterns: Vec<CompiledPattern>,
135 allowlist: AllowList,
136 entropy: EntropyConfig,
137 paths: PathRules,
138 report: ReportConfig,
139 effective_builtin_count: usize,
143 effective_custom_count: usize,
145}
146
147impl Scrubber {
148 pub fn empty() -> Self {
149 Self::default()
150 }
151
152 pub fn with_builtins() -> Result<Self> {
154 let patterns = builtin_patterns()?;
155 let effective_builtin_count = patterns.len();
156 Ok(Self {
157 patterns,
158 allowlist: AllowList::default(),
159 entropy: EntropyConfig::default(),
160 paths: PathRules::default(),
161 report: ReportConfig::default(),
162 effective_builtin_count,
163 effective_custom_count: 0,
164 })
165 }
166
167 pub fn from_config(config: &ScrubConfig) -> Result<Self> {
169 let mut patterns: Vec<CompiledPattern> = match config.builtins {
170 BuiltinsMode::Replace => Vec::new(),
171 BuiltinsMode::Extend | BuiltinsMode::Disable => builtin_patterns()?,
172 };
173
174 if !config.disable_builtins.is_empty() {
176 let drop: std::collections::HashSet<&str> =
177 config.disable_builtins.iter().map(String::as_str).collect();
178 patterns.retain(|p| !drop.contains(p.id.as_str()));
179 }
180 let effective_builtin_count = patterns.len();
181
182 let effective_custom_count = config.patterns.len();
184 for p in &config.patterns {
185 patterns.push(compile_pattern(p)?);
186 }
187
188 let mut allowlist = AllowList::default();
189 for entry in &config.allowlist {
190 if let Some(ex) = &entry.exact {
191 allowlist.exact.push(ex.clone());
192 }
193 if let Some(rg) = &entry.regex {
194 allowlist.regex.push(Regex::new(rg)?);
195 }
196 }
197
198 Ok(Self {
199 patterns,
200 allowlist,
201 entropy: EntropyConfig::from_raw(&config.entropy)?,
202 paths: PathRules::from_raw(&config.paths)?,
203 report: config.report.clone(),
204 effective_builtin_count,
205 effective_custom_count,
206 })
207 }
208
209 pub fn with_workspace(workspace_root: &Path) -> Result<Self> {
213 let path = workspace_root.join(".cargo-context/scrub.yaml");
214 if !path.exists() {
215 return Self::with_builtins();
216 }
217 let raw = std::fs::read_to_string(&path)?;
218 match serde_yaml::from_str::<ScrubConfig>(&raw) {
219 Ok(cfg) => Self::from_config(&cfg),
220 Err(_) => Self::with_builtins(), }
222 }
223
224 pub fn extend(&mut self, extra: Vec<Pattern>) -> Result<()> {
227 for p in extra {
228 self.patterns.push(compile_pattern(&p)?);
229 }
230 Ok(())
231 }
232
233 pub fn scrub(&self, input: &str) -> String {
235 self.scrub_with_report(input).0
236 }
237
238 pub fn scrub_with_report(&self, input: &str) -> (String, ScrubReport) {
240 let mut out = input.to_string();
241 let mut report = ScrubReport::default();
242
243 for p in &self.patterns {
245 out = p
246 .regex
247 .replace_all(&out, |caps: ®ex::Captures<'_>| {
248 let matched = &caps[0];
249 if self.allowlist.is_allowed(matched) {
250 return matched.to_string();
251 }
252 let hash = hash4(matched);
253 report.redactions.push(Redaction {
254 rule_id: p.id.clone(),
255 category: p.category.clone(),
256 severity: p.severity,
257 hash4: hash.clone(),
258 });
259 render_replacement(&p.replacement, &p.category, &hash)
260 })
261 .into_owned();
262 }
263
264 if self.entropy.enabled {
266 let (new_out, entropy_redactions) = self.entropy.scan_and_redact(&out, &self.allowlist);
267 out = new_out;
268 report.redactions.extend(entropy_redactions);
269 }
270
271 (out, report)
272 }
273
274 pub fn scrub_file(&self, path: &Path, content: &str) -> (String, ScrubReport) {
277 if self.paths.is_excluded(path) {
278 return (content.to_string(), ScrubReport::default());
279 }
280 if self.paths.is_redact_whole(path) {
281 let mut report = ScrubReport::default();
282 report.redactions.push(Redaction {
283 rule_id: "path_redact_whole".into(),
284 category: "file".into(),
285 severity: Severity::High,
286 hash4: hash4(content),
287 });
288 return (format!("[REDACTED FILE: {}]", path.display()), report);
289 }
290 self.scrub_with_report(content)
291 }
292
293 pub fn is_path_redacted(&self, path: &Path) -> bool {
295 !self.paths.is_excluded(path) && self.paths.is_redact_whole(path)
296 }
297
298 pub fn is_path_excluded(&self, path: &Path) -> bool {
300 self.paths.is_excluded(path)
301 }
302
303 pub fn report_config(&self) -> &ReportConfig {
305 &self.report
306 }
307
308 pub fn effective_builtin_count(&self) -> usize {
311 self.effective_builtin_count
312 }
313
314 pub fn effective_custom_count(&self) -> usize {
316 self.effective_custom_count
317 }
318
319 pub fn log_redactions(&self, report: &ScrubReport) -> Result<()> {
326 let Some(path) = &self.report.log_file else {
327 return Ok(());
328 };
329 if report.redactions.is_empty() {
330 return Ok(());
331 }
332 append_log_lines(path, &report.redactions, self.report.max_entries)
333 }
334}
335
336fn append_log_lines(
337 path: &Path,
338 redactions: &[Redaction],
339 max_entries: Option<usize>,
340) -> Result<()> {
341 use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
342
343 if let Some(parent) = path.parent()
344 && !parent.as_os_str().is_empty()
345 {
346 std::fs::create_dir_all(parent)?;
347 }
348
349 let mut file = std::fs::OpenOptions::new()
350 .create(true)
351 .append(true)
352 .read(true)
353 .open(path)?;
354
355 let ts = std::time::SystemTime::now()
356 .duration_since(std::time::UNIX_EPOCH)
357 .map(|d| d.as_secs())
358 .unwrap_or(0);
359
360 for r in redactions {
361 let line = serde_json::to_string(&LogEntry {
362 ts,
363 rule_id: &r.rule_id,
364 category: &r.category,
365 severity: r.severity,
366 hash4: &r.hash4,
367 })?;
368 writeln!(file, "{line}")?;
369 }
370 file.flush()?;
371
372 if let Some(cap) = max_entries {
373 file.seek(SeekFrom::Start(0))?;
374 let reader = BufReader::new(&mut file);
375 let lines: Vec<String> = reader.lines().collect::<std::io::Result<_>>()?;
376 if lines.len() > cap {
377 let kept = &lines[lines.len() - cap..];
378 drop(file);
379 let mut new_file = std::fs::OpenOptions::new()
380 .write(true)
381 .truncate(true)
382 .open(path)?;
383 for line in kept {
384 writeln!(new_file, "{line}")?;
385 }
386 new_file.flush()?;
387 }
388 }
389
390 Ok(())
391}
392
393#[derive(Serialize)]
394struct LogEntry<'a> {
395 ts: u64,
396 rule_id: &'a str,
397 category: &'a str,
398 severity: Severity,
399 hash4: &'a str,
400}
401
402fn compile_pattern(p: &Pattern) -> Result<CompiledPattern> {
403 let replacement = p
404 .replacement
405 .clone()
406 .unwrap_or_else(|| format!("<REDACTED:{}:{{hash4}}>", p.category));
407 Ok(CompiledPattern {
408 id: p.id.clone(),
409 category: p.category.clone(),
410 regex: Regex::new(&p.regex)?,
411 replacement,
412 severity: p.severity,
413 })
414}
415
416fn builtin_patterns() -> Result<Vec<CompiledPattern>> {
417 let builtins: &[(&str, &str, &str, Severity)] = &[
418 (
419 "aws_access_key",
420 r"AKIA[0-9A-Z]{16}",
421 "aws_key",
422 Severity::Critical,
423 ),
424 (
425 "github_pat",
426 r"ghp_[A-Za-z0-9]{36,}",
427 "github",
428 Severity::Critical,
429 ),
430 (
431 "github_oauth",
432 r"gho_[A-Za-z0-9]{36,}",
433 "github",
434 Severity::Critical,
435 ),
436 (
437 "openai_key",
438 r"sk-[A-Za-z0-9]{20,}",
439 "api_key",
440 Severity::Critical,
441 ),
442 (
443 "anthropic_key",
444 r"sk-ant-[A-Za-z0-9\-_]{20,}",
445 "api_key",
446 Severity::Critical,
447 ),
448 (
449 "huggingface_token",
450 r"hf_[A-Za-z0-9]{30,}",
451 "api_key",
452 Severity::High,
453 ),
454 (
455 "google_api_key",
456 r"AIza[0-9A-Za-z\-_]{35}",
457 "api_key",
458 Severity::High,
459 ),
460 (
461 "slack_token",
462 r"xox[baprs]-[0-9A-Za-z\-]{10,}",
463 "slack",
464 Severity::High,
465 ),
466 (
467 "jwt",
468 r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+",
469 "jwt",
470 Severity::High,
471 ),
472 (
473 "private_key_pem",
474 r"-----BEGIN (RSA|EC|OPENSSH|PGP) PRIVATE KEY-----",
475 "pem",
476 Severity::Critical,
477 ),
478 ];
479 let mut patterns = Vec::with_capacity(builtins.len());
480 for (id, re, cat, sev) in builtins {
481 patterns.push(CompiledPattern {
482 id: (*id).into(),
483 category: (*cat).into(),
484 regex: Regex::new(re)?,
485 replacement: format!("<REDACTED:{cat}:{{hash4}}>"),
486 severity: *sev,
487 });
488 }
489 Ok(patterns)
490}
491
492fn render_replacement(template: &str, category: &str, hash4_val: &str) -> String {
493 template
494 .replace("{hash4}", hash4_val)
495 .replace("{category}", category)
496}
497
498pub(crate) fn hash4(s: &str) -> String {
499 let mut h = Sha256::new();
500 h.update(s.as_bytes());
501 let digest = h.finalize();
502 format!("{:02x}{:02x}", digest[0], digest[1])
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508
509 #[test]
510 fn redacts_aws_key() {
511 let s = Scrubber::with_builtins().unwrap();
512 let out = s.scrub("AWS_KEY=AKIAIOSFODNN7EXAMPLE trailing");
513 assert!(!out.contains("AKIAIOSFODNN7EXAMPLE"));
514 assert!(out.contains("<REDACTED:aws_key:"));
515 assert!(out.contains("trailing"));
516 }
517
518 #[test]
519 fn redacts_jwt() {
520 let s = Scrubber::with_builtins().unwrap();
521 let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSJ9.abcDEF-_xyz";
522 let out = s.scrub(jwt);
523 assert!(!out.contains(jwt));
524 assert!(out.contains("<REDACTED:jwt:"));
525 }
526
527 #[test]
528 fn leaves_benign_text_alone() {
529 let s = Scrubber::with_builtins().unwrap();
530 let out = s.scrub("just some code // no secrets here");
531 assert_eq!(out, "just some code // no secrets here");
532 }
533
534 #[test]
535 fn hash_is_stable() {
536 assert_eq!(hash4("hello"), hash4("hello"));
537 assert_ne!(hash4("hello"), hash4("world"));
538 }
539
540 #[test]
541 fn report_counts_by_category() {
542 let s = Scrubber::with_builtins().unwrap();
543 let (_, report) = s.scrub_with_report(
544 "AKIAIOSFODNN7EXAMPLE and another AKIAIOSFODNN7EXAMPLW and ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
545 );
546 let by_cat = report.count_by_category();
547 assert_eq!(by_cat.get("aws_key").copied(), Some(2));
548 assert_eq!(by_cat.get("github").copied(), Some(1));
549 }
550
551 #[test]
552 fn report_summary_is_human_readable() {
553 let s = Scrubber::with_builtins().unwrap();
554 let (_, report) = s.scrub_with_report("AKIAIOSFODNN7EXAMPLE");
555 assert!(report.summary().contains("redacted"));
556 assert!(report.summary().contains("aws_key"));
557 }
558
559 #[test]
560 fn allowlist_bypasses_regex() {
561 let config = ScrubConfig {
562 allowlist: vec![config::AllowlistEntry {
563 exact: Some("AKIAEXAMPLEALLOWED".into()),
564 regex: None,
565 }],
566 ..Default::default()
567 };
568 let s = Scrubber::from_config(&config).unwrap();
569 let out = s.scrub("AKIAEXAMPLEALLOWED");
570 assert_eq!(out, "AKIAEXAMPLEALLOWED");
572 }
573
574 #[test]
575 fn disable_builtins_drops_named_rule() {
576 let config = ScrubConfig {
577 disable_builtins: vec!["jwt".into()],
578 ..Default::default()
579 };
580 let s = Scrubber::from_config(&config).unwrap();
581 let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSJ9.abcDEF-_xyz";
582 let out = s.scrub(jwt);
583 assert_eq!(out, jwt, "jwt rule should have been disabled");
584 }
585
586 #[test]
587 fn builtins_replace_drops_all() {
588 let config = ScrubConfig {
589 builtins: BuiltinsMode::Replace,
590 ..Default::default()
591 };
592 let s = Scrubber::from_config(&config).unwrap();
593 assert_eq!(s.scrub("AKIAIOSFODNN7EXAMPLE"), "AKIAIOSFODNN7EXAMPLE");
594 }
595
596 #[test]
597 fn log_redactions_writes_values_free_jsonl() {
598 let tmp = tempfile::tempdir().unwrap();
599 let log_path = tmp.path().join("scrub.log");
600 let config = ScrubConfig {
601 report: ReportConfig {
602 log_file: Some(log_path.clone()),
603 ..Default::default()
604 },
605 ..Default::default()
606 };
607 let scrubber = Scrubber::from_config(&config).unwrap();
608 let (_, report) = scrubber.scrub_with_report(
609 "AWS_KEY=AKIAIOSFODNN7EXAMPLE and jwt: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSJ9.abcDEF-_xyz",
610 );
611 assert!(!report.is_empty());
612
613 scrubber.log_redactions(&report).unwrap();
614 let contents = std::fs::read_to_string(&log_path).unwrap();
615 let lines: Vec<&str> = contents.lines().collect();
616 assert_eq!(lines.len(), report.redactions.len());
617 for (line, r) in lines.iter().zip(&report.redactions) {
619 let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
620 assert_eq!(parsed["rule_id"], r.rule_id);
621 assert_eq!(parsed["category"], r.category);
622 assert_eq!(parsed["hash4"], r.hash4);
623 assert!(parsed["ts"].as_u64().unwrap() > 0);
624 assert!(!line.contains("AKIAIOSFODNN7EXAMPLE"));
626 assert!(!line.contains("eyJhbGciOi"));
627 }
628 }
629
630 #[test]
631 fn log_redactions_noop_when_unconfigured() {
632 let scrubber = Scrubber::with_builtins().unwrap();
633 let (_, report) = scrubber.scrub_with_report("AWS_KEY=AKIAIOSFODNN7EXAMPLE");
634 scrubber.log_redactions(&report).unwrap();
636 }
637
638 #[test]
639 fn log_redactions_creates_parent_dirs() {
640 let tmp = tempfile::tempdir().unwrap();
641 let log_path = tmp.path().join("nested/dir/scrub.log");
642 let config = ScrubConfig {
643 report: ReportConfig {
644 log_file: Some(log_path.clone()),
645 ..Default::default()
646 },
647 ..Default::default()
648 };
649 let scrubber = Scrubber::from_config(&config).unwrap();
650 let (_, report) = scrubber.scrub_with_report("AWS_KEY=AKIAIOSFODNN7EXAMPLE");
651 scrubber.log_redactions(&report).unwrap();
652 assert!(log_path.exists());
653 }
654
655 #[test]
656 fn effective_counts_reflect_config() {
657 let config = ScrubConfig {
658 disable_builtins: vec!["jwt".into()],
659 patterns: vec![Pattern {
660 id: "x".into(),
661 regex: "xx".into(),
662 category: "test".into(),
663 replacement: None,
664 severity: Severity::Low,
665 }],
666 ..Default::default()
667 };
668 let s = Scrubber::from_config(&config).unwrap();
669 assert!(s.effective_builtin_count() > 0);
671 assert_eq!(s.effective_custom_count(), 1);
672 }
673
674 #[test]
675 fn has_severity_at_least_ranks_correctly() {
676 let mut r = ScrubReport::default();
677 r.redactions.push(Redaction {
678 rule_id: "x".into(),
679 category: "x".into(),
680 severity: Severity::Medium,
681 hash4: "0000".into(),
682 });
683 assert!(r.has_severity_at_least(Severity::Low));
684 assert!(r.has_severity_at_least(Severity::Medium));
685 assert!(!r.has_severity_at_least(Severity::High));
686 }
687}