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};
61use crate::scan_budget::{DEFAULT_MAX_SCAN_SECONDS, ScanTimeBudget, budget_exhausted_message};
62
63/// The categories an excluded header skips for a value known to
64/// false-positive them.
65const SSRF_SKIP: &[&str] = &["ssrf"];
66
67/// The hardcoded header exclusion defaults (`_DEFAULT_EXCLUDED_HEADERS`).
68///
69/// The proxy identity, forwarding, and browser-fingerprint headers that
70/// enter the detection scan through the excluded-header routing instead of
71/// the full category sweep. Lowercase names, always merged with the config
72/// and route sets.
73pub const DEFAULT_EXCLUDED_HEADERS: &[&str] = &[
74    "host",
75    "user-agent",
76    "accept",
77    "accept-encoding",
78    "connection",
79    "origin",
80    "referer",
81    "sec-fetch-site",
82    "sec-fetch-mode",
83    "sec-fetch-dest",
84    "sec-ch-ua",
85    "sec-ch-ua-mobile",
86    "sec-ch-ua-platform",
87    "forwarded",
88    "x-forwarded-for",
89    "x-forwarded-host",
90    "x-forwarded-proto",
91    "x-real-ip",
92    "x-client-ip",
93    "x-cluster-client-ip",
94    "cf-connecting-ip",
95    "true-client-ip",
96    "fly-client-ip",
97    "x-envoy-external-address",
98];
99
100/// `_HEADER_CATEGORY_EXCLUSIONS`'s address-carrying names: an excluded match
101/// on one of these headers skips the ssrf category for any value.
102const ADDRESS_HEADER_NAMES: &[&str] = &[
103    "host",
104    "origin",
105    "x-forwarded-for",
106    "x-forwarded-host",
107    "x-real-ip",
108    "x-client-ip",
109    "x-cluster-client-ip",
110    "cf-connecting-ip",
111    "true-client-ip",
112    "fly-client-ip",
113    "x-envoy-external-address",
114    "via",
115];
116
117/// The global config's detection-exclusion surface.
118///
119/// The reference `SecurityConfig` fields of the same names. The category
120/// set is `None` when nothing is configured (every category scans); the
121/// scan-body flag is `None` for the reference default (`true`).
122#[derive(Debug, Clone, Default, PartialEq)]
123pub struct DetectionExclusionConfig {
124    /// `excluded_detection_headers` (merged with the defaults, never a
125    /// replacement).
126    pub excluded_detection_headers: Vec<String>,
127    /// `excluded_semantic_detection_headers` (opt-in, default empty):
128    /// header names whose values skip the semantic analyzer only, matched
129    /// case-insensitively. Regex category scanning is unchanged.
130    pub excluded_semantic_detection_headers: Vec<String>,
131    /// `excluded_detection_params`.
132    pub excluded_detection_params: Vec<String>,
133    /// `excluded_detection_body_fields`.
134    pub excluded_detection_body_fields: Vec<String>,
135    /// `enabled_detection_categories`: `None` scans every category.
136    pub enabled_detection_categories: Option<Vec<String>>,
137    /// `detection_scan_body`: `None` is the reference default (`true`).
138    pub detection_scan_body: Option<bool>,
139    /// `detection_max_scan_seconds` (valid 0.1 to 60): the per-request cap on
140    /// the summed regex scan CPU time; `None` is the reference default
141    /// ([`DEFAULT_MAX_SCAN_SECONDS`], 10.0). See [`crate::scan_budget`].
142    pub max_scan_seconds: Option<f64>,
143}
144
145/// The route decorator's detection-exclusion surface.
146///
147/// The reference `RouteConfig` fields of the same names. A `None` field
148/// inherits the global resolution for that surface; a `Some` value
149/// replaces it (the header set is the exception: it always merges).
150#[derive(Debug, Clone, Default, PartialEq, Eq)]
151pub struct RouteDetectionExclusions {
152    /// `excluded_detection_headers` (merged with the defaults and the
153    /// config set).
154    pub excluded_detection_headers: Option<Vec<String>>,
155    /// `excluded_semantic_detection_headers`: merged with the global set,
156    /// never a replacement.
157    pub excluded_semantic_detection_headers: Option<Vec<String>>,
158    /// `excluded_detection_params`.
159    pub excluded_detection_params: Option<Vec<String>>,
160    /// `excluded_detection_body_fields`.
161    pub excluded_detection_body_fields: Option<Vec<String>>,
162    /// `enabled_detection_categories`: a `Some` (even empty) replaces the
163    /// global set - an empty set disables every category, the reference's
164    /// empty frozenset.
165    pub enabled_detection_categories: Option<Vec<String>>,
166    /// `detection_scan_body`: a `Some` overrides the global flag both
167    /// directions.
168    pub detection_scan_body: Option<bool>,
169}
170
171/// The resolved exclusion sets for one request (`routeDetectionExclusions`
172/// in the Go port).
173#[derive(Debug, Clone, Default, PartialEq)]
174pub struct ResolvedExclusions {
175    /// Lowercased: query-param names the scan skips.
176    pub excluded_params: HashSet<String>,
177    /// Lowercased: body field names the scan skips.
178    pub excluded_body_fields: HashSet<String>,
179    /// Lowercased: header names routed through the excluded-header scan
180    /// (defaults + config + route, merged).
181    pub excluded_headers: HashSet<String>,
182    /// Lowercased, trimmed: header names whose values skip the semantic
183    /// analyzer only (global + route, merged).
184    pub semantic_headers: HashSet<String>,
185    /// Lowercased: the categories that may contribute a detection. `None`
186    /// means every category scans.
187    pub enabled_categories: Option<HashSet<String>>,
188    /// Whether the body surface scans at all.
189    pub scan_body: bool,
190    /// The per-request scan time cap in seconds; `None` is the reference
191    /// default ([`DEFAULT_MAX_SCAN_SECONDS`]).
192    pub max_scan_seconds: Option<f64>,
193}
194
195/// Merge, trim and lowercase one list into the set
196/// (`_resolve_semantic_excluded_headers`).
197fn extend_trimmed_lowered(set: &mut HashSet<String>, values: impl IntoIterator<Item = String>) {
198    set.extend(
199        values
200            .into_iter()
201            .map(|value| value.trim().to_ascii_lowercase()),
202    );
203}
204
205/// Merge and lowercase one list into the set.
206fn extend_lowered(set: &mut HashSet<String>, values: impl IntoIterator<Item = String>) {
207    set.extend(values.into_iter().map(|value| value.to_ascii_lowercase()));
208}
209
210/// `_resolve_*`: every route surface overrides the global config when the
211/// route carries a non-`None` value, except the header set which merges
212/// defaults + config + route.
213#[must_use]
214pub fn resolve(
215    config: Option<&DetectionExclusionConfig>,
216    route: Option<&RouteDetectionExclusions>,
217) -> ResolvedExclusions {
218    let mut resolved = ResolvedExclusions::default();
219
220    // Header set: additive, always (defaults + config + route).
221    resolved
222        .excluded_headers
223        .extend(DEFAULT_EXCLUDED_HEADERS.iter().map(|h| (*h).to_owned()));
224    if let Some(config) = config {
225        extend_lowered(
226            &mut resolved.excluded_headers,
227            config.excluded_detection_headers.clone(),
228        );
229    }
230    if let Some(headers) = route.and_then(|route| route.excluded_detection_headers.as_ref()) {
231        extend_lowered(&mut resolved.excluded_headers, headers.clone());
232    }
233
234    // Semantic-only header set: additive (global + route), opt-in.
235    if let Some(config) = config {
236        extend_trimmed_lowered(
237            &mut resolved.semantic_headers,
238            config.excluded_semantic_detection_headers.clone(),
239        );
240    }
241    if let Some(headers) =
242        route.and_then(|route| route.excluded_semantic_detection_headers.as_ref())
243    {
244        extend_trimmed_lowered(&mut resolved.semantic_headers, headers.clone());
245    }
246
247    // Param / body-field / category sets: a non-None route value replaces
248    // the global one.
249    let params: Option<Vec<String>> = route
250        .and_then(|route| route.excluded_detection_params.clone())
251        .or_else(|| config.map(|config| config.excluded_detection_params.clone()));
252    if let Some(params) = params {
253        extend_lowered(&mut resolved.excluded_params, params);
254    }
255    let body_fields: Option<Vec<String>> = route
256        .and_then(|route| route.excluded_detection_body_fields.clone())
257        .or_else(|| config.map(|config| config.excluded_detection_body_fields.clone()));
258    if let Some(body_fields) = body_fields {
259        extend_lowered(&mut resolved.excluded_body_fields, body_fields);
260    }
261    let categories: Option<Vec<String>> = route
262        .and_then(|route| route.enabled_detection_categories.clone())
263        .or_else(|| config.and_then(|config| config.enabled_detection_categories.clone()));
264    if let Some(categories) = categories {
265        let mut set = HashSet::new();
266        extend_lowered(&mut set, categories);
267        resolved.enabled_categories = Some(set);
268    }
269
270    // `_resolve_scan_body`: route > config > the default of true.
271    resolved.scan_body = route
272        .and_then(|route| route.detection_scan_body)
273        .or_else(|| config.and_then(|config| config.detection_scan_body))
274        .unwrap_or(true);
275
276    // `_resolve_max_scan_seconds`: global only, `None` is the reference
277    // default.
278    resolved.max_scan_seconds = config.and_then(|config| config.max_scan_seconds);
279
280    resolved
281}
282
283/// The categories an excluded header skips for one value
284/// (`_excluded_header_skip_categories`).
285///
286/// Address-carrying proxy headers skip `ssrf` for any value; every other
287/// excluded header skips `ssrf` only when its whole value parses as an
288/// address chain (so an attack payload in the same header still detects).
289/// An empty slice means the header scans with every enabled category.
290#[must_use]
291pub fn excluded_header_skip_categories(name: &str, value: &str) -> &'static [&'static str] {
292    let normalized = name.trim().to_ascii_lowercase();
293    if ADDRESS_HEADER_NAMES.contains(&normalized.as_str()) {
294        return SSRF_SKIP;
295    }
296    if value_looks_like_address_chain(value) {
297        return SSRF_SKIP;
298    }
299    &[]
300}
301
302/// Remove the port from a forwarded-list entry
303/// (`_strip_forwarded_entry_port`).
304///
305/// `1.2.3.4:8080` -> `1.2.3.4`, `[::1]:8080` -> `::1`, so the address
306/// itself can be parsed.
307#[must_use]
308pub fn strip_forwarded_entry_port(value: &str) -> &str {
309    if let Some(rest) = value.strip_prefix('[') {
310        let Some(closing) = rest.find(']') else {
311            return value;
312        };
313        let remainder = &rest[closing + 1..];
314        let port_valid =
315            remainder.starts_with(':') && remainder[1..].bytes().all(|b| b.is_ascii_digit());
316        if !(remainder.is_empty() || port_valid) {
317            return value;
318        }
319        return &rest[..closing];
320    }
321    if value.matches(':').count() == 1
322        && let Some((host, port)) = value.split_once(':')
323        && !port.is_empty()
324        && port.bytes().all(|b| b.is_ascii_digit())
325    {
326        return host;
327    }
328    value
329}
330
331/// Address-chain detection (`_value_looks_like_address_chain`).
332///
333/// Every comma-separated token parses as an IP address once its port entry
334/// is stripped, so a value like `10.0.0.5, 172.16.0.1` reads as a proxy
335/// chain, not an attack payload.
336#[must_use]
337pub fn value_looks_like_address_chain(value: &str) -> bool {
338    let tokens: Vec<&str> = value
339        .split(',')
340        .map(str::trim)
341        .filter(|token| !token.is_empty())
342        .collect();
343    if tokens.is_empty() {
344        return false;
345    }
346    tokens.iter().all(|token| {
347        strip_forwarded_entry_port(token)
348            .parse::<std::net::IpAddr>()
349            .is_ok()
350    })
351}
352
353/// The values one header contributes to the scan (`_header_items_to_scan`).
354///
355/// The `Cookie` header scans one `name=value` pair at a time: as one value,
356/// the semicolons between cookies count as command separators in the
357/// semantic score. Only spaces around a pair are trimmed (so a CR or LF
358/// stays in the scanned value) and empty pairs are skipped. Every other
359/// header is a single value.
360fn header_items_to_scan<'a>(name: &str, value: &'a str) -> Vec<&'a str> {
361    if name.trim().eq_ignore_ascii_case("cookie") {
362        value
363            .split(';')
364            .map(|pair| pair.trim_matches(' '))
365            .filter(|pair| !pair.is_empty())
366            .collect()
367    } else {
368        vec![value]
369    }
370}
371
372/// The request surfaces one scan pass covers (the `detectThreat` input
373/// shape). Headers and query params are `(name, value)` pairs; body values
374/// are the [`crate::body_scan`] extraction's output.
375#[derive(Debug, Clone, Default)]
376pub struct RequestSurfaces<'a> {
377    /// The URL path, scanned first under the `url_path` context.
378    pub url_path: Option<&'a str>,
379    /// Query parameter pairs, scanned under the `query_param` context.
380    pub query_params: &'a [(String, String)],
381    /// Header pairs, scanned under the `header` context.
382    pub headers: &'a [(String, String)],
383    /// Content type of `raw_body` (the body extraction's router).
384    pub content_type: &'a str,
385    /// The raw request body, extracted into scan values when
386    /// [`ResolvedExclusions::scan_body`] is set.
387    pub raw_body: &'a str,
388}
389
390/// The multi-surface verdict (`detectThreat`'s return shape).
391#[derive(Debug, Clone, Default, PartialEq, Eq)]
392pub struct RequestScanVerdict {
393    /// `true` when some scanned value produced at least one enabled
394    /// category.
395    pub is_threat: bool,
396    /// The contributing categories, sorted (the reference emits them
397    /// alphabetically in the reason).
398    pub categories: Vec<String>,
399    /// The reference reason string naming the categories.
400    pub reason: String,
401}
402
403/// The per-request scan budgets (`detection_max_scan_values` /
404/// `detection_max_scan_chars`): the value count increments for every
405/// candidate, the character count at the same per-value point; once a cap
406/// is crossed the remaining request values are not scanned (the
407/// reference's fail-open signal, logged once per request there - the
408/// engine keeps its no-logger idiom, the budget answer is the behavior).
409struct ScanBudget {
410    max_values: usize,
411    scanned_values: usize,
412    max_chars: usize,
413    scanned_chars: usize,
414}
415
416impl ScanBudget {
417    const fn new(config: &DetectConfig) -> Self {
418        Self {
419            max_values: config.max_scan_values,
420            scanned_values: 0,
421            max_chars: config.max_scan_chars,
422            scanned_chars: 0,
423        }
424    }
425
426    /// Account one candidate; `true` when it may scan. Both counts run
427    /// regardless of earlier verdicts (the reference increments the value
428    /// count before the char check).
429    fn admit(&mut self, content: &str) -> bool {
430        self.scanned_values += 1;
431        if self.scanned_values > self.max_values {
432            return false;
433        }
434        let value_chars = content.chars().count();
435        if self.scanned_chars >= self.max_chars {
436            return false;
437        }
438        self.scanned_chars += value_chars;
439        true
440    }
441}
442/// One candidate value of the multi-surface scan.
443struct ScanValue<'a> {
444    content: &'a str,
445    context: &'a str,
446    skip_categories: &'static [&'static str],
447    skip_semantic: bool,
448}
449
450/// The header surface: one [`ScanValue`] per [`header_items_to_scan`] item,
451/// carrying the excluded-header category skips and the semantic-only skip.
452fn push_header_values<'a>(
453    values: &mut Vec<ScanValue<'a>>,
454    headers: &'a [(String, String)],
455    exclusions: &ResolvedExclusions,
456) {
457    for (name, value) in headers {
458        if value.is_empty() {
459            continue;
460        }
461        let skip_semantic = exclusions
462            .semantic_headers
463            .contains(&name.trim().to_ascii_lowercase());
464        let excluded = exclusions
465            .excluded_headers
466            .contains(&name.to_ascii_lowercase());
467        for pair in header_items_to_scan(name, value) {
468            values.push(ScanValue {
469                content: pair,
470                context: "header",
471                skip_categories: if excluded {
472                    excluded_header_skip_categories(name, pair)
473                } else {
474                    &[]
475                },
476                skip_semantic,
477            });
478        }
479    }
480}
481
482/// The multi-surface scan (`detectThreat`).
483///
484/// Scan the request surfaces in the reference order - URL path, query
485/// params (skipping excluded names), headers (excluded names scan with
486/// their known-false-positive categories suppressed, the `Cookie` header one
487/// `name=value` pair at a time, headers in the semantic-only set skipping
488/// the semantic analyzer), then the body surface
489/// when [`ResolvedExclusions::scan_body`] is set - and return the first
490/// value that produces an enabled category.
491///
492/// A value none of whose threats is reportable (every category filtered out
493/// or skipped) is not a threat: the scan continues with the next value,
494/// like the reference's `_check_value_enhanced` miss. Regex threats count
495/// only when their category is enabled and not skipped for the value;
496/// semantic threats are never category-filtered and report their attack
497/// type as the category (`_threat_category`). A threat with no threats list
498/// reports as `uncategorized`.
499#[must_use]
500pub fn scan_request(
501    surfaces: &RequestSurfaces<'_>,
502    exclusions: &ResolvedExclusions,
503    config: &DetectConfig,
504) -> RequestScanVerdict {
505    let mut values: Vec<ScanValue<'_>> = Vec::new();
506    if let Some(path) = surfaces.url_path.filter(|path| !path.is_empty()) {
507        values.push(ScanValue {
508            content: path,
509            context: "url_path",
510            skip_categories: &[],
511            skip_semantic: false,
512        });
513    }
514    for (name, value) in surfaces.query_params {
515        if exclusions
516            .excluded_params
517            .contains(&name.to_ascii_lowercase())
518        {
519            continue;
520        }
521        values.push(ScanValue {
522            content: value,
523            context: "query_param",
524            skip_categories: &[],
525            skip_semantic: false,
526        });
527    }
528    push_header_values(&mut values, surfaces.headers, exclusions);
529    // The body values borrow the extraction's owned strings, so the owned
530    // vector is kept in a binding that outlives the scan loop.
531    let body_values: Vec<BodyScanValue> = if exclusions.scan_body {
532        crate::body_scan::extract_body_scan_values_with_exclusions(
533            surfaces.raw_body,
534            surfaces.content_type,
535            config,
536            crate::body_scan::ExcludedBodyFields::new(&exclusions.excluded_body_fields),
537        )
538    } else {
539        Vec::new()
540    };
541    for value in &body_values {
542        if let Some(forced) = value.forced_category {
543            // JSON mongo-operator keys hit straight from the walk
544            // (`_mongo_operator_key_hit`), unfiltered by the enabled
545            // categories.
546            return forced_verdict(core::slice::from_ref(&forced));
547        }
548        values.push(ScanValue {
549            content: &value.content,
550            context: &value.context,
551            skip_categories: &[],
552            skip_semantic: false,
553        });
554    }
555
556    // The per-request scan budgets (`detection_max_scan_values` /
557    // `detection_max_scan_chars`): the value count increments for every
558    // candidate, the character count at the same per-value point; once a
559    // cap is crossed the remaining request values are not scanned (the
560    // reference's fail-open signal, logged once per request there).
561    let mut budget = ScanBudget::new(config);
562    // One scan time budget (`detection_max_scan_seconds`) spans every value
563    // of the request.
564    let mut time_budget = ScanTimeBudget::new(
565        exclusions
566            .max_scan_seconds
567            .unwrap_or(DEFAULT_MAX_SCAN_SECONDS),
568    );
569    for value in &values {
570        if !budget.admit(value.content) {
571            continue;
572        }
573        let verdict = detect::detect_with_budget(
574            value.content,
575            value.context,
576            config,
577            value.skip_semantic,
578            &mut time_budget,
579        );
580        if !verdict.is_threat {
581            continue;
582        }
583        // The budget threat is an ordinary weighted regex-side threat (it
584        // counts toward `is_threat` through the normal threshold rule above).
585        // Like every threat in the reference `_check_value_enhanced`, a value
586        // that is a threat is reported: the enabled-category filter does not
587        // drop the budget threat, and the reason names the budget.
588        if verdict
589            .threats
590            .iter()
591            .any(|threat| matches!(threat, Threat::PatternTimeout(_)))
592        {
593            return budget_exhausted_verdict(&verdict, exclusions, value.skip_categories);
594        }
595        let mut categories: Vec<String> = Vec::new();
596        for threat in &verdict.threats {
597            let category = match threat {
598                Threat::Regex(regex) | Threat::PatternTimeout(regex) => {
599                    if !category_enabled(&regex.category, exclusions.enabled_categories.as_ref())
600                        || value.skip_categories.contains(&regex.category.as_str())
601                    {
602                        continue;
603                    }
604                    regex.category.clone()
605                }
606                Threat::Semantic(semantic) => semantic.attack_type.clone(),
607            };
608            if !categories.contains(&category) {
609                categories.push(category);
610            }
611        }
612        if verdict.threats.is_empty() {
613            categories.push("uncategorized".to_owned());
614        }
615        if categories.is_empty() {
616            // Every threat of this value was filtered out: not a threat,
617            // keep scanning the later values.
618            continue;
619        }
620        return verdict_from(categories);
621    }
622
623    RequestScanVerdict::default()
624}
625
626/// The verdict for a request whose scan time budget ran out: the threat is
627/// reported under its own `custom` category, together with any enabled
628/// category the same value already matched, and the reason names the
629/// budget the way the reference `_build_threats_message` does.
630fn budget_exhausted_verdict(
631    verdict: &detect::DetectVerdict,
632    exclusions: &ResolvedExclusions,
633    skip_categories: &[&str],
634) -> RequestScanVerdict {
635    let mut matched: Vec<String> = Vec::new();
636    for threat in &verdict.threats {
637        if let Threat::Regex(regex) = threat
638            && category_enabled(&regex.category, exclusions.enabled_categories.as_ref())
639            && !skip_categories.contains(&regex.category.as_str())
640            && !matched.contains(&regex.category)
641        {
642            matched.push(regex.category.clone());
643        }
644    }
645    let budget_message = budget_exhausted_message();
646    let reason = if matched.is_empty() {
647        budget_message
648    } else {
649        matched.sort();
650        format!(
651            "Penetration patterns detected: {}; {budget_message}",
652            matched.join(", ")
653        )
654    };
655    let mut categories = matched;
656    categories.push("custom".to_owned());
657    categories.sort();
658    categories.dedup();
659    RequestScanVerdict {
660        is_threat: true,
661        categories,
662        reason,
663    }
664}
665
666/// Forced-category verdict (`_mongo_operator_key_hit`): the category
667/// reports without a pattern scan or a category filter.
668fn forced_verdict(categories: &[&str]) -> RequestScanVerdict {
669    let categories: Vec<String> = categories.iter().map(|c| (*c).to_owned()).collect();
670    verdict_from(categories)
671}
672
673fn verdict_from(mut categories: Vec<String>) -> RequestScanVerdict {
674    categories.sort();
675    let reason = format!("Penetration patterns detected: {}", categories.join(", "));
676    RequestScanVerdict {
677        is_threat: true,
678        categories,
679        reason,
680    }
681}
682
683fn category_enabled(category: &str, enabled: Option<&HashSet<String>>) -> bool {
684    enabled.is_none_or(|set| set.contains(category))
685}
686
687/// The route toggle's request-time semantics.
688///
689/// `route_config.enable_suspicious_detection`: a route carrying the flag
690/// overrides the global `enable_penetration_detection` in both directions;
691/// without a route flag the global value applies.
692#[must_use]
693pub fn detection_enabled(global_enabled: bool, route_enabled: Option<bool>) -> bool {
694    route_enabled.unwrap_or(global_enabled)
695}
696
697/// The check-set liveness semantics (`SuspiciousActivityCheck.applies_to`).
698///
699/// The suspicious-activity check stays alive while the global flag is on or
700/// any route enables its toggle (or dynamic rules are on, which can route
701/// requests anywhere).
702#[must_use]
703pub fn check_applies(
704    global_enabled: bool,
705    route_toggles: impl IntoIterator<Item = bool>,
706    dynamic_rules: bool,
707) -> bool {
708    global_enabled || route_toggles.into_iter().any(|flag| flag) || dynamic_rules
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use crate::detect::DetectConfig;
715
716    fn corpus_config() -> DetectConfig {
717        DetectConfig {
718            max_content_length: 10_000,
719            max_full_scan_bytes: 262_144,
720            preserve_attack_patterns: true,
721            semantic_threshold: 0.7,
722            threat_score_threshold: 1.0,
723            binary_min_run_length: 16,
724            max_scan_values: 512,
725            max_scan_chars: 65_536,
726            max_json_depth: 32,
727        }
728    }
729
730    fn config_of(params: &[&str]) -> DetectionExclusionConfig {
731        DetectionExclusionConfig {
732            excluded_detection_params: params.iter().map(|s| (*s).to_owned()).collect(),
733            ..DetectionExclusionConfig::default()
734        }
735    }
736
737    #[test]
738    fn resolve_defaults_scan_everything() {
739        let resolved = resolve(None, None);
740        assert!(resolved.excluded_params.is_empty());
741        assert!(resolved.excluded_body_fields.is_empty());
742        assert!(resolved.enabled_categories.is_none());
743        assert!(resolved.scan_body);
744        // The hardcoded header defaults are present with no config at all.
745        assert!(resolved.excluded_headers.contains("host"));
746        assert!(resolved.excluded_headers.contains("x-forwarded-for"));
747        assert!(!resolved.excluded_headers.contains("x-custom"));
748    }
749
750    #[test]
751    fn non_none_route_sets_replace_and_unset_inherit() {
752        let global = config_of(&["debug", "trace"]);
753        let route = RouteDetectionExclusions {
754            excluded_detection_params: Some(vec!["session".to_owned()]),
755            ..RouteDetectionExclusions::default()
756        };
757        let resolved = resolve(Some(&global), Some(&route));
758        assert!(resolved.excluded_params.contains("session"));
759        assert!(!resolved.excluded_params.contains("debug"));
760
761        // An unset route surface inherits the global set.
762        let inheriting = resolve(Some(&global), Some(&RouteDetectionExclusions::default()));
763        assert!(inheriting.excluded_params.contains("debug"));
764        assert!(inheriting.excluded_params.contains("trace"));
765    }
766
767    #[test]
768    fn header_set_merges_defaults_config_and_route() {
769        let global = DetectionExclusionConfig {
770            excluded_detection_headers: vec!["X-Custom-Global".to_owned()],
771            ..DetectionExclusionConfig::default()
772        };
773        let route = RouteDetectionExclusions {
774            excluded_detection_headers: Some(vec!["x-custom-route".to_owned()]),
775            ..RouteDetectionExclusions::default()
776        };
777        let resolved = resolve(Some(&global), Some(&route));
778        assert!(resolved.excluded_headers.contains("x-custom-global"));
779        assert!(resolved.excluded_headers.contains("x-custom-route"));
780        assert!(
781            resolved.excluded_headers.contains("referer"),
782            "defaults survive"
783        );
784    }
785
786    #[test]
787    fn empty_route_category_set_disables_every_category() {
788        let route = RouteDetectionExclusions {
789            enabled_detection_categories: Some(Vec::new()),
790            ..RouteDetectionExclusions::default()
791        };
792        let resolved = resolve(None, Some(&route));
793        let enabled = resolved.enabled_categories.clone().expect("some");
794        assert!(enabled.is_empty(), "an empty Some replaces, not inherits");
795        assert!(!category_enabled(
796            "sqli",
797            resolved.enabled_categories.as_ref()
798        ));
799
800        // Without the route override the global set decides.
801        let global = DetectionExclusionConfig {
802            enabled_detection_categories: Some(vec!["sqli".to_owned()]),
803            ..DetectionExclusionConfig::default()
804        };
805        let inheriting = resolve(Some(&global), None);
806        assert!(category_enabled(
807            "sqli",
808            inheriting.enabled_categories.as_ref()
809        ));
810        assert!(!category_enabled(
811            "xss",
812            inheriting.enabled_categories.as_ref()
813        ));
814    }
815
816    #[test]
817    fn scan_body_toggle_overrides_both_directions() {
818        // Route false over a global true.
819        let global = DetectionExclusionConfig {
820            detection_scan_body: Some(true),
821            ..DetectionExclusionConfig::default()
822        };
823        let route = RouteDetectionExclusions {
824            detection_scan_body: Some(false),
825            ..RouteDetectionExclusions::default()
826        };
827        assert!(!resolve(Some(&global), Some(&route)).scan_body);
828
829        // Route true over a global false.
830        let global = DetectionExclusionConfig {
831            detection_scan_body: Some(false),
832            ..DetectionExclusionConfig::default()
833        };
834        let route = RouteDetectionExclusions {
835            detection_scan_body: Some(true),
836            ..RouteDetectionExclusions::default()
837        };
838        assert!(resolve(Some(&global), Some(&route)).scan_body);
839
840        // Global false with no route flag.
841        let global = DetectionExclusionConfig {
842            detection_scan_body: Some(false),
843            ..DetectionExclusionConfig::default()
844        };
845        assert!(!resolve(Some(&global), None).scan_body);
846    }
847
848    /// The Guard-Core/guard-core#161 repro: a JWT session cookie with
849    /// `theme=system` and `_ga`, which scores 0.711 against the 0.7
850    /// semantic threshold when scanned as one value.
851    const SESSION_COOKIE: &str = "session=eyJhbGciOiAiRVMyNTYiLCAidHlwIjogIkpXVCJ9.eyJzdWIiOiAiM2YyYzFiOWUtOGE0Ny00ZDJlLTljMWYtNWI2YTdkOGU5ZjAxIiwgIm5hbWUiOiAiSmFuZSBEb2UgKEFjbWUpIiwgImVtYWlsIjogImphbmUuZG9lQGV4YW1wbGUuY29tIiwgInJvbGVzIjogWyJhZG1pbiJdLCAiaWF0IjogMTc5MjAwMDAwMCwgImV4cCI6IDE3OTIwMDA5MDB9.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-Pw; theme=system; _ga=GA1.1.123456789.1792000000";
852
853    fn semantic_threat_count(content: &str, skip_semantic: bool) -> usize {
854        crate::detect::detect_with_semantic_skip(content, "header", &corpus_config(), skip_semantic)
855            .threats
856            .iter()
857            .filter(|threat| matches!(threat, Threat::Semantic(_)))
858            .count()
859    }
860
861    /// Scan one header next to an xss body. A semantic hit on the header is
862    /// reported (its attack type is a category) and the xss body is still
863    /// scanned, so the verdict names both when the semantic analyzer runs.
864    fn scan_header_with_xss_body(
865        name: &str,
866        value: &str,
867        global: Option<&DetectionExclusionConfig>,
868        route: Option<&RouteDetectionExclusions>,
869    ) -> RequestScanVerdict {
870        let headers = vec![(name.to_owned(), value.to_owned())];
871        scan_request(
872            &RequestSurfaces {
873                url_path: None,
874                query_params: &[],
875                headers: &headers,
876                content_type: "application/x-www-form-urlencoded",
877                raw_body: "q=<script>alert(1)</script>",
878            },
879            &resolve(global, route),
880            &corpus_config(),
881        )
882    }
883
884    #[test]
885    fn the_semantic_skip_drops_only_the_semantic_threats() {
886        assert!(
887            semantic_threat_count(SESSION_COOKIE, false) > 0,
888            "control: the unsplit session value trips the semantic analyzer"
889        );
890        assert_eq!(semantic_threat_count(SESSION_COOKIE, true), 0);
891        let verdict = crate::detect::detect_with_semantic_skip(
892            "<script>alert(1)</script>",
893            "header",
894            &corpus_config(),
895            true,
896        );
897        assert!(
898            verdict
899                .threats
900                .iter()
901                .any(|threat| matches!(threat, Threat::Regex(regex) if regex.category == "xss")),
902            "regex scanning is unchanged"
903        );
904    }
905
906    #[test]
907    fn semantic_headers_resolve_lowercased_trimmed_and_merged() {
908        let global = DetectionExclusionConfig {
909            excluded_semantic_detection_headers: vec![" X-Global ".to_owned()],
910            ..DetectionExclusionConfig::default()
911        };
912        let route = RouteDetectionExclusions {
913            excluded_semantic_detection_headers: Some(vec!["X-Route".to_owned()]),
914            ..RouteDetectionExclusions::default()
915        };
916        let merged = resolve(Some(&global), Some(&route));
917        assert_eq!(
918            merged.semantic_headers,
919            HashSet::from(["x-global".to_owned(), "x-route".to_owned()])
920        );
921        let inherited = resolve(Some(&global), Some(&RouteDetectionExclusions::default()));
922        assert_eq!(
923            inherited.semantic_headers,
924            HashSet::from(["x-global".to_owned()])
925        );
926        assert!(resolve(None, None).semantic_headers.is_empty(), "opt-in");
927    }
928
929    #[test]
930    fn a_global_semantic_header_skips_only_the_semantic_analyzer() {
931        let global = DetectionExclusionConfig {
932            excluded_semantic_detection_headers: vec!["X-Session".to_owned()],
933            ..DetectionExclusionConfig::default()
934        };
935        let verdict = scan_header_with_xss_body("x-session", SESSION_COOKIE, Some(&global), None);
936        assert_eq!(verdict.categories, vec!["xss".to_owned()]);
937
938        let attack = scan_header_with_xss_body(
939            "x-session",
940            "<img src=x onerror=alert(1)>",
941            Some(&global),
942            None,
943        );
944        assert_eq!(
945            attack.categories,
946            vec!["xss".to_owned()],
947            "regex scanning of the header is unchanged"
948        );
949    }
950
951    #[test]
952    fn a_route_semantic_header_skips_the_semantic_analyzer() {
953        let route = RouteDetectionExclusions {
954            excluded_semantic_detection_headers: Some(vec!["x-session".to_owned()]),
955            ..RouteDetectionExclusions::default()
956        };
957        let verdict = scan_header_with_xss_body("X-Session", SESSION_COOKIE, None, Some(&route));
958        assert_eq!(verdict.categories, vec!["xss".to_owned()]);
959    }
960
961    /// One cookie pair that trips the semantic analyzer alone (no regex
962    /// category), to prove the semantic opt-out applies per pair.
963    const SEMANTIC_ONLY_PAIR: &str = "${x} <t> (y) [z] {w} a://b c://d <b>call(f(x))</b> {{render(jinja(template(mustache(handlebars(ejs(pug(twig)))))))}}";
964
965    fn scan_headers(headers: &[(&str, &str)], config: &DetectConfig) -> RequestScanVerdict {
966        let headers: Vec<(String, String)> = headers
967            .iter()
968            .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
969            .collect();
970        scan_request(
971            &RequestSurfaces {
972                url_path: None,
973                query_params: &[],
974                headers: &headers,
975                content_type: "",
976                raw_body: "",
977            },
978            &resolve(None, None),
979            config,
980        )
981    }
982
983    #[test]
984    fn a_logged_in_cookie_header_is_not_flagged() {
985        let cookie = scan_header_with_xss_body("Cookie", SESSION_COOKIE, None, None);
986        assert_eq!(
987            cookie.categories,
988            vec!["xss".to_owned()],
989            "each pair scans on its own, so the session cookie is not a threat"
990        );
991        let other = scan_header_with_xss_body("x-session", SESSION_COOKIE, None, None);
992        assert!(
993            other.is_threat
994                && !other.categories.is_empty()
995                && other.categories != cookie.categories,
996            "control: the same value in another header scans as one value and is reported on its own: {:?}",
997            other.categories
998        );
999    }
1000
1001    #[test]
1002    fn an_attack_in_one_cookie_is_still_detected() {
1003        for (cookie, category) in [
1004            ("theme=dark; q=<script>alert(1)</script>", "xss"),
1005            ("theme=dark; id=1' OR 1=1--", "sqli"),
1006            ("theme=dark;\r\nSet-Cookie: admin=1", "http_split"),
1007        ] {
1008            let verdict = scan_headers(&[("Cookie", cookie)], &corpus_config());
1009            assert!(
1010                verdict.categories.iter().any(|found| found == category),
1011                "{cookie:?} must detect {category}, got {:?}",
1012                verdict.categories
1013            );
1014        }
1015    }
1016
1017    #[test]
1018    fn the_semantic_exclusion_applies_to_each_cookie_pair() {
1019        let cookie = format!("theme=dark; {SEMANTIC_ONLY_PAIR}");
1020        let blocked = scan_header_with_xss_body("cookie", &cookie, None, None);
1021        assert!(
1022            blocked.is_threat && !blocked.categories.is_empty() && blocked.categories != ["xss"],
1023            "control: the pair trips the semantic analyzer and is reported: {:?}",
1024            blocked.categories
1025        );
1026        let global = DetectionExclusionConfig {
1027            excluded_semantic_detection_headers: vec!["Cookie".to_owned()],
1028            ..DetectionExclusionConfig::default()
1029        };
1030        let verdict = scan_header_with_xss_body("cookie", &cookie, Some(&global), None);
1031        assert_eq!(verdict.categories, vec!["xss".to_owned()]);
1032    }
1033
1034    #[test]
1035    fn each_cookie_pair_counts_against_the_value_budget() {
1036        let mut config = corpus_config();
1037        config.max_scan_values = 2;
1038        let verdict = scan_headers(
1039            &[("cookie", "a=1; b=2; c=<script>alert(1)</script>")],
1040            &config,
1041        );
1042        assert!(
1043            verdict.categories.is_empty(),
1044            "the pair holding the attack is the third value, past the cap"
1045        );
1046    }
1047
1048    #[test]
1049    fn empty_cookie_pairs_are_skipped_and_only_spaces_are_trimmed() {
1050        let mut config = corpus_config();
1051        config.max_scan_values = 1;
1052        let verdict = scan_headers(&[("cookie", " ; ;; <script>alert(1)</script> ;")], &config);
1053        assert_eq!(
1054            verdict.categories,
1055            vec!["xss".to_owned()],
1056            "empty pairs take no budget"
1057        );
1058    }
1059
1060    fn benign_surfaces() -> RequestSurfaces<'static> {
1061        RequestSurfaces {
1062            url_path: Some("/items"),
1063            query_params: &[],
1064            headers: &[],
1065            content_type: "",
1066            raw_body: "",
1067        }
1068    }
1069
1070    fn zero_budget_exclusions(categories: Option<Vec<String>>) -> ResolvedExclusions {
1071        resolve(
1072            Some(&DetectionExclusionConfig {
1073                enabled_detection_categories: categories,
1074                max_scan_seconds: Some(0.0),
1075                ..DetectionExclusionConfig::default()
1076            }),
1077            None,
1078        )
1079    }
1080
1081    #[test]
1082    fn the_scan_time_cap_resolves_from_the_global_config_only() {
1083        assert_eq!(resolve(None, None).max_scan_seconds, None);
1084        let global = DetectionExclusionConfig {
1085            max_scan_seconds: Some(2.5),
1086            ..DetectionExclusionConfig::default()
1087        };
1088        assert_eq!(resolve(Some(&global), None).max_scan_seconds, Some(2.5));
1089        let route = RouteDetectionExclusions::default();
1090        assert_eq!(
1091            resolve(Some(&global), Some(&route)).max_scan_seconds,
1092            Some(2.5)
1093        );
1094    }
1095
1096    #[test]
1097    fn an_exhausted_scan_time_budget_blocks_and_names_the_budget() {
1098        let verdict = scan_request(
1099            &benign_surfaces(),
1100            &zero_budget_exclusions(None),
1101            &corpus_config(),
1102        );
1103        assert!(verdict.is_threat);
1104        assert_eq!(verdict.categories, vec!["custom".to_owned()]);
1105        assert_eq!(
1106            verdict.reason,
1107            "Pattern exceeded scan time budget: 'scan_time_budget_exhausted'"
1108        );
1109    }
1110
1111    #[test]
1112    fn the_budget_threat_follows_the_normal_threshold_rule() {
1113        // Reference parity: the budget threat weighs 1.0 like any `custom`
1114        // threat, so a threshold of 5.0 leaves a budget-only value clean.
1115        let mut config = corpus_config();
1116        config.threat_score_threshold = 5.0;
1117        let verdict = scan_request(&benign_surfaces(), &zero_budget_exclusions(None), &config);
1118        assert!(!verdict.is_threat);
1119        assert!(verdict.reason.is_empty());
1120    }
1121
1122    #[test]
1123    fn the_budget_threat_is_not_dropped_by_the_category_filter() {
1124        // The reference does not post-filter a threat value by enabled
1125        // category, so the budget threat reports under a filter too.
1126        let exclusions = zero_budget_exclusions(Some(vec!["xss".to_owned()]));
1127        let verdict = scan_request(&benign_surfaces(), &exclusions, &corpus_config());
1128        assert!(verdict.is_threat);
1129        assert!(verdict.reason.contains("scan time budget"));
1130    }
1131
1132    #[test]
1133    fn the_budget_reason_keeps_the_categories_the_value_already_matched() {
1134        // The first pattern is cheap to match; the fake-free route: a zero
1135        // cap trips before any pattern runs, so drive the verdict builder
1136        // with a verdict that carries both threat kinds.
1137        let verdict = crate::detect::DetectVerdict {
1138            is_threat: true,
1139            threat_score: 1.0,
1140            threats: vec![
1141                Threat::Regex(crate::patterns::RegexThreat {
1142                    pattern: "p".to_owned(),
1143                    match_text: "m".to_owned(),
1144                    position: 0,
1145                    category: "xss".to_owned(),
1146                    weight: 1.0,
1147                }),
1148                Threat::PatternTimeout(crate::patterns::scan_time_budget_exhausted_threat()),
1149            ],
1150            original_length: 1,
1151            processed_length: 1,
1152        };
1153        let out = budget_exhausted_verdict(&verdict, &resolve(None, None), &[]);
1154        assert_eq!(out.categories, vec!["custom".to_owned(), "xss".to_owned()]);
1155        assert_eq!(
1156            out.reason,
1157            "Penetration patterns detected: xss; Pattern exceeded scan time budget: \
1158             'scan_time_budget_exhausted'"
1159        );
1160        // A filtered-out category does not name itself in the reason.
1161        let filtered = budget_exhausted_verdict(&verdict, &resolve(None, None), &["xss"]);
1162        assert_eq!(filtered.categories, vec!["custom".to_owned()]);
1163    }
1164
1165    #[test]
1166    fn the_scan_time_budget_is_shared_by_every_value_of_the_request() {
1167        let surfaces = RequestSurfaces {
1168            url_path: Some("/items"),
1169            query_params: &[("q".to_owned(), "benign".to_owned())],
1170            headers: &[],
1171            content_type: "",
1172            raw_body: "",
1173        };
1174        let verdict = scan_request(&surfaces, &zero_budget_exclusions(None), &corpus_config());
1175        assert!(
1176            verdict.is_threat,
1177            "the first value already trips the budget"
1178        );
1179    }
1180
1181    #[test]
1182    fn the_default_scan_time_budget_leaves_a_benign_request_clean() {
1183        let verdict = scan_request(&benign_surfaces(), &resolve(None, None), &corpus_config());
1184        assert!(!verdict.is_threat);
1185        assert!(verdict.reason.is_empty());
1186    }
1187
1188    #[test]
1189    fn the_scan_value_budget_caps_the_values_scanned() {
1190        let mut config = corpus_config();
1191        config.max_scan_values = 2;
1192        let exclusions = resolve(None, None);
1193        // Five candidate values; the threat sits in the third, past the cap.
1194        let surfaces = RequestSurfaces {
1195            url_path: Some("/scan"),
1196            query_params: &[
1197                ("a".to_owned(), "benign-one".to_owned()),
1198                ("b".to_owned(), "benign-two".to_owned()),
1199                ("c".to_owned(), "1 UNION SELECT password".to_owned()),
1200                ("d".to_owned(), "benign-four".to_owned()),
1201                ("e".to_owned(), "<script>alert(1)</script>".to_owned()),
1202            ],
1203            headers: &[],
1204            content_type: "",
1205            raw_body: "",
1206        };
1207        let verdict = scan_request(&surfaces, &exclusions, &config);
1208        assert!(
1209            !verdict.is_threat,
1210            "values past the detection_max_scan_values cap are not scanned"
1211        );
1212
1213        // The same request under the default budget detects.
1214        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1215        assert!(verdict.is_threat);
1216    }
1217
1218    #[test]
1219    fn the_scan_char_budget_caps_the_characters_scanned() {
1220        let mut config = corpus_config();
1221        // The two benign values burn the budget; the third is skipped.
1222        config.max_scan_chars = "benign-one".chars().count() + "benign-two".chars().count();
1223        let exclusions = resolve(None, None);
1224        let surfaces = RequestSurfaces {
1225            url_path: Some("/scan"),
1226            query_params: &[
1227                ("a".to_owned(), "benign-one".to_owned()),
1228                ("b".to_owned(), "benign-two".to_owned()),
1229                ("c".to_owned(), "1 UNION SELECT password".to_owned()),
1230            ],
1231            headers: &[],
1232            content_type: "",
1233            raw_body: "",
1234        };
1235        let verdict = scan_request(&surfaces, &exclusions, &config);
1236        assert!(
1237            !verdict.is_threat,
1238            "values past the detection_max_scan_chars cap are not scanned"
1239        );
1240    }
1241
1242    #[test]
1243    fn the_budgets_count_every_candidate_even_skipped_ones() {
1244        let mut config = corpus_config();
1245        // The value budget counts all three candidates even though the
1246        // char budget stops the scan after the first.
1247        config.max_scan_values = 3;
1248        config.max_scan_chars = 0;
1249        let exclusions = resolve(None, None);
1250        let surfaces = RequestSurfaces {
1251            url_path: Some("/scan"),
1252            query_params: &[
1253                ("a".to_owned(), "1 UNION SELECT password".to_owned()),
1254                ("b".to_owned(), "<script>alert(1)</script>".to_owned()),
1255                ("c".to_owned(), "benign".to_owned()),
1256            ],
1257            headers: &[],
1258            content_type: "",
1259            raw_body: "",
1260        };
1261        let verdict = scan_request(&surfaces, &exclusions, &config);
1262        assert!(
1263            !verdict.is_threat,
1264            "a zero char budget skips every value (the count still ran)"
1265        );
1266    }
1267    #[test]
1268    fn scan_body_false_still_scans_headers_params_and_path() {
1269        let route = RouteDetectionExclusions {
1270            detection_scan_body: Some(false),
1271            ..RouteDetectionExclusions::default()
1272        };
1273        let exclusions = resolve(None, Some(&route));
1274        let surfaces = RequestSurfaces {
1275            url_path: Some("/files?x=../../etc/passwd"),
1276            query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
1277            headers: &[(
1278                "x-payload".to_owned(),
1279                "<script>alert(1)</script>".to_owned(),
1280            )],
1281            content_type: "application/x-www-form-urlencoded",
1282            raw_body: "payload=<img src=x onerror=alert(1)>",
1283        };
1284        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1285        assert!(
1286            verdict.is_threat,
1287            "the body surface is skipped, the rest still detect"
1288        );
1289    }
1290
1291    #[test]
1292    fn excluded_param_skips_only_itself() {
1293        let global = config_of(&["debug"]);
1294        let exclusions = resolve(Some(&global), None);
1295        let surfaces = RequestSurfaces {
1296            url_path: Some("/search"),
1297            query_params: &[
1298                ("debug".to_owned(), "1 UNION SELECT password".to_owned()),
1299                ("q".to_owned(), "<script>alert(1)</script>".to_owned()),
1300            ],
1301            headers: &[],
1302            content_type: "",
1303            raw_body: "",
1304        };
1305        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1306        assert!(verdict.is_threat);
1307        assert!(
1308            !verdict.categories.contains(&"sqli".to_owned()),
1309            "the excluded param did not contribute"
1310        );
1311        // Case-insensitive: DEBUG is excluded too.
1312        let surfaces = RequestSurfaces {
1313            url_path: Some("/search"),
1314            query_params: &[("DEBUG".to_owned(), "1 UNION SELECT password".to_owned())],
1315            headers: &[],
1316            content_type: "",
1317            raw_body: "",
1318        };
1319        assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
1320    }
1321
1322    #[test]
1323    fn excluded_body_field_skips_only_that_field() {
1324        let global = DetectionExclusionConfig {
1325            excluded_detection_body_fields: vec!["secret".to_owned()],
1326            ..DetectionExclusionConfig::default()
1327        };
1328        let exclusions = resolve(Some(&global), None);
1329        let surfaces = RequestSurfaces {
1330            url_path: Some("/api"),
1331            query_params: &[],
1332            headers: &[],
1333            content_type: "application/x-www-form-urlencoded",
1334            raw_body: "secret=1 UNION SELECT password&safe=<script>alert(1)</script>",
1335        };
1336        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1337        assert!(verdict.is_threat, "the untouched field still scans");
1338        assert_eq!(verdict.categories, vec!["xss"]);
1339
1340        // And the excluded field alone is clean.
1341        let surfaces = RequestSurfaces {
1342            url_path: Some("/api"),
1343            query_params: &[],
1344            headers: &[],
1345            content_type: "application/x-www-form-urlencoded",
1346            raw_body: "secret=1 UNION SELECT password",
1347        };
1348        assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
1349    }
1350
1351    #[test]
1352    fn excluded_header_with_attack_payload_still_detects() {
1353        let exclusions = resolve(None, None);
1354        let surfaces = RequestSurfaces {
1355            url_path: Some("/x"),
1356            query_params: &[],
1357            headers: &[(
1358                "x-custom".to_owned(),
1359                "<script>alert(1)</script>".to_owned(),
1360            )],
1361            content_type: "",
1362            raw_body: "",
1363        };
1364        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1365        assert!(verdict.is_threat, "an excluded header is not a skip hole");
1366        assert_eq!(verdict.categories, vec!["xss"]);
1367    }
1368
1369    #[test]
1370    fn address_chain_headers_skip_ssrf_only() {
1371        // A whole address chain in an unlisted excluded header reads as a
1372        // proxy chain, not an ssrf probe.
1373        let chain = "10.0.0.5, 172.16.0.1";
1374        assert_eq!(
1375            excluded_header_skip_categories("x-custom", chain),
1376            &["ssrf"][..]
1377        );
1378        assert_eq!(
1379            excluded_header_skip_categories("via", chain),
1380            &["ssrf"][..],
1381            "the address-carrying name skips for any value"
1382        );
1383        assert_eq!(
1384            excluded_header_skip_categories("x-custom", "<script>alert(1)</script>"),
1385            &[] as &[&str],
1386            "a non-address value skips nothing"
1387        );
1388        assert_eq!(
1389            excluded_header_skip_categories("x-custom", "10.0.0.5, not-an-ip"),
1390            &[] as &[&str],
1391            "one non-address token breaks the chain"
1392        );
1393    }
1394
1395    #[test]
1396    fn port_stripping_feeds_the_address_parser() {
1397        assert_eq!(strip_forwarded_entry_port("1.2.3.4:8080"), "1.2.3.4");
1398        assert_eq!(strip_forwarded_entry_port("[::1]:8080"), "::1");
1399        assert_eq!(strip_forwarded_entry_port("::1"), "::1");
1400        assert_eq!(strip_forwarded_entry_port("not-an-ip"), "not-an-ip");
1401        assert_eq!(
1402            strip_forwarded_entry_port("[::1]:nope"),
1403            "[::1]:nope",
1404            "a non-numeric port keeps the value unparsed"
1405        );
1406        assert!(
1407            value_looks_like_address_chain("1.2.3.4:8080, [::1]:8080"),
1408            "socket-literal entries parse once the port is stripped"
1409        );
1410    }
1411
1412    #[test]
1413    fn filtered_out_threat_alone_is_clean() {
1414        // A value whose threats are all filtered out is not a threat; with
1415        // no later value to carry one, the verdict is clean.
1416        let route = RouteDetectionExclusions {
1417            enabled_detection_categories: Some(vec!["xss".to_owned()]),
1418            ..RouteDetectionExclusions::default()
1419        };
1420        let exclusions = resolve(None, Some(&route));
1421        let surfaces = RequestSurfaces {
1422            url_path: Some("/x"),
1423            query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
1424            headers: &[],
1425            content_type: "",
1426            raw_body: "",
1427        };
1428        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1429        assert!(!verdict.is_threat);
1430        assert!(verdict.categories.is_empty());
1431    }
1432
1433    const XSS: &str = "<script>alert(1)</script>";
1434
1435    fn semantic_only_value() -> String {
1436        SESSION_COOKIE.to_owned()
1437    }
1438
1439    fn scan_surfaces(
1440        exclusions: &ResolvedExclusions,
1441        query: &[(String, String)],
1442        headers: &[(String, String)],
1443    ) -> RequestScanVerdict {
1444        let surfaces = RequestSurfaces {
1445            url_path: Some("/x"),
1446            query_params: query,
1447            headers,
1448            content_type: "",
1449            raw_body: "",
1450        };
1451        scan_request(&surfaces, exclusions, &corpus_config())
1452    }
1453
1454    #[test]
1455    fn semantic_only_value_is_reported() {
1456        let query = [("q".to_owned(), semantic_only_value())];
1457        let verdict = scan_surfaces(&resolve(None, None), &query, &[]);
1458        assert!(verdict.is_threat, "semantic-only threat must be reported");
1459        assert!(!verdict.categories.is_empty());
1460    }
1461
1462    #[test]
1463    fn filtered_out_value_does_not_end_the_scan() {
1464        let headers = [
1465            (
1466                "x-forwarded-host".to_owned(),
1467                "http://169.254.169.254/latest/meta-data/".to_owned(),
1468            ),
1469            ("x-evil".to_owned(), XSS.to_owned()),
1470        ];
1471        let verdict = scan_surfaces(&resolve(None, None), &[], &headers);
1472        assert_eq!(verdict.categories, vec!["xss".to_owned()]);
1473    }
1474
1475    #[test]
1476    fn disabled_category_does_not_end_the_scan() {
1477        let route = RouteDetectionExclusions {
1478            enabled_detection_categories: Some(vec!["xss".to_owned()]),
1479            ..RouteDetectionExclusions::default()
1480        };
1481        let query = [("q".to_owned(), "1 UNION SELECT password".to_owned())];
1482        let headers = [("x-evil".to_owned(), XSS.to_owned())];
1483        let verdict = scan_surfaces(&resolve(None, Some(&route)), &query, &headers);
1484        assert_eq!(verdict.categories, vec!["xss".to_owned()]);
1485    }
1486
1487    #[test]
1488    fn a_threat_without_a_threats_list_reports_as_uncategorized() {
1489        // a zero score threshold makes any value a threat with no threats
1490        let config = DetectConfig {
1491            threat_score_threshold: 0.0,
1492            ..corpus_config()
1493        };
1494        let query = [("q".to_owned(), "hello".to_owned())];
1495        let surfaces = RequestSurfaces {
1496            url_path: None,
1497            query_params: &query,
1498            headers: &[],
1499            content_type: "",
1500            raw_body: "",
1501        };
1502        let verdict = scan_request(&surfaces, &resolve(None, None), &config);
1503        assert!(verdict.is_threat);
1504        assert_eq!(verdict.categories, vec!["uncategorized".to_owned()]);
1505    }
1506
1507    #[test]
1508    fn semantic_then_header_xss_is_still_reported() {
1509        let query = [("q".to_owned(), semantic_only_value())];
1510        let headers = [("x-evil".to_owned(), XSS.to_owned())];
1511        let verdict = scan_surfaces(&resolve(None, None), &query, &headers);
1512        assert!(verdict.is_threat);
1513    }
1514
1515    #[test]
1516    fn enabled_categories_route_the_verdict() {
1517        let route = RouteDetectionExclusions {
1518            enabled_detection_categories: Some(vec!["sqli".to_owned()]),
1519            ..RouteDetectionExclusions::default()
1520        };
1521        let exclusions = resolve(None, Some(&route));
1522        let surfaces = RequestSurfaces {
1523            url_path: Some("/x"),
1524            query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
1525            headers: &[(
1526                "x-payload".to_owned(),
1527                "<script>alert(1)</script>".to_owned(),
1528            )],
1529            content_type: "",
1530            raw_body: "",
1531        };
1532        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1533        assert!(verdict.is_threat);
1534        assert_eq!(verdict.categories, vec!["sqli"]);
1535        assert!(verdict.reason.contains("sqli"));
1536        assert!(!verdict.reason.contains("xss"), "xss is not enabled here");
1537    }
1538
1539    #[test]
1540    fn mongo_operator_keys_hit_through_the_body_walk() {
1541        let exclusions = resolve(None, None);
1542        let surfaces = RequestSurfaces {
1543            url_path: Some("/x"),
1544            query_params: &[],
1545            headers: &[],
1546            content_type: "application/json",
1547            raw_body: "{\"$where\": \"1 OR 1=1\"}",
1548        };
1549        let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1550        assert!(verdict.is_threat);
1551        assert_eq!(verdict.categories, vec!["nosql"]);
1552    }
1553
1554    #[test]
1555    fn route_toggles_and_liveness_mirror_the_reference() {
1556        // Request-time: the route flag overrides both directions.
1557        assert!(detection_enabled(false, Some(true)));
1558        assert!(!detection_enabled(true, Some(false)));
1559        assert!(detection_enabled(true, None));
1560        assert!(!detection_enabled(false, None));
1561
1562        // Check-set liveness: alive while any route enables the toggle.
1563        assert!(check_applies(false, [false, true], false));
1564        assert!(!check_applies(false, [false, false], false));
1565        assert!(check_applies(true, [], false));
1566        assert!(
1567            check_applies(false, [false], true),
1568            "dynamic rules keep it alive"
1569        );
1570    }
1571}
1572
1573#[cfg(test)]
1574mod gap_tests {
1575    use super::*;
1576
1577    fn config() -> DetectConfig {
1578        DetectConfig {
1579            max_content_length: 10_000,
1580            max_full_scan_bytes: 262_144,
1581            preserve_attack_patterns: true,
1582            semantic_threshold: 0.7,
1583            threat_score_threshold: 1.0,
1584            binary_min_run_length: 16,
1585            max_scan_values: 512,
1586            max_scan_chars: 65_536,
1587            max_json_depth: 32,
1588        }
1589    }
1590
1591    #[test]
1592    fn an_unterminated_bracket_entry_keeps_its_port_text() {
1593        assert_eq!(strip_forwarded_entry_port("[::1"), "[::1");
1594    }
1595
1596    #[test]
1597    fn a_chain_of_only_empty_tokens_is_not_an_address_chain() {
1598        assert!(!value_looks_like_address_chain(" , ,, "));
1599    }
1600
1601    #[test]
1602    fn empty_header_values_never_scan() {
1603        let exclusions = ResolvedExclusions {
1604            excluded_params: std::collections::HashSet::new(),
1605            excluded_headers: std::collections::HashSet::new(),
1606            excluded_body_fields: std::collections::HashSet::new(),
1607            semantic_headers: std::collections::HashSet::new(),
1608            enabled_categories: None,
1609            scan_body: false,
1610            max_scan_seconds: None,
1611        };
1612        let headers = vec![
1613            ("x-empty".to_owned(), String::new()),
1614            ("x-attack".to_owned(), "10.0.0.5, 127.0.0.1".to_owned()),
1615        ];
1616        let verdict = scan_request(
1617            &RequestSurfaces {
1618                url_path: None,
1619                query_params: &[],
1620                headers: &headers,
1621                content_type: "",
1622                raw_body: "",
1623            },
1624            &exclusions,
1625            &config(),
1626        );
1627        // only the non-empty header scans
1628        assert!(verdict.is_threat);
1629        assert_eq!(verdict.categories, vec!["ssrf".to_owned()]);
1630    }
1631
1632    #[test]
1633    fn an_excluded_address_header_suppresses_its_ssrf_category() {
1634        let mut excluded_headers = std::collections::HashSet::new();
1635        excluded_headers.insert("x-forwarded-for".to_owned());
1636        let exclusions = ResolvedExclusions {
1637            excluded_params: std::collections::HashSet::new(),
1638            excluded_headers,
1639            excluded_body_fields: std::collections::HashSet::new(),
1640            semantic_headers: std::collections::HashSet::new(),
1641            enabled_categories: None,
1642            scan_body: false,
1643            max_scan_seconds: None,
1644        };
1645        let headers = vec![(
1646            "x-forwarded-for".to_owned(),
1647            "10.0.0.5, 127.0.0.1".to_owned(),
1648        )];
1649        let verdict = scan_request(
1650            &RequestSurfaces {
1651                url_path: None,
1652                query_params: &[],
1653                headers: &headers,
1654                content_type: "",
1655                raw_body: "",
1656            },
1657            &exclusions,
1658            &config(),
1659        );
1660        // the ssrf category is filtered out and no later value carries a
1661        // threat, so the verdict is clean
1662        assert!(!verdict.is_threat);
1663        assert!(verdict.categories.is_empty());
1664    }
1665
1666    #[test]
1667    fn a_semantic_threat_reports_its_attack_type_beside_the_regex_categories() {
1668        // the obfuscated keyword blob trips the semantic analyzer alongside
1669        // the regex sqli hits: the semantic attack_type is a category too
1670        // (`_threat_category`)
1671        let content = format!(
1672            "select union insert update delete drop from where order group having concat \
1673             substring database table column (1 OR 1=1) {}",
1674            "A".repeat(120)
1675        );
1676        let verdict = scan_request(
1677            &RequestSurfaces {
1678                url_path: None,
1679                query_params: &[],
1680                headers: &[],
1681                content_type: "text/plain",
1682                raw_body: &content,
1683            },
1684            &ResolvedExclusions {
1685                excluded_params: std::collections::HashSet::new(),
1686                excluded_headers: std::collections::HashSet::new(),
1687                excluded_body_fields: std::collections::HashSet::new(),
1688                semantic_headers: std::collections::HashSet::new(),
1689                enabled_categories: None,
1690                scan_body: true,
1691                max_scan_seconds: None,
1692            },
1693            &config(),
1694        );
1695        assert!(verdict.is_threat);
1696        assert_eq!(
1697            verdict.categories,
1698            vec!["sql".to_owned(), "sqli".to_owned()]
1699        );
1700    }
1701}