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 let prefix = PyRegex::compile(prefix_src, true).ok()?;
165 let terminator = PyRegex::compile(terminator_src, true).ok()?;
166 Some(scan_window::bounded_finditer(
167 haystack,
168 compiled,
169 &prefix,
170 &terminator,
171 ))
172}
173
174#[must_use]
178#[allow(
179 clippy::too_many_lines,
180 reason = "pattern dispatch mirrors the reference table order; splitting would obscure the 1:1 mapping"
181)]
182fn pattern_candidates(entry: &CompiledEntry, haystack: &str) -> Vec<Candidate> {
183 let id = entry.entry.id;
184 let source = entry.entry.source;
185 if let Some(candidates) = scan_window_candidates(entry, haystack) {
186 return candidates;
187 }
188 match id {
189 74 => return xml_xxe::xml_system_finditer(haystack),
190 77 => return xml_xxe::xml_internal_entity_finditer(haystack),
191 75 => return xml_xxe::xml_xxe_public_external_dtd_finditer(haystack),
192 3 => return matchers::xss_event_handler_finditer(haystack),
193 9 => return matchers::sqli_select_from_finditer(haystack),
194 12 => return matchers::sqli_tautology_finditer(haystack),
195 21 => return matchers::sqli_inline_comment_finditer(haystack),
196 27 => return matchers::sqli_order_by_terminator_finditer(haystack),
197 42 => return matchers::glued_backtick_candidate_finditer(haystack),
198 45 => return matchers::shell_dash_flag_finditer(haystack),
199 62 => return matchers::file_inclusion_bare_host_finditer(haystack),
200 63 => return matchers::file_inclusion_scheme_path_finditer(haystack),
201 64 => return matchers::file_inclusion_template_url_finditer(haystack),
202 103 => return matchers::sensitive_path_config(haystack),
203 78 => return matchers::ssrf_private_host_finditer(haystack),
204 79 => return matchers::ssrf_numeric_host_finditer(haystack),
205 101 => return matchers::sensitive_path_env(haystack),
206 104 => {
207 return matchers::sensitive_path_scan_ext(haystack, &["map"]);
208 }
209 106 => {
210 return matchers::sensitive_path_dot_alt(haystack, &["git", "svn", "hg", "bzr"]);
211 }
212 109 => return matchers::sensitive_path_wp_admin(haystack),
213 111 => {
214 return matchers::sensitive_path_literal_ext(
215 haystack,
216 &["phpinfo", "info", "test", "php_info"],
217 "php",
218 );
219 }
220 113 => {
221 return matchers::sensitive_path_scan_ext(haystack, matchers::BACKUP_EXTENSIONS);
222 }
223 114 => return matchers::sensitive_path_literal(haystack, matchers::DOUBLE_DOT_BAD),
224 117 => return matchers::sensitive_path_management(haystack),
225 119 => {
226 return matchers::sensitive_path_literal(
227 haystack,
228 &["actuator", "server-status", "telescope"],
229 );
230 }
231 121 => {
232 return matchers::sensitive_path_literal_suffix(
233 haystack,
234 matchers::RECON_APP_BAD,
235 matchers::BadSuffix::DashRun,
236 );
237 }
238 122 => return matchers::sensitive_path_literal(haystack, &["cgi-bin", "cgi-mod"]),
239 123 => {
240 return matchers::sensitive_path_literal(
241 haystack,
242 &["HNAP1", "IPCamDesc.xml", "SDK/webLanguage"],
243 );
244 }
245 124 => return matchers::sensitive_path_literal(haystack, &["language", "languages"]),
246 125 => {
247 return matchers::sensitive_path_literal_suffix_dot(
248 haystack,
249 matchers::RECON_README_BAD,
250 );
251 }
252 126 => {
253 return matchers::sensitive_path_literal(
254 haystack,
255 &[
256 "sap",
257 "ise",
258 "nidp",
259 "cslu",
260 "rustfs",
261 "developmentserver",
262 "fog/management",
263 "lms/db",
264 "json/login_session",
265 "sms_mp",
266 "plugin/webs_model",
267 "wsman",
268 "am_bin",
269 ],
270 );
271 }
272 128 => {
273 return matchers::sensitive_path_literal(haystack, &[".openclaw", ".clawdbot"]);
274 }
275 130 => return matchers::sensitive_path_literal(haystack, &["inicio.html", "inicio.htm"]),
276 131 => {
277 return matchers::sensitive_path_literal(
278 haystack,
279 &[
280 ".streamlit",
281 ".gpt-pilot",
282 ".aider",
283 ".cursor",
284 ".windsurf",
285 ".copilot",
286 ".devcontainer",
287 ],
288 );
289 }
290 132 => {
291 return matchers::sensitive_path_literal_suffix(
292 haystack,
293 matchers::DOCKERFILE_BAD,
294 matchers::BadSuffix::YamlSuffix,
295 );
296 }
297 133 => return matchers::sensitive_path_secrets(haystack),
298 134 => return matchers::sensitive_path_literal(haystack, &["autodiscover"]),
299 135 => return matchers::sensitive_path_literal(haystack, &["dns-query"]),
300 136 => {
301 return matchers::sensitive_path_literal(
302 haystack,
303 &[
304 ".git/refs",
305 ".git/index",
306 ".git/HEAD",
307 ".git/objects",
308 ".git/logs",
309 ],
310 );
311 }
312 139 => {
313 return matchers::proto_pollution_assign_finditer(haystack);
314 }
315 144 => return matchers::deserialization_b64_finditer(haystack, "rO0AB"),
316 145 => return matchers::deserialization_b64_finditer(haystack, "AAEAAAD"),
317 146 => {
318 let mut out = matchers::deserialization_b64_finditer(haystack, "gASV");
320 out.extend(matchers::deserialization_b64_finditer(haystack, "gAWV"));
321 out.sort_by_key(|c| c.start);
322 return out;
323 }
324 147 => {
325 let mut out = Vec::new();
327 for magic in ["BAhJ", "BAhv", "BAh7", "BAhb", "BAhV"] {
328 out.extend(matchers::deserialization_b64_finditer(haystack, magic));
329 }
330 out.sort_by_key(|c| c.start);
331 return out;
332 }
333 17 => {
334 return entry
335 .re
336 .as_ref()
337 .map(|compiled| matchers::load_file_scan_matches(haystack, compiled))
338 .unwrap_or_default();
339 }
340 40 => {
341 return entry
342 .re
343 .as_ref()
344 .map(|compiled| matchers::dollar_substitution_scan_matches(haystack, compiled))
345 .unwrap_or_default();
346 }
347 47 => {
348 return entry
349 .re
350 .as_ref()
351 .map(|compiled| matchers::shell_dash_c_finditer(haystack, compiled))
352 .unwrap_or_default();
353 }
354 59 => {
355 return entry
356 .re
357 .as_ref()
358 .map(|compiled| matchers::quote_splice_finditer(haystack, compiled))
359 .unwrap_or_default();
360 }
361 60 => {
362 return entry
363 .re
364 .as_ref()
365 .map(|compiled| matchers::glob_wildcard_scan_matches(haystack, compiled))
366 .unwrap_or_default();
367 }
368 70 => {
369 return entry
370 .re
371 .as_ref()
372 .map(|compiled| matchers::ldap_null_byte_attr_raw(haystack, compiled))
373 .unwrap_or_default();
374 }
375 72 => {
376 return entry
377 .re
378 .as_ref()
379 .map(|compiled| matchers::ldap_null_byte_attr_decoded(haystack, compiled))
380 .unwrap_or_default();
381 }
382 89..=92 => {
383 return file_upload::file_upload_scan_matches(
384 haystack,
385 source,
386 source_of_id(89),
387 source_of_id(90),
388 source_of_id(91),
389 source_of_id(92),
390 );
391 }
392 94 => {
393 return templates::template_keyword_matches(
394 haystack,
395 &templates::KIND_CURLY_KEYWORD,
396 true,
397 );
398 }
399 95 => {
400 return templates::template_keyword_matches(
401 haystack,
402 &templates::KIND_PERCENT_KEYWORD,
403 true,
404 );
405 }
406 96 => {
407 return templates::template_expression_matches(haystack, &templates::KIND_ASP, true);
408 }
409 97 => {
410 return templates::template_expression_matches(haystack, &templates::KIND_DOLLAR, true);
411 }
412 98 => {
413 return templates::template_expression_matches(
414 haystack,
415 &templates::KIND_CURLY_CALL,
416 true,
417 );
418 }
419 99 => {
420 return templates::template_expression_matches(haystack, &templates::KIND_HASH, true);
421 }
422 33 => return matchers::lexicon_path_finditer(haystack, LEXICON_ETC_PATH, true),
423 102 => return matchers::lexicon_path_finditer(haystack, LEXICON_ENV_PATH, true),
424 107 => return matchers::lexicon_path_finditer(haystack, LEXICON_GIT_PATH, true),
425 110 => return matchers::lexicon_path_finditer(haystack, LEXICON_WP_PATH, true),
426 112 => return matchers::lexicon_path_finditer(haystack, LEXICON_PHPINFO_PATH, true),
427 115 => return matchers::lexicon_path_finditer(haystack, LEXICON_HTACCESS_PATH, true),
428 _ => {}
429 }
430 entry.re.as_ref().map_or_else(Vec::new, |compiled| {
432 compiled
433 .re()
434 .find_iter(haystack)
435 .map(|m| Candidate::new(m.start(), m.end()))
436 .collect()
437 })
438}
439
440fn source_of_id(id: usize) -> &'static str {
441 table::PATTERN_DEFINITIONS
442 .iter()
443 .find(|e| e.id == id)
444 .map_or("", |e| e.source)
445}
446
447pub 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";
451pub const LEXICON_ENV_PATH: &str = r"\A[^\n]*[/\\](?:\.env(?:\.\w+)?)(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?\b";
452pub const LEXICON_GIT_PATH: &str = r"\A[^\n]*[/\\](?:\.(?:git|svn|hg|bzr))(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?\b";
453pub 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";
454pub const LEXICON_PHPINFO_PATH: &str = r"\A[^\n]*[/\\](?:(?:phpinfo|info|test|php_info)\.php)(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?\b";
455pub 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";
456
457const RECON_BARE_PATH_CONTEXTS: &[&str] = &["url_path", "unknown"];
460
461#[must_use]
466fn recon_path_value_is_probe(matched: &str, validator_context: &str) -> bool {
467 let base = validator_context.split(':').next().unwrap_or("");
468 RECON_BARE_PATH_CONTEXTS.contains(&base)
469 || matched.starts_with('/')
470 || matched.starts_with('\\')
471}
472
473#[must_use]
476pub fn find_first_threat(
477 entry: &CompiledEntry,
478 haystack: &str,
479 context: &str,
480 binary_prefix: Option<&[u32]>,
481) -> Option<RegexThreat> {
482 let noise_prone = table::NOISE_PRONE_PATTERN_SOURCES.contains(entry.entry.source);
483 let recon_optional_separator =
484 table::RECON_OPTIONAL_SEPARATOR_PATTERN_SOURCES.contains(entry.entry.source);
485 for candidate in pattern_candidates(entry, haystack) {
486 if !candidate_accepts(entry, haystack, candidate, context) {
487 continue;
488 }
489 if recon_optional_separator && !recon_path_value_is_probe(candidate.text(haystack), context)
494 {
495 continue;
496 }
497 if noise_prone
498 && let Some(prefix) = binary_prefix
499 && binary::match_is_binary_density(
500 prefix,
501 pyregex::cp_index(haystack, candidate.start),
502 pyregex::cp_index(haystack, candidate.end),
503 )
504 {
505 continue;
506 }
507 return Some(RegexThreat {
508 pattern: entry.entry.source.to_owned(),
509 match_text: candidate.text(haystack).to_owned(),
510 position: pyregex::cp_index(haystack, candidate.start),
511 category: entry.entry.category.to_owned(),
512 weight: resolve_pattern_weight(entry.entry.source),
513 });
514 }
515 None
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
520pub enum ViewFilter {
521 All,
523 Processed,
525 Raw,
527 UrlDecoded,
529}
530
531#[must_use]
532pub fn is_excluded_from_view(source: &str, filter: ViewFilter) -> bool {
533 let raw_only = table::DETECTION_RAW_VIEW_PATTERN_SOURCES.contains(source);
534 let url_only = table::DETECTION_URL_DECODED_VIEW_PATTERN_SOURCES.contains(source);
535 let recon_raw_only = table::DETECTION_RECON_RAW_VIEW_PATTERN_SOURCES.contains(source);
540 match filter {
541 ViewFilter::All => false,
542 ViewFilter::Processed => raw_only || url_only,
543 ViewFilter::Raw => url_only || !(raw_only || recon_raw_only),
544 ViewFilter::UrlDecoded => raw_only || !url_only,
545 }
546}
547
548const SIZE_GATED_IDS: &[usize] = &[
552 32, 33, 34, 35, 36, 102, 107, 110, 112, 115, 101, 103, 104, 105, 106, 109, 111, 113, 114, 116,
553 117, 119, 121, 122, 123, 124, 125, 126, 128, 130, 131, 132, 133, 134, 135, 136,
554];
555
556pub const GATED_PATTERN_MAX_SUBJECT_BYTES: usize = 15_360;
562
563fn first_line_byte_length(content: &str) -> usize {
564 content.find('\n').unwrap_or(content.len())
565}
566
567fn size_gated(content: &str) -> bool {
568 first_line_byte_length(content) >= GATED_PATTERN_MAX_SUBJECT_BYTES
569}
570
571#[must_use]
574pub fn scan_view(
575 content: &str,
576 filter: ViewFilter,
577 normalized_context: &str,
578 skip_filter: bool,
579 validator_context: &str,
580) -> Vec<RegexThreat> {
581 let binary_prefix = binary::build_binary_prefix(content);
582 let gating = size_gated(content);
583 let mut threats = Vec::new();
584 for entry in COMPILED_TABLE.iter() {
585 if is_excluded_from_view(entry.entry.source, filter) {
586 continue;
587 }
588 if !skip_filter && !entry.entry.contexts.contains(&normalized_context) {
589 continue;
590 }
591 if gating && SIZE_GATED_IDS.contains(&entry.entry.id) {
592 continue;
593 }
594 if let Some(threat) =
595 find_first_threat(entry, content, validator_context, Some(&binary_prefix))
596 {
597 threats.push(threat);
598 }
599 }
600 threats
601}
602
603#[must_use]
606pub fn decoded_view_traversal_threat(processed: &str, raw_view: &str) -> Option<RegexThreat> {
607 static SHAPE: LazyLock<PyRegex> =
608 LazyLock::new(|| PyRegex::compile(r"\.\.[\\/]", false).expect("static shape regex"));
609 let decoded_matches: Vec<Candidate> = SHAPE
610 .re()
611 .find_iter(processed)
612 .map(|m| Candidate::new(m.start(), m.end()))
613 .collect();
614 let raw_count = SHAPE.re().find_iter(raw_view).count();
615 if decoded_matches.len() <= raw_count {
616 return None;
617 }
618 let first = decoded_matches[0];
619 Some(RegexThreat {
620 pattern: r"\.\.[\\/]".to_owned(),
621 match_text: first.text(processed).to_owned(),
622 position: pyregex::cp_index(processed, first.start),
623 category: "path_traversal".to_owned(),
624 weight: 1.0,
625 })
626}
627
628#[must_use]
631pub fn decode_budget_exhausted_threat() -> RegexThreat {
632 RegexThreat {
633 pattern: "decode_budget_exhausted".to_owned(),
634 match_text: "decode_budget_exhausted".to_owned(),
635 position: 0,
636 category: "custom".to_owned(),
637 weight: 1.0,
638 }
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 fn entry_for(id: usize) -> &'static CompiledEntry {
646 COMPILED_TABLE
647 .iter()
648 .find(|e| e.entry.id == id)
649 .expect("entry exists")
650 }
651
652 fn threat(id: usize, content: &str) -> Option<RegexThreat> {
653 find_first_threat(entry_for(id), content, "request_body", None)
654 }
655
656 #[test]
657 fn compiled_table_is_complete() {
658 assert_eq!(COMPILED_TABLE.len(), table::PATTERN_DEFINITIONS.len());
659 }
660
661 #[test]
662 fn script_threat_position_and_text() {
663 let t = threat(0, "x<script>alert(1)</script>y").expect("script threat");
664 assert_eq!(t.match_text, "<script>alert(1)</script>");
665 assert_eq!(t.position, 1);
666 assert_eq!(t.category, "xss");
667 }
668
669 #[test]
670 fn etc_passwd_via_url_decoded_view_source() {
671 let t = threat(32, "O:8:\"T\":1:{s:4:\"file\";s:11:\"/etc/passwd\";}").expect("etc threat");
672 assert_eq!(t.position, 0);
673 assert_eq!(t.category, "dir_traversal");
674 }
675
676 #[test]
677 fn select_from_union_case() {
678 let t = threat(9, "id=1 UNION SELECT 1 FROM (SELECT 1)a--").expect("select threat");
679 assert_eq!(t.position, 11);
681 assert_eq!(t.match_text, "SELECT 1 FROM");
682 }
683
684 #[test]
685 fn tautology_match() {
686 let t = threat(12, "' OR 1=1--").expect("tautology");
687 assert_eq!(t.position, 2);
688 assert_eq!(t.match_text, "OR 1=1");
689 }
690
691 #[test]
692 fn union_select_weight_one() {
693 let t = threat(13, "' UNION SELECT NULL, NULL, NULL--").expect("union");
694 assert_eq!(t.position, 2);
695 assert!((t.weight - 1.0).abs() < f64::EPSILON);
696 }
697
698 #[test]
699 fn where_clause_weight_half() {
700 let t = threat(11, "UPDATE users SET admin=true WHERE id=1").expect("where");
701 assert_eq!(t.position, 28);
702 assert!((t.weight - 0.5).abs() < f64::EPSILON);
703 }
704
705 #[test]
706 fn log4shell_and_ssrf() {
707 let content = "${jndi:ldap://10.0.0.1/a}";
708 let t44 = threat(44, content).expect("log4shell");
709 assert_eq!(t44.position, 0);
710 let t78 = threat(78, content).expect("ssrf private");
711 assert_eq!(t78.position, 13);
712 let t79 = threat(79, content).expect("ssrf numeric");
713 assert_eq!(t79.position, 11);
714 }
715
716 #[test]
717 fn ldap_injection_validators() {
718 let content = "*)(uid=*))(|(uid=*";
719 assert!(threat(65, content).is_some(), "paren conj star");
720 assert!(threat(68, content).is_some(), "paren conjunction");
721 assert!(threat(67, content).is_some(), "paren breakout");
722 assert!(threat(69, content).is_some(), "wildcard chain");
723 assert!(threat(66, content).is_some(), "wildcard equals");
724 }
725
726 #[test]
727 fn ldap_conjunction_followup() {
728 assert!(threat(68, "(&(objectClass=user))").is_some());
730 assert!(threat(68, "(|x").is_none());
732 }
733
734 #[test]
735 fn sensitive_paths() {
736 assert!(threat(101, "/.env").is_some());
737 assert!(threat(106, "/.git/config").is_some());
738 assert!(threat(117, "/.aws/credentials").is_some());
739 assert!(threat(114, "/.htpasswd").is_some());
740 assert!(threat(113, "/config.php.bak").is_some());
741 assert!(threat(109, "/wp-login.php").is_some());
742 assert!(threat(119, "/actuator/env").is_some());
743 assert!(threat(109, "/admin/login/?next=/admin/").is_none());
744 assert!(threat(105, "/admin/login/?next=/admin/").is_none());
745 }
746
747 #[test]
748 fn file_upload_kinds() {
749 assert!(threat(89, "filename=\"shell.phtml\"").is_some());
750 assert!(threat(90, "filename=\"shell.php.jpg\"").is_some());
751 assert!(threat(89, "filename=\".htaccess\"").is_none());
752 }
753
754 #[test]
755 fn templates() {
756 assert!(threat(98, "{{ 7 * 7 }}").is_some());
757 assert!(threat(97, "${7 * 7}").is_some());
758 assert!(threat(96, "<%= 7 * 7 %>").is_some());
759 assert!(threat(97, "#set($x = 7 * 7)$x").is_none());
760 }
761
762 #[test]
763 fn deser_magic_prefixes() {
764 assert!(threat(144, "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcA==").is_some());
765 assert!(threat(144, "cHl0aG9u").is_none());
766 }
767
768 #[test]
769 fn pickle_global_validator() {
770 let payload = "cos\nsystem\n(S'id'\ntR.";
771 assert!(threat(152, payload).is_some(), "pickle reduce");
772 assert!(threat(152, "cos\nsystem\n(S'id'\n.").is_none(), "no reduce");
773 }
774
775 #[test]
776 fn dollar_substitution_validator() {
777 assert!(threat(43, "cat${IFS}/etc/passwd").is_some(), "IFS special");
778 assert!(threat(43, "$(whoami)").is_none(), "request_body reject");
780 assert!(threat(43, "total cost is $100 after discount").is_none());
781 }
782
783 #[test]
784 fn backtick_chain_and_candidate() {
785 assert!(threat(41, "`id`").is_some(), "full chain");
786 assert!(threat(42, "`id`").is_none(), "candidate rejected");
789 }
790
791 #[test]
792 fn comment_terminator_raw_view() {
793 assert!(threat(29, "admin'--").is_some());
794 let t = threat(29, "'; EXEC xp_cmdshell('dir')--").expect("comment");
795 assert_eq!(t.position, 24);
796 assert_eq!(t.match_text, "')--");
797 }
798
799 #[test]
800 fn http_split_position() {
801 let t = threat(100, "en\r\nSet-Cookie: injected=1").expect("http split");
802 assert_eq!(t.position, 3);
803 }
804
805 #[test]
806 fn xss_event_handler_cases() {
807 assert!(threat(3, "<img src=x onerror=alert(1)>").is_some());
808 assert!(threat(3, "/search?q=<svg/onload=alert(1)>").is_some());
809 let t = threat(3, "<img src=\"x\" onerror=alert(1)>").expect("quoted attr matches");
813 assert_eq!(t.match_text, "<img src=\"x\" onerror=alert(1)");
814 assert!(threat(3, "<b>bold</b> text").is_none());
815 }
816
817 #[test]
818 fn inline_comment_obfuscation() {
819 assert!(threat(21, "UNI/**/ON SE/**/LECT 1,2,3").is_some());
820 assert!(threat(21, "UNI/*!*/ON").is_none());
822 }
823}