scour_secrets/
allowlist.rs1use regex::Regex;
39use std::collections::HashSet;
40use std::sync::atomic::{AtomicU64, Ordering};
41
42#[must_use = "check .warnings for invalid or suspicious patterns"]
52#[non_exhaustive]
53pub struct AllowlistResult {
54 pub matcher: AllowlistMatcher,
56 pub warnings: Vec<String>,
62}
63
64pub struct AllowlistMatcher {
81 exact: HashSet<String>,
82 globs: Vec<String>,
83 regexes: Vec<(String, Regex)>,
85 case_sensitive: bool,
88 seen: AtomicU64,
90}
91
92impl AllowlistMatcher {
93 #[allow(clippy::new_ret_no_self)] pub fn new(patterns: Vec<String>) -> AllowlistResult {
109 let (matcher, warnings) = Self::build(patterns, false);
110 AllowlistResult { matcher, warnings }
111 }
112
113 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 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 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 pub fn is_allowed(&self, value: &str) -> bool {
186 self.match_pattern(value).is_some()
187 }
188
189 pub fn match_pattern<'a>(&'a self, value: &str) -> Option<&'a str> {
201 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 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 pub fn seen_count(&self) -> u64 {
230 self.seen.load(Ordering::Relaxed)
231 }
232
233 pub fn pattern_count(&self) -> usize {
235 self.exact.len() + self.globs.len() + self.regexes.len()
236 }
237
238 pub fn is_empty(&self) -> bool {
240 self.exact.is_empty() && self.globs.is_empty() && self.regexes.is_empty()
241 }
242}
243
244pub(crate) fn glob_matches(pattern: &str, value: &str) -> bool {
249 let parts: Vec<&str> = pattern.split('*').collect();
250 let n = parts.len();
251
252 if !value.starts_with(parts[0]) {
254 return false;
255 }
256 if !value.ends_with(parts[n - 1]) {
258 return false;
259 }
260 if n == 2 {
262 return value.len() >= parts[0].len() + parts[n - 1].len();
265 }
266
267 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 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")); assert!(m.is_allowed("LOCALHOST")); assert!(!m.is_allowed("localhost2")); }
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")); 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 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 #[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 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 assert_eq!(m.seen_count(), 1);
412 }
413
414 #[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 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 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 #[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.")); assert!(!m.is_allowed("10.0.0.1"));
482 }
483
484 #[test]
485 fn regex_substring_match_without_anchors() {
486 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")); assert!(!m.is_allowed("xtoken-ABC-1234")); }
500
501 #[test]
502 fn regex_case_sensitive_by_default() {
503 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 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 let m = matcher(&["regex:^[A-Z]{3}$"]); assert!(m.is_allowed("ABC"));
570 assert!(!m.is_allowed("abc")); }
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}