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) 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| Pattern {
regex: fancy_regex::RegexBuilder::new(&to_rust_syntax(&entry.regex, &entry.flags))
.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()
});
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 tests {
use super::*;
#[test]
fn the_embedded_table_loads_and_compiles() {
assert!(!PATTERNS.is_empty());
assert_eq!(PATTERNS.len(), 19, "the corpus carries 19 patterns");
}
#[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
));
}
}
}