1use crate::patterns::binary;
25use crate::patterns::{self, RegexThreat, ViewFilter};
26use crate::preprocessor;
27use crate::semantic::{self, AnalysisResult, AttackKeywords, AttackStructures};
28
29#[derive(Debug, Clone, Copy)]
31pub struct DetectConfig {
32 pub max_content_length: usize,
34 pub max_full_scan_bytes: usize,
36 pub preserve_attack_patterns: bool,
38 pub semantic_threshold: f64,
40 pub threat_score_threshold: f64,
42 pub binary_min_run_length: usize,
47}
48
49#[derive(Debug, Clone, PartialEq)]
51pub enum Threat {
52 Regex(RegexThreat),
53 Semantic(SemanticThreat),
54}
55
56#[derive(Debug, Clone, PartialEq)]
58pub struct SemanticThreat {
59 pub attack_type: String,
60 pub score: f64,
63 pub fallback: bool,
66 pub analysis: AnalysisResult,
67}
68
69#[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#[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#[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#[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#[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 regex_threats.extend(scan_pass(
213 &processed,
214 ViewFilter::Processed,
215 normalized,
216 &validator_context,
217 ));
218
219 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 regex_threats.extend(drop_view_duplicate_threats(®ex_threats, raw_pass));
232
233 if let Some(threat) = patterns::decoded_view_traversal_threat(&processed, &raw_view) {
235 regex_threats.push(threat);
236 }
237
238 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 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(®ex_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 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 let hit = detect("a/**/b", "request_body", &corpus_config());
340 assert!(hit.threats.iter().any(is_sqli_comment_threat));
341 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 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 let v = detect("SELECT * FROM users", "request_body", &corpus_config());
376 assert!(v.is_threat);
377 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 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 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 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 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 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 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 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}