1use regex::Regex;
15
16use super::chars_util::{char_at, char_before, py_is_word, str_rfind_in, walk_forward_while};
17use super::pyregex::{Candidate, PyRegex};
18use super::scan_window::bounded_finditer;
19
20fn guarded_finditer(
23 haystack: &str,
24 re: &Regex,
25 guard: impl Fn(&str, Candidate) -> bool,
26) -> Vec<Candidate> {
27 let mut out = Vec::new();
28 let mut from = 0usize;
29 while let Some(m) = re.find_at(haystack, from) {
30 let candidate = Candidate::new(m.start(), m.end());
31 if guard(haystack, candidate) {
32 out.push(candidate);
33 from = if m.end() > m.start() {
34 m.end()
35 } else {
36 m.start() + 1
37 };
38 } else {
39 from = m.start() + 1;
40 }
41 }
42 out
43}
44
45pub(crate) fn match_span(
48 re: &Regex,
49 haystack: &str,
50 start: usize,
51 end: usize,
52) -> Option<Candidate> {
53 if start > end {
54 return None;
55 }
56 if let Some(m) = re.find_at(haystack, start)
57 && m.start() == start
58 {
59 if m.end() <= end {
60 return Some(Candidate::new(m.start(), m.end()));
61 }
62 if let Some(m2) = re.find_at(&haystack[..end], start)
63 && m2.start() == start
64 {
65 return Some(Candidate::new(m2.start(), m2.end()));
66 }
67 }
68 None
69}
70
71#[must_use]
77pub fn shell_dash_c_finditer(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
78 let Ok(prefix) = PyRegex::compile(r"\n[^\S\r\n]*", false) else {
79 return Vec::new();
80 };
81 let Ok(token) = PyRegex::compile(r"[^=\s;|&]+=[^\s;|&]+\s+", false) else {
82 return Vec::new();
83 };
84 let mut matches = Vec::new();
85 let mut last_end = 0usize;
86 for prefix_match in prefix.re().find_iter(haystack) {
87 let start = prefix_match.start();
88 if start < last_end {
89 continue;
90 }
91 let mut pos = prefix_match.end();
92 while let Some(tm) = token.re().find_at(haystack, pos) {
93 if tm.start() != pos {
94 break;
95 }
96 pos = tm.end();
97 }
98 if let Some(m) = compiled.re().find_at(haystack, start)
99 && m.start() == start
100 {
101 matches.push(Candidate::new(m.start(), m.end()));
102 last_end = m.end();
103 } else {
104 last_end = pos;
105 }
106 }
107 matches
108}
109
110const LDAP_NULL_BYTE_TAIL_RAW: &str = r"\*\)+(?:%00|\\u0000|\\x00|\\0|\x00)";
115const LDAP_NULL_BYTE_TAIL_DECODED: &str = r"\*\)+\x00";
116
117fn ldap_null_byte_attr_finditer(
118 haystack: &str,
119 compiled: &PyRegex,
120 tail_source: &str,
121) -> Vec<Candidate> {
122 if !haystack.contains('*') || !haystack.contains(')') {
123 return Vec::new();
124 }
125 let Ok(tail) = PyRegex::compile(tail_source, false) else {
126 return Vec::new();
127 };
128 let mut matches = Vec::new();
129 let mut last_end = 0usize;
130 for tail_match in tail.re().find_iter(haystack) {
131 let star_pos = tail_match.start();
132 if star_pos < last_end {
133 continue;
134 }
135 let value_start = walk_back(haystack, star_pos, |c| {
137 c.is_whitespace() || c.is_alphanumeric() || c == '_'
138 });
139 if value_start == 0 {
140 continue;
141 }
142 if char_before(haystack, value_start).is_none_or(|(_, c)| c != '=') {
143 continue;
144 }
145 let equals_pos = value_start - 1;
147 let mut name_start = equals_pos;
148 while name_start > 0
149 && char_before(haystack, name_start).is_some_and(|(_, c)| py_is_word(c) || c == '-')
150 {
151 name_start = char_before(haystack, name_start).map_or(0, |(i, _)| i);
152 }
153 if name_start == equals_pos {
154 continue;
155 }
156 let Some((_, lead)) = char_at(haystack, name_start) else {
157 continue;
158 };
159 if !lead.is_ascii_alphabetic() {
160 continue;
161 }
162 if let Some(m) = match_span(compiled.re(), haystack, name_start, tail_match.end()) {
163 matches.push(m);
164 last_end = m.end;
165 }
166 }
167 matches
168}
169
170fn walk_back(haystack: &str, mut pos: usize, pred: impl Fn(char) -> bool) -> usize {
171 while pos > 0 && char_before(haystack, pos).is_some_and(|(_, c)| pred(c)) {
172 pos = char_before(haystack, pos).map_or(0, |(i, _)| i);
173 }
174 pos
175}
176
177#[must_use]
179pub fn ldap_null_byte_attr_raw(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
180 ldap_null_byte_attr_finditer(haystack, compiled, LDAP_NULL_BYTE_TAIL_RAW)
181}
182
183#[must_use]
185pub fn ldap_null_byte_attr_decoded(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
186 ldap_null_byte_attr_finditer(haystack, compiled, LDAP_NULL_BYTE_TAIL_DECODED)
187}
188
189#[must_use]
195pub fn quote_splice_finditer(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
196 let mut matches = Vec::new();
197 let mut last_end = 0usize;
198 let bytes = haystack.as_bytes();
199 let mut i = 0usize;
200 while i < bytes.len() {
201 if bytes[i] != b'\'' && bytes[i] != b'"' {
202 i += 1;
203 continue;
204 }
205 let quote_start = i;
206 while i < bytes.len() && (bytes[i] == b'\'' || bytes[i] == b'"') {
207 i += 1;
208 }
209 let quote_end = i;
210 if quote_start < last_end {
211 continue;
212 }
213 if quote_end >= haystack.len()
214 || char_at(haystack, quote_end).is_none_or(|(_, c)| !py_is_word(c))
215 {
216 continue;
217 }
218 let word_start = walk_back(haystack, quote_start, py_is_word);
219 if word_start == quote_start {
220 continue;
221 }
222 if let Some(m) = match_span(compiled.re(), haystack, word_start, haystack.len())
223 && m.start == word_start
224 {
225 matches.push(m);
226 last_end = m.end;
227 } else {
228 last_end = quote_end;
229 }
230 }
231 matches
232}
233
234#[must_use]
240pub fn load_file_scan_matches(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
241 let Ok(prefix) = PyRegex::compile(r"LOAD_FILE\s*\(", true) else {
242 return Vec::new();
243 };
244 let Ok(terminator) = PyRegex::compile(r"\)", false) else {
245 return Vec::new();
246 };
247 bounded_finditer(haystack, compiled, &prefix, &terminator)
248}
249
250#[must_use]
252pub fn dollar_substitution_scan_matches(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
253 let Ok(paren_prefix) = PyRegex::compile(r"[;&|]\s*\$\(", false) else {
254 return Vec::new();
255 };
256 let Ok(paren_term) = PyRegex::compile(r"\)", false) else {
257 return Vec::new();
258 };
259 let Ok(brace_prefix) = PyRegex::compile(r"[;&|]\s*\$\{", false) else {
260 return Vec::new();
261 };
262 let Ok(brace_term) = PyRegex::compile(r"\}", false) else {
263 return Vec::new();
264 };
265 let mut out = bounded_finditer(haystack, compiled, &paren_prefix, &paren_term);
266 out.extend(bounded_finditer(
267 haystack,
268 compiled,
269 &brace_prefix,
270 &brace_term,
271 ));
272 out
273}
274
275#[must_use]
281pub fn glob_wildcard_scan_matches(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
282 let Ok(run_re) = PyRegex::compile(r"[\w./*?-]+", false) else {
283 return Vec::new();
284 };
285 let mut matches = Vec::new();
286 for run in run_re.re().find_iter(haystack) {
287 if !run.as_str().contains(['?', '*']) {
288 continue;
289 }
290 if let Some(m) = compiled.re().find_at(haystack, run.start())
291 && m.start() == run.start()
292 && m.end() <= run.end()
293 {
294 matches.push(Candidate::new(m.start(), m.end()));
295 }
296 }
297 matches
298}
299
300const SELECT_LITERAL: &[u8] = b"SELECT";
305const FROM_LITERAL: &[u8] = b"FROM";
306
307fn eq_ignore_ascii_at(haystack: &str, pos: usize, literal: &[u8]) -> bool {
308 haystack.as_bytes().get(pos..pos + literal.len()) == Some(literal)
309 || haystack
310 .as_bytes()
311 .get(pos..pos + literal.len())
312 .is_some_and(|window| {
313 window
314 .iter()
315 .zip(literal)
316 .all(|(a, b)| a.eq_ignore_ascii_case(b))
317 })
318}
319
320fn word_starts_at(haystack: &str, pos: usize, literal: &[u8]) -> bool {
321 if !haystack.is_char_boundary(pos) {
322 return false;
323 }
324 if !eq_ignore_ascii_at(haystack, pos, literal) {
325 return false;
326 }
327 if pos > 0 && char_before(haystack, pos).is_some_and(|(_, c)| py_is_word(c)) {
328 return false;
329 }
330 let after = pos + literal.len();
331 char_at(haystack, after).is_none_or(|(_, c)| !py_is_word(c))
332}
333
334#[must_use]
339pub fn sqli_select_from_finditer(haystack: &str) -> Vec<Candidate> {
340 let mut matches = Vec::new();
341 let mut search_from = 0usize;
342 while let Some(start) = find_word_at(haystack, search_from, SELECT_LITERAL) {
343 let mut pos = start + SELECT_LITERAL.len();
344 let mut end: Option<usize> = None;
345 loop {
346 if word_starts_at(haystack, pos, FROM_LITERAL) {
347 end = Some(pos + FROM_LITERAL.len());
348 break;
349 }
350 let Some((offset, c)) = char_at(haystack, pos) else {
351 break;
352 };
353 let in_class =
354 py_is_word(c) || c.is_whitespace() || matches!(c, ',' | '*' | '(' | ')' | '.');
355 if !in_class || word_starts_at(haystack, pos, SELECT_LITERAL) {
356 break;
357 }
358 pos = offset + c.len_utf8();
359 }
360 match end {
361 Some(e) => {
362 matches.push(Candidate::new(start, e));
363 search_from = e;
364 }
365 None => {
366 search_from = start + 1;
367 }
368 }
369 }
370 matches
371}
372
373fn find_word_at(haystack: &str, from: usize, literal: &[u8]) -> Option<usize> {
374 let mut pos = from;
375 while pos < haystack.len() {
376 if word_starts_at(haystack, pos, literal) {
377 return Some(pos);
378 }
379 pos += char_at(haystack, pos).map_or(1, |(_, c)| c.len_utf8());
380 }
381 None
382}
383
384const TAUTOLOGY_ATOM: &str = r#"(?:\d+|'[^']*'|"[^"]*"|[@:$][A-Za-z_]\w*)"#;
389
390#[must_use]
397pub fn sqli_tautology_finditer(haystack: &str) -> Vec<Candidate> {
398 let Ok(re) = PyRegex::compile(&format!(r"(?i)\b(?:OR|AND)\s*({TAUTOLOGY_ATOM})"), false) else {
399 return Vec::new();
400 };
401 let mut matches = Vec::new();
402 let mut resume = 0usize;
403 for caps in re.re().captures_iter(haystack) {
404 let whole = caps.get(0).expect("whole match");
405 if whole.start() < resume {
406 continue;
407 }
408 let atom = caps.get(1).expect("atom capture");
409 match tautology_complete(haystack, whole.start(), atom) {
410 Some(end) => {
411 matches.push(Candidate::new(whole.start(), end));
412 resume = end;
413 }
414 None => {
415 resume = whole.start() + 1;
416 }
417 }
418 }
419 matches
420}
421
422fn tautology_atom_variants(haystack: &str, pos: usize, greedy_end: usize) -> Vec<(usize, String)> {
424 let mut variants: Vec<(usize, String)> = Vec::new();
425 let atom_text = &haystack[pos..greedy_end];
426 if !atom_text.is_empty() && atom_text.bytes().all(|b| b.is_ascii_digit()) {
427 let mut end = greedy_end;
429 loop {
430 variants.push((end, haystack[pos..end].to_owned()));
431 if end == pos + 1 {
432 break;
433 }
434 end -= 1;
435 }
436 return variants;
437 }
438 variants.push((greedy_end, atom_text.to_owned()));
439 variants
440}
441
442fn tautology_complete(haystack: &str, match_start: usize, atom: regex::Match<'_>) -> Option<usize> {
445 for (atom_end, atom_text) in tautology_atom_variants(haystack, atom.start(), atom.end()) {
446 let mut cursor = atom_end;
447 cursor = walk_forward_while(haystack, cursor, char::is_whitespace);
448 if char_at(haystack, cursor).map(|(_, c)| c) != Some('=') {
449 continue;
450 }
451 cursor = walk_forward_while(haystack, cursor + 1, char::is_whitespace);
452 let mut second_chars = haystack[cursor..].chars();
456 let equals_atom = atom_text.chars().all(|ac| {
457 second_chars
458 .next()
459 .is_some_and(|bc| bc.eq_ignore_ascii_case(&ac))
460 });
461 if !equals_atom {
462 continue;
463 }
464 let atom_len = atom_text.chars().map(char::len_utf8).sum::<usize>();
465 let after = cursor + atom_len;
466 let before_word = char_before(haystack, after).is_some_and(|(_, c)| py_is_word(c));
467 let after_word = char_at(haystack, after).is_some_and(|(_, c)| py_is_word(c));
468 if before_word != after_word {
469 return Some(after);
470 }
471 }
472 let _ = match_start;
473 None
474}
475
476#[must_use]
482pub fn sqli_inline_comment_finditer(haystack: &str) -> Vec<Candidate> {
483 let Ok(re) = PyRegex::compile(r"\w/\*[^*]*\*/\w", false) else {
484 return Vec::new();
485 };
486 guarded_finditer(haystack, re.re(), |text, candidate| {
487 let Some((i, c)) = char_at(text, candidate.start) else {
490 return false;
491 };
492 let guard_pos = i + c.len_utf8() + 2;
493 text.as_bytes().get(guard_pos).is_some_and(|b| *b != b'!')
494 })
495}
496
497const SSRF_HOST_ALT: &str = r"(?:localhost\.?|127\.0\.0\.1|0\.0\.0\.0|\[::(?:\d*)\]|\[::ffff:127\.0\.0\.1\]|169\.254(?:\.\d{1,3}){2}|192\.168(?:\.\d{1,3}){2}|10(?:\.\d{1,3}){3}|172\.(?:1[6-9]|2[0-9]|3[01])(?:\.\d{1,3}){2}|metadata\.google\.internal|metadata\.goog|100\.100\.100\.200)";
502
503#[must_use]
506pub fn ssrf_private_host_finditer(haystack: &str) -> Vec<Candidate> {
507 let source =
508 format!(r"(?:\A|\s|/)((?:[^\s/@]*@)?)(?:{SSRF_HOST_ALT})(?::\d+)?(?:\s|(?:\n?\z)|/)");
509 let Ok(compiled) = PyRegex::compile(&source, true) else {
510 return Vec::new();
511 };
512 let mut out = Vec::new();
513 let mut from = 0usize;
514 while let Some(caps) = compiled.re().captures_at(haystack, from) {
515 let m = caps.get(0).expect("whole match");
516 let candidate = Candidate::new(m.start(), m.end());
517 let userinfo = caps.get(1).expect("userinfo group");
518 let guard_ok = userinfo.is_empty() || {
519 userinfo.start() >= 3 && &haystack[userinfo.start() - 3..userinfo.start()] == "://"
520 };
521 if guard_ok {
522 out.push(candidate);
523 from = if candidate.end > candidate.start {
524 candidate.end
525 } else {
526 candidate.start + 1
527 };
528 } else {
529 from = candidate.start + 1;
530 }
531 }
532 out
533}
534
535#[must_use]
543pub fn ssrf_numeric_host_finditer(haystack: &str) -> Vec<Candidate> {
544 let Ok(re) = PyRegex::compile(
545 r"://(?:[^/@\s]*@)?((?:0[xX][0-9a-fA-F]+|0[0-7]+|[1-9]\d*|0)(?:\.(?:0[xX][0-9a-fA-F]+|0[0-7]+|[1-9]\d*|0)){0,3})",
546 false,
547 ) else {
548 return Vec::new();
549 };
550 guarded_finditer(haystack, re.re(), |text, candidate| {
551 terminator_guard(text, candidate.end, |c| {
552 matches!(c, ':' | '/') || c.is_whitespace()
553 })
554 })
555}
556
557fn terminator_guard(haystack: &str, pos: usize, terminator: impl Fn(char) -> bool) -> bool {
560 let Some(c) = char_at(haystack, pos).map(|(_, c)| c) else {
561 return true; };
563 if terminator(c) {
564 return true;
565 }
566 c == '\n' && pos + 1 == haystack.len()
568}
569
570const SHELL_DASH_FLAG_BODY: &str = r#"(?:\A|[;|&])\s*/?(?:[\w.-]+/)*(?:env\s+/?(?:[\w.-]+/)*)?(?:bash|sh|ksh|csh|tsch|zsh|ash)\s+-[a-zA-Z]+(?:\s+(?:'[^']*'|"[^"]*"|[^\s;|&]+))?"#;
575
576#[must_use]
581pub fn shell_dash_flag_finditer(haystack: &str) -> Vec<Candidate> {
582 let Ok(re) = PyRegex::compile(SHELL_DASH_FLAG_BODY, true) else {
583 return Vec::new();
584 };
585 guarded_finditer(haystack, re.re(), |text, candidate| {
586 lookahead_shell_chain_terminator(text, candidate.end)
587 })
588}
589
590fn lookahead_shell_chain_terminator(haystack: &str, pos: usize) -> bool {
591 let rest = &haystack[pos..];
592 let trimmed = rest.trim_start_matches(|c: char| c.is_whitespace());
593 trimmed.is_empty()
594 || trimmed.starts_with(';')
595 || trimmed.starts_with('|')
596 || trimmed.starts_with('&')
597}
598
599type BadMatcher<'a> = Box<dyn Fn(&str, usize) -> Vec<usize> + 'a>;
605
606#[derive(Debug, Clone, Copy, PartialEq, Eq)]
609pub enum LeadingSep {
610 Optional,
611 Required,
612}
613
614fn is_path_class(c: char) -> bool {
615 py_is_word(c) || matches!(c, '.' | '-' | '~' | '%')
616}
617
618fn is_word_dash(c: char) -> bool {
619 py_is_word(c) || c == '-'
620}
621
622fn bad_followed_by_segment_boundary(haystack: &str, ends: &[usize]) -> bool {
623 ends.iter().any(|end| {
624 char_at(haystack, *end).is_none_or(|(_, c)| matches!(c, '/' | '\\'))
625 || *end == haystack.len()
626 })
627}
628
629fn sensitive_path_finditer(
636 haystack: &str,
637 leading_sep: LeadingSep,
638 bad: impl Fn(&str, usize) -> Vec<usize>,
639) -> Vec<Candidate> {
640 let mut pos = 0usize;
641 match (leading_sep, char_at(haystack, 0)) {
642 (LeadingSep::Optional | LeadingSep::Required, Some((_, '/' | '\\'))) => pos = 1,
643 (LeadingSep::Required, _) => return Vec::new(),
644 (LeadingSep::Optional, _) => {}
645 }
646 loop {
647 let ends = bad(haystack, pos);
648 if bad_followed_by_segment_boundary(haystack, &ends) {
649 break;
650 }
651 let seg_end = walk_forward_while(haystack, pos, is_path_class);
652 if seg_end > pos && char_at(haystack, seg_end).is_some_and(|(_, c)| matches!(c, '/' | '\\'))
653 {
654 pos = seg_end + 1;
655 continue;
656 }
657 break;
658 }
659 for bad_end in bad(haystack, pos) {
661 let mut cursor = bad_end;
662 while let Some('/' | '\\') = char_at(haystack, cursor).map(|(_, c)| c) {
663 cursor += 1;
664 cursor = walk_forward_while(haystack, cursor, is_path_class);
665 }
666 if char_at(haystack, cursor).map(|(_, c)| c) == Some('?') {
667 cursor += 1;
668 cursor = walk_forward_while(haystack, cursor, |c| !c.is_whitespace());
669 }
670 let tail_end = walk_forward_while(haystack, cursor, char::is_whitespace);
671 if tail_end == haystack.len() {
672 return vec![Candidate::new(0, haystack.len())];
673 }
674 }
675 Vec::new()
676}
677
678fn literal_bad<'a>(alternatives: &'a [&'a str]) -> BadMatcher<'a> {
683 Box::new(move |haystack: &str, pos: usize| -> Vec<usize> {
684 let mut ends = Vec::new();
685 for alt in alternatives {
686 if ascii_starts_with_ignore_case(haystack, pos, alt) {
688 ends.push(pos + alt.len());
689 }
690 }
691 ends
692 })
693}
694
695#[derive(Debug, Clone, Copy, PartialEq, Eq)]
697pub enum BadSuffix {
698 DashRun,
700 YamlSuffix,
702 DotRun,
704}
705
706fn literal_suffix_bad<'a>(alternatives: &'a [&'a str], suffix: BadSuffix) -> BadMatcher<'a> {
707 Box::new(move |haystack: &str, pos: usize| -> Vec<usize> {
708 let mut ends = Vec::new();
709 for alt in alternatives {
710 if !ascii_starts_with_ignore_case(haystack, pos, alt) {
711 continue;
712 }
713 let head = pos + alt.len();
714 ends.push(head);
716 let rest = &haystack[head..];
717 match suffix {
718 BadSuffix::YamlSuffix => {
719 if let Some(after_dot) = rest.strip_prefix('.') {
720 if ascii_starts_with_ignore_case(after_dot, 0, "yaml") {
721 ends.push(head + 5);
722 }
723 if ascii_starts_with_ignore_case(after_dot, 0, "yml") {
724 ends.push(head + 4);
725 }
726 }
727 }
728 BadSuffix::DotRun => {
729 if rest.starts_with('.') {
730 ends.push(walk_forward_while(haystack, head + 1, is_path_class));
731 ends.push(head + 1);
732 }
733 }
734 BadSuffix::DashRun => {
735 if let Some((i, c)) = char_at(haystack, head)
736 && matches!(c, '.' | '-')
737 {
738 let run_end = walk_forward_while(haystack, i + c.len_utf8(), is_path_class);
739 ends.push(run_end);
740 ends.push(i + c.len_utf8());
741 }
742 }
743 }
744 }
745 ends
746 })
747}
748
749fn extension_run_bad<'a>(extensions: &'a [&'a str]) -> BadMatcher<'a> {
752 Box::new(move |haystack: &str, pos: usize| -> Vec<usize> {
753 let run_end = walk_forward_while(haystack, pos, is_path_class);
754 let mut ends = Vec::new();
755 let mut cursor = pos;
756 while cursor < run_end {
757 let Some((i, c)) = char_at(haystack, cursor) else {
758 break;
759 };
760 if c == '.' {
761 let after_dot = i + 1;
762 for ext in extensions {
763 if ascii_starts_with_ignore_case(haystack, after_dot, ext) {
764 ends.push(after_dot + ext.len());
765 }
766 }
767 }
768 cursor = i + c.len_utf8();
769 }
770 ends
771 })
772}
773
774fn secrets_run_bad<'a>(extensions: &'a [&'a str]) -> BadMatcher<'a> {
776 Box::new(move |haystack: &str, pos: usize| -> Vec<usize> {
777 let run_end = walk_forward_while(haystack, pos, is_path_class);
778 let mut ends = Vec::new();
779 let mut cursor = pos;
780 while cursor < run_end {
781 let Some((i, c)) = char_at(haystack, cursor) else {
782 break;
783 };
784 for stem in ["secret", "credential"] {
785 if !ascii_starts_with_ignore_case(haystack, i, stem) {
786 continue;
787 }
788 let mut after_stem = i + stem.len();
789 if let Some((_, plural)) = char_at(haystack, after_stem)
790 && matches!(plural, 's' | 'S')
791 {
792 after_stem += 1;
793 }
794 if char_at(haystack, after_stem).map(|(_, c)| c) != Some('.') {
795 continue;
796 }
797 let after_dot = after_stem + 1;
798 for ext in extensions {
799 if ascii_starts_with_ignore_case(haystack, after_dot, ext) {
800 ends.push(after_dot + ext.len());
801 }
802 }
803 }
804 cursor = i + c.len_utf8();
805 }
806 ends
807 })
808}
809
810const CONFIG_EXTENSIONS: &[&str] = &["env", "yml", "yaml", "json", "toml", "ini", "xml", "conf"];
811
812fn config_bad(haystack: &str, pos: usize) -> Vec<usize> {
817 let run_end = walk_forward_while(haystack, pos, is_word_dash);
818 let mut config_start = None;
819 let mut cursor = pos;
820 while cursor + "config".len() <= run_end {
821 if ascii_starts_with_ignore_case(haystack, cursor, "config") {
822 config_start = Some(cursor);
823 break;
824 }
825 cursor = char_at(haystack, cursor).map_or(cursor + 1, |(i, c)| i + c.len_utf8());
826 }
827 let Some(config_start) = config_start else {
828 return Vec::new();
829 };
830 let word_end = walk_forward_while(haystack, config_start + "config".len(), is_word_dash);
831 if char_at(haystack, word_end).map(|(_, c)| c) != Some('.') {
832 return Vec::new();
833 }
834 let after_dot = word_end + 1;
835 let mut ends = Vec::new();
836 for ext in CONFIG_EXTENSIONS {
837 if ascii_starts_with_ignore_case(haystack, after_dot, ext) {
838 ends.push(after_dot + ext.len());
839 }
840 }
841 ends
842}
843
844fn env_bad(haystack: &str, pos: usize) -> Vec<usize> {
846 let mut ends = Vec::new();
847 if ascii_starts_with_ignore_case(haystack, pos, ".env") {
848 let after = pos + 4;
849 if char_at(haystack, after).map(|(_, c)| c) == Some('.') {
850 let word_end = walk_forward_while(haystack, after + 1, py_is_word);
851 if word_end > after + 1 {
852 ends.push(word_end);
853 }
854 }
855 ends.push(after);
856 }
857 ends
858}
859
860fn dot_alternative_bad<'a>(names: &'a [&'a str]) -> BadMatcher<'a> {
862 Box::new(move |haystack: &str, pos: usize| -> Vec<usize> {
863 let mut ends = Vec::new();
864 if char_at(haystack, pos).map(|(_, c)| c) == Some('.') {
865 for name in names {
866 if ascii_starts_with_ignore_case(haystack, pos + 1, name) {
867 ends.push(pos + 1 + name.len());
868 }
869 }
870 }
871 ends
872 })
873}
874
875fn wp_admin_bad(haystack: &str, pos: usize) -> Vec<usize> {
878 const HEADS: &[&str] = &[
879 "wp-admin",
880 "wp-login",
881 "wp-content",
882 "wp-includes",
883 "wp-config",
884 "administrator",
885 "xmlrpc",
886 ];
887 let mut ends = Vec::new();
888 for head in HEADS {
889 if ascii_starts_with_ignore_case(haystack, pos, head) {
890 let after = pos + head.len();
891 let dotted = char_at(haystack, after).map(|(_, c)| c) == Some('.');
893 let after_dot = if dotted { after + 1 } else { after };
894 if ascii_starts_with_ignore_case(haystack, after_dot, "php") {
895 ends.push(after_dot + 3);
896 }
897 if dotted {
898 ends.push(after_dot);
899 }
900 ends.push(after);
901 }
902 }
903 ends
904}
905
906const MANAGEMENT_BAD: &[&str] = &[
907 "management",
908 "config_dump",
909 "credentials",
910 "system/version",
911 "system\\version",
912 "version/system",
913 "version\\system",
914];
915
916pub const RECON_APP_BAD: &[&str] = &[
917 "geoserver",
918 "confluence",
919 "nifi",
920 "ScadaBR",
921 "pandora_console",
922 "centreon",
923 "kylin",
924 "decisioncenter",
925 "evox",
926 "MagicInfo",
927 "metasys",
928 "officescan",
929 "helpdesk",
930 "ignite",
931];
932
933pub const RECON_README_BAD: &[&str] = &[
934 "readme.txt",
935 "README.md",
936 "CHANGELOG",
937 "pom.xml",
938 "build.gradle",
939 "appsettings.json",
940 "crossdomain.xml",
941];
942
943pub const BACKUP_EXTENSIONS: &[&str] = &[
944 "bak", "backup", "old", "orig", "save", "swp", "swo", "tmp", "temp",
945];
946
947pub const SECRETS_EXTENSIONS: &[&str] = &[
948 "py", "json", "yml", "yaml", "toml", "txt", "env", "xml", "conf", "cfg",
949];
950
951pub const DOCKERFILE_BAD: &[&str] = &[
952 "docker-compose",
953 "Dockerfile",
954 "Makefile",
955 "Vagrantfile",
956 "Jenkinsfile",
957 "Procfile",
958];
959
960pub const DOUBLE_DOT_BAD: &[&str] = &[
961 ".htaccess",
962 ".htpasswd",
963 ".DS_Store",
964 "Thumbs.db",
965 ".npmrc",
966 ".dockerenv",
967 "web.config",
968];
969
970#[must_use]
972pub fn sensitive_path_env(haystack: &str) -> Vec<Candidate> {
973 sensitive_path_finditer(haystack, LeadingSep::Optional, env_bad)
974}
975
976#[must_use]
978pub fn sensitive_path_wp_admin(haystack: &str) -> Vec<Candidate> {
979 sensitive_path_finditer(haystack, LeadingSep::Optional, wp_admin_bad)
980}
981
982#[must_use]
985pub fn sensitive_path_management(haystack: &str) -> Vec<Candidate> {
986 sensitive_path_finditer(haystack, LeadingSep::Required, literal_bad(MANAGEMENT_BAD))
987}
988
989#[must_use]
991pub fn sensitive_path_scan_ext(haystack: &str, extensions: &[&str]) -> Vec<Candidate> {
992 sensitive_path_finditer(
993 haystack,
994 LeadingSep::Optional,
995 extension_run_bad(extensions),
996 )
997}
998
999#[must_use]
1001pub fn sensitive_path_dot_alt(haystack: &str, names: &[&str]) -> Vec<Candidate> {
1002 sensitive_path_finditer(haystack, LeadingSep::Optional, dot_alternative_bad(names))
1003}
1004
1005#[must_use]
1008pub fn sensitive_path_literal_ext(haystack: &str, names: &[&str], ext: &str) -> Vec<Candidate> {
1009 let literals: Vec<String> = names.iter().map(|name| format!("{name}.{ext}")).collect();
1010 let refs: Vec<&str> = literals.iter().map(String::as_str).collect();
1011 sensitive_path_finditer(haystack, LeadingSep::Optional, literal_bad(&refs))
1012}
1013
1014#[must_use]
1017pub fn sensitive_path_literal(haystack: &str, literals: &[&str]) -> Vec<Candidate> {
1018 sensitive_path_finditer(haystack, LeadingSep::Optional, literal_bad(literals))
1019}
1020
1021#[must_use]
1023pub fn sensitive_path_literal_suffix(
1024 haystack: &str,
1025 literals: &[&str],
1026 suffix: BadSuffix,
1027) -> Vec<Candidate> {
1028 sensitive_path_finditer(
1029 haystack,
1030 LeadingSep::Optional,
1031 literal_suffix_bad(literals, suffix),
1032 )
1033}
1034
1035#[must_use]
1037pub fn sensitive_path_literal_suffix_dot(haystack: &str, literals: &[&str]) -> Vec<Candidate> {
1038 sensitive_path_literal_suffix(haystack, literals, BadSuffix::DotRun)
1039}
1040
1041#[must_use]
1043pub fn sensitive_path_secrets(haystack: &str) -> Vec<Candidate> {
1044 sensitive_path_finditer(
1045 haystack,
1046 LeadingSep::Optional,
1047 secrets_run_bad(SECRETS_EXTENSIONS),
1048 )
1049}
1050
1051#[must_use]
1053pub fn sensitive_path_config(haystack: &str) -> Vec<Candidate> {
1054 sensitive_path_finditer(haystack, LeadingSep::Optional, config_bad)
1055}
1056
1057const ATTACK_REPORT_LEXICON: &str = r"\b(?:scan(?:ner|ning|ned|s)?|attack(?:er|ers|ed|s)?|attempt(?:ed|s)?|exploit(?:ation|ed|s|ing|kit)?|prob(?:e|ed|es|ing)|malicious|intrusion(?:s)?|botnet(?:s)?|honeypot(?:s)?|brute[- ]force|credential[- ]stuffing|threat feed|vulnerabilit(?:y|ies)|hostile|recon(?:naissance)?|spoofed referer|bad actor(?:s)?|WAF|IDS|SOC|pentest(?:ing)?|blocked|flagged|triggered|denied|enumerat(?:e|ed|ing)|suspicious)\b";
1060
1061#[must_use]
1067pub fn lexicon_path_finditer(haystack: &str, shape: &str, require_lexicon: bool) -> Vec<Candidate> {
1068 let Ok(compiled) = PyRegex::compile(shape, true) else {
1069 return Vec::new();
1070 };
1071 let matches: Vec<Candidate> = compiled
1072 .re()
1073 .find_iter(haystack)
1074 .map(|m| Candidate::new(m.start(), m.end()))
1075 .collect();
1076 if !require_lexicon || matches.is_empty() {
1077 return matches;
1078 }
1079 let Ok(lexicon) = PyRegex::compile(ATTACK_REPORT_LEXICON, true) else {
1080 return Vec::new();
1081 };
1082 let first_line_end = haystack.find('\n').unwrap_or(haystack.len());
1083 if lexicon.re().is_match(&haystack[..first_line_end]) {
1084 matches
1085 } else {
1086 Vec::new()
1087 }
1088}
1089
1090#[must_use]
1096pub fn proto_pollution_assign_finditer(haystack: &str) -> Vec<Candidate> {
1097 let Ok(re) = PyRegex::compile(r"Object\.prototype\.[A-Za-z_$][\w$]*\s*=", true) else {
1098 return Vec::new();
1099 };
1100 guarded_finditer(haystack, re.re(), |text, candidate| {
1101 text.as_bytes().get(candidate.end) != Some(&b'=')
1102 })
1103}
1104#[must_use]
1111pub fn deserialization_b64_finditer(haystack: &str, magic: &str) -> Vec<Candidate> {
1112 let mut out = Vec::new();
1113 let mut from = 0usize;
1114 while let Some(rel) = haystack[from..].find(magic) {
1115 let start = from + rel;
1116 let end = start + magic.len();
1117 let boundary_ok = start == 0
1118 || char_before(haystack, start)
1119 .is_none_or(|(_, c)| !c.is_ascii_alphanumeric() && c != '/' && c != '+');
1120 if boundary_ok {
1121 out.push(Candidate::new(start, end));
1122 from = end;
1123 } else {
1124 from = start + 1;
1125 }
1126 }
1127 out
1128}
1129
1130#[must_use]
1136pub fn sqli_order_by_terminator_finditer(haystack: &str) -> Vec<Candidate> {
1137 let Ok(alt1) = PyRegex::compile(r"\bORDER\s+BY\s+\d+\s*(?:--|#|;|\)|,|/\*|\z)", true) else {
1138 return Vec::new();
1139 };
1140 let Ok(alt2) = PyRegex::compile(r"ORDER\s+BY\s+\d+\s*\n", true) else {
1141 return Vec::new();
1142 };
1143 let mut out: Vec<Candidate> = alt1
1144 .re()
1145 .find_iter(haystack)
1146 .map(|m| Candidate::new(m.start(), m.end()))
1147 .collect();
1148 let guarded = guarded_finditer(haystack, alt2.re(), |text, candidate| {
1149 candidate.start > 0
1150 && char_before(text, candidate.start).is_some_and(|(_, c)| matches!(c, '=' | '?' | '&'))
1151 });
1152 out.extend(guarded);
1153 out.sort_by_key(|c| (c.start, std::cmp::Reverse(c.end)));
1154 out
1155}
1156
1157#[must_use]
1163pub fn file_inclusion_bare_host_finditer(haystack: &str) -> Vec<Candidate> {
1164 let Ok(re) = PyRegex::compile(
1165 r"//(?:[0-9a-zA-Z](?:[-\w]*[0-9a-zA-Z])?(?:\.[0-9a-zA-Z](?:[-\w]*[0-9a-zA-Z])?)+)(:[0-9]+)?(?:/?)(?:[a-zA-Z0-9\-\.\?,'/\\+&%$#_]*)?",
1166 false,
1167 ) else {
1168 return Vec::new();
1169 };
1170 guarded_finditer(haystack, re.re(), |text, candidate| {
1171 candidate.start == 0 || char_before(text, candidate.start).is_none_or(|(_, c)| c != ':')
1172 })
1173}
1174
1175const SCHEME_PATH_TRIMMED: &str =
1180 r#"=(?:https?|ftp)://[^\s'"<>]+/[^\s'"<>/]*\.(?:phtml|php[3-5]?|phar|jsp|aspx?|pl|py|txt|inc)"#;
1181
1182#[must_use]
1188pub fn file_inclusion_scheme_path_finditer(haystack: &str) -> Vec<Candidate> {
1189 let (Some(compiled), Some(prefix), Some(terminator)) = (
1190 PyRegex::compile(SCHEME_PATH_TRIMMED, true).ok(),
1191 PyRegex::compile(r"=(?:https?|ftp)://", true).ok(),
1192 PyRegex::compile(
1193 r"\.(?:phtml|php\d*|phar|jsp|aspx?|pl|py|txt|inc)[a-zA-Z0-9]*",
1194 true,
1195 )
1196 .ok(),
1197 ) else {
1198 return Vec::new();
1199 };
1200 super::scan_window::bounded_finditer_guarded(
1201 haystack,
1202 &compiled,
1203 &prefix,
1204 &terminator,
1205 |text, candidate| {
1206 char_at(text, candidate.end).is_none_or(|(_, c)| !c.is_ascii_alphanumeric())
1207 },
1208 )
1209}
1210
1211const TEMPLATE_URL_TRIMMED: &str = r#"["'](?:template|include|tpl|module|layout)["']\s*:\s*["'](?:https?|ftp)://[^\s'"<>]+/[^\s'"<>/]*\.(?:phtml|php[3-5]?|phar|jsp|aspx?|cgi|pl|py|sh|txt|inc)["']"#;
1216
1217#[must_use]
1221pub fn file_inclusion_template_url_finditer(haystack: &str) -> Vec<Candidate> {
1222 let Ok(compiled) = PyRegex::compile(TEMPLATE_URL_TRIMMED, true) else {
1223 return Vec::new();
1224 };
1225 compiled
1226 .re()
1227 .find_iter(haystack)
1228 .map(|m| Candidate::new(m.start(), m.end()))
1229 .collect()
1230}
1231
1232#[must_use]
1238pub fn glued_backtick_candidate_finditer(haystack: &str) -> Vec<Candidate> {
1239 let Ok(re) = PyRegex::compile(r"`(?:[A-Za-z0-9_./~]|\$[({])(?:[^`\\\n]|\\.)*`", false) else {
1240 return Vec::new();
1241 };
1242 guarded_finditer(haystack, re.re(), |text, candidate| {
1243 candidate.start == 0 || char_before(text, candidate.start).is_none_or(|(_, c)| c != '`')
1244 })
1245}
1246
1247pub const HTML_EVENT_HANDLER_ATTRS: &[&str] = &[
1254 "onwebkitplaybacktargetavailabilitychanged",
1255 "oncontentvisibilityautostatechange",
1256 "onwebkitpresentationmodechanged",
1257 "onwebkitmouseforcewillbegin",
1258 "onwebkitanimationiteration",
1259 "onsecuritypolicyviolation",
1260 "onwebkitmouseforcechanged",
1261 "onvalidationstatuschange",
1262 "onwebkitfullscreenchange",
1263 "onwebkitwillrevealbottom",
1264 "onwebkitanimationstart",
1265 "onwebkitmouseforcedown",
1266 "onbeforescriptexecute",
1267 "onmozfullscreenchange",
1268 "onwebkittransitionend",
1269 "onafterscriptexecute",
1270 "onanimationiteration",
1271 "onlostpointercapture",
1272 "onscrollsnapchanging",
1273 "onunhandledrejection",
1274 "onwebkitanimationend",
1275 "onwebkitmouseforceup",
1276 "ondeviceorientation",
1277 "ongotpointercapture",
1278 "onbeforedeactivate",
1279 "onfullscreenchange",
1280 "onpointerrawupdate",
1281 "onreadystatechange",
1282 "onrejectionhandled",
1283 "onscrollsnapchange",
1284 "ontransitioncancel",
1285 "onanimationcancel",
1286 "onbeforeeditfocus",
1287 "oncontextrestored",
1288 "ondatasetcomplete",
1289 "onselectionchange",
1290 "ontransitionstart",
1291 "onanimationstart",
1292 "onbeforeactivate",
1293 "oncanplaythrough",
1294 "ondatasetchanged",
1295 "ondurationchange",
1296 "onlanguagechange",
1297 "onlayoutcomplete",
1298 "onloadedmetadata",
1299 "onpropertychange",
1300 "oncontrolselect",
1301 "ondataavailable",
1302 "ongesturechange",
1303 "onmediacomplete",
1304 "onpointercancel",
1305 "onpromptdismiss",
1306 "ontransitionend",
1307 "ontransitionrun",
1308 "onwebkitneedkey",
1309 "onanimationend",
1310 "onbeforetoggle",
1311 "onbeforeunload",
1312 "onbeforeupdate",
1313 "ondevicemotion",
1314 "onfilterchange",
1315 "ongesturestart",
1316 "onmessageerror",
1317 "onpointerenter",
1318 "onpointerleave",
1319 "onpromptaction",
1320 "onsyncrestored",
1321 "onvolumechange",
1322 "onafterupdate",
1323 "onbeforeinput",
1324 "onbeforematch",
1325 "onbeforepaste",
1326 "onbeforeprint",
1327 "oncontextlost",
1328 "oncontextmenu",
1329 "onerrorupdate",
1330 "onlosecapture",
1331 "onpointerdown",
1332 "onpointermove",
1333 "onpointerover",
1334 "onresizestart",
1335 "onrowinserted",
1336 "onselectstart",
1337 "ontouchcancel",
1338 "ontrackchange",
1339 "onafterprint",
1340 "onbeforecopy",
1341 "oncellchange",
1342 "ondeactivate",
1343 "ongestureend",
1344 "onhashchange",
1345 "onloadeddata",
1346 "onmediaerror",
1347 "onmouseenter",
1348 "onmouseleave",
1349 "onmousewheel",
1350 "onpagereveal",
1351 "onpointerout",
1352 "onratechange",
1353 "onslotchange",
1354 "ontimeupdate",
1355 "ontouchstart",
1356 "onbeforecut",
1357 "oncuechange",
1358 "ondragenter",
1359 "ondragleave",
1360 "ondragstart",
1361 "onloadstart",
1362 "onmousedown",
1363 "onmousemove",
1364 "onmouseover",
1365 "onmovestart",
1366 "onoutofsync",
1367 "onpointerup",
1368 "onresizeend",
1369 "onrowdelete",
1370 "onrowsenter",
1371 "onscrollend",
1372 "ontimeerror",
1373 "ontouchmove",
1374 "onactivate",
1375 "onauxclick",
1376 "ondblclick",
1377 "ondragdrop",
1378 "ondragexit",
1379 "ondragover",
1380 "onfocusout",
1381 "onformdata",
1382 "onkeypress",
1383 "onlocation",
1384 "onmouseout",
1385 "onpagehide",
1386 "onpageshow",
1387 "onpageswap",
1388 "onpopstate",
1389 "onprogress",
1390 "ontouchend",
1391 "oncanplay",
1392 "oncommand",
1393 "ondragend",
1394 "onemptied",
1395 "onfocusin",
1396 "oninvalid",
1397 "onkeydown",
1398 "onmessage",
1399 "onmouseup",
1400 "onmoveend",
1401 "onoffline",
1402 "onplaying",
1403 "onreverse",
1404 "onrowexit",
1405 "onseeking",
1406 "onstalled",
1407 "onstorage",
1408 "onsuspend",
1409 "onurlflip",
1410 "onwaiting",
1411 "onbounce",
1412 "oncancel",
1413 "onchange",
1414 "onfinish",
1415 "ononline",
1416 "onrepeat",
1417 "onresize",
1418 "onresume",
1419 "onscroll",
1420 "onsearch",
1421 "onseeked",
1422 "onselect",
1423 "onsubmit",
1424 "ontoggle",
1425 "onunload",
1426 "onabort",
1427 "onbegin",
1428 "onclick",
1429 "onclose",
1430 "onended",
1431 "onerror",
1432 "onfocus",
1433 "oninput",
1434 "onkeyup",
1435 "onpaste",
1436 "onpause",
1437 "onreset",
1438 "onstart",
1439 "onwheel",
1440 "onblur",
1441 "oncopy",
1442 "ondrag",
1443 "ondrop",
1444 "onhelp",
1445 "onload",
1446 "onmove",
1447 "onplay",
1448 "onredo",
1449 "onseek",
1450 "onstop",
1451 "onundo",
1452 "oncut",
1453 "onend",
1454];
1455
1456fn ascii_starts_with_ignore_case(haystack: &str, pos: usize, needle: &str) -> bool {
1459 let bytes = haystack.as_bytes();
1460 let Some(window) = bytes.get(pos..pos + needle.len()) else {
1461 return false;
1462 };
1463 window
1464 .iter()
1465 .zip(needle.bytes())
1466 .all(|(a, b)| a.eq_ignore_ascii_case(&b))
1467}
1468
1469fn event_handler_value_end(haystack: &str, pos: usize) -> Option<usize> {
1471 let mut cursor = walk_forward_while(haystack, pos, char::is_whitespace);
1472 if char_at(haystack, cursor).map(|(_, c)| c) != Some('=') {
1473 return None;
1474 }
1475 cursor = char_at(haystack, cursor).map_or(cursor, |(i, c)| i + c.len_utf8());
1476 let mut taken = 0usize;
1478 while taken < 20 && char_at(haystack, cursor).is_some_and(|(_, c)| c.is_whitespace()) {
1479 cursor = char_at(haystack, cursor).map_or(cursor, |(i, c)| i + c.len_utf8());
1480 taken += 1;
1481 }
1482 match char_at(haystack, cursor).map(|(_, c)| c) {
1483 Some(quote @ ('"' | '\'')) => {
1484 let mut scan = cursor + quote.len_utf8();
1487 while let Some((i, c)) = char_at(haystack, scan) {
1488 if c == '"' || c == '\'' {
1489 return Some(i + c.len_utf8());
1490 }
1491 scan = i + c.len_utf8();
1492 }
1493 None
1494 }
1495 Some(_) => {
1496 let end = walk_forward_while(haystack, cursor, |c| !c.is_whitespace() && c != '>');
1497 (end > cursor).then_some(end)
1498 }
1499 None => None,
1500 }
1501}
1502
1503fn event_handler_lookbehind_ok(haystack: &str, run_start: usize) -> bool {
1507 let Some((c1_pos, c1)) = char_before(haystack, run_start) else {
1508 return true; };
1510 if c1 == '=' {
1511 return false;
1512 }
1513 if c1 == '"' || c1 == '\'' {
1514 let quoted_after_equals = char_before(haystack, c1_pos).is_some_and(|(_, c2)| c2 == '=');
1515 if quoted_after_equals {
1516 return false;
1517 }
1518 }
1519 true
1520}
1521
1522fn event_handler_run_end(haystack: &str, run_start: usize) -> usize {
1524 let mut pos = run_start;
1525 while char_at(haystack, pos).is_some_and(|(_, rc)| rc.is_whitespace() || rc == '/') {
1526 pos = char_at(haystack, pos).map_or(haystack.len(), |(i, rc)| i + rc.len_utf8());
1527 }
1528 pos
1529}
1530
1531#[must_use]
1540pub fn xss_event_handler_finditer(haystack: &str) -> Vec<Candidate> {
1541 let Ok(tag_open) = PyRegex::compile(r"<[A-Za-z/]", false) else {
1542 return Vec::new();
1543 };
1544 let mut matches = Vec::new();
1545 let mut resume = 0usize;
1546 for open in tag_open.re().find_iter(haystack) {
1547 if open.start() < resume {
1548 continue;
1549 }
1550 let mut runs: Vec<usize> = Vec::new();
1553 let mut cursor = open.end();
1554 let mut open_run: Option<usize> = None;
1555 while let Some((offset, c)) = char_at(haystack, cursor) {
1556 if c == '<' || c == '>' {
1557 break;
1558 }
1559 if c.is_whitespace() || c == '/' {
1560 if open_run.is_none() {
1561 open_run = Some(offset);
1562 }
1563 } else if let Some(r) = open_run.take() {
1564 runs.push(r);
1565 }
1566 cursor = offset + c.len_utf8();
1567 }
1568 if let Some(r) = open_run {
1569 runs.push(r);
1570 }
1571 runs.reverse();
1573 for r in runs {
1574 let run_end = event_handler_run_end(haystack, r);
1575 if event_handler_lookbehind_ok(haystack, r)
1576 && let Some(end) = try_event_name_value(haystack, run_end)
1577 {
1578 matches.push(Candidate::new(open.start(), end));
1579 resume = end;
1580 break;
1581 }
1582 }
1583 }
1584 matches
1585}
1586
1587fn try_event_name_value(haystack: &str, name_pos: usize) -> Option<usize> {
1588 for name in HTML_EVENT_HANDLER_ATTRS {
1589 if ascii_starts_with_ignore_case(haystack, name_pos, name) {
1590 let after_name = name_pos + name.len();
1591 if let Some(end) = event_handler_value_end(haystack, after_name) {
1592 return Some(end);
1593 }
1594 }
1595 }
1596 None
1597}
1598
1599#[must_use]
1601pub fn rfind_in(haystack: &str, needle: &str, start: usize, end: usize) -> Option<usize> {
1602 str_rfind_in(haystack, needle, start, end)
1603}
1604
1605#[cfg(test)]
1606mod tests {
1607 use super::*;
1608
1609 #[test]
1610 fn select_from_first_reachable_from() {
1611 let ms = sqli_select_from_finditer("SELECT a FROM b");
1612 assert_eq!(ms.len(), 1);
1613 assert_eq!(ms[0].text("SELECT a FROM b"), "SELECT a FROM");
1615 }
1616
1617 #[test]
1618 fn select_from_blocked_by_second_select() {
1619 let ms = sqli_select_from_finditer("SELECT x SELECT FROM");
1622 assert_eq!(ms.len(), 1);
1623 assert_eq!(ms[0].start, 9);
1624 assert_eq!(ms[0].text("SELECT x SELECT FROM"), "SELECT FROM");
1625 }
1626
1627 #[test]
1628 fn tautology_requires_equal_atoms() {
1629 let ms = sqli_tautology_finditer("' OR 1=1--");
1630 assert_eq!(ms.len(), 1);
1631 assert_eq!(ms[0].text("' OR 1=1--"), "OR 1=1");
1632 assert!(sqli_tautology_finditer("' OR 1=2--").is_empty());
1633 }
1634
1635 #[test]
1636 fn ssrf_private_requires_scheme_for_userinfo() {
1637 let ms = ssrf_private_host_finditer("http://10.0.0.1/shell.txt");
1638 assert_eq!(ms.len(), 1);
1639 assert_eq!(ms[0].text("http://10.0.0.1/shell.txt"), "/10.0.0.1/");
1640 let ms = ssrf_private_host_finditer("| nc 10.0.0.1 4444");
1642 assert_eq!(ms.len(), 1);
1643 }
1644
1645 #[test]
1646 fn ssrf_numeric_guard() {
1647 let ms = ssrf_numeric_host_finditer("curl http://10.0.0.1/x.sh");
1648 assert_eq!(ms.len(), 1);
1649 assert_eq!(ms[0].text("curl http://10.0.0.1/x.sh"), "://10.0.0.1");
1650 assert!(ssrf_numeric_host_finditer("curl http://10.0.0.1x/").is_empty());
1652 }
1653
1654 #[test]
1655 fn shell_dash_flag_lookahead() {
1656 let ms = shell_dash_flag_finditer("bash -c \"id\"");
1657 assert_eq!(ms.len(), 1);
1658 assert!(shell_dash_flag_finditer("bash -c \"id\" x tail").is_empty());
1659 }
1660
1661 #[test]
1662 fn sensitive_env_path() {
1663 let ms = sensitive_path_env("/.env");
1664 assert_eq!(ms.len(), 1);
1665 let ms = sensitive_path_env("/app/.env.local/config");
1667 assert_eq!(ms.len(), 1);
1668 let ms = sensitive_path_env("/app/.env");
1669 assert_eq!(ms.len(), 1);
1670 }
1671
1672 #[test]
1673 fn sensitive_wp_admin_path() {
1674 assert_eq!(sensitive_path_wp_admin("/wp-login.php").len(), 1);
1675 assert_eq!(
1676 sensitive_path_wp_admin("/wp-admin/setup-config.php").len(),
1677 1
1678 );
1679 assert!(sensitive_path_wp_admin("/admin/login/?next=/admin/").is_empty());
1680 }
1681
1682 #[test]
1683 fn sensitive_backup_path() {
1684 assert_eq!(
1685 sensitive_path_scan_ext("/config.php.bak", BACKUP_EXTENSIONS).len(),
1686 1
1687 );
1688 assert!(sensitive_path_scan_ext("/config.php", BACKUP_EXTENSIONS).is_empty());
1689 }
1690
1691 #[test]
1692 fn management_requires_leading_separator() {
1693 assert_eq!(sensitive_path_management("/.aws/credentials").len(), 1);
1694 assert!(sensitive_path_management(".aws/credentials").is_empty());
1695 }
1696
1697 #[test]
1698 fn deserialization_magic_boundary() {
1699 assert_eq!(deserialization_b64_finditer("rO0ABXNy", "rO0AB").len(), 1);
1700 assert!(deserialization_b64_finditer("BASE64rO0AB", "rO0AB").is_empty());
1701 }
1702
1703 #[test]
1704 fn event_handler_matches_unquoted() {
1705 let ms = xss_event_handler_finditer("<img src=x onerror=alert(1)>");
1706 assert_eq!(ms.len(), 1);
1707 assert_eq!(
1708 ms[0].text("<img src=x onerror=alert(1)>"),
1709 "<img src=x onerror=alert(1)"
1710 );
1711 }
1712
1713 #[test]
1714 fn event_handler_slash_separated() {
1715 let ms = xss_event_handler_finditer("/search?q=<svg/onload=alert(1)>");
1716 assert_eq!(ms.len(), 1);
1717 assert_eq!(ms[0].start, 10);
1718 }
1719
1720 #[test]
1721 fn event_handler_quoted_attr_value_still_matches() {
1722 let ms = xss_event_handler_finditer("<img src=\"x\" onerror=alert(1)>");
1726 assert_eq!(ms.len(), 1);
1727 assert_eq!(
1728 ms[0].text("<img src=\"x\" onerror=alert(1)>"),
1729 "<img src=\"x\" onerror=alert(1)"
1730 );
1731 }
1732
1733 #[test]
1734 fn event_handler_greedy_group_picks_rightmost_split() {
1735 let text = "<a b onclick=x c onload=y>";
1738 let ms = xss_event_handler_finditer(text);
1739 assert_eq!(ms.len(), 1);
1740 assert_eq!(ms[0].text(text), "<a b onclick=x c onload=y");
1741 }
1742
1743 #[test]
1744 fn event_handler_equals_sequence_lookbehind_blocks() {
1745 assert!(xss_event_handler_finditer("<a =\" onclick=x>").is_empty());
1748 assert!(xss_event_handler_finditer("<img src= onerror=x>").is_empty());
1750 let ms = xss_event_handler_finditer("<img =x onerror=alert(1)>");
1753 assert_eq!(ms.len(), 1);
1754 assert_eq!(
1755 ms[0].text("<img =x onerror=alert(1)>"),
1756 "<img =x onerror=alert(1)"
1757 );
1758 }
1759
1760 #[test]
1761 fn event_handler_nested_quote_value_ends_at_first_quote() {
1762 let ms = xss_event_handler_finditer("<div onmouseover=\"alert('x')\">hover</div>");
1765 assert_eq!(ms.len(), 1);
1766 assert_eq!(
1767 ms[0].text("<div onmouseover=\"alert('x')\">hover</div>"),
1768 "<div onmouseover=\"alert('"
1769 );
1770 }
1771
1772 #[test]
1773 fn inline_comment_guard() {
1774 let ms = sqli_inline_comment_finditer("UNI/**/ON SE/**/LECT");
1775 assert_eq!(ms.len(), 2);
1776 assert!(sqli_inline_comment_finditer("UNI/*!*/ON").is_empty());
1777 }
1778
1779 #[test]
1780 fn backtick_candidate_guard() {
1781 let compiled =
1782 PyRegex::compile(r"`(?:[A-Za-z0-9_./~]|\$[({])(?:[^`\\\n]|\\.)*`", false).unwrap();
1783 let ms = glued_backtick_candidate_finditer("a`id`b");
1784 assert_eq!(ms.len(), 1);
1785 let _ = compiled;
1786 assert!(glued_backtick_candidate_finditer("``id`").is_empty());
1787 }
1788}