use llm_shield_core::{
async_trait, Entity, Error, Result, RiskFactor, ScanResult, Scanner, ScannerType, Severity,
Vault,
};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegexConfig {
pub patterns: Vec<RegexPattern>,
pub redact: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegexPattern {
pub name: String,
pub pattern: String,
pub risk_score: f32,
}
impl Default for RegexConfig {
fn default() -> Self {
Self {
patterns: Vec::new(),
redact: false,
}
}
}
pub struct RegexScanner {
config: RegexConfig,
compiled_patterns: Vec<(String, Regex, f32)>,
}
impl RegexScanner {
pub fn new(config: RegexConfig) -> Result<Self> {
if config.patterns.is_empty() {
return Err(Error::config("At least one pattern must be provided"));
}
let mut compiled_patterns = Vec::new();
for pattern in &config.patterns {
let regex = Regex::new(&pattern.pattern).map_err(|e| {
Error::config(format!("Invalid regex '{}': {}", pattern.pattern, e))
})?;
compiled_patterns.push((pattern.name.clone(), regex, pattern.risk_score));
}
Ok(Self {
config,
compiled_patterns,
})
}
pub fn with_patterns<I, S>(patterns: I) -> Result<Self>
where
I: IntoIterator<Item = (S, S)>,
S: Into<String>,
{
let regex_patterns: Vec<RegexPattern> = patterns
.into_iter()
.map(|(name, pattern)| RegexPattern {
name: name.into(),
pattern: pattern.into(),
risk_score: 1.0,
})
.collect();
Self::new(RegexConfig {
patterns: regex_patterns,
redact: false,
})
}
}
#[async_trait]
impl Scanner for RegexScanner {
fn name(&self) -> &str {
"Regex"
}
async fn scan(&self, input: &str, _vault: &Vault) -> Result<ScanResult> {
let mut entities = Vec::new();
let mut max_risk = 0.0f32;
let mut sanitized_text = input.to_string();
for (name, regex, risk_score) in &self.compiled_patterns {
for mat in regex.find_iter(input) {
let mut metadata = HashMap::new();
metadata.insert("pattern_name".to_string(), name.clone());
let entity = Entity {
entity_type: "regex_match".to_string(),
text: mat.as_str().to_string(),
start: mat.start(),
end: mat.end(),
confidence: 1.0,
metadata,
};
entities.push(entity);
max_risk = max_risk.max(*risk_score);
if self.config.redact {
let redaction = "*".repeat(mat.end() - mat.start());
sanitized_text.replace_range(mat.start()..mat.end(), &redaction);
}
}
}
if entities.is_empty() {
return Ok(ScanResult::pass(input.to_string()));
}
let description = format!("Found {} regex match(es)", entities.len());
let risk_factor = RiskFactor::new(
"regex_match",
&description,
if max_risk >= 0.7 {
Severity::High
} else {
Severity::Medium
},
max_risk,
);
let mut result = ScanResult::new(sanitized_text, false, max_risk)
.with_risk_factor(risk_factor);
for entity in entities {
result = result.with_entity(entity);
}
Ok(result)
}
fn scanner_type(&self) -> ScannerType {
ScannerType::Input
}
fn description(&self) -> &str {
"Detects patterns using regular expressions"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_regex_email_detection() {
let scanner = RegexScanner::with_patterns(vec![(
"email",
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
)])
.unwrap();
let vault = Vault::new();
let result = scanner
.scan("Contact me at test@example.com", &vault)
.await
.unwrap();
assert!(!result.is_valid);
assert_eq!(result.entities.len(), 1);
assert_eq!(result.entities[0].text, "test@example.com");
}
#[tokio::test]
async fn test_regex_no_match() {
let scanner = RegexScanner::with_patterns(vec![("email", r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b")]).unwrap();
let vault = Vault::new();
let result = scanner.scan("No emails here", &vault).await.unwrap();
assert!(result.is_valid);
assert_eq!(result.entities.len(), 0);
}
}