use std::sync::OnceLock;
use regex::Regex;
#[derive(Debug, Clone, PartialEq)]
pub struct Span {
pub start: usize,
pub end: usize,
pub ty: String,
pub confidence: f32,
}
impl Span {
#[must_use]
pub fn new(start: usize, end: usize, ty: &str, confidence: f32) -> Self {
Self {
start,
end,
ty: ty.to_owned(),
confidence,
}
}
}
#[must_use]
pub fn resolve_overlaps(mut spans: Vec<Span>) -> Vec<Span> {
spans.sort_by(|a, b| {
a.start
.cmp(&b.start)
.then(b.end.cmp(&a.end))
.then(cmp_confidence_desc(a, b))
});
let mut kept: Vec<Span> = Vec::with_capacity(spans.len());
let mut covered_to = 0usize;
for mut span in spans {
if span.end <= covered_to {
continue;
}
if span.start < covered_to {
span.start = covered_to;
}
covered_to = span.end;
kept.push(span);
}
kept
}
fn cmp_confidence_desc(a: &Span, b: &Span) -> std::cmp::Ordering {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
}
fn structured_rule(pat: &str) -> Regex {
Regex::new(pat).unwrap_or_else(|_| never_match().clone())
}
fn never_match() -> &'static Regex {
static NEVER: OnceLock<Regex> = OnceLock::new();
NEVER.get_or_init(|| match Regex::new(r"[^\s\S]") {
Ok(re) => re,
Err(_) => never_match().clone(),
})
}
const STRUCTURED_PATTERNS: [&str; 6] = [
r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}",
r"(?:\+?\d{1,3}[\s.\-]?)?(?:\(\d{3}\)|\d{3})[\s.\-]\d{3}[\s.\-]\d{4}\b",
r"\b\d{3}-\d{2}-\d{4}\b",
r"\b(?:\d{1,3}\.){3}\d{1,3}\b",
r"\b\d(?:[ \-]?\d){12,18}\b",
r"\b[A-Za-z]{2}\d{2}(?:[ ]?[A-Za-z0-9]){11,32}\b",
];
#[must_use]
pub fn structured_pattern_sources() -> Vec<String> {
STRUCTURED_PATTERNS
.iter()
.map(|p| (*p).to_owned())
.collect()
}
struct StructuredRules {
email: Regex,
phone: Regex,
ssn: Regex,
ip: Regex,
digits: Regex,
iban: Regex,
}
fn structured_rules() -> &'static StructuredRules {
static RULES: OnceLock<StructuredRules> = OnceLock::new();
RULES.get_or_init(|| StructuredRules {
email: structured_rule(STRUCTURED_PATTERNS[0]),
phone: structured_rule(STRUCTURED_PATTERNS[1]),
ssn: structured_rule(STRUCTURED_PATTERNS[2]),
ip: structured_rule(STRUCTURED_PATTERNS[3]),
digits: structured_rule(STRUCTURED_PATTERNS[4]),
iban: structured_rule(STRUCTURED_PATTERNS[5]),
})
}
#[must_use]
pub fn detect_structured(text: &str) -> Vec<Span> {
let r = structured_rules();
let mut spans = Vec::new();
for m in r.email.find_iter(text) {
spans.push(Span::new(m.start(), m.end(), "EMAIL", 0.95));
}
for m in r.phone.find_iter(text) {
spans.push(Span::new(m.start(), m.end(), "PHONE", 0.85));
}
for m in r.ssn.find_iter(text) {
spans.push(Span::new(m.start(), m.end(), "US_SSN", 0.9));
}
for m in r.ip.find_iter(text) {
if is_valid_ipv4(m.as_str()) {
spans.push(Span::new(m.start(), m.end(), "IP_ADDRESS", 0.85));
}
}
for m in r.digits.find_iter(text) {
if card_number_ok(m.as_str()) {
spans.push(Span::new(m.start(), m.end(), "CREDIT_CARD", 0.95));
}
}
for m in r.iban.find_iter(text) {
for (offset, len) in valid_ibans_in(m.as_str()) {
let start = m.start() + offset;
spans.push(Span::new(start, start + len, "IBAN", 0.95));
}
}
spans
}
fn is_valid_ipv4(s: &str) -> bool {
let octets: Vec<&str> = s.split('.').collect();
octets.len() == 4 && octets.iter().all(|o| o.parse::<u8>().is_ok())
}
fn card_number_ok(s: &str) -> bool {
let digits = digit_string(s);
issuer_prefix_ok(&digits) && luhn_digits_ok(digits.as_bytes())
}
fn digit_string(s: &str) -> String {
s.chars().filter(char::is_ascii_digit).collect()
}
fn issuer_prefix_ok(digits: &str) -> bool {
let len = digits.len();
let prefix2 = prefix(digits, 2);
let prefix3 = prefix(digits, 3);
let prefix4 = prefix(digits, 4);
let prefix6 = prefix(digits, 6);
(digits.starts_with('4') && matches!(len, 13 | 16 | 19))
|| (matches!(prefix2, Some(51..=55)) && len == 16)
|| (matches!(prefix4, Some(2221..=2720)) && len == 16)
|| (matches!(prefix2, Some(34 | 37)) && len == 15)
|| (matches!(prefix4, Some(6011)) && matches!(len, 16 | 19))
|| (matches!(prefix2, Some(65)) && matches!(len, 16 | 19))
|| (matches!(prefix3, Some(644..=649)) && matches!(len, 16 | 19))
|| (matches!(prefix6, Some(622126..=622925)) && matches!(len, 16 | 19))
|| (matches!(prefix3, Some(300..=305)) && len == 14)
|| (matches!(prefix2, Some(36 | 38 | 39)) && len == 14)
|| (matches!(prefix4, Some(3528..=3589)) && matches!(len, 16..=19))
}
fn prefix(s: &str, len: usize) -> Option<u32> {
s.get(..len)?.parse().ok()
}
#[cfg(test)]
fn luhn_ok(s: &str) -> bool {
let digits = digit_string(s);
luhn_digits_ok(digits.as_bytes())
}
fn luhn_digits_ok(digits: &[u8]) -> bool {
if digits.len() < 13 || digits.len() > 19 {
return false;
}
let mut sum = 0u32;
for (i, &d) in digits.iter().rev().enumerate() {
let mut d = u32::from(d - b'0');
if i % 2 == 1 {
d *= 2;
if d > 9 {
d -= 9;
}
}
sum += d;
}
sum % 10 == 0
}
const IBAN_LENGTHS: &[(&[u8; 2], usize)] = &[
(b"AD", 24),
(b"AE", 23),
(b"AL", 28),
(b"AT", 20),
(b"AZ", 28),
(b"BA", 20),
(b"BE", 16),
(b"BG", 22),
(b"BH", 22),
(b"BI", 27),
(b"BR", 29),
(b"BY", 28),
(b"CH", 21),
(b"CR", 22),
(b"CY", 28),
(b"CZ", 24),
(b"DE", 22),
(b"DJ", 27),
(b"DK", 18),
(b"DO", 28),
(b"EE", 20),
(b"EG", 29),
(b"ES", 24),
(b"FI", 18),
(b"FK", 18),
(b"FO", 18),
(b"FR", 27),
(b"GB", 22),
(b"GE", 22),
(b"GI", 23),
(b"GL", 18),
(b"GR", 27),
(b"GT", 28),
(b"HN", 28),
(b"HR", 21),
(b"HU", 28),
(b"IE", 22),
(b"IL", 23),
(b"IQ", 23),
(b"IS", 26),
(b"IT", 27),
(b"JO", 30),
(b"KW", 30),
(b"KZ", 20),
(b"LB", 28),
(b"LC", 32),
(b"LI", 21),
(b"LT", 20),
(b"LU", 20),
(b"LV", 21),
(b"LY", 25),
(b"MC", 27),
(b"MD", 24),
(b"ME", 22),
(b"MK", 19),
(b"MN", 20),
(b"MR", 27),
(b"MT", 31),
(b"MU", 30),
(b"NI", 28),
(b"NL", 18),
(b"NO", 15),
(b"OM", 23),
(b"PK", 24),
(b"PL", 28),
(b"PS", 29),
(b"PT", 25),
(b"QA", 29),
(b"RO", 24),
(b"RS", 22),
(b"RU", 33),
(b"SA", 24),
(b"SC", 31),
(b"SD", 18),
(b"SE", 24),
(b"SI", 19),
(b"SK", 24),
(b"SM", 27),
(b"SO", 23),
(b"ST", 25),
(b"SV", 28),
(b"TL", 23),
(b"TN", 24),
(b"TR", 26),
(b"UA", 29),
(b"VA", 22),
(b"VG", 24),
(b"XK", 20),
(b"YE", 30),
];
fn iban_length(country: [u8; 2]) -> Option<usize> {
let country = [
country[0].to_ascii_uppercase(),
country[1].to_ascii_uppercase(),
];
IBAN_LENGTHS
.iter()
.find(|(cc, _)| **cc == country)
.map(|(_, len)| *len)
}
fn valid_ibans_in(candidate: &str) -> Vec<(usize, usize)> {
let mut found = Vec::new();
let mut next = 0;
for start in iban_starts(candidate) {
if start < next {
continue;
}
if let Some(len) = valid_iban_prefix_len(&candidate[start..]) {
found.push((start, len));
next = start + len;
}
}
found
}
fn iban_starts(candidate: &str) -> impl Iterator<Item = usize> + '_ {
let bytes = candidate.as_bytes();
(0..bytes.len()).filter(move |&i| {
let after_boundary = i == 0 || bytes[i - 1] == b' ';
let anchor = bytes.get(i..i + 4).is_some_and(|w| {
w[0].is_ascii_alphabetic()
&& w[1].is_ascii_alphabetic()
&& w[2].is_ascii_digit()
&& w[3].is_ascii_digit()
});
after_boundary && anchor
})
}
fn valid_iban_prefix_len(candidate: &str) -> Option<usize> {
let mut end = candidate.len();
loop {
if iban_ok(&candidate[..end]) {
return Some(end);
}
let space = candidate[..end].rfind(' ')?;
end = candidate[..space].trim_end().len();
if end < 15 {
return None;
}
}
}
fn iban_ok(s: &str) -> bool {
let compact: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
let Some(&[c0, c1]) = compact.get(..2) else {
return false;
};
if iban_length([c0, c1]) != Some(compact.len()) {
return false;
}
let (head, tail) = compact.split_at(4);
let mut remainder = 0u32;
for &b in tail.iter().chain(head) {
let value = match b {
b'0'..=b'9' => u32::from(b - b'0'),
b'A'..=b'Z' => u32::from(b - b'A') + 10,
b'a'..=b'z' => u32::from(b - b'a') + 10,
_ => return false,
};
remainder = if value >= 10 {
(remainder * 100 + value) % 97
} else {
(remainder * 10 + value) % 97
};
}
remainder == 1
}
struct SecretRule {
ty: &'static str,
re: Regex,
}
const SECRET_PATTERNS: [(&str, &str); 8] = [
("AWS_ACCESS_KEY", r"AKIA[0-9A-Z]{16}"),
("GITHUB_TOKEN", r"ghp_[A-Za-z0-9]{36}"),
("STRIPE_KEY", r"sk_live_[A-Za-z0-9]{24,}"),
("OPENAI_KEY", r"sk-[A-Za-z0-9]{20,}"),
("GOOGLE_API_KEY", r"AIza[0-9A-Za-z\-_]{35}"),
(
"SLACK_WEBHOOK",
r"https://hooks\.slack\.com/services/[A-Za-z0-9/]+",
),
(
"PRIVATE_KEY",
r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
),
("PRIVATE_KEY", r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
];
const CREDENTIAL_PATTERNS: [&str; 2] = [
r#"(?i)\b(?:password|passwd|pwd|secret|api[_-]?key|token|access[_-]?key)\b\s*[:=]\s*["']?([^\s"'&]{6,})"#,
r"[a-z][a-z0-9+.\-]*://[^\s:/@]+:([^\s/@]+)@",
];
#[must_use]
pub fn secret_pattern_sources() -> Vec<String> {
SECRET_PATTERNS
.iter()
.map(|(_, p)| (*p).to_owned())
.chain(CREDENTIAL_PATTERNS.iter().map(|p| (*p).to_owned()))
.collect()
}
fn secret_rules() -> &'static [SecretRule] {
static RULES: OnceLock<Vec<SecretRule>> = OnceLock::new();
RULES.get_or_init(|| {
let mut rules = Vec::new();
for (ty, pat) in SECRET_PATTERNS {
if let Ok(re) = Regex::new(pat) {
rules.push(SecretRule { ty, re });
}
}
rules
})
}
fn labelled_secret_rule() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| structured_rule(CREDENTIAL_PATTERNS[0]))
}
fn url_credential_rule() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| structured_rule(CREDENTIAL_PATTERNS[1]))
}
#[must_use]
pub fn detect_secrets(text: &str) -> Vec<Span> {
let mut spans = Vec::new();
for rule in secret_rules() {
for m in rule.re.find_iter(text) {
spans.push(Span::new(m.start(), m.end(), rule.ty, 0.99));
}
}
for caps in labelled_secret_rule().captures_iter(text) {
if let Some(v) = caps.get(1) {
spans.push(Span::new(v.start(), v.end(), "SECRET", 0.9));
}
}
for caps in url_credential_rule().captures_iter(text) {
if let Some(v) = caps.get(1) {
spans.push(Span::new(v.start(), v.end(), "SECRET", 0.9));
}
}
spans.extend(entropy_backstop(text));
spans
}
fn shannon_entropy(s: &str) -> f64 {
if s.is_empty() {
return 0.0;
}
let mut counts = [0u32; 256];
for b in s.bytes() {
counts[b as usize] += 1;
}
let len = f64::from(u32::try_from(s.len()).unwrap_or(u32::MAX));
let mut entropy = 0.0;
for &c in &counts {
if c == 0 {
continue;
}
let p = f64::from(c) / len;
entropy -= p * p.log2();
}
entropy
}
fn is_base64ish(s: &str) -> bool {
s.bytes().all(|b| {
b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'=' || b == b'-' || b == b'_'
})
}
fn is_hexish(s: &str) -> bool {
s.len() >= 16 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
fn entropy_backstop(text: &str) -> Vec<Span> {
let mut spans = Vec::new();
for (start, tok) in split_with_offsets(text) {
let len = tok.len();
if len >= 20 && (is_hexish(tok) || is_base64ish(tok)) {
let ent = shannon_entropy(tok);
let hit = (is_hexish(tok) && ent >= 3.0) || (is_base64ish(tok) && ent >= 4.5);
if hit {
spans.push(Span::new(start, start + len, "SECRET", 0.7));
}
}
}
spans
}
fn split_with_offsets(text: &str) -> impl Iterator<Item = (usize, &str)> {
text.split_ascii_whitespace().map(move |tok| {
let start = tok.as_ptr() as usize - text.as_ptr() as usize;
(start, tok)
})
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "tests assert on known-good values"
)]
use super::*;
#[test]
fn shannon_entropy_of_uniform_is_high() {
assert!(shannon_entropy("abcdefghijklmnop") > 3.0);
assert!(shannon_entropy("aaaaaaaaaaaaaaaa") < 0.1);
}
#[test]
fn luhn_accepts_valid_card_rejects_random() {
assert!(luhn_ok("4111 1111 1111 1111"));
assert!(!luhn_ok("4111 1111 1111 1112"));
assert!(!luhn_ok("1234 5678 9012 3456"));
}
#[test]
fn card_detector_requires_plausible_issuer_not_just_luhn() {
let snowflake = "689802468376313879";
assert!(luhn_ok(snowflake));
assert!(!card_number_ok(snowflake));
assert!(!detect_structured(snowflake)
.iter()
.any(|s| s.ty == "CREDIT_CARD"));
for card in [
"4111 1111 1111 1111",
"5555 5555 5555 4444",
"3782 822463 10005",
"6011-1111-1111-1117",
"3530 1113 3330 0000",
] {
assert!(card_number_ok(card), "missed card: {card}");
assert!(
detect_structured(card).iter().any(|s| s.ty == "CREDIT_CARD"),
"missed card span: {card}"
);
}
}
#[test]
fn ipv4_validation_rejects_out_of_range() {
assert!(is_valid_ipv4("192.168.1.1"));
assert!(!is_valid_ipv4("999.1.1.1"));
}
#[test]
fn detects_email_and_ssn() {
let spans = detect_structured("mail me at a@b.com or use 123-45-6789");
assert!(spans.iter().any(|s| s.ty == "EMAIL"));
assert!(spans.iter().any(|s| s.ty == "US_SSN"));
}
#[test]
fn iban_accepts_valid_rejects_bad_checksum() {
assert!(iban_ok("GB82 WEST 1234 5698 7654 32"));
assert!(iban_ok("DE89 3704 0044 0532 0130 00"));
assert!(iban_ok("FR14 2004 1010 0505 0001 3M02 606"));
assert!(iban_ok("NL91ABNA0417164300"));
assert!(iban_ok("NO9386011117947"));
assert!(!iban_ok("GB82 WEST 1234 5698 7654 33"));
assert!(!iban_ok("GB82 WEST"));
}
#[test]
fn iban_rejects_wrong_length_for_country() {
assert!(!iban_ok("GB49 WEST 1234 5698 7654 321"));
assert!(!iban_ok("ZZ00 0000 0000 0000 0000 00"));
}
#[test]
fn detects_iban_across_countries() {
for iban in [
"pay to GB82 WEST 1234 5698 7654 32 today",
"send DE89 3704 0044 0532 0130 00 now",
"ref NL91ABNA0417164300 please",
"to BI13 20001 10001 00001234567 89 ok",
] {
let spans = detect_structured(iban);
assert!(spans.iter().any(|s| s.ty == "IBAN"), "missed: {iban}");
}
}
#[test]
fn iban_span_excludes_trailing_prose() {
let text = "iban GB82 WEST 1234 5698 7654 32 99999 done";
let span = detect_structured(text)
.into_iter()
.find(|s| s.ty == "IBAN")
.expect("IBAN detected");
assert_eq!(&text[span.start..span.end], "GB82 WEST 1234 5698 7654 32");
}
#[test]
fn iban_found_behind_iban_shaped_prefix() {
let text = "note AA00 GB82 WEST 1234 5698 7654 32 end";
let span = detect_structured(text)
.into_iter()
.find(|s| s.ty == "IBAN")
.expect("IBAN behind junk prefix");
assert_eq!(&text[span.start..span.end], "GB82 WEST 1234 5698 7654 32");
}
#[test]
fn detects_two_adjacent_ibans_in_one_candidate() {
let text = "pay BE68 5390 0754 7034 and NO9386011117947 now";
let ibans: Vec<_> = detect_structured(text)
.into_iter()
.filter(|s| s.ty == "IBAN")
.map(|s| text[s.start..s.end].to_owned())
.collect();
assert_eq!(ibans, ["BE68 5390 0754 7034", "NO9386011117947"]);
}
#[test]
fn iban_bad_checksum_not_flagged() {
let spans = detect_structured("ref GB82 WEST 1234 5698 7654 33 here");
assert!(!spans.iter().any(|s| s.ty == "IBAN"));
}
#[test]
fn iban_not_fragmented_by_overlapping_card_match() {
let kept = resolve_overlaps(detect_structured("iban GB82 WEST 1234 5698 7654 32 end"));
let ibans: Vec<_> = kept.iter().filter(|s| s.ty == "IBAN").collect();
assert_eq!(ibans.len(), 1);
assert!(!kept.iter().any(|s| s.ty == "CREDIT_CARD"));
}
#[test]
fn detects_aws_key_and_private_key_header() {
let spans =
detect_secrets("creds AKIAIOSFODNN7EXAMPLE and -----BEGIN RSA PRIVATE KEY-----");
assert!(spans.iter().any(|s| s.ty == "AWS_ACCESS_KEY"));
assert!(spans.iter().any(|s| s.ty == "PRIVATE_KEY"));
}
#[test]
fn higher_confidence_span_wins_overlap() {
let kept = resolve_overlaps(vec![
Span::new(0, 16, "PHONE", 0.5),
Span::new(0, 16, "CREDIT_CARD", 0.95),
]);
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].ty, "CREDIT_CARD");
}
#[test]
fn nested_high_confidence_does_not_uncover_wider_region() {
let kept = resolve_overlaps(vec![
Span::new(0, 20, "WIDE", 0.5),
Span::new(5, 10, "NARROW", 0.95),
Span::new(12, 18, "TRAIL", 0.9),
]);
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].start, 0);
assert_eq!(kept[0].end, 20);
assert_eq!(kept[0].ty, "WIDE");
}
#[test]
fn partial_overlap_clips_to_cover_the_union() {
let kept = resolve_overlaps(vec![Span::new(0, 10, "A", 0.9), Span::new(6, 16, "B", 0.9)]);
assert_eq!(kept.len(), 2);
assert_eq!((kept[0].start, kept[0].end), (0, 10));
assert_eq!((kept[1].start, kept[1].end), (10, 16));
}
#[test]
fn kept_spans_cover_every_flagged_byte_without_overlap() {
let input = vec![
Span::new(2, 9, "X", 0.4),
Span::new(0, 5, "Y", 0.8),
Span::new(20, 25, "Z", 0.6),
Span::new(7, 22, "W", 0.5),
];
let flagged: std::collections::BTreeSet<usize> =
input.iter().flat_map(|s| s.start..s.end).collect();
let kept = resolve_overlaps(input);
for win in kept.windows(2) {
assert!(win[0].end <= win[1].start, "kept spans overlap: {kept:?}");
}
let covered: std::collections::BTreeSet<usize> =
kept.iter().flat_map(|s| s.start..s.end).collect();
assert_eq!(flagged, covered, "a flagged byte was left uncovered");
}
}