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<()> {
323 let Some(path) = &self.report.log_file else {
324 return Ok(());
325 };
326 if report.redactions.is_empty() {
327 return Ok(());
328 }
329 append_log_lines(path, &report.redactions)
330 }
331}
332
333fn append_log_lines(path: &Path, redactions: &[Redaction]) -> Result<()> {
334 use std::io::Write;
335
336 if let Some(parent) = path.parent()
337 && !parent.as_os_str().is_empty()
338 {
339 std::fs::create_dir_all(parent)?;
340 }
341
342 let mut file = std::fs::OpenOptions::new()
343 .create(true)
344 .append(true)
345 .open(path)?;
346
347 let ts = std::time::SystemTime::now()
348 .duration_since(std::time::UNIX_EPOCH)
349 .map(|d| d.as_secs())
350 .unwrap_or(0);
351
352 for r in redactions {
353 let line = serde_json::to_string(&LogEntry {
354 ts,
355 rule_id: &r.rule_id,
356 category: &r.category,
357 severity: r.severity,
358 hash4: &r.hash4,
359 })?;
360 writeln!(file, "{line}")?;
361 }
362 file.flush()?;
363 Ok(())
364}
365
366#[derive(Serialize)]
367struct LogEntry<'a> {
368 ts: u64,
369 rule_id: &'a str,
370 category: &'a str,
371 severity: Severity,
372 hash4: &'a str,
373}
374
375fn compile_pattern(p: &Pattern) -> Result<CompiledPattern> {
376 let replacement = p
377 .replacement
378 .clone()
379 .unwrap_or_else(|| format!("<REDACTED:{}:{{hash4}}>", p.category));
380 Ok(CompiledPattern {
381 id: p.id.clone(),
382 category: p.category.clone(),
383 regex: Regex::new(&p.regex)?,
384 replacement,
385 severity: p.severity,
386 })
387}
388
389fn builtin_patterns() -> Result<Vec<CompiledPattern>> {
390 let builtins: &[(&str, &str, &str, Severity)] = &[
391 (
392 "aws_access_key",
393 r"AKIA[0-9A-Z]{16}",
394 "aws_key",
395 Severity::Critical,
396 ),
397 (
398 "github_pat",
399 r"ghp_[A-Za-z0-9]{36,}",
400 "github",
401 Severity::Critical,
402 ),
403 (
404 "github_oauth",
405 r"gho_[A-Za-z0-9]{36,}",
406 "github",
407 Severity::Critical,
408 ),
409 (
410 "openai_key",
411 r"sk-[A-Za-z0-9]{20,}",
412 "api_key",
413 Severity::Critical,
414 ),
415 (
416 "anthropic_key",
417 r"sk-ant-[A-Za-z0-9\-_]{20,}",
418 "api_key",
419 Severity::Critical,
420 ),
421 (
422 "huggingface_token",
423 r"hf_[A-Za-z0-9]{30,}",
424 "api_key",
425 Severity::High,
426 ),
427 (
428 "google_api_key",
429 r"AIza[0-9A-Za-z\-_]{35}",
430 "api_key",
431 Severity::High,
432 ),
433 (
434 "slack_token",
435 r"xox[baprs]-[0-9A-Za-z\-]{10,}",
436 "slack",
437 Severity::High,
438 ),
439 (
440 "jwt",
441 r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+",
442 "jwt",
443 Severity::High,
444 ),
445 (
446 "private_key_pem",
447 r"-----BEGIN (RSA|EC|OPENSSH|PGP) PRIVATE KEY-----",
448 "pem",
449 Severity::Critical,
450 ),
451 ];
452 let mut patterns = Vec::with_capacity(builtins.len());
453 for (id, re, cat, sev) in builtins {
454 patterns.push(CompiledPattern {
455 id: (*id).into(),
456 category: (*cat).into(),
457 regex: Regex::new(re)?,
458 replacement: format!("<REDACTED:{cat}:{{hash4}}>"),
459 severity: *sev,
460 });
461 }
462 Ok(patterns)
463}
464
465fn render_replacement(template: &str, category: &str, hash4_val: &str) -> String {
466 template
467 .replace("{hash4}", hash4_val)
468 .replace("{category}", category)
469}
470
471pub(crate) fn hash4(s: &str) -> String {
472 let mut h = Sha256::new();
473 h.update(s.as_bytes());
474 let digest = h.finalize();
475 format!("{:02x}{:02x}", digest[0], digest[1])
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn redacts_aws_key() {
484 let s = Scrubber::with_builtins().unwrap();
485 let out = s.scrub("AWS_KEY=AKIAIOSFODNN7EXAMPLE trailing");
486 assert!(!out.contains("AKIAIOSFODNN7EXAMPLE"));
487 assert!(out.contains("<REDACTED:aws_key:"));
488 assert!(out.contains("trailing"));
489 }
490
491 #[test]
492 fn redacts_jwt() {
493 let s = Scrubber::with_builtins().unwrap();
494 let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSJ9.abcDEF-_xyz";
495 let out = s.scrub(jwt);
496 assert!(!out.contains(jwt));
497 assert!(out.contains("<REDACTED:jwt:"));
498 }
499
500 #[test]
501 fn leaves_benign_text_alone() {
502 let s = Scrubber::with_builtins().unwrap();
503 let out = s.scrub("just some code // no secrets here");
504 assert_eq!(out, "just some code // no secrets here");
505 }
506
507 #[test]
508 fn hash_is_stable() {
509 assert_eq!(hash4("hello"), hash4("hello"));
510 assert_ne!(hash4("hello"), hash4("world"));
511 }
512
513 #[test]
514 fn report_counts_by_category() {
515 let s = Scrubber::with_builtins().unwrap();
516 let (_, report) = s.scrub_with_report(
517 "AKIAIOSFODNN7EXAMPLE and another AKIAIOSFODNN7EXAMPLW and ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
518 );
519 let by_cat = report.count_by_category();
520 assert_eq!(by_cat.get("aws_key").copied(), Some(2));
521 assert_eq!(by_cat.get("github").copied(), Some(1));
522 }
523
524 #[test]
525 fn report_summary_is_human_readable() {
526 let s = Scrubber::with_builtins().unwrap();
527 let (_, report) = s.scrub_with_report("AKIAIOSFODNN7EXAMPLE");
528 assert!(report.summary().contains("redacted"));
529 assert!(report.summary().contains("aws_key"));
530 }
531
532 #[test]
533 fn allowlist_bypasses_regex() {
534 let config = ScrubConfig {
535 allowlist: vec![config::AllowlistEntry {
536 exact: Some("AKIAEXAMPLEALLOWED".into()),
537 regex: None,
538 }],
539 ..Default::default()
540 };
541 let s = Scrubber::from_config(&config).unwrap();
542 let out = s.scrub("AKIAEXAMPLEALLOWED");
543 assert_eq!(out, "AKIAEXAMPLEALLOWED");
545 }
546
547 #[test]
548 fn disable_builtins_drops_named_rule() {
549 let config = ScrubConfig {
550 disable_builtins: vec!["jwt".into()],
551 ..Default::default()
552 };
553 let s = Scrubber::from_config(&config).unwrap();
554 let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSJ9.abcDEF-_xyz";
555 let out = s.scrub(jwt);
556 assert_eq!(out, jwt, "jwt rule should have been disabled");
557 }
558
559 #[test]
560 fn builtins_replace_drops_all() {
561 let config = ScrubConfig {
562 builtins: BuiltinsMode::Replace,
563 ..Default::default()
564 };
565 let s = Scrubber::from_config(&config).unwrap();
566 assert_eq!(s.scrub("AKIAIOSFODNN7EXAMPLE"), "AKIAIOSFODNN7EXAMPLE");
567 }
568
569 #[test]
570 fn log_redactions_writes_values_free_jsonl() {
571 let tmp = tempfile::tempdir().unwrap();
572 let log_path = tmp.path().join("scrub.log");
573 let config = ScrubConfig {
574 report: ReportConfig {
575 log_file: Some(log_path.clone()),
576 ..Default::default()
577 },
578 ..Default::default()
579 };
580 let scrubber = Scrubber::from_config(&config).unwrap();
581 let (_, report) = scrubber.scrub_with_report(
582 "AWS_KEY=AKIAIOSFODNN7EXAMPLE and jwt: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSJ9.abcDEF-_xyz",
583 );
584 assert!(!report.is_empty());
585
586 scrubber.log_redactions(&report).unwrap();
587 let contents = std::fs::read_to_string(&log_path).unwrap();
588 let lines: Vec<&str> = contents.lines().collect();
589 assert_eq!(lines.len(), report.redactions.len());
590 for (line, r) in lines.iter().zip(&report.redactions) {
592 let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
593 assert_eq!(parsed["rule_id"], r.rule_id);
594 assert_eq!(parsed["category"], r.category);
595 assert_eq!(parsed["hash4"], r.hash4);
596 assert!(parsed["ts"].as_u64().unwrap() > 0);
597 assert!(!line.contains("AKIAIOSFODNN7EXAMPLE"));
599 assert!(!line.contains("eyJhbGciOi"));
600 }
601 }
602
603 #[test]
604 fn log_redactions_noop_when_unconfigured() {
605 let scrubber = Scrubber::with_builtins().unwrap();
606 let (_, report) = scrubber.scrub_with_report("AWS_KEY=AKIAIOSFODNN7EXAMPLE");
607 scrubber.log_redactions(&report).unwrap();
609 }
610
611 #[test]
612 fn log_redactions_creates_parent_dirs() {
613 let tmp = tempfile::tempdir().unwrap();
614 let log_path = tmp.path().join("nested/dir/scrub.log");
615 let config = ScrubConfig {
616 report: ReportConfig {
617 log_file: Some(log_path.clone()),
618 ..Default::default()
619 },
620 ..Default::default()
621 };
622 let scrubber = Scrubber::from_config(&config).unwrap();
623 let (_, report) = scrubber.scrub_with_report("AWS_KEY=AKIAIOSFODNN7EXAMPLE");
624 scrubber.log_redactions(&report).unwrap();
625 assert!(log_path.exists());
626 }
627
628 #[test]
629 fn effective_counts_reflect_config() {
630 let config = ScrubConfig {
631 disable_builtins: vec!["jwt".into()],
632 patterns: vec![Pattern {
633 id: "x".into(),
634 regex: "xx".into(),
635 category: "test".into(),
636 replacement: None,
637 severity: Severity::Low,
638 }],
639 ..Default::default()
640 };
641 let s = Scrubber::from_config(&config).unwrap();
642 assert!(s.effective_builtin_count() > 0);
644 assert_eq!(s.effective_custom_count(), 1);
645 }
646
647 #[test]
648 fn has_severity_at_least_ranks_correctly() {
649 let mut r = ScrubReport::default();
650 r.redactions.push(Redaction {
651 rule_id: "x".into(),
652 category: "x".into(),
653 severity: Severity::Medium,
654 hash4: "0000".into(),
655 });
656 assert!(r.has_severity_at_least(Severity::Low));
657 assert!(r.has_severity_at_least(Severity::Medium));
658 assert!(!r.has_severity_at_least(Severity::High));
659 }
660}