mod common;
use common::HIGH_ENTROPY_SECRET as S;
use velociredactor::config::Config;
use velociredactor::detect::{
BETTERLEAKS_RULESET, DetectorConfig, EmailConfig, PathDetector, RegexConfig, RegexDetector,
RulesetDetector, ValueDetector,
};
use velociredactor::{Allow, Finding, FormatHint, Redaction, Redactor, RedactorBuilder};
const DOC: &str = r#"{
"users": [{"name": "Jane Roe", "ssn": "123-45-6789"}],
"db": {"host": "h.example", "user": "u", "password": "hunter2"},
"build": {"note": "n"}
}"#;
fn render(redaction: &Redaction<'_>, allow: &Allow) -> String {
String::from_utf8(redaction.render(allow).unwrap()).unwrap()
}
fn scan<'a>(builder: RedactorBuilder, input: &'a str) -> Redaction<'a> {
builder
.build()
.redact(input.as_bytes(), FormatHint::Name("json"))
.expect("valid json")
}
fn redact(builder: RedactorBuilder, input: &str) -> String {
render(&scan(builder, input), &Allow::none())
}
fn redact_with(redactor: &Redactor, input: &str) -> String {
let redaction = redactor
.redact(input.as_bytes(), FormatHint::Name("json"))
.expect("valid json");
render(&redaction, &Allow::none())
}
fn configured(keep: &[&str]) -> Redactor {
let mut config = Config::builtin().clone();
config.detectors.retain(|d| keep.contains(&d.name()));
config
.detectors
.push(DetectorConfig::PiiEmail(EmailConfig::default()));
config.redactor().expect("valid configuration").0
}
fn tokens_of<'a>(redaction: &'a Redaction<'_>, detector: &str) -> Vec<&'a Finding> {
redaction
.findings()
.iter()
.filter(|f| f.detector == detector)
.collect()
}
#[test]
fn disallowed_paths_redact_whatever_the_value_holds() {
let redaction = scan(
Redactor::builder().detector(PathDetector::new(["users.name", "users.ssn"])),
DOC,
);
let out = render(&redaction, &Allow::none());
assert!(out.contains(r#""name": "REDACTION-1""#), "{out}");
assert!(out.contains(r#""ssn": "REDACTION-2""#), "{out}");
assert_eq!(tokens_of(&redaction, "path").len(), 2);
assert_eq!(
redaction.findings()[0].path.as_deref(),
Some("users.name"),
"the path is reported"
);
}
#[test]
fn path_globs_span_keys_and_segments() {
let out = redact(
Redactor::builder().detector(PathDetector::new(["**.ssn"])),
DOC,
);
assert!(out.contains(r#""ssn": "REDACTION-1""#), "{out}");
assert!(out.contains(r#""name": "Jane Roe""#), "{out}");
let out = redact(
Redactor::builder().detector(PathDetector::new(["db.*"])),
DOC,
);
assert!(out.contains(r#""host": "REDACTION-1""#), "{out}");
assert!(out.contains(r#""name": "Jane Roe""#), "{out}");
let out = redact(Redactor::builder().detector(PathDetector::new(["*"])), DOC);
assert!(out.contains(r#""name": "Jane Roe""#), "{out}");
}
#[test]
fn allowed_paths_are_never_scanned() {
let doc = format!(r#"{{"keep": {{"api_key": "{S}"}}, "other": "{S}"}}"#);
let redaction = scan(Redactor::builder().allow_paths(["keep.**"]), &doc);
let out = render(&redaction, &Allow::none());
assert_eq!(
out,
format!(r#"{{"keep": {{"api_key": "{S}"}}, "other": "REDACTION-1"}}"#),
"a secret at an allowed path survives even where the same value is redacted elsewhere"
);
assert_eq!(
redaction.findings()[0].occurrences,
1,
"the allowed occurrence is not counted"
);
let out = redact(
Redactor::builder()
.allow_paths(["users"])
.detector(PathDetector::new(["users.**"])),
DOC,
);
assert!(out.contains(r#""ssn": "123-45-6789""#), "{out}");
}
#[test]
fn disallowed_values_and_patterns_are_reported_by_their_own_detectors() {
let redaction = scan(
Redactor::builder()
.detector(ValueDetector::new(["Jane Roe"]).unwrap())
.detector(RegexDetector::new("regex", r"\d{3}-\d{2}-\d{4}").unwrap()),
DOC,
);
assert_eq!(tokens_of(&redaction, "value").len(), 1);
assert_eq!(tokens_of(&redaction, "regex").len(), 1);
let out = render(&redaction, &Allow::none());
assert!(out.contains(r#""name": "REDACTION-1""#), "{out}");
assert!(out.contains(r#""ssn": "REDACTION-2""#), "{out}");
}
#[test]
fn a_disallowed_value_is_redacted_wherever_it_appears() {
let out = redact(
Redactor::builder().detector(ValueDetector::new(["acme"]).unwrap()),
r#"{"a": "acme corp", "b": "at acme", "c": "ok"}"#,
);
assert_eq!(
out,
r#"{"a": "REDACTION-1 corp", "b": "at REDACTION-1", "c": "ok"}"#
);
}
#[test]
fn allowing_wins_over_disallowing() {
let redaction = scan(
Redactor::builder()
.detector(PathDetector::new(["users.name"]))
.detector(ValueDetector::new(["123-45-6789"]).unwrap()),
DOC,
);
let allow = Allow::values(["Jane Roe"])
.with_regexes([r"\d{3}-\d{2}-\d{4}"])
.unwrap();
let out = render(&redaction, &allow);
assert!(out.contains(r#""name": "Jane Roe""#), "{out}");
assert!(out.contains(r#""ssn": "123-45-6789""#), "{out}");
let allowed: Vec<&str> = redaction
.findings()
.iter()
.filter(|f| allow.allows(f))
.map(|f| f.secret.as_str())
.collect();
assert_eq!(
allowed,
["Jane Roe", "123-45-6789"],
"both are still listed, as allowed"
);
}
#[test]
fn only_the_configured_detectors_run() {
let doc = format!(r#"{{"api_key": "{S}", "mail": "jane@corp.example"}}"#);
let both = configured(&["entropy", "ruleset"]);
assert_eq!(
redact_with(&both, &doc),
r#"{"api_key": "REDACTION-1", "mail": "REDACTION-2"}"#
);
let no_entropy = configured(&["ruleset"]);
assert_eq!(
redact_with(&no_entropy, &doc),
format!(r#"{{"api_key": "{S}", "mail": "REDACTION-1"}}"#)
);
let mut none = Config::builtin().clone();
none.detectors.clear();
let none = none.redactor().expect("valid configuration").0;
assert_eq!(redact_with(&none, &doc), doc);
}
#[test]
fn regex_entries_report_their_configured_label() {
let mut config = Config::builtin().clone();
config.detectors.push(DetectorConfig::Regex(RegexConfig {
label: "acme".to_owned(),
patterns: vec!["ACME-[0-9]{4}".to_owned()],
}));
config.detectors.push(DetectorConfig::Regex(RegexConfig {
patterns: vec!["ticket-[0-9]+".to_owned()],
..RegexConfig::default()
}));
let redactor = config.redactor().expect("valid configuration").0;
let redaction = redactor
.redact(
br#"{"a": "ACME-1234", "b": "ticket-99"}"#,
FormatHint::Name("json"),
)
.expect("valid json");
let labels: Vec<&str> = redaction
.findings()
.iter()
.map(|f| f.detector.as_str())
.collect();
assert_eq!(labels, ["acme", "regex"], "the default label is `regex`");
}
#[test]
fn the_builtin_regex_patterns_keep_their_label() {
let secret = format!("sb_secret_{}", "probe_20260710_7f91c2d8e4a6b3f0");
let doc = format!(r#"{{"note": "{secret}"}}"#);
let redaction = Redactor::builder()
.build()
.redact(doc.as_bytes(), FormatHint::Name("json"))
.expect("valid json");
assert_eq!(redaction.findings()[0].detector, "provider_token");
}
#[test]
fn ruleset_rules_can_be_excluded_by_id() {
let key = "ghp_a1b2c1d2e1f2g1h2a1b2c1d2e1f2g1h2a1b2";
let doc = format!(r#"{{"note": "{key}"}}"#);
let mut config = Config::builtin().clone();
config.detectors.retain(|d| d.name() == "ruleset");
let ruleset = config.redactor().expect("valid configuration").0;
assert_eq!(redact_with(&ruleset, &doc), r#"{"note": "REDACTION-1"}"#);
for detector in &mut config.detectors {
if let DetectorConfig::Ruleset(ruleset) = detector {
ruleset.exclude_rules = vec!["github-*".into()];
}
}
let excluded = config.redactor().expect("valid configuration").0;
assert_eq!(redact_with(&excluded, &doc), doc);
}
#[test]
fn the_bundled_ruleset_is_a_detector_like_any_other() {
let key = "ghp_a1b2c1d2e1f2g1h2a1b2c1d2e1f2g1h2a1b2";
let doc = format!(r#"{{"note": "{key}"}}"#);
let rules: RulesetDetector = BETTERLEAKS_RULESET.clone();
assert_eq!(
redact_with(&Redactor::builder().detector(rules.clone()).build(), &doc),
r#"{"note": "REDACTION-1"}"#
);
let excluded = rules.exclude_rules(["github-pat"]);
let redactor = RedactorBuilder::new()
.shared_format(std::sync::Arc::new(velociredactor::format::Json))
.detector(excluded)
.build();
assert_eq!(redact_with(&redactor, &doc), doc);
}
#[test]
fn plain_text_values_have_no_path() {
let redactor = Redactor::builder()
.detector(PathDetector::new(["**"]))
.build();
let redaction = redactor
.redact(b"just some words", FormatHint::Raw)
.unwrap();
assert_eq!(redaction.findings()[0].path, None);
assert_eq!(
render(&redaction, &Allow::none()),
"REDACTION-1",
"`**` matches the empty path of a plain-text document"
);
}