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