Skip to main content

guard_core_engine/patterns/
matchers.rs

1//! Structural matchers for pattern-table entries the `regex` crate rejects.
2//!
3//! The table entries handled here use lookarounds or backreferences plus the
4//! reference's bespoke candidate locators (ported from
5//! `guard_core/handlers/_suspatterns_matchers.py`, spec 4.0.2).
6//!
7//! Guarded matchers compile the reference pattern without the zero-width
8//! construct and enforce it as a suffix/prefix check per candidate. When a
9//! guard rejects a candidate, scanning resumes at candidate start + 1, which
10//! reproduces the reference engine's per-start-position backtracking exactly
11//! for the guarded shapes here (every backtracked variant ends in a character
12//! the guard also rejects).
13
14use 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
20/// Find all candidates of `re` whose guard accepts. On rejection the scan
21/// resumes one char after the candidate start (engine backtracking).
22fn 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
45/// Python `pattern.match(text, start, end)`: anchored at `start`, truncated
46/// at `end` (with the reference's overshoot-then-truncate fallback).
47pub(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// ---------------------------------------------------------------------------
72// cmd-injection newline shell -c finder
73// ---------------------------------------------------------------------------
74
75/// `_cmd_injection_shell_dash_c_finditer`.
76#[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
110// ---------------------------------------------------------------------------
111// LDAP null-byte attribute finder
112// ---------------------------------------------------------------------------
113
114const 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        // value start: walk back over [\d\w\s]
136        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        // attr name start: walk back over [\w-] from the '='
146        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/// Raw-view LDAP null-byte attribute pattern.
178#[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/// URL-decoded-view LDAP null-byte attribute pattern.
184#[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// ---------------------------------------------------------------------------
190// Quote-splice finder
191// ---------------------------------------------------------------------------
192
193/// `_quote_splice_finditer`.
194#[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// ---------------------------------------------------------------------------
235// LOAD_FILE / dollar substitution scan windows
236// ---------------------------------------------------------------------------
237
238/// `_load_file_scan_matches`.
239#[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/// `_cmd_injection_dollar_scan_matches`.
251#[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// ---------------------------------------------------------------------------
276// Glob wildcard atom scan
277// ---------------------------------------------------------------------------
278
279/// Glob wildcard scan: the atom is matched anchored inside each path run.
280#[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
300// ---------------------------------------------------------------------------
301// SQLi SELECT ... FROM walker (negative-lookahead loop)
302// ---------------------------------------------------------------------------
303
304const 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/// Lazy-loop matcher for `(?i)\bSELECT\b(?:(?!\bSELECT\b)[\w\s,\*().])*?\bFROM\b`.
335///
336/// The lazy loop consumes class characters but must not step over a new
337/// `\bSELECT\b`; the first `\bFROM\b` reachable under that rule ends the match.
338#[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
384// ---------------------------------------------------------------------------
385// SQLi tautology (backreference as capture comparison)
386// ---------------------------------------------------------------------------
387
388const TAUTOLOGY_ATOM: &str = r#"(?:\d+|'[^']*'|"[^"]*"|[@:$][A-Za-z_]\w*)"#;
389
390/// Explicit backreference evaluation for `(?i)\b(?:OR|AND)\s*(ATOM)\s*=\s*\1\b`.
391///
392/// Atom variants are tried in regex backtracking order (greedy digit runs
393/// first, longest prefix first) and the second occurrence must equal the
394/// first case-insensitively (the reference backref inherits the pattern's
395/// `(?i)`).
396#[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
422/// All backtracking variants of the first atom at `pos`, longest first.
423fn 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        // `\d+` backtracks one digit at a time
428        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
442/// Complete the tautology match from a fixed atom: `\s*=\s*` then the same
443/// atom text case-insensitively, then `\b`.
444fn 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        // Python compares the same number of CHARACTERS as the atom (the
453        // reference re-matches the atom text case-insensitively); a byte
454        // count would split multi-byte chars on binary-decoded content.
455        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// ---------------------------------------------------------------------------
477// SQLi inline comment obfuscation
478// ---------------------------------------------------------------------------
479
480/// `\w/\*(?!!)[^*]*\*/\w` with the `(?!!)` guard as a suffix check.
481#[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        // the char right after `/*` must not be `!`; the position follows the
488        // (possibly multibyte) leading `\w` char, so it is walked by char
489        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
497// ---------------------------------------------------------------------------
498// SSRF private hosts (lookbehind-guarded userinfo)
499// ---------------------------------------------------------------------------
500
501const 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/// `(?:^|\s|/)(?:(?<=://)[^\s/@]*@)?HOST(?::\d+)?(?:\s|$|/)`: the userinfo
504/// alternative requires `://` immediately before it (lookbehind guard).
505#[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// ---------------------------------------------------------------------------
536// SSRF numeric hosts (lookahead-guarded terminator)
537// ---------------------------------------------------------------------------
538
539/// `://(?:[^/@\s]*@)?((?:0[xX]...)(?:\.(...)){0,3})(?=[:/\s]|$)`: the
540/// terminator lookahead is a suffix check; shorter host matches end in host
541/// characters, so the check is exact per start position.
542#[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
557/// Python terminator guard `(?=T|$)`: `T` at `pos`, or end of text, or a
558/// single trailing newline before the end.
559fn 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; // end of text
562    };
563    if terminator(c) {
564        return true;
565    }
566    // Python `$`: just before a trailing newline
567    c == '\n' && pos + 1 == haystack.len()
568}
569
570// ---------------------------------------------------------------------------
571// cmd-injection shell dash-flag (trailing lookahead guard)
572// ---------------------------------------------------------------------------
573
574const 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/// Trailing-lookahead suffix check for `(?:\A|[;|&])\s*...(?=\s*(?:[;|&]|\Z))`.
577///
578/// Every backtracked variant of the optional tail ends in a word character,
579/// which the guard also rejects, so the check is exact.
580#[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
599// ---------------------------------------------------------------------------
600// Sensitive path family (segment-loop negative lookahead)
601// ---------------------------------------------------------------------------
602
603/// A `BAD` alternative: tried greedily in order, each yielding the BAD end.
604type BadMatcher<'a> = Box<dyn Fn(&str, usize) -> Vec<usize> + 'a>;
605
606/// The leading separator of the reference's anchored path shapes:
607/// `\A[/\\]?...` (optional) or `\A[/\\]...` (required).
608#[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
629/// Generic walker for the anchored sensitive-path shapes:
630/// `\A[/\\]?(?:(?!BAD(?:[/\\]|\Z))[\w.\-~%]+[/\\])*BAD(?:[/\\][\w.\-~%]*)*(?:\?\S*)?\s*\z`.
631/// The greedy segment loop cannot resume once BAD (followed by a segment
632/// boundary) appears, and the tail cannot succeed at a segment start (BAD
633/// followed by `/` or `\z` there is exactly what the lookahead blocked), so a
634/// single greedy walk plus one tail check is exact.
635fn 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    // tail: BAD + (?:[/\\][\w.\-~%]*)* + (?:\?\S*)? + \s*\z
660    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
678/// Anchored literal alternatives: `(?:alt|alt|...)` matched at `pos`.
679///
680/// The reference compiles these rows with builtin IGNORECASE
681/// (`_BUILTIN_PATTERN_COMPILE_FLAGS`), so the literals fold ASCII case.
682fn 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            // alternatives may contain embedded separators (`system/version`)
687            if ascii_starts_with_ignore_case(haystack, pos, alt) {
688                ends.push(pos + alt.len());
689            }
690        }
691        ends
692    })
693}
694
695/// The optional trailing group of the literal-suffix shapes.
696#[derive(Debug, Clone, Copy, PartialEq, Eq)]
697pub enum BadSuffix {
698    /// `(?:[.\-][\w.\-~%]*)?` (id 121)
699    DashRun,
700    /// `(?:\.ya?ml)?` (id 132)
701    YamlSuffix,
702    /// `(?:\.[\w.\-~%]*)?` (id 125)
703    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            // the bare alternative is the last backtracking branch
715            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
749/// `[\w.\-~%]*\.(?:ext|ext|...)`: every extension occurrence reachable through
750/// the surrounding path-class run (ids 104, 113).
751fn 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
774/// `[\w.\-~%]*(?:secrets?|credentials?)\.(?:ext|...)` (id 133).
775fn 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
812/// `(?:(?!config)[\w-])*config[\w-]*\.(?:env|yml|...)` (id 103). Only the first
813/// `config` occurrence in a `[\w-]` run is reachable: every later occurrence
814/// has a `config` start in its consumed prefix, which the per-char lookahead
815/// rejects. Literals fold ASCII case (builtin IGNORECASE).
816fn 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
844/// `.env` family: BAD = `\.env(?:\.\w+)?` (longest first).
845fn 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
860/// `\.(?:git|svn|hg|bzr)` (id 106).
861fn 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
875/// `(?:wp-(admin|login|content|includes|config)|administrator|xmlrpc)\.?(?:php)?`
876/// (id 109).
877fn 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            // greedy `\.?` then `(?:php)?`
892            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/// `\A[/\\]?...\.env...` (id 101).
971#[must_use]
972pub fn sensitive_path_env(haystack: &str) -> Vec<Candidate> {
973    sensitive_path_finditer(haystack, LeadingSep::Optional, env_bad)
974}
975
976/// `\A[/\\]?...(?:wp-...|administrator|xmlrpc)...` (id 109).
977#[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/// `\A[/\\]...(?:management|config_dump|credentials|system[/\\]version|...)`
983/// (id 117; the nested shape requires the leading separator).
984#[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/// `\A[/\\]?...[^\w.\-~%]*\.(?:ext|...)` (ids 104 `.map`, 113 backup files).
990#[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/// `\A[/\\]?...\.(?:git|svn|hg|bzr)...` (id 106).
1000#[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/// `\A[/\\]?...(?:phpinfo|info|test|php_info)\.php...` (id 111) and the
1006/// literal-plus-extension shapes.
1007#[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/// `\A[/\\]?...(?:alt|alt|...)...` anchored literal shapes (ids 114, 119, 122,
1015/// 123, 124, 126, 128, 130, 131, 134, 135, 136).
1016#[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/// Anchored literal shapes with an optional trailing group (ids 121, 132).
1022#[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/// Anchored literal shapes with `(?:\.[\w.\-~%]*)?` (id 125).
1036#[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/// `\A[/\\]?...(?:secrets?|credentials?)\.(?:ext|...)...` (id 133).
1042#[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/// `\A[/\\]?...(?:(?!config)[\w-])*config[\w-]*\.(?:env|yml|...)...` (id 103).
1052#[must_use]
1053pub fn sensitive_path_config(haystack: &str) -> Vec<Candidate> {
1054    sensitive_path_finditer(haystack, LeadingSep::Optional, config_bad)
1055}
1056
1057/// The attack-report lexicon of the embedded-prose shapes (`ids 33, 102, 107,
1058/// 110, 112, 115`), verbatim from the canonical sources.
1059const 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/// Lexicon-lookahead path shapes (ids 33/102/107/110/112/115).
1062///
1063/// The reference pattern is `\A(?=(?:(?!\n).)*LEXICON)\A(?:(?!\n).)*<path-shape>`,
1064/// so the shape must sit in the first line and the first line must carry a
1065/// lexicon word.
1066#[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/// `Object\.prototype\.[A-Za-z_$][\w$]*\s*=(?!=)` (id 139).
1091///
1092/// The negative lookahead becomes a suffix check on the assignment operator.
1093/// The row carries the reference's builtin IGNORECASE, so it compiles
1094/// case-folded.
1095#[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// ---------------------------------------------------------------------------
1105// Deserialization base64 magic prefixes
1106// ---------------------------------------------------------------------------
1107
1108/// `(?<![A-Za-z0-9+/])(?-i:MAGIC)` for the four magic prefixes; the
1109/// lookbehind is a boundary check on the literal occurrence.
1110#[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// ---------------------------------------------------------------------------
1131// SQLi ORDER BY terminator (second alternative guard)
1132// ---------------------------------------------------------------------------
1133
1134/// `(?i)\bORDER\s+BY\s+\d+\s*(?:--|#|;|\)|,|/\*|\Z)|(?<=[=?&])ORDER\s+BY\s+\d+\s*\n`
1135#[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// ---------------------------------------------------------------------------
1158// File-inclusion bare host (lookbehind guard)
1159// ---------------------------------------------------------------------------
1160
1161/// `(?:(?<!:)\/\/HOST...)`: the lookbehind is a one-char prefix check.
1162#[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\-\.\?,'/\\+&amp;%$#_]*)?",
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
1175// ---------------------------------------------------------------------------
1176// File-inclusion scheme URL (trailing lookahead + bounded window)
1177// ---------------------------------------------------------------------------
1178
1179const SCHEME_PATH_TRIMMED: &str =
1180    r#"=(?:https?|ftp)://[^\s'"<>]+/[^\s'"<>/]*\.(?:phtml|php[3-5]?|phar|jsp|aspx?|pl|py|txt|inc)"#;
1181
1182/// id 63: the reference runs the UNMODIFIED pattern inside the bounded window.
1183///
1184/// The trailing `(?![a-zA-Z0-9])` is enforced as a suffix guard; on rejection
1185/// the scan abandons the prefix candidate and moves to the next one, exactly
1186/// like a failed window match.
1187#[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
1211// ---------------------------------------------------------------------------
1212// File-inclusion template URL (inner lookahead implied by the closing quote)
1213// ---------------------------------------------------------------------------
1214
1215const 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/// id 64: the source's `(?![a-zA-Z0-9])` sits directly before the required
1218/// closing quote, so any match of the lookahead-free equivalent already
1219/// satisfies it (the quote is never alphanumeric).
1220#[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// ---------------------------------------------------------------------------
1233// Glued backtick candidate (lookbehind guard)
1234// ---------------------------------------------------------------------------
1235
1236/// `(?<!\x60)\x60(?:[A-Za-z0-9_./~]|\$[({])(?:[^\x60\\\n]|\\.)*\x60`
1237#[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
1247// ---------------------------------------------------------------------------
1248// XSS event-handler attribute walker
1249// ---------------------------------------------------------------------------
1250
1251/// The frozen 2026-08-20 `_HTML_EVENT_HANDLER_ATTRS` set, in reference order
1252/// (extracted verbatim from the canonical pattern source).
1253pub 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
1456/// ASCII case-folded `starts_with` at a byte position: the reference's builtin
1457/// `re.IGNORECASE` folding for the ASCII literals of the structural rows.
1458fn 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
1469/// The value part: `\s*=\s{0,20}(?:["'][^"']*["']|[^\s>]+)`, returning the end.
1470fn 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    // `\s{0,20}`
1477    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            // `["'][^"']*["']`: the body excludes BOTH quote characters and
1485            // the FIRST quote encountered ends the value (either flavor)
1486            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
1503/// `(?<!=)(?<!=\")(?<!=')` evaluated at the whitespace/slash run start: the
1504/// 1-char lookbehind rejects a bare `=`; the 2-char lookbehinds reject the
1505/// sequences `="` and `='`.
1506fn 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; // nothing before the run: the lookbehinds pass trivially
1509    };
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
1522/// The maximal whitespace/slash run ending at/just before `run_start`.
1523fn 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/// The monster pattern:
1532/// `(?:<[A-Za-z/](?:[^<>]*[^<>\s/])?(?<!=)(?<!=\")(?<!=')[\s/]+(?:NAMES)\s*=\s{0,20}(?:["'][^"']*["']|[^\s>]+))`.
1533///
1534/// The three lookbehinds sit between the attribute group and the whitespace
1535/// run, and the group is greedy: the engine tries the RIGHTMOST run split
1536/// first (longest attribute group) and backtracks leftward, with the
1537/// group-absent split (right after the tag-open letter) last. Each split's
1538/// guard is a 1-2 char sequence check on the characters before the run.
1539#[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        // whitespace/slash run starts in the attribute region (bounded by the
1551        // next angle bracket), leftmost-first
1552        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        // the greedy attribute group tries the longest run first
1572        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// helper re-export for the scan-window module users
1600#[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        // the lazy loop stops at the first `\bFROM\b`
1614        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        // the first scan is blocked by the second SELECT, but finditer
1620        // rescans from the next position and finds `SELECT FROM`
1621        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        // bare host without scheme context still fires via whitespace prefix
1641        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        // terminator must follow the host
1651        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        // `.env.local` is itself the BAD alternative (`.env` + `.\w+`)
1666        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        // oracle-verified: the lookbehinds reject the SEQUENCES `=`/`="/`='`
1723        // before the run; a bare `"` before the run does not block, and the
1724        // greedy group picks the rightmost split (`onerror`)
1725        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        // oracle-verified: the greedy attribute group tries the rightmost
1736        // run first, so the second handler wins the match
1737        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        // `="` directly before the run: the 2-char lookbehind blocks, and no
1746        // other split can rescue the match
1747        assert!(xss_event_handler_finditer("<a =\" onclick=x>").is_empty());
1748        // a bare `=` directly before the run: the 1-char lookbehind blocks
1749        assert!(xss_event_handler_finditer("<img src= onerror=x>").is_empty());
1750        // oracle-verified: `=x` before the run does NOT block (rightmost
1751        // split sees `x`, not `=`)
1752        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        // the value body excludes both quote flavors, and the first quote
1763        // encountered closes the value (either flavor): `"alert('`
1764        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}