1use std::collections::HashSet;
4
5use regex::{Regex, RegexBuilder, RegexSet, RegexSetBuilder};
6
7use crate::Error;
8use crate::normalize::Normalized;
9use crate::report::{Decision, Finding, Redacted, Report};
10use crate::rules::{Category, Policy, Rule, RuleSet, Severity};
11
12pub(crate) const INVISIBLE_RULE_ID: &str = "obfuscation.invisible_chars";
14
15const REGEX_SIZE_LIMIT: usize = 10 * (1 << 20);
17
18struct Compiled {
19 rule: Rule,
20 re: Regex,
21}
22
23pub struct Inspector {
28 rules: Vec<Compiled>,
29 set: RegexSet,
30 policy: Policy,
31}
32
33impl std::fmt::Debug for Inspector {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 f.debug_struct("Inspector")
36 .field("rules", &self.rules.len())
37 .field("policy", &self.policy)
38 .finish_non_exhaustive()
39 }
40}
41
42impl Inspector {
43 pub fn new(rule_set: RuleSet) -> Result<Self, Error> {
45 rule_set.validate_ids()?;
46 let mut rules = Vec::with_capacity(rule_set.rules.len());
47 for rule in rule_set.rules {
48 let re = RegexBuilder::new(&rule.pattern)
49 .size_limit(REGEX_SIZE_LIMIT)
50 .build()
51 .map_err(|e| Error::InvalidPattern {
52 id: rule.id.clone(),
53 source: Box::new(e),
54 })?;
55 rules.push(Compiled { rule, re });
56 }
57 let set = RegexSetBuilder::new(rules.iter().map(|c| c.rule.pattern.as_str()))
58 .size_limit(REGEX_SIZE_LIMIT * 4)
59 .build()
60 .map_err(|e| Error::InvalidPattern {
61 id: "<rule set>".to_owned(),
62 source: Box::new(e),
63 })?;
64 Ok(Self {
65 rules,
66 set,
67 policy: rule_set.policy,
68 })
69 }
70
71 pub fn with_default_rules() -> Self {
77 Self::new(RuleSet::default_rules()).expect("bundled rules must compile")
78 }
79
80 pub fn policy(&self) -> &Policy {
82 &self.policy
83 }
84
85 pub fn rules(&self) -> impl Iterator<Item = &Rule> {
87 self.rules.iter().map(|c| &c.rule)
88 }
89
90 pub fn scan(&self, text: &str) -> Report {
92 let norm = Normalized::new(text);
93 let mut findings = Vec::new();
94
95 for idx in self.set.matches(norm.text()) {
96 let compiled = &self.rules[idx];
97 let raw_spans: Option<HashSet<(usize, usize)>> = norm
100 .changed()
101 .then(|| spans(&compiled.re, compiled.rule.validator, text).collect());
102
103 for (s, e) in spans(&compiled.re, compiled.rule.validator, norm.text()) {
104 let (start, end) = norm.to_original(s, e);
105 let normalized_only = raw_spans
106 .as_ref()
107 .is_some_and(|raw| !raw.contains(&(start, end)));
108 findings.push(Finding {
109 rule_id: compiled.rule.id.clone(),
110 category: compiled.rule.category,
111 severity: compiled.rule.severity,
112 start,
113 end,
114 normalized_only,
115 });
116 }
117 }
118
119 if norm.removed_invisible() > 0 {
120 findings.push(Finding {
121 rule_id: INVISIBLE_RULE_ID.to_owned(),
122 category: Category::Injection,
123 severity: Severity::Low,
124 start: 0,
125 end: text.len(),
126 normalized_only: true,
127 });
128 }
129
130 findings.sort_by(|a, b| {
131 a.start
132 .cmp(&b.start)
133 .then_with(|| b.severity.cmp(&a.severity))
134 .then_with(|| a.end.cmp(&b.end))
135 .then_with(|| a.rule_id.cmp(&b.rule_id))
136 });
137
138 let decision = decide(&self.policy, findings.iter().map(|f| f.severity).max());
139 Report {
140 decision,
141 findings,
142 normalized: norm.changed(),
143 removed_invisible: norm.removed_invisible(),
144 }
145 }
146
147 pub fn redact(&self, text: &str) -> Redacted {
154 let report = self.scan(text);
155 let mut out = String::with_capacity(text.len());
156 let mut cursor = 0usize;
157 let mut pending: Option<(usize, usize, String)> = None;
158
159 let redactable = report
160 .findings
161 .iter()
162 .filter(|f| f.rule_id != INVISIBLE_RULE_ID && self.policy.redact.contains(&f.category));
163
164 for f in redactable {
165 let marker = self.marker_for(&f.rule_id, f.category);
166 match pending.as_mut() {
167 Some((_, end, _)) if f.start < *end => {
168 *end = (*end).max(f.end);
169 }
170 _ => {
171 if let Some((s, e, m)) = pending.take() {
172 out.push_str(&text[cursor..s]);
173 out.push_str(&m);
174 cursor = e;
175 }
176 pending = Some((f.start, f.end, marker));
177 }
178 }
179 }
180 if let Some((s, e, m)) = pending.take() {
181 out.push_str(&text[cursor..s]);
182 out.push_str(&m);
183 cursor = e;
184 }
185 out.push_str(&text[cursor..]);
186
187 Redacted { text: out, report }
188 }
189
190 fn marker_for(&self, rule_id: &str, category: Category) -> String {
191 self.rules
192 .iter()
193 .find(|c| c.rule.id == rule_id)
194 .and_then(|c| c.rule.redact_with.clone())
195 .unwrap_or_else(|| format!("[REDACTED:{}]", category.label()))
196 }
197}
198
199fn spans<'h>(
202 re: &'h Regex,
203 validator: Option<crate::Validator>,
204 hay: &'h str,
205) -> impl Iterator<Item = (usize, usize)> + 'h {
206 re.captures_iter(hay).filter_map(move |caps| {
207 let m = caps.get(1).or_else(|| caps.get(0))?;
208 if m.is_empty() {
209 return None;
210 }
211 if validator.is_some_and(|v| !v.accepts(m.as_str())) {
212 return None;
213 }
214 Some((m.start(), m.end()))
215 })
216}
217
218fn decide(policy: &Policy, highest: Option<Severity>) -> Decision {
219 match highest {
220 Some(s) if s >= policy.block_at => Decision::Block,
221 Some(s) if s >= policy.warn_at => Decision::Warn,
222 _ => Decision::Allow,
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 fn inspector(toml_text: &str) -> Inspector {
231 Inspector::new(RuleSet::from_toml(toml_text).unwrap()).unwrap()
232 }
233
234 #[test]
235 fn no_rules_allows_everything() {
236 let i = inspector("");
237 let r = i.scan("anything at all");
238 assert_eq!(r.decision, Decision::Allow);
239 assert_eq!(r.findings, Vec::new());
240 }
241
242 #[test]
243 fn capture_group_one_is_the_finding() {
244 let i = inspector(
245 r#"
246 [[rules]]
247 id = "t"
248 category = "pii"
249 severity = "high"
250 pattern = '''(?:^|[^0-9])(0[0-9]{2}-[0-9]{4}-[0-9]{4})(?:[^0-9]|$)'''
251 "#,
252 );
253 let text = "tel: 090-1234-5678.";
254 let r = i.scan(text);
255 assert_eq!(r.findings.len(), 1);
256 assert_eq!(r.findings[0].excerpt(text), "090-1234-5678");
257 assert_eq!(r.decision, Decision::Block);
258 }
259
260 #[test]
261 fn thresholds_drive_the_decision() {
262 let i = inspector(
263 r#"
264 [policy]
265 block_at = "critical"
266 warn_at = "high"
267 [[rules]]
268 id = "t"
269 category = "injection"
270 severity = "high"
271 pattern = "ignore"
272 "#,
273 );
274 assert_eq!(i.scan("please ignore").decision, Decision::Warn);
275 assert_eq!(i.scan("fine").decision, Decision::Allow);
276 }
277
278 #[test]
279 fn redaction_merges_overlaps_and_respects_categories() {
280 let i = inspector(
281 r#"
282 [[rules]]
283 id = "a"
284 category = "pii"
285 severity = "high"
286 pattern = "abcd"
287 [[rules]]
288 id = "b"
289 category = "pii"
290 severity = "low"
291 pattern = "cdef"
292 redact_with = "<b>"
293 [[rules]]
294 id = "c"
295 category = "injection"
296 severity = "high"
297 pattern = "xyz"
298 "#,
299 );
300 let r = i.redact("..abcdef..xyz");
301 assert_eq!(r.text, "..[REDACTED:PII]..xyz");
302 assert_eq!(r.report.findings.len(), 3);
303 }
304
305 #[test]
306 fn invalid_pattern_names_the_rule() {
307 let err = Inspector::new(
308 RuleSet::from_toml(
309 r#"
310 [[rules]]
311 id = "broken"
312 category = "pii"
313 severity = "low"
314 pattern = "("
315 "#,
316 )
317 .unwrap(),
318 )
319 .unwrap_err();
320 assert!(matches!(err, Error::InvalidPattern { id, .. } if id == "broken"));
321 }
322}