use std::collections::HashSet;
use std::ops::Range;
use std::sync::LazyLock;
use regex::Regex;
use crate::detect::{Detection, describe_regex_error};
use crate::{Error, Finding};
pub const TOKEN_PREFIX: &str = "REDACTION-";
static TOKEN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\bREDACTION-[1-9][0-9]*\b").unwrap());
pub fn token(id: usize) -> String {
format!("{TOKEN_PREFIX}{id}")
}
pub fn is_redaction_token(s: &str) -> bool {
TOKEN.find(s).is_some_and(|m| m.range() == (0..s.len()))
}
pub fn find_tokens(s: &str) -> impl Iterator<Item = Range<usize>> + '_ {
TOKEN.find_iter(s).map(|m| m.range())
}
#[derive(Debug, Clone, Default)]
pub struct Allow {
values: HashSet<String>,
regexes: Vec<Regex>,
}
impl Allow {
pub fn none() -> Self {
Self::default()
}
pub fn values<S: Into<String>>(values: impl IntoIterator<Item = S>) -> Self {
Self::none().with_values(values)
}
pub fn regexes<S: AsRef<str>>(patterns: impl IntoIterator<Item = S>) -> Result<Self, Error> {
Self::none().with_regexes(patterns)
}
pub fn with_values<S: Into<String>>(mut self, values: impl IntoIterator<Item = S>) -> Self {
self.values.extend(values.into_iter().map(Into::into));
self
}
pub fn with_regexes<S: AsRef<str>>(
mut self,
patterns: impl IntoIterator<Item = S>,
) -> Result<Self, Error> {
for pattern in patterns {
let anchored = format!("^(?s:{})$", pattern.as_ref());
let regex = Regex::new(&anchored).map_err(|err| Error::InvalidPattern {
name: "allow-regex".into(),
message: describe_regex_error(&err),
})?;
self.regexes.push(regex);
}
Ok(self)
}
pub fn allows(&self, finding: &Finding) -> bool {
self.values.contains(&finding.secret)
|| self.regexes.iter().any(|r| r.is_match(&finding.secret))
}
}
pub(crate) fn merge(value: &str, mut detections: Vec<Detection>) -> Vec<Detection> {
for d in &mut detections {
d.range = snap(value, d.range.clone());
}
let tokens: Vec<Range<usize>> = if value.contains(TOKEN_PREFIX) {
find_tokens(value).collect()
} else {
Vec::new()
};
detections.retain(|d| {
!d.range.is_empty()
&& !tokens
.iter()
.any(|t| t.start <= d.range.start && d.range.end <= t.end)
});
detections.sort_by(|a, b| {
a.range
.start
.cmp(&b.range.start)
.then(b.range.end.cmp(&a.range.end))
.then_with(|| a.label.cmp(&b.label))
});
let mut merged: Vec<Detection> = Vec::with_capacity(detections.len());
for d in detections {
match merged.last_mut() {
Some(last) if d.range.start <= last.range.end => {
last.range.end = last.range.end.max(d.range.end);
}
_ => merged.push(d),
}
}
merged
}
fn snap(value: &str, range: Range<usize>) -> Range<usize> {
let mut start = range.start.min(value.len());
let mut end = range.end.clamp(start, value.len());
while !value.is_char_boundary(start) {
start -= 1;
}
while !value.is_char_boundary(end) {
end += 1;
}
start..end
}
#[cfg(test)]
mod tests {
use super::*;
fn d(range: Range<usize>, label: &str) -> Detection {
Detection::new(range, label)
}
fn finding(secret: &str) -> Finding {
Finding {
id: 1,
secret: secret.into(),
detector: "d".into(),
len: secret.len(),
field: None,
path: None,
offsets: vec![],
occurrences: 1,
}
}
#[test]
fn tokens() {
let t = token(1);
assert_eq!(t, "REDACTION-1");
assert!(is_redaction_token(&t));
assert_eq!(token(12), "REDACTION-12");
assert!(is_redaction_token("REDACTION-12"));
assert!(!is_redaction_token("xREDACTION-1"));
assert!(!is_redaction_token("REDACTION-01"));
assert!(!is_redaction_token("REDACTION-0"));
assert!(!is_redaction_token("[REDACTION|x|1|abc]"));
}
#[test]
fn allow_accepts_values() {
let finding = finding("v");
assert!(Allow::values(["v"]).allows(&finding));
assert!(!Allow::values(["w"]).allows(&finding));
assert!(!Allow::none().allows(&finding));
}
#[test]
fn allow_regexes_must_match_the_whole_secret() {
let finding = finding("EMP-123456");
assert!(Allow::regexes([r"EMP-\d{6}"]).unwrap().allows(&finding));
assert!(Allow::regexes([r"EMP-\d+"]).unwrap().allows(&finding));
assert!(!Allow::regexes([r"EMP-\d{3}"]).unwrap().allows(&finding));
assert!(!Allow::regexes([r"\d{6}"]).unwrap().allows(&finding));
assert!(Allow::regexes([".*"]).unwrap().allows(&finding));
}
#[test]
fn allow_regexes_match_across_lines() {
assert!(Allow::regexes(["a.b"]).unwrap().allows(&finding("a\nb")));
}
#[test]
fn invalid_allow_regex_does_not_echo_the_pattern() {
let err = Allow::regexes(["sk-live-SECRET("]).unwrap_err();
assert!(!err.to_string().contains("SECRET"), "{err}");
}
#[test]
fn merges_overlapping_and_adjacent_ranges() {
let value = "0123456789abcdef";
let merged = merge(
value,
vec![
d(6..8, "c"),
d(0..3, "a"),
d(2..5, "b"),
d(5..6, "d"),
d(10..12, "e"),
],
);
assert_eq!(merged, vec![d(0..8, "a"), d(10..12, "e")]);
}
#[test]
fn longer_range_wins_label_on_equal_start() {
let merged = merge("0123456789", vec![d(0..3, "short"), d(0..6, "long")]);
assert_eq!(merged, vec![d(0..6, "long")]);
}
#[test]
fn snaps_to_char_boundaries_and_drops_ranges_inside_tokens() {
let t = token(1);
let value = format!("é {t}");
let token_range = 3..value.len();
let merged = merge(
&value,
vec![d(1..2, "x"), d(token_range, "y"), d(3..value.len(), "z")],
);
assert_eq!(merged, vec![d(0..2, "x")]);
}
}