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            // an accepted candidate always advances: `m.end() >= m.start()`,
34            // so a zero-width match steps one byte via the same max
35            from = m.end().max(m.start() + 1);
36        } else {
37            from = m.start() + 1;
38        }
39    }
40    out
41}
42
43/// Python `pattern.match(text, start, end)`: anchored at `start`, truncated
44/// at `end` (with the reference's overshoot-then-truncate fallback).
45pub(crate) fn match_span(
46    re: &Regex,
47    haystack: &str,
48    start: usize,
49    end: usize,
50) -> Option<Candidate> {
51    if start > end {
52        return None;
53    }
54    let first = re.find_at(haystack, start)?;
55    if first.start() != start {
56        return None;
57    }
58    if first.end() <= end {
59        return Some(Candidate::new(first.start(), first.end()));
60    }
61    let truncated = re.find_at(&haystack[..end], start)?;
62    if truncated.start() != start {
63        return None;
64    }
65    Some(Candidate::new(truncated.start(), truncated.end()))
66}
67
68// ---------------------------------------------------------------------------
69// cmd-injection newline shell -c finder
70// ---------------------------------------------------------------------------
71
72/// `_cmd_injection_shell_dash_c_finditer`.
73#[must_use]
74pub fn shell_dash_c_finditer(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
75    #[cfg(not(coverage))] // unreachable: statically valid literal
76    let Ok(prefix) = PyRegex::compile(r"\n[^\S\r\n]*", false) else {
77        return Vec::new();
78    };
79    #[cfg(coverage)]
80    let prefix = PyRegex::compile(r"\n[^\S\r\n]*", false).expect("statically valid literal");
81    #[cfg(not(coverage))] // unreachable: statically valid literal
82    let Ok(token) = PyRegex::compile(r"[^=\s;|&]+=[^\s;|&]+\s+", false) else {
83        return Vec::new();
84    };
85    #[cfg(coverage)]
86    let token =
87        PyRegex::compile(r"[^=\s;|&]+=[^\s;|&]+\s+", false).expect("statically valid literal");
88    let mut matches = Vec::new();
89    let mut last_end = 0usize;
90    for prefix_match in prefix.re().find_iter(haystack) {
91        let start = prefix_match.start();
92        if start < last_end {
93            continue;
94        }
95        let mut pos = prefix_match.end();
96        while let Some(tm) = token.re().find_at(haystack, pos) {
97            if tm.start() != pos {
98                break;
99            }
100            pos = tm.end();
101        }
102        if let Some(m) = compiled.re().find_at(haystack, start)
103            && m.start() == start
104        {
105            matches.push(Candidate::new(m.start(), m.end()));
106            last_end = m.end();
107        } else {
108            last_end = pos;
109        }
110    }
111    matches
112}
113
114// ---------------------------------------------------------------------------
115// LDAP null-byte attribute finder
116// ---------------------------------------------------------------------------
117
118const LDAP_NULL_BYTE_TAIL_RAW: &str = r"\*\)+(?:%00|\\u0000|\\x00|\\0|\x00)";
119const LDAP_NULL_BYTE_TAIL_DECODED: &str = r"\*\)+\x00";
120
121fn ldap_null_byte_attr_finditer(
122    haystack: &str,
123    compiled: &PyRegex,
124    tail_source: &str,
125) -> Vec<Candidate> {
126    if !haystack.contains('*') || !haystack.contains(')') {
127        return Vec::new();
128    }
129    #[cfg(not(coverage))] // unreachable: statically valid literal
130    let Ok(tail) = PyRegex::compile(tail_source, false) else {
131        return Vec::new();
132    };
133    #[cfg(coverage)]
134    let tail = PyRegex::compile(tail_source, false).expect("statically valid literal");
135    let mut matches = Vec::new();
136    // the overlap bookkeeping only feeds the coverage-gated check above
137    #[cfg_attr(coverage, allow(unused_variables, unused_mut))]
138    let mut last_end = 0usize;
139    for tail_match in tail.re().find_iter(haystack) {
140        let star_pos = tail_match.start();
141        #[cfg(not(coverage))] // unreachable: find_iter tail matches never overlap
142        if star_pos < last_end {
143            continue;
144        }
145        // value start: walk back over [\d\w\s]
146        let value_start = walk_back(haystack, star_pos, |c| {
147            c.is_whitespace() || c.is_alphanumeric() || c == '_'
148        });
149        if value_start == 0 {
150            continue;
151        }
152        if char_before(haystack, value_start).is_none_or(|(_, c)| c != '=') {
153            continue;
154        }
155        // attr name start: walk back over [\w-] from the '='
156        let equals_pos = value_start - 1;
157        let mut name_start = equals_pos;
158        while name_start > 0
159            && char_before(haystack, name_start).is_some_and(|(_, c)| py_is_word(c) || c == '-')
160        {
161            name_start = char_before(haystack, name_start).map_or(0, |(i, _)| i);
162        }
163        if name_start == equals_pos {
164            continue;
165        }
166        #[cfg(not(coverage))] // unreachable: name_start < star_pos <= haystack.len()
167        let Some((_, lead)) = char_at(haystack, name_start) else {
168            continue;
169        };
170        #[cfg(coverage)]
171        let (_, lead) = char_at(haystack, name_start).expect("name_start in bounds");
172        if !lead.is_ascii_alphabetic() {
173            continue;
174        }
175        if let Some(m) = match_span(compiled.re(), haystack, name_start, tail_match.end()) {
176            matches.push(m);
177            #[cfg(not(coverage))]
178            {
179                last_end = m.end;
180            }
181            // keep the binding live in coverage builds
182            #[cfg(coverage)]
183            let _ = last_end;
184        }
185    }
186    matches
187}
188
189fn walk_back(haystack: &str, mut pos: usize, pred: impl Fn(char) -> bool) -> usize {
190    while pos > 0 && char_before(haystack, pos).is_some_and(|(_, c)| pred(c)) {
191        pos = char_before(haystack, pos).map_or(0, |(i, _)| i);
192    }
193    pos
194}
195
196/// Raw-view LDAP null-byte attribute pattern.
197#[must_use]
198pub fn ldap_null_byte_attr_raw(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
199    ldap_null_byte_attr_finditer(haystack, compiled, LDAP_NULL_BYTE_TAIL_RAW)
200}
201
202/// URL-decoded-view LDAP null-byte attribute pattern.
203#[must_use]
204pub fn ldap_null_byte_attr_decoded(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
205    ldap_null_byte_attr_finditer(haystack, compiled, LDAP_NULL_BYTE_TAIL_DECODED)
206}
207
208// ---------------------------------------------------------------------------
209// Quote-splice finder
210// ---------------------------------------------------------------------------
211
212/// `_quote_splice_finditer`.
213#[must_use]
214pub fn quote_splice_finditer(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
215    let mut matches = Vec::new();
216    let mut last_end = 0usize;
217    let bytes = haystack.as_bytes();
218    let mut i = 0usize;
219    while i < bytes.len() {
220        if bytes[i] != b'\'' && bytes[i] != b'"' {
221            i += 1;
222            continue;
223        }
224        let quote_start = i;
225        while i < bytes.len() && (bytes[i] == b'\'' || bytes[i] == b'"') {
226            i += 1;
227        }
228        let quote_end = i;
229        if quote_start < last_end {
230            continue;
231        }
232        if quote_end >= haystack.len()
233            || char_at(haystack, quote_end).is_none_or(|(_, c)| !py_is_word(c))
234        {
235            continue;
236        }
237        let word_start = walk_back(haystack, quote_start, py_is_word);
238        if word_start == quote_start {
239            continue;
240        }
241        if let Some(m) = match_span(compiled.re(), haystack, word_start, haystack.len())
242            && m.start == word_start
243        {
244            matches.push(m);
245            last_end = m.end;
246        } else {
247            last_end = quote_end;
248        }
249    }
250    matches
251}
252
253// ---------------------------------------------------------------------------
254// LOAD_FILE / dollar substitution scan windows
255// ---------------------------------------------------------------------------
256
257/// `_load_file_scan_matches`.
258#[must_use]
259pub fn load_file_scan_matches(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
260    #[cfg(not(coverage))] // unreachable: statically valid literal
261    let Ok(prefix) = PyRegex::compile(r"LOAD_FILE\s*\(", true) else {
262        return Vec::new();
263    };
264    #[cfg(coverage)]
265    let prefix = PyRegex::compile(r"LOAD_FILE\s*\(", true).expect("statically valid literal");
266    #[cfg(not(coverage))] // unreachable: statically valid literal
267    let Ok(terminator) = PyRegex::compile(r"\)", false) else {
268        return Vec::new();
269    };
270    #[cfg(coverage)]
271    let terminator = PyRegex::compile(r"\)", false).expect("statically valid literal");
272    bounded_finditer(haystack, compiled, &prefix, &terminator)
273}
274
275/// `_cmd_injection_dollar_scan_matches`.
276#[must_use]
277pub fn dollar_substitution_scan_matches(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
278    #[cfg(not(coverage))] // unreachable: statically valid literal
279    let Ok(paren_prefix) = PyRegex::compile(r"[;&|]\s*\$\(", false) else {
280        return Vec::new();
281    };
282    #[cfg(coverage)]
283    let paren_prefix = PyRegex::compile(r"[;&|]\s*\$\(", false).expect("statically valid literal");
284    #[cfg(not(coverage))] // unreachable: statically valid literal
285    let Ok(paren_term) = PyRegex::compile(r"\)", false) else {
286        return Vec::new();
287    };
288    #[cfg(coverage)]
289    let paren_term = PyRegex::compile(r"\)", false).expect("statically valid literal");
290    #[cfg(not(coverage))] // unreachable: statically valid literal
291    let Ok(brace_prefix) = PyRegex::compile(r"[;&|]\s*\$\{", false) else {
292        return Vec::new();
293    };
294    #[cfg(coverage)]
295    let brace_prefix = PyRegex::compile(r"[;&|]\s*\$\{", false).expect("statically valid literal");
296    #[cfg(not(coverage))] // unreachable: statically valid literal
297    let Ok(brace_term) = PyRegex::compile(r"\}", false) else {
298        return Vec::new();
299    };
300    #[cfg(coverage)]
301    let brace_term = PyRegex::compile(r"\}", false).expect("statically valid literal");
302    let mut out = bounded_finditer(haystack, compiled, &paren_prefix, &paren_term);
303    out.extend(bounded_finditer(
304        haystack,
305        compiled,
306        &brace_prefix,
307        &brace_term,
308    ));
309    out
310}
311
312// ---------------------------------------------------------------------------
313// Glob wildcard atom scan
314// ---------------------------------------------------------------------------
315
316/// Glob wildcard scan: the atom is matched anchored inside each path run.
317#[must_use]
318pub fn glob_wildcard_scan_matches(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
319    #[cfg(not(coverage))] // unreachable: statically valid literal
320    let Ok(run_re) = PyRegex::compile(r"[\w./*?-]+", false) else {
321        return Vec::new();
322    };
323    #[cfg(coverage)]
324    let run_re = PyRegex::compile(r"[\w./*?-]+", false).expect("statically valid literal");
325    let mut matches = Vec::new();
326    for run in run_re.re().find_iter(haystack) {
327        if !run.as_str().contains(['?', '*']) {
328            continue;
329        }
330        if let Some(m) = compiled.re().find_at(haystack, run.start())
331            && m.start() == run.start()
332            && m.end() <= run.end()
333        {
334            matches.push(Candidate::new(m.start(), m.end()));
335        }
336    }
337    matches
338}
339
340// ---------------------------------------------------------------------------
341// SQLi SELECT ... FROM walker (negative-lookahead loop)
342// ---------------------------------------------------------------------------
343
344const SELECT_LITERAL: &[u8] = b"SELECT";
345const FROM_LITERAL: &[u8] = b"FROM";
346
347fn eq_ignore_ascii_at(haystack: &str, pos: usize, literal: &[u8]) -> bool {
348    haystack.as_bytes().get(pos..pos + literal.len()) == Some(literal)
349        || haystack
350            .as_bytes()
351            .get(pos..pos + literal.len())
352            .is_some_and(|window| {
353                window
354                    .iter()
355                    .zip(literal)
356                    .all(|(a, b)| a.eq_ignore_ascii_case(b))
357            })
358}
359
360fn word_starts_at(haystack: &str, pos: usize, literal: &[u8]) -> bool {
361    #[cfg(not(coverage))] // unreachable: every call site passes a char boundary
362    if !haystack.is_char_boundary(pos) {
363        return false;
364    }
365    if !eq_ignore_ascii_at(haystack, pos, literal) {
366        return false;
367    }
368    if pos > 0 && char_before(haystack, pos).is_some_and(|(_, c)| py_is_word(c)) {
369        return false;
370    }
371    let after = pos + literal.len();
372    char_at(haystack, after).is_none_or(|(_, c)| !py_is_word(c))
373}
374
375/// Lazy-loop matcher for `(?i)\bSELECT\b(?:(?!\bSELECT\b)[\w\s,\*().])*?\bFROM\b`.
376///
377/// The lazy loop consumes class characters but must not step over a new
378/// `\bSELECT\b`; the first `\bFROM\b` reachable under that rule ends the match.
379#[must_use]
380pub fn sqli_select_from_finditer(haystack: &str) -> Vec<Candidate> {
381    let mut matches = Vec::new();
382    let mut search_from = 0usize;
383    while let Some(start) = find_word_at(haystack, search_from, SELECT_LITERAL) {
384        let mut pos = start + SELECT_LITERAL.len();
385        let mut end: Option<usize> = None;
386        loop {
387            if word_starts_at(haystack, pos, FROM_LITERAL) {
388                end = Some(pos + FROM_LITERAL.len());
389                break;
390            }
391            let Some((offset, c)) = char_at(haystack, pos) else {
392                break;
393            };
394            let in_class =
395                py_is_word(c) || c.is_whitespace() || matches!(c, ',' | '*' | '(' | ')' | '.');
396            if !in_class || word_starts_at(haystack, pos, SELECT_LITERAL) {
397                break;
398            }
399            pos = offset + c.len_utf8();
400        }
401        match end {
402            Some(e) => {
403                matches.push(Candidate::new(start, e));
404                search_from = e;
405            }
406            None => {
407                search_from = start + 1;
408            }
409        }
410    }
411    matches
412}
413
414fn find_word_at(haystack: &str, from: usize, literal: &[u8]) -> Option<usize> {
415    let mut pos = from;
416    while pos < haystack.len() {
417        if word_starts_at(haystack, pos, literal) {
418            return Some(pos);
419        }
420        pos += char_at(haystack, pos).map_or(1, |(_, c)| c.len_utf8());
421    }
422    None
423}
424
425// ---------------------------------------------------------------------------
426// SQLi tautology (backreference as capture comparison)
427// ---------------------------------------------------------------------------
428
429const TAUTOLOGY_ATOM: &str = r#"(?:\d+|'[^']*'|"[^"]*"|[@:$][A-Za-z_]\w*)"#;
430
431/// Explicit backreference evaluation for `(?i)\b(?:OR|AND)\s*(ATOM)\s*=\s*\1\b`.
432///
433/// Atom variants are tried in regex backtracking order (greedy digit runs
434/// first, longest prefix first) and the second occurrence must equal the
435/// first case-insensitively (the reference backref inherits the pattern's
436/// `(?i)`).
437#[must_use]
438pub fn sqli_tautology_finditer(haystack: &str) -> Vec<Candidate> {
439    #[cfg(not(coverage))] // unreachable: statically valid literal
440    let Ok(re) = PyRegex::compile(&format!(r"(?i)\b(?:OR|AND)\s*({TAUTOLOGY_ATOM})"), false) else {
441        return Vec::new();
442    };
443    #[cfg(coverage)]
444    let re = PyRegex::compile(&format!(r"(?i)\b(?:OR|AND)\s*({TAUTOLOGY_ATOM})"), false)
445        .expect("statically valid literal");
446    let mut matches = Vec::new();
447    let mut resume = 0usize;
448    for caps in re.re().captures_iter(haystack) {
449        let whole = caps.get(0).expect("whole match");
450        if whole.start() < resume {
451            continue;
452        }
453        let atom = caps.get(1).expect("atom capture");
454        match tautology_complete(haystack, whole.start(), atom) {
455            Some(end) => {
456                matches.push(Candidate::new(whole.start(), end));
457                resume = end;
458            }
459            None => {
460                resume = whole.start() + 1;
461            }
462        }
463    }
464    matches
465}
466
467/// All backtracking variants of the first atom at `pos`, longest first.
468fn tautology_atom_variants(haystack: &str, pos: usize, greedy_end: usize) -> Vec<(usize, String)> {
469    let mut variants: Vec<(usize, String)> = Vec::new();
470    let atom_text = &haystack[pos..greedy_end];
471    if !atom_text.is_empty() && atom_text.bytes().all(|b| b.is_ascii_digit()) {
472        // `\d+` backtracks one digit at a time
473        let mut end = greedy_end;
474        loop {
475            variants.push((end, haystack[pos..end].to_owned()));
476            if end == pos + 1 {
477                break;
478            }
479            end -= 1;
480        }
481        return variants;
482    }
483    variants.push((greedy_end, atom_text.to_owned()));
484    variants
485}
486
487/// Complete the tautology match from a fixed atom: `\s*=\s*` then the same
488/// atom text case-insensitively, then `\b`.
489fn tautology_complete(haystack: &str, match_start: usize, atom: regex::Match<'_>) -> Option<usize> {
490    for (atom_end, atom_text) in tautology_atom_variants(haystack, atom.start(), atom.end()) {
491        let mut cursor = atom_end;
492        cursor = walk_forward_while(haystack, cursor, char::is_whitespace);
493        if char_at(haystack, cursor).map(|(_, c)| c) != Some('=') {
494            continue;
495        }
496        cursor = walk_forward_while(haystack, cursor + 1, char::is_whitespace);
497        // Python compares the same number of CHARACTERS as the atom (the
498        // reference re-matches the atom text case-insensitively); a byte
499        // count would split multi-byte chars on binary-decoded content.
500        let mut second_chars = haystack[cursor..].chars();
501        let equals_atom = atom_text.chars().all(|ac| {
502            second_chars
503                .next()
504                .is_some_and(|bc| bc.eq_ignore_ascii_case(&ac))
505        });
506        if !equals_atom {
507            continue;
508        }
509        let atom_len = atom_text.chars().map(char::len_utf8).sum::<usize>();
510        let after = cursor + atom_len;
511        let before_word = char_before(haystack, after).is_some_and(|(_, c)| py_is_word(c));
512        let after_word = char_at(haystack, after).is_some_and(|(_, c)| py_is_word(c));
513        if before_word != after_word {
514            return Some(after);
515        }
516    }
517    let _ = match_start;
518    None
519}
520
521// ---------------------------------------------------------------------------
522// SQLi inline comment obfuscation
523// ---------------------------------------------------------------------------
524
525/// `\w/\*(?!!)[^*]*\*/\w` with the `(?!!)` guard as a suffix check.
526#[must_use]
527pub fn sqli_inline_comment_finditer(haystack: &str) -> Vec<Candidate> {
528    #[cfg(not(coverage))] // unreachable: statically valid literal
529    let Ok(re) = PyRegex::compile(r"\w/\*[^*]*\*/\w", false) else {
530        return Vec::new();
531    };
532    #[cfg(coverage)]
533    let re = PyRegex::compile(r"\w/\*[^*]*\*/\w", false).expect("statically valid literal");
534    guarded_finditer(haystack, re.re(), |text, candidate| {
535        // the char right after `/*` must not be `!`; the position follows the
536        // (possibly multibyte) leading `\w` char, so it is walked by char
537        #[cfg(not(coverage))] // unreachable: candidate.start is a regex match start
538        let Some((i, c)) = char_at(text, candidate.start) else {
539            return false;
540        };
541        #[cfg(coverage)]
542        let (i, c) = char_at(text, candidate.start).expect("candidate.start in bounds");
543        let guard_pos = i + c.len_utf8() + 2;
544        text.as_bytes().get(guard_pos).is_some_and(|b| *b != b'!')
545    })
546}
547
548// ---------------------------------------------------------------------------
549// SSRF private hosts (lookbehind-guarded userinfo)
550// ---------------------------------------------------------------------------
551
552const 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)";
553
554/// `(?:^|\s|/)(?:(?<=://)[^\s/@]*@)?HOST(?::\d+)?(?:\s|$|/)`: the userinfo
555/// alternative requires `://` immediately before it (lookbehind guard).
556#[must_use]
557pub fn ssrf_private_host_finditer(haystack: &str) -> Vec<Candidate> {
558    let source =
559        format!(r"(?:\A|\s|/)((?:[^\s/@]*@)?)(?:{SSRF_HOST_ALT})(?::\d+)?(?:\s|(?:\n?\z)|/)");
560    #[cfg(not(coverage))] // unreachable: statically valid literal
561    let Ok(compiled) = PyRegex::compile(&source, true) else {
562        return Vec::new();
563    };
564    #[cfg(coverage)]
565    let compiled = PyRegex::compile(&source, true).expect("statically valid literal");
566    let mut out = Vec::new();
567    let mut from = 0usize;
568    while let Some(caps) = compiled.re().captures_at(haystack, from) {
569        let m = caps.get(0).expect("whole match");
570        let candidate = Candidate::new(m.start(), m.end());
571        let userinfo = caps.get(1).expect("userinfo group");
572        let guard_ok = userinfo.is_empty() || {
573            userinfo.start() >= 3 && &haystack[userinfo.start() - 3..userinfo.start()] == "://"
574        };
575        if guard_ok {
576            out.push(candidate);
577            // the prefix alternative and every host are non-empty, so the
578            // candidate is never zero-width; the max keeps one advancing line
579            from = candidate.end.max(candidate.start + 1);
580        } else {
581            from = candidate.start + 1;
582        }
583    }
584    out
585}
586
587// ---------------------------------------------------------------------------
588// SSRF numeric hosts (lookahead-guarded terminator)
589// ---------------------------------------------------------------------------
590
591/// `://(?:[^/@\s]*@)?((?:0[xX]...)(?:\.(...)){0,3})(?=[:/\s]|$)`: the
592/// terminator lookahead is a suffix check; shorter host matches end in host
593/// characters, so the check is exact per start position.
594#[must_use]
595pub fn ssrf_numeric_host_finditer(haystack: &str) -> Vec<Candidate> {
596    #[cfg(not(coverage))] // unreachable: statically valid literal
597    let Ok(re) = PyRegex::compile(
598        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})",
599        false,
600    ) else {
601        return Vec::new();
602    };
603    #[cfg(coverage)]
604    let re = PyRegex::compile(
605        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})",
606        false,
607    )
608    .expect("statically valid literal");
609    guarded_finditer(haystack, re.re(), |text, candidate| {
610        terminator_guard(text, candidate.end, |c| {
611            matches!(c, ':' | '/') || c.is_whitespace()
612        })
613    })
614}
615
616/// Python terminator guard `(?=T|$)`: `T` at `pos`, or end of text, or a
617/// single trailing newline before the end.
618fn terminator_guard(haystack: &str, pos: usize, terminator: impl Fn(char) -> bool) -> bool {
619    let Some(c) = char_at(haystack, pos).map(|(_, c)| c) else {
620        return true; // end of text
621    };
622    if terminator(c) {
623        return true;
624    }
625    // Python `$`: just before a trailing newline
626    c == '\n' && pos + 1 == haystack.len()
627}
628
629// ---------------------------------------------------------------------------
630// cmd-injection shell dash-flag (trailing lookahead guard)
631// ---------------------------------------------------------------------------
632
633const 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;|&]+))?"#;
634
635/// Trailing-lookahead suffix check for `(?:\A|[;|&])\s*...(?=\s*(?:[;|&]|\Z))`.
636///
637/// Every backtracked variant of the optional tail ends in a word character,
638/// which the guard also rejects, so the check is exact.
639#[must_use]
640pub fn shell_dash_flag_finditer(haystack: &str) -> Vec<Candidate> {
641    #[cfg(not(coverage))] // unreachable: statically valid literal
642    let Ok(re) = PyRegex::compile(SHELL_DASH_FLAG_BODY, true) else {
643        return Vec::new();
644    };
645    #[cfg(coverage)]
646    let re = PyRegex::compile(SHELL_DASH_FLAG_BODY, true).expect("statically valid literal");
647    guarded_finditer(haystack, re.re(), |text, candidate| {
648        lookahead_shell_chain_terminator(text, candidate.end)
649    })
650}
651
652fn lookahead_shell_chain_terminator(haystack: &str, pos: usize) -> bool {
653    let rest = &haystack[pos..];
654    let trimmed = rest.trim_start_matches(|c: char| c.is_whitespace());
655    trimmed.is_empty()
656        || trimmed.starts_with(';')
657        || trimmed.starts_with('|')
658        || trimmed.starts_with('&')
659}
660
661// ---------------------------------------------------------------------------
662// Sensitive path family (segment-loop negative lookahead)
663// ---------------------------------------------------------------------------
664
665/// A `BAD` alternative: tried greedily in order, each yielding the BAD end.
666type BadMatcher<'a> = Box<dyn Fn(&str, usize) -> Vec<usize> + 'a>;
667
668/// The leading separator of the reference's anchored path shapes:
669/// `\A[/\\]?...` (optional) or `\A[/\\]...` (required).
670#[derive(Debug, Clone, Copy, PartialEq, Eq)]
671pub enum LeadingSep {
672    Optional,
673    Required,
674}
675
676fn is_path_class(c: char) -> bool {
677    py_is_word(c) || matches!(c, '.' | '-' | '~' | '%')
678}
679
680fn is_word_dash(c: char) -> bool {
681    py_is_word(c) || c == '-'
682}
683
684fn bad_followed_by_segment_boundary(haystack: &str, ends: &[usize]) -> bool {
685    ends.iter().any(|end| {
686        char_at(haystack, *end).is_none_or(|(_, c)| matches!(c, '/' | '\\'))
687            || *end == haystack.len()
688    })
689}
690
691/// Generic walker for the anchored sensitive-path shapes:
692/// `\A[/\\]?(?:(?!BAD(?:[/\\]|\Z))[\w.\-~%]+[/\\])*BAD(?:[/\\][\w.\-~%]*)*(?:\?\S*)?\s*\z`.
693/// The greedy segment loop cannot resume once BAD (followed by a segment
694/// boundary) appears, and the tail cannot succeed at a segment start (BAD
695/// followed by `/` or `\z` there is exactly what the lookahead blocked), so a
696/// single greedy walk plus one tail check is exact.
697fn sensitive_path_finditer(
698    haystack: &str,
699    leading_sep: LeadingSep,
700    bad: impl Fn(&str, usize) -> Vec<usize>,
701) -> Vec<Candidate> {
702    let mut pos = 0usize;
703    match (leading_sep, char_at(haystack, 0)) {
704        (LeadingSep::Optional | LeadingSep::Required, Some((_, '/' | '\\'))) => pos = 1,
705        (LeadingSep::Required, _) => return Vec::new(),
706        (LeadingSep::Optional, _) => {}
707    }
708    loop {
709        let ends = bad(haystack, pos);
710        if bad_followed_by_segment_boundary(haystack, &ends) {
711            break;
712        }
713        let seg_end = walk_forward_while(haystack, pos, is_path_class);
714        if seg_end > pos && char_at(haystack, seg_end).is_some_and(|(_, c)| matches!(c, '/' | '\\'))
715        {
716            pos = seg_end + 1;
717            continue;
718        }
719        break;
720    }
721    // tail: BAD + (?:[/\\][\w.\-~%]*)* + (?:\?\S*)? + \s*\z
722    for bad_end in bad(haystack, pos) {
723        let mut cursor = bad_end;
724        while let Some('/' | '\\') = char_at(haystack, cursor).map(|(_, c)| c) {
725            cursor += 1;
726            cursor = walk_forward_while(haystack, cursor, is_path_class);
727        }
728        if char_at(haystack, cursor).map(|(_, c)| c) == Some('?') {
729            cursor += 1;
730            cursor = walk_forward_while(haystack, cursor, |c| !c.is_whitespace());
731        }
732        let tail_end = walk_forward_while(haystack, cursor, char::is_whitespace);
733        if tail_end == haystack.len() {
734            return vec![Candidate::new(0, haystack.len())];
735        }
736    }
737    Vec::new()
738}
739
740/// Anchored literal alternatives: `(?:alt|alt|...)` matched at `pos`.
741///
742/// The reference compiles these rows with builtin IGNORECASE
743/// (`_BUILTIN_PATTERN_COMPILE_FLAGS`), so the literals fold ASCII case.
744fn literal_bad<'a>(alternatives: &'a [&'a str]) -> BadMatcher<'a> {
745    Box::new(move |haystack: &str, pos: usize| -> Vec<usize> {
746        let mut ends = Vec::new();
747        for alt in alternatives {
748            // alternatives may contain embedded separators (`system/version`)
749            if ascii_starts_with_ignore_case(haystack, pos, alt) {
750                ends.push(pos + alt.len());
751            }
752        }
753        ends
754    })
755}
756
757/// The optional trailing group of the literal-suffix shapes.
758#[derive(Debug, Clone, Copy, PartialEq, Eq)]
759pub enum BadSuffix {
760    /// `(?:[.\-][\w.\-~%]*)?` (id 121)
761    DashRun,
762    /// `(?:\.ya?ml)?` (id 132)
763    YamlSuffix,
764    /// `(?:\.[\w.\-~%]*)?` (id 125)
765    DotRun,
766}
767
768fn literal_suffix_bad<'a>(alternatives: &'a [&'a str], suffix: BadSuffix) -> BadMatcher<'a> {
769    Box::new(move |haystack: &str, pos: usize| -> Vec<usize> {
770        let mut ends = Vec::new();
771        for alt in alternatives {
772            if !ascii_starts_with_ignore_case(haystack, pos, alt) {
773                continue;
774            }
775            let head = pos + alt.len();
776            // the bare alternative is the last backtracking branch
777            ends.push(head);
778            let rest = &haystack[head..];
779            match suffix {
780                BadSuffix::YamlSuffix => {
781                    if let Some(after_dot) = rest.strip_prefix('.') {
782                        if ascii_starts_with_ignore_case(after_dot, 0, "yaml") {
783                            ends.push(head + 5);
784                        }
785                        if ascii_starts_with_ignore_case(after_dot, 0, "yml") {
786                            ends.push(head + 4);
787                        }
788                    }
789                }
790                BadSuffix::DotRun => {
791                    if rest.starts_with('.') {
792                        ends.push(walk_forward_while(haystack, head + 1, is_path_class));
793                        ends.push(head + 1);
794                    }
795                }
796                BadSuffix::DashRun => {
797                    if let Some((i, c)) = char_at(haystack, head)
798                        && matches!(c, '.' | '-')
799                    {
800                        let run_end = walk_forward_while(haystack, i + c.len_utf8(), is_path_class);
801                        ends.push(run_end);
802                        ends.push(i + c.len_utf8());
803                    }
804                }
805            }
806        }
807        ends
808    })
809}
810
811/// `[\w.\-~%]*\.(?:ext|ext|...)`: every extension occurrence reachable through
812/// the surrounding path-class run (ids 104, 113).
813fn extension_run_bad<'a>(extensions: &'a [&'a str]) -> BadMatcher<'a> {
814    Box::new(move |haystack: &str, pos: usize| -> Vec<usize> {
815        let run_end = walk_forward_while(haystack, pos, is_path_class);
816        let mut ends = Vec::new();
817        let mut cursor = pos;
818        while cursor < run_end {
819            #[cfg(not(coverage))] // unreachable: cursor < run_end is always a boundary
820            let Some((i, c)) = char_at(haystack, cursor) else {
821                break;
822            };
823            #[cfg(coverage)]
824            let (i, c) = char_at(haystack, cursor).expect("cursor within the path run");
825            if c == '.' {
826                let after_dot = i + 1;
827                for ext in extensions {
828                    if ascii_starts_with_ignore_case(haystack, after_dot, ext) {
829                        ends.push(after_dot + ext.len());
830                    }
831                }
832            }
833            cursor = i + c.len_utf8();
834        }
835        ends
836    })
837}
838
839/// `[\w.\-~%]*(?:secrets?|credentials?)\.(?:ext|...)` (id 133).
840fn secrets_run_bad<'a>(extensions: &'a [&'a str]) -> BadMatcher<'a> {
841    Box::new(move |haystack: &str, pos: usize| -> Vec<usize> {
842        let run_end = walk_forward_while(haystack, pos, is_path_class);
843        let mut ends = Vec::new();
844        let mut cursor = pos;
845        while cursor < run_end {
846            #[cfg(not(coverage))] // unreachable: cursor < run_end is always a boundary
847            let Some((i, c)) = char_at(haystack, cursor) else {
848                break;
849            };
850            #[cfg(coverage)]
851            let (i, c) = char_at(haystack, cursor).expect("cursor within the path run");
852            for stem in ["secret", "credential"] {
853                if !ascii_starts_with_ignore_case(haystack, i, stem) {
854                    continue;
855                }
856                let mut after_stem = i + stem.len();
857                if let Some((_, plural)) = char_at(haystack, after_stem)
858                    && matches!(plural, 's' | 'S')
859                {
860                    after_stem += 1;
861                }
862                if char_at(haystack, after_stem).map(|(_, c)| c) != Some('.') {
863                    continue;
864                }
865                let after_dot = after_stem + 1;
866                for ext in extensions {
867                    if ascii_starts_with_ignore_case(haystack, after_dot, ext) {
868                        ends.push(after_dot + ext.len());
869                    }
870                }
871            }
872            cursor = i + c.len_utf8();
873        }
874        ends
875    })
876}
877
878const CONFIG_EXTENSIONS: &[&str] = &["env", "yml", "yaml", "json", "toml", "ini", "xml", "conf"];
879
880/// `(?:(?!config)[\w-])*config[\w-]*\.(?:env|yml|...)` (id 103). Only the first
881/// `config` occurrence in a `[\w-]` run is reachable: every later occurrence
882/// has a `config` start in its consumed prefix, which the per-char lookahead
883/// rejects. Literals fold ASCII case (builtin IGNORECASE).
884fn config_bad(haystack: &str, pos: usize) -> Vec<usize> {
885    let run_end = walk_forward_while(haystack, pos, is_word_dash);
886    let mut config_start = None;
887    let mut cursor = pos;
888    while cursor + "config".len() <= run_end {
889        if ascii_starts_with_ignore_case(haystack, cursor, "config") {
890            config_start = Some(cursor);
891            break;
892        }
893        cursor = char_at(haystack, cursor).map_or(cursor + 1, |(i, c)| i + c.len_utf8());
894    }
895    let Some(config_start) = config_start else {
896        return Vec::new();
897    };
898    let word_end = walk_forward_while(haystack, config_start + "config".len(), is_word_dash);
899    if char_at(haystack, word_end).map(|(_, c)| c) != Some('.') {
900        return Vec::new();
901    }
902    let after_dot = word_end + 1;
903    let mut ends = Vec::new();
904    for ext in CONFIG_EXTENSIONS {
905        if ascii_starts_with_ignore_case(haystack, after_dot, ext) {
906            ends.push(after_dot + ext.len());
907        }
908    }
909    ends
910}
911
912/// `.env` family: BAD = `\.env(?:\.\w+)?` (longest first).
913fn env_bad(haystack: &str, pos: usize) -> Vec<usize> {
914    let mut ends = Vec::new();
915    if ascii_starts_with_ignore_case(haystack, pos, ".env") {
916        let after = pos + 4;
917        if char_at(haystack, after).map(|(_, c)| c) == Some('.') {
918            let word_end = walk_forward_while(haystack, after + 1, py_is_word);
919            if word_end > after + 1 {
920                ends.push(word_end);
921            }
922        }
923        ends.push(after);
924    }
925    ends
926}
927
928/// `\.(?:git|svn|hg|bzr)` (id 106).
929fn dot_alternative_bad<'a>(names: &'a [&'a str]) -> BadMatcher<'a> {
930    Box::new(move |haystack: &str, pos: usize| -> Vec<usize> {
931        let mut ends = Vec::new();
932        if char_at(haystack, pos).map(|(_, c)| c) == Some('.') {
933            for name in names {
934                if ascii_starts_with_ignore_case(haystack, pos + 1, name) {
935                    ends.push(pos + 1 + name.len());
936                }
937            }
938        }
939        ends
940    })
941}
942
943/// `(?:wp-(admin|login|content|includes|config)|administrator|xmlrpc)\.?(?:php)?`
944/// (id 109).
945fn wp_admin_bad(haystack: &str, pos: usize) -> Vec<usize> {
946    const HEADS: &[&str] = &[
947        "wp-admin",
948        "wp-login",
949        "wp-content",
950        "wp-includes",
951        "wp-config",
952        "administrator",
953        "xmlrpc",
954    ];
955    let mut ends = Vec::new();
956    for head in HEADS {
957        if ascii_starts_with_ignore_case(haystack, pos, head) {
958            let after = pos + head.len();
959            // greedy `\.?` then `(?:php)?`
960            let dotted = char_at(haystack, after).map(|(_, c)| c) == Some('.');
961            let after_dot = if dotted { after + 1 } else { after };
962            if ascii_starts_with_ignore_case(haystack, after_dot, "php") {
963                ends.push(after_dot + 3);
964            }
965            if dotted {
966                ends.push(after_dot);
967            }
968            ends.push(after);
969        }
970    }
971    ends
972}
973
974const MANAGEMENT_BAD: &[&str] = &[
975    "management",
976    "config_dump",
977    "credentials",
978    "system/version",
979    "system\\version",
980    "version/system",
981    "version\\system",
982];
983
984pub const RECON_APP_BAD: &[&str] = &[
985    "geoserver",
986    "confluence",
987    "nifi",
988    "ScadaBR",
989    "pandora_console",
990    "centreon",
991    "kylin",
992    "decisioncenter",
993    "evox",
994    "MagicInfo",
995    "metasys",
996    "officescan",
997    "helpdesk",
998    "ignite",
999];
1000
1001pub const RECON_README_BAD: &[&str] = &[
1002    "readme.txt",
1003    "README.md",
1004    "CHANGELOG",
1005    "pom.xml",
1006    "build.gradle",
1007    "appsettings.json",
1008    "crossdomain.xml",
1009];
1010
1011pub const BACKUP_EXTENSIONS: &[&str] = &[
1012    "bak", "backup", "old", "orig", "save", "swp", "swo", "tmp", "temp",
1013];
1014
1015pub const SECRETS_EXTENSIONS: &[&str] = &[
1016    "py", "json", "yml", "yaml", "toml", "txt", "env", "xml", "conf", "cfg",
1017];
1018
1019pub const DOCKERFILE_BAD: &[&str] = &[
1020    "docker-compose",
1021    "Dockerfile",
1022    "Makefile",
1023    "Vagrantfile",
1024    "Jenkinsfile",
1025    "Procfile",
1026];
1027
1028pub const DOUBLE_DOT_BAD: &[&str] = &[
1029    ".htaccess",
1030    ".htpasswd",
1031    ".DS_Store",
1032    "Thumbs.db",
1033    ".npmrc",
1034    ".dockerenv",
1035    "web.config",
1036];
1037
1038/// `\A[/\\]?...\.env...` (id 101).
1039#[must_use]
1040pub fn sensitive_path_env(haystack: &str) -> Vec<Candidate> {
1041    sensitive_path_finditer(haystack, LeadingSep::Optional, env_bad)
1042}
1043
1044/// `\A[/\\]?...(?:wp-...|administrator|xmlrpc)...` (id 109).
1045#[must_use]
1046pub fn sensitive_path_wp_admin(haystack: &str) -> Vec<Candidate> {
1047    sensitive_path_finditer(haystack, LeadingSep::Optional, wp_admin_bad)
1048}
1049
1050/// `\A[/\\]...(?:management|config_dump|credentials|system[/\\]version|...)`
1051/// (id 117; the nested shape requires the leading separator).
1052#[must_use]
1053pub fn sensitive_path_management(haystack: &str) -> Vec<Candidate> {
1054    sensitive_path_finditer(haystack, LeadingSep::Required, literal_bad(MANAGEMENT_BAD))
1055}
1056
1057/// `\A[/\\]?...[^\w.\-~%]*\.(?:ext|...)` (ids 104 `.map`, 113 backup files).
1058#[must_use]
1059pub fn sensitive_path_scan_ext(haystack: &str, extensions: &[&str]) -> Vec<Candidate> {
1060    sensitive_path_finditer(
1061        haystack,
1062        LeadingSep::Optional,
1063        extension_run_bad(extensions),
1064    )
1065}
1066
1067/// `\A[/\\]?...\.(?:git|svn|hg|bzr)...` (id 106).
1068#[must_use]
1069pub fn sensitive_path_dot_alt(haystack: &str, names: &[&str]) -> Vec<Candidate> {
1070    sensitive_path_finditer(haystack, LeadingSep::Optional, dot_alternative_bad(names))
1071}
1072
1073/// `\A[/\\]?...(?:phpinfo|info|test|php_info)\.php...` (id 111) and the
1074/// literal-plus-extension shapes.
1075#[must_use]
1076pub fn sensitive_path_literal_ext(haystack: &str, names: &[&str], ext: &str) -> Vec<Candidate> {
1077    let literals: Vec<String> = names.iter().map(|name| format!("{name}.{ext}")).collect();
1078    let refs: Vec<&str> = literals.iter().map(String::as_str).collect();
1079    sensitive_path_finditer(haystack, LeadingSep::Optional, literal_bad(&refs))
1080}
1081
1082/// `\A[/\\]?...(?:alt|alt|...)...` anchored literal shapes (ids 114, 119, 122,
1083/// 123, 124, 126, 128, 130, 131, 134, 135, 136).
1084#[must_use]
1085pub fn sensitive_path_literal(haystack: &str, literals: &[&str]) -> Vec<Candidate> {
1086    sensitive_path_finditer(haystack, LeadingSep::Optional, literal_bad(literals))
1087}
1088
1089/// Anchored literal shapes with an optional trailing group (ids 121, 132).
1090#[must_use]
1091pub fn sensitive_path_literal_suffix(
1092    haystack: &str,
1093    literals: &[&str],
1094    suffix: BadSuffix,
1095) -> Vec<Candidate> {
1096    sensitive_path_finditer(
1097        haystack,
1098        LeadingSep::Optional,
1099        literal_suffix_bad(literals, suffix),
1100    )
1101}
1102
1103/// Anchored literal shapes with `(?:\.[\w.\-~%]*)?` (id 125).
1104#[must_use]
1105pub fn sensitive_path_literal_suffix_dot(haystack: &str, literals: &[&str]) -> Vec<Candidate> {
1106    sensitive_path_literal_suffix(haystack, literals, BadSuffix::DotRun)
1107}
1108
1109/// `\A[/\\]?...(?:secrets?|credentials?)\.(?:ext|...)...` (id 133).
1110#[must_use]
1111pub fn sensitive_path_secrets(haystack: &str) -> Vec<Candidate> {
1112    sensitive_path_finditer(
1113        haystack,
1114        LeadingSep::Optional,
1115        secrets_run_bad(SECRETS_EXTENSIONS),
1116    )
1117}
1118
1119/// `\A[/\\]?...(?:(?!config)[\w-])*config[\w-]*\.(?:env|yml|...)...` (id 103).
1120#[must_use]
1121pub fn sensitive_path_config(haystack: &str) -> Vec<Candidate> {
1122    sensitive_path_finditer(haystack, LeadingSep::Optional, config_bad)
1123}
1124
1125/// The attack-report lexicon of the embedded-prose shapes (`ids 33, 102, 107,
1126/// 110, 112, 115`), verbatim from the canonical sources.
1127const 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";
1128
1129/// Lexicon-lookahead path shapes (ids 33/102/107/110/112/115).
1130///
1131/// The reference pattern is `\A(?=(?:(?!\n).)*LEXICON)\A(?:(?!\n).)*<path-shape>`,
1132/// so the shape must sit in the first line and the first line must carry a
1133/// lexicon word.
1134#[must_use]
1135pub fn lexicon_path_finditer(haystack: &str, shape: &str, require_lexicon: bool) -> Vec<Candidate> {
1136    #[cfg(not(coverage))] // unreachable: every caller passes a statically valid shape
1137    let Ok(compiled) = PyRegex::compile(shape, true) else {
1138        return Vec::new();
1139    };
1140    #[cfg(coverage)]
1141    let compiled = PyRegex::compile(shape, true).expect("statically valid shape");
1142    let matches: Vec<Candidate> = compiled
1143        .re()
1144        .find_iter(haystack)
1145        .map(|m| Candidate::new(m.start(), m.end()))
1146        .collect();
1147    if !require_lexicon || matches.is_empty() {
1148        return matches;
1149    }
1150    #[cfg(not(coverage))] // unreachable: statically valid literal
1151    let Ok(lexicon) = PyRegex::compile(ATTACK_REPORT_LEXICON, true) else {
1152        return Vec::new();
1153    };
1154    #[cfg(coverage)]
1155    let lexicon = PyRegex::compile(ATTACK_REPORT_LEXICON, true).expect("statically valid literal");
1156    let first_line_end = haystack.find('\n').unwrap_or(haystack.len());
1157    if lexicon.re().is_match(&haystack[..first_line_end]) {
1158        matches
1159    } else {
1160        Vec::new()
1161    }
1162}
1163
1164/// `Object\.prototype\.[A-Za-z_$][\w$]*\s*=(?!=)` (id 139).
1165///
1166/// The negative lookahead becomes a suffix check on the assignment operator.
1167/// The row carries the reference's builtin IGNORECASE, so it compiles
1168/// case-folded.
1169#[must_use]
1170pub fn proto_pollution_assign_finditer(haystack: &str) -> Vec<Candidate> {
1171    #[cfg(not(coverage))] // unreachable: statically valid literal
1172    let Ok(re) = PyRegex::compile(r"Object\.prototype\.[A-Za-z_$][\w$]*\s*=", true) else {
1173        return Vec::new();
1174    };
1175    #[cfg(coverage)]
1176    let re = PyRegex::compile(r"Object\.prototype\.[A-Za-z_$][\w$]*\s*=", true)
1177        .expect("statically valid literal");
1178    guarded_finditer(haystack, re.re(), |text, candidate| {
1179        text.as_bytes().get(candidate.end) != Some(&b'=')
1180    })
1181}
1182// ---------------------------------------------------------------------------
1183// Deserialization base64 magic prefixes
1184// ---------------------------------------------------------------------------
1185
1186/// `(?<![A-Za-z0-9+/])(?-i:MAGIC)` for the four magic prefixes; the
1187/// lookbehind is a boundary check on the literal occurrence.
1188#[must_use]
1189pub fn deserialization_b64_finditer(haystack: &str, magic: &str) -> Vec<Candidate> {
1190    let mut out = Vec::new();
1191    let mut from = 0usize;
1192    while let Some(rel) = haystack[from..].find(magic) {
1193        let start = from + rel;
1194        let end = start + magic.len();
1195        let boundary_ok = start == 0
1196            || char_before(haystack, start)
1197                .is_none_or(|(_, c)| !c.is_ascii_alphanumeric() && c != '/' && c != '+');
1198        if boundary_ok {
1199            out.push(Candidate::new(start, end));
1200            from = end;
1201        } else {
1202            from = start + 1;
1203        }
1204    }
1205    out
1206}
1207
1208// ---------------------------------------------------------------------------
1209// SQLi ORDER BY terminator (second alternative guard)
1210// ---------------------------------------------------------------------------
1211
1212/// `(?i)\bORDER\s+BY\s+\d+\s*(?:--|#|;|\)|,|/\*|\Z)|(?<=[=?&])ORDER\s+BY\s+\d+\s*\n`
1213#[must_use]
1214pub fn sqli_order_by_terminator_finditer(haystack: &str) -> Vec<Candidate> {
1215    #[cfg(not(coverage))] // unreachable: statically valid literal
1216    let Ok(alt1) = PyRegex::compile(r"\bORDER\s+BY\s+\d+\s*(?:--|#|;|\)|,|/\*|\z)", true) else {
1217        return Vec::new();
1218    };
1219    #[cfg(coverage)]
1220    let alt1 = PyRegex::compile(r"\bORDER\s+BY\s+\d+\s*(?:--|#|;|\)|,|/\*|\z)", true)
1221        .expect("statically valid literal");
1222    #[cfg(not(coverage))] // unreachable: statically valid literal
1223    let Ok(alt2) = PyRegex::compile(r"ORDER\s+BY\s+\d+\s*\n", true) else {
1224        return Vec::new();
1225    };
1226    #[cfg(coverage)]
1227    let alt2 = PyRegex::compile(r"ORDER\s+BY\s+\d+\s*\n", true).expect("statically valid literal");
1228    let mut out: Vec<Candidate> = alt1
1229        .re()
1230        .find_iter(haystack)
1231        .map(|m| Candidate::new(m.start(), m.end()))
1232        .collect();
1233    let guarded = guarded_finditer(haystack, alt2.re(), |text, candidate| {
1234        candidate.start > 0
1235            && char_before(text, candidate.start).is_some_and(|(_, c)| matches!(c, '=' | '?' | '&'))
1236    });
1237    out.extend(guarded);
1238    out.sort_by_key(|c| (c.start, std::cmp::Reverse(c.end)));
1239    out
1240}
1241
1242// ---------------------------------------------------------------------------
1243// File-inclusion bare host (lookbehind guard)
1244// ---------------------------------------------------------------------------
1245
1246/// `(?:(?<!:)\/\/HOST...)`: the lookbehind is a one-char prefix check.
1247#[must_use]
1248pub fn file_inclusion_bare_host_finditer(haystack: &str) -> Vec<Candidate> {
1249    #[cfg(not(coverage))] // unreachable: statically valid literal
1250    let Ok(re) = PyRegex::compile(
1251        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;%$#_]*)?",
1252        false,
1253    ) else {
1254        return Vec::new();
1255    };
1256    #[cfg(coverage)]
1257    let re = PyRegex::compile(
1258        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;%$#_]*)?",
1259        false,
1260    )
1261    .expect("statically valid literal");
1262    guarded_finditer(haystack, re.re(), |text, candidate| {
1263        candidate.start == 0 || char_before(text, candidate.start).is_none_or(|(_, c)| c != ':')
1264    })
1265}
1266
1267// ---------------------------------------------------------------------------
1268// File-inclusion scheme URL (trailing lookahead + bounded window)
1269// ---------------------------------------------------------------------------
1270
1271const SCHEME_PATH_TRIMMED: &str =
1272    r#"=(?:https?|ftp)://[^\s'"<>]+/[^\s'"<>/]*\.(?:phtml|php[3-5]?|phar|jsp|aspx?|pl|py|txt|inc)"#;
1273
1274/// id 63: the reference runs the UNMODIFIED pattern inside the bounded window.
1275///
1276/// The trailing `(?![a-zA-Z0-9])` is enforced as a suffix guard; on rejection
1277/// the scan abandons the prefix candidate and moves to the next one, exactly
1278/// like a failed window match.
1279#[must_use]
1280pub fn file_inclusion_scheme_path_finditer(haystack: &str) -> Vec<Candidate> {
1281    #[cfg(not(coverage))] // unreachable: statically valid literals
1282    let (Some(compiled), Some(prefix), Some(terminator)) = (
1283        PyRegex::compile(SCHEME_PATH_TRIMMED, true).ok(),
1284        PyRegex::compile(r"=(?:https?|ftp)://", true).ok(),
1285        PyRegex::compile(
1286            r"\.(?:phtml|php\d*|phar|jsp|aspx?|pl|py|txt|inc)[a-zA-Z0-9]*",
1287            true,
1288        )
1289        .ok(),
1290    ) else {
1291        return Vec::new();
1292    };
1293    #[cfg(coverage)]
1294    let (compiled, prefix, terminator) = (
1295        PyRegex::compile(SCHEME_PATH_TRIMMED, true),
1296        PyRegex::compile(r"=(?:https?|ftp)://", true),
1297        PyRegex::compile(
1298            r"\.(?:phtml|php\d*|phar|jsp|aspx?|pl|py|txt|inc)[a-zA-Z0-9]*",
1299            true,
1300        ),
1301    );
1302    #[cfg(coverage)]
1303    let (compiled, prefix, terminator) = (
1304        compiled.expect("statically valid literal"),
1305        prefix.expect("statically valid literal"),
1306        terminator.expect("statically valid literal"),
1307    );
1308    super::scan_window::bounded_finditer_guarded(
1309        haystack,
1310        &compiled,
1311        &prefix,
1312        &terminator,
1313        |text, candidate| {
1314            char_at(text, candidate.end).is_none_or(|(_, c)| !c.is_ascii_alphanumeric())
1315        },
1316    )
1317}
1318
1319// ---------------------------------------------------------------------------
1320// File-inclusion template URL (inner lookahead implied by the closing quote)
1321// ---------------------------------------------------------------------------
1322
1323const 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)["']"#;
1324
1325/// id 64: the source's `(?![a-zA-Z0-9])` sits directly before the required
1326/// closing quote, so any match of the lookahead-free equivalent already
1327/// satisfies it (the quote is never alphanumeric).
1328#[must_use]
1329pub fn file_inclusion_template_url_finditer(haystack: &str) -> Vec<Candidate> {
1330    #[cfg(not(coverage))] // unreachable: statically valid literal
1331    let Ok(compiled) = PyRegex::compile(TEMPLATE_URL_TRIMMED, true) else {
1332        return Vec::new();
1333    };
1334    #[cfg(coverage)]
1335    let compiled = PyRegex::compile(TEMPLATE_URL_TRIMMED, true).expect("statically valid literal");
1336    compiled
1337        .re()
1338        .find_iter(haystack)
1339        .map(|m| Candidate::new(m.start(), m.end()))
1340        .collect()
1341}
1342
1343// ---------------------------------------------------------------------------
1344// Glued backtick candidate (lookbehind guard)
1345// ---------------------------------------------------------------------------
1346
1347/// `(?<!\x60)\x60(?:[A-Za-z0-9_./~]|\$[({])(?:[^\x60\\\n]|\\.)*\x60`
1348#[must_use]
1349pub fn glued_backtick_candidate_finditer(haystack: &str) -> Vec<Candidate> {
1350    #[cfg(not(coverage))] // unreachable: statically valid literal
1351    let Ok(re) = PyRegex::compile(r"`(?:[A-Za-z0-9_./~]|\$[({])(?:[^`\\\n]|\\.)*`", false) else {
1352        return Vec::new();
1353    };
1354    #[cfg(coverage)]
1355    let re = PyRegex::compile(r"`(?:[A-Za-z0-9_./~]|\$[({])(?:[^`\\\n]|\\.)*`", false)
1356        .expect("statically valid literal");
1357    guarded_finditer(haystack, re.re(), |text, candidate| {
1358        candidate.start == 0 || char_before(text, candidate.start).is_none_or(|(_, c)| c != '`')
1359    })
1360}
1361
1362// ---------------------------------------------------------------------------
1363// XSS event-handler attribute walker
1364// ---------------------------------------------------------------------------
1365
1366/// The frozen 2026-08-20 `_HTML_EVENT_HANDLER_ATTRS` set, in reference order
1367/// (extracted verbatim from the canonical pattern source).
1368pub const HTML_EVENT_HANDLER_ATTRS: &[&str] = &[
1369    "onwebkitplaybacktargetavailabilitychanged",
1370    "oncontentvisibilityautostatechange",
1371    "onwebkitpresentationmodechanged",
1372    "onwebkitmouseforcewillbegin",
1373    "onwebkitanimationiteration",
1374    "onsecuritypolicyviolation",
1375    "onwebkitmouseforcechanged",
1376    "onvalidationstatuschange",
1377    "onwebkitfullscreenchange",
1378    "onwebkitwillrevealbottom",
1379    "onwebkitanimationstart",
1380    "onwebkitmouseforcedown",
1381    "onbeforescriptexecute",
1382    "onmozfullscreenchange",
1383    "onwebkittransitionend",
1384    "onafterscriptexecute",
1385    "onanimationiteration",
1386    "onlostpointercapture",
1387    "onscrollsnapchanging",
1388    "onunhandledrejection",
1389    "onwebkitanimationend",
1390    "onwebkitmouseforceup",
1391    "ondeviceorientation",
1392    "ongotpointercapture",
1393    "onbeforedeactivate",
1394    "onfullscreenchange",
1395    "onpointerrawupdate",
1396    "onreadystatechange",
1397    "onrejectionhandled",
1398    "onscrollsnapchange",
1399    "ontransitioncancel",
1400    "onanimationcancel",
1401    "onbeforeeditfocus",
1402    "oncontextrestored",
1403    "ondatasetcomplete",
1404    "onselectionchange",
1405    "ontransitionstart",
1406    "onanimationstart",
1407    "onbeforeactivate",
1408    "oncanplaythrough",
1409    "ondatasetchanged",
1410    "ondurationchange",
1411    "onlanguagechange",
1412    "onlayoutcomplete",
1413    "onloadedmetadata",
1414    "onpropertychange",
1415    "oncontrolselect",
1416    "ondataavailable",
1417    "ongesturechange",
1418    "onmediacomplete",
1419    "onpointercancel",
1420    "onpromptdismiss",
1421    "ontransitionend",
1422    "ontransitionrun",
1423    "onwebkitneedkey",
1424    "onanimationend",
1425    "onbeforetoggle",
1426    "onbeforeunload",
1427    "onbeforeupdate",
1428    "ondevicemotion",
1429    "onfilterchange",
1430    "ongesturestart",
1431    "onmessageerror",
1432    "onpointerenter",
1433    "onpointerleave",
1434    "onpromptaction",
1435    "onsyncrestored",
1436    "onvolumechange",
1437    "onafterupdate",
1438    "onbeforeinput",
1439    "onbeforematch",
1440    "onbeforepaste",
1441    "onbeforeprint",
1442    "oncontextlost",
1443    "oncontextmenu",
1444    "onerrorupdate",
1445    "onlosecapture",
1446    "onpointerdown",
1447    "onpointermove",
1448    "onpointerover",
1449    "onresizestart",
1450    "onrowinserted",
1451    "onselectstart",
1452    "ontouchcancel",
1453    "ontrackchange",
1454    "onafterprint",
1455    "onbeforecopy",
1456    "oncellchange",
1457    "ondeactivate",
1458    "ongestureend",
1459    "onhashchange",
1460    "onloadeddata",
1461    "onmediaerror",
1462    "onmouseenter",
1463    "onmouseleave",
1464    "onmousewheel",
1465    "onpagereveal",
1466    "onpointerout",
1467    "onratechange",
1468    "onslotchange",
1469    "ontimeupdate",
1470    "ontouchstart",
1471    "onbeforecut",
1472    "oncuechange",
1473    "ondragenter",
1474    "ondragleave",
1475    "ondragstart",
1476    "onloadstart",
1477    "onmousedown",
1478    "onmousemove",
1479    "onmouseover",
1480    "onmovestart",
1481    "onoutofsync",
1482    "onpointerup",
1483    "onresizeend",
1484    "onrowdelete",
1485    "onrowsenter",
1486    "onscrollend",
1487    "ontimeerror",
1488    "ontouchmove",
1489    "onactivate",
1490    "onauxclick",
1491    "ondblclick",
1492    "ondragdrop",
1493    "ondragexit",
1494    "ondragover",
1495    "onfocusout",
1496    "onformdata",
1497    "onkeypress",
1498    "onlocation",
1499    "onmouseout",
1500    "onpagehide",
1501    "onpageshow",
1502    "onpageswap",
1503    "onpopstate",
1504    "onprogress",
1505    "ontouchend",
1506    "oncanplay",
1507    "oncommand",
1508    "ondragend",
1509    "onemptied",
1510    "onfocusin",
1511    "oninvalid",
1512    "onkeydown",
1513    "onmessage",
1514    "onmouseup",
1515    "onmoveend",
1516    "onoffline",
1517    "onplaying",
1518    "onreverse",
1519    "onrowexit",
1520    "onseeking",
1521    "onstalled",
1522    "onstorage",
1523    "onsuspend",
1524    "onurlflip",
1525    "onwaiting",
1526    "onbounce",
1527    "oncancel",
1528    "onchange",
1529    "onfinish",
1530    "ononline",
1531    "onrepeat",
1532    "onresize",
1533    "onresume",
1534    "onscroll",
1535    "onsearch",
1536    "onseeked",
1537    "onselect",
1538    "onsubmit",
1539    "ontoggle",
1540    "onunload",
1541    "onabort",
1542    "onbegin",
1543    "onclick",
1544    "onclose",
1545    "onended",
1546    "onerror",
1547    "onfocus",
1548    "oninput",
1549    "onkeyup",
1550    "onpaste",
1551    "onpause",
1552    "onreset",
1553    "onstart",
1554    "onwheel",
1555    "onblur",
1556    "oncopy",
1557    "ondrag",
1558    "ondrop",
1559    "onhelp",
1560    "onload",
1561    "onmove",
1562    "onplay",
1563    "onredo",
1564    "onseek",
1565    "onstop",
1566    "onundo",
1567    "oncut",
1568    "onend",
1569];
1570
1571/// ASCII case-folded `starts_with` at a byte position: the reference's builtin
1572/// `re.IGNORECASE` folding for the ASCII literals of the structural rows.
1573fn ascii_starts_with_ignore_case(haystack: &str, pos: usize, needle: &str) -> bool {
1574    let bytes = haystack.as_bytes();
1575    let Some(window) = bytes.get(pos..pos + needle.len()) else {
1576        return false;
1577    };
1578    window
1579        .iter()
1580        .zip(needle.bytes())
1581        .all(|(a, b)| a.eq_ignore_ascii_case(&b))
1582}
1583
1584/// The value part: `\s*=\s{0,20}(?:["'][^"']*["']|[^\s>]+)`, returning the end.
1585fn event_handler_value_end(haystack: &str, pos: usize) -> Option<usize> {
1586    let mut cursor = walk_forward_while(haystack, pos, char::is_whitespace);
1587    if char_at(haystack, cursor).map(|(_, c)| c) != Some('=') {
1588        return None;
1589    }
1590    cursor = char_at(haystack, cursor).map_or(cursor, |(i, c)| i + c.len_utf8());
1591    // `\s{0,20}`
1592    let mut taken = 0usize;
1593    while taken < 20 && char_at(haystack, cursor).is_some_and(|(_, c)| c.is_whitespace()) {
1594        cursor = char_at(haystack, cursor).map_or(cursor, |(i, c)| i + c.len_utf8());
1595        taken += 1;
1596    }
1597    match char_at(haystack, cursor).map(|(_, c)| c) {
1598        Some(quote @ ('"' | '\'')) => {
1599            // `["'][^"']*["']`: the body excludes BOTH quote characters and
1600            // the FIRST quote encountered ends the value (either flavor)
1601            let mut scan = cursor + quote.len_utf8();
1602            while let Some((i, c)) = char_at(haystack, scan) {
1603                if c == '"' || c == '\'' {
1604                    return Some(i + c.len_utf8());
1605                }
1606                scan = i + c.len_utf8();
1607            }
1608            None
1609        }
1610        Some(_) => {
1611            let end = walk_forward_while(haystack, cursor, |c| !c.is_whitespace() && c != '>');
1612            (end > cursor).then_some(end)
1613        }
1614        None => None,
1615    }
1616}
1617
1618/// `(?<!=)(?<!=\")(?<!=')` evaluated at the whitespace/slash run start: the
1619/// 1-char lookbehind rejects a bare `=`; the 2-char lookbehinds reject the
1620/// sequences `="` and `='`.
1621fn event_handler_lookbehind_ok(haystack: &str, run_start: usize) -> bool {
1622    #[cfg(not(coverage))] // unreachable: the run always starts past the tag-open letter
1623    let Some((c1_pos, c1)) = char_before(haystack, run_start) else {
1624        return true; // nothing before the run: the lookbehinds pass trivially
1625    };
1626    #[cfg(coverage)]
1627    let (c1_pos, c1) = char_before(haystack, run_start).expect("run past the tag-open letter");
1628    if c1 == '=' {
1629        return false;
1630    }
1631    if c1 == '"' || c1 == '\'' {
1632        let quoted_after_equals = char_before(haystack, c1_pos).is_some_and(|(_, c2)| c2 == '=');
1633        if quoted_after_equals {
1634            return false;
1635        }
1636    }
1637    true
1638}
1639
1640/// The maximal whitespace/slash run ending at/just before `run_start`.
1641fn event_handler_run_end(haystack: &str, run_start: usize) -> usize {
1642    let mut pos = run_start;
1643    while char_at(haystack, pos).is_some_and(|(_, rc)| rc.is_whitespace() || rc == '/') {
1644        pos = char_at(haystack, pos).map_or(haystack.len(), |(i, rc)| i + rc.len_utf8());
1645    }
1646    pos
1647}
1648
1649/// The monster pattern:
1650/// `(?:<[A-Za-z/](?:[^<>]*[^<>\s/])?(?<!=)(?<!=\")(?<!=')[\s/]+(?:NAMES)\s*=\s{0,20}(?:["'][^"']*["']|[^\s>]+))`.
1651///
1652/// The three lookbehinds sit between the attribute group and the whitespace
1653/// run, and the group is greedy: the engine tries the RIGHTMOST run split
1654/// first (longest attribute group) and backtracks leftward, with the
1655/// group-absent split (right after the tag-open letter) last. Each split's
1656/// guard is a 1-2 char sequence check on the characters before the run.
1657#[must_use]
1658pub fn xss_event_handler_finditer(haystack: &str) -> Vec<Candidate> {
1659    #[cfg(not(coverage))] // unreachable: statically valid literal
1660    let Ok(tag_open) = PyRegex::compile(r"<[A-Za-z/]", false) else {
1661        return Vec::new();
1662    };
1663    #[cfg(coverage)]
1664    let tag_open = PyRegex::compile(r"<[A-Za-z/]", false).expect("statically valid literal");
1665    let mut matches = Vec::new();
1666    let mut resume = 0usize;
1667    for open in tag_open.re().find_iter(haystack) {
1668        if open.start() < resume {
1669            continue;
1670        }
1671        // whitespace/slash run starts in the attribute region (bounded by the
1672        // next angle bracket), leftmost-first
1673        let mut runs: Vec<usize> = Vec::new();
1674        let mut cursor = open.end();
1675        let mut open_run: Option<usize> = None;
1676        while let Some((offset, c)) = char_at(haystack, cursor) {
1677            if c == '<' || c == '>' {
1678                break;
1679            }
1680            if c.is_whitespace() || c == '/' {
1681                if open_run.is_none() {
1682                    open_run = Some(offset);
1683                }
1684            } else if let Some(r) = open_run.take() {
1685                runs.push(r);
1686            }
1687            cursor = offset + c.len_utf8();
1688        }
1689        if let Some(r) = open_run {
1690            runs.push(r);
1691        }
1692        // the greedy attribute group tries the longest run first
1693        runs.reverse();
1694        for r in runs {
1695            let run_end = event_handler_run_end(haystack, r);
1696            if event_handler_lookbehind_ok(haystack, r)
1697                && let Some(end) = try_event_name_value(haystack, run_end)
1698            {
1699                matches.push(Candidate::new(open.start(), end));
1700                resume = end;
1701                break;
1702            }
1703        }
1704    }
1705    matches
1706}
1707
1708fn try_event_name_value(haystack: &str, name_pos: usize) -> Option<usize> {
1709    for name in HTML_EVENT_HANDLER_ATTRS {
1710        if ascii_starts_with_ignore_case(haystack, name_pos, name) {
1711            let after_name = name_pos + name.len();
1712            if let Some(end) = event_handler_value_end(haystack, after_name) {
1713                return Some(end);
1714            }
1715        }
1716    }
1717    None
1718}
1719
1720// helper re-export for the scan-window module users
1721#[must_use]
1722pub fn rfind_in(haystack: &str, needle: &str, start: usize, end: usize) -> Option<usize> {
1723    str_rfind_in(haystack, needle, start, end)
1724}
1725
1726#[cfg(test)]
1727mod tests {
1728    use super::*;
1729
1730    #[test]
1731    fn select_from_first_reachable_from() {
1732        let ms = sqli_select_from_finditer("SELECT a FROM b");
1733        assert_eq!(ms.len(), 1);
1734        // the lazy loop stops at the first `\bFROM\b`
1735        assert_eq!(ms[0].text("SELECT a FROM b"), "SELECT a FROM");
1736    }
1737
1738    #[test]
1739    fn select_from_blocked_by_second_select() {
1740        // the first scan is blocked by the second SELECT, but finditer
1741        // rescans from the next position and finds `SELECT FROM`
1742        let ms = sqli_select_from_finditer("SELECT x SELECT FROM");
1743        assert_eq!(ms.len(), 1);
1744        assert_eq!(ms[0].start, 9);
1745        assert_eq!(ms[0].text("SELECT x SELECT FROM"), "SELECT FROM");
1746    }
1747
1748    #[test]
1749    fn tautology_requires_equal_atoms() {
1750        let ms = sqli_tautology_finditer("' OR 1=1--");
1751        assert_eq!(ms.len(), 1);
1752        assert_eq!(ms[0].text("' OR 1=1--"), "OR 1=1");
1753        assert!(sqli_tautology_finditer("' OR 1=2--").is_empty());
1754    }
1755
1756    #[test]
1757    fn ssrf_private_requires_scheme_for_userinfo() {
1758        let ms = ssrf_private_host_finditer("http://10.0.0.1/shell.txt");
1759        assert_eq!(ms.len(), 1);
1760        assert_eq!(ms[0].text("http://10.0.0.1/shell.txt"), "/10.0.0.1/");
1761        // bare host without scheme context still fires via whitespace prefix
1762        let ms = ssrf_private_host_finditer("| nc 10.0.0.1 4444");
1763        assert_eq!(ms.len(), 1);
1764    }
1765
1766    #[test]
1767    fn ssrf_numeric_guard() {
1768        let ms = ssrf_numeric_host_finditer("curl http://10.0.0.1/x.sh");
1769        assert_eq!(ms.len(), 1);
1770        assert_eq!(ms[0].text("curl http://10.0.0.1/x.sh"), "://10.0.0.1");
1771        // terminator must follow the host
1772        assert!(ssrf_numeric_host_finditer("curl http://10.0.0.1x/").is_empty());
1773    }
1774
1775    #[test]
1776    fn shell_dash_flag_lookahead() {
1777        let ms = shell_dash_flag_finditer("bash -c \"id\"");
1778        assert_eq!(ms.len(), 1);
1779        assert!(shell_dash_flag_finditer("bash -c \"id\" x tail").is_empty());
1780    }
1781
1782    #[test]
1783    fn sensitive_env_path() {
1784        let ms = sensitive_path_env("/.env");
1785        assert_eq!(ms.len(), 1);
1786        // `.env.local` is itself the BAD alternative (`.env` + `.\w+`)
1787        let ms = sensitive_path_env("/app/.env.local/config");
1788        assert_eq!(ms.len(), 1);
1789        let ms = sensitive_path_env("/app/.env");
1790        assert_eq!(ms.len(), 1);
1791        // a dot with no word after it falls back to the bare `.env` arm
1792        assert_eq!(env_bad("/app/.env./x", 5), vec![9]);
1793        assert_eq!(env_bad("/app/.env.local", 5), vec![15, 9]);
1794    }
1795
1796    #[test]
1797    fn sensitive_wp_admin_path() {
1798        assert_eq!(sensitive_path_wp_admin("/wp-login.php").len(), 1);
1799        assert_eq!(
1800            sensitive_path_wp_admin("/wp-admin/setup-config.php").len(),
1801            1
1802        );
1803        assert!(sensitive_path_wp_admin("/admin/login/?next=/admin/").is_empty());
1804    }
1805
1806    #[test]
1807    fn sensitive_backup_path() {
1808        assert_eq!(
1809            sensitive_path_scan_ext("/config.php.bak", BACKUP_EXTENSIONS).len(),
1810            1
1811        );
1812        assert!(sensitive_path_scan_ext("/config.php", BACKUP_EXTENSIONS).is_empty());
1813    }
1814
1815    #[test]
1816    fn management_requires_leading_separator() {
1817        assert_eq!(sensitive_path_management("/.aws/credentials").len(), 1);
1818        assert!(sensitive_path_management(".aws/credentials").is_empty());
1819    }
1820
1821    #[test]
1822    fn deserialization_magic_boundary() {
1823        assert_eq!(deserialization_b64_finditer("rO0ABXNy", "rO0AB").len(), 1);
1824        assert!(deserialization_b64_finditer("BASE64rO0AB", "rO0AB").is_empty());
1825        // `/` and `+` are mid-token base64 alphabet: no boundary, no match
1826        assert!(deserialization_b64_finditer("/rO0AB", "rO0AB").is_empty());
1827        assert!(deserialization_b64_finditer("+rO0AB", "rO0AB").is_empty());
1828        // any other non-alphanumeric boundary passes
1829        assert_eq!(deserialization_b64_finditer("x rO0AB", "rO0AB").len(), 1);
1830    }
1831
1832    #[test]
1833    fn event_handler_matches_unquoted() {
1834        let ms = xss_event_handler_finditer("<img src=x onerror=alert(1)>");
1835        assert_eq!(ms.len(), 1);
1836        assert_eq!(
1837            ms[0].text("<img src=x onerror=alert(1)>"),
1838            "<img src=x onerror=alert(1)"
1839        );
1840    }
1841
1842    #[test]
1843    fn event_handler_slash_separated() {
1844        let ms = xss_event_handler_finditer("/search?q=<svg/onload=alert(1)>");
1845        assert_eq!(ms.len(), 1);
1846        assert_eq!(ms[0].start, 10);
1847    }
1848
1849    #[test]
1850    fn event_handler_quoted_attr_value_still_matches() {
1851        // oracle-verified: the lookbehinds reject the SEQUENCES `=`/`="/`='`
1852        // before the run; a bare `"` before the run does not block, and the
1853        // greedy group picks the rightmost split (`onerror`)
1854        let ms = xss_event_handler_finditer("<img src=\"x\" onerror=alert(1)>");
1855        assert_eq!(ms.len(), 1);
1856        assert_eq!(
1857            ms[0].text("<img src=\"x\" onerror=alert(1)>"),
1858            "<img src=\"x\" onerror=alert(1)"
1859        );
1860    }
1861
1862    #[test]
1863    fn event_handler_greedy_group_picks_rightmost_split() {
1864        // oracle-verified: the greedy attribute group tries the rightmost
1865        // run first, so the second handler wins the match
1866        let text = "<a b onclick=x c onload=y>";
1867        let ms = xss_event_handler_finditer(text);
1868        assert_eq!(ms.len(), 1);
1869        assert_eq!(ms[0].text(text), "<a b onclick=x c onload=y");
1870    }
1871
1872    #[test]
1873    fn event_handler_equals_sequence_lookbehind_blocks() {
1874        // `="` directly before the run: the 2-char lookbehind blocks, and no
1875        // other split can rescue the match
1876        assert!(xss_event_handler_finditer("<a =\" onclick=x>").is_empty());
1877        // a bare `=` directly before the run: the 1-char lookbehind blocks
1878        assert!(xss_event_handler_finditer("<img src= onerror=x>").is_empty());
1879        // oracle-verified: `=x` before the run does NOT block (rightmost
1880        // split sees `x`, not `=`)
1881        let ms = xss_event_handler_finditer("<img =x onerror=alert(1)>");
1882        assert_eq!(ms.len(), 1);
1883        assert_eq!(
1884            ms[0].text("<img =x onerror=alert(1)>"),
1885            "<img =x onerror=alert(1)"
1886        );
1887    }
1888
1889    #[test]
1890    fn event_handler_nested_quote_value_ends_at_first_quote() {
1891        // the value body excludes both quote flavors, and the first quote
1892        // encountered closes the value (either flavor): `"alert('`
1893        let ms = xss_event_handler_finditer("<div onmouseover=\"alert('x')\">hover</div>");
1894        assert_eq!(ms.len(), 1);
1895        assert_eq!(
1896            ms[0].text("<div onmouseover=\"alert('x')\">hover</div>"),
1897            "<div onmouseover=\"alert('"
1898        );
1899    }
1900
1901    #[test]
1902    fn inline_comment_guard() {
1903        let ms = sqli_inline_comment_finditer("UNI/**/ON SE/**/LECT");
1904        assert_eq!(ms.len(), 2);
1905        assert!(sqli_inline_comment_finditer("UNI/*!*/ON").is_empty());
1906    }
1907
1908    #[test]
1909    fn backtick_candidate_guard() {
1910        let compiled =
1911            PyRegex::compile(r"`(?:[A-Za-z0-9_./~]|\$[({])(?:[^`\\\n]|\\.)*`", false).unwrap();
1912        let ms = glued_backtick_candidate_finditer("a`id`b");
1913        assert_eq!(ms.len(), 1);
1914        let _ = compiled;
1915        assert!(glued_backtick_candidate_finditer("``id`").is_empty());
1916    }
1917
1918    #[test]
1919    fn terminator_guard_accepts_terminators_end_and_trailing_newline() {
1920        let guard = |c: char| c == ';';
1921        // end of text and the terminator itself pass
1922        assert!(terminator_guard("abc", 3, guard));
1923        assert!(terminator_guard("ab;c", 2, guard));
1924        // Python `$`: one newline immediately before the end is an anchor
1925        assert!(terminator_guard("ab;\n", 3, guard));
1926        // a newline with content after it is not
1927        assert!(!terminator_guard("ab;\ncd", 3, guard));
1928    }
1929
1930    #[test]
1931    fn secrets_run_bad_accepts_the_plural_stems() {
1932        assert_eq!(sensitive_path_secrets("/x/secret.txt").len(), 1);
1933        assert_eq!(sensitive_path_secrets("/x/secrets.txt").len(), 1);
1934        assert_eq!(sensitive_path_secrets("/x/credentials.json").len(), 1);
1935    }
1936
1937    #[test]
1938    fn match_span_truncates_overshoots_and_rejects_bad_bounds() {
1939        let re = PyRegex::compile(r"ab?", false).unwrap();
1940        // start past end
1941        assert!(match_span(re.re(), "abab", 3, 2).is_none());
1942        // the greedy match overshoots `end`: the truncated retry wins
1943        let m = match_span(re.re(), "ab", 0, 1).expect("truncated match");
1944        assert_eq!((m.start, m.end), (0, 1));
1945        // the truncated retry cannot match either
1946        assert!(match_span(re.re(), "ab", 1, 1).is_none());
1947        // the truncated window has no room for the whole pattern at all
1948        assert!(match_span(PyRegex::compile(r"ab", false).unwrap().re(), "ab", 0, 1).is_none());
1949        // no match at `start` at all
1950        assert!(match_span(PyRegex::compile(r"zz", false).unwrap().re(), "ab", 0, 2).is_none());
1951        // a match found only after `start` is not an anchor
1952        assert!(match_span(PyRegex::compile(r"b", false).unwrap().re(), "ab", 0, 2).is_none());
1953        // the truncated retry can only anchor at `start` (or not match)
1954        let alt = PyRegex::compile(r"aba|b", false).unwrap();
1955        assert!(match_span(alt.re(), "aba", 0, 2).is_none());
1956    }
1957
1958    #[test]
1959    fn shell_dash_c_skips_prefixes_swallowed_by_tokens_and_matches() {
1960        let compiled = PyRegex::compile(r"\n[^\S\r\n]*evil\s+-c", false).unwrap();
1961        // the row matches right at the newline prefix
1962        let ms = shell_dash_c_finditer("\n  evil -c id", &compiled);
1963        assert_eq!(ms.len(), 1);
1964        assert_eq!(ms[0].text("\n  evil -c id"), "\n  evil -c");
1965        // no newline prefix, no candidate
1966        assert!(shell_dash_c_finditer("evil -c id", &compiled).is_empty());
1967        // token runs extend the resume point past a failed row match
1968        assert!(shell_dash_c_finditer("\n  X=1  Y=2 evil -c id", &compiled).is_empty());
1969        // a later prefix swallowed by the previous resume point is skipped
1970        assert!(shell_dash_c_finditer("\n x=1\n y=2 evil", &compiled).is_empty());
1971    }
1972
1973    #[test]
1974    fn shell_dash_c_finds_the_row_after_newline_indent() {
1975        let compiled = PyRegex::compile(r"\n[^\S\r\n]*evil\s+-c", false).unwrap();
1976        let ms = shell_dash_c_finditer("\n  evil -c id", &compiled);
1977        assert_eq!(ms.len(), 1);
1978        assert_eq!(ms[0].text("\n  evil -c id"), "\n  evil -c");
1979        // no newline prefix, no candidate
1980        assert!(shell_dash_c_finditer("evil -c id", &compiled).is_empty());
1981    }
1982
1983    #[test]
1984    fn ldap_null_byte_attr_skips_names_the_pattern_cannot_anchor() {
1985        let compiled = PyRegex::compile(r"uidabcdef", false).unwrap();
1986        // the attribute name carries no match for the compiled pattern, so
1987        // the anchored window match fails and the tail row is dropped
1988        assert!(ldap_null_byte_attr_raw("(uidlong=*)%00", &compiled).is_empty());
1989    }
1990
1991    #[test]
1992    fn ldap_null_byte_attr_walks_back_to_the_attribute() {
1993        let compiled = PyRegex::compile(r"uid", false).unwrap();
1994        let ms = ldap_null_byte_attr_raw("(uid=*)%00", &compiled);
1995        assert_eq!(ms.len(), 1);
1996        assert_eq!(ms[0].start, 1);
1997        assert_eq!(ms[0].end, 4);
1998        let ms = ldap_null_byte_attr_decoded("(uid=*)\0", &compiled);
1999        assert_eq!(ms.len(), 1);
2000        assert_eq!(ms[0].start, 1);
2001    }
2002
2003    #[test]
2004    fn ldap_null_byte_attr_rejects_non_attribute_prefixes() {
2005        let compiled = PyRegex::compile(r"uid", false).unwrap();
2006        // no `*` + `)` pair at all
2007        assert!(ldap_null_byte_attr_raw("(uid=x)", &compiled).is_empty());
2008        // nothing word-like before the star run
2009        assert!(ldap_null_byte_attr_raw("*)%00", &compiled).is_empty());
2010        // the character before the value run is not `=`
2011        assert!(ldap_null_byte_attr_raw("-a*)%00", &compiled).is_empty());
2012        // no attribute name between a boundary and the `=`
2013        assert!(ldap_null_byte_attr_raw("=*)%00", &compiled).is_empty());
2014        // the attribute name does not start with a letter
2015        assert!(ldap_null_byte_attr_raw("1a=*)%00", &compiled).is_empty());
2016    }
2017
2018    #[test]
2019    fn quote_splice_resumes_past_failed_candidates() {
2020        let compiled = PyRegex::compile(r"zzz", false).unwrap();
2021        assert!(quote_splice_finditer("ab\"cd", &compiled).is_empty());
2022    }
2023
2024    #[test]
2025    fn quote_splice_matches_word_then_quote_and_skips_consumed_quotes() {
2026        // a quote splicing a word onto the row pattern matches
2027        let compiled = PyRegex::compile(r"ab", false).unwrap();
2028        let ms = quote_splice_finditer("ab\"cd", &compiled);
2029        assert_eq!(ms.len(), 1);
2030        assert_eq!(ms[0].text("ab\"cd"), "ab");
2031        // a quote inside the previous match's span is skipped
2032        let greedy = PyRegex::compile(r"ab.+", false).unwrap();
2033        let ms = quote_splice_finditer("ab\"cd\"ef", &greedy);
2034        assert_eq!(ms.len(), 1);
2035        assert_eq!(ms[0].text("ab\"cd\"ef"), "ab\"cd\"ef");
2036        // a quote at end of input has no word after it
2037        assert!(quote_splice_finditer("ab\"", &compiled).is_empty());
2038        // a quote not preceded by a word character cannot splice
2039        assert!(
2040            quote_splice_finditer(" \"cd", &PyRegex::compile(r"cd", false).unwrap()).is_empty()
2041        );
2042    }
2043
2044    #[test]
2045    fn ssrf_private_userinfo_requires_scheme_context() {
2046        // userinfo present but not directly after `://`: rejected
2047        assert!(ssrf_private_host_finditer("evil@10.0.0.1/x").is_empty());
2048        // userinfo directly after `://`: accepted
2049        let ms = ssrf_private_host_finditer("http://user@10.0.0.1/x");
2050        assert_eq!(ms.len(), 1);
2051    }
2052
2053    #[test]
2054    fn ssrf_numeric_host_accepts_end_of_text_terminator() {
2055        let ms = ssrf_numeric_host_finditer("curl http://10.0.0.1");
2056        assert_eq!(ms.len(), 1);
2057        assert_eq!(ms[0].text("curl http://10.0.0.1"), "://10.0.0.1");
2058    }
2059
2060    #[test]
2061    fn select_from_requires_word_boundaries() {
2062        // SELECT glued to a word character is never a loop start
2063        assert!(sqli_select_from_finditer("xSELECT a FROM b").is_empty());
2064        // FROM must also sit on its own word boundary
2065        assert!(sqli_select_from_finditer("SELECT a xFROM b").is_empty());
2066    }
2067
2068    #[test]
2069    fn tautology_backtracks_numeric_atoms_digit_by_digit() {
2070        // neither 12 nor the backtracked 1 completes `12=1`
2071        assert!(sqli_tautology_finditer("1 OR 12=1").is_empty());
2072        // a word character after the second atom kills the boundary
2073        assert!(sqli_tautology_finditer("' OR 1=1x").is_empty());
2074    }
2075
2076    #[test]
2077    fn tautology_swallows_candidates_inside_completed_matches() {
2078        // the completed `='a OR 1'` comparison ends past the inner OR/AND
2079        // candidate, so the engine skips it (the reference backtracks the same
2080        // way: the whole span is one match)
2081        let ms = sqli_tautology_finditer("' OR 'a OR 1'='a OR 1'x");
2082        assert_eq!(ms.len(), 1);
2083        assert_eq!(ms[0].start, 2);
2084        assert_eq!(ms[0].end, 22);
2085    }
2086
2087    #[test]
2088    fn sensitive_path_tails_accept_query_strings() {
2089        assert_eq!(sensitive_path_env("/.env?token=abc").len(), 1);
2090    }
2091
2092    #[test]
2093    fn sensitive_paths_walk_segments_and_reject_bad_tails() {
2094        // the segment loop walks several directories before the BAD literal
2095        assert_eq!(sensitive_path_management("/x/.aws/credentials").len(), 1);
2096        // the required shape without a leading separator never matches
2097        assert!(sensitive_path_management("x/.aws/credentials").is_empty());
2098        // a BAD literal whose tail does not end the path is rejected
2099        assert!(sensitive_path_wp_admin("/wp-admin.X").is_empty());
2100        // the same tail rejection on the boxed-literal rows
2101        assert!(sensitive_path_management(".aws/credentialsX").is_empty());
2102    }
2103
2104    #[test]
2105    fn literal_suffix_variants_cover_yaml_dot_and_dash_runs() {
2106        assert_eq!(
2107            sensitive_path_literal_suffix("/site.yml", &["site"], BadSuffix::YamlSuffix).len(),
2108            1
2109        );
2110        assert_eq!(
2111            sensitive_path_literal_suffix("/site.yaml", &["site"], BadSuffix::YamlSuffix).len(),
2112            1
2113        );
2114        // the optional suffix group stays absent when no dot follows
2115        assert!(
2116            sensitive_path_literal_suffix("/site:8080", &["site"], BadSuffix::YamlSuffix)
2117                .is_empty()
2118        );
2119        assert_eq!(
2120            sensitive_path_literal_suffix_dot("/app.settings", &["app"]).len(),
2121            1
2122        );
2123        assert_eq!(
2124            sensitive_path_literal_suffix("/secret-backup", &["secret"], BadSuffix::DashRun).len(),
2125            1
2126        );
2127    }
2128
2129    #[test]
2130    fn lexicon_paths_require_the_lexicon_word_on_the_first_line() {
2131        let shape = r"\A[^\n]*/etc/passwd";
2132        assert_eq!(
2133            lexicon_path_finditer("scanner found /etc/passwd", shape, true).len(),
2134            1
2135        );
2136        assert!(lexicon_path_finditer("nothing here /etc/passwd", shape, true).is_empty());
2137        // the lexicon gate is skipped entirely when not required
2138        assert_eq!(
2139            lexicon_path_finditer("nothing here /etc/passwd", shape, false).len(),
2140            1
2141        );
2142    }
2143
2144    #[test]
2145    fn order_by_terminator_accepts_both_alternatives() {
2146        let ms = sqli_order_by_terminator_finditer("ORDER BY 1 -- comment");
2147        assert_eq!(ms.len(), 1);
2148        // the lookbehind-guarded newline alternative fires after `=` and the
2149        // two candidates are returned sorted by (start, end descending)
2150        let ms = sqli_order_by_terminator_finditer("x=ORDER BY 1\n");
2151        assert_eq!(ms.len(), 2);
2152        assert_eq!(ms[0].start, 2);
2153        assert_eq!(ms[1].start, 2);
2154    }
2155
2156    #[test]
2157    fn file_inclusion_scheme_path_guard_rejects_trailing_alnum() {
2158        let ms = file_inclusion_scheme_path_finditer("=http://h/a.php");
2159        assert_eq!(ms.len(), 1);
2160        // the extension runs on into alphanumerics: the suffix guard rejects
2161        assert!(file_inclusion_scheme_path_finditer("=http://h/a.php3x").is_empty());
2162    }
2163
2164    #[test]
2165    fn file_inclusion_template_url_matches_quoted_keys() {
2166        let hay = "\"tpl\" : \"http://h/x.jsp\"";
2167        let ms = file_inclusion_template_url_finditer(hay);
2168        assert_eq!(ms.len(), 1);
2169        assert_eq!(ms[0].text(hay), "\"tpl\" : \"http://h/x.jsp\"");
2170    }
2171
2172    #[test]
2173    fn event_handler_value_shapes_without_a_value_do_not_match() {
2174        // no `=` after the handler name
2175        assert!(xss_event_handler_finditer("<img onclick").is_empty());
2176        // unterminated quoted value
2177        assert!(xss_event_handler_finditer("<a onclick=\"unterminated").is_empty());
2178        // end of input right after the `=`
2179        assert!(xss_event_handler_finditer("<a onclick=").is_empty());
2180    }
2181
2182    #[test]
2183    fn event_handler_value_takes_up_to_twenty_whitespace_chars() {
2184        let ms = xss_event_handler_finditer("<a onclick=                    x>");
2185        assert_eq!(ms.len(), 1);
2186        // the 21st whitespace char falls outside `\s{0,20}`: the unquoted
2187        // value would be empty, so nothing matches
2188        assert!(xss_event_handler_finditer("<a onclick=                     x>").is_empty());
2189    }
2190
2191    #[test]
2192    fn event_handler_skips_tag_opens_inside_consumed_matches() {
2193        // the `<img` open sits inside the consumed quoted value
2194        let ms = xss_event_handler_finditer("<div onmouseover=\"<img src=x> onload=y\">");
2195        assert_eq!(ms.len(), 1);
2196        assert_eq!(ms[0].start, 0);
2197    }
2198
2199    #[test]
2200    fn event_handler_trailing_whitespace_run_still_scans_the_run() {
2201        // the whitespace run runs into the closing bracket: the rightmost-run
2202        // split is tried with no attribute name there, so nothing matches
2203        assert!(xss_event_handler_finditer("<img src=x >").is_empty());
2204    }
2205
2206    #[test]
2207    fn rfind_in_searches_within_the_window() {
2208        assert_eq!(rfind_in("abcabc", "abc", 0, 6), Some(3));
2209        assert_eq!(rfind_in("abcabc", "abc", 0, 3), Some(0));
2210        assert_eq!(rfind_in("abcabc", "zz", 0, 6), None);
2211    }
2212}
2213
2214#[cfg(test)]
2215mod unit_twins {
2216    use super::*;
2217    use crate::patterns::pyregex::PyRegex;
2218
2219    #[test]
2220    fn a_misaligned_token_stops_the_env_prefix_walk() {
2221        // after the newline prefix the text holds `;a=b `: the first token
2222        // match starts past the resume point, so the anchor check abandons
2223        // the prefix and no candidate renders
2224        let compiled = PyRegex::compile(r"\n[^\S\r\n]*evil\s+-c", false).unwrap();
2225        assert!(shell_dash_c_finditer("\n;a=b ", &compiled).is_empty());
2226    }
2227
2228    #[test]
2229    fn a_query_tail_after_a_sensitive_path_scans_to_the_whitespace_edge() {
2230        // the extension ends before a `?query`: the tail consumes the query
2231        // and the candidate spans the whole string
2232        let hits = sensitive_path_scan_ext("/x/world.map?a=1", &["map"]);
2233        assert_eq!(hits.len(), 1, "the query tail keeps the match: {hits:?}");
2234        assert_eq!((hits[0].start, hits[0].end), (0, 16));
2235    }
2236}