Skip to main content

guard_core_engine/
detection_exclusions.rs

1//! Per-request detection exclusion resolution and the multi-surface request
2//! scan.
3//!
4//! The Rust port of `guard_core/_utils/detection_config.py` (via the Go
5//! port's `detectionexclusions.go` / `headerexclusions.go` and the pipeline
6//! `detectThreat`). Every route surface overrides the global config when the route carries a
7//! non-`None` value (the Python sentinel is `None`), and the header
8//! exclusion set is additive - the hardcoded defaults merge with the config
9//! set and the route set (`_resolve_excluded_headers`), while the param,
10//! body-field, and enabled-category sets replace (`_resolve_excluded_params`
11//! and friends). Matching is case-insensitive: the resolved sets are
12//! lowercased at resolution time.
13//!
14//! An excluded surface is not skipped outright in every case: excluded
15//! headers whose values are known to false-positive a category (the ssrf
16//! category for address-carrying proxy headers and for address-chain
17//! values) still scan with every other enabled category, so an attack
18//! payload smuggled into an excluded header still detects
19//! (`_excluded_header_skip_categories`).
20//!
21//! # Example
22//!
23//! ```
24//! use guard_core_engine::detection_exclusions::{
25//!     resolve, DetectionExclusionConfig, RouteDetectionExclusions,
26//! };
27//! use std::collections::HashSet;
28//!
29//! // The global config excludes two params; the route replaces the set.
30//! let global = DetectionExclusionConfig {
31//!     excluded_detection_params: vec!["debug".to_owned(), "Trace".to_owned()],
32//!     ..DetectionExclusionConfig::default()
33//! };
34//! let route = RouteDetectionExclusions {
35//!     excluded_detection_params: Some(vec!["session".to_owned()]),
36//!     ..RouteDetectionExclusions::default()
37//! };
38//! let resolved = resolve(Some(&global), Some(&route));
39//!
40//! assert!(resolved.excluded_params.contains("session"));
41//! assert!(
42//!     !resolved.excluded_params.contains("debug") && !resolved.excluded_params.contains("trace"),
43//!     "a non-None route set replaces the global one"
44//! );
45//! // The sets are lowercased at resolution time: the global config's
46//! // mixed-case "Trace" entry resolves to "trace" when no route replaces it.
47//! let inherited = resolve(Some(&global), None);
48//! assert!(inherited.excluded_params.contains("trace"));
49//! // The header set always merges the hardcoded proxy defaults.
50//! assert!(resolved.excluded_headers.contains("x-forwarded-for"));
51//! // Unset route surfaces keep the global resolution; unset everywhere
52//! // means every category scans and the body surface scans.
53//! assert!(resolved.enabled_categories.is_none());
54//! assert!(resolved.scan_body);
55//! ```
56
57use std::collections::HashSet;
58
59use crate::body_scan::BodyScanValue;
60use crate::detect::{self, DetectConfig, Threat};
61
62/// The categories an excluded header skips for a value known to
63/// false-positive them.
64const SSRF_SKIP: &[&str] = &["ssrf"];
65
66/// The hardcoded header exclusion defaults (`_DEFAULT_EXCLUDED_HEADERS`).
67///
68/// The proxy identity, forwarding, and browser-fingerprint headers that
69/// enter the detection scan through the excluded-header routing instead of
70/// the full category sweep. Lowercase names, always merged with the config
71/// and route sets.
72pub const DEFAULT_EXCLUDED_HEADERS: &[&str] = &[
73    "host",
74    "user-agent",
75    "accept",
76    "accept-encoding",
77    "connection",
78    "origin",
79    "referer",
80    "sec-fetch-site",
81    "sec-fetch-mode",
82    "sec-fetch-dest",
83    "sec-ch-ua",
84    "sec-ch-ua-mobile",
85    "sec-ch-ua-platform",
86    "forwarded",
87    "x-forwarded-for",
88    "x-forwarded-host",
89    "x-forwarded-proto",
90    "x-real-ip",
91    "x-client-ip",
92    "x-cluster-client-ip",
93    "cf-connecting-ip",
94    "true-client-ip",
95    "fly-client-ip",
96    "x-envoy-external-address",
97];
98
99/// `_HEADER_CATEGORY_EXCLUSIONS`'s address-carrying names: an excluded match
100/// on one of these headers skips the ssrf category for any value.
101const ADDRESS_HEADER_NAMES: &[&str] = &[
102    "host",
103    "origin",
104    "x-forwarded-for",
105    "x-forwarded-host",
106    "x-real-ip",
107    "x-client-ip",
108    "x-cluster-client-ip",
109    "cf-connecting-ip",
110    "true-client-ip",
111    "fly-client-ip",
112    "x-envoy-external-address",
113    "via",
114];
115
116/// The global config's detection-exclusion surface.
117///
118/// The reference `SecurityConfig` fields of the same names. The category
119/// set is `None` when nothing is configured (every category scans); the
120/// scan-body flag is `None` for the reference default (`true`).
121#[derive(Debug, Clone, Default, PartialEq, Eq)]
122pub struct DetectionExclusionConfig {
123    /// `excluded_detection_headers` (merged with the defaults, never a
124    /// replacement).
125    pub excluded_detection_headers: Vec<String>,
126    /// `excluded_detection_params`.
127    pub excluded_detection_params: Vec<String>,
128    /// `excluded_detection_body_fields`.
129    pub excluded_detection_body_fields: Vec<String>,
130    /// `enabled_detection_categories`: `None` scans every category.
131    pub enabled_detection_categories: Option<Vec<String>>,
132    /// `detection_scan_body`: `None` is the reference default (`true`).
133    pub detection_scan_body: Option<bool>,
134}
135
136/// The route decorator's detection-exclusion surface.
137///
138/// The reference `RouteConfig` fields of the same names. A `None` field
139/// inherits the global resolution for that surface; a `Some` value
140/// replaces it (the header set is the exception: it always merges).
141#[derive(Debug, Clone, Default, PartialEq, Eq)]
142pub struct RouteDetectionExclusions {
143    /// `excluded_detection_headers` (merged with the defaults and the
144    /// config set).
145    pub excluded_detection_headers: Option<Vec<String>>,
146    /// `excluded_detection_params`.
147    pub excluded_detection_params: Option<Vec<String>>,
148    /// `excluded_detection_body_fields`.
149    pub excluded_detection_body_fields: Option<Vec<String>>,
150    /// `enabled_detection_categories`: a `Some` (even empty) replaces the
151    /// global set - an empty set disables every category, the reference's
152    /// empty frozenset.
153    pub enabled_detection_categories: Option<Vec<String>>,
154    /// `detection_scan_body`: a `Some` overrides the global flag both
155    /// directions.
156    pub detection_scan_body: Option<bool>,
157}
158
159/// The resolved exclusion sets for one request (`routeDetectionExclusions`
160/// in the Go port).
161#[derive(Debug, Clone, Default, PartialEq, Eq)]
162pub struct ResolvedExclusions {
163    /// Lowercased: query-param names the scan skips.
164    pub excluded_params: HashSet<String>,
165    /// Lowercased: body field names the scan skips.
166    pub excluded_body_fields: HashSet<String>,
167    /// Lowercased: header names routed through the excluded-header scan
168    /// (defaults + config + route, merged).
169    pub excluded_headers: HashSet<String>,
170    /// Lowercased: the categories that may contribute a detection. `None`
171    /// means every category scans.
172    pub enabled_categories: Option<HashSet<String>>,
173    /// Whether the body surface scans at all.
174    pub scan_body: bool,
175}
176
177/// Merge and lowercase one list into the set.
178fn extend_lowered(set: &mut HashSet<String>, values: impl IntoIterator<Item = String>) {
179    set.extend(values.into_iter().map(|value| value.to_ascii_lowercase()));
180}
181
182/// `_resolve_*`: every route surface overrides the global config when the
183/// route carries a non-`None` value, except the header set which merges
184/// defaults + config + route.
185#[must_use]
186pub fn resolve(
187    config: Option<&DetectionExclusionConfig>,
188    route: Option<&RouteDetectionExclusions>,
189) -> ResolvedExclusions {
190    let mut resolved = ResolvedExclusions::default();
191
192    // Header set: additive, always (defaults + config + route).
193    resolved
194        .excluded_headers
195        .extend(DEFAULT_EXCLUDED_HEADERS.iter().map(|h| (*h).to_owned()));
196    if let Some(config) = config {
197        extend_lowered(
198            &mut resolved.excluded_headers,
199            config.excluded_detection_headers.clone(),
200        );
201    }
202    if let Some(headers) = route.and_then(|route| route.excluded_detection_headers.as_ref()) {
203        extend_lowered(&mut resolved.excluded_headers, headers.clone());
204    }
205
206    // Param / body-field / category sets: a non-None route value replaces
207    // the global one.
208    let params: Option<Vec<String>> = route
209        .and_then(|route| route.excluded_detection_params.clone())
210        .or_else(|| config.map(|config| config.excluded_detection_params.clone()));
211    if let Some(params) = params {
212        extend_lowered(&mut resolved.excluded_params, params);
213    }
214    let body_fields: Option<Vec<String>> = route
215        .and_then(|route| route.excluded_detection_body_fields.clone())
216        .or_else(|| config.map(|config| config.excluded_detection_body_fields.clone()));
217    if let Some(body_fields) = body_fields {
218        extend_lowered(&mut resolved.excluded_body_fields, body_fields);
219    }
220    let categories: Option<Vec<String>> = route
221        .and_then(|route| route.enabled_detection_categories.clone())
222        .or_else(|| config.and_then(|config| config.enabled_detection_categories.clone()));
223    if let Some(categories) = categories {
224        let mut set = HashSet::new();
225        extend_lowered(&mut set, categories);
226        resolved.enabled_categories = Some(set);
227    }
228
229    // `_resolve_scan_body`: route > config > the default of true.
230    resolved.scan_body = route
231        .and_then(|route| route.detection_scan_body)
232        .or_else(|| config.and_then(|config| config.detection_scan_body))
233        .unwrap_or(true);
234
235    resolved
236}
237
238/// The categories an excluded header skips for one value
239/// (`_excluded_header_skip_categories`).
240///
241/// Address-carrying proxy headers skip `ssrf` for any value; every other
242/// excluded header skips `ssrf` only when its whole value parses as an
243/// address chain (so an attack payload in the same header still detects).
244/// An empty slice means the header scans with every enabled category.
245#[must_use]
246pub fn excluded_header_skip_categories(name: &str, value: &str) -> &'static [&'static str] {
247    let normalized = name.trim().to_ascii_lowercase();
248    if ADDRESS_HEADER_NAMES.contains(&normalized.as_str()) {
249        return SSRF_SKIP;
250    }
251    if value_looks_like_address_chain(value) {
252        return SSRF_SKIP;
253    }
254    &[]
255}
256
257/// Remove the port from a forwarded-list entry
258/// (`_strip_forwarded_entry_port`).
259///
260/// `1.2.3.4:8080` -> `1.2.3.4`, `[::1]:8080` -> `::1`, so the address
261/// itself can be parsed.
262#[must_use]
263pub fn strip_forwarded_entry_port(value: &str) -> &str {
264    if let Some(rest) = value.strip_prefix('[') {
265        let Some(closing) = rest.find(']') else {
266            return value;
267        };
268        let remainder = &rest[closing + 1..];
269        let port_valid =
270            remainder.starts_with(':') && remainder[1..].bytes().all(|b| b.is_ascii_digit());
271        if !(remainder.is_empty() || port_valid) {
272            return value;
273        }
274        return &rest[..closing];
275    }
276    if value.matches(':').count() == 1
277        && let Some((host, port)) = value.split_once(':')
278        && !port.is_empty()
279        && port.bytes().all(|b| b.is_ascii_digit())
280    {
281        return host;
282    }
283    value
284}
285
286/// Address-chain detection (`_value_looks_like_address_chain`).
287///
288/// Every comma-separated token parses as an IP address once its port entry
289/// is stripped, so a value like `10.0.0.5, 172.16.0.1` reads as a proxy
290/// chain, not an attack payload.
291#[must_use]
292pub fn value_looks_like_address_chain(value: &str) -> bool {
293    let tokens: Vec<&str> = value
294        .split(',')
295        .map(str::trim)
296        .filter(|token| !token.is_empty())
297        .collect();
298    if tokens.is_empty() {
299        return false;
300    }
301    tokens.iter().all(|token| {
302        strip_forwarded_entry_port(token)
303            .parse::<std::net::IpAddr>()
304            .is_ok()
305    })
306}
307
308/// The request surfaces one scan pass covers (the `detectThreat` input
309/// shape). Headers and query params are `(name, value)` pairs; body values
310/// are the [`crate::body_scan`] extraction's output.
311#[derive(Debug, Clone, Default)]
312pub struct RequestSurfaces<'a> {
313    /// The URL path, scanned first under the `url_path` context.
314    pub url_path: Option<&'a str>,
315    /// Query parameter pairs, scanned under the `query_param` context.
316    pub query_params: &'a [(String, String)],
317    /// Header pairs, scanned under the `header` context.
318    pub headers: &'a [(String, String)],
319    /// Content type of `raw_body` (the body extraction's router).
320    pub content_type: &'a str,
321    /// The raw request body, extracted into scan values when
322    /// [`ResolvedExclusions::scan_body`] is set.
323    pub raw_body: &'a str,
324}
325
326/// The multi-surface verdict (`detectThreat`'s return shape).
327#[derive(Debug, Clone, Default, PartialEq, Eq)]
328pub struct RequestScanVerdict {
329    /// `true` when some scanned value produced at least one enabled
330    /// category.
331    pub is_threat: bool,
332    /// The contributing categories, sorted (the reference emits them
333    /// alphabetically in the reason).
334    pub categories: Vec<String>,
335    /// The reference reason string naming the categories.
336    pub reason: String,
337}
338
339/// The per-request scan budgets (`detection_max_scan_values` /
340/// `detection_max_scan_chars`): the value count increments for every
341/// candidate, the character count at the same per-value point; once a cap
342/// is crossed the remaining request values are not scanned (the
343/// reference's fail-open signal, logged once per request there - the
344/// engine keeps its no-logger idiom, the budget answer is the behavior).
345struct ScanBudget {
346    max_values: usize,
347    scanned_values: usize,
348    max_chars: usize,
349    scanned_chars: usize,
350}
351
352impl ScanBudget {
353    const fn new(config: &DetectConfig) -> Self {
354        Self {
355            max_values: config.max_scan_values,
356            scanned_values: 0,
357            max_chars: config.max_scan_chars,
358            scanned_chars: 0,
359        }
360    }
361
362    /// Account one candidate; `true` when it may scan. Both counts run
363    /// regardless of earlier verdicts (the reference increments the value
364    /// count before the char check).
365    fn admit(&mut self, content: &str) -> bool {
366        self.scanned_values += 1;
367        if self.scanned_values > self.max_values {
368            return false;
369        }
370        let value_chars = content.chars().count();
371        if self.scanned_chars >= self.max_chars {
372            return false;
373        }
374        self.scanned_chars += value_chars;
375        true
376    }
377}
378/// The multi-surface scan (`detectThreat`).
379///
380/// Scan the request surfaces in the reference order - URL path, query
381/// params (skipping excluded names), headers (excluded names scan with
382/// their known-false-positive categories suppressed), then the body surface
383/// when [`ResolvedExclusions::scan_body`] is set - and return the first
384/// value that produces an enabled category.
385///
386/// A value that is a threat but whose categories are all filtered out ends
387/// the scan with a clean verdict, exactly like the reference: the
388/// per-value category filter is terminal, not a reason to keep scanning
389/// later values. Regex threats carry the category; semantic threats carry
390/// none (the reference's semantic payloads have no `category` and never
391/// contribute here), but still mark the value a threat.
392#[must_use]
393pub fn scan_request(
394    surfaces: &RequestSurfaces<'_>,
395    exclusions: &ResolvedExclusions,
396    config: &DetectConfig,
397) -> RequestScanVerdict {
398    struct Value<'a> {
399        content: &'a str,
400        context: &'a str,
401        skip_categories: &'static [&'static str],
402    }
403
404    let mut values: Vec<Value<'_>> = Vec::new();
405    if let Some(path) = surfaces.url_path.filter(|path| !path.is_empty()) {
406        values.push(Value {
407            content: path,
408            context: "url_path",
409            skip_categories: &[],
410        });
411    }
412    for (name, value) in surfaces.query_params {
413        if exclusions
414            .excluded_params
415            .contains(&name.to_ascii_lowercase())
416        {
417            continue;
418        }
419        values.push(Value {
420            content: value,
421            context: "query_param",
422            skip_categories: &[],
423        });
424    }
425    for (name, value) in surfaces.headers {
426        if value.is_empty() {
427            continue;
428        }
429        let skip_categories = if exclusions
430            .excluded_headers
431            .contains(&name.to_ascii_lowercase())
432        {
433            excluded_header_skip_categories(name, value)
434        } else {
435            &[]
436        };
437        values.push(Value {
438            content: value,
439            context: "header",
440            skip_categories,
441        });
442    }
443    // The body values borrow the extraction's owned strings, so the owned
444    // vector is kept in a binding that outlives the scan loop.
445    let body_values: Vec<BodyScanValue> = if exclusions.scan_body {
446        crate::body_scan::extract_body_scan_values_with_exclusions(
447            surfaces.raw_body,
448            surfaces.content_type,
449            config,
450            crate::body_scan::ExcludedBodyFields::new(&exclusions.excluded_body_fields),
451        )
452    } else {
453        Vec::new()
454    };
455    for value in &body_values {
456        if let Some(forced) = value.forced_category {
457            // JSON mongo-operator keys hit straight from the walk
458            // (`_mongo_operator_key_hit`), unfiltered by the enabled
459            // categories.
460            return forced_verdict(core::slice::from_ref(&forced));
461        }
462        values.push(Value {
463            content: &value.content,
464            context: &value.context,
465            skip_categories: &[],
466        });
467    }
468
469    // The per-request scan budgets (`detection_max_scan_values` /
470    // `detection_max_scan_chars`): the value count increments for every
471    // candidate, the character count at the same per-value point; once a
472    // cap is crossed the remaining request values are not scanned (the
473    // reference's fail-open signal, logged once per request there).
474    let mut budget = ScanBudget::new(config);
475    for value in &values {
476        if !budget.admit(value.content) {
477            continue;
478        }
479        let verdict = detect::detect(value.content, value.context, config);
480        if !verdict.is_threat {
481            continue;
482        }
483        let mut categories: Vec<String> = Vec::new();
484        for threat in &verdict.threats {
485            let category = match threat {
486                Threat::Regex(regex) => Some(regex.category.clone()),
487                // Semantic threats carry no category (the reference's
488                // semantic payloads have no `category` key and are
489                // skipped by the `category == ""` guard).
490                Threat::Semantic(_) => None,
491            };
492            let Some(category) = category else {
493                continue;
494            };
495            if !category_enabled(&category, exclusions.enabled_categories.as_ref())
496                || value.skip_categories.contains(&category.as_str())
497                || categories.contains(&category)
498            {
499                continue;
500            }
501            categories.push(category);
502        }
503        if categories.is_empty() {
504            // A threat whose categories are all filtered out ends the scan
505            // clean, exactly the reference's terminal per-value filter.
506            return RequestScanVerdict::default();
507        }
508        categories.sort();
509        return verdict_from(categories);
510    }
511
512    RequestScanVerdict::default()
513}
514
515/// Forced-category verdict (`_mongo_operator_key_hit`): the category
516/// reports without a pattern scan or a category filter.
517fn forced_verdict(categories: &[&str]) -> RequestScanVerdict {
518    let categories: Vec<String> = categories.iter().map(|c| (*c).to_owned()).collect();
519    verdict_from(categories)
520}
521
522fn verdict_from(mut categories: Vec<String>) -> RequestScanVerdict {
523    categories.sort();
524    let reason = format!("Penetration patterns detected: {}", categories.join(", "));
525    RequestScanVerdict {
526        is_threat: true,
527        categories,
528        reason,
529    }
530}
531
532fn category_enabled(category: &str, enabled: Option<&HashSet<String>>) -> bool {
533    enabled.is_none_or(|set| set.contains(category))
534}
535
536/// The route toggle's request-time semantics.
537///
538/// `route_config.enable_suspicious_detection`: a route carrying the flag
539/// overrides the global `enable_penetration_detection` in both directions;
540/// without a route flag the global value applies.
541#[must_use]
542pub fn detection_enabled(global_enabled: bool, route_enabled: Option<bool>) -> bool {
543    route_enabled.unwrap_or(global_enabled)
544}
545
546/// The check-set liveness semantics (`SuspiciousActivityCheck.applies_to`).
547///
548/// The suspicious-activity check stays alive while the global flag is on or
549/// any route enables its toggle (or dynamic rules are on, which can route
550/// requests anywhere).
551#[must_use]
552pub fn check_applies(
553    global_enabled: bool,
554    route_toggles: impl IntoIterator<Item = bool>,
555    dynamic_rules: bool,
556) -> bool {
557    global_enabled || route_toggles.into_iter().any(|flag| flag) || dynamic_rules
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use crate::detect::DetectConfig;
564
565    fn corpus_config() -> DetectConfig {
566        DetectConfig {
567            max_content_length: 10_000,
568            max_full_scan_bytes: 262_144,
569            preserve_attack_patterns: true,
570            semantic_threshold: 0.7,
571            threat_score_threshold: 1.0,
572            binary_min_run_length: 16,
573            max_scan_values: 512,
574            max_scan_chars: 65_536,
575            max_json_depth: 32,
576        }
577    }
578
579    fn config_of(params: &[&str]) -> DetectionExclusionConfig {
580        DetectionExclusionConfig {
581            excluded_detection_params: params.iter().map(|s| (*s).to_owned()).collect(),
582            ..DetectionExclusionConfig::default()
583        }
584    }
585
586    #[test]
587    fn resolve_defaults_scan_everything() {
588        let resolved = resolve(None, None);
589        assert!(resolved.excluded_params.is_empty());
590        assert!(resolved.excluded_body_fields.is_empty());
591        assert!(resolved.enabled_categories.is_none());
592        assert!(resolved.scan_body);
593        // The hardcoded header defaults are present with no config at all.
594        assert!(resolved.excluded_headers.contains("host"));
595        assert!(resolved.excluded_headers.contains("x-forwarded-for"));
596        assert!(!resolved.excluded_headers.contains("x-custom"));
597    }
598
599    #[test]
600    fn non_none_route_sets_replace_and_unset_inherit() {
601        let global = config_of(&["debug", "trace"]);
602        let route = RouteDetectionExclusions {
603            excluded_detection_params: Some(vec!["session".to_owned()]),
604            ..RouteDetectionExclusions::default()
605        };
606        let resolved = resolve(Some(&global), Some(&route));
607        assert!(resolved.excluded_params.contains("session"));
608        assert!(!resolved.excluded_params.contains("debug"));
609
610        // An unset route surface inherits the global set.
611        let inheriting = resolve(Some(&global), Some(&RouteDetectionExclusions::default()));
612        assert!(inheriting.excluded_params.contains("debug"));
613        assert!(inheriting.excluded_params.contains("trace"));
614    }
615
616    #[test]
617    fn header_set_merges_defaults_config_and_route() {
618        let global = DetectionExclusionConfig {
619            excluded_detection_headers: vec!["X-Custom-Global".to_owned()],
620            ..DetectionExclusionConfig::default()
621        };
622        let route = RouteDetectionExclusions {
623            excluded_detection_headers: Some(vec!["x-custom-route".to_owned()]),
624            ..RouteDetectionExclusions::default()
625        };
626        let resolved = resolve(Some(&global), Some(&route));
627        assert!(resolved.excluded_headers.contains("x-custom-global"));
628        assert!(resolved.excluded_headers.contains("x-custom-route"));
629        assert!(
630            resolved.excluded_headers.contains("referer"),
631            "defaults survive"
632        );
633    }
634
635    #[test]
636    fn empty_route_category_set_disables_every_category() {
637        let route = RouteDetectionExclusions {
638            enabled_detection_categories: Some(Vec::new()),
639            ..RouteDetectionExclusions::default()
640        };
641        let resolved = resolve(None, Some(&route));
642        let enabled = resolved.enabled_categories.clone().expect("some");
643        assert!(enabled.is_empty(), "an empty Some replaces, not inherits");
644        assert!(!category_enabled(
645            "sqli",
646            resolved.enabled_categories.as_ref()
647        ));
648
649        // Without the route override the global set decides.
650        let global = DetectionExclusionConfig {
651            enabled_detection_categories: Some(vec!["sqli".to_owned()]),
652            ..DetectionExclusionConfig::default()
653        };
654        let inheriting = resolve(Some(&global), None);
655        assert!(category_enabled(
656            "sqli",
657            inheriting.enabled_categories.as_ref()
658        ));
659        assert!(!category_enabled(
660            "xss",
661            inheriting.enabled_categories.as_ref()
662        ));
663    }
664
665    #[test]
666    fn scan_body_toggle_overrides_both_directions() {
667        // Route false over a global true.
668        let global = DetectionExclusionConfig {
669            detection_scan_body: Some(true),
670            ..DetectionExclusionConfig::default()
671        };
672        let route = RouteDetectionExclusions {
673            detection_scan_body: Some(false),
674            ..RouteDetectionExclusions::default()
675        };
676        assert!(!resolve(Some(&global), Some(&route)).scan_body);
677
678        // Route true over a global false.
679        let global = DetectionExclusionConfig {
680            detection_scan_body: Some(false),
681            ..DetectionExclusionConfig::default()
682        };
683        let route = RouteDetectionExclusions {
684            detection_scan_body: Some(true),
685            ..RouteDetectionExclusions::default()
686        };
687        assert!(resolve(Some(&global), Some(&route)).scan_body);
688
689        // Global false with no route flag.
690        let global = DetectionExclusionConfig {
691            detection_scan_body: Some(false),
692            ..DetectionExclusionConfig::default()
693        };
694        assert!(!resolve(Some(&global), None).scan_body);
695    }
696
697    #[test]
698    fn the_scan_value_budget_caps_the_values_scanned() {
699        let mut config = corpus_config();
700        config.max_scan_values = 2;
701        let exclusions = resolve(None, None);
702        // Five candidate values; the threat sits in the third, past the cap.
703        let surfaces = RequestSurfaces {
704            url_path: Some("/scan"),
705            query_params: &[
706                ("a".to_owned(), "benign-one".to_owned()),
707                ("b".to_owned(), "benign-two".to_owned()),
708                ("c".to_owned(), "1 UNION SELECT password".to_owned()),
709                ("d".to_owned(), "benign-four".to_owned()),
710                ("e".to_owned(), "<script>alert(1)</script>".to_owned()),
711            ],
712            headers: &[],
713            content_type: "",
714            raw_body: "",
715        };
716        let verdict = scan_request(&surfaces, &exclusions, &config);
717        assert!(
718            !verdict.is_threat,
719            "values past the detection_max_scan_values cap are not scanned"
720        );
721
722        // The same request under the default budget detects.
723        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
724        assert!(verdict.is_threat);
725    }
726
727    #[test]
728    fn the_scan_char_budget_caps_the_characters_scanned() {
729        let mut config = corpus_config();
730        // The two benign values burn the budget; the third is skipped.
731        config.max_scan_chars = "benign-one".chars().count() + "benign-two".chars().count();
732        let exclusions = resolve(None, None);
733        let surfaces = RequestSurfaces {
734            url_path: Some("/scan"),
735            query_params: &[
736                ("a".to_owned(), "benign-one".to_owned()),
737                ("b".to_owned(), "benign-two".to_owned()),
738                ("c".to_owned(), "1 UNION SELECT password".to_owned()),
739            ],
740            headers: &[],
741            content_type: "",
742            raw_body: "",
743        };
744        let verdict = scan_request(&surfaces, &exclusions, &config);
745        assert!(
746            !verdict.is_threat,
747            "values past the detection_max_scan_chars cap are not scanned"
748        );
749    }
750
751    #[test]
752    fn the_budgets_count_every_candidate_even_skipped_ones() {
753        let mut config = corpus_config();
754        // The value budget counts all three candidates even though the
755        // char budget stops the scan after the first.
756        config.max_scan_values = 3;
757        config.max_scan_chars = 0;
758        let exclusions = resolve(None, None);
759        let surfaces = RequestSurfaces {
760            url_path: Some("/scan"),
761            query_params: &[
762                ("a".to_owned(), "1 UNION SELECT password".to_owned()),
763                ("b".to_owned(), "<script>alert(1)</script>".to_owned()),
764                ("c".to_owned(), "benign".to_owned()),
765            ],
766            headers: &[],
767            content_type: "",
768            raw_body: "",
769        };
770        let verdict = scan_request(&surfaces, &exclusions, &config);
771        assert!(
772            !verdict.is_threat,
773            "a zero char budget skips every value (the count still ran)"
774        );
775    }
776    #[test]
777    fn scan_body_false_still_scans_headers_params_and_path() {
778        let route = RouteDetectionExclusions {
779            detection_scan_body: Some(false),
780            ..RouteDetectionExclusions::default()
781        };
782        let exclusions = resolve(None, Some(&route));
783        let surfaces = RequestSurfaces {
784            url_path: Some("/files?x=../../etc/passwd"),
785            query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
786            headers: &[(
787                "x-payload".to_owned(),
788                "<script>alert(1)</script>".to_owned(),
789            )],
790            content_type: "application/x-www-form-urlencoded",
791            raw_body: "payload=<img src=x onerror=alert(1)>",
792        };
793        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
794        assert!(
795            verdict.is_threat,
796            "the body surface is skipped, the rest still detect"
797        );
798    }
799
800    #[test]
801    fn excluded_param_skips_only_itself() {
802        let global = config_of(&["debug"]);
803        let exclusions = resolve(Some(&global), None);
804        let surfaces = RequestSurfaces {
805            url_path: Some("/search"),
806            query_params: &[
807                ("debug".to_owned(), "1 UNION SELECT password".to_owned()),
808                ("q".to_owned(), "<script>alert(1)</script>".to_owned()),
809            ],
810            headers: &[],
811            content_type: "",
812            raw_body: "",
813        };
814        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
815        assert!(verdict.is_threat);
816        assert!(
817            !verdict.categories.contains(&"sqli".to_owned()),
818            "the excluded param did not contribute"
819        );
820        // Case-insensitive: DEBUG is excluded too.
821        let surfaces = RequestSurfaces {
822            url_path: Some("/search"),
823            query_params: &[("DEBUG".to_owned(), "1 UNION SELECT password".to_owned())],
824            headers: &[],
825            content_type: "",
826            raw_body: "",
827        };
828        assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
829    }
830
831    #[test]
832    fn excluded_body_field_skips_only_that_field() {
833        let global = DetectionExclusionConfig {
834            excluded_detection_body_fields: vec!["secret".to_owned()],
835            ..DetectionExclusionConfig::default()
836        };
837        let exclusions = resolve(Some(&global), None);
838        let surfaces = RequestSurfaces {
839            url_path: Some("/api"),
840            query_params: &[],
841            headers: &[],
842            content_type: "application/x-www-form-urlencoded",
843            raw_body: "secret=1 UNION SELECT password&safe=<script>alert(1)</script>",
844        };
845        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
846        assert!(verdict.is_threat, "the untouched field still scans");
847        assert_eq!(verdict.categories, vec!["xss"]);
848
849        // And the excluded field alone is clean.
850        let surfaces = RequestSurfaces {
851            url_path: Some("/api"),
852            query_params: &[],
853            headers: &[],
854            content_type: "application/x-www-form-urlencoded",
855            raw_body: "secret=1 UNION SELECT password",
856        };
857        assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
858    }
859
860    #[test]
861    fn excluded_header_with_attack_payload_still_detects() {
862        let exclusions = resolve(None, None);
863        let surfaces = RequestSurfaces {
864            url_path: Some("/x"),
865            query_params: &[],
866            headers: &[(
867                "x-custom".to_owned(),
868                "<script>alert(1)</script>".to_owned(),
869            )],
870            content_type: "",
871            raw_body: "",
872        };
873        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
874        assert!(verdict.is_threat, "an excluded header is not a skip hole");
875        assert_eq!(verdict.categories, vec!["xss"]);
876    }
877
878    #[test]
879    fn address_chain_headers_skip_ssrf_only() {
880        // A whole address chain in an unlisted excluded header reads as a
881        // proxy chain, not an ssrf probe.
882        let chain = "10.0.0.5, 172.16.0.1";
883        assert_eq!(
884            excluded_header_skip_categories("x-custom", chain),
885            &["ssrf"][..]
886        );
887        assert_eq!(
888            excluded_header_skip_categories("via", chain),
889            &["ssrf"][..],
890            "the address-carrying name skips for any value"
891        );
892        assert_eq!(
893            excluded_header_skip_categories("x-custom", "<script>alert(1)</script>"),
894            &[] as &[&str],
895            "a non-address value skips nothing"
896        );
897        assert_eq!(
898            excluded_header_skip_categories("x-custom", "10.0.0.5, not-an-ip"),
899            &[] as &[&str],
900            "one non-address token breaks the chain"
901        );
902    }
903
904    #[test]
905    fn port_stripping_feeds_the_address_parser() {
906        assert_eq!(strip_forwarded_entry_port("1.2.3.4:8080"), "1.2.3.4");
907        assert_eq!(strip_forwarded_entry_port("[::1]:8080"), "::1");
908        assert_eq!(strip_forwarded_entry_port("::1"), "::1");
909        assert_eq!(strip_forwarded_entry_port("not-an-ip"), "not-an-ip");
910        assert_eq!(
911            strip_forwarded_entry_port("[::1]:nope"),
912            "[::1]:nope",
913            "a non-numeric port keeps the value unparsed"
914        );
915        assert!(
916            value_looks_like_address_chain("1.2.3.4:8080, [::1]:8080"),
917            "socket-literal entries parse once the port is stripped"
918        );
919    }
920
921    #[test]
922    fn filtered_out_threat_ends_the_scan_clean() {
923        // The reference's terminal per-value filter: a threat whose
924        // categories are all disabled ends the scan with a clean verdict
925        // instead of continuing to later values.
926        let route = RouteDetectionExclusions {
927            enabled_detection_categories: Some(vec!["xss".to_owned()]),
928            ..RouteDetectionExclusions::default()
929        };
930        let exclusions = resolve(None, Some(&route));
931        let surfaces = RequestSurfaces {
932            url_path: Some("/x"),
933            query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
934            headers: &[],
935            content_type: "",
936            raw_body: "",
937        };
938        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
939        assert!(!verdict.is_threat);
940        assert!(verdict.categories.is_empty());
941    }
942
943    #[test]
944    fn enabled_categories_route_the_verdict() {
945        let route = RouteDetectionExclusions {
946            enabled_detection_categories: Some(vec!["sqli".to_owned()]),
947            ..RouteDetectionExclusions::default()
948        };
949        let exclusions = resolve(None, Some(&route));
950        let surfaces = RequestSurfaces {
951            url_path: Some("/x"),
952            query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
953            headers: &[(
954                "x-payload".to_owned(),
955                "<script>alert(1)</script>".to_owned(),
956            )],
957            content_type: "",
958            raw_body: "",
959        };
960        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
961        assert!(verdict.is_threat);
962        assert_eq!(verdict.categories, vec!["sqli"]);
963        assert!(verdict.reason.contains("sqli"));
964        assert!(!verdict.reason.contains("xss"), "xss is not enabled here");
965    }
966
967    #[test]
968    fn mongo_operator_keys_hit_through_the_body_walk() {
969        let exclusions = resolve(None, None);
970        let surfaces = RequestSurfaces {
971            url_path: Some("/x"),
972            query_params: &[],
973            headers: &[],
974            content_type: "application/json",
975            raw_body: "{\"$where\": \"1 OR 1=1\"}",
976        };
977        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
978        assert!(verdict.is_threat);
979        assert_eq!(verdict.categories, vec!["nosql"]);
980    }
981
982    #[test]
983    fn route_toggles_and_liveness_mirror_the_reference() {
984        // Request-time: the route flag overrides both directions.
985        assert!(detection_enabled(false, Some(true)));
986        assert!(!detection_enabled(true, Some(false)));
987        assert!(detection_enabled(true, None));
988        assert!(!detection_enabled(false, None));
989
990        // Check-set liveness: alive while any route enables the toggle.
991        assert!(check_applies(false, [false, true], false));
992        assert!(!check_applies(false, [false, false], false));
993        assert!(check_applies(true, [], false));
994        assert!(
995            check_applies(false, [false], true),
996            "dynamic rules keep it alive"
997        );
998    }
999}
1000
1001#[cfg(test)]
1002mod gap_tests {
1003    use super::*;
1004
1005    fn config() -> DetectConfig {
1006        DetectConfig {
1007            max_content_length: 10_000,
1008            max_full_scan_bytes: 262_144,
1009            preserve_attack_patterns: true,
1010            semantic_threshold: 0.7,
1011            threat_score_threshold: 1.0,
1012            binary_min_run_length: 16,
1013            max_scan_values: 512,
1014            max_scan_chars: 65_536,
1015            max_json_depth: 32,
1016        }
1017    }
1018
1019    #[test]
1020    fn an_unterminated_bracket_entry_keeps_its_port_text() {
1021        assert_eq!(strip_forwarded_entry_port("[::1"), "[::1");
1022    }
1023
1024    #[test]
1025    fn a_chain_of_only_empty_tokens_is_not_an_address_chain() {
1026        assert!(!value_looks_like_address_chain(" , ,, "));
1027    }
1028
1029    #[test]
1030    fn empty_header_values_never_scan() {
1031        let exclusions = ResolvedExclusions {
1032            excluded_params: std::collections::HashSet::new(),
1033            excluded_headers: std::collections::HashSet::new(),
1034            excluded_body_fields: std::collections::HashSet::new(),
1035            enabled_categories: None,
1036            scan_body: false,
1037        };
1038        let headers = vec![
1039            ("x-empty".to_owned(), String::new()),
1040            ("x-attack".to_owned(), "10.0.0.5, 127.0.0.1".to_owned()),
1041        ];
1042        let verdict = scan_request(
1043            &RequestSurfaces {
1044                url_path: None,
1045                query_params: &[],
1046                headers: &headers,
1047                content_type: "",
1048                raw_body: "",
1049            },
1050            &exclusions,
1051            &config(),
1052        );
1053        // only the non-empty header scans
1054        assert!(verdict.is_threat);
1055        assert_eq!(verdict.categories, vec!["ssrf".to_owned()]);
1056    }
1057
1058    #[test]
1059    fn an_excluded_address_header_suppresses_its_ssrf_category() {
1060        let mut excluded_headers = std::collections::HashSet::new();
1061        excluded_headers.insert("x-forwarded-for".to_owned());
1062        let exclusions = ResolvedExclusions {
1063            excluded_params: std::collections::HashSet::new(),
1064            excluded_headers,
1065            excluded_body_fields: std::collections::HashSet::new(),
1066            enabled_categories: None,
1067            scan_body: false,
1068        };
1069        let headers = vec![(
1070            "x-forwarded-for".to_owned(),
1071            "10.0.0.5, 127.0.0.1".to_owned(),
1072        )];
1073        let verdict = scan_request(
1074            &RequestSurfaces {
1075                url_path: None,
1076                query_params: &[],
1077                headers: &headers,
1078                content_type: "",
1079                raw_body: "",
1080            },
1081            &exclusions,
1082            &config(),
1083        );
1084        // the ssrf category is filtered out, and with nothing left the scan
1085        // ends clean (the terminal per-value filter)
1086        assert!(!verdict.is_threat);
1087        assert!(verdict.categories.is_empty());
1088    }
1089
1090    #[test]
1091    fn a_semantic_threat_contributes_no_category_to_the_reason() {
1092        // the obfuscated keyword blob trips the semantic analyzer alongside
1093        // the regex sqli hits: only the regex categories are reportable
1094        let content = format!(
1095            "select union insert update delete drop from where order group having concat \
1096             substring database table column (1 OR 1=1) {}",
1097            "A".repeat(120)
1098        );
1099        let verdict = scan_request(
1100            &RequestSurfaces {
1101                url_path: None,
1102                query_params: &[],
1103                headers: &[],
1104                content_type: "text/plain",
1105                raw_body: &content,
1106            },
1107            &ResolvedExclusions {
1108                excluded_params: std::collections::HashSet::new(),
1109                excluded_headers: std::collections::HashSet::new(),
1110                excluded_body_fields: std::collections::HashSet::new(),
1111                enabled_categories: None,
1112                scan_body: true,
1113            },
1114            &config(),
1115        );
1116        assert!(verdict.is_threat);
1117        assert_eq!(verdict.categories, vec!["sqli".to_owned()]);
1118    }
1119}