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/// First accepted regex threat for one entry (validator + noise gate).
458#[must_use]
459pub fn find_first_threat(
460    entry: &CompiledEntry,
461    haystack: &str,
462    context: &str,
463    binary_prefix: Option<&[u32]>,
464) -> Option<RegexThreat> {
465    let noise_prone = table::NOISE_PRONE_PATTERN_SOURCES.contains(entry.entry.source);
466    for candidate in pattern_candidates(entry, haystack) {
467        if !candidate_accepts(entry, haystack, candidate, context) {
468            continue;
469        }
470        if noise_prone
471            && let Some(prefix) = binary_prefix
472            && binary::match_is_binary_density(
473                prefix,
474                pyregex::cp_index(haystack, candidate.start),
475                pyregex::cp_index(haystack, candidate.end),
476            )
477        {
478            continue;
479        }
480        return Some(RegexThreat {
481            pattern: entry.entry.source.to_owned(),
482            match_text: candidate.text(haystack).to_owned(),
483            position: pyregex::cp_index(haystack, candidate.start),
484            category: entry.entry.category.to_owned(),
485            weight: resolve_pattern_weight(entry.entry.source),
486        });
487    }
488    None
489}
490
491/// Which scan view is being processed; mirrors `_pattern_excluded_from_view`.
492#[derive(Debug, Clone, Copy, PartialEq, Eq)]
493pub enum ViewFilter {
494    /// short-base64 additive view (and legacy checks): no exclusion
495    All,
496    /// processed view: raw-only and url-decoded-only patterns are excluded
497    Processed,
498    /// raw signal-preserving view: only raw-view-only patterns
499    Raw,
500    /// URL-decoded view: only url-decoded-view-only patterns
501    UrlDecoded,
502}
503
504#[must_use]
505pub fn is_excluded_from_view(source: &str, filter: ViewFilter) -> bool {
506    let raw_only = table::DETECTION_RAW_VIEW_PATTERN_SOURCES.contains(source);
507    let url_only = table::DETECTION_URL_DECODED_VIEW_PATTERN_SOURCES.contains(source);
508    match filter {
509        ViewFilter::All => false,
510        ViewFilter::Processed => raw_only || url_only,
511        ViewFilter::Raw => url_only || !raw_only,
512        ViewFilter::UrlDecoded => raw_only || !url_only,
513    }
514}
515
516/// Table ids of the size-gated family (guard-core-php PR #1 parity): the
517/// `\A`-anchored line-walk and path-segment-loop shapes, skipped when the
518/// view's first line reaches `GATED_PATTERN_MAX_SUBJECT_BYTES` bytes.
519const SIZE_GATED_IDS: &[usize] = &[
520    32, 33, 34, 35, 36, 102, 107, 110, 112, 115, 101, 103, 104, 105, 106, 109, 111, 113, 114, 116,
521    117, 119, 121, 122, 123, 124, 125, 126, 128, 130, 131, 132, 133, 134, 135, 136,
522];
523
524/// `SusPatterns::GATED_PATTERN_MAX_SUBJECT_BYTES` (15 KiB).
525///
526/// The frozen corpus maxes out below the gate, so this never changes
527/// conformance outcomes; it ports the reference skip for large single-line
528/// bodies.
529pub const GATED_PATTERN_MAX_SUBJECT_BYTES: usize = 15_360;
530
531fn first_line_byte_length(content: &str) -> usize {
532    content.find('\n').unwrap_or(content.len())
533}
534
535fn size_gated(content: &str) -> bool {
536    first_line_byte_length(content) >= GATED_PATTERN_MAX_SUBJECT_BYTES
537}
538
539/// One view pass of `_check_regex_patterns`: context filter, per-pattern
540/// dispatch, validator and binary-density gates, first accepted threat each.
541#[must_use]
542pub fn scan_view(
543    content: &str,
544    filter: ViewFilter,
545    normalized_context: &str,
546    skip_filter: bool,
547    validator_context: &str,
548) -> Vec<RegexThreat> {
549    let binary_prefix = binary::build_binary_prefix(content);
550    let gating = size_gated(content);
551    let mut threats = Vec::new();
552    for entry in COMPILED_TABLE.iter() {
553        if is_excluded_from_view(entry.entry.source, filter) {
554            continue;
555        }
556        if !skip_filter && !entry.entry.contexts.contains(&normalized_context) {
557            continue;
558        }
559        if gating && SIZE_GATED_IDS.contains(&entry.entry.id) {
560            continue;
561        }
562        if let Some(threat) =
563            find_first_threat(entry, content, validator_context, Some(&binary_prefix))
564        {
565            threats.push(threat);
566        }
567    }
568    threats
569}
570
571/// `_check_decoded_view_path_traversal`: the `\.\.[\\/]` shape fires only
572/// when decoding revealed more traversal shapes than the raw view carries.
573#[must_use]
574pub fn decoded_view_traversal_threat(processed: &str, raw_view: &str) -> Option<RegexThreat> {
575    static SHAPE: LazyLock<PyRegex> =
576        LazyLock::new(|| PyRegex::compile(r"\.\.[\\/]", false).expect("static shape regex"));
577    let decoded_matches: Vec<Candidate> = SHAPE
578        .re()
579        .find_iter(processed)
580        .map(|m| Candidate::new(m.start(), m.end()))
581        .collect();
582    let raw_count = SHAPE.re().find_iter(raw_view).count();
583    if decoded_matches.len() <= raw_count {
584        return None;
585    }
586    let first = decoded_matches[0];
587    Some(RegexThreat {
588        pattern: r"\.\.[\\/]".to_owned(),
589        match_text: first.text(processed).to_owned(),
590        position: pyregex::cp_index(processed, first.start),
591        category: "path_traversal".to_owned(),
592        weight: 1.0,
593    })
594}
595
596/// The synthetic exhaustion threat appended when a decode pass hit its
597/// 16-iteration budget.
598#[must_use]
599pub fn decode_budget_exhausted_threat() -> RegexThreat {
600    RegexThreat {
601        pattern: "decode_budget_exhausted".to_owned(),
602        match_text: "decode_budget_exhausted".to_owned(),
603        position: 0,
604        category: "custom".to_owned(),
605        weight: 1.0,
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    fn entry_for(id: usize) -> &'static CompiledEntry {
614        COMPILED_TABLE
615            .iter()
616            .find(|e| e.entry.id == id)
617            .expect("entry exists")
618    }
619
620    fn threat(id: usize, content: &str) -> Option<RegexThreat> {
621        find_first_threat(entry_for(id), content, "request_body", None)
622    }
623
624    #[test]
625    fn compiled_table_is_complete() {
626        assert_eq!(COMPILED_TABLE.len(), table::PATTERN_DEFINITIONS.len());
627    }
628
629    #[test]
630    fn script_threat_position_and_text() {
631        let t = threat(0, "x<script>alert(1)</script>y").expect("script threat");
632        assert_eq!(t.match_text, "<script>alert(1)</script>");
633        assert_eq!(t.position, 1);
634        assert_eq!(t.category, "xss");
635    }
636
637    #[test]
638    fn etc_passwd_via_url_decoded_view_source() {
639        let t = threat(32, "O:8:\"T\":1:{s:4:\"file\";s:11:\"/etc/passwd\";}").expect("etc threat");
640        assert_eq!(t.position, 0);
641        assert_eq!(t.category, "dir_traversal");
642    }
643
644    #[test]
645    fn select_from_union_case() {
646        let t = threat(9, "id=1 UNION SELECT 1 FROM (SELECT 1)a--").expect("select threat");
647        // leftmost \bSELECT\b is inside "UNION SELECT"
648        assert_eq!(t.position, 11);
649        assert_eq!(t.match_text, "SELECT 1 FROM");
650    }
651
652    #[test]
653    fn tautology_match() {
654        let t = threat(12, "' OR 1=1--").expect("tautology");
655        assert_eq!(t.position, 2);
656        assert_eq!(t.match_text, "OR 1=1");
657    }
658
659    #[test]
660    fn union_select_weight_one() {
661        let t = threat(13, "' UNION SELECT NULL, NULL, NULL--").expect("union");
662        assert_eq!(t.position, 2);
663        assert!((t.weight - 1.0).abs() < f64::EPSILON);
664    }
665
666    #[test]
667    fn where_clause_weight_half() {
668        let t = threat(11, "UPDATE users SET admin=true WHERE id=1").expect("where");
669        assert_eq!(t.position, 28);
670        assert!((t.weight - 0.5).abs() < f64::EPSILON);
671    }
672
673    #[test]
674    fn log4shell_and_ssrf() {
675        let content = "${jndi:ldap://10.0.0.1/a}";
676        let t44 = threat(44, content).expect("log4shell");
677        assert_eq!(t44.position, 0);
678        let t78 = threat(78, content).expect("ssrf private");
679        assert_eq!(t78.position, 13);
680        let t79 = threat(79, content).expect("ssrf numeric");
681        assert_eq!(t79.position, 11);
682    }
683
684    #[test]
685    fn ldap_injection_validators() {
686        let content = "*)(uid=*))(|(uid=*";
687        assert!(threat(65, content).is_some(), "paren conj star");
688        assert!(threat(68, content).is_some(), "paren conjunction");
689        assert!(threat(67, content).is_some(), "paren breakout");
690        assert!(threat(69, content).is_some(), "wildcard chain");
691        assert!(threat(66, content).is_some(), "wildcard equals");
692    }
693
694    #[test]
695    fn ldap_conjunction_followup() {
696        // conjunction + followup clause is the injection canary
697        assert!(threat(68, "(&(objectClass=user))").is_some());
698        // bare conjunction without followup is rejected
699        assert!(threat(68, "(|x").is_none());
700    }
701
702    #[test]
703    fn sensitive_paths() {
704        assert!(threat(101, "/.env").is_some());
705        assert!(threat(106, "/.git/config").is_some());
706        assert!(threat(117, "/.aws/credentials").is_some());
707        assert!(threat(114, "/.htpasswd").is_some());
708        assert!(threat(113, "/config.php.bak").is_some());
709        assert!(threat(109, "/wp-login.php").is_some());
710        assert!(threat(119, "/actuator/env").is_some());
711        assert!(threat(109, "/admin/login/?next=/admin/").is_none());
712        assert!(threat(105, "/admin/login/?next=/admin/").is_none());
713    }
714
715    #[test]
716    fn file_upload_kinds() {
717        assert!(threat(89, "filename=\"shell.phtml\"").is_some());
718        assert!(threat(90, "filename=\"shell.php.jpg\"").is_some());
719        assert!(threat(89, "filename=\".htaccess\"").is_none());
720    }
721
722    #[test]
723    fn templates() {
724        assert!(threat(98, "{{ 7 * 7 }}").is_some());
725        assert!(threat(97, "${7 * 7}").is_some());
726        assert!(threat(96, "<%= 7 * 7 %>").is_some());
727        assert!(threat(97, "#set($x = 7 * 7)$x").is_none());
728    }
729
730    #[test]
731    fn deser_magic_prefixes() {
732        assert!(threat(144, "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcA==").is_some());
733        assert!(threat(144, "cHl0aG9u").is_none());
734    }
735
736    #[test]
737    fn pickle_global_validator() {
738        let payload = "cos\nsystem\n(S'id'\ntR.";
739        assert!(threat(152, payload).is_some(), "pickle reduce");
740        assert!(threat(152, "cos\nsystem\n(S'id'\n.").is_none(), "no reduce");
741    }
742
743    #[test]
744    fn dollar_substitution_validator() {
745        assert!(threat(43, "cat${IFS}/etc/passwd").is_some(), "IFS special");
746        // plausible $() token only fires in the ambiguous contexts
747        assert!(threat(43, "$(whoami)").is_none(), "request_body reject");
748        assert!(threat(43, "total cost is $100 after discount").is_none());
749    }
750
751    #[test]
752    fn backtick_chain_and_candidate() {
753        assert!(threat(41, "`id`").is_some(), "full chain");
754        // the glued candidate fires only when its validator accepts; bare
755        // `id` with no glue is rejected by the candidate validator
756        assert!(threat(42, "`id`").is_none(), "candidate rejected");
757    }
758
759    #[test]
760    fn comment_terminator_raw_view() {
761        assert!(threat(29, "admin'--").is_some());
762        let t = threat(29, "'; EXEC xp_cmdshell('dir')--").expect("comment");
763        assert_eq!(t.position, 24);
764        assert_eq!(t.match_text, "')--");
765    }
766
767    #[test]
768    fn http_split_position() {
769        let t = threat(100, "en\r\nSet-Cookie: injected=1").expect("http split");
770        assert_eq!(t.position, 3);
771    }
772
773    #[test]
774    fn xss_event_handler_cases() {
775        assert!(threat(3, "<img src=x onerror=alert(1)>").is_some());
776        assert!(threat(3, "/search?q=<svg/onload=alert(1)>").is_some());
777        // oracle-verified: the lookbehinds reject the SEQUENCES `=`/`="/`='`;
778        // a bare `"` before the run does not block, and the greedy group
779        // picks the rightmost split
780        let t = threat(3, "<img src=\"x\" onerror=alert(1)>").expect("quoted attr matches");
781        assert_eq!(t.match_text, "<img src=\"x\" onerror=alert(1)");
782        assert!(threat(3, "<b>bold</b> text").is_none());
783    }
784
785    #[test]
786    fn inline_comment_obfuscation() {
787        assert!(threat(21, "UNI/**/ON SE/**/LECT 1,2,3").is_some());
788        // /*! is protected in the reference table too (distinct pattern id 20)
789        assert!(threat(21, "UNI/*!*/ON").is_none());
790    }
791}