#[cfg(test)]
mod benchmark;
mod go_regex;
mod guards;
mod learned;
use std::collections::{BTreeMap, HashMap, HashSet};
use regex::Regex;
use serde::Deserialize;
use crate::config::{Config, CustomPattern, EngineConfig, HeuristicConfig, PiiConfig};
use go_regex::go_to_rust_regex;
pub use guards::percent_decode;
pub use learned::{learned_value_hint, sha256_hex};
const BUILTIN_ENGINE_YML: &str = include_str!("engine.yml");
#[derive(Deserialize)]
struct BuiltinPatternYaml {
name: String,
regex: String,
#[serde(default)]
includes_key: bool,
#[serde(default)]
prefilters: Vec<String>,
#[serde(default)]
prefilters_ignore_case: Vec<String>,
#[serde(default)]
opt_in: bool,
}
#[derive(Deserialize)]
struct BuiltinEngine {
value_safe_char: String,
allow_values: Vec<String>,
patterns: Vec<BuiltinPatternYaml>,
}
struct Pattern {
name: String,
regex: Regex,
includes_key: bool,
prefilters: Vec<String>,
prefilters_ignore_case: Vec<String>,
is_heuristic: bool,
}
impl Pattern {
fn unkeyed(name: String, regex: Regex) -> Self {
Pattern {
name,
regex,
includes_key: false,
prefilters: Vec::new(),
prefilters_ignore_case: Vec::new(),
is_heuristic: false,
}
}
}
pub struct Scrubber {
patterns: Vec<Pattern>,
disabled_patterns: HashSet<String>,
allowed_keys_upper: Vec<String>,
allow_values: Vec<Regex>,
learned_shapes: Vec<(String, Regex)>,
learned_name_by_hash: HashMap<String, String>,
learned_byte_lengths: HashSet<usize>,
value_char_run: Regex,
non_whitespace_run: Regex,
heuristic_pattern: Option<Pattern>,
heuristic_thresholds: HeuristicConfig,
}
#[derive(Debug, Default)]
pub struct ScrubResult {
pub text: String,
pub count: usize,
pub counts_by_pattern: BTreeMap<String, usize>,
}
impl ScrubResult {
pub fn has_redactions(&self) -> bool {
self.count > 0
}
fn add_redactions(&mut self, name: &str, redactions: usize) {
if redactions == 0 {
return;
}
*self.counts_by_pattern.entry(name.to_string()).or_default() += redactions;
self.count += redactions;
}
}
fn compile_go_regex(expr: &str) -> Result<Regex, String> {
Regex::new(&go_to_rust_regex(expr)).map_err(|e| format!("invalid regex {expr:?}: {e}"))
}
fn compile_vendor_patterns(
builtin: Vec<BuiltinPatternYaml>,
user: &[CustomPattern],
pii: &PiiConfig,
) -> Result<Vec<Pattern>, String> {
let mut patterns = Vec::new();
for pattern in builtin {
let is_toggled_on = match pattern.name.as_str() {
"email" => pii.email,
"phone" => pii.phone,
_ => false,
};
if pattern.opt_in && !is_toggled_on {
continue;
}
patterns.push(Pattern {
regex: compile_go_regex(&pattern.regex)?,
name: pattern.name,
includes_key: pattern.includes_key,
prefilters: pattern.prefilters,
prefilters_ignore_case: pattern.prefilters_ignore_case,
is_heuristic: false,
});
}
for pattern in user {
patterns.push(Pattern::unkeyed(
pattern.name.clone(),
compile_go_regex(&pattern.regex)?,
));
}
Ok(patterns)
}
impl Scrubber {
pub fn new(config: &Config, user_engine: &EngineConfig) -> Result<Self, String> {
let builtin: BuiltinEngine =
serde_yaml::from_str(BUILTIN_ENGINE_YML).map_err(|e| format!("engine.yml: {e}"))?;
let patterns =
compile_vendor_patterns(builtin.patterns, &user_engine.patterns, &config.pii)?;
let allow_values = builtin
.allow_values
.iter()
.chain(&user_engine.allow_values)
.map(|expr| compile_go_regex(expr))
.collect::<Result<_, _>>()?;
let heuristic_pattern = compile_heuristic(&config.heuristic, &builtin.value_safe_char)?;
let mut learned_shapes = Vec::new();
let mut learned_name_by_hash = HashMap::new();
let mut learned_byte_lengths = HashSet::new();
for learned in &config.learned {
learned_name_by_hash.insert(learned.sha256.clone(), learned.name.clone());
learned_byte_lengths.insert(learned.byte_len);
if let Some(shape) = &learned.shape {
learned_shapes.push((learned.name.clone(), compile_go_regex(shape)?));
}
}
Ok(Scrubber {
patterns,
disabled_patterns: config.disabled_patterns.iter().cloned().collect(),
allowed_keys_upper: config
.allowed_keys
.iter()
.map(|a| a.to_uppercase())
.collect(),
allow_values,
learned_shapes,
learned_name_by_hash,
learned_byte_lengths,
value_char_run: compile_go_regex(&format!("{}+", builtin.value_safe_char))?,
non_whitespace_run: compile_go_regex(r"[^ \t\n\x0C\r]+")?,
heuristic_pattern,
heuristic_thresholds: config.heuristic.clone(),
})
}
pub fn scrub(&self, text: &str) -> ScrubResult {
let mut result = ScrubResult {
text: text.to_string(),
..Default::default()
};
let mut lowercase_text: Option<String> = None;
for pattern in &self.patterns {
if self.disabled_patterns.contains(&pattern.name) {
continue;
}
if !pattern.prefilters.is_empty()
&& !pattern
.prefilters
.iter()
.any(|literal| result.text.contains(literal.as_str()))
{
continue;
}
if !pattern.prefilters_ignore_case.is_empty() {
let lowercase = lowercase_text.get_or_insert_with(|| result.text.to_lowercase());
if !pattern
.prefilters_ignore_case
.iter()
.any(|literal| lowercase.contains(literal.as_str()))
{
continue;
}
}
let redactions = self.redact_matches(pattern, &mut result.text);
result.add_redactions(&pattern.name, redactions);
}
self.scrub_learned(&mut result);
if let Some(pattern) = &self.heuristic_pattern {
let redactions = self.redact_matches(pattern, &mut result.text);
result.add_redactions(&pattern.name, redactions);
}
result
}
fn redact_matches(&self, pattern: &Pattern, text: &mut String) -> usize {
let mut out = String::with_capacity(text.len());
let (mut copied_until, mut count) = (0, 0);
for found in pattern.regex.find_iter(text) {
out.push_str(&text[copied_until..found.start()]);
copied_until = found.end();
if self.should_skip_match(pattern, found.as_str(), text, found.end()) {
out.push_str(found.as_str());
continue;
}
out.push_str(&marker_for(
&pattern.name,
found.as_str(),
pattern.includes_key,
));
count += 1;
}
if count > 0 {
out.push_str(&text[copied_until..]);
*text = out;
}
count
}
}
fn marker_for(name: &str, matched: &str, includes_key: bool) -> String {
if includes_key && let Some(separator_at) = matched.find(['=', ':']) {
let separator = &matched[separator_at..=separator_at];
let value = matched[separator_at + 1..].trim_start_matches([' ', '\t']);
let key = &matched[..separator_at];
return format!("{key}{separator} [REDACTED ...{}]", last_chars(value, 4));
}
format!("[REDACTED:{name} ...{}]", last_chars(matched, 4))
}
fn last_chars(text: &str, count: usize) -> &str {
match text.char_indices().rev().nth(count - 1) {
Some((start, _)) => &text[start..],
None => text,
}
}
fn compile_heuristic(
heuristic: &HeuristicConfig,
safe_char: &str,
) -> Result<Option<Pattern>, String> {
if !heuristic.enabled {
return Ok(None);
}
let regex = compile_go_regex(&heuristic_regex(heuristic.min_length, safe_char))?;
Ok(Some(Pattern {
includes_key: true,
is_heuristic: true,
..Pattern::unkeyed("secret_value".into(), regex)
}))
}
fn heuristic_regex(min_length: usize, safe_char: &str) -> String {
let first_char_class = match safe_char.strip_suffix(']') {
Some(class) if safe_char.starts_with("[^") => format!("{class}/]"),
_ => safe_char.to_string(),
};
format!(
r#"(?i)\b[A-Za-z0-9_\-]*[A-Za-z][A-Za-z0-9_\-]*["']?[ \t]*(?:=>?|:)[ \t]*["']?{first_char_class}{safe_char}{{{},}}"#,
min_length.max(1) - 1
)
}
#[cfg(test)]
mod tests;