Skip to main content

redact_core/recognizers/
validation.rs

1// Copyright 2026 Censgate LLC.
2// Licensed under the Apache License, Version 2.0. See the LICENSE file
3// in the project root for license information.
4
5//! Validation functions for PII patterns.
6//!
7//! These functions provide additional validation beyond regex matching
8//! to reduce false positives. For example, credit card numbers must
9//! pass the Luhn checksum, and IBANs have country-specific formats.
10
11use crate::types::EntityType;
12
13/// Validate a detected entity value based on its type.
14///
15/// Returns a confidence adjustment factor:
16/// - 1.0: Validation passed or not applicable
17/// - 0.0-0.99: Validation partially passed (reduces confidence)
18/// - 0.0: Validation failed (entity should be rejected)
19pub fn validate_entity(entity_type: &EntityType, value: &str) -> f32 {
20    match entity_type {
21        EntityType::CreditCard => validate_credit_card(value),
22        EntityType::IbanCode | EntityType::Iban => validate_iban(value),
23        EntityType::UsSsn => validate_us_ssn(value),
24        EntityType::UkNino => validate_uk_nino(value),
25        EntityType::UkNhs => validate_uk_nhs(value),
26        EntityType::Isbn => validate_isbn(value),
27        EntityType::IpAddress => validate_ip_address(value),
28        EntityType::HttpBasicAuth => validate_http_basic_auth(value),
29        _ => 1.0, // No validation available
30    }
31}
32
33/// Decode-validate an HTTP Basic credential token (the value after `Basic `).
34///
35/// Requires canonical base64 padding, UTF-8 printable ASCII with no NUL, and
36/// a `user:password` split where both sides are non-empty.
37pub fn validate_http_basic_auth(value: &str) -> f32 {
38    let token = value
39        .strip_prefix("Basic ")
40        .or_else(|| value.strip_prefix("basic "))
41        .unwrap_or(value);
42    if !is_canonical_b64(token) {
43        return 0.0;
44    }
45    let Ok(bytes) = decode_base64(token) else {
46        return 0.0;
47    };
48    let Ok(plain) = std::str::from_utf8(&bytes) else {
49        return 0.0;
50    };
51    if plain.contains('\0') || !plain.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) {
52        return 0.0;
53    }
54    let Some((user, password)) = plain.split_once(':') else {
55        return 0.0;
56    };
57    if user.is_empty() || password.is_empty() {
58        return 0.0;
59    }
60    1.0
61}
62
63fn is_canonical_b64(token: &str) -> bool {
64    if token.is_empty() || !token.len().is_multiple_of(4) {
65        return false;
66    }
67    let pad = token.bytes().rev().take_while(|&b| b == b'=').count();
68    if pad > 2 {
69        return false;
70    }
71    let body_len = token.len() - pad;
72    if !token.as_bytes()[..body_len]
73        .iter()
74        .all(|b| b.is_ascii_alphanumeric() || *b == b'+' || *b == b'/')
75    {
76        return false;
77    }
78    // Reject non-canonical unused padding bits (`dTpwYXN=` vs `dTpwYXM=`).
79    let Ok(bytes) = decode_base64(token) else {
80        return false;
81    };
82    encode_base64(&bytes) == token
83}
84
85fn encode_base64(bytes: &[u8]) -> String {
86    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
87    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
88    let mut i = 0;
89    while i < bytes.len() {
90        let b0 = bytes[i];
91        let b1 = bytes.get(i + 1).copied();
92        let b2 = bytes.get(i + 2).copied();
93        out.push(TABLE[(b0 >> 2) as usize] as char);
94        match (b1, b2) {
95            (Some(b1), Some(b2)) => {
96                out.push(TABLE[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
97                out.push(TABLE[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char);
98                out.push(TABLE[(b2 & 0x3f) as usize] as char);
99            }
100            (Some(b1), None) => {
101                out.push(TABLE[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
102                out.push(TABLE[((b1 & 0x0f) << 2) as usize] as char);
103                out.push('=');
104            }
105            (None, _) => {
106                out.push(TABLE[((b0 & 0x03) << 4) as usize] as char);
107                out.push('=');
108                out.push('=');
109            }
110        }
111        i += 3;
112    }
113    out
114}
115
116fn decode_base64(token: &str) -> Result<Vec<u8>, ()> {
117    const TABLE: [i8; 256] = {
118        let mut t = [-1i8; 256];
119        let mut i = 0;
120        while i < 26 {
121            t[(b'A' + i as u8) as usize] = i;
122            t[(b'a' + i as u8) as usize] = 26 + i;
123            i += 1;
124        }
125        i = 0;
126        while i < 10 {
127            t[(b'0' + i as u8) as usize] = 52 + i;
128            i += 1;
129        }
130        t[b'+' as usize] = 62;
131        t[b'/' as usize] = 63;
132        t
133    };
134    let mut out = Vec::with_capacity(token.len() / 4 * 3);
135    let bytes = token.as_bytes();
136    let mut i = 0;
137    while i < bytes.len() {
138        let a = TABLE[bytes[i] as usize];
139        let b = TABLE[bytes[i + 1] as usize];
140        if a < 0 || b < 0 {
141            return Err(());
142        }
143        out.push(((a as u8) << 2) | ((b as u8) >> 4));
144        if bytes[i + 2] != b'=' {
145            let c = TABLE[bytes[i + 2] as usize];
146            if c < 0 {
147                return Err(());
148            }
149            out.push(((b as u8) << 4) | ((c as u8) >> 2));
150            if bytes[i + 3] != b'=' {
151                let d = TABLE[bytes[i + 3] as usize];
152                if d < 0 {
153                    return Err(());
154                }
155                out.push(((c as u8) << 6) | (d as u8));
156            }
157        } else if bytes[i + 3] != b'=' {
158            return Err(());
159        }
160        i += 4;
161    }
162    Ok(out)
163}
164
165/// Validate credit card number using Luhn algorithm.
166///
167/// The Luhn algorithm (mod 10) is used by most credit card issuers.
168pub fn validate_credit_card(value: &str) -> f32 {
169    let digits: Vec<u32> = value
170        .chars()
171        .filter(|c| c.is_ascii_digit())
172        .filter_map(|c| c.to_digit(10))
173        .collect();
174
175    if digits.len() < 13 || digits.len() > 19 {
176        return 0.0;
177    }
178
179    if luhn_check(&digits) {
180        1.0
181    } else {
182        0.0
183    }
184}
185
186/// Luhn algorithm implementation
187fn luhn_check(digits: &[u32]) -> bool {
188    let mut sum = 0;
189    let mut double = false;
190
191    for &digit in digits.iter().rev() {
192        let mut d = digit;
193        if double {
194            d *= 2;
195            if d > 9 {
196                d -= 9;
197            }
198        }
199        sum += d;
200        double = !double;
201    }
202
203    sum.is_multiple_of(10)
204}
205
206/// Validate IBAN format and checksum.
207///
208/// IBAN validation:
209/// 1. Check length matches country-specific requirements
210/// 2. Verify mod-97 checksum
211pub fn validate_iban(value: &str) -> f32 {
212    let cleaned: String = value.chars().filter(|c| c.is_alphanumeric()).collect();
213
214    if cleaned.len() < 15 || cleaned.len() > 34 {
215        return 0.0;
216    }
217
218    // Check country code (first 2 chars must be letters)
219    let country_code: String = cleaned.chars().take(2).collect();
220    if !country_code.chars().all(|c| c.is_ascii_alphabetic()) {
221        return 0.0;
222    }
223
224    // Validate length for known countries
225    let expected_length = get_iban_length(&country_code);
226    if expected_length > 0 && cleaned.len() != expected_length {
227        return 0.5; // Partial match - wrong length for country
228    }
229
230    // Mod-97 checksum validation
231    if validate_iban_checksum(&cleaned) {
232        1.0
233    } else {
234        0.0
235    }
236}
237
238/// Get expected IBAN length for a country
239fn get_iban_length(country_code: &str) -> usize {
240    match country_code.to_uppercase().as_str() {
241        "GB" => 22,
242        "DE" => 22,
243        "FR" => 27,
244        "ES" => 24,
245        "IT" => 27,
246        "NL" => 18,
247        "BE" => 16,
248        "AT" => 20,
249        "CH" => 21,
250        "IE" => 22,
251        "PL" => 28,
252        "PT" => 25,
253        "SE" => 24,
254        "NO" => 15,
255        "DK" => 18,
256        "FI" => 18,
257        _ => 0, // Unknown country
258    }
259}
260
261/// Validate IBAN mod-97 checksum
262fn validate_iban_checksum(iban: &str) -> bool {
263    // Move first 4 chars to end
264    let rearranged = format!("{}{}", &iban[4..], &iban[..4]);
265
266    // Convert letters to numbers (A=10, B=11, etc.)
267    let mut numeric = String::new();
268    for c in rearranged.chars() {
269        if c.is_ascii_digit() {
270            numeric.push(c);
271        } else if c.is_ascii_alphabetic() {
272            let val = c.to_ascii_uppercase() as u32 - 'A' as u32 + 10;
273            numeric.push_str(&val.to_string());
274        }
275    }
276
277    // Calculate mod 97 (handle large numbers by processing in chunks)
278    let mut remainder: u64 = 0;
279    for chunk in numeric.as_bytes().chunks(9) {
280        let chunk_str: String = std::str::from_utf8(chunk).unwrap_or("0").to_string();
281        let combined = format!("{}{}", remainder, chunk_str);
282        remainder = combined.parse::<u64>().unwrap_or(0) % 97;
283    }
284
285    remainder == 1
286}
287
288/// Validate US Social Security Number format.
289///
290/// SSN rules:
291/// - Cannot start with 000, 666, or 900-999
292/// - Middle group cannot be 00
293/// - Last group cannot be 0000
294pub fn validate_us_ssn(value: &str) -> f32 {
295    let digits: String = value.chars().filter(|c| c.is_ascii_digit()).collect();
296
297    if digits.len() != 9 {
298        return 0.0;
299    }
300
301    let area: u32 = digits[0..3].parse().unwrap_or(0);
302    let group: u32 = digits[3..5].parse().unwrap_or(0);
303    let serial: u32 = digits[5..9].parse().unwrap_or(0);
304
305    // Invalid area numbers
306    if area == 0 || area == 666 || area >= 900 {
307        return 0.0;
308    }
309
310    // Invalid group or serial
311    if group == 0 || serial == 0 {
312        return 0.0;
313    }
314
315    1.0
316}
317
318/// Validate UK National Insurance Number format.
319///
320/// NINO format: 2 letters + 6 digits + 1 letter (A-D)
321/// First letter cannot be D, F, I, Q, U, V
322/// Second letter cannot be D, F, I, O, Q, U, V
323/// Prefixes BG, GB, NK, KN, TN, NT, ZZ are invalid
324pub fn validate_uk_nino(value: &str) -> f32 {
325    let cleaned: String = value
326        .chars()
327        .filter(|c| c.is_alphanumeric())
328        .collect::<String>()
329        .to_uppercase();
330
331    if cleaned.len() != 9 {
332        return 0.0;
333    }
334
335    let prefix: String = cleaned.chars().take(2).collect();
336    let suffix = cleaned.chars().last().unwrap_or('X');
337
338    // Check invalid prefixes
339    let invalid_prefixes = ["BG", "GB", "NK", "KN", "TN", "NT", "ZZ"];
340    if invalid_prefixes.contains(&prefix.as_str()) {
341        return 0.0;
342    }
343
344    // Check first letter restrictions
345    let first = prefix.chars().next().unwrap_or('X');
346    if "DFIQUV".contains(first) {
347        return 0.0;
348    }
349
350    // Check second letter restrictions
351    let second = prefix.chars().nth(1).unwrap_or('X');
352    if "DFIOQUV".contains(second) {
353        return 0.0;
354    }
355
356    // Check suffix is A-D
357    if !"ABCD".contains(suffix) {
358        return 0.0;
359    }
360
361    // Check middle 6 characters are digits
362    let middle: String = cleaned.chars().skip(2).take(6).collect();
363    if !middle.chars().all(|c| c.is_ascii_digit()) {
364        return 0.0;
365    }
366
367    1.0
368}
369
370/// Validate UK NHS Number using mod-11 checksum.
371pub fn validate_uk_nhs(value: &str) -> f32 {
372    let digits: Vec<u32> = value
373        .chars()
374        .filter(|c| c.is_ascii_digit())
375        .filter_map(|c| c.to_digit(10))
376        .collect();
377
378    if digits.len() != 10 {
379        return 0.0;
380    }
381
382    // Mod-11 checksum: multiply first 9 digits by weights 10-2
383    let weights = [10, 9, 8, 7, 6, 5, 4, 3, 2];
384    let sum: u32 = digits
385        .iter()
386        .take(9)
387        .zip(weights.iter())
388        .map(|(d, w)| d * w)
389        .sum();
390
391    let remainder = 11 - (sum % 11);
392    let check_digit = if remainder == 11 { 0 } else { remainder };
393
394    if check_digit == 10 {
395        return 0.0; // Invalid NHS number
396    }
397
398    if digits[9] == check_digit {
399        1.0
400    } else {
401        0.0
402    }
403}
404
405/// Validate ISBN-10 or ISBN-13 checksum.
406pub fn validate_isbn(value: &str) -> f32 {
407    let cleaned: String = value
408        .chars()
409        .filter(|c| c.is_ascii_digit() || *c == 'X' || *c == 'x')
410        .collect();
411
412    match cleaned.len() {
413        10 => validate_isbn10(&cleaned),
414        13 => validate_isbn13(&cleaned),
415        _ => 0.0,
416    }
417}
418
419fn validate_isbn10(isbn: &str) -> f32 {
420    let mut sum = 0;
421    for (i, c) in isbn.chars().enumerate() {
422        let digit = if c == 'X' || c == 'x' {
423            10
424        } else {
425            c.to_digit(10).unwrap_or(0)
426        };
427        sum += digit * (10 - i as u32);
428    }
429
430    if sum.is_multiple_of(11) {
431        1.0
432    } else {
433        0.0
434    }
435}
436
437fn validate_isbn13(isbn: &str) -> f32 {
438    let digits: Vec<u32> = isbn.chars().filter_map(|c| c.to_digit(10)).collect();
439
440    if digits.len() != 13 {
441        return 0.0;
442    }
443
444    let sum: u32 = digits
445        .iter()
446        .enumerate()
447        .map(|(i, &d)| if i % 2 == 0 { d } else { d * 3 })
448        .sum();
449
450    if sum.is_multiple_of(10) {
451        1.0
452    } else {
453        0.0
454    }
455}
456
457/// Validate IPv4 address octets are in valid range.
458pub fn validate_ip_address(value: &str) -> f32 {
459    let octets: Vec<&str> = value.split('.').collect();
460
461    if octets.len() != 4 {
462        return 0.0;
463    }
464
465    for octet in octets {
466        match octet.parse::<u32>() {
467            Ok(n) if n <= 255 => continue,
468            _ => return 0.0,
469        }
470    }
471
472    1.0
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn test_luhn_valid_cards() {
481        // Valid test card numbers
482        assert_eq!(validate_credit_card("4532015112830366"), 1.0);
483        assert_eq!(validate_credit_card("5425233430109903"), 1.0);
484        assert_eq!(validate_credit_card("374245455400126"), 1.0);
485    }
486
487    #[test]
488    fn test_luhn_invalid_cards() {
489        assert_eq!(validate_credit_card("4532015112830367"), 0.0);
490        assert_eq!(validate_credit_card("1234567890123456"), 0.0);
491    }
492
493    #[test]
494    fn test_valid_ssn() {
495        assert_eq!(validate_us_ssn("123-45-6789"), 1.0);
496        assert_eq!(validate_us_ssn("123456789"), 1.0);
497    }
498
499    #[test]
500    fn test_invalid_ssn() {
501        assert_eq!(validate_us_ssn("000-12-3456"), 0.0); // Invalid area
502        assert_eq!(validate_us_ssn("666-12-3456"), 0.0); // Invalid area
503        assert_eq!(validate_us_ssn("900-12-3456"), 0.0); // Invalid area
504        assert_eq!(validate_us_ssn("123-00-3456"), 0.0); // Invalid group
505        assert_eq!(validate_us_ssn("123-45-0000"), 0.0); // Invalid serial
506    }
507
508    #[test]
509    fn test_valid_uk_nino() {
510        assert_eq!(validate_uk_nino("AB123456C"), 1.0);
511        assert_eq!(validate_uk_nino("JG103759A"), 1.0);
512    }
513
514    #[test]
515    fn test_invalid_uk_nino() {
516        assert_eq!(validate_uk_nino("BG123456A"), 0.0); // Invalid prefix
517        assert_eq!(validate_uk_nino("DA123456A"), 0.0); // Invalid first letter
518        assert_eq!(validate_uk_nino("AB123456E"), 0.0); // Invalid suffix
519    }
520
521    #[test]
522    fn test_valid_iban() {
523        assert_eq!(validate_iban("GB82WEST12345698765432"), 1.0);
524        assert_eq!(validate_iban("DE89370400440532013000"), 1.0);
525    }
526
527    #[test]
528    fn test_invalid_iban() {
529        assert_eq!(validate_iban("GB82WEST12345698765433"), 0.0); // Bad checksum
530        assert_eq!(validate_iban("XX00000000000000"), 0.0);
531    }
532
533    #[test]
534    fn test_valid_isbn() {
535        assert_eq!(validate_isbn("0-306-40615-2"), 1.0); // ISBN-10
536        assert_eq!(validate_isbn("978-0-306-40615-7"), 1.0); // ISBN-13
537    }
538
539    #[test]
540    fn test_valid_ip() {
541        assert_eq!(validate_ip_address("192.168.1.1"), 1.0);
542        assert_eq!(validate_ip_address("0.0.0.0"), 1.0);
543        assert_eq!(validate_ip_address("255.255.255.255"), 1.0);
544    }
545
546    #[test]
547    fn test_invalid_ip() {
548        assert_eq!(validate_ip_address("256.1.1.1"), 0.0);
549        assert_eq!(validate_ip_address("1.1.1"), 0.0);
550    }
551
552    #[test]
553    fn test_http_basic_auth_requires_user_password() {
554        let ok = format!("{}{}", "dXNlcm5hbWU6", "cGFzc3dvcmQ=");
555        assert_eq!(validate_http_basic_auth(&ok), 1.0);
556        assert_eq!(validate_http_basic_auth("not-base64"), 0.0);
557        // "::::" is valid base64 alphabet but decodes without a colon pair.
558        assert_eq!(validate_http_basic_auth("QQ=="), 0.0);
559    }
560
561    #[test]
562    fn test_http_basic_auth_rejects_noncanonical_unused_bits() {
563        // "u:pas" encodes as dTpwYXM=; dTpwYXN= differs only in unused pad bits.
564        assert_eq!(validate_http_basic_auth("dTpwYXM="), 1.0);
565        assert_eq!(validate_http_basic_auth("dTpwYXN="), 0.0);
566        assert_eq!(
567            encode_base64(&decode_base64("dTpwYXM=").unwrap()),
568            "dTpwYXM="
569        );
570    }
571}