1use regex::Regex;
15
16use super::chars_util::{char_at, char_before, py_is_word, str_rfind_in, walk_forward_while};
17use super::pyregex::{Candidate, PyRegex};
18use super::scan_window::bounded_finditer;
19
20fn guarded_finditer(
23 haystack: &str,
24 re: &Regex,
25 guard: impl Fn(&str, Candidate) -> bool,
26) -> Vec<Candidate> {
27 let mut out = Vec::new();
28 let mut from = 0usize;
29 while let Some(m) = re.find_at(haystack, from) {
30 let candidate = Candidate::new(m.start(), m.end());
31 if guard(haystack, candidate) {
32 out.push(candidate);
33 from = m.end().max(m.start() + 1);
36 } else {
37 from = m.start() + 1;
38 }
39 }
40 out
41}
42
43pub(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#[must_use]
74pub fn shell_dash_c_finditer(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
75 #[cfg(not(coverage))] 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))] 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
114const 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))] 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 #[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))] if star_pos < last_end {
143 continue;
144 }
145 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 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))] 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 #[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#[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#[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#[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#[must_use]
259pub fn load_file_scan_matches(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
260 #[cfg(not(coverage))] 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))] 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#[must_use]
277pub fn dollar_substitution_scan_matches(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
278 #[cfg(not(coverage))] 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))] 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))] 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))] 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#[must_use]
318pub fn glob_wildcard_scan_matches(haystack: &str, compiled: &PyRegex) -> Vec<Candidate> {
319 #[cfg(not(coverage))] 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
340const 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))] 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#[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
425const TAUTOLOGY_ATOM: &str = r#"(?:\d+|'[^']*'|"[^"]*"|[@:$][A-Za-z_]\w*)"#;
430
431#[must_use]
438pub fn sqli_tautology_finditer(haystack: &str) -> Vec<Candidate> {
439 #[cfg(not(coverage))] 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
467fn 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 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
487fn 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 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#[must_use]
527pub fn sqli_inline_comment_finditer(haystack: &str) -> Vec<Candidate> {
528 #[cfg(not(coverage))] 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 #[cfg(not(coverage))] 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
548const 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#[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))] 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 from = candidate.end.max(candidate.start + 1);
580 } else {
581 from = candidate.start + 1;
582 }
583 }
584 out
585}
586
587#[must_use]
595pub fn ssrf_numeric_host_finditer(haystack: &str) -> Vec<Candidate> {
596 #[cfg(not(coverage))] 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
616fn 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; };
622 if terminator(c) {
623 return true;
624 }
625 c == '\n' && pos + 1 == haystack.len()
627}
628
629const 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#[must_use]
640pub fn shell_dash_flag_finditer(haystack: &str) -> Vec<Candidate> {
641 #[cfg(not(coverage))] 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
661type BadMatcher<'a> = Box<dyn Fn(&str, usize) -> Vec<usize> + 'a>;
667
668#[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
691fn 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 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
740fn 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 if ascii_starts_with_ignore_case(haystack, pos, alt) {
750 ends.push(pos + alt.len());
751 }
752 }
753 ends
754 })
755}
756
757#[derive(Debug, Clone, Copy, PartialEq, Eq)]
759pub enum BadSuffix {
760 DashRun,
762 YamlSuffix,
764 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 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
811fn 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))] 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
839fn 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))] 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
880fn 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
912fn 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
928fn 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
943fn 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 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#[must_use]
1040pub fn sensitive_path_env(haystack: &str) -> Vec<Candidate> {
1041 sensitive_path_finditer(haystack, LeadingSep::Optional, env_bad)
1042}
1043
1044#[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#[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#[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#[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#[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#[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#[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#[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#[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#[must_use]
1121pub fn sensitive_path_config(haystack: &str) -> Vec<Candidate> {
1122 sensitive_path_finditer(haystack, LeadingSep::Optional, config_bad)
1123}
1124
1125const 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#[must_use]
1135pub fn lexicon_path_finditer(haystack: &str, shape: &str, require_lexicon: bool) -> Vec<Candidate> {
1136 #[cfg(not(coverage))] 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))] 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#[must_use]
1170pub fn proto_pollution_assign_finditer(haystack: &str) -> Vec<Candidate> {
1171 #[cfg(not(coverage))] 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#[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#[must_use]
1214pub fn sqli_order_by_terminator_finditer(haystack: &str) -> Vec<Candidate> {
1215 #[cfg(not(coverage))] 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))] 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#[must_use]
1248pub fn file_inclusion_bare_host_finditer(haystack: &str) -> Vec<Candidate> {
1249 #[cfg(not(coverage))] 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\-\.\?,'/\\+&%$#_]*)?",
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\-\.\?,'/\\+&%$#_]*)?",
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
1267const SCHEME_PATH_TRIMMED: &str =
1272 r#"=(?:https?|ftp)://[^\s'"<>]+/[^\s'"<>/]*\.(?:phtml|php[3-5]?|phar|jsp|aspx?|pl|py|txt|inc)"#;
1273
1274#[must_use]
1280pub fn file_inclusion_scheme_path_finditer(haystack: &str) -> Vec<Candidate> {
1281 #[cfg(not(coverage))] 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
1319const 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#[must_use]
1329pub fn file_inclusion_template_url_finditer(haystack: &str) -> Vec<Candidate> {
1330 #[cfg(not(coverage))] 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#[must_use]
1349pub fn glued_backtick_candidate_finditer(haystack: &str) -> Vec<Candidate> {
1350 #[cfg(not(coverage))] 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
1362pub 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
1571fn 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
1584fn 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 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 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
1618fn event_handler_lookbehind_ok(haystack: &str, run_start: usize) -> bool {
1622 #[cfg(not(coverage))] let Some((c1_pos, c1)) = char_before(haystack, run_start) else {
1624 return true; };
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
1640fn 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#[must_use]
1658pub fn xss_event_handler_finditer(haystack: &str) -> Vec<Candidate> {
1659 #[cfg(not(coverage))] 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 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 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#[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 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 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 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 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 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 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 assert!(deserialization_b64_finditer("/rO0AB", "rO0AB").is_empty());
1827 assert!(deserialization_b64_finditer("+rO0AB", "rO0AB").is_empty());
1828 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 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 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 assert!(xss_event_handler_finditer("<a =\" onclick=x>").is_empty());
1877 assert!(xss_event_handler_finditer("<img src= onerror=x>").is_empty());
1879 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 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 assert!(terminator_guard("abc", 3, guard));
1923 assert!(terminator_guard("ab;c", 2, guard));
1924 assert!(terminator_guard("ab;\n", 3, guard));
1926 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 assert!(match_span(re.re(), "abab", 3, 2).is_none());
1942 let m = match_span(re.re(), "ab", 0, 1).expect("truncated match");
1944 assert_eq!((m.start, m.end), (0, 1));
1945 assert!(match_span(re.re(), "ab", 1, 1).is_none());
1947 assert!(match_span(PyRegex::compile(r"ab", false).unwrap().re(), "ab", 0, 1).is_none());
1949 assert!(match_span(PyRegex::compile(r"zz", false).unwrap().re(), "ab", 0, 2).is_none());
1951 assert!(match_span(PyRegex::compile(r"b", false).unwrap().re(), "ab", 0, 2).is_none());
1953 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 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 assert!(shell_dash_c_finditer("evil -c id", &compiled).is_empty());
1967 assert!(shell_dash_c_finditer("\n X=1 Y=2 evil -c id", &compiled).is_empty());
1969 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 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 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 assert!(ldap_null_byte_attr_raw("(uid=x)", &compiled).is_empty());
2008 assert!(ldap_null_byte_attr_raw("*)%00", &compiled).is_empty());
2010 assert!(ldap_null_byte_attr_raw("-a*)%00", &compiled).is_empty());
2012 assert!(ldap_null_byte_attr_raw("=*)%00", &compiled).is_empty());
2014 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 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 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 assert!(quote_splice_finditer("ab\"", &compiled).is_empty());
2038 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 assert!(ssrf_private_host_finditer("evil@10.0.0.1/x").is_empty());
2048 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 assert!(sqli_select_from_finditer("xSELECT a FROM b").is_empty());
2064 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 assert!(sqli_tautology_finditer("1 OR 12=1").is_empty());
2072 assert!(sqli_tautology_finditer("' OR 1=1x").is_empty());
2074 }
2075
2076 #[test]
2077 fn tautology_swallows_candidates_inside_completed_matches() {
2078 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 assert_eq!(sensitive_path_management("/x/.aws/credentials").len(), 1);
2096 assert!(sensitive_path_management("x/.aws/credentials").is_empty());
2098 assert!(sensitive_path_wp_admin("/wp-admin.X").is_empty());
2100 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 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 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 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 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 assert!(xss_event_handler_finditer("<img onclick").is_empty());
2176 assert!(xss_event_handler_finditer("<a onclick=\"unterminated").is_empty());
2178 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 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 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 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 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 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}