use crate::scanner::detect::{resolve_overlaps, Span};
use crate::scanner::{
Direction, Disposition, HoldBack, RestorePolicy, ScanCtx, ScanResult, Scanner, ScannerId,
Verdict,
};
use crate::scanners::redact::redact;
use regex::Regex;
#[derive(Debug, Clone)]
pub struct RegexRule {
re: Regex,
label: String,
source: String,
}
impl RegexRule {
pub fn new(pattern: &str, label: impl Into<String>) -> Result<Self, regex::Error> {
Ok(Self {
re: Regex::new(pattern)?,
label: label.into(),
source: pattern.to_owned(),
})
}
#[must_use]
pub fn label(&self) -> &str {
&self.label
}
#[must_use]
pub fn source(&self) -> &str {
&self.source
}
}
#[derive(Debug, Clone)]
pub struct RegexScanner {
rules: Vec<RegexRule>,
direction: Direction,
policy: RestorePolicy,
}
impl RegexScanner {
pub const ID: ScannerId = ScannerId("native:regex");
#[must_use]
pub fn new(rules: Vec<RegexRule>) -> Self {
Self {
rules,
direction: Direction::Input,
policy: RestorePolicy::RoundTrip,
}
}
#[must_use]
pub fn from_patterns<'a>(
patterns: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> (Self, Vec<(String, regex::Error)>) {
let mut rules = Vec::new();
let mut errors = Vec::new();
for (pattern, label) in patterns {
match RegexRule::new(pattern, label) {
Ok(rule) => rules.push(rule),
Err(e) => errors.push((pattern.to_owned(), e)),
}
}
(Self::new(rules), errors)
}
#[must_use]
pub fn with_direction(mut self, direction: Direction) -> Self {
self.direction = direction;
self
}
#[must_use]
pub fn with_policy(mut self, policy: RestorePolicy) -> Self {
self.policy = policy;
self
}
#[must_use]
pub fn policy(&self) -> RestorePolicy {
self.policy
}
pub fn labels(&self) -> impl Iterator<Item = &str> {
self.rules.iter().map(RegexRule::label)
}
fn detect(&self, text: &str) -> Vec<Span> {
let mut spans = Vec::new();
for rule in &self.rules {
for m in rule.re.find_iter(text) {
spans.push(Span::new(m.start(), m.end(), &rule.label, 0.99));
}
}
spans
}
}
impl Scanner for RegexScanner {
fn id(&self) -> ScannerId {
Self::ID
}
fn direction(&self) -> Direction {
self.direction
}
fn disposition(&self) -> Disposition {
Disposition::Transform
}
fn scan<'a>(&self, text: &'a str, ctx: &mut ScanCtx) -> ScanResult<'a> {
let spans = resolve_overlaps(self.detect(text));
let restorable =
self.direction == Direction::Input && self.policy == RestorePolicy::RoundTrip;
Ok(Verdict::transformed(redact(
text,
&spans,
&mut ctx.vault,
restorable,
)))
}
fn max_input_inflation_ratio(&self) -> f64 {
1.3
}
fn hold_back(&self) -> HoldBack {
HoldBack::TokenBoundary
}
fn stream_patterns(&self) -> Vec<String> {
self.rules.iter().map(|r| r.source().to_owned()).collect()
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "tests assert on known-good values"
)]
use std::time::Instant;
use crate::scanners::RestoreScanner;
use super::*;
fn ticket_scanner() -> RegexScanner {
let (scanner, errors) = RegexScanner::from_patterns([(r"TICKET-\d+", "TICKET_ID")]);
assert!(errors.is_empty());
scanner
}
#[test]
fn redacts_custom_pattern_into_vault() {
let scanner = ticket_scanner();
let mut ctx = ScanCtx::new();
let verdict = scanner
.scan("see TICKET-4821 for details", &mut ctx)
.unwrap();
assert!(verdict.text.contains("[REDACTED_TICKET_ID_1_"));
assert!(!verdict.text.contains("TICKET-4821"));
assert_eq!(ctx.vault.len(), 1);
assert!(!verdict.is_risk_judgment());
assert!(verdict.valid);
}
#[test]
fn round_trip_restores_custom_label() {
let scanner = ticket_scanner();
let restore =
RestoreScanner::for_types(ScannerId("native:regex-restore"), ["TICKET_ID".to_owned()]);
let mut ctx = ScanCtx::new();
let redacted = scanner
.scan("see TICKET-4821", &mut ctx)
.unwrap()
.text
.into_owned();
let out = restore.scan(&redacted, &mut ctx).unwrap();
assert_eq!(out.text, "see TICKET-4821");
}
#[test]
fn one_way_is_not_restored() {
let scanner = ticket_scanner().with_policy(RestorePolicy::OneWay);
assert_eq!(scanner.policy(), RestorePolicy::OneWay);
let restore = RestoreScanner::for_types(ScannerId("native:regex-restore"), []);
let mut ctx = ScanCtx::new();
let redacted = scanner
.scan("see TICKET-4821", &mut ctx)
.unwrap()
.text
.into_owned();
let out = restore.scan(&redacted, &mut ctx).unwrap();
assert!(out.text.contains("[REDACTED_TICKET_ID_1_"));
assert!(!out.text.contains("TICKET-4821"));
}
#[test]
fn clean_text_borrows_unchanged() {
let scanner = ticket_scanner();
let mut ctx = ScanCtx::new();
let verdict = scanner.scan("nothing matches here", &mut ctx).unwrap();
assert_eq!(verdict.text, "nothing matches here");
assert!(ctx.vault.is_empty());
}
#[test]
fn invalid_pattern_is_reported_not_panicked() {
let (scanner, errors) = RegexScanner::from_patterns([("(unclosed", "BAD")]);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].0, "(unclosed");
assert_eq!(scanner.labels().count(), 0);
}
#[test]
fn redos_pattern_compiles_and_runs_linear() {
let (scanner, errors) = RegexScanner::from_patterns([(r"(a+)+$", "EVIL")]);
assert!(errors.is_empty());
let mut ctx = ScanCtx::new();
let adversarial = format!("{}!", "a".repeat(50_000));
let start = Instant::now();
let verdict = scanner.scan(&adversarial, &mut ctx).unwrap();
assert!(start.elapsed().as_secs() < 2, "matcher was not linear");
assert_eq!(verdict.text, adversarial);
}
}