Skip to main content

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