Skip to main content

guard_core_engine/
detect.rs

1//! Public detection entry point: the spec 4.0.2 `SusPatternsManager.detect`
2//! pipeline (preprocess, four scan views, semantic analysis, scoring).
3//!
4//! View passes mirror the reference mixins:
5//! - processed view (`raw_view_only=False`): excludes raw-view-only and
6//!   URL-decoded-view-only patterns;
7//! - raw signal-preserving view (`raw_view_only=True`): raw-view-only
8//!   patterns plus the recon rows (the processed views fold LDAP hex
9//!   escapes such as `\default` before the tables run; duplicates against
10//!   the processed view are dropped on merge);
11//! - decoded-view path-traversal check (processed vs raw view counts);
12//! - URL-decoded view (`url_decoded_view_only=True`): only URL-decoded-view
13//!   patterns, content is the precomputed decoded view truncated safely;
14//! - decode-budget exhaustion threat when a decode pass hit its budget;
15//! - short-base64 additive view (no view exclusion);
16//! - semantic analysis over the processed content (skipped for binary-looking
17//!   raw content).
18//!
19//! Scoring follows `_regex_anomaly` / `_calculate_threat_score`: `is_threat`
20//! is `sum(regex weights) >= threat_score_threshold or any semantic threat`;
21//! `threat_score` is `min(max(regex anomaly, semantic max), 1.0)` when any
22//! threat exists, else 0.0.
23
24use crate::patterns::binary;
25use crate::patterns::{self, RegexThreat, ViewFilter};
26use crate::preprocessor;
27use crate::scan_budget::{
28    DEFAULT_MAX_SCAN_SECONDS, SCAN_TIME_BUDGET_EXHAUSTED_PATTERN, ScanTimeBudget,
29};
30use crate::semantic::{self, AnalysisResult, AttackKeywords, AttackStructures};
31
32/// Knob mapping for one detect call (spec `config_knobs`).
33#[derive(Debug, Clone, Copy)]
34pub struct DetectConfig {
35    /// `detection_max_content_length` (semantic budget, truncation budget).
36    pub max_content_length: usize,
37    /// `detection_max_body_inspect_bytes` (the preprocessor's full-scan cap).
38    pub max_full_scan_bytes: usize,
39    /// `detection_preserve_attack_patterns`.
40    pub preserve_attack_patterns: bool,
41    /// `detection_semantic_threshold`.
42    pub semantic_threshold: f64,
43    /// `detection_threat_score_threshold`.
44    pub threat_score_threshold: f64,
45    /// `detection_binary_min_run_length` (reference bounds 4..=1024, default
46    /// 16): minimum code-point length a printable run inside a binary-dense
47    /// multipart file-part payload must reach before the run is handed to the
48    /// pattern scan (see [`crate::binary_islands`]).
49    pub binary_min_run_length: usize,
50    /// `detection_max_scan_values` (reference default 512): the per-request
51    /// scan budget counting values handed to [`detect`]; once the count
52    /// crosses the cap the remaining request values are not scanned (the
53    /// fail-open signal the reference logs once).
54    pub max_scan_values: usize,
55    /// `detection_max_scan_chars` (reference default 65536): the
56    /// per-request character budget, accounted at the same per-value point
57    /// as `max_scan_values`; once the consumed count reaches the cap the
58    /// remaining request values are not scanned.
59    pub max_scan_chars: usize,
60    /// `detection_max_json_depth` (reference default 32): the JSON walk's
61    /// semantic depth cap; containers at or beyond this nesting serialize
62    /// back to compact JSON and scan as text (see [`crate::json_walk`]).
63    pub max_json_depth: usize,
64}
65
66impl Default for DetectConfig {
67    /// The reference defaults from `_security_config_fields.py`: the
68    /// reference `detection_max_content_length` (ten thousand), the
69    /// reference `detection_max_body_inspect_bytes` (262,144), preserved
70    /// patterns, the 0.7 / 1.0 thresholds, the reference binary run
71    /// length (16), the reference `detection_max_scan_values` (512), the
72    /// reference `detection_max_scan_chars` (65,536), and the reference
73    /// `detection_max_json_depth` (32).
74    fn default() -> Self {
75        Self {
76            max_content_length: 10_000,
77            max_full_scan_bytes: 262_144,
78            preserve_attack_patterns: true,
79            semantic_threshold: 0.7,
80            threat_score_threshold: 1.0,
81            binary_min_run_length: 16,
82            max_scan_values: 512,
83            max_scan_chars: 65_536,
84            max_json_depth: 32,
85        }
86    }
87}
88
89/// One threat, regex or semantic, exactly as the reference emits it.
90#[derive(Debug, Clone, PartialEq)]
91pub enum Threat {
92    Regex(RegexThreat),
93    Semantic(SemanticThreat),
94    /// The reference `pattern_timeout` shape for a spent scan time budget:
95    /// pattern `scan_time_budget_exhausted`, empty match, position 0,
96    /// category `custom`, weighted 1.0 like any `custom` threat. See
97    /// [`crate::scan_budget`].
98    PatternTimeout(RegexThreat),
99}
100
101/// Semantic threat with the full analysis payload (`_check_semantic_threats`).
102#[derive(Debug, Clone, PartialEq)]
103pub struct SemanticThreat {
104    pub attack_type: String,
105    /// `probability` for per-attack-type threats, the overall score for the
106    /// `suspicious` fallback (serialized under `threat_score`).
107    pub score: f64,
108    /// `true` when the score is serialized under the `threat_score` key
109    /// (the fallback threat shape).
110    pub fallback: bool,
111    pub analysis: AnalysisResult,
112}
113
114/// Full verdict (`SusPatternsManager.detect` return shape, minus timing).
115#[derive(Debug, Clone, PartialEq)]
116pub struct DetectVerdict {
117    pub is_threat: bool,
118    pub threat_score: f64,
119    pub threats: Vec<Threat>,
120    pub original_length: usize,
121    pub processed_length: usize,
122}
123
124const KNOWN_CONTEXTS: &[&str] = &[
125    "query_param",
126    "header",
127    "url_path",
128    "request_body",
129    "unknown",
130];
131pub const EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX: &str = ":embedded_json";
132
133/// `_normalize_context`: keep the part before the first `:`, map unknown
134/// contexts to `unknown`.
135#[must_use]
136pub fn normalize_context(context: &str) -> &str {
137    let head = context.split(':').next().unwrap_or("");
138    if KNOWN_CONTEXTS.contains(&head) {
139        head
140    } else {
141        "unknown"
142    }
143}
144
145fn regex_anomaly(regex_threats: &[RegexThreat]) -> f64 {
146    regex_threats.iter().map(|t| t.weight).sum()
147}
148
149/// One scan pass with the reference context normalization applied
150/// (`_check_regex_patterns` minus the async machinery).
151#[must_use]
152fn scan_pass(
153    view_content: &str,
154    filter: ViewFilter,
155    normalized_context: &str,
156    validator_context: &str,
157    budget: &mut ScanTimeBudget,
158) -> Vec<RegexThreat> {
159    let skip_filter = matches!(normalized_context, "unknown" | "request_body");
160    patterns::scan_view_budgeted(
161        view_content,
162        filter,
163        normalized_context,
164        skip_filter,
165        validator_context,
166        budget,
167    )
168}
169
170/// Keep only the scan results whose (pattern, match) pair the earlier views
171/// have not already recorded (`_drop_view_duplicate_threats`).
172#[must_use]
173fn drop_view_duplicate_threats(
174    seen_threats: &[RegexThreat],
175    new_threats: Vec<RegexThreat>,
176) -> Vec<RegexThreat> {
177    let mut seen: std::collections::HashSet<(String, String)> = seen_threats
178        .iter()
179        .map(|threat| (threat.pattern.clone(), threat.match_text.clone()))
180        .collect();
181    let mut kept = Vec::new();
182    for threat in new_threats {
183        if seen.insert((threat.pattern.clone(), threat.match_text.clone())) {
184            kept.push(threat);
185        }
186    }
187    kept
188}
189
190fn semantic_threats(
191    processed: &str,
192    raw_content: &str,
193    config: &DetectConfig,
194) -> Vec<SemanticThreat> {
195    if binary::looks_like_binary_content(raw_content) {
196        return Vec::new();
197    }
198    let semantic_budget = config.max_content_length;
199    let input: String = processed.chars().take(semantic_budget).collect();
200    let analysis = semantic::analyze(
201        &input,
202        &AttackKeywords::default(),
203        &AttackStructures::default(),
204    );
205    let score = semantic::get_threat_score(&analysis);
206
207    let mut threats = Vec::new();
208    if score > config.semantic_threshold {
209        let mut probs: Vec<(&str, f64)> = analysis
210            .attack_probabilities
211            .iter()
212            .map(|(k, v)| (*k, *v))
213            .collect();
214        probs.sort_unstable_by(|a, b| a.0.cmp(b.0));
215        for (attack_type, probability) in probs {
216            if probability >= config.semantic_threshold {
217                threats.push(SemanticThreat {
218                    attack_type: attack_type.to_owned(),
219                    score: probability,
220                    fallback: false,
221                    analysis: analysis.clone(),
222                });
223            }
224        }
225        if threats.is_empty() && score >= config.semantic_threshold {
226            threats.push(SemanticThreat {
227                attack_type: "suspicious".to_owned(),
228                score,
229                fallback: true,
230                analysis,
231            });
232        }
233    }
234    threats
235}
236
237/// The reference `detect` pipeline over one request content, under a fresh
238/// default scan time budget ([`DEFAULT_MAX_SCAN_SECONDS`]) for this call.
239#[must_use]
240pub fn detect(content: &str, request_context: &str, config: &DetectConfig) -> DetectVerdict {
241    detect_with_semantic_skip(content, request_context, config, false)
242}
243
244/// [`detect`] with the semantic analyzer optionally skipped.
245///
246/// The reference `_context_header_is_semantic_excluded`: the regex passes
247/// still run on the value, only the semantic threats are left out. The
248/// opt-in `excluded_semantic_detection_headers` path.
249#[must_use]
250pub fn detect_with_semantic_skip(
251    content: &str,
252    request_context: &str,
253    config: &DetectConfig,
254    skip_semantic: bool,
255) -> DetectVerdict {
256    let mut budget = ScanTimeBudget::new(DEFAULT_MAX_SCAN_SECONDS);
257    detect_with_budget(content, request_context, config, skip_semantic, &mut budget)
258}
259
260/// [`detect_with_semantic_skip`] over a caller-owned [`ScanTimeBudget`].
261///
262/// One budget spans every value of a request (the reference carries it in a
263/// context variable). Once the budget is spent each remaining view pass
264/// records one [`Threat::PatternTimeout`] and stops scanning.
265#[must_use]
266pub fn detect_with_budget(
267    content: &str,
268    request_context: &str,
269    config: &DetectConfig,
270    skip_semantic: bool,
271    budget: &mut ScanTimeBudget,
272) -> DetectVerdict {
273    let original_length = content.chars().count();
274    let (processed, decoded, decode_budget_exhausted) = preprocessor::preprocess_with_decoded(
275        content,
276        config.max_full_scan_bytes,
277        config.preserve_attack_patterns,
278        config.max_content_length,
279    );
280    let processed_length = processed.chars().count();
281
282    let normalized = normalize_context(request_context);
283    let validator_context = if request_context.ends_with(EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX) {
284        format!("{normalized}{EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX}")
285    } else {
286        normalized.to_owned()
287    };
288
289    let mut regex_threats: Vec<RegexThreat> = Vec::new();
290
291    // processed view (raw_view_only = False)
292    regex_threats.extend(scan_pass(
293        &processed,
294        ViewFilter::Processed,
295        normalized,
296        &validator_context,
297        budget,
298    ));
299
300    // raw signal-preserving view (raw_view_only = True)
301    let raw_view = preprocessor::preprocess_signal_preserving(
302        content,
303        config.max_full_scan_bytes,
304        config.preserve_attack_patterns,
305        config.max_content_length,
306    );
307    let raw_pass = scan_pass(
308        &raw_view,
309        ViewFilter::Raw,
310        normalized,
311        &validator_context,
312        budget,
313    );
314    // Threats and matched patterns are parallel lists in the reference (one
315    // append per match), so a raw-view sighting of a pattern over text the
316    // processed views already matched is the same evidence and must not
317    // inflate the threat score (`_drop_view_duplicate_threats`).
318    regex_threats.extend(drop_view_duplicate_threats(&regex_threats, raw_pass));
319
320    // decoded-view path traversal (processed vs raw view shape counts)
321    if let Some(threat) = patterns::decoded_view_traversal_threat(&processed, &raw_view) {
322        regex_threats.push(threat);
323    }
324
325    // URL-decoded view (url_decoded_view_only = True): the precomputed decoded
326    // view, truncated safely
327    let url_decoded_view = preprocessor::truncate_safely(
328        &decoded,
329        config.max_full_scan_bytes,
330        config.preserve_attack_patterns,
331        config.max_content_length,
332    );
333    regex_threats.extend(scan_pass(
334        &url_decoded_view,
335        ViewFilter::UrlDecoded,
336        normalized,
337        &validator_context,
338        budget,
339    ));
340
341    if decode_budget_exhausted {
342        regex_threats.push(patterns::decode_budget_exhausted_threat());
343    }
344
345    // short-base64 additive view (no view exclusion)
346    let additive_view = preprocessor::short_base64_additive_view(
347        content,
348        config.max_full_scan_bytes,
349        config.preserve_attack_patterns,
350        config.max_content_length,
351    );
352    if !additive_view.is_empty() {
353        regex_threats.extend(scan_pass(
354            &additive_view,
355            ViewFilter::All,
356            normalized,
357            &validator_context,
358            budget,
359        ));
360    }
361
362    let semantic = if skip_semantic {
363        Vec::new()
364    } else {
365        semantic_threats(&processed, content, config)
366    };
367
368    score_verdict(
369        regex_threats,
370        semantic,
371        config,
372        original_length,
373        processed_length,
374    )
375}
376
377/// Fold the regex and semantic threats into the verdict (`_regex_anomaly`
378/// and `_calculate_threat_score`).
379fn score_verdict(
380    regex_threats: Vec<RegexThreat>,
381    semantic: Vec<SemanticThreat>,
382    config: &DetectConfig,
383    original_length: usize,
384    processed_length: usize,
385) -> DetectVerdict {
386    let anomaly = regex_anomaly(&regex_threats);
387    let is_threat = anomaly >= config.threat_score_threshold || !semantic.is_empty();
388
389    let threat_score = if regex_threats.is_empty() && semantic.is_empty() {
390        0.0
391    } else {
392        let semantic_max = semantic.iter().map(|s| s.score).fold(0.0_f64, f64::max);
393        (anomaly.max(semantic_max)).min(1.0)
394    };
395
396    let mut threats: Vec<Threat> = regex_threats
397        .into_iter()
398        .map(|threat| {
399            if threat.pattern == SCAN_TIME_BUDGET_EXHAUSTED_PATTERN {
400                Threat::PatternTimeout(threat)
401            } else {
402                Threat::Regex(threat)
403            }
404        })
405        .collect();
406    threats.extend(semantic.into_iter().map(Threat::Semantic));
407
408    DetectVerdict {
409        is_threat,
410        threat_score,
411        threats,
412        original_length,
413        processed_length,
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    fn corpus_config() -> DetectConfig {
422        DetectConfig {
423            max_content_length: 10_000,
424            max_full_scan_bytes: 262_144,
425            preserve_attack_patterns: true,
426            semantic_threshold: 0.7,
427            threat_score_threshold: 1.0,
428            binary_min_run_length: 16,
429            max_scan_values: 512,
430            max_scan_chars: 65_536,
431            max_json_depth: 32,
432        }
433    }
434
435    #[test]
436    fn the_config_defaults_carry_the_reference_values() {
437        let config = DetectConfig::default();
438        assert_eq!(config.max_content_length, 10_000);
439        assert_eq!(config.max_full_scan_bytes, 262_144);
440        assert!(config.preserve_attack_patterns);
441        assert!((config.semantic_threshold - 0.7).abs() < f64::EPSILON);
442        assert!((config.threat_score_threshold - 1.0).abs() < f64::EPSILON);
443        assert_eq!(config.binary_min_run_length, 16);
444        assert_eq!(config.max_scan_values, 512);
445        assert_eq!(config.max_scan_chars, 65_536);
446        assert_eq!(config.max_json_depth, 32);
447    }
448
449    #[test]
450    fn traversal_in_url_path_context() {
451        let v = detect("/files?name=../../etc/passwd", "url_path", &corpus_config());
452        assert!(v.is_threat);
453        assert!((v.threat_score - 1.0).abs() < f64::EPSILON);
454        assert_eq!(v.threats.len(), 2);
455        assert_eq!(v.original_length, 28);
456        assert_eq!(v.processed_length, 28);
457    }
458
459    #[test]
460    fn benign_content_is_clean() {
461        let v = detect("hello world", "request_body", &corpus_config());
462        assert!(!v.is_threat);
463        assert!((v.threat_score - 0.0).abs() < f64::EPSILON);
464        assert!(v.threats.is_empty());
465    }
466
467    /// The sqli-comment predicate shared by the positive and negative
468    /// context-filtering assertions so both match arms execute.
469    fn is_sqli_comment_threat(t: &Threat) -> bool {
470        matches!(t, Threat::Regex(r) if r.category == "sqli" && r.pattern.contains("/\\*"))
471    }
472
473    #[test]
474    fn context_filtering_gates_sqli_narrow() {
475        // `\w/\*(?!!)[^*]*\*/\w` is SQLI_NARROW (no url_path/header)
476        let hit = detect("a/**/b", "request_body", &corpus_config());
477        assert!(hit.threats.iter().any(is_sqli_comment_threat));
478        // a url_path verdict carrying other threats has no sqli comment hit
479        let miss = detect("/files?name=../../etc/passwd", "url_path", &corpus_config());
480        assert!(
481            miss.threats
482                .iter()
483                .any(|t| matches!(t, Threat::Regex(r) if r.category == "dir_traversal"))
484        );
485        assert!(!miss.threats.iter().any(is_sqli_comment_threat));
486    }
487
488    #[test]
489    fn unknown_context_normalizes() {
490        assert_eq!(normalize_context("weird:thing"), "unknown");
491        assert_eq!(
492            normalize_context("query_param:embedded_json"),
493            "query_param"
494        );
495        assert_eq!(normalize_context("header"), "header");
496    }
497
498    #[test]
499    fn raw_view_only_patterns_need_the_raw_view() {
500        // encoded-dot traversal only fires on the raw view
501        let v = detect("/search?q=%2e%2e%2f", "url_path", &corpus_config());
502        assert!(
503            v.threats
504                .iter()
505                .any(|t| matches!(t, Threat::Regex(r) if r.category == "path_traversal"))
506        );
507    }
508
509    #[test]
510    fn regex_anomaly_sums_weights() {
511        // two 0.5-weight sqli hits cross the 1.0 threshold together
512        let v = detect("SELECT * FROM users", "request_body", &corpus_config());
513        assert!(v.is_threat);
514        // a semantic threat rides along on its own (no regex hits of its
515        // own weight change): it contributes nothing to the anomaly sum
516        let semantic_side = detect(&mixed_content(), "request_body", &corpus_config());
517        let semantic_threat = semantic_side
518            .threats
519            .iter()
520            .find(|t| matches!(t, Threat::Semantic(_)))
521            .expect("the mixed payload carries a semantic threat");
522        let anomaly: f64 = v
523            .threats
524            .iter()
525            .chain(std::iter::once(semantic_threat))
526            .filter_map(|t| match t {
527                Threat::Regex(r) | Threat::PatternTimeout(r) => Some(r.weight),
528                Threat::Semantic(_) => None,
529            })
530            .sum();
531        assert!((anomaly - 1.0).abs() < 1e-9, "anomaly {anomaly}");
532
533        // a mixed verdict: semantic threats carry no anomaly weight
534        let mixed = detect(&mixed_content(), "request_body", &corpus_config());
535        assert!(
536            mixed
537                .threats
538                .iter()
539                .any(|t| matches!(t, Threat::Semantic(_)))
540        );
541        let mixed_anomaly: f64 = mixed
542            .threats
543            .iter()
544            .filter_map(|t| match t {
545                Threat::Regex(r) | Threat::PatternTimeout(r) => Some(r.weight),
546                Threat::Semantic(_) => None,
547            })
548            .sum();
549        assert!(mixed_anomaly > 0.0, "regex hits still carry weight");
550        assert!(mixed.is_threat);
551    }
552
553    fn mixed_content() -> String {
554        format!(
555            "select union insert update delete drop from where order group having concat \
556             substring database table column (1 OR 1=1) {}",
557            "A".repeat(120)
558        )
559    }
560
561    #[test]
562    fn an_embedded_json_leaf_context_keeps_its_suffix() {
563        // a context carrying the embedded-json suffix keeps it through
564        // normalization for the validator seam
565        let v = detect(
566            "SELECT * FROM users",
567            "request_body:embedded_json",
568            &corpus_config(),
569        );
570        assert!(v.is_threat);
571    }
572
573    #[test]
574    fn a_short_base64_view_scans_as_an_additive_pass() {
575        // `JHt9` decodes to `${}`: the short-token additive view fills and
576        // its fragments run through their own scan pass, surfacing the
577        // command-injection shape the raw text hides
578        let v = detect("JHt9", "request_body", &corpus_config());
579        assert!(v.is_threat, "unexpected verdict: {v:?}");
580        assert!(
581            v.threats
582                .iter()
583                .any(|t| matches!(t, Threat::Regex(r) if r.category == "cmd_injection")),
584            "verdict: {v:?}"
585        );
586    }
587
588    #[test]
589    fn binary_raw_content_never_yields_semantic_threats() {
590        // a NUL-heavy raw payload trips the binary gate before any scan
591        let processed = "\0".repeat(16);
592        assert!(
593            semantic_threats(&processed, &processed, &corpus_config()).is_empty(),
594            "binary content is exempt from the semantic pass"
595        );
596    }
597
598    #[test]
599    fn an_exhausted_decode_budget_appends_its_marker_threat() {
600        // `%25` followed by fifteen `25` pairs peels exactly one encoding
601        // layer per pass, so the 16-iteration decode budget runs out and
602        // the synthetic exhaustion threat rides on the verdict
603        let content = format!("%25{}", "25".repeat(15));
604        let v = detect(&content, "url_path", &corpus_config());
605        assert!(
606            v.threats.iter().any(|t| matches!(
607                t,
608                Threat::Regex(r) if r.pattern == "decode_budget_exhausted"
609            )),
610            "verdict: {v:?}"
611        );
612    }
613}
614
615#[cfg(test)]
616mod gap_tests {
617    use super::*;
618
619    fn corpus_config() -> DetectConfig {
620        DetectConfig {
621            max_content_length: 10_000,
622            max_full_scan_bytes: 262_144,
623            preserve_attack_patterns: true,
624            semantic_threshold: 0.7,
625            threat_score_threshold: 1.0,
626            binary_min_run_length: 16,
627            max_scan_values: 512,
628            max_scan_chars: 65_536,
629            max_json_depth: 32,
630        }
631    }
632
633    #[test]
634    fn a_semantic_fallback_threat_reports_suspicious() {
635        // high aggregate score without any single attack class above the
636        // threshold: the fallback "suspicious" semantic threat fires
637        let mut content = String::from("from where drop select ");
638        content.push_str(&"A".repeat(120));
639        content.push_str(" ({}$x==) foo(");
640        let v = detect(&content, "request_body", &corpus_config());
641        assert!(v.is_threat, "verdict: {v:?}");
642        assert!(
643            v.threats.iter().any(
644                |t| matches!(t, Threat::Semantic(s) if s.attack_type == "suspicious" && s.fallback)
645            ),
646            "verdict: {v:?}"
647        );
648    }
649
650    #[test]
651    fn a_mixed_verdict_scores_the_semantic_maximum_against_the_anomaly() {
652        // regex sqli hits plus a semantic sql threat: the threat score folds
653        // over both lists
654        let content = format!(
655            "select union insert update delete drop from where order group having concat \
656             substring database table column (1 OR 1=1) {}",
657            "A".repeat(120)
658        );
659        let v = detect(&content, "request_body", &corpus_config());
660        assert!(v.is_threat);
661        assert!(
662            v.threats
663                .iter()
664                .any(|t| matches!(t, Threat::Regex(r) if r.category == "sqli"))
665        );
666        assert!(v.threats.iter().any(|t| matches!(t, Threat::Semantic(_))));
667        let semantic_max = v
668            .threats
669            .iter()
670            .filter_map(|t| match t {
671                Threat::Semantic(s) => Some(s.score),
672                Threat::Regex(_) | Threat::PatternTimeout(_) => None,
673            })
674            .fold(0.0_f64, f64::max);
675        let anomaly = v
676            .threats
677            .iter()
678            .filter_map(|t| match t {
679                Threat::Regex(r) | Threat::PatternTimeout(r) => Some(r.weight),
680                Threat::Semantic(_) => None,
681            })
682            .sum::<f64>();
683        assert!((v.threat_score - anomaly.max(semantic_max).min(1.0)).abs() < 1e-9);
684    }
685}
686
687#[cfg(test)]
688mod scan_budget_tests {
689    use super::*;
690    use crate::scan_budget::{SCAN_TIME_BUDGET_EXHAUSTED_PATTERN, ScanTimeBudget};
691    use std::cell::Cell;
692
693    thread_local! {
694        static NOW: Cell<f64> = const { Cell::new(0.0) };
695    }
696
697    /// One fake CPU second per clock read, so every measured scan costs 1 s.
698    fn fake_clock() -> f64 {
699        NOW.with(|now| {
700            now.set(now.get() + 1.0);
701            now.get()
702        })
703    }
704
705    fn budget_threats(verdict: &DetectVerdict) -> Vec<&RegexThreat> {
706        verdict
707            .threats
708            .iter()
709            .filter_map(|t| match t {
710                Threat::PatternTimeout(r) => Some(r),
711                _ => None,
712            })
713            .collect()
714    }
715
716    #[test]
717    fn an_exhausted_budget_records_the_reference_pattern_timeout_threat() {
718        let mut budget = ScanTimeBudget::with_clock(Some(0.1), fake_clock);
719        let verdict = detect_with_budget(
720            "hello world",
721            "query_param",
722            &DetectConfig::default(),
723            false,
724            &mut budget,
725        );
726        let threats = budget_threats(&verdict);
727        assert!(!threats.is_empty(), "verdict: {verdict:?}");
728        let threat = threats[0];
729        assert_eq!(threat.pattern, SCAN_TIME_BUDGET_EXHAUSTED_PATTERN);
730        assert_eq!(threat.match_text, "");
731        assert_eq!(threat.position, 0);
732        assert_eq!(threat.category, "custom");
733        assert!((threat.weight - 1.0).abs() < f64::EPSILON);
734        assert!(verdict.is_threat, "the weighted threat blocks");
735        assert!(
736            !verdict
737                .threats
738                .iter()
739                .any(|t| matches!(t, Threat::Regex(_))),
740            "the scan stopped after the first pattern"
741        );
742    }
743
744    #[test]
745    fn the_budget_is_shared_across_values() {
746        let mut budget = ScanTimeBudget::with_clock(Some(2.5), fake_clock);
747        let config = DetectConfig::default();
748        let first = detect_with_budget("hello", "query_param", &config, false, &mut budget);
749        let second = detect_with_budget("world", "query_param", &config, false, &mut budget);
750        let recorded = budget_threats(&first)
751            .into_iter()
752            .chain(budget_threats(&second))
753            .any(|t| t.pattern == SCAN_TIME_BUDGET_EXHAUSTED_PATTERN);
754        assert!(recorded);
755        assert!(second.is_threat);
756    }
757
758    #[test]
759    fn within_budget_nothing_changes() {
760        let config = DetectConfig::default();
761        let benign = detect("page=2&sort=asc", "query_param", &config);
762        assert!(!benign.is_threat);
763        assert!(budget_threats(&benign).is_empty());
764
765        let attack = detect("<script>alert(1)</script>", "query_param", &config);
766        assert!(attack.is_threat);
767        assert!(budget_threats(&attack).is_empty());
768        assert!(
769            attack
770                .threats
771                .iter()
772                .any(|t| matches!(t, Threat::Regex(r) if r.category == "xss"))
773        );
774    }
775
776    #[test]
777    fn a_zero_cap_exhausts_before_the_first_pattern() {
778        let verdict = detect_with_budget(
779            "hello world",
780            "query_param",
781            &DetectConfig::default(),
782            false,
783            &mut ScanTimeBudget::new(0.0),
784        );
785        // One per view pass that ran (the raw pass's duplicate is dropped).
786        assert_eq!(budget_threats(&verdict).len(), 2, "verdict: {verdict:?}");
787        assert!(
788            verdict
789                .threats
790                .iter()
791                .all(|t| matches!(t, Threat::PatternTimeout(_)))
792        );
793    }
794}