use regex::Regex;
use serde_json::{Map, Value};
use std::collections::HashMap;
const FILTERED: &str = "[FILTERED]";
#[derive(Debug, Clone)]
pub enum FilterPattern {
Exact(String),
Regex(Regex),
}
impl FilterPattern {
#[must_use]
pub fn exact(pattern: impl Into<String>) -> Self {
Self::Exact(pattern.into())
}
pub fn regex(pattern: &str) -> Result<Self, regex::Error> {
Regex::new(pattern).map(Self::Regex)
}
}
#[derive(Debug, Clone)]
pub struct ParameterFilter {
patterns: Vec<FilterPattern>,
}
impl Default for ParameterFilter {
fn default() -> Self {
Self::new([
FilterPattern::exact("password"),
FilterPattern::exact("token"),
FilterPattern::exact("secret"),
FilterPattern::exact("key"),
])
}
}
impl ParameterFilter {
#[must_use]
pub fn new<I>(patterns: I) -> Self
where
I: IntoIterator<Item = FilterPattern>,
{
Self {
patterns: patterns.into_iter().collect(),
}
}
#[must_use]
pub fn filter(&self, params: &HashMap<String, Value>) -> HashMap<String, Value> {
params
.iter()
.map(|(key, value)| (key.clone(), self.filter_value(key, key, value)))
.collect()
}
fn filter_value(&self, path: &str, key: &str, value: &Value) -> Value {
if self.matches(path, key) {
return Value::String(String::from(FILTERED));
}
match value {
Value::Object(map) => Value::Object(
map.iter()
.map(|(child_key, child_value)| {
let child_path = format!("{path}.{child_key}");
(
child_key.clone(),
self.filter_value(&child_path, child_key, child_value),
)
})
.collect::<Map<String, Value>>(),
),
Value::Array(values) => Value::Array(
values
.iter()
.map(|child| self.filter_value(path, key, child))
.collect(),
),
_ => value.clone(),
}
}
fn matches(&self, path: &str, key: &str) -> bool {
self.patterns.iter().any(|pattern| match pattern {
FilterPattern::Exact(candidate) => {
candidate.eq_ignore_ascii_case(path) || candidate.eq_ignore_ascii_case(key)
}
FilterPattern::Regex(regex) => regex.is_match(path) || regex.is_match(key),
})
}
}
#[cfg(test)]
mod tests {
use super::{FILTERED, FilterPattern, ParameterFilter};
use serde_json::json;
use std::collections::HashMap;
#[test]
fn default_filter_redacts_password_keys() {
let filter = ParameterFilter::default();
let params = HashMap::from([
(String::from("password"), json!("secret")),
(String::from("name"), json!("RustRails")),
]);
let filtered = filter.filter(¶ms);
assert_eq!(filtered.get("password"), Some(&json!(FILTERED)));
assert_eq!(filtered.get("name"), Some(&json!("RustRails")));
}
#[test]
fn default_filter_redacts_token_keys() {
let filter = ParameterFilter::default();
let params = HashMap::from([(String::from("token"), json!("abc123"))]);
let filtered = filter.filter(¶ms);
assert_eq!(filtered.get("token"), Some(&json!(FILTERED)));
}
#[test]
fn exact_patterns_match_nested_dotted_paths() {
let filter = ParameterFilter::new([FilterPattern::exact("user.password")]);
let params = HashMap::from([(
String::from("user"),
json!({"password": "secret", "name": "DHH"}),
)]);
let filtered = filter.filter(¶ms);
assert_eq!(
filtered.get("user"),
Some(&json!({"password": FILTERED, "name": "DHH"}))
);
}
#[test]
fn exact_patterns_match_leaf_keys_at_any_depth() {
let filter = ParameterFilter::new([FilterPattern::exact("password")]);
let params = HashMap::from([(
String::from("user"),
json!({"password": "secret", "profile": {"password": "hidden"}}),
)]);
let filtered = filter.filter(¶ms);
assert_eq!(
filtered.get("user"),
Some(&json!({"password": FILTERED, "profile": {"password": FILTERED}})),
);
}
#[test]
fn regex_patterns_can_match_variants_of_sensitive_keys() {
let filter = ParameterFilter::new([
FilterPattern::regex("(?i)api[_-]?key$").expect("regex should compile")
]);
let params = HashMap::from([
(String::from("api_key"), json!("abc123")),
(String::from("api-key"), json!("def456")),
]);
let filtered = filter.filter(¶ms);
assert_eq!(filtered.get("api_key"), Some(&json!(FILTERED)));
assert_eq!(filtered.get("api-key"), Some(&json!(FILTERED)));
}
#[test]
fn regex_patterns_can_match_dotted_paths() {
let filter =
ParameterFilter::new([FilterPattern::regex("^credentials\\.(secret|token)$")
.expect("regex should compile")]);
let params = HashMap::from([(
String::from("credentials"),
json!({"secret": "x", "token": "y", "name": "app"}),
)]);
let filtered = filter.filter(¶ms);
assert_eq!(
filtered.get("credentials"),
Some(&json!({"secret": FILTERED, "token": FILTERED, "name": "app"}))
);
}
#[test]
fn nested_arrays_are_filtered_recursively() {
let filter = ParameterFilter::new([FilterPattern::exact("token")]);
let params = HashMap::from([(
String::from("users"),
json!([
{"token": "abc"},
{"token": "def", "name": "Jane"}
]),
)]);
let filtered = filter.filter(¶ms);
assert_eq!(
filtered.get("users"),
Some(&json!([
{"token": FILTERED},
{"token": FILTERED, "name": "Jane"}
])),
);
}
#[test]
fn non_matching_values_are_left_unchanged() {
let filter = ParameterFilter::new([FilterPattern::exact("password")]);
let params = HashMap::from([(String::from("count"), json!(3))]);
let filtered = filter.filter(¶ms);
assert_eq!(filtered.get("count"), Some(&json!(3)));
}
#[test]
fn filtering_does_not_mutate_the_source_hash() {
let filter = ParameterFilter::new([FilterPattern::exact("password")]);
let params = HashMap::from([(String::from("password"), json!("secret"))]);
let filtered = filter.filter(¶ms);
assert_eq!(params.get("password"), Some(&json!("secret")));
assert_eq!(filtered.get("password"), Some(&json!(FILTERED)));
}
#[test]
fn multiple_patterns_can_redact_different_keys() {
let filter = ParameterFilter::new([
FilterPattern::exact("password"),
FilterPattern::exact("token"),
]);
let params = HashMap::from([
(String::from("password"), json!("secret")),
(String::from("token"), json!("abc123")),
]);
let filtered = filter.filter(¶ms);
assert_eq!(filtered.get("password"), Some(&json!(FILTERED)));
assert_eq!(filtered.get("token"), Some(&json!(FILTERED)));
}
#[test]
fn exact_matching_is_case_insensitive() {
let filter = ParameterFilter::new([FilterPattern::exact("password")]);
let params = HashMap::from([(String::from("Password"), json!("secret"))]);
let filtered = filter.filter(¶ms);
assert_eq!(filtered.get("Password"), Some(&json!(FILTERED)));
}
#[test]
fn empty_hashes_round_trip_cleanly() {
let filter = ParameterFilter::default();
let params = HashMap::new();
let filtered = filter.filter(¶ms);
assert!(filtered.is_empty());
}
#[test]
fn nested_values_can_mix_exact_and_regex_rules() {
let filter = ParameterFilter::new([
FilterPattern::exact("user.password"),
FilterPattern::regex("secret$").expect("regex should compile"),
]);
let params = HashMap::from([(
String::from("user"),
json!({
"password": "secret",
"profile": {"secret": "hidden", "nickname": "rails"}
}),
)]);
let filtered = filter.filter(¶ms);
assert_eq!(
filtered.get("user"),
Some(&json!({
"password": FILTERED,
"profile": {"secret": FILTERED, "nickname": "rails"}
})),
);
}
}