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::semantic::{self, AnalysisResult, AttackKeywords, AttackStructures};
28
29/// Knob mapping for one detect call (spec `config_knobs`).
30#[derive(Debug, Clone, Copy)]
31pub struct DetectConfig {
32    /// `detection_max_content_length` (semantic budget, truncation budget).
33    pub max_content_length: usize,
34    /// `detection_max_body_inspect_bytes` (the preprocessor's full-scan cap).
35    pub max_full_scan_bytes: usize,
36    /// `detection_preserve_attack_patterns`.
37    pub preserve_attack_patterns: bool,
38    /// `detection_semantic_threshold`.
39    pub semantic_threshold: f64,
40    /// `detection_threat_score_threshold`.
41    pub threat_score_threshold: f64,
42    /// `detection_binary_min_run_length` (reference bounds 4..=1024, default
43    /// 16): minimum code-point length a printable run inside a binary-dense
44    /// multipart file-part payload must reach before the run is handed to the
45    /// pattern scan (see [`crate::binary_islands`]).
46    pub binary_min_run_length: usize,
47    /// `detection_max_scan_values` (reference default 512): the per-request
48    /// scan budget counting values handed to [`detect`]; once the count
49    /// crosses the cap the remaining request values are not scanned (the
50    /// fail-open signal the reference logs once).
51    pub max_scan_values: usize,
52    /// `detection_max_scan_chars` (reference default 65536): the
53    /// per-request character budget, accounted at the same per-value point
54    /// as `max_scan_values`; once the consumed count reaches the cap the
55    /// remaining request values are not scanned.
56    pub max_scan_chars: usize,
57    /// `detection_max_json_depth` (reference default 32): the JSON walk's
58    /// semantic depth cap; containers at or beyond this nesting serialize
59    /// back to compact JSON and scan as text (see [`crate::json_walk`]).
60    pub max_json_depth: usize,
61}
62
63impl Default for DetectConfig {
64    /// The reference defaults from `_security_config_fields.py`: the
65    /// reference `detection_max_content_length` (ten thousand), the
66    /// reference `detection_max_body_inspect_bytes` (262,144), preserved
67    /// patterns, the 0.7 / 1.0 thresholds, the reference binary run
68    /// length (16), the reference `detection_max_scan_values` (512), the
69    /// reference `detection_max_scan_chars` (65,536), and the reference
70    /// `detection_max_json_depth` (32).
71    fn default() -> Self {
72        Self {
73            max_content_length: 10_000,
74            max_full_scan_bytes: 262_144,
75            preserve_attack_patterns: true,
76            semantic_threshold: 0.7,
77            threat_score_threshold: 1.0,
78            binary_min_run_length: 16,
79            max_scan_values: 512,
80            max_scan_chars: 65_536,
81            max_json_depth: 32,
82        }
83    }
84}
85
86/// One threat, regex or semantic, exactly as the reference emits it.
87#[derive(Debug, Clone, PartialEq)]
88pub enum Threat {
89    Regex(RegexThreat),
90    Semantic(SemanticThreat),
91}
92
93/// Semantic threat with the full analysis payload (`_check_semantic_threats`).
94#[derive(Debug, Clone, PartialEq)]
95pub struct SemanticThreat {
96    pub attack_type: String,
97    /// `probability` for per-attack-type threats, the overall score for the
98    /// `suspicious` fallback (serialized under `threat_score`).
99    pub score: f64,
100    /// `true` when the score is serialized under the `threat_score` key
101    /// (the fallback threat shape).
102    pub fallback: bool,
103    pub analysis: AnalysisResult,
104}
105
106/// Full verdict (`SusPatternsManager.detect` return shape, minus timing).
107#[derive(Debug, Clone, PartialEq)]
108pub struct DetectVerdict {
109    pub is_threat: bool,
110    pub threat_score: f64,
111    pub threats: Vec<Threat>,
112    pub original_length: usize,
113    pub processed_length: usize,
114}
115
116const KNOWN_CONTEXTS: &[&str] = &[
117    "query_param",
118    "header",
119    "url_path",
120    "request_body",
121    "unknown",
122];
123pub const EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX: &str = ":embedded_json";
124
125/// `_normalize_context`: keep the part before the first `:`, map unknown
126/// contexts to `unknown`.
127#[must_use]
128pub fn normalize_context(context: &str) -> &str {
129    let head = context.split(':').next().unwrap_or("");
130    if KNOWN_CONTEXTS.contains(&head) {
131        head
132    } else {
133        "unknown"
134    }
135}
136
137fn regex_anomaly(regex_threats: &[RegexThreat]) -> f64 {
138    regex_threats.iter().map(|t| t.weight).sum()
139}
140
141/// One scan pass with the reference context normalization applied
142/// (`_check_regex_patterns` minus the async machinery).
143#[must_use]
144fn scan_pass(
145    view_content: &str,
146    filter: ViewFilter,
147    normalized_context: &str,
148    validator_context: &str,
149) -> Vec<RegexThreat> {
150    let skip_filter = matches!(normalized_context, "unknown" | "request_body");
151    patterns::scan_view(
152        view_content,
153        filter,
154        normalized_context,
155        skip_filter,
156        validator_context,
157    )
158}
159
160/// Keep only the scan results whose (pattern, match) pair the earlier views
161/// have not already recorded (`_drop_view_duplicate_threats`).
162#[must_use]
163fn drop_view_duplicate_threats(
164    seen_threats: &[RegexThreat],
165    new_threats: Vec<RegexThreat>,
166) -> Vec<RegexThreat> {
167    let mut seen: std::collections::HashSet<(String, String)> = seen_threats
168        .iter()
169        .map(|threat| (threat.pattern.clone(), threat.match_text.clone()))
170        .collect();
171    let mut kept = Vec::new();
172    for threat in new_threats {
173        if seen.insert((threat.pattern.clone(), threat.match_text.clone())) {
174            kept.push(threat);
175        }
176    }
177    kept
178}
179
180fn semantic_threats(
181    processed: &str,
182    raw_content: &str,
183    config: &DetectConfig,
184) -> Vec<SemanticThreat> {
185    if binary::looks_like_binary_content(raw_content) {
186        return Vec::new();
187    }
188    let semantic_budget = config.max_content_length;
189    let input: String = processed.chars().take(semantic_budget).collect();
190    let analysis = semantic::analyze(
191        &input,
192        &AttackKeywords::default(),
193        &AttackStructures::default(),
194    );
195    let score = semantic::get_threat_score(&analysis);
196
197    let mut threats = Vec::new();
198    if score > config.semantic_threshold {
199        let mut probs: Vec<(&str, f64)> = analysis
200            .attack_probabilities
201            .iter()
202            .map(|(k, v)| (*k, *v))
203            .collect();
204        probs.sort_unstable_by(|a, b| a.0.cmp(b.0));
205        for (attack_type, probability) in probs {
206            if probability >= config.semantic_threshold {
207                threats.push(SemanticThreat {
208                    attack_type: attack_type.to_owned(),
209                    score: probability,
210                    fallback: false,
211                    analysis: analysis.clone(),
212                });
213            }
214        }
215        if threats.is_empty() && score >= config.semantic_threshold {
216            threats.push(SemanticThreat {
217                attack_type: "suspicious".to_owned(),
218                score,
219                fallback: true,
220                analysis,
221            });
222        }
223    }
224    threats
225}
226
227/// The reference `detect` pipeline over one request content.
228#[must_use]
229pub fn detect(content: &str, request_context: &str, config: &DetectConfig) -> DetectVerdict {
230    let original_length = content.chars().count();
231    let (processed, decoded, decode_budget_exhausted) = preprocessor::preprocess_with_decoded(
232        content,
233        config.max_full_scan_bytes,
234        config.preserve_attack_patterns,
235        config.max_content_length,
236    );
237    let processed_length = processed.chars().count();
238
239    let normalized = normalize_context(request_context);
240    let validator_context = if request_context.ends_with(EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX) {
241        format!("{normalized}{EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX}")
242    } else {
243        normalized.to_owned()
244    };
245
246    let mut regex_threats: Vec<RegexThreat> = Vec::new();
247
248    // processed view (raw_view_only = False)
249    regex_threats.extend(scan_pass(
250        &processed,
251        ViewFilter::Processed,
252        normalized,
253        &validator_context,
254    ));
255
256    // raw signal-preserving view (raw_view_only = True)
257    let raw_view = preprocessor::preprocess_signal_preserving(
258        content,
259        config.max_full_scan_bytes,
260        config.preserve_attack_patterns,
261        config.max_content_length,
262    );
263    let raw_pass = scan_pass(&raw_view, ViewFilter::Raw, normalized, &validator_context);
264    // Threats and matched patterns are parallel lists in the reference (one
265    // append per match), so a raw-view sighting of a pattern over text the
266    // processed views already matched is the same evidence and must not
267    // inflate the threat score (`_drop_view_duplicate_threats`).
268    regex_threats.extend(drop_view_duplicate_threats(&regex_threats, raw_pass));
269
270    // decoded-view path traversal (processed vs raw view shape counts)
271    if let Some(threat) = patterns::decoded_view_traversal_threat(&processed, &raw_view) {
272        regex_threats.push(threat);
273    }
274
275    // URL-decoded view (url_decoded_view_only = True): the precomputed decoded
276    // view, truncated safely
277    let url_decoded_view = preprocessor::truncate_safely(
278        &decoded,
279        config.max_full_scan_bytes,
280        config.preserve_attack_patterns,
281        config.max_content_length,
282    );
283    regex_threats.extend(scan_pass(
284        &url_decoded_view,
285        ViewFilter::UrlDecoded,
286        normalized,
287        &validator_context,
288    ));
289
290    if decode_budget_exhausted {
291        regex_threats.push(patterns::decode_budget_exhausted_threat());
292    }
293
294    // short-base64 additive view (no view exclusion)
295    let additive_view = preprocessor::short_base64_additive_view(
296        content,
297        config.max_full_scan_bytes,
298        config.preserve_attack_patterns,
299        config.max_content_length,
300    );
301    if !additive_view.is_empty() {
302        regex_threats.extend(scan_pass(
303            &additive_view,
304            ViewFilter::All,
305            normalized,
306            &validator_context,
307        ));
308    }
309
310    let semantic = semantic_threats(&processed, content, config);
311
312    let anomaly = regex_anomaly(&regex_threats);
313    let is_threat = anomaly >= config.threat_score_threshold || !semantic.is_empty();
314
315    let threat_score = if regex_threats.is_empty() && semantic.is_empty() {
316        0.0
317    } else {
318        let semantic_max = semantic.iter().map(|s| s.score).fold(0.0_f64, f64::max);
319        (anomaly.max(semantic_max)).min(1.0)
320    };
321
322    let mut threats: Vec<Threat> = regex_threats.into_iter().map(Threat::Regex).collect();
323    threats.extend(semantic.into_iter().map(Threat::Semantic));
324
325    DetectVerdict {
326        is_threat,
327        threat_score,
328        threats,
329        original_length,
330        processed_length,
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    fn corpus_config() -> DetectConfig {
339        DetectConfig {
340            max_content_length: 10_000,
341            max_full_scan_bytes: 262_144,
342            preserve_attack_patterns: true,
343            semantic_threshold: 0.7,
344            threat_score_threshold: 1.0,
345            binary_min_run_length: 16,
346            max_scan_values: 512,
347            max_scan_chars: 65_536,
348            max_json_depth: 32,
349        }
350    }
351
352    #[test]
353    fn the_config_defaults_carry_the_reference_values() {
354        let config = DetectConfig::default();
355        assert_eq!(config.max_content_length, 10_000);
356        assert_eq!(config.max_full_scan_bytes, 262_144);
357        assert!(config.preserve_attack_patterns);
358        assert!((config.semantic_threshold - 0.7).abs() < f64::EPSILON);
359        assert!((config.threat_score_threshold - 1.0).abs() < f64::EPSILON);
360        assert_eq!(config.binary_min_run_length, 16);
361        assert_eq!(config.max_scan_values, 512);
362        assert_eq!(config.max_scan_chars, 65_536);
363        assert_eq!(config.max_json_depth, 32);
364    }
365
366    #[test]
367    fn traversal_in_url_path_context() {
368        let v = detect("/files?name=../../etc/passwd", "url_path", &corpus_config());
369        assert!(v.is_threat);
370        assert!((v.threat_score - 1.0).abs() < f64::EPSILON);
371        assert_eq!(v.threats.len(), 2);
372        assert_eq!(v.original_length, 28);
373        assert_eq!(v.processed_length, 28);
374    }
375
376    #[test]
377    fn benign_content_is_clean() {
378        let v = detect("hello world", "request_body", &corpus_config());
379        assert!(!v.is_threat);
380        assert!((v.threat_score - 0.0).abs() < f64::EPSILON);
381        assert!(v.threats.is_empty());
382    }
383
384    /// The sqli-comment predicate shared by the positive and negative
385    /// context-filtering assertions so both match arms execute.
386    fn is_sqli_comment_threat(t: &Threat) -> bool {
387        matches!(t, Threat::Regex(r) if r.category == "sqli" && r.pattern.contains("/\\*"))
388    }
389
390    #[test]
391    fn context_filtering_gates_sqli_narrow() {
392        // `\w/\*(?!!)[^*]*\*/\w` is SQLI_NARROW (no url_path/header)
393        let hit = detect("a/**/b", "request_body", &corpus_config());
394        assert!(hit.threats.iter().any(is_sqli_comment_threat));
395        // a url_path verdict carrying other threats has no sqli comment hit
396        let miss = detect("/files?name=../../etc/passwd", "url_path", &corpus_config());
397        assert!(
398            miss.threats
399                .iter()
400                .any(|t| matches!(t, Threat::Regex(r) if r.category == "dir_traversal"))
401        );
402        assert!(!miss.threats.iter().any(is_sqli_comment_threat));
403    }
404
405    #[test]
406    fn unknown_context_normalizes() {
407        assert_eq!(normalize_context("weird:thing"), "unknown");
408        assert_eq!(
409            normalize_context("query_param:embedded_json"),
410            "query_param"
411        );
412        assert_eq!(normalize_context("header"), "header");
413    }
414
415    #[test]
416    fn raw_view_only_patterns_need_the_raw_view() {
417        // encoded-dot traversal only fires on the raw view
418        let v = detect("/search?q=%2e%2e%2f", "url_path", &corpus_config());
419        assert!(
420            v.threats
421                .iter()
422                .any(|t| matches!(t, Threat::Regex(r) if r.category == "path_traversal"))
423        );
424    }
425
426    #[test]
427    fn regex_anomaly_sums_weights() {
428        // two 0.5-weight sqli hits cross the 1.0 threshold together
429        let v = detect("SELECT * FROM users", "request_body", &corpus_config());
430        assert!(v.is_threat);
431        // a semantic threat rides along on its own (no regex hits of its
432        // own weight change): it contributes nothing to the anomaly sum
433        let semantic_side = detect(&mixed_content(), "request_body", &corpus_config());
434        let semantic_threat = semantic_side
435            .threats
436            .iter()
437            .find(|t| matches!(t, Threat::Semantic(_)))
438            .expect("the mixed payload carries a semantic threat");
439        let anomaly: f64 = v
440            .threats
441            .iter()
442            .chain(std::iter::once(semantic_threat))
443            .filter_map(|t| match t {
444                Threat::Regex(r) => Some(r.weight),
445                Threat::Semantic(_) => None,
446            })
447            .sum();
448        assert!((anomaly - 1.0).abs() < 1e-9, "anomaly {anomaly}");
449
450        // a mixed verdict: semantic threats carry no anomaly weight
451        let mixed = detect(&mixed_content(), "request_body", &corpus_config());
452        assert!(
453            mixed
454                .threats
455                .iter()
456                .any(|t| matches!(t, Threat::Semantic(_)))
457        );
458        let mixed_anomaly: f64 = mixed
459            .threats
460            .iter()
461            .filter_map(|t| match t {
462                Threat::Regex(r) => Some(r.weight),
463                Threat::Semantic(_) => None,
464            })
465            .sum();
466        assert!(mixed_anomaly > 0.0, "regex hits still carry weight");
467        assert!(mixed.is_threat);
468    }
469
470    fn mixed_content() -> String {
471        format!(
472            "select union insert update delete drop from where order group having concat \
473             substring database table column (1 OR 1=1) {}",
474            "A".repeat(120)
475        )
476    }
477
478    #[test]
479    fn an_embedded_json_leaf_context_keeps_its_suffix() {
480        // a context carrying the embedded-json suffix keeps it through
481        // normalization for the validator seam
482        let v = detect(
483            "SELECT * FROM users",
484            "request_body:embedded_json",
485            &corpus_config(),
486        );
487        assert!(v.is_threat);
488    }
489
490    #[test]
491    fn a_short_base64_view_scans_as_an_additive_pass() {
492        // `JHt9` decodes to `${}`: the short-token additive view fills and
493        // its fragments run through their own scan pass, surfacing the
494        // command-injection shape the raw text hides
495        let v = detect("JHt9", "request_body", &corpus_config());
496        assert!(v.is_threat, "unexpected verdict: {v:?}");
497        assert!(
498            v.threats
499                .iter()
500                .any(|t| matches!(t, Threat::Regex(r) if r.category == "cmd_injection")),
501            "verdict: {v:?}"
502        );
503    }
504
505    #[test]
506    fn binary_raw_content_never_yields_semantic_threats() {
507        // a NUL-heavy raw payload trips the binary gate before any scan
508        let processed = "\0".repeat(16);
509        assert!(
510            semantic_threats(&processed, &processed, &corpus_config()).is_empty(),
511            "binary content is exempt from the semantic pass"
512        );
513    }
514
515    #[test]
516    fn an_exhausted_decode_budget_appends_its_marker_threat() {
517        // `%25` followed by fifteen `25` pairs peels exactly one encoding
518        // layer per pass, so the 16-iteration decode budget runs out and
519        // the synthetic exhaustion threat rides on the verdict
520        let content = format!("%25{}", "25".repeat(15));
521        let v = detect(&content, "url_path", &corpus_config());
522        assert!(
523            v.threats.iter().any(|t| matches!(
524                t,
525                Threat::Regex(r) if r.pattern == "decode_budget_exhausted"
526            )),
527            "verdict: {v:?}"
528        );
529    }
530}
531
532#[cfg(test)]
533mod gap_tests {
534    use super::*;
535
536    fn corpus_config() -> DetectConfig {
537        DetectConfig {
538            max_content_length: 10_000,
539            max_full_scan_bytes: 262_144,
540            preserve_attack_patterns: true,
541            semantic_threshold: 0.7,
542            threat_score_threshold: 1.0,
543            binary_min_run_length: 16,
544            max_scan_values: 512,
545            max_scan_chars: 65_536,
546            max_json_depth: 32,
547        }
548    }
549
550    #[test]
551    fn a_semantic_fallback_threat_reports_suspicious() {
552        // high aggregate score without any single attack class above the
553        // threshold: the fallback "suspicious" semantic threat fires
554        let mut content = String::from("from where drop select ");
555        content.push_str(&"A".repeat(120));
556        content.push_str(" ({}$x==) foo(");
557        let v = detect(&content, "request_body", &corpus_config());
558        assert!(v.is_threat, "verdict: {v:?}");
559        assert!(
560            v.threats.iter().any(
561                |t| matches!(t, Threat::Semantic(s) if s.attack_type == "suspicious" && s.fallback)
562            ),
563            "verdict: {v:?}"
564        );
565    }
566
567    #[test]
568    fn a_mixed_verdict_scores_the_semantic_maximum_against_the_anomaly() {
569        // regex sqli hits plus a semantic sql threat: the threat score folds
570        // over both lists
571        let content = format!(
572            "select union insert update delete drop from where order group having concat \
573             substring database table column (1 OR 1=1) {}",
574            "A".repeat(120)
575        );
576        let v = detect(&content, "request_body", &corpus_config());
577        assert!(v.is_threat);
578        assert!(
579            v.threats
580                .iter()
581                .any(|t| matches!(t, Threat::Regex(r) if r.category == "sqli"))
582        );
583        assert!(v.threats.iter().any(|t| matches!(t, Threat::Semantic(_))));
584        let semantic_max = v
585            .threats
586            .iter()
587            .filter_map(|t| match t {
588                Threat::Semantic(s) => Some(s.score),
589                Threat::Regex(_) => None,
590            })
591            .fold(0.0_f64, f64::max);
592        let anomaly = v
593            .threats
594            .iter()
595            .filter_map(|t| match t {
596                Threat::Regex(r) => Some(r.weight),
597                Threat::Semantic(_) => None,
598            })
599            .sum::<f64>();
600        assert!((v.threat_score - anomaly.max(semantic_max).min(1.0)).abs() < 1e-9);
601    }
602}