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
457#[must_use]
459pub fn find_first_threat(
460 entry: &CompiledEntry,
461 haystack: &str,
462 context: &str,
463 binary_prefix: Option<&[u32]>,
464) -> Option<RegexThreat> {
465 let noise_prone = table::NOISE_PRONE_PATTERN_SOURCES.contains(entry.entry.source);
466 for candidate in pattern_candidates(entry, haystack) {
467 if !candidate_accepts(entry, haystack, candidate, context) {
468 continue;
469 }
470 if noise_prone
471 && let Some(prefix) = binary_prefix
472 && binary::match_is_binary_density(
473 prefix,
474 pyregex::cp_index(haystack, candidate.start),
475 pyregex::cp_index(haystack, candidate.end),
476 )
477 {
478 continue;
479 }
480 return Some(RegexThreat {
481 pattern: entry.entry.source.to_owned(),
482 match_text: candidate.text(haystack).to_owned(),
483 position: pyregex::cp_index(haystack, candidate.start),
484 category: entry.entry.category.to_owned(),
485 weight: resolve_pattern_weight(entry.entry.source),
486 });
487 }
488 None
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Eq)]
493pub enum ViewFilter {
494 All,
496 Processed,
498 Raw,
500 UrlDecoded,
502}
503
504#[must_use]
505pub fn is_excluded_from_view(source: &str, filter: ViewFilter) -> bool {
506 let raw_only = table::DETECTION_RAW_VIEW_PATTERN_SOURCES.contains(source);
507 let url_only = table::DETECTION_URL_DECODED_VIEW_PATTERN_SOURCES.contains(source);
508 match filter {
509 ViewFilter::All => false,
510 ViewFilter::Processed => raw_only || url_only,
511 ViewFilter::Raw => url_only || !raw_only,
512 ViewFilter::UrlDecoded => raw_only || !url_only,
513 }
514}
515
516const SIZE_GATED_IDS: &[usize] = &[
520 32, 33, 34, 35, 36, 102, 107, 110, 112, 115, 101, 103, 104, 105, 106, 109, 111, 113, 114, 116,
521 117, 119, 121, 122, 123, 124, 125, 126, 128, 130, 131, 132, 133, 134, 135, 136,
522];
523
524pub const GATED_PATTERN_MAX_SUBJECT_BYTES: usize = 15_360;
530
531fn first_line_byte_length(content: &str) -> usize {
532 content.find('\n').unwrap_or(content.len())
533}
534
535fn size_gated(content: &str) -> bool {
536 first_line_byte_length(content) >= GATED_PATTERN_MAX_SUBJECT_BYTES
537}
538
539#[must_use]
542pub fn scan_view(
543 content: &str,
544 filter: ViewFilter,
545 normalized_context: &str,
546 skip_filter: bool,
547 validator_context: &str,
548) -> Vec<RegexThreat> {
549 let binary_prefix = binary::build_binary_prefix(content);
550 let gating = size_gated(content);
551 let mut threats = Vec::new();
552 for entry in COMPILED_TABLE.iter() {
553 if is_excluded_from_view(entry.entry.source, filter) {
554 continue;
555 }
556 if !skip_filter && !entry.entry.contexts.contains(&normalized_context) {
557 continue;
558 }
559 if gating && SIZE_GATED_IDS.contains(&entry.entry.id) {
560 continue;
561 }
562 if let Some(threat) =
563 find_first_threat(entry, content, validator_context, Some(&binary_prefix))
564 {
565 threats.push(threat);
566 }
567 }
568 threats
569}
570
571#[must_use]
574pub fn decoded_view_traversal_threat(processed: &str, raw_view: &str) -> Option<RegexThreat> {
575 static SHAPE: LazyLock<PyRegex> =
576 LazyLock::new(|| PyRegex::compile(r"\.\.[\\/]", false).expect("static shape regex"));
577 let decoded_matches: Vec<Candidate> = SHAPE
578 .re()
579 .find_iter(processed)
580 .map(|m| Candidate::new(m.start(), m.end()))
581 .collect();
582 let raw_count = SHAPE.re().find_iter(raw_view).count();
583 if decoded_matches.len() <= raw_count {
584 return None;
585 }
586 let first = decoded_matches[0];
587 Some(RegexThreat {
588 pattern: r"\.\.[\\/]".to_owned(),
589 match_text: first.text(processed).to_owned(),
590 position: pyregex::cp_index(processed, first.start),
591 category: "path_traversal".to_owned(),
592 weight: 1.0,
593 })
594}
595
596#[must_use]
599pub fn decode_budget_exhausted_threat() -> RegexThreat {
600 RegexThreat {
601 pattern: "decode_budget_exhausted".to_owned(),
602 match_text: "decode_budget_exhausted".to_owned(),
603 position: 0,
604 category: "custom".to_owned(),
605 weight: 1.0,
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 fn entry_for(id: usize) -> &'static CompiledEntry {
614 COMPILED_TABLE
615 .iter()
616 .find(|e| e.entry.id == id)
617 .expect("entry exists")
618 }
619
620 fn threat(id: usize, content: &str) -> Option<RegexThreat> {
621 find_first_threat(entry_for(id), content, "request_body", None)
622 }
623
624 #[test]
625 fn compiled_table_is_complete() {
626 assert_eq!(COMPILED_TABLE.len(), table::PATTERN_DEFINITIONS.len());
627 }
628
629 #[test]
630 fn script_threat_position_and_text() {
631 let t = threat(0, "x<script>alert(1)</script>y").expect("script threat");
632 assert_eq!(t.match_text, "<script>alert(1)</script>");
633 assert_eq!(t.position, 1);
634 assert_eq!(t.category, "xss");
635 }
636
637 #[test]
638 fn etc_passwd_via_url_decoded_view_source() {
639 let t = threat(32, "O:8:\"T\":1:{s:4:\"file\";s:11:\"/etc/passwd\";}").expect("etc threat");
640 assert_eq!(t.position, 0);
641 assert_eq!(t.category, "dir_traversal");
642 }
643
644 #[test]
645 fn select_from_union_case() {
646 let t = threat(9, "id=1 UNION SELECT 1 FROM (SELECT 1)a--").expect("select threat");
647 assert_eq!(t.position, 11);
649 assert_eq!(t.match_text, "SELECT 1 FROM");
650 }
651
652 #[test]
653 fn tautology_match() {
654 let t = threat(12, "' OR 1=1--").expect("tautology");
655 assert_eq!(t.position, 2);
656 assert_eq!(t.match_text, "OR 1=1");
657 }
658
659 #[test]
660 fn union_select_weight_one() {
661 let t = threat(13, "' UNION SELECT NULL, NULL, NULL--").expect("union");
662 assert_eq!(t.position, 2);
663 assert!((t.weight - 1.0).abs() < f64::EPSILON);
664 }
665
666 #[test]
667 fn where_clause_weight_half() {
668 let t = threat(11, "UPDATE users SET admin=true WHERE id=1").expect("where");
669 assert_eq!(t.position, 28);
670 assert!((t.weight - 0.5).abs() < f64::EPSILON);
671 }
672
673 #[test]
674 fn log4shell_and_ssrf() {
675 let content = "${jndi:ldap://10.0.0.1/a}";
676 let t44 = threat(44, content).expect("log4shell");
677 assert_eq!(t44.position, 0);
678 let t78 = threat(78, content).expect("ssrf private");
679 assert_eq!(t78.position, 13);
680 let t79 = threat(79, content).expect("ssrf numeric");
681 assert_eq!(t79.position, 11);
682 }
683
684 #[test]
685 fn ldap_injection_validators() {
686 let content = "*)(uid=*))(|(uid=*";
687 assert!(threat(65, content).is_some(), "paren conj star");
688 assert!(threat(68, content).is_some(), "paren conjunction");
689 assert!(threat(67, content).is_some(), "paren breakout");
690 assert!(threat(69, content).is_some(), "wildcard chain");
691 assert!(threat(66, content).is_some(), "wildcard equals");
692 }
693
694 #[test]
695 fn ldap_conjunction_followup() {
696 assert!(threat(68, "(&(objectClass=user))").is_some());
698 assert!(threat(68, "(|x").is_none());
700 }
701
702 #[test]
703 fn sensitive_paths() {
704 assert!(threat(101, "/.env").is_some());
705 assert!(threat(106, "/.git/config").is_some());
706 assert!(threat(117, "/.aws/credentials").is_some());
707 assert!(threat(114, "/.htpasswd").is_some());
708 assert!(threat(113, "/config.php.bak").is_some());
709 assert!(threat(109, "/wp-login.php").is_some());
710 assert!(threat(119, "/actuator/env").is_some());
711 assert!(threat(109, "/admin/login/?next=/admin/").is_none());
712 assert!(threat(105, "/admin/login/?next=/admin/").is_none());
713 }
714
715 #[test]
716 fn file_upload_kinds() {
717 assert!(threat(89, "filename=\"shell.phtml\"").is_some());
718 assert!(threat(90, "filename=\"shell.php.jpg\"").is_some());
719 assert!(threat(89, "filename=\".htaccess\"").is_none());
720 }
721
722 #[test]
723 fn templates() {
724 assert!(threat(98, "{{ 7 * 7 }}").is_some());
725 assert!(threat(97, "${7 * 7}").is_some());
726 assert!(threat(96, "<%= 7 * 7 %>").is_some());
727 assert!(threat(97, "#set($x = 7 * 7)$x").is_none());
728 }
729
730 #[test]
731 fn deser_magic_prefixes() {
732 assert!(threat(144, "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcA==").is_some());
733 assert!(threat(144, "cHl0aG9u").is_none());
734 }
735
736 #[test]
737 fn pickle_global_validator() {
738 let payload = "cos\nsystem\n(S'id'\ntR.";
739 assert!(threat(152, payload).is_some(), "pickle reduce");
740 assert!(threat(152, "cos\nsystem\n(S'id'\n.").is_none(), "no reduce");
741 }
742
743 #[test]
744 fn dollar_substitution_validator() {
745 assert!(threat(43, "cat${IFS}/etc/passwd").is_some(), "IFS special");
746 assert!(threat(43, "$(whoami)").is_none(), "request_body reject");
748 assert!(threat(43, "total cost is $100 after discount").is_none());
749 }
750
751 #[test]
752 fn backtick_chain_and_candidate() {
753 assert!(threat(41, "`id`").is_some(), "full chain");
754 assert!(threat(42, "`id`").is_none(), "candidate rejected");
757 }
758
759 #[test]
760 fn comment_terminator_raw_view() {
761 assert!(threat(29, "admin'--").is_some());
762 let t = threat(29, "'; EXEC xp_cmdshell('dir')--").expect("comment");
763 assert_eq!(t.position, 24);
764 assert_eq!(t.match_text, "')--");
765 }
766
767 #[test]
768 fn http_split_position() {
769 let t = threat(100, "en\r\nSet-Cookie: injected=1").expect("http split");
770 assert_eq!(t.position, 3);
771 }
772
773 #[test]
774 fn xss_event_handler_cases() {
775 assert!(threat(3, "<img src=x onerror=alert(1)>").is_some());
776 assert!(threat(3, "/search?q=<svg/onload=alert(1)>").is_some());
777 let t = threat(3, "<img src=\"x\" onerror=alert(1)>").expect("quoted attr matches");
781 assert_eq!(t.match_text, "<img src=\"x\" onerror=alert(1)");
782 assert!(threat(3, "<b>bold</b> text").is_none());
783 }
784
785 #[test]
786 fn inline_comment_obfuscation() {
787 assert!(threat(21, "UNI/**/ON SE/**/LECT 1,2,3").is_some());
788 assert!(threat(21, "UNI/*!*/ON").is_none());
790 }
791}