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(processed: &str, raw_content: &str, config: &DetectConfig) -> Vec<Threat> {
144    if binary::looks_like_binary_content(raw_content) {
145        return Vec::new();
146    }
147    let semantic_budget = config.max_content_length;
148    let input: String = processed.chars().take(semantic_budget).collect();
149    let analysis = semantic::analyze(
150        &input,
151        &AttackKeywords::default(),
152        &AttackStructures::default(),
153    );
154    let score = semantic::get_threat_score(&analysis);
155
156    let mut threats = Vec::new();
157    if score > config.semantic_threshold {
158        let mut probs: Vec<(&str, f64)> = analysis
159            .attack_probabilities
160            .iter()
161            .map(|(k, v)| (*k, *v))
162            .collect();
163        probs.sort_unstable_by(|a, b| a.0.cmp(b.0));
164        for (attack_type, probability) in probs {
165            if probability >= config.semantic_threshold {
166                threats.push(Threat::Semantic(SemanticThreat {
167                    attack_type: attack_type.to_owned(),
168                    score: probability,
169                    fallback: false,
170                    analysis: analysis.clone(),
171                }));
172            }
173        }
174        if threats.is_empty() && score >= config.semantic_threshold {
175            threats.push(Threat::Semantic(SemanticThreat {
176                attack_type: "suspicious".to_owned(),
177                score,
178                fallback: true,
179                analysis,
180            }));
181        }
182    }
183    threats
184}
185
186/// The reference `detect` pipeline over one request content.
187#[must_use]
188pub fn detect(content: &str, request_context: &str, config: &DetectConfig) -> DetectVerdict {
189    let original_length = content.chars().count();
190    let (processed, decoded, decode_budget_exhausted) = preprocessor::preprocess_with_decoded(
191        content,
192        config.max_full_scan_bytes,
193        config.preserve_attack_patterns,
194        config.max_content_length,
195    );
196    let processed_length = processed.chars().count();
197
198    let normalized = normalize_context(request_context);
199    let validator_context = if request_context.ends_with(EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX) {
200        format!("{normalized}{EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX}")
201    } else {
202        normalized.to_owned()
203    };
204
205    let mut regex_threats: Vec<RegexThreat> = Vec::new();
206
207    // processed view (raw_view_only = False)
208    regex_threats.extend(scan_pass(
209        &processed,
210        ViewFilter::Processed,
211        normalized,
212        &validator_context,
213    ));
214
215    // raw signal-preserving view (raw_view_only = True)
216    let raw_view = preprocessor::preprocess_signal_preserving(
217        content,
218        config.max_full_scan_bytes,
219        config.preserve_attack_patterns,
220        config.max_content_length,
221    );
222    let raw_pass = scan_pass(&raw_view, ViewFilter::Raw, normalized, &validator_context);
223    // Threats and matched patterns are parallel lists in the reference (one
224    // append per match), so a raw-view sighting of a pattern over text the
225    // processed views already matched is the same evidence and must not
226    // inflate the threat score (`_drop_view_duplicate_threats`).
227    regex_threats.extend(drop_view_duplicate_threats(&regex_threats, raw_pass));
228
229    // decoded-view path traversal (processed vs raw view shape counts)
230    if let Some(threat) = patterns::decoded_view_traversal_threat(&processed, &raw_view) {
231        regex_threats.push(threat);
232    }
233
234    // URL-decoded view (url_decoded_view_only = True): the precomputed decoded
235    // view, truncated safely
236    let url_decoded_view = preprocessor::truncate_safely(
237        &decoded,
238        config.max_full_scan_bytes,
239        config.preserve_attack_patterns,
240        config.max_content_length,
241    );
242    regex_threats.extend(scan_pass(
243        &url_decoded_view,
244        ViewFilter::UrlDecoded,
245        normalized,
246        &validator_context,
247    ));
248
249    if decode_budget_exhausted {
250        regex_threats.push(patterns::decode_budget_exhausted_threat());
251    }
252
253    // short-base64 additive view (no view exclusion)
254    let additive_view = preprocessor::short_base64_additive_view(
255        content,
256        config.max_full_scan_bytes,
257        config.preserve_attack_patterns,
258        config.max_content_length,
259    );
260    if !additive_view.is_empty() {
261        regex_threats.extend(scan_pass(
262            &additive_view,
263            ViewFilter::All,
264            normalized,
265            &validator_context,
266        ));
267    }
268
269    let semantic = semantic_threats(&processed, content, config);
270
271    let anomaly = regex_anomaly(&regex_threats);
272    let is_threat = anomaly >= config.threat_score_threshold || !semantic.is_empty();
273
274    let threat_score = if regex_threats.is_empty() && semantic.is_empty() {
275        0.0
276    } else {
277        let semantic_max = semantic
278            .iter()
279            .map(|t| match t {
280                Threat::Semantic(s) => s.score,
281                Threat::Regex(_) => 0.0,
282            })
283            .fold(0.0_f64, f64::max);
284        (anomaly.max(semantic_max)).min(1.0)
285    };
286
287    let mut threats: Vec<Threat> = regex_threats.into_iter().map(Threat::Regex).collect();
288    threats.extend(semantic);
289
290    DetectVerdict {
291        is_threat,
292        threat_score,
293        threats,
294        original_length,
295        processed_length,
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn corpus_config() -> DetectConfig {
304        DetectConfig {
305            max_content_length: 10_000,
306            max_full_scan_bytes: 262_144,
307            preserve_attack_patterns: true,
308            semantic_threshold: 0.7,
309            threat_score_threshold: 1.0,
310            binary_min_run_length: 16,
311        }
312    }
313
314    #[test]
315    fn traversal_in_url_path_context() {
316        let v = detect("/files?name=../../etc/passwd", "url_path", &corpus_config());
317        assert!(v.is_threat);
318        assert!((v.threat_score - 1.0).abs() < f64::EPSILON);
319        assert_eq!(v.threats.len(), 2);
320        assert_eq!(v.original_length, 28);
321        assert_eq!(v.processed_length, 28);
322    }
323
324    #[test]
325    fn benign_content_is_clean() {
326        let v = detect("hello world", "request_body", &corpus_config());
327        assert!(!v.is_threat);
328        assert!((v.threat_score - 0.0).abs() < f64::EPSILON);
329        assert!(v.threats.is_empty());
330    }
331
332    #[test]
333    fn context_filtering_gates_sqli_narrow() {
334        // `\w/\*(?!!)[^*]*\*/\w` is SQLI_NARROW (no url_path/header)
335        let hit = detect("a/**/b", "request_body", &corpus_config());
336        assert!(
337            hit.threats
338                .iter()
339                .any(|t| matches!(t, Threat::Regex(r) if r.category == "sqli"))
340        );
341        let miss = detect("a/**/b", "url_path", &corpus_config());
342        assert!(!miss.threats.iter().any(
343            |t| matches!(t, Threat::Regex(r) if r.category == "sqli" && r.pattern.contains("/\\*"))
344        ));
345    }
346
347    #[test]
348    fn unknown_context_normalizes() {
349        assert_eq!(normalize_context("weird:thing"), "unknown");
350        assert_eq!(
351            normalize_context("query_param:embedded_json"),
352            "query_param"
353        );
354        assert_eq!(normalize_context("header"), "header");
355    }
356
357    #[test]
358    fn raw_view_only_patterns_need_the_raw_view() {
359        // encoded-dot traversal only fires on the raw view
360        let v = detect("/search?q=%2e%2e%2f", "url_path", &corpus_config());
361        assert!(
362            v.threats
363                .iter()
364                .any(|t| matches!(t, Threat::Regex(r) if r.category == "path_traversal"))
365        );
366    }
367
368    #[test]
369    fn regex_anomaly_sums_weights() {
370        // two 0.5-weight sqli hits cross the 1.0 threshold together
371        let v = detect("SELECT * FROM users", "request_body", &corpus_config());
372        assert!(v.is_threat);
373        let anomaly: f64 = v
374            .threats
375            .iter()
376            .filter_map(|t| match t {
377                Threat::Regex(r) => Some(r.weight),
378                Threat::Semantic(_) => None,
379            })
380            .sum();
381        assert!((anomaly - 1.0).abs() < 1e-9, "anomaly {anomaly}");
382    }
383}