Skip to main content

rust_sanitize/
allowlist.rs

1//! Allowlist for suppressing specific values from sanitization.
2//!
3//! Values matching an allowlist entry pass through the output unchanged and
4//! are **not** recorded in the [`MappingStore`](crate::store::MappingStore).
5//! This means they also won't propagate to the Phase 2 augmented scanner as
6//! discovered literals — a value that is allowed stays allowed everywhere.
7//!
8//! # Pattern syntax
9//!
10//! Three pattern forms are supported:
11//!
12//! | Pattern                          | Matches                                        |
13//! |----------------------------------|------------------------------------------------|
14//! | `localhost`                      | Exactly `localhost`                            |
15//! | `*.internal`                     | Any value ending with `.internal` (glob)       |
16//! | `192.168.1.*`                    | Any value starting with `192.168.1.` (glob)    |
17//! | `user-*@corp.com`                | Prefix + suffix glob                           |
18//! | `regex:^192\.168\.[0-9]+\.[0-9]+$` | Full regex match                             |
19//!
20//! **Glob patterns** use `*` as the only wildcard (matches any sequence of
21//! characters). Multiple `*` wildcards are supported. Globs are
22//! case-insensitive by default (see [`AllowlistMatcher::new_case_sensitive`]).
23//!
24//! **Regex patterns** are prefixed with `regex:`. The remainder is compiled as
25//! a [`regex::Regex`] and matched against the full value. Regex patterns are
26//! always case-sensitive; use the `(?i)` flag inside the pattern for
27//! case-insensitive matching. The `regex:` prefix is stripped before
28//! compiling, so `regex:^foo$` compiles to `^foo$`.
29//!
30//! If a regex fails to compile, a warning is returned and the pattern is
31//! skipped (the matcher continues without it rather than panicking).
32//!
33//! If a plain pattern (no `*`, no `regex:` prefix) contains regex
34//! metacharacters (`^`, `$`, `+`, `(`, `)`), a warning is emitted suggesting
35//! the `regex:` prefix — those characters are still matched literally in the
36//! plain form.
37
38use regex::Regex;
39use std::collections::HashSet;
40use std::sync::atomic::{AtomicU64, Ordering};
41
42/// Result of building an [`AllowlistMatcher`].
43///
44/// Returned by [`AllowlistMatcher::new`] and
45/// [`AllowlistMatcher::new_case_sensitive`].
46///
47/// The `#[must_use]` attribute ensures callers don't silently discard
48/// [`warnings`](Self::warnings), which includes failed regex compilations
49/// (the pattern is **skipped** — values that should be suppressed will
50/// instead be sanitized) and metacharacter hints.
51#[must_use = "check .warnings for invalid or suspicious patterns"]
52pub struct AllowlistResult {
53    /// The compiled matcher, ready for use.
54    pub matcher: AllowlistMatcher,
55    /// Non-fatal build warnings. Includes:
56    /// - `regex:` patterns that failed to compile (pattern was skipped).
57    /// - Plain patterns containing regex metacharacters (`^`, `$`, `+`,
58    ///   `(`, `)`) that are matched literally; add the `regex:` prefix to
59    ///   use them as regexes.
60    pub warnings: Vec<String>,
61}
62
63/// Compiled allowlist that can be queried concurrently.
64///
65/// Exact patterns are stored in a [`HashSet`] for O(1) lookup. Glob patterns
66/// (those containing `*`) are stored in a [`Vec`] and scanned linearly after
67/// the hash check misses. Regex patterns (`regex:` prefix) are stored in a
68/// separate [`Vec`] and tried last. This means allowlists with many exact
69/// entries — the common case — pay no linear scan cost.
70///
71/// # Case sensitivity
72///
73/// By default the matcher is **case-insensitive**: patterns and query values
74/// are both lowercased before comparison (applies to exact and glob patterns
75/// only). Use [`AllowlistMatcher::new_case_sensitive`] when exact-case
76/// matching is required. Regex patterns (`regex:` prefix) are always
77/// case-sensitive; use the `(?i)` flag inside the pattern for
78/// case-insensitive regex matching.
79pub struct AllowlistMatcher {
80    exact: HashSet<String>,
81    globs: Vec<String>,
82    /// `(original_pattern_string, compiled_regex)` pairs from `regex:` entries.
83    regexes: Vec<(String, Regex)>,
84    /// When `false` (the default), patterns and query values are lowercased
85    /// before comparison (exact and glob only; regex patterns are unaffected).
86    case_sensitive: bool,
87    /// Number of values passed through as allowed across all `is_allowed` calls.
88    seen: AtomicU64,
89}
90
91impl AllowlistMatcher {
92    /// Build a case-insensitive [`AllowlistMatcher`] from a list of pattern strings.
93    ///
94    /// This is the default constructor. Patterns and query values are both
95    /// lowercased before comparison, so `"Localhost"` matches a pattern of
96    /// `"localhost"` and vice-versa.
97    ///
98    /// Each string is treated as a glob if it contains `*`, otherwise as an
99    /// exact match. Patterns that look like regexes (contain `^`, `$`, `+`,
100    /// `(`, or `)`) are accepted but a warning message is included in
101    /// [`AllowlistResult::warnings`] so the caller can surface it to the user.
102    ///
103    /// Always check [`AllowlistResult::warnings`]: a failed `regex:` pattern
104    /// is skipped silently, meaning values that should be suppressed will
105    /// instead be sanitized.
106    #[allow(clippy::new_ret_no_self)] // intentional: returns AllowlistResult, not Self
107    pub fn new(patterns: Vec<String>) -> AllowlistResult {
108        let (matcher, warnings) = Self::build(patterns, false);
109        AllowlistResult { matcher, warnings }
110    }
111
112    /// Build a case-sensitive [`AllowlistMatcher`] from a list of pattern strings.
113    ///
114    /// Use this when exact-case matching is required (e.g. allowlisting a
115    /// known token value that must not match differently-cased substrings).
116    ///
117    /// Always check [`AllowlistResult::warnings`]: a failed `regex:` pattern
118    /// is skipped silently.
119    pub fn new_case_sensitive(patterns: Vec<String>) -> AllowlistResult {
120        let (matcher, warnings) = Self::build(patterns, true);
121        AllowlistResult { matcher, warnings }
122    }
123
124    fn build(patterns: Vec<String>, case_sensitive: bool) -> (Self, Vec<String>) {
125        let mut exact = HashSet::new();
126        let mut globs = Vec::new();
127        let mut regexes = Vec::new();
128        let mut warnings = Vec::new();
129
130        for pat in patterns {
131            if let Some(re_src) = pat.strip_prefix("regex:") {
132                match Regex::new(re_src) {
133                    Ok(compiled) => regexes.push((pat, compiled)),
134                    Err(e) => warnings.push(format!(
135                        "allowlist pattern '{pat}' failed to compile: {e} — pattern skipped"
136                    )),
137                }
138                continue;
139            }
140
141            for ch in ['^', '$', '+', '(', ')'] {
142                // '$' followed by '{' is template-variable syntax (e.g. ${VAR}),
143                // not a regex end-anchor — skip the warning for that form.
144                if ch == '$' && !pat.replace("${", "").contains('$') {
145                    continue;
146                }
147                if pat.contains(ch) {
148                    warnings.push(format!(
149                        "allowlist pattern '{pat}' contains regex character '{ch}'; \
150                         it is matched literally — use the 'regex:' prefix for regex syntax"
151                    ));
152                    break;
153                }
154            }
155            // Normalize to lowercase for case-insensitive matchers so that
156            // both the stored pattern and the query value are in the same case.
157            let stored = if case_sensitive {
158                pat
159            } else {
160                pat.to_lowercase()
161            };
162            if stored.contains('*') {
163                globs.push(stored);
164            } else {
165                exact.insert(stored);
166            }
167        }
168
169        (
170            Self {
171                exact,
172                globs,
173                regexes,
174                case_sensitive,
175                seen: AtomicU64::new(0),
176            },
177            warnings,
178        )
179    }
180
181    /// Returns `true` if `value` matches any allowlist entry.
182    ///
183    /// Thread-safe; increments an internal counter when a match is found.
184    pub fn is_allowed(&self, value: &str) -> bool {
185        self.match_pattern(value).is_some()
186    }
187
188    /// Returns the pattern that matches `value`, or `None`.
189    ///
190    /// Lookup order: exact hash → glob scan → regex scan. Increments the seen
191    /// counter when a match is found.
192    ///
193    /// Exact and glob patterns are case-insensitive by default (the matcher
194    /// built by [`new`](Self::new) lowercases both patterns and query values
195    /// before comparison). Regex patterns (`regex:` prefix) are always matched
196    /// against the original, un-lowercased value regardless of the
197    /// case-sensitivity setting; use `(?i)` inside the pattern for
198    /// case-insensitive regex matching.
199    pub fn match_pattern<'a>(&'a self, value: &str) -> Option<&'a str> {
200        // Exact + glob: apply case normalization.
201        let normalized: std::borrow::Cow<str> = if self.case_sensitive {
202            std::borrow::Cow::Borrowed(value)
203        } else {
204            std::borrow::Cow::Owned(value.to_lowercase())
205        };
206        if let Some(s) = self.exact.get(normalized.as_ref()) {
207            self.seen.fetch_add(1, Ordering::Relaxed);
208            return Some(s.as_str());
209        }
210        for pat in &self.globs {
211            if glob_matches(pat, &normalized) {
212                self.seen.fetch_add(1, Ordering::Relaxed);
213                return Some(pat.as_str());
214            }
215        }
216        // Regex: always match against the original value (regex has (?i) for
217        // case-insensitive matching; we must not pre-lowercase the input).
218        for (pat_str, re) in &self.regexes {
219            if re.is_match(value) {
220                self.seen.fetch_add(1, Ordering::Relaxed);
221                return Some(pat_str.as_str());
222            }
223        }
224        None
225    }
226
227    /// Total number of values that have been allowed through.
228    pub fn seen_count(&self) -> u64 {
229        self.seen.load(Ordering::Relaxed)
230    }
231
232    /// Number of patterns registered (exact + glob + regex).
233    pub fn pattern_count(&self) -> usize {
234        self.exact.len() + self.globs.len() + self.regexes.len()
235    }
236
237    /// `true` if no patterns are registered (allowlist is effectively disabled).
238    pub fn is_empty(&self) -> bool {
239        self.exact.is_empty() && self.globs.is_empty() && self.regexes.is_empty()
240    }
241}
242
243/// Match `value` against a `*`-glob `pattern`.
244///
245/// `*` matches any sequence of characters (including empty). Multiple `*`
246/// wildcards are supported. Matching is case-sensitive.
247pub(crate) fn glob_matches(pattern: &str, value: &str) -> bool {
248    let parts: Vec<&str> = pattern.split('*').collect();
249    let n = parts.len();
250
251    // First segment must be a prefix.
252    if !value.starts_with(parts[0]) {
253        return false;
254    }
255    // Last segment must be a suffix.
256    if !value.ends_with(parts[n - 1]) {
257        return false;
258    }
259    // For a single `*` these two checks are sufficient.
260    if n == 2 {
261        // Guard against overlap: e.g. "ab" matching "a*b" is fine, but
262        // "a" with prefix "a" and suffix "b" must fail.
263        return value.len() >= parts[0].len() + parts[n - 1].len();
264    }
265
266    // For multiple wildcards, verify inner segments appear in order.
267    let mut pos = parts[0].len();
268    let end = value.len().saturating_sub(parts[n - 1].len());
269    for part in &parts[1..n - 1] {
270        if part.is_empty() {
271            continue;
272        }
273        match value[pos..end].find(part) {
274            Some(found) => pos += found + part.len(),
275            None => return false,
276        }
277    }
278    true
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn matcher(pats: &[&str]) -> AllowlistMatcher {
286        AllowlistMatcher::new(pats.iter().map(|s| (*s).to_string()).collect()).matcher
287    }
288
289    fn matcher_cs(pats: &[&str]) -> AllowlistMatcher {
290        AllowlistMatcher::new_case_sensitive(pats.iter().map(|s| (*s).to_string()).collect())
291            .matcher
292    }
293
294    #[test]
295    fn exact_match() {
296        // Default: case-insensitive
297        let m = matcher(&["localhost", "127.0.0.1"]);
298        assert!(m.is_allowed("localhost"));
299        assert!(m.is_allowed("127.0.0.1"));
300        assert!(m.is_allowed("Localhost")); // now matches — case-insensitive
301        assert!(m.is_allowed("LOCALHOST")); // now matches
302        assert!(!m.is_allowed("localhost2")); // suffix still fails
303    }
304
305    #[test]
306    fn exact_match_case_sensitive() {
307        let m = matcher_cs(&["localhost", "127.0.0.1"]);
308        assert!(m.is_allowed("localhost"));
309        assert!(!m.is_allowed("Localhost")); // case-sensitive: no match
310        assert!(!m.is_allowed("LOCALHOST"));
311    }
312
313    #[test]
314    fn glob_suffix() {
315        let m = matcher(&["*.internal"]);
316        assert!(m.is_allowed("db.internal"));
317        assert!(m.is_allowed("staging.db.internal"));
318        assert!(!m.is_allowed("db.internal.evil"));
319        assert!(!m.is_allowed("internal"));
320    }
321
322    #[test]
323    fn glob_prefix() {
324        let m = matcher(&["192.168.1.*"]);
325        assert!(m.is_allowed("192.168.1.1"));
326        assert!(m.is_allowed("192.168.1.255"));
327        assert!(!m.is_allowed("192.168.2.1"));
328        // * matches zero or more chars, so trailing-dot form also matches
329        assert!(m.is_allowed("192.168.1."));
330    }
331
332    #[test]
333    fn glob_middle() {
334        let m = matcher(&["user-*@corp.com"]);
335        assert!(m.is_allowed("user-alice@corp.com"));
336        assert!(m.is_allowed("user-bob@corp.com"));
337        assert!(!m.is_allowed("admin@corp.com"));
338        assert!(!m.is_allowed("user-alice@other.com"));
339    }
340
341    #[test]
342    fn glob_star_only() {
343        let m = matcher(&["*"]);
344        assert!(m.is_allowed("anything"));
345        assert!(m.is_allowed(""));
346    }
347
348    #[test]
349    fn seen_counter() {
350        let m = matcher(&["ok"]);
351        assert_eq!(m.seen_count(), 0);
352        m.is_allowed("ok");
353        m.is_allowed("ok");
354        m.is_allowed("not-ok");
355        assert_eq!(m.seen_count(), 2);
356    }
357
358    #[test]
359    fn regex_char_warning() {
360        let result = AllowlistMatcher::new(vec!["^bad$".into()]);
361        assert!(!result.warnings.is_empty());
362    }
363
364    #[test]
365    fn empty_allowlist_is_empty() {
366        let m = matcher(&[]);
367        assert!(m.is_empty());
368        assert!(!m.is_allowed("anything"));
369    }
370
371    // match_pattern
372
373    #[test]
374    fn match_pattern_returns_exact_pattern() {
375        let m = matcher(&["localhost"]);
376        assert_eq!(m.match_pattern("localhost"), Some("localhost"));
377        assert_eq!(m.match_pattern("other"), None);
378    }
379
380    #[test]
381    fn match_pattern_returns_glob_pattern() {
382        let m = matcher(&["*.internal"]);
383        assert_eq!(m.match_pattern("db.internal"), Some("*.internal"));
384        assert_eq!(m.match_pattern("github.com"), None);
385    }
386
387    #[test]
388    fn match_pattern_returns_first_matching_pattern() {
389        let m = matcher(&["*.internal", "db.*"]);
390        // "db.internal" matches both; first pattern wins
391        assert_eq!(m.match_pattern("db.internal"), Some("*.internal"));
392    }
393
394    #[test]
395    fn match_pattern_increments_seen_counter() {
396        let m = matcher(&["ok"]);
397        assert_eq!(m.seen_count(), 0);
398        m.match_pattern("ok");
399        assert_eq!(m.seen_count(), 1);
400        m.match_pattern("not-ok");
401        assert_eq!(m.seen_count(), 1);
402    }
403
404    #[test]
405    fn is_allowed_delegates_to_match_pattern() {
406        let m = matcher(&["*.internal"]);
407        assert!(m.is_allowed("db.internal"));
408        assert!(!m.is_allowed("github.com"));
409        // seen counter is shared
410        assert_eq!(m.seen_count(), 1);
411    }
412
413    // glob edge cases
414
415    #[test]
416    fn glob_multiple_wildcards() {
417        let m = matcher(&["a*b*c"]);
418        assert!(m.is_allowed("abc"));
419        assert!(m.is_allowed("aXbYc"));
420        assert!(m.is_allowed("aXXXbYYYc"));
421        assert!(!m.is_allowed("abX"));
422        assert!(!m.is_allowed("Xbc"));
423    }
424
425    #[test]
426    fn glob_adjacent_wildcards_treated_as_one() {
427        let m = matcher(&["a**b"]);
428        assert!(m.is_allowed("ab"));
429        assert!(m.is_allowed("aXb"));
430        assert!(!m.is_allowed("ba"));
431    }
432
433    #[test]
434    fn glob_empty_value_only_matches_star() {
435        let m = matcher(&["*"]);
436        assert!(m.is_allowed(""));
437        let m2 = matcher(&["a*"]);
438        assert!(!m2.is_allowed(""));
439    }
440
441    #[test]
442    fn glob_prefix_suffix_overlap_rejected() {
443        // "a*b" must not match "a" (suffix "b" requires at least one more char)
444        let m = matcher(&["a*b"]);
445        assert!(!m.is_allowed("a"));
446        assert!(!m.is_allowed("b"));
447        assert!(m.is_allowed("ab"));
448        assert!(m.is_allowed("aXb"));
449    }
450
451    #[test]
452    fn large_exact_list_all_match() {
453        // Verify HashSet lookup works correctly across many entries.
454        let words: Vec<String> = (0..500).map(|i| format!("word{i}")).collect();
455        let m = AllowlistMatcher::new(words.clone()).matcher;
456        for w in &words {
457            assert!(m.is_allowed(w), "should allow {w}");
458        }
459        assert!(!m.is_allowed("word500"));
460        assert!(!m.is_allowed("notaword"));
461    }
462
463    #[test]
464    fn exact_and_glob_coexist() {
465        let m = matcher(&["localhost", "127.0.0.1", "*.internal"]);
466        assert!(m.is_allowed("localhost"));
467        assert!(m.is_allowed("127.0.0.1"));
468        assert!(m.is_allowed("db.internal"));
469        assert!(!m.is_allowed("github.com"));
470    }
471
472    // ── regex: prefix ──────────────────────────────────────────────────────
473
474    #[test]
475    fn regex_basic_match() {
476        let m = matcher(&["regex:^192\\.168\\.[0-9]+\\.[0-9]+$"]);
477        assert!(m.is_allowed("192.168.1.1"));
478        assert!(m.is_allowed("192.168.100.255"));
479        assert!(!m.is_allowed("192.168.1.")); // trailing dot
480        assert!(!m.is_allowed("10.0.0.1"));
481    }
482
483    #[test]
484    fn regex_substring_match_without_anchors() {
485        // Without ^ and $, the regex matches as a substring.
486        let m = matcher(&["regex:internal"]);
487        assert!(m.is_allowed("db.internal.corp"));
488        assert!(m.is_allowed("internal"));
489        assert!(!m.is_allowed("external"));
490    }
491
492    #[test]
493    fn regex_anchored_full_match() {
494        let m = matcher(&["regex:^token-[A-Z]{3}-[0-9]{4}$"]);
495        assert!(m.is_allowed("token-ABC-1234"));
496        assert!(!m.is_allowed("token-AB-1234")); // too short
497        assert!(!m.is_allowed("xtoken-ABC-1234")); // extra prefix
498    }
499
500    #[test]
501    fn regex_case_sensitive_by_default() {
502        // regex: patterns are always case-sensitive; (?i) opts in.
503        let m = matcher(&["regex:^localhost$"]);
504        assert!(m.is_allowed("localhost"));
505        assert!(!m.is_allowed("LOCALHOST"));
506        assert!(!m.is_allowed("Localhost"));
507    }
508
509    #[test]
510    fn regex_case_insensitive_via_flag() {
511        let m = matcher(&["regex:(?i)^localhost$"]);
512        assert!(m.is_allowed("localhost"));
513        assert!(m.is_allowed("LOCALHOST"));
514        assert!(m.is_allowed("LocalHost"));
515    }
516
517    #[test]
518    fn regex_invalid_pattern_produces_warning_and_is_skipped() {
519        let result = AllowlistMatcher::new(vec!["regex:[invalid".into()]);
520        assert!(
521            !result.warnings.is_empty(),
522            "invalid regex must produce a warning"
523        );
524        assert!(result.warnings[0].contains("failed to compile"));
525        // Pattern is skipped — nothing is allowed.
526        assert!(!result.matcher.is_allowed("anything"));
527        assert_eq!(result.matcher.pattern_count(), 0);
528    }
529
530    #[test]
531    fn regex_match_pattern_returns_full_prefixed_string() {
532        let m = matcher(&["regex:^10\\.0\\."]);
533        assert_eq!(m.match_pattern("10.0.1.5"), Some("regex:^10\\.0\\."),);
534        assert_eq!(m.match_pattern("192.168.1.1"), None);
535    }
536
537    #[test]
538    fn regex_seen_counter_increments() {
539        let m = matcher(&["regex:^test"]);
540        assert_eq!(m.seen_count(), 0);
541        m.is_allowed("test-value");
542        m.is_allowed("test-value");
543        m.is_allowed("other");
544        assert_eq!(m.seen_count(), 2);
545    }
546
547    #[test]
548    fn regex_coexists_with_exact_and_glob() {
549        let m = matcher(&[
550            "localhost",
551            "*.internal",
552            "regex:^10\\.[0-9]+\\.[0-9]+\\.[0-9]+$",
553        ]);
554        assert!(m.is_allowed("localhost"));
555        assert!(m.is_allowed("db.internal"));
556        assert!(m.is_allowed("10.0.0.1"));
557        assert!(m.is_allowed("10.255.255.255"));
558        assert!(!m.is_allowed("192.168.1.1"));
559        assert!(!m.is_allowed("github.com"));
560        assert_eq!(m.pattern_count(), 3);
561    }
562
563    #[test]
564    fn regex_not_subject_to_case_insensitive_lowercasing() {
565        // The case-insensitive matcher lowercases exact/glob query values,
566        // but regex must receive the original value to honour (?i) correctly.
567        let m = matcher(&["regex:^[A-Z]{3}$"]); // matches exactly 3 uppercase letters
568        assert!(m.is_allowed("ABC"));
569        assert!(!m.is_allowed("abc")); // no (?i) — must not match lowercased
570    }
571
572    #[test]
573    fn metacharacter_warning_updated_to_suggest_regex_prefix() {
574        let result = AllowlistMatcher::new(vec!["^bad$".into()]);
575        assert!(!result.warnings.is_empty());
576        assert!(
577            result.warnings[0].contains("regex:"),
578            "warning should suggest regex: prefix, got: {}",
579            result.warnings[0],
580        );
581    }
582}