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 multi-surface scan (`detectThreat`).
340///
341/// Scan the request surfaces in the reference order - URL path, query
342/// params (skipping excluded names), headers (excluded names scan with
343/// their known-false-positive categories suppressed), then the body surface
344/// when [`ResolvedExclusions::scan_body`] is set - and return the first
345/// value that produces an enabled category.
346///
347/// A value that is a threat but whose categories are all filtered out ends
348/// the scan with a clean verdict, exactly like the reference: the
349/// per-value category filter is terminal, not a reason to keep scanning
350/// later values. Regex threats carry the category; semantic threats carry
351/// none (the reference's semantic payloads have no `category` and never
352/// contribute here), but still mark the value a threat.
353#[must_use]
354pub fn scan_request(
355    surfaces: &RequestSurfaces<'_>,
356    exclusions: &ResolvedExclusions,
357    config: &DetectConfig,
358) -> RequestScanVerdict {
359    struct Value<'a> {
360        content: &'a str,
361        context: &'a str,
362        skip_categories: &'static [&'static str],
363    }
364
365    let mut values: Vec<Value<'_>> = Vec::new();
366    if let Some(path) = surfaces.url_path.filter(|path| !path.is_empty()) {
367        values.push(Value {
368            content: path,
369            context: "url_path",
370            skip_categories: &[],
371        });
372    }
373    for (name, value) in surfaces.query_params {
374        if exclusions
375            .excluded_params
376            .contains(&name.to_ascii_lowercase())
377        {
378            continue;
379        }
380        values.push(Value {
381            content: value,
382            context: "query_param",
383            skip_categories: &[],
384        });
385    }
386    for (name, value) in surfaces.headers {
387        if value.is_empty() {
388            continue;
389        }
390        let skip_categories = if exclusions
391            .excluded_headers
392            .contains(&name.to_ascii_lowercase())
393        {
394            excluded_header_skip_categories(name, value)
395        } else {
396            &[]
397        };
398        values.push(Value {
399            content: value,
400            context: "header",
401            skip_categories,
402        });
403    }
404    // The body values borrow the extraction's owned strings, so the owned
405    // vector is kept in a binding that outlives the scan loop.
406    let body_values: Vec<BodyScanValue> = if exclusions.scan_body {
407        crate::body_scan::extract_body_scan_values_with_exclusions(
408            surfaces.raw_body,
409            surfaces.content_type,
410            config,
411            crate::body_scan::ExcludedBodyFields::new(&exclusions.excluded_body_fields),
412        )
413    } else {
414        Vec::new()
415    };
416    for value in &body_values {
417        if let Some(forced) = value.forced_category {
418            // JSON mongo-operator keys hit straight from the walk
419            // (`_mongo_operator_key_hit`), unfiltered by the enabled
420            // categories.
421            return forced_verdict(core::slice::from_ref(&forced));
422        }
423        values.push(Value {
424            content: &value.content,
425            context: &value.context,
426            skip_categories: &[],
427        });
428    }
429
430    for value in &values {
431        let verdict = detect::detect(value.content, value.context, config);
432        if !verdict.is_threat {
433            continue;
434        }
435        let mut categories: Vec<String> = Vec::new();
436        for threat in &verdict.threats {
437            let category = match threat {
438                Threat::Regex(regex) => Some(regex.category.clone()),
439                // Semantic threats carry no category (the reference's
440                // semantic payloads have no `category` key and are
441                // skipped by the `category == ""` guard).
442                Threat::Semantic(_) => None,
443            };
444            let Some(category) = category else {
445                continue;
446            };
447            if !category_enabled(&category, exclusions.enabled_categories.as_ref())
448                || value.skip_categories.contains(&category.as_str())
449                || categories.contains(&category)
450            {
451                continue;
452            }
453            categories.push(category);
454        }
455        if categories.is_empty() {
456            // A threat whose categories are all filtered out ends the scan
457            // clean, exactly the reference's terminal per-value filter.
458            return RequestScanVerdict::default();
459        }
460        categories.sort();
461        return verdict_from(categories);
462    }
463
464    RequestScanVerdict::default()
465}
466
467/// Forced-category verdict (`_mongo_operator_key_hit`): the category
468/// reports without a pattern scan or a category filter.
469fn forced_verdict(categories: &[&str]) -> RequestScanVerdict {
470    let categories: Vec<String> = categories.iter().map(|c| (*c).to_owned()).collect();
471    verdict_from(categories)
472}
473
474fn verdict_from(mut categories: Vec<String>) -> RequestScanVerdict {
475    categories.sort();
476    let reason = format!("Penetration patterns detected: {}", categories.join(", "));
477    RequestScanVerdict {
478        is_threat: true,
479        categories,
480        reason,
481    }
482}
483
484fn category_enabled(category: &str, enabled: Option<&HashSet<String>>) -> bool {
485    enabled.is_none_or(|set| set.contains(category))
486}
487
488/// The route toggle's request-time semantics.
489///
490/// `route_config.enable_suspicious_detection`: a route carrying the flag
491/// overrides the global `enable_penetration_detection` in both directions;
492/// without a route flag the global value applies.
493#[must_use]
494pub fn detection_enabled(global_enabled: bool, route_enabled: Option<bool>) -> bool {
495    route_enabled.unwrap_or(global_enabled)
496}
497
498/// The check-set liveness semantics (`SuspiciousActivityCheck.applies_to`).
499///
500/// The suspicious-activity check stays alive while the global flag is on or
501/// any route enables its toggle (or dynamic rules are on, which can route
502/// requests anywhere).
503#[must_use]
504pub fn check_applies(
505    global_enabled: bool,
506    route_toggles: impl IntoIterator<Item = bool>,
507    dynamic_rules: bool,
508) -> bool {
509    global_enabled || route_toggles.into_iter().any(|flag| flag) || dynamic_rules
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use crate::detect::DetectConfig;
516
517    fn corpus_config() -> DetectConfig {
518        DetectConfig {
519            max_content_length: 10_000,
520            max_full_scan_bytes: 262_144,
521            preserve_attack_patterns: true,
522            semantic_threshold: 0.7,
523            threat_score_threshold: 1.0,
524            binary_min_run_length: 16,
525        }
526    }
527
528    fn config_of(params: &[&str]) -> DetectionExclusionConfig {
529        DetectionExclusionConfig {
530            excluded_detection_params: params.iter().map(|s| (*s).to_owned()).collect(),
531            ..DetectionExclusionConfig::default()
532        }
533    }
534
535    #[test]
536    fn resolve_defaults_scan_everything() {
537        let resolved = resolve(None, None);
538        assert!(resolved.excluded_params.is_empty());
539        assert!(resolved.excluded_body_fields.is_empty());
540        assert!(resolved.enabled_categories.is_none());
541        assert!(resolved.scan_body);
542        // The hardcoded header defaults are present with no config at all.
543        assert!(resolved.excluded_headers.contains("host"));
544        assert!(resolved.excluded_headers.contains("x-forwarded-for"));
545        assert!(!resolved.excluded_headers.contains("x-custom"));
546    }
547
548    #[test]
549    fn non_none_route_sets_replace_and_unset_inherit() {
550        let global = config_of(&["debug", "trace"]);
551        let route = RouteDetectionExclusions {
552            excluded_detection_params: Some(vec!["session".to_owned()]),
553            ..RouteDetectionExclusions::default()
554        };
555        let resolved = resolve(Some(&global), Some(&route));
556        assert!(resolved.excluded_params.contains("session"));
557        assert!(!resolved.excluded_params.contains("debug"));
558
559        // An unset route surface inherits the global set.
560        let inheriting = resolve(Some(&global), Some(&RouteDetectionExclusions::default()));
561        assert!(inheriting.excluded_params.contains("debug"));
562        assert!(inheriting.excluded_params.contains("trace"));
563    }
564
565    #[test]
566    fn header_set_merges_defaults_config_and_route() {
567        let global = DetectionExclusionConfig {
568            excluded_detection_headers: vec!["X-Custom-Global".to_owned()],
569            ..DetectionExclusionConfig::default()
570        };
571        let route = RouteDetectionExclusions {
572            excluded_detection_headers: Some(vec!["x-custom-route".to_owned()]),
573            ..RouteDetectionExclusions::default()
574        };
575        let resolved = resolve(Some(&global), Some(&route));
576        assert!(resolved.excluded_headers.contains("x-custom-global"));
577        assert!(resolved.excluded_headers.contains("x-custom-route"));
578        assert!(
579            resolved.excluded_headers.contains("referer"),
580            "defaults survive"
581        );
582    }
583
584    #[test]
585    fn empty_route_category_set_disables_every_category() {
586        let route = RouteDetectionExclusions {
587            enabled_detection_categories: Some(Vec::new()),
588            ..RouteDetectionExclusions::default()
589        };
590        let resolved = resolve(None, Some(&route));
591        let enabled = resolved.enabled_categories.clone().expect("some");
592        assert!(enabled.is_empty(), "an empty Some replaces, not inherits");
593        assert!(!category_enabled(
594            "sqli",
595            resolved.enabled_categories.as_ref()
596        ));
597
598        // Without the route override the global set decides.
599        let global = DetectionExclusionConfig {
600            enabled_detection_categories: Some(vec!["sqli".to_owned()]),
601            ..DetectionExclusionConfig::default()
602        };
603        let inheriting = resolve(Some(&global), None);
604        assert!(category_enabled(
605            "sqli",
606            inheriting.enabled_categories.as_ref()
607        ));
608        assert!(!category_enabled(
609            "xss",
610            inheriting.enabled_categories.as_ref()
611        ));
612    }
613
614    #[test]
615    fn scan_body_toggle_overrides_both_directions() {
616        // Route false over a global true.
617        let global = DetectionExclusionConfig {
618            detection_scan_body: Some(true),
619            ..DetectionExclusionConfig::default()
620        };
621        let route = RouteDetectionExclusions {
622            detection_scan_body: Some(false),
623            ..RouteDetectionExclusions::default()
624        };
625        assert!(!resolve(Some(&global), Some(&route)).scan_body);
626
627        // Route true over a global false.
628        let global = DetectionExclusionConfig {
629            detection_scan_body: Some(false),
630            ..DetectionExclusionConfig::default()
631        };
632        let route = RouteDetectionExclusions {
633            detection_scan_body: Some(true),
634            ..RouteDetectionExclusions::default()
635        };
636        assert!(resolve(Some(&global), Some(&route)).scan_body);
637
638        // Global false with no route flag.
639        let global = DetectionExclusionConfig {
640            detection_scan_body: Some(false),
641            ..DetectionExclusionConfig::default()
642        };
643        assert!(!resolve(Some(&global), None).scan_body);
644    }
645
646    #[test]
647    fn scan_body_false_still_scans_headers_params_and_path() {
648        let route = RouteDetectionExclusions {
649            detection_scan_body: Some(false),
650            ..RouteDetectionExclusions::default()
651        };
652        let exclusions = resolve(None, Some(&route));
653        let surfaces = RequestSurfaces {
654            url_path: Some("/files?x=../../etc/passwd"),
655            query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
656            headers: &[(
657                "x-payload".to_owned(),
658                "<script>alert(1)</script>".to_owned(),
659            )],
660            content_type: "application/x-www-form-urlencoded",
661            raw_body: "payload=<img src=x onerror=alert(1)>",
662        };
663        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
664        assert!(
665            verdict.is_threat,
666            "the body surface is skipped, the rest still detect"
667        );
668    }
669
670    #[test]
671    fn excluded_param_skips_only_itself() {
672        let global = config_of(&["debug"]);
673        let exclusions = resolve(Some(&global), None);
674        let surfaces = RequestSurfaces {
675            url_path: Some("/search"),
676            query_params: &[
677                ("debug".to_owned(), "1 UNION SELECT password".to_owned()),
678                ("q".to_owned(), "<script>alert(1)</script>".to_owned()),
679            ],
680            headers: &[],
681            content_type: "",
682            raw_body: "",
683        };
684        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
685        assert!(verdict.is_threat);
686        assert!(
687            !verdict.categories.contains(&"sqli".to_owned()),
688            "the excluded param did not contribute"
689        );
690        // Case-insensitive: DEBUG is excluded too.
691        let surfaces = RequestSurfaces {
692            url_path: Some("/search"),
693            query_params: &[("DEBUG".to_owned(), "1 UNION SELECT password".to_owned())],
694            headers: &[],
695            content_type: "",
696            raw_body: "",
697        };
698        assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
699    }
700
701    #[test]
702    fn excluded_body_field_skips_only_that_field() {
703        let global = DetectionExclusionConfig {
704            excluded_detection_body_fields: vec!["secret".to_owned()],
705            ..DetectionExclusionConfig::default()
706        };
707        let exclusions = resolve(Some(&global), None);
708        let surfaces = RequestSurfaces {
709            url_path: Some("/api"),
710            query_params: &[],
711            headers: &[],
712            content_type: "application/x-www-form-urlencoded",
713            raw_body: "secret=1 UNION SELECT password&safe=<script>alert(1)</script>",
714        };
715        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
716        assert!(verdict.is_threat, "the untouched field still scans");
717        assert_eq!(verdict.categories, vec!["xss"]);
718
719        // And the excluded field alone is clean.
720        let surfaces = RequestSurfaces {
721            url_path: Some("/api"),
722            query_params: &[],
723            headers: &[],
724            content_type: "application/x-www-form-urlencoded",
725            raw_body: "secret=1 UNION SELECT password",
726        };
727        assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
728    }
729
730    #[test]
731    fn excluded_header_with_attack_payload_still_detects() {
732        let exclusions = resolve(None, None);
733        let surfaces = RequestSurfaces {
734            url_path: Some("/x"),
735            query_params: &[],
736            headers: &[(
737                "x-custom".to_owned(),
738                "<script>alert(1)</script>".to_owned(),
739            )],
740            content_type: "",
741            raw_body: "",
742        };
743        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
744        assert!(verdict.is_threat, "an excluded header is not a skip hole");
745        assert_eq!(verdict.categories, vec!["xss"]);
746    }
747
748    #[test]
749    fn address_chain_headers_skip_ssrf_only() {
750        // A whole address chain in an unlisted excluded header reads as a
751        // proxy chain, not an ssrf probe.
752        let chain = "10.0.0.5, 172.16.0.1";
753        assert_eq!(
754            excluded_header_skip_categories("x-custom", chain),
755            &["ssrf"][..]
756        );
757        assert_eq!(
758            excluded_header_skip_categories("via", chain),
759            &["ssrf"][..],
760            "the address-carrying name skips for any value"
761        );
762        assert_eq!(
763            excluded_header_skip_categories("x-custom", "<script>alert(1)</script>"),
764            &[] as &[&str],
765            "a non-address value skips nothing"
766        );
767        assert_eq!(
768            excluded_header_skip_categories("x-custom", "10.0.0.5, not-an-ip"),
769            &[] as &[&str],
770            "one non-address token breaks the chain"
771        );
772    }
773
774    #[test]
775    fn port_stripping_feeds_the_address_parser() {
776        assert_eq!(strip_forwarded_entry_port("1.2.3.4:8080"), "1.2.3.4");
777        assert_eq!(strip_forwarded_entry_port("[::1]:8080"), "::1");
778        assert_eq!(strip_forwarded_entry_port("::1"), "::1");
779        assert_eq!(strip_forwarded_entry_port("not-an-ip"), "not-an-ip");
780        assert_eq!(
781            strip_forwarded_entry_port("[::1]:nope"),
782            "[::1]:nope",
783            "a non-numeric port keeps the value unparsed"
784        );
785        assert!(
786            value_looks_like_address_chain("1.2.3.4:8080, [::1]:8080"),
787            "socket-literal entries parse once the port is stripped"
788        );
789    }
790
791    #[test]
792    fn filtered_out_threat_ends_the_scan_clean() {
793        // The reference's terminal per-value filter: a threat whose
794        // categories are all disabled ends the scan with a clean verdict
795        // instead of continuing to later values.
796        let route = RouteDetectionExclusions {
797            enabled_detection_categories: Some(vec!["xss".to_owned()]),
798            ..RouteDetectionExclusions::default()
799        };
800        let exclusions = resolve(None, Some(&route));
801        let surfaces = RequestSurfaces {
802            url_path: Some("/x"),
803            query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
804            headers: &[],
805            content_type: "",
806            raw_body: "",
807        };
808        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
809        assert!(!verdict.is_threat);
810        assert!(verdict.categories.is_empty());
811    }
812
813    #[test]
814    fn enabled_categories_route_the_verdict() {
815        let route = RouteDetectionExclusions {
816            enabled_detection_categories: Some(vec!["sqli".to_owned()]),
817            ..RouteDetectionExclusions::default()
818        };
819        let exclusions = resolve(None, Some(&route));
820        let surfaces = RequestSurfaces {
821            url_path: Some("/x"),
822            query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
823            headers: &[(
824                "x-payload".to_owned(),
825                "<script>alert(1)</script>".to_owned(),
826            )],
827            content_type: "",
828            raw_body: "",
829        };
830        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
831        assert!(verdict.is_threat);
832        assert_eq!(verdict.categories, vec!["sqli"]);
833        assert!(verdict.reason.contains("sqli"));
834        assert!(!verdict.reason.contains("xss"), "xss is not enabled here");
835    }
836
837    #[test]
838    fn mongo_operator_keys_hit_through_the_body_walk() {
839        let exclusions = resolve(None, None);
840        let surfaces = RequestSurfaces {
841            url_path: Some("/x"),
842            query_params: &[],
843            headers: &[],
844            content_type: "application/json",
845            raw_body: "{\"$where\": \"1 OR 1=1\"}",
846        };
847        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
848        assert!(verdict.is_threat);
849        assert_eq!(verdict.categories, vec!["nosql"]);
850    }
851
852    #[test]
853    fn route_toggles_and_liveness_mirror_the_reference() {
854        // Request-time: the route flag overrides both directions.
855        assert!(detection_enabled(false, Some(true)));
856        assert!(!detection_enabled(true, Some(false)));
857        assert!(detection_enabled(true, None));
858        assert!(!detection_enabled(false, None));
859
860        // Check-set liveness: alive while any route enables the toggle.
861        assert!(check_applies(false, [false, true], false));
862        assert!(!check_applies(false, [false, false], false));
863        assert!(check_applies(true, [], false));
864        assert!(
865            check_applies(false, [false], true),
866            "dynamic rules keep it alive"
867        );
868    }
869}