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.bytes().map(|b| b - b'0').collect();
394        let mut arr = [0u8; 13];
395        arr[..12].copy_from_slice(&digits);
396        let mut s = String::new();
397        s.push_str(&first12[..6]);
398        s.push('-');
399        s.push_str(&first12[6..]);
400        s.push_str(&rrn_check_digit(&digits).to_string());
401        arr[12] = rrn_check_digit(&digits);
402        assert!(rrn_checksum_valid(&arr));
403        s
404    }
405
406    fn rrn_check_digit(first12: &[u8]) -> u8 {
407        const WEIGHTS: [u32; 12] = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5];
408        let sum: u32 = first12
409            .iter()
410            .zip(WEIGHTS)
411            .map(|(d, w)| u32::from(*d) * w)
412            .sum();
413        ((11 - sum % 11) % 10) as u8
414    }
415
416    #[test]
417    fn bearer_header_detected() {
418        let hits = rules_hit(
419            "Authorization: Bearer abcdefghijklmnopqrstuvwxyz",
420            "bearer_header",
421        );
422        assert_eq!(hits.len(), 1);
423        assert_eq!(hits[0].severity, Severity::Critical);
424    }
425
426    #[test]
427    fn bearer_short_token_ignored() {
428        assert!(rules_hit("Authorization: Bearer abc", "bearer_header").is_empty());
429    }
430
431    #[test]
432    fn key_value_assignment_requires_long_value() {
433        let hits = rules_hit("password=hunter2secret", "key_value_assignment");
434        assert_eq!(hits.len(), 1);
435        assert!(rules_hit("key=mode", "key_value_assignment").is_empty());
436        assert!(rules_hit("api_key: short", "key_value_assignment").is_empty());
437    }
438
439    #[test]
440    fn private_key_header_detected() {
441        let hits = rules_hit("-----BEGIN RSA PRIVATE KEY-----", "private_key_header");
442        assert_eq!(hits.len(), 1);
443        assert!(rules_hit("-----BEGIN CERTIFICATE-----", "private_key_header").is_empty());
444    }
445
446    #[test]
447    fn aws_access_key_detected_with_length_bound() {
448        assert_eq!(
449            rules_hit("AKIAIOSFODNN7EXAMPLE", "aws_access_key_id").len(),
450            1
451        );
452        assert!(rules_hit("AKIAIOSFODNN7EXAMPL", "aws_access_key_id").is_empty());
453    }
454
455    #[test]
456    fn aws_secret_key_detected() {
457        assert_eq!(
458            rules_hit(
459                "aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
460                "aws_secret_key"
461            )
462            .len(),
463            1
464        );
465    }
466
467    #[test]
468    fn github_token_detected() {
469        assert_eq!(
470            rules_hit("ghp_0123456789abcdefghijklmnopqrstuvwxyzAB", "github_token").len(),
471            1
472        );
473        assert!(rules_hit("ghp_short", "github_token").is_empty());
474    }
475
476    #[test]
477    fn openai_style_key_with_proj_prefix_detected() {
478        // Regression: a charset without `-` misses modern `sk-proj-…` keys.
479        assert_eq!(
480            rules_hit("sk-proj-abc123def456ghi789jkl", "openai_style_key").len(),
481            1
482        );
483        assert_eq!(
484            rules_hit("sk-abcdef0123456789abcdef", "openai_style_key").len(),
485            1
486        );
487    }
488
489    #[test]
490    fn anthropic_key_detected() {
491        assert_eq!(
492            rules_hit("sk-ant-api03-0123456789abcdefGHIJKL", "anthropic_key").len(),
493            1
494        );
495    }
496
497    #[test]
498    fn key_value_span_excludes_json_structure_chars() {
499        // claudy DLP proxy contract: redacting a span inside a JSON body must
500        // never consume quotes/braces — byte identity outside the secret.
501        let json = r#"{"api_key": "abc123def456ghi789"}"#;
502        let report = scan(json);
503        let finding = report
504            .findings
505            .iter()
506            .find(|f| f.rule == "key_value_assignment")
507            .expect("key_value finding");
508        let spanned = &json[finding.span.start..finding.span.end];
509        assert!(!spanned.contains('"'), "span ate a quote: {spanned}");
510        assert_eq!(spanned, "abc123def456ghi789");
511        let redacted = apply_redactions(json, &report.redact_spans);
512        let value: serde_json::Value =
513            serde_json::from_str(&redacted).expect("redacted JSON still parses");
514        assert_eq!(value["api_key"], "****");
515    }
516
517    #[test]
518    fn stripe_slack_figma_tokens_detected() {
519        // Built by concatenation so the literal never lands in the git blob
520        // (GitHub push protection flags `sk_live_…`-shaped strings even as
521        // test fixtures).
522        let stripe_key = ["sk_", "live_", "0123456789abcdefGHIJ"].concat();
523        assert_eq!(rules_hit(&stripe_key, "stripe_secret_key").len(), 1);
524        assert_eq!(
525            rules_hit("xoxb-1234567890abcdefWXYZ", "slack_token").len(),
526            1
527        );
528        assert_eq!(
529            rules_hit("figd_0123456789abcdefghij", "figma_token").len(),
530            1
531        );
532    }
533
534    #[test]
535    fn db_connection_string_detected() {
536        assert_eq!(
537            rules_hit(
538                "postgres://admin:hunter2@db.example/prod",
539                "db_connection_string"
540            )
541            .len(),
542            1
543        );
544        // No credentials in the URI → no finding.
545        assert!(rules_hit("postgres://db.example/prod", "db_connection_string").is_empty());
546    }
547
548    #[test]
549    fn rrn_valid_checksum_detected() {
550        let rrn = make_valid_rrn("900101123456");
551        let hits = rules_hit(&rrn, "rrn_kr");
552        assert_eq!(hits.len(), 1);
553        assert_eq!(hits[0].severity, Severity::Critical);
554        assert_eq!(scan(&rrn).sensitivity, Sensitivity::Restricted);
555    }
556
557    #[test]
558    fn rrn_invalid_checksum_ignored() {
559        // Same shape, fails the checksum gate.
560        assert!(rules_hit("900101-1234567", "rrn_kr").is_empty());
561    }
562
563    #[test]
564    fn rrn_multibyte_span_is_exact() {
565        let rrn = make_valid_rrn("900101123456");
566        let text = format!("주민번호는 {rrn} 입니다");
567        let report = scan(&text);
568        let f = report
569            .findings
570            .iter()
571            .find(|f| f.rule == "rrn_kr")
572            .expect("rrn finding");
573        assert_eq!(&text[f.span.start..f.span.end], rrn);
574    }
575
576    #[test]
577    fn bank_account_kr_detected() {
578        assert_eq!(
579            rules_hit("계좌 번호: 123-456-789012", "bank_account_kr").len(),
580            1
581        );
582        assert_eq!(
583            rules_hit("account no. 301-0123-4567", "bank_account_kr").len(),
584            1
585        );
586    }
587
588    #[test]
589    fn phone_kr_both_forms_detected() {
590        assert_eq!(rules_hit("010-1234-5678", "phone_kr").len(), 1);
591        assert_eq!(rules_hit("010-123-4567", "phone_kr").len(), 1);
592        assert!(rules_hit("01012345678", "phone_kr").is_empty());
593    }
594
595    #[test]
596    fn filesystem_paths_detected() {
597        assert_eq!(
598            rules_hit("/Users/hackme/notes.md", "home_path_posix").len(),
599            1
600        );
601        assert_eq!(rules_hit("/home/user/.env", "home_path_posix").len(), 1);
602        assert_eq!(
603            rules_hit("C:\\Users\\kim\\doc.txt", "home_path_windows").len(),
604            1
605        );
606    }
607
608    #[test]
609    fn tilde_path_span_excludes_leading_context() {
610        let text = "see ~/secret.md now";
611        let hits = rules_hit(text, "tilde_path");
612        assert_eq!(hits.len(), 1);
613        assert_eq!(&text[hits[0].span.start..hits[0].span.end], "~/secret.md");
614        // Line-start anchor also matches.
615        assert_eq!(rules_hit("~/notes.md", "tilde_path").len(), 1);
616        // Bare `~` with nothing after it does not match.
617        assert!(rules_hit("cd ~ then", "tilde_path").is_empty());
618    }
619
620    #[test]
621    fn clean_text_is_public() {
622        let report = scan("just a normal sentence about the weather");
623        assert_eq!(report.sensitivity, Sensitivity::Public);
624        assert!(report.findings.is_empty());
625        assert!(report.redact_spans.is_empty());
626    }
627
628    #[test]
629    fn empty_text_is_public() {
630        let report = scan("");
631        assert_eq!(report.sensitivity, Sensitivity::Public);
632    }
633
634    #[test]
635    fn sensitivity_floor_follows_max_severity() {
636        // phone_kr (Medium) → Internal
637        assert_eq!(
638            scan("call me at 010-1234-5678").sensitivity,
639            Sensitivity::Internal
640        );
641        // github_token (Critical) → Restricted
642        assert_eq!(
643            scan("ghp_0123456789abcdefghijklmnopqrstuvwxyzAB").sensitivity,
644            Sensitivity::Restricted
645        );
646    }
647
648    #[test]
649    fn redact_spans_sorted_and_deduped() {
650        // bearer_header and key_value_assignment can both hit the same region.
651        let text =
652            "Authorization: Bearer abcdefghijklmnopqrstuvwxyz token=abcdefghijklmnopqrstuvwxyz";
653        let report = scan(text);
654        let mut spans = report.redact_spans.clone();
655        spans.sort_unstable();
656        spans.dedup();
657        assert_eq!(report.redact_spans, spans);
658        assert!(!report.redact_spans.is_empty());
659    }
660
661    #[test]
662    fn apply_redactions_multibyte_safe() {
663        let rrn = make_valid_rrn("900101123456");
664        let text = format!("주민번호는 {rrn} 입니다");
665        let report = scan(&text);
666        let redacted = apply_redactions(&text, &report.redact_spans);
667        assert_eq!(redacted, format!("주민번호는 **** 입니다"));
668    }
669
670    #[test]
671    fn apply_redactions_merges_overlapping_spans() {
672        let text = "abcdefghij";
673        // Overlapping spans covering [1,4) and [2,6).
674        let spans = vec![Span { start: 2, end: 6 }, Span { start: 1, end: 4 }];
675        assert_eq!(apply_redactions(text, &spans), "a****ghij");
676    }
677
678    #[test]
679    fn findings_sorted_ascending() {
680        let text = "path ~/a.md and 010-1234-5678 and ghp_0123456789abcdefghijklmnopqrstuvwxyzAB";
681        let report = scan(text);
682        let starts: Vec<usize> = report.findings.iter().map(|f| f.span.start).collect();
683        let mut sorted = starts.clone();
684        sorted.sort_unstable();
685        assert_eq!(starts, sorted);
686    }
687}