1use crate::patterns::binary;
23use crate::patterns::{self, RegexThreat, ViewFilter};
24use crate::preprocessor;
25use crate::semantic::{self, AnalysisResult, AttackKeywords, AttackStructures};
26
27#[derive(Debug, Clone, Copy)]
29pub struct DetectConfig {
30 pub max_content_length: usize,
32 pub max_full_scan_bytes: usize,
34 pub preserve_attack_patterns: bool,
36 pub semantic_threshold: f64,
38 pub threat_score_threshold: f64,
40}
41
42#[derive(Debug, Clone, PartialEq)]
44pub enum Threat {
45 Regex(RegexThreat),
46 Semantic(SemanticThreat),
47}
48
49#[derive(Debug, Clone, PartialEq)]
51pub struct SemanticThreat {
52 pub attack_type: String,
53 pub score: f64,
56 pub fallback: bool,
59 pub analysis: AnalysisResult,
60}
61
62#[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#[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#[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#[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 regex_threats.extend(scan_pass(
182 &processed,
183 ViewFilter::Processed,
184 normalized,
185 &validator_context,
186 ));
187
188 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 if let Some(threat) = patterns::decoded_view_traversal_threat(&processed, &raw_view) {
204 regex_threats.push(threat);
205 }
206
207 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 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(®ex_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 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 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 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}