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`.
679fn 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            // alternatives may contain embedded separators (`system/version`)
684            if haystack[pos..].starts_with(alt) {
685                ends.push(pos + alt.len());
686            }
687        }
688        ends
689    })
690}
691
692/// The optional trailing group of the literal-suffix shapes.
693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
694pub enum BadSuffix {
695    /// `(?:[.\-][\w.\-~%]*)?` (id 121)
696    DashRun,
697    /// `(?:\.ya?ml)?` (id 132)
698    YamlSuffix,
699    /// `(?:\.[\w.\-~%]*)?` (id 125)
700    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            // the bare alternative is the last backtracking branch
712            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
746/// `[\w.\-~%]*\.(?:ext|ext|...)`: every extension occurrence reachable through
747/// the surrounding path-class run (ids 104, 113).
748fn 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
771/// `[\w.\-~%]*(?:secrets?|credentials?)\.(?:ext|...)` (id 133).
772fn 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
807/// `(?:(?!config)[\w-])*config[\w-]*\.(?:env|yml|...)` (id 103). Only the first
808/// `config` occurrence in a `[\w-]` run is reachable: every later occurrence
809/// has a `config` start in its consumed prefix, which the per-char lookahead
810/// rejects.
811fn 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
831/// `.env` family: BAD = `\.env(?:\.\w+)?` (longest first).
832fn 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
847/// `\.(?:git|svn|hg|bzr)` (id 106).
848fn 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
862/// `(?:wp-(admin|login|content|includes|config)|administrator|xmlrpc)\.?(?:php)?`
863/// (id 109).
864fn 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            // greedy `\.?` then `(?:php)?`
879            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/// `\A[/\\]?...\.env...` (id 101).
958#[must_use]
959pub fn sensitive_path_env(haystack: &str) -> Vec<Candidate> {
960    sensitive_path_finditer(haystack, LeadingSep::Optional, env_bad)
961}
962
963/// `\A[/\\]?...(?:wp-...|administrator|xmlrpc)...` (id 109).
964#[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/// `\A[/\\]...(?:management|config_dump|credentials|system[/\\]version|...)`
970/// (id 117; the nested shape requires the leading separator).
971#[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/// `\A[/\\]?...[^\w.\-~%]*\.(?:ext|...)` (ids 104 `.map`, 113 backup files).
977#[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/// `\A[/\\]?...\.(?:git|svn|hg|bzr)...` (id 106).
987#[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/// `\A[/\\]?...(?:phpinfo|info|test|php_info)\.php...` (id 111) and the
993/// literal-plus-extension shapes.
994#[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/// `\A[/\\]?...(?:alt|alt|...)...` anchored literal shapes (ids 114, 119, 122,
1002/// 123, 124, 126, 128, 130, 131, 134, 135, 136).
1003#[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/// Anchored literal shapes with an optional trailing group (ids 121, 132).
1009#[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/// Anchored literal shapes with `(?:\.[\w.\-~%]*)?` (id 125).
1023#[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/// `\A[/\\]?...(?:secrets?|credentials?)\.(?:ext|...)...` (id 133).
1029#[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/// `\A[/\\]?...(?:(?!config)[\w-])*config[\w-]*\.(?:env|yml|...)...` (id 103).
1039#[must_use]
1040pub fn sensitive_path_config(haystack: &str) -> Vec<Candidate> {
1041    sensitive_path_finditer(haystack, LeadingSep::Optional, config_bad)
1042}
1043
1044/// The attack-report lexicon of the embedded-prose shapes (`ids 33, 102, 107,
1045/// 110, 112, 115`), verbatim from the canonical sources.
1046const 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/// Lexicon-lookahead path shapes (ids 33/102/107/110/112/115).
1049///
1050/// The reference pattern is `\A(?=(?:(?!\n).)*LEXICON)\A(?:(?!\n).)*<path-shape>`,
1051/// so the shape must sit in the first line and the first line must carry a
1052/// lexicon word.
1053#[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/// `Object\.prototype\.[A-Za-z_$][\w$]*\s*=(?!=)` (id 139): the negative
1078/// lookahead becomes a suffix check on the assignment operator.
1079#[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// ---------------------------------------------------------------------------
1089// Deserialization base64 magic prefixes
1090// ---------------------------------------------------------------------------
1091
1092/// `(?<![A-Za-z0-9+/])(?-i:MAGIC)` for the four magic prefixes; the
1093/// lookbehind is a boundary check on the literal occurrence.
1094#[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// ---------------------------------------------------------------------------
1115// SQLi ORDER BY terminator (second alternative guard)
1116// ---------------------------------------------------------------------------
1117
1118/// `(?i)\bORDER\s+BY\s+\d+\s*(?:--|#|;|\)|,|/\*|\Z)|(?<=[=?&])ORDER\s+BY\s+\d+\s*\n`
1119#[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// ---------------------------------------------------------------------------
1142// File-inclusion bare host (lookbehind guard)
1143// ---------------------------------------------------------------------------
1144
1145/// `(?:(?<!:)\/\/HOST...)`: the lookbehind is a one-char prefix check.
1146#[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\-\.\?,'/\\+&amp;%$#_]*)?",
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
1159// ---------------------------------------------------------------------------
1160// File-inclusion scheme URL (trailing lookahead + bounded window)
1161// ---------------------------------------------------------------------------
1162
1163const SCHEME_PATH_TRIMMED: &str =
1164    r#"=(?:https?|ftp)://[^\s'"<>]+/[^\s'"<>/]*\.(?:phtml|php[3-5]?|phar|jsp|aspx?|pl|py|txt|inc)"#;
1165
1166/// id 63: the reference runs the UNMODIFIED pattern inside the bounded window.
1167///
1168/// The trailing `(?![a-zA-Z0-9])` is enforced as a suffix guard; on rejection
1169/// the scan abandons the prefix candidate and moves to the next one, exactly
1170/// like a failed window match.
1171#[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
1195// ---------------------------------------------------------------------------
1196// File-inclusion template URL (inner lookahead implied by the closing quote)
1197// ---------------------------------------------------------------------------
1198
1199const 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/// id 64: the source's `(?![a-zA-Z0-9])` sits directly before the required
1202/// closing quote, so any match of the lookahead-free equivalent already
1203/// satisfies it (the quote is never alphanumeric).
1204#[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// ---------------------------------------------------------------------------
1217// Glued backtick candidate (lookbehind guard)
1218// ---------------------------------------------------------------------------
1219
1220/// `(?<!\x60)\x60(?:[A-Za-z0-9_./~]|\$[({])(?:[^\x60\\\n]|\\.)*\x60`
1221#[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
1231// ---------------------------------------------------------------------------
1232// XSS event-handler attribute walker
1233// ---------------------------------------------------------------------------
1234
1235/// The frozen 2026-08-20 `_HTML_EVENT_HANDLER_ATTRS` set, in reference order
1236/// (extracted verbatim from the canonical pattern source).
1237pub 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
1451/// The value part: `\s*=\s{0,20}(?:["'][^"']*["']|[^\s>]+)`, returning the end.
1452fn 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    // `\s{0,20}`
1459    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            // `["'][^"']*["']`: the body excludes BOTH quote characters and
1467            // the FIRST quote encountered ends the value (either flavor)
1468            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
1485/// `(?<!=)(?<!=\")(?<!=')` evaluated at the whitespace/slash run start: the
1486/// 1-char lookbehind rejects a bare `=`; the 2-char lookbehinds reject the
1487/// sequences `="` and `='`.
1488fn 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; // nothing before the run: the lookbehinds pass trivially
1491    };
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
1504/// The maximal whitespace/slash run ending at/just before `run_start`.
1505fn 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/// The monster pattern:
1514/// `(?:<[A-Za-z/](?:[^<>]*[^<>\s/])?(?<!=)(?<!=\")(?<!=')[\s/]+(?:NAMES)\s*=\s{0,20}(?:["'][^"']*["']|[^\s>]+))`.
1515///
1516/// The three lookbehinds sit between the attribute group and the whitespace
1517/// run, and the group is greedy: the engine tries the RIGHTMOST run split
1518/// first (longest attribute group) and backtracks leftward, with the
1519/// group-absent split (right after the tag-open letter) last. Each split's
1520/// guard is a 1-2 char sequence check on the characters before the run.
1521#[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        // whitespace/slash run starts in the attribute region (bounded by the
1533        // next angle bracket), leftmost-first
1534        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        // the greedy attribute group tries the longest run first
1554        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// helper re-export for the scan-window module users
1582#[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        // the lazy loop stops at the first `\bFROM\b`
1596        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        // the first scan is blocked by the second SELECT, but finditer
1602        // rescans from the next position and finds `SELECT FROM`
1603        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        // bare host without scheme context still fires via whitespace prefix
1623        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        // terminator must follow the host
1633        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        // `.env.local` is itself the BAD alternative (`.env` + `.\w+`)
1648        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        // oracle-verified: the lookbehinds reject the SEQUENCES `=`/`="/`='`
1705        // before the run; a bare `"` before the run does not block, and the
1706        // greedy group picks the rightmost split (`onerror`)
1707        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        // oracle-verified: the greedy attribute group tries the rightmost
1718        // run first, so the second handler wins the match
1719        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        // `="` directly before the run: the 2-char lookbehind blocks, and no
1728        // other split can rescue the match
1729        assert!(xss_event_handler_finditer("<a =\" onclick=x>").is_empty());
1730        // a bare `=` directly before the run: the 1-char lookbehind blocks
1731        assert!(xss_event_handler_finditer("<img src= onerror=x>").is_empty());
1732        // oracle-verified: `=x` before the run does NOT block (rightmost
1733        // split sees `x`, not `=`)
1734        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        // the value body excludes both quote flavors, and the first quote
1745        // encountered closes the value (either flavor): `"alert('`
1746        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}