Skip to main content

llm_kernel/dlp/
scan.rs

1//! L1 — deterministic content scan.
2//!
3//! Single pass over static compiled rules: credentials, Korean PII, and
4//! machine-local filesystem paths. Returns byte spans, categories, severity,
5//! and an overall [`Sensitivity`] grade. Infallible — no I/O, no model.
6//!
7//! ```
8//! use llm_kernel::dlp::{scan, Sensitivity};
9//!
10//! let report = scan("Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345");
11//! assert!(report.sensitivity >= Sensitivity::Confidential);
12//! assert!(!report.redact_spans.is_empty());
13//! ```
14
15use crate::provider::policy::Sensitivity;
16use regex::Regex;
17use serde::{Deserialize, Serialize};
18use std::sync::LazyLock;
19
20/// Byte-offset range `[start, end)` into the scanned text.
21///
22/// **Byte** offsets, not char indices: the `regex` crate reports byte offsets
23/// and Korean text is multibyte. Slice with `&text[span.start..span.end]`.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
25pub struct Span {
26    /// Byte offset of the first byte of the match.
27    pub start: usize,
28    /// Byte offset one past the last byte of the match.
29    pub end: usize,
30}
31
32/// Severity of a single finding.
33///
34/// Variant order is the ordering (`Low < Medium < High < Critical`).
35#[non_exhaustive]
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum Severity {
39    /// Informational.
40    Low,
41    /// Likely personal or machine-revealing, not a credential.
42    Medium,
43    /// Sensitive personal data or a probable credential.
44    High,
45    /// Structurally unmistakable credential or strong PII.
46    Critical,
47}
48
49/// Coarse finding category (drives severity floor and sensitivity).
50///
51/// Fine-grained detector identity is [`Finding::rule`]. New variants may be
52/// added in any minor release.
53#[non_exhaustive]
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum FindingCategory {
57    /// Credentials: API keys, tokens, private keys, DB connection strings.
58    Secret,
59    /// Korean PII: RRN (주민등록번호), bank accounts, mobile numbers.
60    KoreanPii,
61    /// Machine-local filesystem paths.
62    FileSystemPath,
63}
64
65/// One detection.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct Finding {
68    /// Coarse category.
69    pub category: FindingCategory,
70    /// Detector label (e.g. `"rrn_kr"`, `"github_token"`) — audit identity,
71    /// never contains matched text.
72    pub rule: String,
73    /// Severity of this finding.
74    pub severity: Severity,
75    /// Byte span of the matched text.
76    pub span: Span,
77}
78
79/// Result of [`scan`].
80#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
81pub struct ScanReport {
82    /// All detections, ascending by span start.
83    pub findings: Vec<Finding>,
84    /// Sorted, deduplicated spans to redact.
85    pub redact_spans: Vec<Span>,
86    /// Severity floor of the findings.
87    pub sensitivity: Sensitivity,
88}
89
90struct Rule {
91    category: FindingCategory,
92    label: &'static str,
93    severity: Severity,
94    pattern: Regex,
95    /// True when the redactable span is capture group 1 (context-anchored
96    /// patterns where the leading context must not be consumed — the `regex`
97    /// crate has no lookbehind and `~` has no word boundary).
98    group1_span: bool,
99}
100
101// (label, category, severity, pattern, group1_span)
102//
103// Deferred detector families (one-line adds when needed): email, ssn_us,
104// card_pan, Korean 사업자번호 (business registration), source-code/infra,
105// healthcare PHI, finance MNPI.
106const 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        // Group 1 (the value) is the redactable span. Optional quotes on
119        // either side of the separator let the rule fire inside JSON bodies
120        // (`"api_key": "…"`), and the value charset excludes quotes/braces
121        // so a redacted span never removes JSON structure characters
122        // (claudy DLP proxy contract: byte identity outside the secret).
123        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
247// RRN (주민등록번호) shape — gated by a checksum so same-shaped order numbers
248// and dates do not flag.
249static RRN_SHAPE: LazyLock<Regex> =
250    LazyLock::new(|| Regex::new(r"\b\d{6}-[1-4]\d{6}\b").expect("RRN_SHAPE is valid"));
251
252/// Korean RRN checksum: weights 2..9 then 2..5 over the first 12 digits;
253/// check digit = `(11 - sum % 11) % 10`.
254fn 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
273/// Deterministically scan `context` for secrets, Korean PII, and filesystem
274/// paths. Infallible: static compiled rules, no I/O.
275pub 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
340/// Replace every span with `****`, multibyte-safe.
341///
342/// Overlapping spans are merged before splicing. Spans must come from
343/// [`scan`] on the same `text` (regex byte offsets are guaranteed char
344/// boundaries).
345///
346/// # Panics
347///
348/// Panics if a span is out of bounds or not on a UTF-8 char boundary of
349/// `text`.
350pub 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    /// Build a checksum-valid RRN from 12 digits ("YYMMDD" + "SNNNNNN" minus
390    /// the check digit), so tests never hardcode a real-format constant.
391    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        // Regression: a charset without `-` misses modern `sk-proj-…` keys.
484        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        // claudy DLP proxy contract: redacting a span inside a JSON body must
505        // never consume quotes/braces — byte identity outside the secret.
506        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        // Built by concatenation so the literal never lands in the git blob
525        // (GitHub push protection flags `sk_live_…`-shaped strings even as
526        // test fixtures).
527        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        // No credentials in the URI → no finding.
550        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        // Same shape, fails the checksum gate.
565        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        // Line-start anchor also matches.
620        assert_eq!(rules_hit("~/notes.md", "tilde_path").len(), 1);
621        // Bare `~` with nothing after it does not match.
622        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        // phone_kr (Medium) → Internal
642        assert_eq!(
643            scan("call me at 010-1234-5678").sensitivity,
644            Sensitivity::Internal
645        );
646        // github_token (Critical) → Restricted
647        assert_eq!(
648            scan("ghp_0123456789abcdefghijklmnopqrstuvwxyzAB").sensitivity,
649            Sensitivity::Restricted
650        );
651    }
652
653    #[test]
654    fn redact_spans_sorted_and_deduped() {
655        // bearer_header and key_value_assignment can both hit the same region.
656        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        // Overlapping spans covering [1,4) and [2,6).
679        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}