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`): only raw-view-only
8//!   patterns;
9//! - decoded-view path-traversal check (processed vs raw view counts);
10//! - URL-decoded view (`url_decoded_view_only=True`): only URL-decoded-view
11//!   patterns, content is the precomputed decoded view truncated safely;
12//! - decode-budget exhaustion threat when a decode pass hit its budget;
13//! - short-base64 additive view (no view exclusion);
14//! - semantic analysis over the processed content (skipped for binary-looking
15//!   raw content).
16//!
17//! Scoring follows `_regex_anomaly` / `_calculate_threat_score`: `is_threat`
18//! is `sum(regex weights) >= threat_score_threshold or any semantic threat`;
19//! `threat_score` is `min(max(regex anomaly, semantic max), 1.0)` when any
20//! threat exists, else 0.0.
21
22use crate::patterns::binary;
23use crate::patterns::{self, RegexThreat, ViewFilter};
24use crate::preprocessor;
25use crate::semantic::{self, AnalysisResult, AttackKeywords, AttackStructures};
26
27/// Knob mapping for one detect call (spec `config_knobs`).
28#[derive(Debug, Clone, Copy)]
29pub struct DetectConfig {
30    /// `detection_max_content_length` (semantic budget, truncation budget).
31    pub max_content_length: usize,
32    /// `detection_max_body_inspect_bytes` (the preprocessor's full-scan cap).
33    pub max_full_scan_bytes: usize,
34    /// `detection_preserve_attack_patterns`.
35    pub preserve_attack_patterns: bool,
36    /// `detection_semantic_threshold`.
37    pub semantic_threshold: f64,
38    /// `detection_threat_score_threshold`.
39    pub threat_score_threshold: f64,
40}
41
42/// One threat, regex or semantic, exactly as the reference emits it.
43#[derive(Debug, Clone, PartialEq)]
44pub enum Threat {
45    Regex(RegexThreat),
46    Semantic(SemanticThreat),
47}
48
49/// Semantic threat with the full analysis payload (`_check_semantic_threats`).
50#[derive(Debug, Clone, PartialEq)]
51pub struct SemanticThreat {
52    pub attack_type: String,
53    /// `probability` for per-attack-type threats, the overall score for the
54    /// `suspicious` fallback (serialized under `threat_score`).
55    pub score: f64,
56    /// `true` when the score is serialized under the `threat_score` key
57    /// (the fallback threat shape).
58    pub fallback: bool,
59    pub analysis: AnalysisResult,
60}
61
62/// Full verdict (`SusPatternsManager.detect` return shape, minus timing).
63#[derive(Debug, Clone, PartialEq)]
64pub struct DetectVerdict {
65    pub is_threat: bool,
66    pub threat_score: f64,
67    pub threats: Vec<Threat>,
68    pub original_length: usize,
69    pub processed_length: usize,
70}
71
72const KNOWN_CONTEXTS: &[&str] = &[
73    "query_param",
74    "header",
75    "url_path",
76    "request_body",
77    "unknown",
78];
79pub const EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX: &str = ":embedded_json";
80
81/// `_normalize_context`: keep the part before the first `:`, map unknown
82/// contexts to `unknown`.
83#[must_use]
84pub fn normalize_context(context: &str) -> &str {
85    let head = context.split(':').next().unwrap_or("");
86    if KNOWN_CONTEXTS.contains(&head) {
87        head
88    } else {
89        "unknown"
90    }
91}
92
93fn regex_anomaly(regex_threats: &[RegexThreat]) -> f64 {
94    regex_threats.iter().map(|t| t.weight).sum()
95}
96
97/// One scan pass with the reference context normalization applied
98/// (`_check_regex_patterns` minus the async machinery).
99#[must_use]
100fn scan_pass(
101    view_content: &str,
102    filter: ViewFilter,
103    normalized_context: &str,
104    validator_context: &str,
105) -> Vec<RegexThreat> {
106    let skip_filter = matches!(normalized_context, "unknown" | "request_body");
107    patterns::scan_view(
108        view_content,
109        filter,
110        normalized_context,
111        skip_filter,
112        validator_context,
113    )
114}
115
116fn semantic_threats(processed: &str, raw_content: &str, config: &DetectConfig) -> Vec<Threat> {
117    if binary::looks_like_binary_content(raw_content) {
118        return Vec::new();
119    }
120    let semantic_budget = config.max_content_length;
121    let input: String = processed.chars().take(semantic_budget).collect();
122    let analysis = semantic::analyze(
123        &input,
124        &AttackKeywords::default(),
125        &AttackStructures::default(),
126    );
127    let score = semantic::get_threat_score(&analysis);
128
129    let mut threats = Vec::new();
130    if score > config.semantic_threshold {
131        let mut probs: Vec<(&str, f64)> = analysis
132            .attack_probabilities
133            .iter()
134            .map(|(k, v)| (*k, *v))
135            .collect();
136        probs.sort_unstable_by(|a, b| a.0.cmp(b.0));
137        for (attack_type, probability) in probs {
138            if probability >= config.semantic_threshold {
139                threats.push(Threat::Semantic(SemanticThreat {
140                    attack_type: attack_type.to_owned(),
141                    score: probability,
142                    fallback: false,
143                    analysis: analysis.clone(),
144                }));
145            }
146        }
147        if threats.is_empty() && score >= config.semantic_threshold {
148            threats.push(Threat::Semantic(SemanticThreat {
149                attack_type: "suspicious".to_owned(),
150                score,
151                fallback: true,
152                analysis,
153            }));
154        }
155    }
156    threats
157}
158
159/// The reference `detect` pipeline over one request content.
160#[must_use]
161pub fn detect(content: &str, request_context: &str, config: &DetectConfig) -> DetectVerdict {
162    let original_length = content.chars().count();
163    let (processed, decoded, decode_budget_exhausted) = preprocessor::preprocess_with_decoded(
164        content,
165        config.max_full_scan_bytes,
166        config.preserve_attack_patterns,
167        config.max_content_length,
168    );
169    let processed_length = processed.chars().count();
170
171    let normalized = normalize_context(request_context);
172    let validator_context = if request_context.ends_with(EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX) {
173        format!("{normalized}{EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX}")
174    } else {
175        normalized.to_owned()
176    };
177
178    let mut regex_threats: Vec<RegexThreat> = Vec::new();
179
180    // processed view (raw_view_only = False)
181    regex_threats.extend(scan_pass(
182        &processed,
183        ViewFilter::Processed,
184        normalized,
185        &validator_context,
186    ));
187
188    // raw signal-preserving view (raw_view_only = True)
189    let raw_view = preprocessor::preprocess_signal_preserving(
190        content,
191        config.max_full_scan_bytes,
192        config.preserve_attack_patterns,
193        config.max_content_length,
194    );
195    regex_threats.extend(scan_pass(
196        &raw_view,
197        ViewFilter::Raw,
198        normalized,
199        &validator_context,
200    ));
201
202    // decoded-view path traversal (processed vs raw view shape counts)
203    if let Some(threat) = patterns::decoded_view_traversal_threat(&processed, &raw_view) {
204        regex_threats.push(threat);
205    }
206
207    // URL-decoded view (url_decoded_view_only = True): the precomputed decoded
208    // view, truncated safely
209    let url_decoded_view = preprocessor::truncate_safely(
210        &decoded,
211        config.max_full_scan_bytes,
212        config.preserve_attack_patterns,
213        config.max_content_length,
214    );
215    regex_threats.extend(scan_pass(
216        &url_decoded_view,
217        ViewFilter::UrlDecoded,
218        normalized,
219        &validator_context,
220    ));
221
222    if decode_budget_exhausted {
223        regex_threats.push(patterns::decode_budget_exhausted_threat());
224    }
225
226    // short-base64 additive view (no view exclusion)
227    let additive_view = preprocessor::short_base64_additive_view(
228        content,
229        config.max_full_scan_bytes,
230        config.preserve_attack_patterns,
231        config.max_content_length,
232    );
233    if !additive_view.is_empty() {
234        regex_threats.extend(scan_pass(
235            &additive_view,
236            ViewFilter::All,
237            normalized,
238            &validator_context,
239        ));
240    }
241
242    let semantic = semantic_threats(&processed, content, config);
243
244    let anomaly = regex_anomaly(&regex_threats);
245    let is_threat = anomaly >= config.threat_score_threshold || !semantic.is_empty();
246
247    let threat_score = if regex_threats.is_empty() && semantic.is_empty() {
248        0.0
249    } else {
250        let semantic_max = semantic
251            .iter()
252            .map(|t| match t {
253                Threat::Semantic(s) => s.score,
254                Threat::Regex(_) => 0.0,
255            })
256            .fold(0.0_f64, f64::max);
257        (anomaly.max(semantic_max)).min(1.0)
258    };
259
260    let mut threats: Vec<Threat> = regex_threats.into_iter().map(Threat::Regex).collect();
261    threats.extend(semantic);
262
263    DetectVerdict {
264        is_threat,
265        threat_score,
266        threats,
267        original_length,
268        processed_length,
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    fn corpus_config() -> DetectConfig {
277        DetectConfig {
278            max_content_length: 10_000,
279            max_full_scan_bytes: 262_144,
280            preserve_attack_patterns: true,
281            semantic_threshold: 0.7,
282            threat_score_threshold: 1.0,
283        }
284    }
285
286    #[test]
287    fn traversal_in_url_path_context() {
288        let v = detect("/files?name=../../etc/passwd", "url_path", &corpus_config());
289        assert!(v.is_threat);
290        assert!((v.threat_score - 1.0).abs() < f64::EPSILON);
291        assert_eq!(v.threats.len(), 2);
292        assert_eq!(v.original_length, 28);
293        assert_eq!(v.processed_length, 28);
294    }
295
296    #[test]
297    fn benign_content_is_clean() {
298        let v = detect("hello world", "request_body", &corpus_config());
299        assert!(!v.is_threat);
300        assert!((v.threat_score - 0.0).abs() < f64::EPSILON);
301        assert!(v.threats.is_empty());
302    }
303
304    #[test]
305    fn context_filtering_gates_sqli_narrow() {
306        // `\w/\*(?!!)[^*]*\*/\w` is SQLI_NARROW (no url_path/header)
307        let hit = detect("a/**/b", "request_body", &corpus_config());
308        assert!(
309            hit.threats
310                .iter()
311                .any(|t| matches!(t, Threat::Regex(r) if r.category == "sqli"))
312        );
313        let miss = detect("a/**/b", "url_path", &corpus_config());
314        assert!(!miss.threats.iter().any(
315            |t| matches!(t, Threat::Regex(r) if r.category == "sqli" && r.pattern.contains("/\\*"))
316        ));
317    }
318
319    #[test]
320    fn unknown_context_normalizes() {
321        assert_eq!(normalize_context("weird:thing"), "unknown");
322        assert_eq!(
323            normalize_context("query_param:embedded_json"),
324            "query_param"
325        );
326        assert_eq!(normalize_context("header"), "header");
327    }
328
329    #[test]
330    fn raw_view_only_patterns_need_the_raw_view() {
331        // encoded-dot traversal only fires on the raw view
332        let v = detect("/search?q=%2e%2e%2f", "url_path", &corpus_config());
333        assert!(
334            v.threats
335                .iter()
336                .any(|t| matches!(t, Threat::Regex(r) if r.category == "path_traversal"))
337        );
338    }
339
340    #[test]
341    fn regex_anomaly_sums_weights() {
342        // two 0.5-weight sqli hits cross the 1.0 threshold together
343        let v = detect("SELECT * FROM users", "request_body", &corpus_config());
344        assert!(v.is_threat);
345        let anomaly: f64 = v
346            .threats
347            .iter()
348            .filter_map(|t| match t {
349                Threat::Regex(r) => Some(r.weight),
350                Threat::Semantic(_) => None,
351            })
352            .sum();
353        assert!((anomaly - 1.0).abs() < 1e-9, "anomaly {anomaly}");
354    }
355}