use std::sync::LazyLock;
use fancy_regex::Regex;
use serde::Deserialize;
use super::heuristics;
const TABLE: &str = include_str!("../../signatures/patterns.toml");
#[derive(Debug, Deserialize)]
struct Table {
pattern: Vec<Entry>,
}
#[derive(Debug, Deserialize)]
struct Entry {
#[serde(rename = "type")]
kind: String,
description: String,
regex: String,
flags: String,
key_group: Option<usize>,
value_group: usize,
confidence: Rule,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq)]
#[serde(tag = "kind", rename_all = "lowercase")]
enum Rule {
Fixed {
level: Confidence,
},
Length {
high: usize,
medium: usize,
},
Jwt {
matched: Confidence,
unmatched: Confidence,
},
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Confidence {
Low,
Medium,
High,
}
pub(crate) struct Pattern {
pub(crate) kind: String,
pub(crate) description: String,
pub(crate) regex: Regex,
pub(crate) prefilter: Option<regex::Regex>,
pub(crate) key_group: Option<usize>,
pub(crate) value_group: usize,
rule: Rule,
}
impl Pattern {
pub(crate) fn confidence(&self, value: &str) -> Confidence {
match self.rule {
Rule::Fixed { level } => level,
Rule::Length { high, medium } => heuristics::confidence_by_length(value, high, medium),
Rule::Jwt { matched, unmatched } => {
if heuristics::is_jwt_shaped(value) {
matched
} else {
unmatched
}
}
}
}
}
const BACKTRACK_LIMIT: usize = 100_000_000;
pub(crate) static PATTERNS: LazyLock<Vec<Pattern>> = LazyLock::new(|| {
let table: Table = toml::from_str(TABLE).expect("the embedded pattern table parses");
table
.pattern
.into_iter()
.map(|entry| {
let translated = to_rust_syntax(&entry.regex, &entry.flags);
Pattern {
prefilter: relaxed(&translated),
regex: fancy_regex::RegexBuilder::new(&translated)
.backtrack_limit(BACKTRACK_LIMIT)
.build()
.expect("a pattern from the shared table compiles"),
kind: entry.kind,
description: entry.description,
key_group: entry.key_group,
value_group: entry.value_group,
rule: entry.confidence,
}
})
.collect()
});
fn relaxed(source: &str) -> Option<regex::Regex> {
let bytes: Vec<char> = source.chars().collect();
let mut out = String::with_capacity(source.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == '\\' && index + 1 < bytes.len() {
out.push(bytes[index]);
out.push(bytes[index + 1]);
index += 2;
continue;
}
if bytes[index] == '(' && is_lookaround(&bytes, index) {
index = closing_paren(&bytes, index)? + 1;
continue;
}
out.push(bytes[index]);
index += 1;
}
regex::RegexBuilder::new(&out)
.size_limit(1 << 22)
.build()
.ok()
}
fn is_lookaround(chars: &[char], open: usize) -> bool {
let rest: String = chars[open..].iter().take(4).collect();
rest.starts_with("(?=")
|| rest.starts_with("(?!")
|| rest.starts_with("(?<=")
|| rest.starts_with("(?<!")
}
fn closing_paren(chars: &[char], open: usize) -> Option<usize> {
let mut depth = 0;
let mut in_class = false;
let mut index = open;
while index < chars.len() {
match chars[index] {
'\\' => index += 1,
'[' if !in_class => in_class = true,
']' if in_class => in_class = false,
'(' if !in_class => depth += 1,
')' if !in_class => {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
_ => {}
}
index += 1;
}
None
}
const ASCII_BOUNDARY: &str =
r"(?:(?<![A-Za-z0-9_])(?=[A-Za-z0-9_])|(?<=[A-Za-z0-9_])(?![A-Za-z0-9_]))";
fn to_rust_syntax(source: &str, flags: &str) -> String {
let space = format!("[{}]", heuristics::JS_SPACE_CLASS);
let non_space = format!("[^{}]", heuristics::JS_SPACE_CLASS);
let mut out = String::with_capacity(source.len() + 32);
if flags.contains('i') {
out.push_str("(?i)");
}
let mut chars = source.chars().peekable();
while let Some(character) = chars.next() {
if character != '\\' {
out.push(character);
continue;
}
match chars.next() {
Some('s') => out.push_str(&space),
Some('S') => out.push_str(&non_space),
Some('b') => out.push_str(ASCII_BOUNDARY),
Some(escaped) => {
out.push('\\');
out.push(escaped);
}
None => out.push('\\'),
}
}
out
}
#[cfg(test)]
mod prefilter_soundness {
use super::*;
#[test]
fn the_relaxed_pattern_matches_whenever_the_real_one_does() {
let corpus: Vec<(&str, &str)> = crate::detect::corpus::documents().collect();
for pattern in PATTERNS.iter() {
let Some(prefilter) = &pattern.prefilter else {
continue;
};
for (name, content) in &corpus {
let real = pattern.regex.find(content).ok().flatten().is_some();
if real {
assert!(
prefilter.is_match(content),
"the {} prefilter would have suppressed a real match in {name}",
pattern.kind
);
}
}
}
}
#[test]
fn a_lookaround_only_rejection_still_reaches_the_real_pattern() {
for text in [
"aws_secret_access_key = \"AKIAIOSFODNN7EXAMPLEAKIAIOSFODNN7EXAMPLE\"",
"password: \"hunter2hunter2\"",
"api_key='abcdefghijklmnopqrstuvwxyz'",
"Authorization: bearer abcdefghijklmnopqrstuvwxyz",
"eyJhbGciOi.eyJzdWIiOi.SflKxwRJSM",
"postgres://user:pass@host/db",
"-----BEGIN RSA PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----",
"square-cli --access-token sq0atp-EXAMPLEnotarealsquare00",
"https://x.blob.core.windows.invalid/c/b?sv=2024-11-04&sp=r&sig=EXAMPLEnotarealazuresas0000",
] {
for pattern in PATTERNS.iter() {
let Some(prefilter) = &pattern.prefilter else {
continue;
};
if pattern.regex.find(text).ok().flatten().is_some() {
assert!(
prefilter.is_match(text),
"the {} prefilter would have suppressed {text:?}",
pattern.kind
);
}
}
}
}
#[test]
fn no_generated_document_is_suppressed_by_its_prefilter() {
const KEYS: [&str; 18] = [
"password",
"DATABASE_PASSWORD",
"passwd",
"pwd",
"api_key",
"apiKey",
"aws_secret_access_key",
"secretkey",
"access_token",
"refresh_token",
"oauth2_token",
"jwt",
"json_web_token",
"token",
"session_id",
"cookie",
"connection_string",
"accountkey",
];
const VALUES: [&str; 21] = [
"hunter2hunter2",
"aB3xY7zQ9mK2pL5vN8wR4tS6",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"AKIAIOSFODNN7EXAMPLE",
"ghp_1234567890abcdefghijklmnopqrstuvwxyz",
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N",
"postgres://user:pass@db.example.invalid/app",
"Server=prod;Database=app;Uid=admin;Pwd=secret123;",
"sk-ant-api03-EXAMPLEnotarealanthropickey00000",
"sk-proj-EXAMPLEnotarealopenaikey000000000000",
"glpat-EXAMPLEnotarealgitlab00",
"SG.EXAMPLEnotarealsendgridselector1234.EXAMPLEnotarealsendgridsecret00000",
"key-deadbeefdeadbeefdeadbeefdeadbeefface",
"sntrys_EXAMPLEnotarealsentryorgauthtoken00000000",
"npm_EXAMPLEnotarealnpmtoken00000000000000000",
"pypi-AgENOTAREALpypitokenEXAMPLE0000000000000000000000000000",
"dckr_pat_EXAMPLEnotarealdockertoken00000000",
"hvs.EXAMPLEnotarealvaulttoken00",
"EXAMPLEnotar.atlasv1.EXAMPLEnotarealterraformcloudtoken000000000000",
"sbp_deadbeefdeadbeefdeadbeefdeadbeefdeadbeefface",
concat!("shpat_", "deadbeef", "deadbeef", "deadbeef", "deadbeef"),
];
const WRAPPERS: [&str; 12] = [
"{key}={value}",
"{key} = {value}",
"{key} = \"{value}\"",
"{key} = '{value}'",
" \"{key}\": \"{value}\",",
"{key}: {value}",
"// {key}={value}",
"# {key}={value}",
"<config {key}=\"{value}\" />",
"const c = {{ {key}: '{value}' }};",
"Authorization: Bearer {value} # {key}",
"\u{fc}nicode \u{1f3af} {key}={value}\u{feff}",
];
let mut checked = 0_usize;
for key in KEYS {
for value in VALUES {
for wrapper in WRAPPERS {
let document = wrapper
.replace("{key}", key)
.replace("{value}", value)
.replace("{{", "{")
.replace("}}", "}");
for pattern in PATTERNS.iter() {
let Some(prefilter) = &pattern.prefilter else {
continue;
};
checked += 1;
if pattern.regex.find(&document).ok().flatten().is_none() {
continue;
}
assert!(
prefilter.is_match(&document),
"the {} prefilter would have suppressed a real match, so this \
credential would go unreported and the scan would still exit \
clean:\n {document:?}",
pattern.kind
);
}
}
}
}
assert!(
checked > 20_000,
"only {checked} pattern/document pairs ran"
);
}
#[test]
fn every_pattern_has_a_prefilter() {
let without: Vec<&str> = PATTERNS
.iter()
.filter(|pattern| pattern.prefilter.is_none())
.map(|pattern| pattern.kind.as_str())
.collect();
assert!(without.is_empty(), "no prefilter for: {without:?}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_embedded_table_loads_and_compiles() {
assert!(!PATTERNS.is_empty());
assert_eq!(PATTERNS.len(), 34, "the corpus carries 34 patterns");
}
#[test]
fn the_issuer_prefixed_patterns_lead_the_table() {
const ISSUERS: [&str; 15] = [
"anthropic-key",
"openai-key",
"gitlab-token",
"sendgrid-key",
"mailgun-key",
"sentry-token",
"npm-token",
"pypi-token",
"docker-token",
"vault-token",
"terraform-token",
"supabase-key",
"shopify-token",
"square-token",
"azure-sas",
];
let leading: Vec<&str> = PATTERNS
.iter()
.take_while(|pattern| pattern.key_group.is_none())
.map(|pattern| pattern.kind.as_str())
.collect();
assert_eq!(leading, ISSUERS);
}
#[test]
fn the_specific_key_patterns_run_before_the_generic_one() {
let position = |kind: &str| {
PATTERNS
.iter()
.position(|pattern| pattern.kind == kind)
.unwrap_or_else(|| panic!("{kind} is missing from the table"))
};
for specific in ["access-token", "refresh-token", "oauth-token", "jwt"] {
assert!(
position(specific) < position("token"),
"{specific} must precede the generic token pattern"
);
}
}
#[test]
fn every_pattern_names_a_group_that_could_exist() {
for pattern in PATTERNS.iter() {
let groups = pattern.regex.captures_len();
assert!(
pattern.value_group < groups,
"{}: value group {} but only {groups} groups",
pattern.kind,
pattern.value_group
);
if let Some(key_group) = pattern.key_group {
assert!(
key_group < groups,
"{}: key group out of range",
pattern.kind
);
}
}
}
#[test]
fn a_case_insensitive_flag_becomes_an_inline_group() {
assert!(to_rust_syntax("abc", "dgi").starts_with("(?i)"));
assert!(!to_rust_syntax("abc", "dg").starts_with("(?i)"));
}
#[test]
fn word_boundaries_become_ascii_like_javascripts() {
assert_eq!(to_rust_syntax(r"\bx", "dg"), format!("{ASCII_BOUNDARY}x"));
}
#[test]
fn an_ascii_boundary_matches_before_a_non_ascii_letter() {
let translated = Regex::new(&to_rust_syntax(r"\bpassword\b", "dg")).expect("compiles");
assert!(
translated
.is_match("passwordé")
.expect("no backtrack limit"),
"JavaScript sees a boundary here, so this must too"
);
let unicode = Regex::new(r"\bpassword\b").expect("compiles");
assert!(
!unicode.is_match("passwordé").expect("no backtrack limit"),
"the untranslated form is what would have missed it"
);
}
#[test]
fn whitespace_classes_use_javascripts_set() {
let translated = Regex::new(&to_rust_syntax(r"a\sb", "dg")).expect("compiles");
assert!(translated.is_match("a\u{feff}b").expect("no limit"));
let unicode = Regex::new(r"a\sb").expect("compiles");
assert!(!unicode.is_match("a\u{feff}b").expect("no limit"));
}
#[test]
fn other_escapes_are_left_alone() {
assert_eq!(to_rust_syntax(r"a\.b\+c", "dg"), r"a\.b\+c");
}
#[test]
fn confidence_rules_answer_for_every_pattern() {
for pattern in PATTERNS.iter() {
let level = pattern.confidence("aB3xY7zQ9mK2pL5vN8wR4tS6");
assert!(matches!(
level,
Confidence::Low | Confidence::Medium | Confidence::High
));
}
}
}