Skip to main content

guard_core_engine/patterns/
mod.rs

1//! Detection wiring for the compiled pattern table and its dispatch.
2//!
3//! Compiles the canonical pattern table and dispatches each pattern to the
4//! same structural matcher, windowed finder, candidate validator and
5//! scan-window bounds as the guard-core spec 4.0.2 reference
6//! (`guard_core/handlers/_suspatterns_regex.py`).
7//!
8//! Residual patterns (lookaround / backreference constructs the `regex`
9//! crate rejects) are served by structural equivalents in `matchers.rs`.
10//! Table entry `id` is the table position (see `table.rs`) and keys the
11//! dispatch below.
12
13pub mod binary;
14pub mod chars_util;
15pub mod file_upload;
16pub mod ldap_ipv4;
17pub mod matchers;
18pub mod pickle;
19pub mod pyregex;
20pub mod scan_window;
21pub mod shell_validators;
22pub mod table;
23pub mod templates;
24pub mod xml_xxe;
25
26use std::sync::LazyLock;
27
28use pyregex::{Candidate, PyRegex};
29
30/// A regex threat exactly as the reference `_build_regex_threat` emits it.
31#[derive(Debug, Clone, PartialEq)]
32pub struct RegexThreat {
33    pub pattern: String,
34    pub match_text: String,
35    /// Unicode code-point index into the scanned view content.
36    pub position: usize,
37    pub category: String,
38    pub weight: f64,
39}
40
41pub struct CompiledEntry {
42    pub entry: table::TableEntry,
43    pub re: Option<PyRegex>,
44}
45
46fn rewrite_for_crate(source: &str) -> String {
47    // `\A(?:(?!\n).)*` is exactly `[^\n]*` under Python semantics (`.` never
48    // matches `\n` without DOTALL), so the negative-lookahead loop rewrites
49    // to a plain negated class for the crate.
50    source.replace(r"\A(?:(?!\n).)*", r"\A[^\n]*")
51}
52
53/// Table ids served by structural matchers or bespoke finders; their
54/// canonical sources do not need to compile for the `regex` crate.
55const STRUCTURAL_IDS: &[usize] = &[
56    3, 9, 12, 21, 27, 33, 42, 45, 62, 63, 64, 75, 78, 79, 90, 91, 92, 98, 99, 101, 102, 103, 104,
57    106, 107, 109, 110, 111, 112, 113, 114, 115, 117, 119, 121, 122, 123, 124, 125, 126, 128, 130,
58    131, 132, 133, 134, 135, 136, 139, 144, 145, 146, 147,
59];
60
61fn is_structural(id: usize) -> bool {
62    STRUCTURAL_IDS.contains(&id)
63}
64
65pub static COMPILED_TABLE: LazyLock<Vec<CompiledEntry>> = LazyLock::new(|| {
66    table::PATTERN_DEFINITIONS
67        .iter()
68        .map(|entry| {
69            let ignore_case = !entry.source.contains("(?-i:");
70            let re = PyRegex::compile(&rewrite_for_crate(entry.source), ignore_case)
71                .map_err(|e| format!("pattern id {}: {e}", entry.id))
72                .ok();
73            assert!(
74                re.is_some() || is_structural(entry.id),
75                "pattern id {} has neither a compiled regex nor a structural matcher",
76                entry.id
77            );
78            CompiledEntry {
79                entry: table::TableEntry {
80                    id: entry.id,
81                    source: entry.source,
82                    contexts: entry.contexts,
83                    category: entry.category,
84                },
85                re,
86            }
87        })
88        .collect()
89});
90
91pub fn resolve_pattern_weight(pattern_source: &str) -> f64 {
92    table::DETECTION_PATTERN_WEIGHT_OVERRIDES
93        .get(pattern_source)
94        .copied()
95        .unwrap_or(1.0)
96}
97
98/// Candidate rejection decision: `true` accepts the candidate as a threat
99/// (`_CANDIDATE_REJECTION_VALIDATORS`).
100#[must_use]
101fn candidate_accepts(
102    entry: &CompiledEntry,
103    haystack: &str,
104    candidate: Candidate,
105    context: &str,
106) -> bool {
107    let id = entry.entry.id;
108    let text = candidate.text(haystack);
109    match id {
110        42 => shell_validators::glued_backtick_pair_is_injection(haystack, candidate, context),
111        43 => shell_validators::dollar_substitution_pair_is_injection(haystack, candidate, context),
112        58 => shell_validators::brace_expansion_is_dangerous_command(text),
113        59 => shell_validators::quote_splice_token_is_dangerous_command(text),
114        60 => {
115            shell_validators::glob_wildcard_token_is_dangerous_command(haystack, candidate, context)
116        }
117        66 | 67 | 69 => {
118            let Some(compiled) = entry.re.as_ref() else {
119                return true;
120            };
121            ldap_ipv4::ldap_wildcard_chain_is_injection(compiled, haystack, &candidate)
122        }
123        68 => {
124            let Some(compiled) = entry.re.as_ref() else {
125                return true;
126            };
127            ldap_ipv4::ldap_paren_conjunction_is_injection(compiled, haystack, &candidate)
128        }
129        105 => ldap_ipv4::source_extension_path_is_probe(context),
130        79 => ldap_ipv4::legacy_ipv4_match_is_blocked(text),
131        152 => {
132            // group one (`c<module>\n<ident>\n`) ends after the second newline
133            let group_one_end = candidate.start
134                + haystack[candidate.start..]
135                    .find('\n')
136                    .and_then(|rel| {
137                        let after_first = candidate.start + rel + 1;
138                        haystack[after_first..]
139                            .find('\n')
140                            .map(|r2| after_first + r2 + 1)
141                    })
142                    .unwrap_or(candidate.end);
143            pickle::pickle_global_candidate_is_injection(haystack, candidate, group_one_end)
144        }
145        _ => true,
146    }
147}
148
149/// The two XML patterns served by bespoke finders inside the scan-window
150/// route, and the generic bounded scan windows (`_SCAN_WINDOW_BOUND_SOURCES`).
151fn scan_window_candidates(entry: &CompiledEntry, haystack: &str) -> Option<Vec<Candidate>> {
152    let compiled = entry.re.as_ref()?;
153    let id = entry.entry.id;
154    let (prefix_src, terminator_src) = match id {
155        0 => ("<script", r"<\/script\s*>"),
156        5 => (r"<[A-Za-z/][^<>]*style\s*=", r"\)"),
157        6 => ("<object", r"<\/object\s*>"),
158        7 => ("<embed", r"<\/embed\s*>"),
159        8 => ("<applet", r"<\/applet\s*>"),
160        37 => (r"\.\.;", r"[/\\]"),
161        76 => ("<!\\[CDATA\\[", "\\]\\]>"),
162        _ => return None,
163    };
164    let prefix = PyRegex::compile(prefix_src, true).ok()?;
165    let terminator = PyRegex::compile(terminator_src, true).ok()?;
166    Some(scan_window::bounded_finditer(
167        haystack,
168        compiled,
169        &prefix,
170        &terminator,
171    ))
172}
173
174/// Candidates for one table entry under the reference dispatch order:
175/// windowed finder, dedicated scan matcher, bounded scan windows, structural
176/// matchers, then plain full-content search.
177#[must_use]
178#[allow(
179    clippy::too_many_lines,
180    reason = "pattern dispatch mirrors the reference table order; splitting would obscure the 1:1 mapping"
181)]
182fn pattern_candidates(entry: &CompiledEntry, haystack: &str) -> Vec<Candidate> {
183    let id = entry.entry.id;
184    let source = entry.entry.source;
185    if let Some(candidates) = scan_window_candidates(entry, haystack) {
186        return candidates;
187    }
188    match id {
189        74 => return xml_xxe::xml_system_finditer(haystack),
190        77 => return xml_xxe::xml_internal_entity_finditer(haystack),
191        75 => return xml_xxe::xml_xxe_public_external_dtd_finditer(haystack),
192        3 => return matchers::xss_event_handler_finditer(haystack),
193        9 => return matchers::sqli_select_from_finditer(haystack),
194        12 => return matchers::sqli_tautology_finditer(haystack),
195        21 => return matchers::sqli_inline_comment_finditer(haystack),
196        27 => return matchers::sqli_order_by_terminator_finditer(haystack),
197        42 => return matchers::glued_backtick_candidate_finditer(haystack),
198        45 => return matchers::shell_dash_flag_finditer(haystack),
199        62 => return matchers::file_inclusion_bare_host_finditer(haystack),
200        63 => return matchers::file_inclusion_scheme_path_finditer(haystack),
201        64 => return matchers::file_inclusion_template_url_finditer(haystack),
202        103 => return matchers::sensitive_path_config(haystack),
203        78 => return matchers::ssrf_private_host_finditer(haystack),
204        79 => return matchers::ssrf_numeric_host_finditer(haystack),
205        101 => return matchers::sensitive_path_env(haystack),
206        104 => {
207            return matchers::sensitive_path_scan_ext(haystack, &["map"]);
208        }
209        106 => {
210            return matchers::sensitive_path_dot_alt(haystack, &["git", "svn", "hg", "bzr"]);
211        }
212        109 => return matchers::sensitive_path_wp_admin(haystack),
213        111 => {
214            return matchers::sensitive_path_literal_ext(
215                haystack,
216                &["phpinfo", "info", "test", "php_info"],
217                "php",
218            );
219        }
220        113 => {
221            return matchers::sensitive_path_scan_ext(haystack, matchers::BACKUP_EXTENSIONS);
222        }
223        114 => return matchers::sensitive_path_literal(haystack, matchers::DOUBLE_DOT_BAD),
224        117 => return matchers::sensitive_path_management(haystack),
225        119 => {
226            return matchers::sensitive_path_literal(
227                haystack,
228                &["actuator", "server-status", "telescope"],
229            );
230        }
231        121 => {
232            return matchers::sensitive_path_literal_suffix(
233                haystack,
234                matchers::RECON_APP_BAD,
235                matchers::BadSuffix::DashRun,
236            );
237        }
238        122 => return matchers::sensitive_path_literal(haystack, &["cgi-bin", "cgi-mod"]),
239        123 => {
240            return matchers::sensitive_path_literal(
241                haystack,
242                &["HNAP1", "IPCamDesc.xml", "SDK/webLanguage"],
243            );
244        }
245        124 => return matchers::sensitive_path_literal(haystack, &["language", "languages"]),
246        125 => {
247            return matchers::sensitive_path_literal_suffix_dot(
248                haystack,
249                matchers::RECON_README_BAD,
250            );
251        }
252        126 => {
253            return matchers::sensitive_path_literal(
254                haystack,
255                &[
256                    "sap",
257                    "ise",
258                    "nidp",
259                    "cslu",
260                    "rustfs",
261                    "developmentserver",
262                    "fog/management",
263                    "lms/db",
264                    "json/login_session",
265                    "sms_mp",
266                    "plugin/webs_model",
267                    "wsman",
268                    "am_bin",
269                ],
270            );
271        }
272        128 => {
273            return matchers::sensitive_path_literal(haystack, &[".openclaw", ".clawdbot"]);
274        }
275        130 => return matchers::sensitive_path_literal(haystack, &["inicio.html", "inicio.htm"]),
276        131 => {
277            return matchers::sensitive_path_literal(
278                haystack,
279                &[
280                    ".streamlit",
281                    ".gpt-pilot",
282                    ".aider",
283                    ".cursor",
284                    ".windsurf",
285                    ".copilot",
286                    ".devcontainer",
287                ],
288            );
289        }
290        132 => {
291            return matchers::sensitive_path_literal_suffix(
292                haystack,
293                matchers::DOCKERFILE_BAD,
294                matchers::BadSuffix::YamlSuffix,
295            );
296        }
297        133 => return matchers::sensitive_path_secrets(haystack),
298        134 => return matchers::sensitive_path_literal(haystack, &["autodiscover"]),
299        135 => return matchers::sensitive_path_literal(haystack, &["dns-query"]),
300        136 => {
301            return matchers::sensitive_path_literal(
302                haystack,
303                &[
304                    ".git/refs",
305                    ".git/index",
306                    ".git/HEAD",
307                    ".git/objects",
308                    ".git/logs",
309                ],
310            );
311        }
312        139 => {
313            return matchers::proto_pollution_assign_finditer(haystack);
314        }
315        144 => return matchers::deserialization_b64_finditer(haystack, "rO0AB"),
316        145 => return matchers::deserialization_b64_finditer(haystack, "AAEAAAD"),
317        146 => {
318            // gA[SW]V: both literal variants with the shared boundary guard
319            let mut out = matchers::deserialization_b64_finditer(haystack, "gASV");
320            out.extend(matchers::deserialization_b64_finditer(haystack, "gAWV"));
321            out.sort_by_key(|c| c.start);
322            return out;
323        }
324        147 => {
325            // BAh[Jv7bV]
326            let mut out = Vec::new();
327            for magic in ["BAhJ", "BAhv", "BAh7", "BAhb", "BAhV"] {
328                out.extend(matchers::deserialization_b64_finditer(haystack, magic));
329            }
330            out.sort_by_key(|c| c.start);
331            return out;
332        }
333        17 => {
334            return entry
335                .re
336                .as_ref()
337                .map(|compiled| matchers::load_file_scan_matches(haystack, compiled))
338                .unwrap_or_default();
339        }
340        40 => {
341            return entry
342                .re
343                .as_ref()
344                .map(|compiled| matchers::dollar_substitution_scan_matches(haystack, compiled))
345                .unwrap_or_default();
346        }
347        47 => {
348            return entry
349                .re
350                .as_ref()
351                .map(|compiled| matchers::shell_dash_c_finditer(haystack, compiled))
352                .unwrap_or_default();
353        }
354        59 => {
355            return entry
356                .re
357                .as_ref()
358                .map(|compiled| matchers::quote_splice_finditer(haystack, compiled))
359                .unwrap_or_default();
360        }
361        60 => {
362            return entry
363                .re
364                .as_ref()
365                .map(|compiled| matchers::glob_wildcard_scan_matches(haystack, compiled))
366                .unwrap_or_default();
367        }
368        70 => {
369            return entry
370                .re
371                .as_ref()
372                .map(|compiled| matchers::ldap_null_byte_attr_raw(haystack, compiled))
373                .unwrap_or_default();
374        }
375        72 => {
376            return entry
377                .re
378                .as_ref()
379                .map(|compiled| matchers::ldap_null_byte_attr_decoded(haystack, compiled))
380                .unwrap_or_default();
381        }
382        89..=92 => {
383            return file_upload::file_upload_scan_matches(
384                haystack,
385                source,
386                source_of_id(89),
387                source_of_id(90),
388                source_of_id(91),
389                source_of_id(92),
390            );
391        }
392        94 => {
393            return templates::template_keyword_matches(
394                haystack,
395                &templates::KIND_CURLY_KEYWORD,
396                true,
397            );
398        }
399        95 => {
400            return templates::template_keyword_matches(
401                haystack,
402                &templates::KIND_PERCENT_KEYWORD,
403                true,
404            );
405        }
406        96 => {
407            return templates::template_expression_matches(haystack, &templates::KIND_ASP, true);
408        }
409        97 => {
410            return templates::template_expression_matches(haystack, &templates::KIND_DOLLAR, true);
411        }
412        98 => {
413            return templates::template_expression_matches(
414                haystack,
415                &templates::KIND_CURLY_CALL,
416                true,
417            );
418        }
419        99 => {
420            return templates::template_expression_matches(haystack, &templates::KIND_HASH, true);
421        }
422        33 => return matchers::lexicon_path_finditer(haystack, LEXICON_ETC_PATH, true),
423        102 => return matchers::lexicon_path_finditer(haystack, LEXICON_ENV_PATH, true),
424        107 => return matchers::lexicon_path_finditer(haystack, LEXICON_GIT_PATH, true),
425        110 => return matchers::lexicon_path_finditer(haystack, LEXICON_WP_PATH, true),
426        112 => return matchers::lexicon_path_finditer(haystack, LEXICON_PHPINFO_PATH, true),
427        115 => return matchers::lexicon_path_finditer(haystack, LEXICON_HTACCESS_PATH, true),
428        _ => {}
429    }
430    // plain full-content search
431    entry.re.as_ref().map_or_else(Vec::new, |compiled| {
432        compiled
433            .re()
434            .find_iter(haystack)
435            .map(|m| Candidate::new(m.start(), m.end()))
436            .collect()
437    })
438}
439
440fn source_of_id(id: usize) -> &'static str {
441    table::PATTERN_DEFINITIONS
442        .iter()
443        .find(|e| e.id == id)
444        .map_or("", |e| e.source)
445}
446
447/// Lexicon-lookahead path shapes (ids 33/102/107/110/112/115): the reference
448/// pattern is `\A(?=LEXICON-in-first-line)\A<path-shape>`; the lexicon
449/// membership becomes a guard on the compiled path shape.
450pub const LEXICON_ETC_PATH: &str = r"\A[^\n]*[/\\](?:etc/(?:passwd|shadow|group|hosts|motd|issue|mysql/my\.cnf|ssh/ssh_config))(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?\b";
451pub const LEXICON_ENV_PATH: &str = r"\A[^\n]*[/\\](?:\.env(?:\.\w+)?)(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?\b";
452pub const LEXICON_GIT_PATH: &str = r"\A[^\n]*[/\\](?:\.(?:git|svn|hg|bzr))(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?\b";
453pub const LEXICON_WP_PATH: &str = r"\A[^\n]*[/\\](?:(?:wp-(?:admin|login|content|includes|config)|administrator|xmlrpc)\.?(?:php)?)(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?\b";
454pub const LEXICON_PHPINFO_PATH: &str = r"\A[^\n]*[/\\](?:(?:phpinfo|info|test|php_info)\.php)(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?\b";
455pub const LEXICON_HTACCESS_PATH: &str = r"\A[^\n]*[/\\](?:(?:\.htaccess|\.htpasswd|\.DS_Store|Thumbs\.db|\.npmrc|\.dockerenv|web\.config))(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?(?:[/\\][\w.\-~%]{1,64})?\b";
456
457/// `_RECON_BARE_PATH_CONTEXTS`: contexts where a recon whole-value hit is a
458/// probe regardless of a leading separator.
459const RECON_BARE_PATH_CONTEXTS: &[&str] = &["url_path", "unknown"];
460
461/// `_recon_path_value_is_probe`: accepted when the scanned value's base
462/// context (validator contexts keep the `:embedded_json` suffix, so split it
463/// off) is `url_path` or `unknown`, else the matched text must start with a
464/// path separator.
465#[must_use]
466fn recon_path_value_is_probe(matched: &str, validator_context: &str) -> bool {
467    let base = validator_context.split(':').next().unwrap_or("");
468    RECON_BARE_PATH_CONTEXTS.contains(&base)
469        || matched.starts_with('/')
470        || matched.starts_with('\\')
471}
472
473/// First accepted regex threat for one entry (validator + recon
474/// leading-separator + noise gates).
475#[must_use]
476pub fn find_first_threat(
477    entry: &CompiledEntry,
478    haystack: &str,
479    context: &str,
480    binary_prefix: Option<&[u32]>,
481) -> Option<RegexThreat> {
482    let noise_prone = table::NOISE_PRONE_PATTERN_SOURCES.contains(entry.entry.source);
483    let recon_optional_separator =
484        table::RECON_OPTIONAL_SEPARATOR_PATTERN_SOURCES.contains(entry.entry.source);
485    for candidate in pattern_candidates(entry, haystack) {
486        if !candidate_accepts(entry, haystack, candidate, context) {
487            continue;
488        }
489        // Upstream guard-core 08f79d67: rows with an optional leading
490        // separator must not read bare query or body words as probe paths;
491        // ordered after the candidate validators and before the binary-noise
492        // gate, mirroring `_build_regex_threat`.
493        if recon_optional_separator && !recon_path_value_is_probe(candidate.text(haystack), context)
494        {
495            continue;
496        }
497        if noise_prone
498            && let Some(prefix) = binary_prefix
499            && binary::match_is_binary_density(
500                prefix,
501                pyregex::cp_index(haystack, candidate.start),
502                pyregex::cp_index(haystack, candidate.end),
503            )
504        {
505            continue;
506        }
507        return Some(RegexThreat {
508            pattern: entry.entry.source.to_owned(),
509            match_text: candidate.text(haystack).to_owned(),
510            position: pyregex::cp_index(haystack, candidate.start),
511            category: entry.entry.category.to_owned(),
512            weight: resolve_pattern_weight(entry.entry.source),
513        });
514    }
515    None
516}
517
518/// Which scan view is being processed; mirrors `_pattern_excluded_from_view`.
519#[derive(Debug, Clone, Copy, PartialEq, Eq)]
520pub enum ViewFilter {
521    /// short-base64 additive view (and legacy checks): no exclusion
522    All,
523    /// processed view: raw-only and url-decoded-only patterns are excluded
524    Processed,
525    /// raw signal-preserving view: raw-view-only patterns plus the recon rows
526    Raw,
527    /// URL-decoded view: only url-decoded-view-only patterns
528    UrlDecoded,
529}
530
531#[must_use]
532pub fn is_excluded_from_view(source: &str, filter: ViewFilter) -> bool {
533    let raw_only = table::DETECTION_RAW_VIEW_PATTERN_SOURCES.contains(source);
534    let url_only = table::DETECTION_URL_DECODED_VIEW_PATTERN_SOURCES.contains(source);
535    // Recon rows also run on the raw view: the processed views fold LDAP hex
536    // escapes before the tables run, so separator-prefixed probes such as
537    // `\default` only survive there (upstream `DETECTION_RECON_RAW_VIEW_
538    // PATTERN_SOURCES`).
539    let recon_raw_only = table::DETECTION_RECON_RAW_VIEW_PATTERN_SOURCES.contains(source);
540    match filter {
541        ViewFilter::All => false,
542        ViewFilter::Processed => raw_only || url_only,
543        ViewFilter::Raw => url_only || !(raw_only || recon_raw_only),
544        ViewFilter::UrlDecoded => raw_only || !url_only,
545    }
546}
547
548/// Table ids of the size-gated family (guard-core-php PR #1 parity): the
549/// `\A`-anchored line-walk and path-segment-loop shapes, skipped when the
550/// view's first line reaches `GATED_PATTERN_MAX_SUBJECT_BYTES` bytes.
551const SIZE_GATED_IDS: &[usize] = &[
552    32, 33, 34, 35, 36, 102, 107, 110, 112, 115, 101, 103, 104, 105, 106, 109, 111, 113, 114, 116,
553    117, 119, 121, 122, 123, 124, 125, 126, 128, 130, 131, 132, 133, 134, 135, 136,
554];
555
556/// `SusPatterns::GATED_PATTERN_MAX_SUBJECT_BYTES` (15 KiB).
557///
558/// The frozen corpus maxes out below the gate, so this never changes
559/// conformance outcomes; it ports the reference skip for large single-line
560/// bodies.
561pub const GATED_PATTERN_MAX_SUBJECT_BYTES: usize = 15_360;
562
563fn first_line_byte_length(content: &str) -> usize {
564    content.find('\n').unwrap_or(content.len())
565}
566
567fn size_gated(content: &str) -> bool {
568    first_line_byte_length(content) >= GATED_PATTERN_MAX_SUBJECT_BYTES
569}
570
571/// One view pass of `_check_regex_patterns`: context filter, per-pattern
572/// dispatch, validator and binary-density gates, first accepted threat each.
573#[must_use]
574pub fn scan_view(
575    content: &str,
576    filter: ViewFilter,
577    normalized_context: &str,
578    skip_filter: bool,
579    validator_context: &str,
580) -> Vec<RegexThreat> {
581    let binary_prefix = binary::build_binary_prefix(content);
582    let gating = size_gated(content);
583    let mut threats = Vec::new();
584    for entry in COMPILED_TABLE.iter() {
585        if is_excluded_from_view(entry.entry.source, filter) {
586            continue;
587        }
588        if !skip_filter && !entry.entry.contexts.contains(&normalized_context) {
589            continue;
590        }
591        if gating && SIZE_GATED_IDS.contains(&entry.entry.id) {
592            continue;
593        }
594        if let Some(threat) =
595            find_first_threat(entry, content, validator_context, Some(&binary_prefix))
596        {
597            threats.push(threat);
598        }
599    }
600    threats
601}
602
603/// `_check_decoded_view_path_traversal`: the `\.\.[\\/]` shape fires only
604/// when decoding revealed more traversal shapes than the raw view carries.
605#[must_use]
606pub fn decoded_view_traversal_threat(processed: &str, raw_view: &str) -> Option<RegexThreat> {
607    static SHAPE: LazyLock<PyRegex> =
608        LazyLock::new(|| PyRegex::compile(r"\.\.[\\/]", false).expect("static shape regex"));
609    let decoded_matches: Vec<Candidate> = SHAPE
610        .re()
611        .find_iter(processed)
612        .map(|m| Candidate::new(m.start(), m.end()))
613        .collect();
614    let raw_count = SHAPE.re().find_iter(raw_view).count();
615    if decoded_matches.len() <= raw_count {
616        return None;
617    }
618    let first = decoded_matches[0];
619    Some(RegexThreat {
620        pattern: r"\.\.[\\/]".to_owned(),
621        match_text: first.text(processed).to_owned(),
622        position: pyregex::cp_index(processed, first.start),
623        category: "path_traversal".to_owned(),
624        weight: 1.0,
625    })
626}
627
628/// The synthetic exhaustion threat appended when a decode pass hit its
629/// 16-iteration budget.
630#[must_use]
631pub fn decode_budget_exhausted_threat() -> RegexThreat {
632    RegexThreat {
633        pattern: "decode_budget_exhausted".to_owned(),
634        match_text: "decode_budget_exhausted".to_owned(),
635        position: 0,
636        category: "custom".to_owned(),
637        weight: 1.0,
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    fn entry_for(id: usize) -> &'static CompiledEntry {
646        COMPILED_TABLE
647            .iter()
648            .find(|e| e.entry.id == id)
649            .expect("entry exists")
650    }
651
652    fn threat(id: usize, content: &str) -> Option<RegexThreat> {
653        find_first_threat(entry_for(id), content, "request_body", None)
654    }
655
656    #[test]
657    fn compiled_table_is_complete() {
658        assert_eq!(COMPILED_TABLE.len(), table::PATTERN_DEFINITIONS.len());
659    }
660
661    #[test]
662    fn script_threat_position_and_text() {
663        let t = threat(0, "x<script>alert(1)</script>y").expect("script threat");
664        assert_eq!(t.match_text, "<script>alert(1)</script>");
665        assert_eq!(t.position, 1);
666        assert_eq!(t.category, "xss");
667    }
668
669    #[test]
670    fn etc_passwd_via_url_decoded_view_source() {
671        let t = threat(32, "O:8:\"T\":1:{s:4:\"file\";s:11:\"/etc/passwd\";}").expect("etc threat");
672        assert_eq!(t.position, 0);
673        assert_eq!(t.category, "dir_traversal");
674    }
675
676    #[test]
677    fn select_from_union_case() {
678        let t = threat(9, "id=1 UNION SELECT 1 FROM (SELECT 1)a--").expect("select threat");
679        // leftmost \bSELECT\b is inside "UNION SELECT"
680        assert_eq!(t.position, 11);
681        assert_eq!(t.match_text, "SELECT 1 FROM");
682    }
683
684    #[test]
685    fn tautology_match() {
686        let t = threat(12, "' OR 1=1--").expect("tautology");
687        assert_eq!(t.position, 2);
688        assert_eq!(t.match_text, "OR 1=1");
689    }
690
691    #[test]
692    fn union_select_weight_one() {
693        let t = threat(13, "' UNION SELECT NULL, NULL, NULL--").expect("union");
694        assert_eq!(t.position, 2);
695        assert!((t.weight - 1.0).abs() < f64::EPSILON);
696    }
697
698    #[test]
699    fn where_clause_weight_half() {
700        let t = threat(11, "UPDATE users SET admin=true WHERE id=1").expect("where");
701        assert_eq!(t.position, 28);
702        assert!((t.weight - 0.5).abs() < f64::EPSILON);
703    }
704
705    #[test]
706    fn log4shell_and_ssrf() {
707        let content = "${jndi:ldap://10.0.0.1/a}";
708        let t44 = threat(44, content).expect("log4shell");
709        assert_eq!(t44.position, 0);
710        let t78 = threat(78, content).expect("ssrf private");
711        assert_eq!(t78.position, 13);
712        let t79 = threat(79, content).expect("ssrf numeric");
713        assert_eq!(t79.position, 11);
714    }
715
716    #[test]
717    fn ldap_injection_validators() {
718        let content = "*)(uid=*))(|(uid=*";
719        assert!(threat(65, content).is_some(), "paren conj star");
720        assert!(threat(68, content).is_some(), "paren conjunction");
721        assert!(threat(67, content).is_some(), "paren breakout");
722        assert!(threat(69, content).is_some(), "wildcard chain");
723        assert!(threat(66, content).is_some(), "wildcard equals");
724    }
725
726    #[test]
727    fn ldap_conjunction_followup() {
728        // conjunction + followup clause is the injection canary
729        assert!(threat(68, "(&(objectClass=user))").is_some());
730        // bare conjunction without followup is rejected
731        assert!(threat(68, "(|x").is_none());
732    }
733
734    #[test]
735    fn sensitive_paths() {
736        assert!(threat(101, "/.env").is_some());
737        assert!(threat(106, "/.git/config").is_some());
738        assert!(threat(117, "/.aws/credentials").is_some());
739        assert!(threat(114, "/.htpasswd").is_some());
740        assert!(threat(113, "/config.php.bak").is_some());
741        assert!(threat(109, "/wp-login.php").is_some());
742        assert!(threat(119, "/actuator/env").is_some());
743        assert!(threat(109, "/admin/login/?next=/admin/").is_none());
744        assert!(threat(105, "/admin/login/?next=/admin/").is_none());
745    }
746
747    #[test]
748    fn file_upload_kinds() {
749        assert!(threat(89, "filename=\"shell.phtml\"").is_some());
750        assert!(threat(90, "filename=\"shell.php.jpg\"").is_some());
751        assert!(threat(89, "filename=\".htaccess\"").is_none());
752    }
753
754    #[test]
755    fn templates() {
756        assert!(threat(98, "{{ 7 * 7 }}").is_some());
757        assert!(threat(97, "${7 * 7}").is_some());
758        assert!(threat(96, "<%= 7 * 7 %>").is_some());
759        assert!(threat(97, "#set($x = 7 * 7)$x").is_none());
760    }
761
762    #[test]
763    fn deser_magic_prefixes() {
764        assert!(threat(144, "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcA==").is_some());
765        assert!(threat(144, "cHl0aG9u").is_none());
766    }
767
768    #[test]
769    fn pickle_global_validator() {
770        let payload = "cos\nsystem\n(S'id'\ntR.";
771        assert!(threat(152, payload).is_some(), "pickle reduce");
772        assert!(threat(152, "cos\nsystem\n(S'id'\n.").is_none(), "no reduce");
773    }
774
775    #[test]
776    fn dollar_substitution_validator() {
777        assert!(threat(43, "cat${IFS}/etc/passwd").is_some(), "IFS special");
778        // plausible $() token only fires in the ambiguous contexts
779        assert!(threat(43, "$(whoami)").is_none(), "request_body reject");
780        assert!(threat(43, "total cost is $100 after discount").is_none());
781    }
782
783    #[test]
784    fn backtick_chain_and_candidate() {
785        assert!(threat(41, "`id`").is_some(), "full chain");
786        // the glued candidate fires only when its validator accepts; bare
787        // `id` with no glue is rejected by the candidate validator
788        assert!(threat(42, "`id`").is_none(), "candidate rejected");
789    }
790
791    #[test]
792    fn comment_terminator_raw_view() {
793        assert!(threat(29, "admin'--").is_some());
794        let t = threat(29, "'; EXEC xp_cmdshell('dir')--").expect("comment");
795        assert_eq!(t.position, 24);
796        assert_eq!(t.match_text, "')--");
797    }
798
799    #[test]
800    fn http_split_position() {
801        let t = threat(100, "en\r\nSet-Cookie: injected=1").expect("http split");
802        assert_eq!(t.position, 3);
803    }
804
805    #[test]
806    fn xss_event_handler_cases() {
807        assert!(threat(3, "<img src=x onerror=alert(1)>").is_some());
808        assert!(threat(3, "/search?q=<svg/onload=alert(1)>").is_some());
809        // oracle-verified: the lookbehinds reject the SEQUENCES `=`/`="/`='`;
810        // a bare `"` before the run does not block, and the greedy group
811        // picks the rightmost split
812        let t = threat(3, "<img src=\"x\" onerror=alert(1)>").expect("quoted attr matches");
813        assert_eq!(t.match_text, "<img src=\"x\" onerror=alert(1)");
814        assert!(threat(3, "<b>bold</b> text").is_none());
815    }
816
817    #[test]
818    fn inline_comment_obfuscation() {
819        assert!(threat(21, "UNI/**/ON SE/**/LECT 1,2,3").is_some());
820        // /*! is protected in the reference table too (distinct pattern id 20)
821        assert!(threat(21, "UNI/*!*/ON").is_none());
822    }
823}