use std::collections::HashMap;
use super::{Pattern, Scrubber};
use crate::config::{DEFAULT_MIN_CHAR_CLASSES, DEFAULT_MIN_ENTROPY};
impl Scrubber {
pub(super) fn should_skip_match(
&self,
pattern: &Pattern,
matched: &str,
text: &str,
end: usize,
) -> bool {
if self.contains_allowed_key(matched)
|| self.matches_allow_values(pattern, matched, text, end)
|| is_pii_lookalike(&pattern.name, matched, text, end - matched.len())
{
return true;
}
if !pattern.includes_key {
return false;
}
if matches!(text.as_bytes().get(end), Some(b'(' | b'[')) {
return true;
}
let value = value_of(matched);
let key = key_of(matched);
if !key.is_empty() && key.eq_ignore_ascii_case(value) {
return true;
}
if looks_like_identifier(value)
|| (looks_like_code_reference(value) && !has_random_segment(value))
{
return true;
}
if has_code_style_separator(matched)
&& looks_like_code_expression(value)
&& !has_random_segment(value)
{
return true;
}
if (1..=12).contains(&value.len()) && value.bytes().all(|c| c.is_ascii_lowercase()) {
return true;
}
pattern.is_heuristic && self.should_skip_heuristic_match(matched, value, text, end)
}
fn should_skip_heuristic_match(
&self,
matched: &str,
value: &str,
text: &str,
end: usize,
) -> bool {
let key = key_of(matched);
is_id_key(key)
|| is_inside_url(text, end - matched.len())
|| (!self.looks_random(value) && !is_long_hex_under_key_name(key, value))
}
fn looks_random(&self, value: &str) -> bool {
if value.contains("://") {
return false;
}
let decoded = percent_decode(value);
if decoded != value.as_bytes() && decoded.iter().any(|&c| c < 0x21 || c == 0x7f) {
return false;
}
let decoded_text = String::from_utf8_lossy(&decoded);
let thresholds = &self.heuristic_thresholds;
let len = decoded_text.chars().count();
(thresholds.min_length..=thresholds.max_length).contains(&len)
&& char_class_count(&decoded_text) >= thresholds.min_char_classes
&& shannon_entropy(&decoded_text) >= thresholds.min_entropy
}
fn contains_allowed_key(&self, matched: &str) -> bool {
if self.allowed_keys_upper.is_empty() {
return false;
}
let upper = matched.to_uppercase();
self.allowed_keys_upper
.iter()
.any(|name| upper.contains(name.as_str()))
}
fn matches_allow_values(
&self,
pattern: &Pattern,
matched: &str,
text: &str,
end: usize,
) -> bool {
if self.allow_values.is_empty() {
return false;
}
let value_to_check = if pattern.includes_key {
value_through_token_end(matched, text, end)
} else {
matched.to_string()
};
self.allow_values
.iter()
.any(|allow_regex| allow_regex.is_match(&value_to_check))
}
}
const UNOWNED_EMAIL_DOMAINS: [&str; 8] = [
"example.com",
"example.net",
"example.org",
"example",
"test",
"invalid",
"localhost",
"noreply.github.com",
];
fn is_pii_lookalike(pattern_name: &str, matched: &str, text: &str, start: usize) -> bool {
match pattern_name {
"email" => {
is_unowned_email(matched)
|| is_url_userinfo(text, start)
|| is_ssh_target(text, start, start + matched.len())
}
"phone" => {
is_embedded_number(text, start, start + matched.len())
|| is_diff_added_line(matched, text, start)
}
_ => false,
}
}
fn is_unowned_email(address: &str) -> bool {
let Some((local_part, domain)) = address.rsplit_once('@') else {
return false;
};
let domain_lower = domain.to_ascii_lowercase();
local_part.eq_ignore_ascii_case("noreply")
|| local_part.eq_ignore_ascii_case("no-reply")
|| UNOWNED_EMAIL_DOMAINS.into_iter().any(|parent| {
domain_lower == parent || domain_lower.ends_with(&format!(".{parent}"))
})
}
fn is_url_userinfo(text: &str, start: usize) -> bool {
matches!(text.as_bytes()[..start].last(), Some(b':' | b'/')) && is_inside_url(text, start)
}
const SSH_COMMANDS: [&str; 5] = ["ssh", "scp", "sftp", "rsync", "ssh-copy-id"];
fn is_ssh_target(text: &str, start: usize, end: usize) -> bool {
let word_before = text[..start].split_whitespace().next_back();
text.as_bytes().get(end) == Some(&b':')
|| word_before.is_some_and(|word| SSH_COMMANDS.contains(&word))
}
fn is_diff_added_line(matched: &str, text: &str, start: usize) -> bool {
let is_line_start = start == 0 || text.as_bytes()[start - 1] == b'\n';
matched.starts_with('+') && is_line_start && (text.starts_with("@@ ") || text.contains("\n@@ "))
}
fn is_embedded_number(text: &str, start: usize, end: usize) -> bool {
let bytes = text.as_bytes();
let is_glued_before = bytes[..start]
.last()
.is_some_and(|before| before.is_ascii_alphanumeric() || b".-+".contains(before));
let is_continued_after = matches!(bytes.get(end), Some(b'.' | b':'))
&& bytes.get(end + 1).is_some_and(u8::is_ascii_digit);
is_glued_before || is_continued_after
}
fn is_long_hex_under_key_name(key: &str, value: &str) -> bool {
if key.len() < 4 || value.len() < 24 || !value.bytes().all(|c| c.is_ascii_hexdigit()) {
return false;
}
let upper = key.to_uppercase();
upper.ends_with("_KEY") || upper.ends_with("-KEY")
}
fn is_inside_url(text: &str, start: usize) -> bool {
let bytes = text.as_bytes();
let mut i = start;
while i > 0 && start - i < 2048 && !is_token_boundary(bytes[i - 1]) {
i -= 1;
}
bytes[i..start].windows(3).any(|w| w == b"://")
}
fn is_id_key(key: &str) -> bool {
let key_lower = key.to_lowercase();
key_lower == "id"
|| key_lower == "uuid"
|| ["_id", "-id", "_uuid", "-uuid"]
.iter()
.any(|s| key_lower.ends_with(s))
|| key.ends_with("Id")
|| key.ends_with("Uuid")
}
pub fn percent_decode(encoded: &str) -> Vec<u8> {
let bytes = encoded.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let hex = |c: u8| (c as char).to_digit(16);
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let (Some(high_nibble), Some(low_nibble)) = (hex(bytes[i + 1]), hex(bytes[i + 2]))
{
out.push((high_nibble * 16 + low_nibble) as u8);
i += 3;
continue;
}
out.push(bytes[i]);
i += 1;
}
out
}
fn key_of(matched: &str) -> &str {
match matched.find(['=', ':']) {
Some(i) => matched[..i]
.trim_end_matches([' ', '\t'])
.trim_matches(['"', '\'']),
None => "",
}
}
fn value_of(matched: &str) -> &str {
let Some(i) = matched.find(['=', ':']) else {
return "";
};
let value = matched[i + 1..]
.strip_prefix('>')
.unwrap_or(&matched[i + 1..]);
let value = value.trim_start_matches([' ', '\t']);
value.strip_prefix(['\'', '"']).unwrap_or(value)
}
fn value_through_token_end(matched: &str, text: &str, end: usize) -> String {
let rest = &text[end..];
let token_len = rest
.bytes()
.position(is_token_boundary)
.unwrap_or(rest.len());
format!("{}{}", value_of(matched), &rest[..token_len])
}
fn is_token_boundary(c: u8) -> bool {
matches!(
c,
b' ' | b'\t'
| b'\n'
| b'\r'
| b'"'
| b'\''
| b'`'
| b'('
| b')'
| b'['
| b']'
| b'{'
| b'}'
| b'<'
| b'>'
| b','
| b';'
)
}
fn looks_like_identifier(value: &str) -> bool {
let (mut lower, mut upper, mut sep) = (false, false, false);
for c in value.chars() {
match c {
'a'..='z' => lower = true,
'A'..='Z' => upper = true,
'_' | '.' => sep = true,
_ => return false,
}
}
sep && lower != upper
}
fn looks_like_code_reference(value: &str) -> bool {
let (mut sep, mut lower, mut upper) = (false, false, false);
for c in value.chars() {
match c {
'a'..='z' => lower = true,
'A'..='Z' => upper = true,
'0'..='9' | '_' => {}
'.' | ':' => sep = true,
_ => return false,
}
}
sep && lower && upper
}
fn has_random_segment(value: &str) -> bool {
value.split(['.', ':']).filter(|s| !s.is_empty()).any(|s| {
char_class_count(s) >= DEFAULT_MIN_CHAR_CLASSES && shannon_entropy(s) >= DEFAULT_MIN_ENTROPY
})
}
fn char_class_count(value: &str) -> usize {
[
value.bytes().any(|c| c.is_ascii_lowercase()),
value.bytes().any(|c| c.is_ascii_uppercase()),
value.bytes().any(|c| c.is_ascii_digit()),
]
.into_iter()
.filter(|&bytes| bytes)
.count()
}
fn shannon_entropy(s: &str) -> f64 {
let mut counts: HashMap<char, usize> = HashMap::new();
for c in s.chars() {
*counts.entry(c).or_default() += 1;
}
let total = s.chars().count() as f64;
counts
.values()
.map(|&n| {
let p = n as f64 / total;
-p * p.log2()
})
.sum()
}
fn has_code_style_separator(matched: &str) -> bool {
let bytes = matched.as_bytes();
for (i, &c) in bytes.iter().enumerate() {
match c {
b':' => return true,
b'=' => {
if bytes.get(i + 1) == Some(&b'>') {
return true;
}
let spaced = |c: Option<&u8>| matches!(c, Some(b' ' | b'\t'));
return i > 0 && spaced(bytes.get(i - 1)) && spaced(bytes.get(i + 1));
}
_ => {}
}
}
false
}
fn looks_like_code_expression(value: &str) -> bool {
let stem = value.strip_suffix(['?', '!']).unwrap_or(value);
let (mut letter, mut upper, mut lower, mut digit, mut sep) =
(false, false, false, false, false);
for c in stem.bytes() {
match c {
b'a'..=b'z' => (lower, letter) = (true, true),
b'A'..=b'Z' => (upper, letter) = (true, true),
b'0'..=b'9' => digit = true,
b'_' | b'.' | b'&' | b'#' => sep = true,
_ => return false,
}
}
letter && (sep || (!digit && upper && lower))
}
#[cfg(test)]
mod tests;