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