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