1use crate::provider::policy::Sensitivity;
16use regex::Regex;
17use serde::{Deserialize, Serialize};
18use std::sync::LazyLock;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
25pub struct Span {
26 pub start: usize,
28 pub end: usize,
30}
31
32#[non_exhaustive]
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum Severity {
39 Low,
41 Medium,
43 High,
45 Critical,
47}
48
49#[non_exhaustive]
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum FindingCategory {
57 Secret,
59 KoreanPii,
61 FileSystemPath,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct Finding {
68 pub category: FindingCategory,
70 pub rule: String,
73 pub severity: Severity,
75 pub span: Span,
77}
78
79#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
81pub struct ScanReport {
82 pub findings: Vec<Finding>,
84 pub redact_spans: Vec<Span>,
86 pub sensitivity: Sensitivity,
88}
89
90struct Rule {
91 category: FindingCategory,
92 label: &'static str,
93 severity: Severity,
94 pattern: Regex,
95 group1_span: bool,
99}
100
101const TABLE: &[(&str, FindingCategory, Severity, &str, bool)] = &[
107 (
108 "bearer_header",
109 FindingCategory::Secret,
110 Severity::Critical,
111 r"(?i)\bauthorization\s*:\s*bearer\s+[A-Za-z0-9._\-]{20,}",
112 false,
113 ),
114 (
115 "key_value_assignment",
116 FindingCategory::Secret,
117 Severity::High,
118 r#"(?i)(?:password|passwd|token|key|secret|api_key|apikey|access_token|private_key)\s*["']?\s*[=:]\s*["']?((?:\\.|[^\s"'{}\\]){8,})"#,
127 true,
128 ),
129 (
130 "anthropic_key",
131 FindingCategory::Secret,
132 Severity::Critical,
133 r"\bsk-ant-[A-Za-z0-9_-]{16,}\b",
134 false,
135 ),
136 (
137 "private_key_header",
138 FindingCategory::Secret,
139 Severity::Critical,
140 r"(?i)-----BEGIN\s+(?:RSA|EC|DSA|OPENSSH|PGP)?\s*PRIVATE KEY",
141 false,
142 ),
143 (
144 "aws_access_key_id",
145 FindingCategory::Secret,
146 Severity::Critical,
147 r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b",
148 false,
149 ),
150 (
151 "aws_secret_key",
152 FindingCategory::Secret,
153 Severity::Critical,
154 r#"(?i)aws_secret_access_key\s*[=:]\s*["']?[A-Za-z0-9/+=]{16,}"#,
155 false,
156 ),
157 (
158 "github_token",
159 FindingCategory::Secret,
160 Severity::Critical,
161 r"\bgh[pousr]_[A-Za-z0-9]{36,}\b",
162 false,
163 ),
164 (
165 "openai_style_key",
166 FindingCategory::Secret,
167 Severity::Critical,
168 r"\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}\b",
169 false,
170 ),
171 (
172 "stripe_secret_key",
173 FindingCategory::Secret,
174 Severity::Critical,
175 r"\bsk_live_[A-Za-z0-9]{16,}\b",
176 false,
177 ),
178 (
179 "figma_token",
180 FindingCategory::Secret,
181 Severity::Critical,
182 r"\bfigd_[A-Za-z0-9]{20,}\b",
183 false,
184 ),
185 (
186 "slack_token",
187 FindingCategory::Secret,
188 Severity::Critical,
189 r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b",
190 false,
191 ),
192 (
193 "db_connection_string",
194 FindingCategory::Secret,
195 Severity::Critical,
196 r"(?i)\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^\s:@]+:[^\s@]+@",
197 false,
198 ),
199 (
200 "bank_account_kr",
201 FindingCategory::KoreanPii,
202 Severity::High,
203 r"(?i)(?:계좌|account)\s*(?:번호|no\.?|number)?\s*[::]?\s*\d{2,6}-\d{2,6}-\d{2,8}",
204 false,
205 ),
206 (
207 "phone_kr",
208 FindingCategory::KoreanPii,
209 Severity::Medium,
210 r"\b01[016789]-\d{3,4}-\d{4}\b",
211 false,
212 ),
213 (
214 "home_path_posix",
215 FindingCategory::FileSystemPath,
216 Severity::Medium,
217 r"/(?:Users|home)/[A-Za-z0-9_.][A-Za-z0-9_./-]*",
218 false,
219 ),
220 (
221 "home_path_windows",
222 FindingCategory::FileSystemPath,
223 Severity::Medium,
224 r"(?i)\b[a-z]:\\users\\[A-Za-z0-9_.][A-Za-z0-9_.\\-]*",
225 false,
226 ),
227 (
228 "tilde_path",
229 FindingCategory::FileSystemPath,
230 Severity::Medium,
231 r#"(?:^|[\s"'`(=:])(~/[A-Za-z0-9_./-]+)"#,
232 true,
233 ),
234];
235
236static RULES: LazyLock<Vec<Rule>> = LazyLock::new(|| {
237 TABLE
238 .iter()
239 .map(|&(label, category, severity, pattern, group1_span)| Rule {
240 category,
241 label,
242 severity,
243 pattern: Regex::new(pattern)
244 .unwrap_or_else(|e| panic!("invalid rule /{pattern}/: {e}")),
245 group1_span,
246 })
247 .collect()
248});
249
250static RRN_SHAPE: LazyLock<Regex> =
253 LazyLock::new(|| Regex::new(r"\b\d{6}-[1-4]\d{6}\b").expect("RRN_SHAPE is valid"));
254
255fn rrn_checksum_valid(digits: &[u8; 13]) -> bool {
258 const WEIGHTS: [u32; 12] = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5];
259 let sum: u32 = digits
260 .iter()
261 .take(12)
262 .zip(WEIGHTS)
263 .map(|(d, w)| u32::from(*d) * w)
264 .sum();
265 ((11 - sum % 11) % 10) == u32::from(digits[12])
266}
267
268fn severity_to_sensitivity(severity: Severity) -> Sensitivity {
269 match severity {
270 Severity::Critical => Sensitivity::Restricted,
271 Severity::High => Sensitivity::Confidential,
272 Severity::Medium | Severity::Low => Sensitivity::Internal,
273 }
274}
275
276pub fn scan(context: &str) -> ScanReport {
279 let mut findings: Vec<Finding> = Vec::new();
280
281 for rule in RULES.iter() {
282 for caps in rule.pattern.captures_iter(context) {
283 let m = if rule.group1_span {
284 caps.get(1)
285 .expect("group1_span rule always captures group 1")
286 } else {
287 caps.get(0).expect("capture 0 always present")
288 };
289 findings.push(Finding {
290 category: rule.category,
291 rule: rule.label.to_string(),
292 severity: rule.severity,
293 span: Span {
294 start: m.start(),
295 end: m.end(),
296 },
297 });
298 }
299 }
300
301 for m in RRN_SHAPE.find_iter(context) {
302 let text = m.as_str();
303 let mut digits = [0u8; 13];
304 let mut i = 0;
305 for b in text.bytes() {
306 if b.is_ascii_digit() {
307 digits[i] = b - b'0';
308 i += 1;
309 }
310 }
311 if i == 13 && rrn_checksum_valid(&digits) {
312 findings.push(Finding {
313 category: FindingCategory::KoreanPii,
314 rule: "rrn_kr".to_string(),
315 severity: Severity::Critical,
316 span: Span {
317 start: m.start(),
318 end: m.end(),
319 },
320 });
321 }
322 }
323
324 findings.sort_by_key(|f| f.span.start);
325
326 let mut redact_spans: Vec<Span> = findings.iter().map(|f| f.span).collect();
327 redact_spans.sort_unstable();
328 redact_spans.dedup();
329
330 let sensitivity = findings
331 .iter()
332 .map(|f| f.severity)
333 .max()
334 .map_or(Sensitivity::Public, severity_to_sensitivity);
335
336 ScanReport {
337 findings,
338 redact_spans,
339 sensitivity,
340 }
341}
342
343pub fn apply_redactions(text: &str, spans: &[Span]) -> String {
354 let mut sorted = spans.to_vec();
355 sorted.sort_unstable();
356
357 let mut merged: Vec<Span> = Vec::with_capacity(sorted.len());
358 for s in sorted {
359 match merged.last_mut() {
360 Some(last) if s.start <= last.end => last.end = last.end.max(s.end),
361 _ => merged.push(s),
362 }
363 }
364
365 let mut out = String::with_capacity(text.len());
366 let mut pos = 0usize;
367 for s in merged {
368 if s.start > pos {
369 out.push_str(&text[pos..s.start]);
370 }
371 out.push_str("****");
372 pos = pos.max(s.end);
373 }
374 if pos < text.len() {
375 out.push_str(&text[pos..]);
376 }
377 out
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 fn rules_hit(text: &str, label: &str) -> Vec<Finding> {
385 scan(text)
386 .findings
387 .into_iter()
388 .filter(|f| f.rule == label)
389 .collect()
390 }
391
392 fn make_valid_rrn(first12: &str) -> String {
395 assert_eq!(first12.len(), 12);
396 let digits: Vec<u8> = first12.bytes().map(|b| b - b'0').collect();
397 let mut arr = [0u8; 13];
398 arr[..12].copy_from_slice(&digits);
399 let mut s = String::new();
400 s.push_str(&first12[..6]);
401 s.push('-');
402 s.push_str(&first12[6..]);
403 s.push_str(&rrn_check_digit(&digits).to_string());
404 arr[12] = rrn_check_digit(&digits);
405 assert!(rrn_checksum_valid(&arr));
406 s
407 }
408
409 fn rrn_check_digit(first12: &[u8]) -> u8 {
410 const WEIGHTS: [u32; 12] = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5];
411 let sum: u32 = first12
412 .iter()
413 .zip(WEIGHTS)
414 .map(|(d, w)| u32::from(*d) * w)
415 .sum();
416 ((11 - sum % 11) % 10) as u8
417 }
418
419 #[test]
420 fn bearer_header_detected() {
421 let hits = rules_hit(
422 "Authorization: Bearer abcdefghijklmnopqrstuvwxyz",
423 "bearer_header",
424 );
425 assert_eq!(hits.len(), 1);
426 assert_eq!(hits[0].severity, Severity::Critical);
427 }
428
429 #[test]
430 fn bearer_short_token_ignored() {
431 assert!(rules_hit("Authorization: Bearer abc", "bearer_header").is_empty());
432 }
433
434 #[test]
435 fn key_value_assignment_requires_long_value() {
436 let hits = rules_hit("password=hunter2secret", "key_value_assignment");
437 assert_eq!(hits.len(), 1);
438 assert!(rules_hit("key=mode", "key_value_assignment").is_empty());
439 assert!(rules_hit("api_key: short", "key_value_assignment").is_empty());
440 }
441
442 #[test]
443 fn private_key_header_detected() {
444 let hits = rules_hit("-----BEGIN RSA PRIVATE KEY-----", "private_key_header");
445 assert_eq!(hits.len(), 1);
446 assert!(rules_hit("-----BEGIN CERTIFICATE-----", "private_key_header").is_empty());
447 }
448
449 #[test]
450 fn aws_access_key_detected_with_length_bound() {
451 assert_eq!(
452 rules_hit("AKIAIOSFODNN7EXAMPLE", "aws_access_key_id").len(),
453 1
454 );
455 assert!(rules_hit("AKIAIOSFODNN7EXAMPL", "aws_access_key_id").is_empty());
456 }
457
458 #[test]
459 fn aws_secret_key_detected() {
460 assert_eq!(
461 rules_hit(
462 "aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
463 "aws_secret_key"
464 )
465 .len(),
466 1
467 );
468 }
469
470 #[test]
471 fn github_token_detected() {
472 assert_eq!(
473 rules_hit("ghp_0123456789abcdefghijklmnopqrstuvwxyzAB", "github_token").len(),
474 1
475 );
476 assert!(rules_hit("ghp_short", "github_token").is_empty());
477 }
478
479 #[test]
480 fn openai_style_key_with_proj_prefix_detected() {
481 assert_eq!(
483 rules_hit("sk-proj-abc123def456ghi789jkl", "openai_style_key").len(),
484 1
485 );
486 assert_eq!(
487 rules_hit("sk-abcdef0123456789abcdef", "openai_style_key").len(),
488 1
489 );
490 }
491
492 #[test]
493 fn anthropic_key_detected() {
494 assert_eq!(
495 rules_hit("sk-ant-api03-0123456789abcdefGHIJKL", "anthropic_key").len(),
496 1
497 );
498 }
499
500 #[test]
501 fn key_value_span_excludes_json_structure_chars() {
502 let json = r#"{"api_key": "abc123def456ghi789"}"#;
505 let report = scan(json);
506 let finding = report
507 .findings
508 .iter()
509 .find(|f| f.rule == "key_value_assignment")
510 .expect("key_value finding");
511 let spanned = &json[finding.span.start..finding.span.end];
512 assert!(!spanned.contains('"'), "span ate a quote: {spanned}");
513 assert_eq!(spanned, "abc123def456ghi789");
514 let redacted = apply_redactions(json, &report.redact_spans);
515 let value: serde_json::Value =
516 serde_json::from_str(&redacted).expect("redacted JSON still parses");
517 assert_eq!(value["api_key"], "****");
518 }
519
520 #[test]
521 fn key_value_span_never_splits_json_escape_pair() {
522 let wire = r#"{"content":"check password=hunter2secret1\" in config"}"#;
527 let report = scan(wire);
528 let finding = report
529 .findings
530 .iter()
531 .find(|f| f.rule == "key_value_assignment")
532 .expect("key_value finding");
533 let spanned = &wire[finding.span.start..finding.span.end];
534 assert!(
535 !spanned.ends_with('\\'),
536 "span splits a wire escape pair: {spanned:?}"
537 );
538 let mut spliced = wire.to_string();
540 spliced.replace_range(finding.span.start..finding.span.end, "[REDACTED:key_value]");
541 let value: serde_json::Value =
542 serde_json::from_str(&spliced).expect("spliced wire still parses");
543 assert_eq!(
544 value["content"],
545 "check password=[REDACTED:key_value] in config"
546 );
547 }
548
549 #[test]
550 fn key_value_value_with_raw_backslashes_still_detected() {
551 let hits = rules_hit("password=C:\\secrets\\vault91", "key_value_assignment");
555 assert_eq!(hits.len(), 1);
556 assert_eq!(
557 "password=C:\\secrets\\vault91"[hits[0].span.start..hits[0].span.end].len(),
558 "C:\\secrets\\vault91".len()
559 );
560 }
561
562 #[test]
563 fn stripe_slack_figma_tokens_detected() {
564 let stripe_key = ["sk_", "live_", "0123456789abcdefGHIJ"].concat();
568 assert_eq!(rules_hit(&stripe_key, "stripe_secret_key").len(), 1);
569 assert_eq!(
570 rules_hit("xoxb-1234567890abcdefWXYZ", "slack_token").len(),
571 1
572 );
573 assert_eq!(
574 rules_hit("figd_0123456789abcdefghij", "figma_token").len(),
575 1
576 );
577 }
578
579 #[test]
580 fn db_connection_string_detected() {
581 assert_eq!(
582 rules_hit(
583 "postgres://admin:hunter2@db.example/prod",
584 "db_connection_string"
585 )
586 .len(),
587 1
588 );
589 assert!(rules_hit("postgres://db.example/prod", "db_connection_string").is_empty());
591 }
592
593 #[test]
594 fn rrn_valid_checksum_detected() {
595 let rrn = make_valid_rrn("900101123456");
596 let hits = rules_hit(&rrn, "rrn_kr");
597 assert_eq!(hits.len(), 1);
598 assert_eq!(hits[0].severity, Severity::Critical);
599 assert_eq!(scan(&rrn).sensitivity, Sensitivity::Restricted);
600 }
601
602 #[test]
603 fn rrn_invalid_checksum_ignored() {
604 assert!(rules_hit("900101-1234567", "rrn_kr").is_empty());
606 }
607
608 #[test]
609 fn rrn_multibyte_span_is_exact() {
610 let rrn = make_valid_rrn("900101123456");
611 let text = format!("주민번호는 {rrn} 입니다");
612 let report = scan(&text);
613 let f = report
614 .findings
615 .iter()
616 .find(|f| f.rule == "rrn_kr")
617 .expect("rrn finding");
618 assert_eq!(&text[f.span.start..f.span.end], rrn);
619 }
620
621 #[test]
622 fn bank_account_kr_detected() {
623 assert_eq!(
624 rules_hit("계좌 번호: 123-456-789012", "bank_account_kr").len(),
625 1
626 );
627 assert_eq!(
628 rules_hit("account no. 301-0123-4567", "bank_account_kr").len(),
629 1
630 );
631 }
632
633 #[test]
634 fn phone_kr_both_forms_detected() {
635 assert_eq!(rules_hit("010-1234-5678", "phone_kr").len(), 1);
636 assert_eq!(rules_hit("010-123-4567", "phone_kr").len(), 1);
637 assert!(rules_hit("01012345678", "phone_kr").is_empty());
638 }
639
640 #[test]
641 fn filesystem_paths_detected() {
642 assert_eq!(
643 rules_hit("/Users/hackme/notes.md", "home_path_posix").len(),
644 1
645 );
646 assert_eq!(rules_hit("/home/user/.env", "home_path_posix").len(), 1);
647 assert_eq!(
648 rules_hit("C:\\Users\\kim\\doc.txt", "home_path_windows").len(),
649 1
650 );
651 }
652
653 #[test]
654 fn tilde_path_span_excludes_leading_context() {
655 let text = "see ~/secret.md now";
656 let hits = rules_hit(text, "tilde_path");
657 assert_eq!(hits.len(), 1);
658 assert_eq!(&text[hits[0].span.start..hits[0].span.end], "~/secret.md");
659 assert_eq!(rules_hit("~/notes.md", "tilde_path").len(), 1);
661 assert!(rules_hit("cd ~ then", "tilde_path").is_empty());
663 }
664
665 #[test]
666 fn clean_text_is_public() {
667 let report = scan("just a normal sentence about the weather");
668 assert_eq!(report.sensitivity, Sensitivity::Public);
669 assert!(report.findings.is_empty());
670 assert!(report.redact_spans.is_empty());
671 }
672
673 #[test]
674 fn empty_text_is_public() {
675 let report = scan("");
676 assert_eq!(report.sensitivity, Sensitivity::Public);
677 }
678
679 #[test]
680 fn sensitivity_floor_follows_max_severity() {
681 assert_eq!(
683 scan("call me at 010-1234-5678").sensitivity,
684 Sensitivity::Internal
685 );
686 assert_eq!(
688 scan("ghp_0123456789abcdefghijklmnopqrstuvwxyzAB").sensitivity,
689 Sensitivity::Restricted
690 );
691 }
692
693 #[test]
694 fn redact_spans_sorted_and_deduped() {
695 let text =
697 "Authorization: Bearer abcdefghijklmnopqrstuvwxyz token=abcdefghijklmnopqrstuvwxyz";
698 let report = scan(text);
699 let mut spans = report.redact_spans.clone();
700 spans.sort_unstable();
701 spans.dedup();
702 assert_eq!(report.redact_spans, spans);
703 assert!(!report.redact_spans.is_empty());
704 }
705
706 #[test]
707 fn apply_redactions_multibyte_safe() {
708 let rrn = make_valid_rrn("900101123456");
709 let text = format!("주민번호는 {rrn} 입니다");
710 let report = scan(&text);
711 let redacted = apply_redactions(&text, &report.redact_spans);
712 assert_eq!(redacted, format!("주민번호는 **** 입니다"));
713 }
714
715 #[test]
716 fn apply_redactions_merges_overlapping_spans() {
717 let text = "abcdefghij";
718 let spans = vec![Span { start: 2, end: 6 }, Span { start: 1, end: 4 }];
720 assert_eq!(apply_redactions(text, &spans), "a****ghij");
721 }
722
723 #[test]
724 fn findings_sorted_ascending() {
725 let text = "path ~/a.md and 010-1234-5678 and ghp_0123456789abcdefghijklmnopqrstuvwxyzAB";
726 let report = scan(text);
727 let starts: Vec<usize> = report.findings.iter().map(|f| f.span.start).collect();
728 let mut sorted = starts.clone();
729 sorted.sort_unstable();
730 assert_eq!(starts, sorted);
731 }
732}