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> {
680 Box::new(move |haystack: &str, pos: usize| -> Vec<usize> {
681 let mut ends = Vec::new();
682 for alt in alternatives {
683 if haystack[pos..].starts_with(alt) {
685 ends.push(pos + alt.len());
686 }
687 }
688 ends
689 })
690}
691
692#[derive(Debug, Clone, Copy, PartialEq, Eq)]
694pub enum BadSuffix {
695 DashRun,
697 YamlSuffix,
699 DotRun,
701}
702
703fn literal_suffix_bad<'a>(alternatives: &'a [&'a str], suffix: BadSuffix) -> BadMatcher<'a> {
704 Box::new(move |haystack: &str, pos: usize| -> Vec<usize> {
705 let mut ends = Vec::new();
706 for alt in alternatives {
707 if !haystack[pos..].starts_with(alt) {
708 continue;
709 }
710 let head = pos + alt.len();
711 ends.push(head);
713 let rest = &haystack[head..];
714 match suffix {
715 BadSuffix::YamlSuffix => {
716 if let Some(after_dot) = rest.strip_prefix('.') {
717 if after_dot.starts_with("yaml") {
718 ends.push(head + 5);
719 }
720 if after_dot.starts_with("yml") {
721 ends.push(head + 4);
722 }
723 }
724 }
725 BadSuffix::DotRun => {
726 if rest.starts_with('.') {
727 ends.push(walk_forward_while(haystack, head + 1, is_path_class));
728 ends.push(head + 1);
729 }
730 }
731 BadSuffix::DashRun => {
732 if let Some((i, c)) = char_at(haystack, head)
733 && matches!(c, '.' | '-')
734 {
735 let run_end = walk_forward_while(haystack, i + c.len_utf8(), is_path_class);
736 ends.push(run_end);
737 ends.push(i + c.len_utf8());
738 }
739 }
740 }
741 }
742 ends
743 })
744}
745
746fn extension_run_bad<'a>(extensions: &'a [&'a str]) -> BadMatcher<'a> {
749 Box::new(move |haystack: &str, pos: usize| -> Vec<usize> {
750 let run_end = walk_forward_while(haystack, pos, is_path_class);
751 let mut ends = Vec::new();
752 let mut cursor = pos;
753 while cursor < run_end {
754 let Some((i, c)) = char_at(haystack, cursor) else {
755 break;
756 };
757 if c == '.' {
758 let after_dot = i + 1;
759 for ext in extensions {
760 if haystack[after_dot..].starts_with(ext) {
761 ends.push(after_dot + ext.len());
762 }
763 }
764 }
765 cursor = i + c.len_utf8();
766 }
767 ends
768 })
769}
770
771fn secrets_run_bad<'a>(extensions: &'a [&'a str]) -> BadMatcher<'a> {
773 Box::new(move |haystack: &str, pos: usize| -> Vec<usize> {
774 let run_end = walk_forward_while(haystack, pos, is_path_class);
775 let mut ends = Vec::new();
776 let mut cursor = pos;
777 while cursor < run_end {
778 let Some((i, c)) = char_at(haystack, cursor) else {
779 break;
780 };
781 for stem in ["secret", "credential"] {
782 if !haystack[i..].starts_with(stem) {
783 continue;
784 }
785 let mut after_stem = i + stem.len();
786 if haystack[after_stem..].starts_with('s') {
787 after_stem += 1;
788 }
789 if char_at(haystack, after_stem).map(|(_, c)| c) != Some('.') {
790 continue;
791 }
792 let after_dot = after_stem + 1;
793 for ext in extensions {
794 if haystack[after_dot..].starts_with(ext) {
795 ends.push(after_dot + ext.len());
796 }
797 }
798 }
799 cursor = i + c.len_utf8();
800 }
801 ends
802 })
803}
804
805const CONFIG_EXTENSIONS: &[&str] = &["env", "yml", "yaml", "json", "toml", "ini", "xml", "conf"];
806
807fn config_bad(haystack: &str, pos: usize) -> Vec<usize> {
812 let run_end = walk_forward_while(haystack, pos, is_word_dash);
813 let Some(rel) = haystack[pos..run_end].find("config") else {
814 return Vec::new();
815 };
816 let config_start = pos + rel;
817 let word_end = walk_forward_while(haystack, config_start + "config".len(), is_word_dash);
818 if char_at(haystack, word_end).map(|(_, c)| c) != Some('.') {
819 return Vec::new();
820 }
821 let after_dot = word_end + 1;
822 let mut ends = Vec::new();
823 for ext in CONFIG_EXTENSIONS {
824 if haystack[after_dot..].starts_with(ext) {
825 ends.push(after_dot + ext.len());
826 }
827 }
828 ends
829}
830
831fn env_bad(haystack: &str, pos: usize) -> Vec<usize> {
833 let mut ends = Vec::new();
834 if haystack[pos..].starts_with(".env") {
835 let after = pos + 4;
836 if char_at(haystack, after).map(|(_, c)| c) == Some('.') {
837 let word_end = walk_forward_while(haystack, after + 1, py_is_word);
838 if word_end > after + 1 {
839 ends.push(word_end);
840 }
841 }
842 ends.push(after);
843 }
844 ends
845}
846
847fn dot_alternative_bad<'a>(names: &'a [&'a str]) -> BadMatcher<'a> {
849 Box::new(move |haystack: &str, pos: usize| -> Vec<usize> {
850 let mut ends = Vec::new();
851 if char_at(haystack, pos).map(|(_, c)| c) == Some('.') {
852 for name in names {
853 if haystack[pos + 1..].starts_with(name) {
854 ends.push(pos + 1 + name.len());
855 }
856 }
857 }
858 ends
859 })
860}
861
862fn wp_admin_bad(haystack: &str, pos: usize) -> Vec<usize> {
865 const HEADS: &[&str] = &[
866 "wp-admin",
867 "wp-login",
868 "wp-content",
869 "wp-includes",
870 "wp-config",
871 "administrator",
872 "xmlrpc",
873 ];
874 let mut ends = Vec::new();
875 for head in HEADS {
876 if haystack[pos..].starts_with(head) {
877 let after = pos + head.len();
878 let dotted = char_at(haystack, after).map(|(_, c)| c) == Some('.');
880 let after_dot = if dotted { after + 1 } else { after };
881 if haystack[after_dot..].starts_with("php") {
882 ends.push(after_dot + 3);
883 }
884 if dotted {
885 ends.push(after_dot);
886 }
887 ends.push(after);
888 }
889 }
890 ends
891}
892
893const MANAGEMENT_BAD: &[&str] = &[
894 "management",
895 "config_dump",
896 "credentials",
897 "system/version",
898 "system\\version",
899 "version/system",
900 "version\\system",
901];
902
903pub const RECON_APP_BAD: &[&str] = &[
904 "geoserver",
905 "confluence",
906 "nifi",
907 "ScadaBR",
908 "pandora_console",
909 "centreon",
910 "kylin",
911 "decisioncenter",
912 "evox",
913 "MagicInfo",
914 "metasys",
915 "officescan",
916 "helpdesk",
917 "ignite",
918];
919
920pub const RECON_README_BAD: &[&str] = &[
921 "readme.txt",
922 "README.md",
923 "CHANGELOG",
924 "pom.xml",
925 "build.gradle",
926 "appsettings.json",
927 "crossdomain.xml",
928];
929
930pub const BACKUP_EXTENSIONS: &[&str] = &[
931 "bak", "backup", "old", "orig", "save", "swp", "swo", "tmp", "temp",
932];
933
934pub const SECRETS_EXTENSIONS: &[&str] = &[
935 "py", "json", "yml", "yaml", "toml", "txt", "env", "xml", "conf", "cfg",
936];
937
938pub const DOCKERFILE_BAD: &[&str] = &[
939 "docker-compose",
940 "Dockerfile",
941 "Makefile",
942 "Vagrantfile",
943 "Jenkinsfile",
944 "Procfile",
945];
946
947pub const DOUBLE_DOT_BAD: &[&str] = &[
948 ".htaccess",
949 ".htpasswd",
950 ".DS_Store",
951 "Thumbs.db",
952 ".npmrc",
953 ".dockerenv",
954 "web.config",
955];
956
957#[must_use]
959pub fn sensitive_path_env(haystack: &str) -> Vec<Candidate> {
960 sensitive_path_finditer(haystack, LeadingSep::Optional, env_bad)
961}
962
963#[must_use]
965pub fn sensitive_path_wp_admin(haystack: &str) -> Vec<Candidate> {
966 sensitive_path_finditer(haystack, LeadingSep::Optional, wp_admin_bad)
967}
968
969#[must_use]
972pub fn sensitive_path_management(haystack: &str) -> Vec<Candidate> {
973 sensitive_path_finditer(haystack, LeadingSep::Required, literal_bad(MANAGEMENT_BAD))
974}
975
976#[must_use]
978pub fn sensitive_path_scan_ext(haystack: &str, extensions: &[&str]) -> Vec<Candidate> {
979 sensitive_path_finditer(
980 haystack,
981 LeadingSep::Optional,
982 extension_run_bad(extensions),
983 )
984}
985
986#[must_use]
988pub fn sensitive_path_dot_alt(haystack: &str, names: &[&str]) -> Vec<Candidate> {
989 sensitive_path_finditer(haystack, LeadingSep::Optional, dot_alternative_bad(names))
990}
991
992#[must_use]
995pub fn sensitive_path_literal_ext(haystack: &str, names: &[&str], ext: &str) -> Vec<Candidate> {
996 let literals: Vec<String> = names.iter().map(|name| format!("{name}.{ext}")).collect();
997 let refs: Vec<&str> = literals.iter().map(String::as_str).collect();
998 sensitive_path_finditer(haystack, LeadingSep::Optional, literal_bad(&refs))
999}
1000
1001#[must_use]
1004pub fn sensitive_path_literal(haystack: &str, literals: &[&str]) -> Vec<Candidate> {
1005 sensitive_path_finditer(haystack, LeadingSep::Optional, literal_bad(literals))
1006}
1007
1008#[must_use]
1010pub fn sensitive_path_literal_suffix(
1011 haystack: &str,
1012 literals: &[&str],
1013 suffix: BadSuffix,
1014) -> Vec<Candidate> {
1015 sensitive_path_finditer(
1016 haystack,
1017 LeadingSep::Optional,
1018 literal_suffix_bad(literals, suffix),
1019 )
1020}
1021
1022#[must_use]
1024pub fn sensitive_path_literal_suffix_dot(haystack: &str, literals: &[&str]) -> Vec<Candidate> {
1025 sensitive_path_literal_suffix(haystack, literals, BadSuffix::DotRun)
1026}
1027
1028#[must_use]
1030pub fn sensitive_path_secrets(haystack: &str) -> Vec<Candidate> {
1031 sensitive_path_finditer(
1032 haystack,
1033 LeadingSep::Optional,
1034 secrets_run_bad(SECRETS_EXTENSIONS),
1035 )
1036}
1037
1038#[must_use]
1040pub fn sensitive_path_config(haystack: &str) -> Vec<Candidate> {
1041 sensitive_path_finditer(haystack, LeadingSep::Optional, config_bad)
1042}
1043
1044const 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";
1047
1048#[must_use]
1054pub fn lexicon_path_finditer(haystack: &str, shape: &str, require_lexicon: bool) -> Vec<Candidate> {
1055 let Ok(compiled) = PyRegex::compile(shape, true) else {
1056 return Vec::new();
1057 };
1058 let matches: Vec<Candidate> = compiled
1059 .re()
1060 .find_iter(haystack)
1061 .map(|m| Candidate::new(m.start(), m.end()))
1062 .collect();
1063 if !require_lexicon || matches.is_empty() {
1064 return matches;
1065 }
1066 let Ok(lexicon) = PyRegex::compile(ATTACK_REPORT_LEXICON, true) else {
1067 return Vec::new();
1068 };
1069 let first_line_end = haystack.find('\n').unwrap_or(haystack.len());
1070 if lexicon.re().is_match(&haystack[..first_line_end]) {
1071 matches
1072 } else {
1073 Vec::new()
1074 }
1075}
1076
1077#[must_use]
1080pub fn proto_pollution_assign_finditer(haystack: &str) -> Vec<Candidate> {
1081 let Ok(re) = PyRegex::compile(r"Object\.prototype\.[A-Za-z_$][\w$]*\s*=", false) else {
1082 return Vec::new();
1083 };
1084 guarded_finditer(haystack, re.re(), |text, candidate| {
1085 text.as_bytes().get(candidate.end) != Some(&b'=')
1086 })
1087}
1088#[must_use]
1095pub fn deserialization_b64_finditer(haystack: &str, magic: &str) -> Vec<Candidate> {
1096 let mut out = Vec::new();
1097 let mut from = 0usize;
1098 while let Some(rel) = haystack[from..].find(magic) {
1099 let start = from + rel;
1100 let end = start + magic.len();
1101 let boundary_ok = start == 0
1102 || char_before(haystack, start)
1103 .is_none_or(|(_, c)| !c.is_ascii_alphanumeric() && c != '/' && c != '+');
1104 if boundary_ok {
1105 out.push(Candidate::new(start, end));
1106 from = end;
1107 } else {
1108 from = start + 1;
1109 }
1110 }
1111 out
1112}
1113
1114#[must_use]
1120pub fn sqli_order_by_terminator_finditer(haystack: &str) -> Vec<Candidate> {
1121 let Ok(alt1) = PyRegex::compile(r"\bORDER\s+BY\s+\d+\s*(?:--|#|;|\)|,|/\*|\z)", true) else {
1122 return Vec::new();
1123 };
1124 let Ok(alt2) = PyRegex::compile(r"ORDER\s+BY\s+\d+\s*\n", true) else {
1125 return Vec::new();
1126 };
1127 let mut out: Vec<Candidate> = alt1
1128 .re()
1129 .find_iter(haystack)
1130 .map(|m| Candidate::new(m.start(), m.end()))
1131 .collect();
1132 let guarded = guarded_finditer(haystack, alt2.re(), |text, candidate| {
1133 candidate.start > 0
1134 && char_before(text, candidate.start).is_some_and(|(_, c)| matches!(c, '=' | '?' | '&'))
1135 });
1136 out.extend(guarded);
1137 out.sort_by_key(|c| (c.start, std::cmp::Reverse(c.end)));
1138 out
1139}
1140
1141#[must_use]
1147pub fn file_inclusion_bare_host_finditer(haystack: &str) -> Vec<Candidate> {
1148 let Ok(re) = PyRegex::compile(
1149 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\-\.\?,'/\\+&%$#_]*)?",
1150 false,
1151 ) else {
1152 return Vec::new();
1153 };
1154 guarded_finditer(haystack, re.re(), |text, candidate| {
1155 candidate.start == 0 || char_before(text, candidate.start).is_none_or(|(_, c)| c != ':')
1156 })
1157}
1158
1159const SCHEME_PATH_TRIMMED: &str =
1164 r#"=(?:https?|ftp)://[^\s'"<>]+/[^\s'"<>/]*\.(?:phtml|php[3-5]?|phar|jsp|aspx?|pl|py|txt|inc)"#;
1165
1166#[must_use]
1172pub fn file_inclusion_scheme_path_finditer(haystack: &str) -> Vec<Candidate> {
1173 let (Some(compiled), Some(prefix), Some(terminator)) = (
1174 PyRegex::compile(SCHEME_PATH_TRIMMED, true).ok(),
1175 PyRegex::compile(r"=(?:https?|ftp)://", true).ok(),
1176 PyRegex::compile(
1177 r"\.(?:phtml|php\d*|phar|jsp|aspx?|pl|py|txt|inc)[a-zA-Z0-9]*",
1178 true,
1179 )
1180 .ok(),
1181 ) else {
1182 return Vec::new();
1183 };
1184 super::scan_window::bounded_finditer_guarded(
1185 haystack,
1186 &compiled,
1187 &prefix,
1188 &terminator,
1189 |text, candidate| {
1190 char_at(text, candidate.end).is_none_or(|(_, c)| !c.is_ascii_alphanumeric())
1191 },
1192 )
1193}
1194
1195const 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)["']"#;
1200
1201#[must_use]
1205pub fn file_inclusion_template_url_finditer(haystack: &str) -> Vec<Candidate> {
1206 let Ok(compiled) = PyRegex::compile(TEMPLATE_URL_TRIMMED, true) else {
1207 return Vec::new();
1208 };
1209 compiled
1210 .re()
1211 .find_iter(haystack)
1212 .map(|m| Candidate::new(m.start(), m.end()))
1213 .collect()
1214}
1215
1216#[must_use]
1222pub fn glued_backtick_candidate_finditer(haystack: &str) -> Vec<Candidate> {
1223 let Ok(re) = PyRegex::compile(r"`(?:[A-Za-z0-9_./~]|\$[({])(?:[^`\\\n]|\\.)*`", false) else {
1224 return Vec::new();
1225 };
1226 guarded_finditer(haystack, re.re(), |text, candidate| {
1227 candidate.start == 0 || char_before(text, candidate.start).is_none_or(|(_, c)| c != '`')
1228 })
1229}
1230
1231pub const HTML_EVENT_HANDLER_ATTRS: &[&str] = &[
1238 "onwebkitplaybacktargetavailabilitychanged",
1239 "oncontentvisibilityautostatechange",
1240 "onwebkitpresentationmodechanged",
1241 "onwebkitmouseforcewillbegin",
1242 "onwebkitanimationiteration",
1243 "onsecuritypolicyviolation",
1244 "onwebkitmouseforcechanged",
1245 "onvalidationstatuschange",
1246 "onwebkitfullscreenchange",
1247 "onwebkitwillrevealbottom",
1248 "onwebkitanimationstart",
1249 "onwebkitmouseforcedown",
1250 "onbeforescriptexecute",
1251 "onmozfullscreenchange",
1252 "onwebkittransitionend",
1253 "onafterscriptexecute",
1254 "onanimationiteration",
1255 "onlostpointercapture",
1256 "onscrollsnapchanging",
1257 "onunhandledrejection",
1258 "onwebkitanimationend",
1259 "onwebkitmouseforceup",
1260 "ondeviceorientation",
1261 "ongotpointercapture",
1262 "onbeforedeactivate",
1263 "onfullscreenchange",
1264 "onpointerrawupdate",
1265 "onreadystatechange",
1266 "onrejectionhandled",
1267 "onscrollsnapchange",
1268 "ontransitioncancel",
1269 "onanimationcancel",
1270 "onbeforeeditfocus",
1271 "oncontextrestored",
1272 "ondatasetcomplete",
1273 "onselectionchange",
1274 "ontransitionstart",
1275 "onanimationstart",
1276 "onbeforeactivate",
1277 "oncanplaythrough",
1278 "ondatasetchanged",
1279 "ondurationchange",
1280 "onlanguagechange",
1281 "onlayoutcomplete",
1282 "onloadedmetadata",
1283 "onpropertychange",
1284 "oncontrolselect",
1285 "ondataavailable",
1286 "ongesturechange",
1287 "onmediacomplete",
1288 "onpointercancel",
1289 "onpromptdismiss",
1290 "ontransitionend",
1291 "ontransitionrun",
1292 "onwebkitneedkey",
1293 "onanimationend",
1294 "onbeforetoggle",
1295 "onbeforeunload",
1296 "onbeforeupdate",
1297 "ondevicemotion",
1298 "onfilterchange",
1299 "ongesturestart",
1300 "onmessageerror",
1301 "onpointerenter",
1302 "onpointerleave",
1303 "onpromptaction",
1304 "onsyncrestored",
1305 "onvolumechange",
1306 "onafterupdate",
1307 "onbeforeinput",
1308 "onbeforematch",
1309 "onbeforepaste",
1310 "onbeforeprint",
1311 "oncontextlost",
1312 "oncontextmenu",
1313 "onerrorupdate",
1314 "onlosecapture",
1315 "onpointerdown",
1316 "onpointermove",
1317 "onpointerover",
1318 "onresizestart",
1319 "onrowinserted",
1320 "onselectstart",
1321 "ontouchcancel",
1322 "ontrackchange",
1323 "onafterprint",
1324 "onbeforecopy",
1325 "oncellchange",
1326 "ondeactivate",
1327 "ongestureend",
1328 "onhashchange",
1329 "onloadeddata",
1330 "onmediaerror",
1331 "onmouseenter",
1332 "onmouseleave",
1333 "onmousewheel",
1334 "onpagereveal",
1335 "onpointerout",
1336 "onratechange",
1337 "onslotchange",
1338 "ontimeupdate",
1339 "ontouchstart",
1340 "onbeforecut",
1341 "oncuechange",
1342 "ondragenter",
1343 "ondragleave",
1344 "ondragstart",
1345 "onloadstart",
1346 "onmousedown",
1347 "onmousemove",
1348 "onmouseover",
1349 "onmovestart",
1350 "onoutofsync",
1351 "onpointerup",
1352 "onresizeend",
1353 "onrowdelete",
1354 "onrowsenter",
1355 "onscrollend",
1356 "ontimeerror",
1357 "ontouchmove",
1358 "onactivate",
1359 "onauxclick",
1360 "ondblclick",
1361 "ondragdrop",
1362 "ondragexit",
1363 "ondragover",
1364 "onfocusout",
1365 "onformdata",
1366 "onkeypress",
1367 "onlocation",
1368 "onmouseout",
1369 "onpagehide",
1370 "onpageshow",
1371 "onpageswap",
1372 "onpopstate",
1373 "onprogress",
1374 "ontouchend",
1375 "oncanplay",
1376 "oncommand",
1377 "ondragend",
1378 "onemptied",
1379 "onfocusin",
1380 "oninvalid",
1381 "onkeydown",
1382 "onmessage",
1383 "onmouseup",
1384 "onmoveend",
1385 "onoffline",
1386 "onplaying",
1387 "onreverse",
1388 "onrowexit",
1389 "onseeking",
1390 "onstalled",
1391 "onstorage",
1392 "onsuspend",
1393 "onurlflip",
1394 "onwaiting",
1395 "onbounce",
1396 "oncancel",
1397 "onchange",
1398 "onfinish",
1399 "ononline",
1400 "onrepeat",
1401 "onresize",
1402 "onresume",
1403 "onscroll",
1404 "onsearch",
1405 "onseeked",
1406 "onselect",
1407 "onsubmit",
1408 "ontoggle",
1409 "onunload",
1410 "onabort",
1411 "onbegin",
1412 "onclick",
1413 "onclose",
1414 "onended",
1415 "onerror",
1416 "onfocus",
1417 "oninput",
1418 "onkeyup",
1419 "onpaste",
1420 "onpause",
1421 "onreset",
1422 "onstart",
1423 "onwheel",
1424 "onblur",
1425 "oncopy",
1426 "ondrag",
1427 "ondrop",
1428 "onhelp",
1429 "onload",
1430 "onmove",
1431 "onplay",
1432 "onredo",
1433 "onseek",
1434 "onstop",
1435 "onundo",
1436 "oncut",
1437 "onend",
1438];
1439
1440fn ascii_starts_with_ignore_case(haystack: &str, pos: usize, needle: &str) -> bool {
1441 let bytes = haystack.as_bytes();
1442 let Some(window) = bytes.get(pos..pos + needle.len()) else {
1443 return false;
1444 };
1445 window
1446 .iter()
1447 .zip(needle.bytes())
1448 .all(|(a, b)| a.eq_ignore_ascii_case(&b))
1449}
1450
1451fn event_handler_value_end(haystack: &str, pos: usize) -> Option<usize> {
1453 let mut cursor = walk_forward_while(haystack, pos, char::is_whitespace);
1454 if char_at(haystack, cursor).map(|(_, c)| c) != Some('=') {
1455 return None;
1456 }
1457 cursor = char_at(haystack, cursor).map_or(cursor, |(i, c)| i + c.len_utf8());
1458 let mut taken = 0usize;
1460 while taken < 20 && char_at(haystack, cursor).is_some_and(|(_, c)| c.is_whitespace()) {
1461 cursor = char_at(haystack, cursor).map_or(cursor, |(i, c)| i + c.len_utf8());
1462 taken += 1;
1463 }
1464 match char_at(haystack, cursor).map(|(_, c)| c) {
1465 Some(quote @ ('"' | '\'')) => {
1466 let mut scan = cursor + quote.len_utf8();
1469 while let Some((i, c)) = char_at(haystack, scan) {
1470 if c == '"' || c == '\'' {
1471 return Some(i + c.len_utf8());
1472 }
1473 scan = i + c.len_utf8();
1474 }
1475 None
1476 }
1477 Some(_) => {
1478 let end = walk_forward_while(haystack, cursor, |c| !c.is_whitespace() && c != '>');
1479 (end > cursor).then_some(end)
1480 }
1481 None => None,
1482 }
1483}
1484
1485fn event_handler_lookbehind_ok(haystack: &str, run_start: usize) -> bool {
1489 let Some((c1_pos, c1)) = char_before(haystack, run_start) else {
1490 return true; };
1492 if c1 == '=' {
1493 return false;
1494 }
1495 if c1 == '"' || c1 == '\'' {
1496 let quoted_after_equals = char_before(haystack, c1_pos).is_some_and(|(_, c2)| c2 == '=');
1497 if quoted_after_equals {
1498 return false;
1499 }
1500 }
1501 true
1502}
1503
1504fn event_handler_run_end(haystack: &str, run_start: usize) -> usize {
1506 let mut pos = run_start;
1507 while char_at(haystack, pos).is_some_and(|(_, rc)| rc.is_whitespace() || rc == '/') {
1508 pos = char_at(haystack, pos).map_or(haystack.len(), |(i, rc)| i + rc.len_utf8());
1509 }
1510 pos
1511}
1512
1513#[must_use]
1522pub fn xss_event_handler_finditer(haystack: &str) -> Vec<Candidate> {
1523 let Ok(tag_open) = PyRegex::compile(r"<[A-Za-z/]", false) else {
1524 return Vec::new();
1525 };
1526 let mut matches = Vec::new();
1527 let mut resume = 0usize;
1528 for open in tag_open.re().find_iter(haystack) {
1529 if open.start() < resume {
1530 continue;
1531 }
1532 let mut runs: Vec<usize> = Vec::new();
1535 let mut cursor = open.end();
1536 let mut open_run: Option<usize> = None;
1537 while let Some((offset, c)) = char_at(haystack, cursor) {
1538 if c == '<' || c == '>' {
1539 break;
1540 }
1541 if c.is_whitespace() || c == '/' {
1542 if open_run.is_none() {
1543 open_run = Some(offset);
1544 }
1545 } else if let Some(r) = open_run.take() {
1546 runs.push(r);
1547 }
1548 cursor = offset + c.len_utf8();
1549 }
1550 if let Some(r) = open_run {
1551 runs.push(r);
1552 }
1553 runs.reverse();
1555 for r in runs {
1556 let run_end = event_handler_run_end(haystack, r);
1557 if event_handler_lookbehind_ok(haystack, r)
1558 && let Some(end) = try_event_name_value(haystack, run_end)
1559 {
1560 matches.push(Candidate::new(open.start(), end));
1561 resume = end;
1562 break;
1563 }
1564 }
1565 }
1566 matches
1567}
1568
1569fn try_event_name_value(haystack: &str, name_pos: usize) -> Option<usize> {
1570 for name in HTML_EVENT_HANDLER_ATTRS {
1571 if ascii_starts_with_ignore_case(haystack, name_pos, name) {
1572 let after_name = name_pos + name.len();
1573 if let Some(end) = event_handler_value_end(haystack, after_name) {
1574 return Some(end);
1575 }
1576 }
1577 }
1578 None
1579}
1580
1581#[must_use]
1583pub fn rfind_in(haystack: &str, needle: &str, start: usize, end: usize) -> Option<usize> {
1584 str_rfind_in(haystack, needle, start, end)
1585}
1586
1587#[cfg(test)]
1588mod tests {
1589 use super::*;
1590
1591 #[test]
1592 fn select_from_first_reachable_from() {
1593 let ms = sqli_select_from_finditer("SELECT a FROM b");
1594 assert_eq!(ms.len(), 1);
1595 assert_eq!(ms[0].text("SELECT a FROM b"), "SELECT a FROM");
1597 }
1598
1599 #[test]
1600 fn select_from_blocked_by_second_select() {
1601 let ms = sqli_select_from_finditer("SELECT x SELECT FROM");
1604 assert_eq!(ms.len(), 1);
1605 assert_eq!(ms[0].start, 9);
1606 assert_eq!(ms[0].text("SELECT x SELECT FROM"), "SELECT FROM");
1607 }
1608
1609 #[test]
1610 fn tautology_requires_equal_atoms() {
1611 let ms = sqli_tautology_finditer("' OR 1=1--");
1612 assert_eq!(ms.len(), 1);
1613 assert_eq!(ms[0].text("' OR 1=1--"), "OR 1=1");
1614 assert!(sqli_tautology_finditer("' OR 1=2--").is_empty());
1615 }
1616
1617 #[test]
1618 fn ssrf_private_requires_scheme_for_userinfo() {
1619 let ms = ssrf_private_host_finditer("http://10.0.0.1/shell.txt");
1620 assert_eq!(ms.len(), 1);
1621 assert_eq!(ms[0].text("http://10.0.0.1/shell.txt"), "/10.0.0.1/");
1622 let ms = ssrf_private_host_finditer("| nc 10.0.0.1 4444");
1624 assert_eq!(ms.len(), 1);
1625 }
1626
1627 #[test]
1628 fn ssrf_numeric_guard() {
1629 let ms = ssrf_numeric_host_finditer("curl http://10.0.0.1/x.sh");
1630 assert_eq!(ms.len(), 1);
1631 assert_eq!(ms[0].text("curl http://10.0.0.1/x.sh"), "://10.0.0.1");
1632 assert!(ssrf_numeric_host_finditer("curl http://10.0.0.1x/").is_empty());
1634 }
1635
1636 #[test]
1637 fn shell_dash_flag_lookahead() {
1638 let ms = shell_dash_flag_finditer("bash -c \"id\"");
1639 assert_eq!(ms.len(), 1);
1640 assert!(shell_dash_flag_finditer("bash -c \"id\" x tail").is_empty());
1641 }
1642
1643 #[test]
1644 fn sensitive_env_path() {
1645 let ms = sensitive_path_env("/.env");
1646 assert_eq!(ms.len(), 1);
1647 let ms = sensitive_path_env("/app/.env.local/config");
1649 assert_eq!(ms.len(), 1);
1650 let ms = sensitive_path_env("/app/.env");
1651 assert_eq!(ms.len(), 1);
1652 }
1653
1654 #[test]
1655 fn sensitive_wp_admin_path() {
1656 assert_eq!(sensitive_path_wp_admin("/wp-login.php").len(), 1);
1657 assert_eq!(
1658 sensitive_path_wp_admin("/wp-admin/setup-config.php").len(),
1659 1
1660 );
1661 assert!(sensitive_path_wp_admin("/admin/login/?next=/admin/").is_empty());
1662 }
1663
1664 #[test]
1665 fn sensitive_backup_path() {
1666 assert_eq!(
1667 sensitive_path_scan_ext("/config.php.bak", BACKUP_EXTENSIONS).len(),
1668 1
1669 );
1670 assert!(sensitive_path_scan_ext("/config.php", BACKUP_EXTENSIONS).is_empty());
1671 }
1672
1673 #[test]
1674 fn management_requires_leading_separator() {
1675 assert_eq!(sensitive_path_management("/.aws/credentials").len(), 1);
1676 assert!(sensitive_path_management(".aws/credentials").is_empty());
1677 }
1678
1679 #[test]
1680 fn deserialization_magic_boundary() {
1681 assert_eq!(deserialization_b64_finditer("rO0ABXNy", "rO0AB").len(), 1);
1682 assert!(deserialization_b64_finditer("BASE64rO0AB", "rO0AB").is_empty());
1683 }
1684
1685 #[test]
1686 fn event_handler_matches_unquoted() {
1687 let ms = xss_event_handler_finditer("<img src=x onerror=alert(1)>");
1688 assert_eq!(ms.len(), 1);
1689 assert_eq!(
1690 ms[0].text("<img src=x onerror=alert(1)>"),
1691 "<img src=x onerror=alert(1)"
1692 );
1693 }
1694
1695 #[test]
1696 fn event_handler_slash_separated() {
1697 let ms = xss_event_handler_finditer("/search?q=<svg/onload=alert(1)>");
1698 assert_eq!(ms.len(), 1);
1699 assert_eq!(ms[0].start, 10);
1700 }
1701
1702 #[test]
1703 fn event_handler_quoted_attr_value_still_matches() {
1704 let ms = xss_event_handler_finditer("<img src=\"x\" onerror=alert(1)>");
1708 assert_eq!(ms.len(), 1);
1709 assert_eq!(
1710 ms[0].text("<img src=\"x\" onerror=alert(1)>"),
1711 "<img src=\"x\" onerror=alert(1)"
1712 );
1713 }
1714
1715 #[test]
1716 fn event_handler_greedy_group_picks_rightmost_split() {
1717 let text = "<a b onclick=x c onload=y>";
1720 let ms = xss_event_handler_finditer(text);
1721 assert_eq!(ms.len(), 1);
1722 assert_eq!(ms[0].text(text), "<a b onclick=x c onload=y");
1723 }
1724
1725 #[test]
1726 fn event_handler_equals_sequence_lookbehind_blocks() {
1727 assert!(xss_event_handler_finditer("<a =\" onclick=x>").is_empty());
1730 assert!(xss_event_handler_finditer("<img src= onerror=x>").is_empty());
1732 let ms = xss_event_handler_finditer("<img =x onerror=alert(1)>");
1735 assert_eq!(ms.len(), 1);
1736 assert_eq!(
1737 ms[0].text("<img =x onerror=alert(1)>"),
1738 "<img =x onerror=alert(1)"
1739 );
1740 }
1741
1742 #[test]
1743 fn event_handler_nested_quote_value_ends_at_first_quote() {
1744 let ms = xss_event_handler_finditer("<div onmouseover=\"alert('x')\">hover</div>");
1747 assert_eq!(ms.len(), 1);
1748 assert_eq!(
1749 ms[0].text("<div onmouseover=\"alert('x')\">hover</div>"),
1750 "<div onmouseover=\"alert('"
1751 );
1752 }
1753
1754 #[test]
1755 fn inline_comment_guard() {
1756 let ms = sqli_inline_comment_finditer("UNI/**/ON SE/**/LECT");
1757 assert_eq!(ms.len(), 2);
1758 assert!(sqli_inline_comment_finditer("UNI/*!*/ON").is_empty());
1759 }
1760
1761 #[test]
1762 fn backtick_candidate_guard() {
1763 let compiled =
1764 PyRegex::compile(r"`(?:[A-Za-z0-9_./~]|\$[({])(?:[^`\\\n]|\\.)*`", false).unwrap();
1765 let ms = glued_backtick_candidate_finditer("a`id`b");
1766 assert_eq!(ms.len(), 1);
1767 let _ = compiled;
1768 assert!(glued_backtick_candidate_finditer("``id`").is_empty());
1769 }
1770}