use std::collections::BTreeSet;
use unicode_normalization::UnicodeNormalization;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum EvasionKind {
CyrillicHomoglyph,
GreekHomoglyph,
Fullwidth,
ZeroWidth,
RTLOverride,
Decomposed,
Suspicious,
}
#[derive(Debug, Clone)]
pub(crate) struct EvasionMatch {
pub position: usize,
pub kind: EvasionKind,
pub char: char,
pub replacement: Option<char>,
}
pub(crate) fn detect_unicode_attacks(text: &str) -> Vec<EvasionMatch> {
let mut matches = Vec::new();
for (byte_pos, ch) in text.char_indices() {
if let Some(latin) = cyrillic_to_latin(ch) {
matches.push(EvasionMatch {
position: byte_pos,
kind: EvasionKind::CyrillicHomoglyph,
char: ch,
replacement: Some(latin),
});
continue;
}
if let Some(latin) = greek_to_latin(ch) {
matches.push(EvasionMatch {
position: byte_pos,
kind: EvasionKind::GreekHomoglyph,
char: ch,
replacement: Some(latin),
});
continue;
}
if let Some(latin) = unicode_casefold_to_ascii(ch) {
matches.push(EvasionMatch {
position: byte_pos,
kind: EvasionKind::Suspicious,
char: ch,
replacement: Some(latin),
});
continue;
}
if is_fullwidth(ch) {
matches.push(EvasionMatch {
position: byte_pos,
kind: EvasionKind::Fullwidth,
char: ch,
replacement: Some(fullwidth_to_ascii(ch)),
});
continue;
}
if is_zero_width(ch) {
matches.push(EvasionMatch {
position: byte_pos,
kind: EvasionKind::ZeroWidth,
char: ch,
replacement: None,
});
continue;
}
if is_rtl_override(ch) {
matches.push(EvasionMatch {
position: byte_pos,
kind: EvasionKind::RTLOverride,
char: ch,
replacement: None,
});
continue;
}
if is_combining_mark(ch) {
matches.push(EvasionMatch {
position: byte_pos,
kind: EvasionKind::Decomposed,
char: ch,
replacement: None,
});
continue;
}
if is_unicode_separator_evasion(ch) {
matches.push(EvasionMatch {
position: byte_pos,
kind: EvasionKind::Suspicious,
char: ch,
replacement: None,
});
continue;
}
if is_ascii_evasion_control(ch) {
matches.push(EvasionMatch {
position: byte_pos,
kind: EvasionKind::Suspicious,
char: ch,
replacement: None,
});
continue;
}
}
matches
}
pub(crate) fn normalize_homoglyphs(text: &str) -> std::borrow::Cow<'_, str> {
match ascii_normalization_scan(text.as_bytes()) {
AsciiNormalizationScan::CleanAscii => return std::borrow::Cow::Borrowed(text),
AsciiNormalizationScan::EvasiveAscii | AsciiNormalizationScan::NonAscii => {}
}
normalize_evasive_chars(text)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum NormalizedChar {
Keep,
Replace(char),
Drop,
}
pub(crate) fn normalized_char(ch: char) -> NormalizedChar {
if let Some(latin) = cyrillic_to_latin(ch) {
return NormalizedChar::Replace(latin);
}
if let Some(latin) = greek_to_latin(ch) {
return NormalizedChar::Replace(latin);
}
if let Some(latin) = unicode_casefold_to_ascii(ch) {
return NormalizedChar::Replace(latin);
}
if is_fullwidth(ch) {
return NormalizedChar::Replace(fullwidth_to_ascii(ch));
}
if is_zero_width(ch)
|| is_rtl_override(ch)
|| is_unicode_separator_evasion(ch)
|| is_combining_mark(ch)
|| is_ascii_evasion_control(ch)
{
return NormalizedChar::Drop;
}
NormalizedChar::Keep
}
#[inline]
fn unicode_casefold_to_ascii(ch: char) -> Option<char> {
match ch {
'\u{017f}' => Some('s'), '\u{212a}' => Some('K'), _ => None,
}
}
fn normalize_evasive_chars(text: &str) -> std::borrow::Cow<'_, str> {
let mut normalized: Option<String> = None;
for (byte_pos, ch) in text.char_indices() {
match normalized_char(ch) {
NormalizedChar::Keep => {
if let Some(out) = &mut normalized {
out.push(ch);
}
}
NormalizedChar::Replace(replacement) => {
let out = normalized.get_or_insert_with(|| {
let mut out = String::with_capacity(text.len());
out.push_str(&text[..byte_pos]);
out
});
out.push(replacement);
}
NormalizedChar::Drop => {
normalized.get_or_insert_with(|| {
let mut out = String::with_capacity(text.len());
out.push_str(&text[..byte_pos]);
out
});
}
}
}
normalized
.map(std::borrow::Cow::Owned)
.unwrap_or(std::borrow::Cow::Borrowed(text)) }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AsciiNormalizationScan {
CleanAscii,
EvasiveAscii,
NonAscii,
}
fn ascii_normalization_scan(bytes: &[u8]) -> AsciiNormalizationScan {
for &byte in bytes {
if byte >= 0x80 {
return AsciiNormalizationScan::NonAscii;
}
if is_ascii_evasion_control_byte(byte) {
return AsciiNormalizationScan::EvasiveAscii;
}
}
AsciiNormalizationScan::CleanAscii
}
#[inline]
fn is_ascii_evasion_control_byte(b: u8) -> bool {
(b < 0x20 || b == 0x7F) && !matches!(b, b'\n' | b'\r' | b'\t')
}
pub(crate) fn full_normalize(text: &str) -> String {
let nfc: String = text.nfc().collect();
normalize_homoglyphs(&nfc).into_owned()
}
#[derive(serde::Deserialize)]
struct EvasionAnchorFile {
anchors: Vec<String>,
}
static EVASION_ANCHORS: std::sync::LazyLock<Vec<String>> = std::sync::LazyLock::new(|| {
match parse_evasion_anchors(include_str!("../data/evasion-anchors.toml")) {
Ok(anchors) => anchors,
Err(error) => {
panic!(
"crates/scanner/data/evasion-anchors.toml is invalid: {error}. \
Fix the bundled Tier-B evasion anchors; refusing to run without \
split-credential evasion normalization truth."
)
}
}
});
pub(crate) fn parse_evasion_anchors(raw: &str) -> Result<Vec<String>, String> {
let parsed: EvasionAnchorFile =
toml::from_str(raw).map_err(|error| format!("invalid evasion-anchors.toml: {error}"))?;
let mut seen = BTreeSet::new();
let mut anchors = Vec::with_capacity(parsed.anchors.len());
for raw_anchor in parsed.anchors {
let anchor = raw_anchor.trim();
if anchor.is_empty() {
return Err("evasion anchor entries must not be empty".to_string());
}
if !seen.insert(anchor.to_string()) {
return Err(format!("duplicate evasion anchor {anchor:?}"));
}
anchors.push(anchor.to_string());
}
if anchors.is_empty() {
return Err("evasion anchors must contain at least one entry".to_string());
}
Ok(anchors)
}
static EVASION_ANCHOR_AC: std::sync::LazyLock<aho_corasick::AhoCorasick> =
std::sync::LazyLock::new(|| {
let anchors = &*EVASION_ANCHORS;
assert!(
!anchors.is_empty(),
"EVASION_ANCHORS is empty; parse_evasion_anchors must reject empty anchor sets"
);
match aho_corasick::AhoCorasick::new(anchors) {
Ok(automaton) => automaton,
Err(error) => panic!(
"failed to build the evasion-anchor Aho-Corasick automaton from \
embedded Tier-B anchors: {error}. This is a build/data bug in \
crates/scanner/data/evasion-anchors.toml; refusing to run with \
split-credential evasion normalization silently disabled."
),
}
});
#[inline]
fn is_credential_body_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'_' | b'+' | b'/' | b'=' | b'.' | b'-')
}
#[inline]
fn is_anchor_start_blocked_by(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
#[inline]
fn is_interior_control(b: u8) -> bool {
matches!(b, b'\t' | b'\r')
}
pub(crate) fn strip_interior_evasion_controls(text: &str) -> std::borrow::Cow<'_, str> {
let bytes = text.as_bytes();
if bytes.len() < 3 {
return std::borrow::Cow::Borrowed(text);
}
let has_candidate = memchr::memchr2_iter(b'\t', b'\r', &bytes[1..bytes.len() - 1]).any(|i| {
let i = i + 1;
is_credential_body_byte(bytes[i - 1]) && is_credential_body_byte(bytes[i + 1])
});
if !has_candidate {
return std::borrow::Cow::Borrowed(text);
}
let ac = &*EVASION_ANCHOR_AC;
const MAX_BODY_WINDOW: usize = 256;
let mut drop_indices = Vec::new();
for mat in ac.find_iter(text) {
let start = mat.start();
let end = mat.end();
if start > 0 && is_anchor_start_blocked_by(bytes[start - 1]) {
continue;
}
let window_end = end.saturating_add(MAX_BODY_WINDOW).min(bytes.len());
let mut j = end;
while j < window_end {
let b = bytes[j];
if is_credential_body_byte(b) {
j += 1;
} else if is_interior_control(b)
&& j + 1 < bytes.len()
&& is_credential_body_byte(bytes[j + 1])
{
drop_indices.push(j);
j += 1;
} else {
break;
}
}
}
if drop_indices.is_empty() {
return std::borrow::Cow::Borrowed(text);
}
drop_indices.sort_unstable();
drop_indices.dedup();
let mut out = Vec::with_capacity(bytes.len() - drop_indices.len());
let mut keep_start = 0;
for drop_index in drop_indices {
out.extend_from_slice(&bytes[keep_start..drop_index]);
keep_start = drop_index + 1;
}
out.extend_from_slice(&bytes[keep_start..]);
String::from_utf8(out)
.map(std::borrow::Cow::Owned)
.unwrap_or(std::borrow::Cow::Borrowed(text)) }
pub(crate) fn contains_evasion(text: &str) -> bool {
contains_ascii_evasion(text.as_bytes())
|| text
.chars()
.any(|ch| !matches!(normalized_char(ch), NormalizedChar::Keep))
}
fn contains_ascii_evasion(bytes: &[u8]) -> bool {
bytes.iter().any(|&b| is_ascii_evasion_control_byte(b))
}
fn is_ascii_evasion_control(ch: char) -> bool {
ch.is_ascii() && is_ascii_evasion_control_byte(ch as u8)
}
pub(crate) fn cyrillic_to_latin(ch: char) -> Option<char> {
match ch {
'а' => Some('a'), 'е' => Some('e'), 'і' => Some('i'), 'ј' => Some('j'), 'о' => Some('o'), 'р' => Some('p'), 'с' => Some('c'), 'у' => Some('y'), 'х' => Some('x'), 'ѕ' => Some('s'), 'һ' => Some('h'), 'ɡ' => Some('g'), 'ї' => Some('i'), 'к' => Some('k'), 'т' => Some('t'), 'А' => Some('A'), 'В' => Some('B'), 'Е' => Some('E'), 'І' => Some('I'), 'Ј' => Some('J'), 'К' => Some('K'), 'М' => Some('M'), 'Н' => Some('H'), 'О' => Some('O'), 'Р' => Some('P'), 'С' => Some('C'), 'Ѕ' => Some('S'), 'Т' => Some('T'), 'Х' => Some('X'), 'Ү' => Some('Y'), 'Ї' => Some('I'), _ => None,
}
}
pub(crate) fn greek_to_latin(ch: char) -> Option<char> {
match ch {
'α' => Some('a'), 'β' => Some('b'), 'ε' => Some('e'), 'ι' => Some('i'), 'κ' => Some('k'), 'ν' => Some('v'), 'ο' => Some('o'), 'ρ' => Some('p'), 'τ' => Some('t'), 'υ' => Some('u'), 'χ' => Some('x'), 'ω' => Some('w'), 'Α' => Some('A'), 'Β' => Some('B'), 'Ε' => Some('E'), 'Η' => Some('H'), 'Ι' => Some('I'), 'Κ' => Some('K'), 'Μ' => Some('M'), 'Ν' => Some('N'), 'Ο' => Some('O'), 'Ρ' => Some('P'), 'Τ' => Some('T'), 'Υ' => Some('Y'), 'Χ' => Some('X'), 'Ζ' => Some('Z'), _ => None,
}
}
pub(crate) fn is_fullwidth(ch: char) -> bool {
matches!(ch, '\u{FF01}'..='\u{FF5E}')
}
pub(crate) fn fullwidth_to_ascii(ch: char) -> char {
if is_fullwidth(ch) {
let code = ch as u32;
std::char::from_u32(code - 0xFEE0).map_or(ch, |ascii| ascii)
} else {
ch
}
}
pub(crate) fn is_evasion_char(ch: char) -> bool {
is_zero_width(ch) || is_rtl_override(ch)
}
pub(crate) fn is_zero_width(ch: char) -> bool {
matches!(
ch,
'\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' | '\u{2060}'..='\u{2064}' | '\u{2065}' | '\u{180E}' | '\u{180B}'..='\u{180D}' |
'\u{180F}' |
'\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{00AD}' | '\u{2066}' | '\u{2067}' | '\u{2068}' | '\u{2069}' | '\u{206A}'..='\u{206F}' | '\u{115F}' | '\u{1160}' | '\u{3164}' | '\u{FFA0}' | '\u{1BCA0}'..='\u{1BCA3}' | '\u{1D173}'..='\u{1D17A}' | '\u{FFF0}'..='\u{FFF8}' | '\u{FFF9}'..='\u{FFFB}' | '\u{E0000}'..='\u{E007F}' )
}
fn is_unicode_separator_evasion(ch: char) -> bool {
matches!(
ch,
'\u{0085}' | '\u{00A0}' | '\u{1680}' | '\u{2000}'
..='\u{200A}' | '\u{2028}' | '\u{2029}' | '\u{202F}' | '\u{205F}' | '\u{3000}' )
}
pub(crate) fn is_combining_mark(ch: char) -> bool {
!ch.is_ascii() && unicode_normalization::char::is_combining_mark(ch)
}
pub(crate) fn is_rtl_override(ch: char) -> bool {
matches!(
ch,
'\u{202E}' | '\u{202D}' | '\u{202A}' | '\u{202B}' | '\u{202C}' )
}