rust_sanitize/
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"]
52pub struct AllowlistResult {
53 pub matcher: AllowlistMatcher,
55 pub warnings: Vec<String>,
61}
62
63pub struct AllowlistMatcher {
80 exact: HashSet<String>,
81 globs: Vec<String>,
82 regexes: Vec<(String, Regex)>,
84 case_sensitive: bool,
87 seen: AtomicU64,
89}
90
91impl AllowlistMatcher {
92 #[allow(clippy::new_ret_no_self)] pub fn new(patterns: Vec<String>) -> AllowlistResult {
108 let (matcher, warnings) = Self::build(patterns, false);
109 AllowlistResult { matcher, warnings }
110 }
111
112 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 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 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 pub fn is_allowed(&self, value: &str) -> bool {
185 self.match_pattern(value).is_some()
186 }
187
188 pub fn match_pattern<'a>(&'a self, value: &str) -> Option<&'a str> {
200 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 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 pub fn seen_count(&self) -> u64 {
229 self.seen.load(Ordering::Relaxed)
230 }
231
232 pub fn pattern_count(&self) -> usize {
234 self.exact.len() + self.globs.len() + self.regexes.len()
235 }
236
237 pub fn is_empty(&self) -> bool {
239 self.exact.is_empty() && self.globs.is_empty() && self.regexes.is_empty()
240 }
241}
242
243pub(crate) fn glob_matches(pattern: &str, value: &str) -> bool {
248 let parts: Vec<&str> = pattern.split('*').collect();
249 let n = parts.len();
250
251 if !value.starts_with(parts[0]) {
253 return false;
254 }
255 if !value.ends_with(parts[n - 1]) {
257 return false;
258 }
259 if n == 2 {
261 return value.len() >= parts[0].len() + parts[n - 1].len();
264 }
265
266 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 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")); assert!(m.is_allowed("LOCALHOST")); assert!(!m.is_allowed("localhost2")); }
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")); 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 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 #[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 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 assert_eq!(m.seen_count(), 1);
411 }
412
413 #[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 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 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 #[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.")); assert!(!m.is_allowed("10.0.0.1"));
481 }
482
483 #[test]
484 fn regex_substring_match_without_anchors() {
485 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")); assert!(!m.is_allowed("xtoken-ABC-1234")); }
499
500 #[test]
501 fn regex_case_sensitive_by_default() {
502 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 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 let m = matcher(&["regex:^[A-Z]{3}$"]); assert!(m.is_allowed("ABC"));
569 assert!(!m.is_allowed("abc")); }
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}