1pub mod binary;
14pub mod chars_util;
15pub mod file_upload;
16pub mod ldap_ipv4;
17pub mod matchers;
18pub mod pickle;
19pub mod pyregex;
20pub mod scan_window;
21pub mod shell_validators;
22pub mod table;
23pub mod templates;
24pub mod xml_xxe;
25
26use std::sync::LazyLock;
27
28use crate::scan_budget::{SCAN_TIME_BUDGET_EXHAUSTED_PATTERN, ScanTimeBudget};
29use pyregex::{Candidate, PyRegex};
30
31#[derive(Debug, Clone, PartialEq)]
33pub struct RegexThreat {
34 pub pattern: String,
35 pub match_text: String,
36 pub position: usize,
38 pub category: String,
39 pub weight: f64,
40}
41
42pub struct CompiledEntry {
43 pub entry: table::TableEntry,
44 pub re: Option<PyRegex>,
45}
46
47fn rewrite_for_crate(source: &str) -> String {
48 source.replace(r"\A(?:(?!\n).)*", r"\A[^\n]*")
52}
53
54const STRUCTURAL_IDS: &[usize] = &[
57 3, 9, 12, 21, 27, 33, 42, 45, 62, 63, 64, 75, 78, 79, 90, 91, 92, 98, 99, 101, 102, 103, 104,
58 106, 107, 109, 110, 111, 112, 113, 114, 115, 117, 119, 121, 122, 123, 124, 125, 126, 128, 130,
59 131, 132, 133, 134, 135, 136, 139, 144, 145, 146, 147,
60];
61
62fn is_structural(id: usize) -> bool {
63 STRUCTURAL_IDS.contains(&id)
64}
65
66pub static COMPILED_TABLE: LazyLock<Vec<CompiledEntry>> = LazyLock::new(|| {
67 table::PATTERN_DEFINITIONS
68 .iter()
69 .map(|entry| {
70 let ignore_case = !entry.source.contains("(?-i:");
71 let re = PyRegex::compile(&rewrite_for_crate(entry.source), ignore_case)
72 .map_err(|e| format!("pattern id {}: {e}", entry.id))
73 .ok();
74 assert!(
75 re.is_some() || is_structural(entry.id),
76 "pattern id {} has neither a compiled regex nor a structural matcher",
77 entry.id
78 );
79 CompiledEntry {
80 entry: table::TableEntry {
81 id: entry.id,
82 source: entry.source,
83 contexts: entry.contexts,
84 category: entry.category,
85 },
86 re,
87 }
88 })
89 .collect()
90});
91
92pub fn resolve_pattern_weight(pattern_source: &str) -> f64 {
93 table::DETECTION_PATTERN_WEIGHT_OVERRIDES
94 .get(pattern_source)
95 .copied()
96 .unwrap_or(1.0)
97}
98
99#[must_use]
102fn candidate_accepts(
103 entry: &CompiledEntry,
104 haystack: &str,
105 candidate: Candidate,
106 context: &str,
107) -> bool {
108 let id = entry.entry.id;
109 let text = candidate.text(haystack);
110 match id {
111 42 => shell_validators::glued_backtick_pair_is_injection(haystack, candidate, context),
112 43 => shell_validators::dollar_substitution_pair_is_injection(haystack, candidate, context),
113 58 => shell_validators::brace_expansion_is_dangerous_command(text),
114 59 => shell_validators::quote_splice_token_is_dangerous_command(text),
115 60 => {
116 shell_validators::glob_wildcard_token_is_dangerous_command(haystack, candidate, context)
117 }
118 66 | 67 | 69 => {
119 let Some(compiled) = entry.re.as_ref() else {
120 return true;
121 };
122 ldap_ipv4::ldap_wildcard_chain_is_injection(compiled, haystack, &candidate)
123 }
124 68 => {
125 let Some(compiled) = entry.re.as_ref() else {
126 return true;
127 };
128 ldap_ipv4::ldap_paren_conjunction_is_injection(compiled, haystack, &candidate)
129 }
130 105 => ldap_ipv4::source_extension_path_is_probe(context),
131 79 => ldap_ipv4::legacy_ipv4_match_is_blocked(text),
132 152 => {
133 let group_one_end = candidate.start
135 + haystack[candidate.start..]
136 .find('\n')
137 .and_then(|rel| {
138 let after_first = candidate.start + rel + 1;
139 haystack[after_first..]
140 .find('\n')
141 .map(|r2| after_first + r2 + 1)
142 })
143 .unwrap_or(candidate.end);
144 pickle::pickle_global_candidate_is_injection(haystack, candidate, group_one_end)
145 }
146 _ => true,
147 }
148}
149
150fn scan_window_candidates(entry: &CompiledEntry, haystack: &str) -> Option<Vec<Candidate>> {
153 let compiled = entry.re.as_ref()?;
154 let id = entry.entry.id;
155 let (prefix_src, terminator_src) = match id {
156 0 => ("<script", r"<\/script\s*>"),
157 5 => (r"<[A-Za-z/][^<>]*style\s*=", r"\)"),
158 6 => ("<object", r"<\/object\s*>"),
159 7 => ("<embed", r"<\/embed\s*>"),
160 8 => ("<applet", r"<\/applet\s*>"),
161 37 => (r"\.\.;", r"[/\\]"),
162 76 => ("<!\\[CDATA\\[", "\\]\\]>"),
163 _ => return None,
164 };
165 #[cfg(not(coverage))] let prefix = PyRegex::compile(prefix_src, true).ok()?;
167 #[cfg(coverage)]
168 let prefix = PyRegex::compile(prefix_src, true).expect("statically valid literal");
169 #[cfg(not(coverage))] let terminator = PyRegex::compile(terminator_src, true).ok()?;
171 #[cfg(coverage)]
172 let terminator = PyRegex::compile(terminator_src, true).expect("statically valid literal");
173 Some(scan_window::bounded_finditer(
174 haystack,
175 compiled,
176 &prefix,
177 &terminator,
178 ))
179}
180
181#[must_use]
185#[allow(
186 clippy::too_many_lines,
187 reason = "pattern dispatch mirrors the reference table order; splitting would obscure the 1:1 mapping"
188)]
189fn pattern_candidates(entry: &CompiledEntry, haystack: &str) -> Vec<Candidate> {
190 let id = entry.entry.id;
191 let source = entry.entry.source;
192 if let Some(candidates) = scan_window_candidates(entry, haystack) {
193 return candidates;
194 }
195 match id {
196 74 => return xml_xxe::xml_system_finditer(haystack),
197 77 => return xml_xxe::xml_internal_entity_finditer(haystack),
198 75 => return xml_xxe::xml_xxe_public_external_dtd_finditer(haystack),
199 3 => return matchers::xss_event_handler_finditer(haystack),
200 9 => return matchers::sqli_select_from_finditer(haystack),
201 12 => return matchers::sqli_tautology_finditer(haystack),
202 21 => return matchers::sqli_inline_comment_finditer(haystack),
203 27 => return matchers::sqli_order_by_terminator_finditer(haystack),
204 42 => return matchers::glued_backtick_candidate_finditer(haystack),
205 45 => return matchers::shell_dash_flag_finditer(haystack),
206 62 => return matchers::file_inclusion_bare_host_finditer(haystack),
207 63 => return matchers::file_inclusion_scheme_path_finditer(haystack),
208 64 => return matchers::file_inclusion_template_url_finditer(haystack),
209 103 => return matchers::sensitive_path_config(haystack),
210 78 => return matchers::ssrf_private_host_finditer(haystack),
211 79 => return matchers::ssrf_numeric_host_finditer(haystack),
212 101 => return matchers::sensitive_path_env(haystack),
213 104 => {
214 return matchers::sensitive_path_scan_ext(haystack, &["map"]);
215 }
216 106 => {
217 return matchers::sensitive_path_dot_alt(haystack, &["git", "svn", "hg", "bzr"]);
218 }
219 109 => return matchers::sensitive_path_wp_admin(haystack),
220 111 => {
221 return matchers::sensitive_path_literal_ext(
222 haystack,
223 &["phpinfo", "info", "test", "php_info"],
224 "php",
225 );
226 }
227 113 => {
228 return matchers::sensitive_path_scan_ext(haystack, matchers::BACKUP_EXTENSIONS);
229 }
230 114 => return matchers::sensitive_path_literal(haystack, matchers::DOUBLE_DOT_BAD),
231 117 => return matchers::sensitive_path_management(haystack),
232 119 => {
233 return matchers::sensitive_path_literal(
234 haystack,
235 &["actuator", "server-status", "telescope"],
236 );
237 }
238 121 => {
239 return matchers::sensitive_path_literal_suffix(
240 haystack,
241 matchers::RECON_APP_BAD,
242 matchers::BadSuffix::DashRun,
243 );
244 }
245 122 => return matchers::sensitive_path_literal(haystack, &["cgi-bin", "cgi-mod"]),
246 123 => {
247 return matchers::sensitive_path_literal(
248 haystack,
249 &["HNAP1", "IPCamDesc.xml", "SDK/webLanguage"],
250 );
251 }
252 124 => return matchers::sensitive_path_literal(haystack, &["language", "languages"]),
253 125 => {
254 return matchers::sensitive_path_literal_suffix_dot(
255 haystack,
256 matchers::RECON_README_BAD,
257 );
258 }
259 126 => {
260 return matchers::sensitive_path_literal(
261 haystack,
262 &[
263 "sap",
264 "ise",
265 "nidp",
266 "cslu",
267 "rustfs",
268 "developmentserver",
269 "fog/management",
270 "lms/db",
271 "json/login_session",
272 "sms_mp",
273 "plugin/webs_model",
274 "wsman",
275 "am_bin",
276 ],
277 );
278 }
279 128 => {
280 return matchers::sensitive_path_literal(haystack, &[".openclaw", ".clawdbot"]);
281 }
282 130 => return matchers::sensitive_path_literal(haystack, &["inicio.html", "inicio.htm"]),
283 131 => {
284 return matchers::sensitive_path_literal(
285 haystack,
286 &[
287 ".streamlit",
288 ".gpt-pilot",
289 ".aider",
290 ".cursor",
291 ".windsurf",
292 ".copilot",
293 ".devcontainer",
294 ],
295 );
296 }
297 132 => {
298 return matchers::sensitive_path_literal_suffix(
299 haystack,
300 matchers::DOCKERFILE_BAD,
301 matchers::BadSuffix::YamlSuffix,
302 );
303 }
304 133 => return matchers::sensitive_path_secrets(haystack),
305 134 => return matchers::sensitive_path_literal(haystack, &["autodiscover"]),
306 135 => return matchers::sensitive_path_literal(haystack, &["dns-query"]),
307 136 => {
308 return matchers::sensitive_path_literal(
309 haystack,
310 &[
311 ".git/refs",
312 ".git/index",
313 ".git/HEAD",
314 ".git/objects",
315 ".git/logs",
316 ],
317 );
318 }
319 139 => {
320 return matchers::proto_pollution_assign_finditer(haystack);
321 }
322 144 => return matchers::deserialization_b64_finditer(haystack, "rO0AB"),
323 145 => return matchers::deserialization_b64_finditer(haystack, "AAEAAAD"),
324 146 => {
325 let mut out = matchers::deserialization_b64_finditer(haystack, "gASV");
327 out.extend(matchers::deserialization_b64_finditer(haystack, "gAWV"));
328 out.sort_by_key(|c| c.start);
329 return out;
330 }
331 147 => {
332 let mut out = Vec::new();
334 for magic in ["BAhJ", "BAhv", "BAh7", "BAhb", "BAhV"] {
335 out.extend(matchers::deserialization_b64_finditer(haystack, magic));
336 }
337 out.sort_by_key(|c| c.start);
338 return out;
339 }
340 17 => {
341 return entry
342 .re
343 .as_ref()
344 .map(|compiled| matchers::load_file_scan_matches(haystack, compiled))
345 .unwrap_or_default();
346 }
347 40 => {
348 return entry
349 .re
350 .as_ref()
351 .map(|compiled| matchers::dollar_substitution_scan_matches(haystack, compiled))
352 .unwrap_or_default();
353 }
354 47 => {
355 return entry
356 .re
357 .as_ref()
358 .map(|compiled| matchers::shell_dash_c_finditer(haystack, compiled))
359 .unwrap_or_default();
360 }
361 59 => {
362 return entry
363 .re
364 .as_ref()
365 .map(|compiled| matchers::quote_splice_finditer(haystack, compiled))
366 .unwrap_or_default();
367 }
368 60 => {
369 return entry
370 .re
371 .as_ref()
372 .map(|compiled| matchers::glob_wildcard_scan_matches(haystack, compiled))
373 .unwrap_or_default();
374 }
375 70 => {
376 return entry
377 .re
378 .as_ref()
379 .map(|compiled| matchers::ldap_null_byte_attr_raw(haystack, compiled))
380 .unwrap_or_default();
381 }
382 72 => {
383 return entry
384 .re
385 .as_ref()
386 .map(|compiled| matchers::ldap_null_byte_attr_decoded(haystack, compiled))
387 .unwrap_or_default();
388 }
389 89..=92 => {
390 return file_upload::file_upload_scan_matches(
391 haystack,
392 source,
393 source_of_id(89),
394 source_of_id(90),
395 source_of_id(91),
396 source_of_id(92),
397 );
398 }
399 94 => {
400 return templates::template_keyword_matches(
401 haystack,
402 &templates::KIND_CURLY_KEYWORD,
403 true,
404 );
405 }
406 95 => {
407 return templates::template_keyword_matches(
408 haystack,
409 &templates::KIND_PERCENT_KEYWORD,
410 true,
411 );
412 }
413 96 => {
414 return templates::template_expression_matches(haystack, &templates::KIND_ASP, true);
415 }
416 97 => {
417 return templates::template_expression_matches(haystack, &templates::KIND_DOLLAR, true);
418 }
419 98 => {
420 return templates::template_expression_matches(
421 haystack,
422 &templates::KIND_CURLY_CALL,
423 true,
424 );
425 }
426 99 => {
427 return templates::template_expression_matches(haystack, &templates::KIND_HASH, true);
428 }
429 33 => return matchers::lexicon_path_finditer(haystack, LEXICON_ETC_PATH, true),
430 102 => return matchers::lexicon_path_finditer(haystack, LEXICON_ENV_PATH, true),
431 107 => return matchers::lexicon_path_finditer(haystack, LEXICON_GIT_PATH, true),
432 110 => return matchers::lexicon_path_finditer(haystack, LEXICON_WP_PATH, true),
433 112 => return matchers::lexicon_path_finditer(haystack, LEXICON_PHPINFO_PATH, true),
434 115 => return matchers::lexicon_path_finditer(haystack, LEXICON_HTACCESS_PATH, true),
435 _ => {}
436 }
437 entry.re.as_ref().map_or_else(Vec::new, |compiled| {
439 compiled
440 .re()
441 .find_iter(haystack)
442 .map(|m| Candidate::new(m.start(), m.end()))
443 .collect()
444 })
445}
446
447fn source_of_id(id: usize) -> &'static str {
448 table::PATTERN_DEFINITIONS
449 .iter()
450 .find(|e| e.id == id)
451 .map_or("", |e| e.source)
452}
453
454pub const LEXICON_ETC_PATH: &str = r"\A[^\n]*[/\\](?:etc/(?:passwd|shadow|group|hosts|motd|issue|mysql/my\.cnf|ssh/ssh_config))(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?\b";
458pub const LEXICON_ENV_PATH: &str = r"\A[^\n]*[/\\](?:\.env(?:\.\w+)?)(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?\b";
459pub const LEXICON_GIT_PATH: &str = r"\A[^\n]*[/\\](?:\.(?:git|svn|hg|bzr))(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?\b";
460pub const LEXICON_WP_PATH: &str = r"\A[^\n]*[/\\](?:(?:wp-(?:admin|login|content|includes|config)|administrator|xmlrpc)\.?(?:php)?)(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?\b";
461pub const LEXICON_PHPINFO_PATH: &str = r"\A[^\n]*[/\\](?:(?:phpinfo|info|test|php_info)\.php)(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?\b";
462pub const LEXICON_HTACCESS_PATH: &str = r"\A[^\n]*[/\\](?:(?:\.htaccess|\.htpasswd|\.DS_Store|Thumbs\.db|\.npmrc|\.dockerenv|web\.config))(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?\b";
463
464const RECON_BARE_PATH_CONTEXTS: &[&str] = &["url_path", "unknown"];
467
468#[must_use]
473fn recon_path_value_is_probe(matched: &str, validator_context: &str) -> bool {
474 let base = validator_context.split(':').next().unwrap_or("");
475 RECON_BARE_PATH_CONTEXTS.contains(&base)
476 || matched.starts_with('/')
477 || matched.starts_with('\\')
478}
479
480#[must_use]
483pub fn find_first_threat(
484 entry: &CompiledEntry,
485 haystack: &str,
486 context: &str,
487 binary_prefix: Option<&[u32]>,
488) -> Option<RegexThreat> {
489 let noise_prone = table::NOISE_PRONE_PATTERN_SOURCES.contains(entry.entry.source);
490 let recon_optional_separator =
491 table::RECON_OPTIONAL_SEPARATOR_PATTERN_SOURCES.contains(entry.entry.source);
492 for candidate in pattern_candidates(entry, haystack) {
493 if !candidate_accepts(entry, haystack, candidate, context) {
494 continue;
495 }
496 if recon_optional_separator && !recon_path_value_is_probe(candidate.text(haystack), context)
501 {
502 continue;
503 }
504 if noise_prone
505 && let Some(prefix) = binary_prefix
506 && binary::match_is_binary_density(
507 prefix,
508 pyregex::cp_index(haystack, candidate.start),
509 pyregex::cp_index(haystack, candidate.end),
510 )
511 {
512 continue;
513 }
514 return Some(RegexThreat {
515 pattern: entry.entry.source.to_owned(),
516 match_text: candidate.text(haystack).to_owned(),
517 position: pyregex::cp_index(haystack, candidate.start),
518 category: entry.entry.category.to_owned(),
519 weight: resolve_pattern_weight(entry.entry.source),
520 });
521 }
522 None
523}
524
525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
527pub enum ViewFilter {
528 All,
530 Processed,
532 Raw,
534 UrlDecoded,
536}
537
538#[must_use]
539pub fn is_excluded_from_view(source: &str, filter: ViewFilter) -> bool {
540 let raw_only = table::DETECTION_RAW_VIEW_PATTERN_SOURCES.contains(source);
541 let url_only = table::DETECTION_URL_DECODED_VIEW_PATTERN_SOURCES.contains(source);
542 let recon_raw_only = table::DETECTION_RECON_RAW_VIEW_PATTERN_SOURCES.contains(source);
547 match filter {
548 ViewFilter::All => false,
549 ViewFilter::Processed => raw_only || url_only,
550 ViewFilter::Raw => url_only || !(raw_only || recon_raw_only),
551 ViewFilter::UrlDecoded => raw_only || !url_only,
552 }
553}
554
555const SIZE_GATED_IDS: &[usize] = &[
559 32, 33, 34, 35, 36, 102, 107, 110, 112, 115, 101, 103, 104, 105, 106, 109, 111, 113, 114, 116,
560 117, 119, 121, 122, 123, 124, 125, 126, 128, 130, 131, 132, 133, 134, 135, 136,
561];
562
563pub const GATED_PATTERN_MAX_SUBJECT_BYTES: usize = 15_360;
569
570fn first_line_byte_length(content: &str) -> usize {
571 content.find('\n').unwrap_or(content.len())
572}
573
574fn size_gated(content: &str) -> bool {
575 first_line_byte_length(content) >= GATED_PATTERN_MAX_SUBJECT_BYTES
576}
577
578#[must_use]
582pub fn scan_view(
583 content: &str,
584 filter: ViewFilter,
585 normalized_context: &str,
586 skip_filter: bool,
587 validator_context: &str,
588) -> Vec<RegexThreat> {
589 scan_view_budgeted(
590 content,
591 filter,
592 normalized_context,
593 skip_filter,
594 validator_context,
595 &mut ScanTimeBudget::unbounded(),
596 )
597}
598
599#[must_use]
605pub fn scan_view_budgeted(
606 content: &str,
607 filter: ViewFilter,
608 normalized_context: &str,
609 skip_filter: bool,
610 validator_context: &str,
611 budget: &mut ScanTimeBudget,
612) -> Vec<RegexThreat> {
613 let binary_prefix = binary::build_binary_prefix(content);
614 let gating = size_gated(content);
615 let mut threats = Vec::new();
616 for entry in COMPILED_TABLE.iter() {
617 if budget.exhausted() {
618 threats.push(scan_time_budget_exhausted_threat());
619 break;
620 }
621 if is_excluded_from_view(entry.entry.source, filter) {
622 continue;
623 }
624 if !skip_filter && !entry.entry.contexts.contains(&normalized_context) {
625 continue;
626 }
627 if gating && SIZE_GATED_IDS.contains(&entry.entry.id) {
628 continue;
629 }
630 if let Some(threat) = budget
631 .measure(|| find_first_threat(entry, content, validator_context, Some(&binary_prefix)))
632 {
633 threats.push(threat);
634 }
635 }
636 threats
637}
638
639#[must_use]
643pub fn scan_time_budget_exhausted_threat() -> RegexThreat {
644 RegexThreat {
645 pattern: SCAN_TIME_BUDGET_EXHAUSTED_PATTERN.to_owned(),
646 match_text: String::new(),
647 position: 0,
648 category: "custom".to_owned(),
649 weight: 1.0,
650 }
651}
652
653#[must_use]
656pub fn decoded_view_traversal_threat(processed: &str, raw_view: &str) -> Option<RegexThreat> {
657 static SHAPE: LazyLock<PyRegex> =
658 LazyLock::new(|| PyRegex::compile(r"\.\.[\\/]", false).expect("static shape regex"));
659 let decoded_matches: Vec<Candidate> = SHAPE
660 .re()
661 .find_iter(processed)
662 .map(|m| Candidate::new(m.start(), m.end()))
663 .collect();
664 let raw_count = SHAPE.re().find_iter(raw_view).count();
665 if decoded_matches.len() <= raw_count {
666 return None;
667 }
668 let first = decoded_matches[0];
669 Some(RegexThreat {
670 pattern: r"\.\.[\\/]".to_owned(),
671 match_text: first.text(processed).to_owned(),
672 position: pyregex::cp_index(processed, first.start),
673 category: "path_traversal".to_owned(),
674 weight: 1.0,
675 })
676}
677
678#[must_use]
681pub fn decode_budget_exhausted_threat() -> RegexThreat {
682 RegexThreat {
683 pattern: "decode_budget_exhausted".to_owned(),
684 match_text: "decode_budget_exhausted".to_owned(),
685 position: 0,
686 category: "custom".to_owned(),
687 weight: 1.0,
688 }
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694
695 fn entry_for(id: usize) -> &'static CompiledEntry {
696 COMPILED_TABLE
697 .iter()
698 .find(|e| e.entry.id == id)
699 .expect("entry exists")
700 }
701
702 fn threat(id: usize, content: &str) -> Option<RegexThreat> {
703 find_first_threat(entry_for(id), content, "request_body", None)
704 }
705
706 #[test]
707 fn compiled_table_is_complete() {
708 assert_eq!(COMPILED_TABLE.len(), table::PATTERN_DEFINITIONS.len());
709 }
710
711 #[test]
712 fn script_threat_position_and_text() {
713 let t = threat(0, "x<script>alert(1)</script>y").expect("script threat");
714 assert_eq!(t.match_text, "<script>alert(1)</script>");
715 assert_eq!(t.position, 1);
716 assert_eq!(t.category, "xss");
717 }
718
719 #[test]
720 fn etc_passwd_via_url_decoded_view_source() {
721 let t = threat(32, "O:8:\"T\":1:{s:4:\"file\";s:11:\"/etc/passwd\";}").expect("etc threat");
722 assert_eq!(t.position, 0);
723 assert_eq!(t.category, "dir_traversal");
724 }
725
726 #[test]
727 fn select_from_union_case() {
728 let t = threat(9, "id=1 UNION SELECT 1 FROM (SELECT 1)a--").expect("select threat");
729 assert_eq!(t.position, 11);
731 assert_eq!(t.match_text, "SELECT 1 FROM");
732 }
733
734 #[test]
735 fn tautology_match() {
736 let t = threat(12, "' OR 1=1--").expect("tautology");
737 assert_eq!(t.position, 2);
738 assert_eq!(t.match_text, "OR 1=1");
739 }
740
741 #[test]
742 fn union_select_weight_one() {
743 let t = threat(13, "' UNION SELECT NULL, NULL, NULL--").expect("union");
744 assert_eq!(t.position, 2);
745 assert!((t.weight - 1.0).abs() < f64::EPSILON);
746 }
747
748 #[test]
749 fn where_clause_weight_half() {
750 let t = threat(11, "UPDATE users SET admin=true WHERE id=1").expect("where");
751 assert_eq!(t.position, 28);
752 assert!((t.weight - 0.5).abs() < f64::EPSILON);
753 }
754
755 #[test]
756 fn log4shell_and_ssrf() {
757 let content = "${jndi:ldap://10.0.0.1/a}";
758 let t44 = threat(44, content).expect("log4shell");
759 assert_eq!(t44.position, 0);
760 let t78 = threat(78, content).expect("ssrf private");
761 assert_eq!(t78.position, 13);
762 let t79 = threat(79, content).expect("ssrf numeric");
763 assert_eq!(t79.position, 11);
764 }
765
766 #[test]
767 fn ldap_injection_validators() {
768 let content = "*)(uid=*))(|(uid=*";
769 assert!(threat(65, content).is_some(), "paren conj star");
770 assert!(threat(68, content).is_some(), "paren conjunction");
771 assert!(threat(67, content).is_some(), "paren breakout");
772 assert!(threat(69, content).is_some(), "wildcard chain");
773 assert!(threat(66, content).is_some(), "wildcard equals");
774 }
775
776 #[test]
777 fn ldap_conjunction_followup() {
778 assert!(threat(68, "(&(objectClass=user))").is_some());
780 assert!(threat(68, "(|x").is_none());
782 }
783
784 #[test]
785 fn sensitive_paths() {
786 assert!(threat(101, "/.env").is_some());
787 assert!(threat(106, "/.git/config").is_some());
788 assert!(threat(117, "/.aws/credentials").is_some());
789 assert!(threat(114, "/.htpasswd").is_some());
790 assert!(threat(113, "/config.php.bak").is_some());
791 assert!(threat(109, "/wp-login.php").is_some());
792 assert!(threat(119, "/actuator/env").is_some());
793 assert!(threat(109, "/admin/login/?next=/admin/").is_none());
794 assert!(threat(105, "/admin/login/?next=/admin/").is_none());
795 }
796
797 #[test]
798 fn file_upload_kinds() {
799 assert!(threat(89, "filename=\"shell.phtml\"").is_some());
800 assert!(threat(90, "filename=\"shell.php.jpg\"").is_some());
801 assert!(threat(89, "filename=\".htaccess\"").is_none());
802 }
803
804 #[test]
805 fn templates() {
806 assert!(threat(98, "{{ 7 * 7 }}").is_some());
807 assert!(threat(97, "${7 * 7}").is_some());
808 assert!(threat(96, "<%= 7 * 7 %>").is_some());
809 assert!(threat(97, "#set($x = 7 * 7)$x").is_none());
810 }
811
812 #[test]
813 fn deser_magic_prefixes() {
814 assert!(threat(144, "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcA==").is_some());
815 assert!(threat(144, "cHl0aG9u").is_none());
816 }
817
818 #[test]
819 fn pickle_global_validator() {
820 let payload = "cos\nsystem\n(S'id'\ntR.";
821 assert!(threat(152, payload).is_some(), "pickle reduce");
822 assert!(threat(152, "cos\nsystem\n(S'id'\n.").is_none(), "no reduce");
823 }
824
825 #[test]
826 fn dollar_substitution_validator() {
827 assert!(threat(43, "cat${IFS}/etc/passwd").is_some(), "IFS special");
828 assert!(threat(43, "$(whoami)").is_none(), "request_body reject");
830 assert!(threat(43, "total cost is $100 after discount").is_none());
831 }
832
833 #[test]
834 fn backtick_chain_and_candidate() {
835 assert!(threat(41, "`id`").is_some(), "full chain");
836 assert!(threat(42, "`id`").is_none(), "candidate rejected");
839 }
840
841 #[test]
842 fn comment_terminator_raw_view() {
843 assert!(threat(29, "admin'--").is_some());
844 let t = threat(29, "'; EXEC xp_cmdshell('dir')--").expect("comment");
845 assert_eq!(t.position, 24);
846 assert_eq!(t.match_text, "')--");
847 }
848
849 #[test]
850 fn http_split_position() {
851 let t = threat(100, "en\r\nSet-Cookie: injected=1").expect("http split");
852 assert_eq!(t.position, 3);
853 }
854
855 #[test]
856 fn xss_event_handler_cases() {
857 assert!(threat(3, "<img src=x onerror=alert(1)>").is_some());
858 assert!(threat(3, "/search?q=<svg/onload=alert(1)>").is_some());
859 let t = threat(3, "<img src=\"x\" onerror=alert(1)>").expect("quoted attr matches");
863 assert_eq!(t.match_text, "<img src=\"x\" onerror=alert(1)");
864 assert!(threat(3, "<b>bold</b> text").is_none());
865 }
866
867 #[test]
868 fn inline_comment_obfuscation() {
869 assert!(threat(21, "UNI/**/ON SE/**/LECT 1,2,3").is_some());
870 assert!(threat(21, "UNI/*!*/ON").is_none());
872 }
873}
874
875#[cfg(test)]
876mod coverage_tests {
877 use super::*;
878
879 #[test]
880 fn decode_budget_exhausted_threat_carries_the_marker() {
881 let threat = decode_budget_exhausted_threat();
882 assert_eq!(threat.pattern, "decode_budget_exhausted");
883 assert_eq!(threat.match_text, "decode_budget_exhausted");
884 assert_eq!(threat.position, 0);
885 assert_eq!(threat.category, "custom");
886 }
887
888 #[test]
889 fn structural_candidates_route_through_the_validators() {
890 let entry = |id: usize| COMPILED_TABLE.iter().find(|e| e.entry.id == id).unwrap();
892 assert!(candidate_accepts(
893 entry(58),
894 "cmd {a,b}",
895 Candidate::new(4, 9),
896 "arg"
897 ));
898 assert!(!candidate_accepts(
899 entry(58),
900 "plain",
901 Candidate::new(0, 5),
902 "arg"
903 ));
904 let content = "a'b'c";
906 assert!(candidate_accepts(
907 entry(59),
908 content,
909 Candidate::new(0, 5),
910 "arg"
911 ));
912 assert!(candidate_accepts(
914 entry(105),
915 "",
916 Candidate::new(0, 0),
917 "url_path:.php"
918 ));
919 }
920
921 #[test]
922 fn ldap_candidate_routes_fall_back_to_accepting_when_uncompiled() {
923 let entry = COMPILED_TABLE.iter().find(|e| e.entry.id == 66).unwrap();
926 let stripped_entry = CompiledEntry {
927 entry: crate::patterns::table::TableEntry {
928 id: entry.entry.id,
929 source: entry.entry.source,
930 contexts: entry.entry.contexts,
931 category: entry.entry.category,
932 },
933 re: None,
934 };
935 assert!(candidate_accepts(
936 &stripped_entry,
937 "x",
938 Candidate::new(0, 1),
939 "arg"
940 ));
941 }
942}
943
944#[cfg(test)]
945mod gap_tests {
946 use super::*;
947
948 #[test]
949 fn the_conjunction_validator_falls_back_to_accepting_when_uncompiled() {
950 let entry = COMPILED_TABLE.iter().find(|e| e.entry.id == 68).unwrap();
953 let stripped_entry = CompiledEntry {
954 entry: crate::patterns::table::TableEntry {
955 id: entry.entry.id,
956 source: entry.entry.source,
957 contexts: entry.entry.contexts,
958 category: entry.entry.category,
959 },
960 re: None,
961 };
962 assert!(candidate_accepts(
963 &stripped_entry,
964 "x",
965 Candidate::new(0, 1),
966 "arg"
967 ));
968 }
969}