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