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 pub max_scan_values: usize,
52 pub max_scan_chars: usize,
57 pub max_json_depth: usize,
61}
62
63impl Default for DetectConfig {
64 fn default() -> Self {
72 Self {
73 max_content_length: 10_000,
74 max_full_scan_bytes: 262_144,
75 preserve_attack_patterns: true,
76 semantic_threshold: 0.7,
77 threat_score_threshold: 1.0,
78 binary_min_run_length: 16,
79 max_scan_values: 512,
80 max_scan_chars: 65_536,
81 max_json_depth: 32,
82 }
83 }
84}
85
86#[derive(Debug, Clone, PartialEq)]
88pub enum Threat {
89 Regex(RegexThreat),
90 Semantic(SemanticThreat),
91}
92
93#[derive(Debug, Clone, PartialEq)]
95pub struct SemanticThreat {
96 pub attack_type: String,
97 pub score: f64,
100 pub fallback: bool,
103 pub analysis: AnalysisResult,
104}
105
106#[derive(Debug, Clone, PartialEq)]
108pub struct DetectVerdict {
109 pub is_threat: bool,
110 pub threat_score: f64,
111 pub threats: Vec<Threat>,
112 pub original_length: usize,
113 pub processed_length: usize,
114}
115
116const KNOWN_CONTEXTS: &[&str] = &[
117 "query_param",
118 "header",
119 "url_path",
120 "request_body",
121 "unknown",
122];
123pub const EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX: &str = ":embedded_json";
124
125#[must_use]
128pub fn normalize_context(context: &str) -> &str {
129 let head = context.split(':').next().unwrap_or("");
130 if KNOWN_CONTEXTS.contains(&head) {
131 head
132 } else {
133 "unknown"
134 }
135}
136
137fn regex_anomaly(regex_threats: &[RegexThreat]) -> f64 {
138 regex_threats.iter().map(|t| t.weight).sum()
139}
140
141#[must_use]
144fn scan_pass(
145 view_content: &str,
146 filter: ViewFilter,
147 normalized_context: &str,
148 validator_context: &str,
149) -> Vec<RegexThreat> {
150 let skip_filter = matches!(normalized_context, "unknown" | "request_body");
151 patterns::scan_view(
152 view_content,
153 filter,
154 normalized_context,
155 skip_filter,
156 validator_context,
157 )
158}
159
160#[must_use]
163fn drop_view_duplicate_threats(
164 seen_threats: &[RegexThreat],
165 new_threats: Vec<RegexThreat>,
166) -> Vec<RegexThreat> {
167 let mut seen: std::collections::HashSet<(String, String)> = seen_threats
168 .iter()
169 .map(|threat| (threat.pattern.clone(), threat.match_text.clone()))
170 .collect();
171 let mut kept = Vec::new();
172 for threat in new_threats {
173 if seen.insert((threat.pattern.clone(), threat.match_text.clone())) {
174 kept.push(threat);
175 }
176 }
177 kept
178}
179
180fn semantic_threats(
181 processed: &str,
182 raw_content: &str,
183 config: &DetectConfig,
184) -> Vec<SemanticThreat> {
185 if binary::looks_like_binary_content(raw_content) {
186 return Vec::new();
187 }
188 let semantic_budget = config.max_content_length;
189 let input: String = processed.chars().take(semantic_budget).collect();
190 let analysis = semantic::analyze(
191 &input,
192 &AttackKeywords::default(),
193 &AttackStructures::default(),
194 );
195 let score = semantic::get_threat_score(&analysis);
196
197 let mut threats = Vec::new();
198 if score > config.semantic_threshold {
199 let mut probs: Vec<(&str, f64)> = analysis
200 .attack_probabilities
201 .iter()
202 .map(|(k, v)| (*k, *v))
203 .collect();
204 probs.sort_unstable_by(|a, b| a.0.cmp(b.0));
205 for (attack_type, probability) in probs {
206 if probability >= config.semantic_threshold {
207 threats.push(SemanticThreat {
208 attack_type: attack_type.to_owned(),
209 score: probability,
210 fallback: false,
211 analysis: analysis.clone(),
212 });
213 }
214 }
215 if threats.is_empty() && score >= config.semantic_threshold {
216 threats.push(SemanticThreat {
217 attack_type: "suspicious".to_owned(),
218 score,
219 fallback: true,
220 analysis,
221 });
222 }
223 }
224 threats
225}
226
227#[must_use]
229pub fn detect(content: &str, request_context: &str, config: &DetectConfig) -> DetectVerdict {
230 let original_length = content.chars().count();
231 let (processed, decoded, decode_budget_exhausted) = preprocessor::preprocess_with_decoded(
232 content,
233 config.max_full_scan_bytes,
234 config.preserve_attack_patterns,
235 config.max_content_length,
236 );
237 let processed_length = processed.chars().count();
238
239 let normalized = normalize_context(request_context);
240 let validator_context = if request_context.ends_with(EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX) {
241 format!("{normalized}{EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX}")
242 } else {
243 normalized.to_owned()
244 };
245
246 let mut regex_threats: Vec<RegexThreat> = Vec::new();
247
248 regex_threats.extend(scan_pass(
250 &processed,
251 ViewFilter::Processed,
252 normalized,
253 &validator_context,
254 ));
255
256 let raw_view = preprocessor::preprocess_signal_preserving(
258 content,
259 config.max_full_scan_bytes,
260 config.preserve_attack_patterns,
261 config.max_content_length,
262 );
263 let raw_pass = scan_pass(&raw_view, ViewFilter::Raw, normalized, &validator_context);
264 regex_threats.extend(drop_view_duplicate_threats(®ex_threats, raw_pass));
269
270 if let Some(threat) = patterns::decoded_view_traversal_threat(&processed, &raw_view) {
272 regex_threats.push(threat);
273 }
274
275 let url_decoded_view = preprocessor::truncate_safely(
278 &decoded,
279 config.max_full_scan_bytes,
280 config.preserve_attack_patterns,
281 config.max_content_length,
282 );
283 regex_threats.extend(scan_pass(
284 &url_decoded_view,
285 ViewFilter::UrlDecoded,
286 normalized,
287 &validator_context,
288 ));
289
290 if decode_budget_exhausted {
291 regex_threats.push(patterns::decode_budget_exhausted_threat());
292 }
293
294 let additive_view = preprocessor::short_base64_additive_view(
296 content,
297 config.max_full_scan_bytes,
298 config.preserve_attack_patterns,
299 config.max_content_length,
300 );
301 if !additive_view.is_empty() {
302 regex_threats.extend(scan_pass(
303 &additive_view,
304 ViewFilter::All,
305 normalized,
306 &validator_context,
307 ));
308 }
309
310 let semantic = semantic_threats(&processed, content, config);
311
312 let anomaly = regex_anomaly(®ex_threats);
313 let is_threat = anomaly >= config.threat_score_threshold || !semantic.is_empty();
314
315 let threat_score = if regex_threats.is_empty() && semantic.is_empty() {
316 0.0
317 } else {
318 let semantic_max = semantic.iter().map(|s| s.score).fold(0.0_f64, f64::max);
319 (anomaly.max(semantic_max)).min(1.0)
320 };
321
322 let mut threats: Vec<Threat> = regex_threats.into_iter().map(Threat::Regex).collect();
323 threats.extend(semantic.into_iter().map(Threat::Semantic));
324
325 DetectVerdict {
326 is_threat,
327 threat_score,
328 threats,
329 original_length,
330 processed_length,
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 fn corpus_config() -> DetectConfig {
339 DetectConfig {
340 max_content_length: 10_000,
341 max_full_scan_bytes: 262_144,
342 preserve_attack_patterns: true,
343 semantic_threshold: 0.7,
344 threat_score_threshold: 1.0,
345 binary_min_run_length: 16,
346 max_scan_values: 512,
347 max_scan_chars: 65_536,
348 max_json_depth: 32,
349 }
350 }
351
352 #[test]
353 fn the_config_defaults_carry_the_reference_values() {
354 let config = DetectConfig::default();
355 assert_eq!(config.max_content_length, 10_000);
356 assert_eq!(config.max_full_scan_bytes, 262_144);
357 assert!(config.preserve_attack_patterns);
358 assert!((config.semantic_threshold - 0.7).abs() < f64::EPSILON);
359 assert!((config.threat_score_threshold - 1.0).abs() < f64::EPSILON);
360 assert_eq!(config.binary_min_run_length, 16);
361 assert_eq!(config.max_scan_values, 512);
362 assert_eq!(config.max_scan_chars, 65_536);
363 assert_eq!(config.max_json_depth, 32);
364 }
365
366 #[test]
367 fn traversal_in_url_path_context() {
368 let v = detect("/files?name=../../etc/passwd", "url_path", &corpus_config());
369 assert!(v.is_threat);
370 assert!((v.threat_score - 1.0).abs() < f64::EPSILON);
371 assert_eq!(v.threats.len(), 2);
372 assert_eq!(v.original_length, 28);
373 assert_eq!(v.processed_length, 28);
374 }
375
376 #[test]
377 fn benign_content_is_clean() {
378 let v = detect("hello world", "request_body", &corpus_config());
379 assert!(!v.is_threat);
380 assert!((v.threat_score - 0.0).abs() < f64::EPSILON);
381 assert!(v.threats.is_empty());
382 }
383
384 fn is_sqli_comment_threat(t: &Threat) -> bool {
387 matches!(t, Threat::Regex(r) if r.category == "sqli" && r.pattern.contains("/\\*"))
388 }
389
390 #[test]
391 fn context_filtering_gates_sqli_narrow() {
392 let hit = detect("a/**/b", "request_body", &corpus_config());
394 assert!(hit.threats.iter().any(is_sqli_comment_threat));
395 let miss = detect("/files?name=../../etc/passwd", "url_path", &corpus_config());
397 assert!(
398 miss.threats
399 .iter()
400 .any(|t| matches!(t, Threat::Regex(r) if r.category == "dir_traversal"))
401 );
402 assert!(!miss.threats.iter().any(is_sqli_comment_threat));
403 }
404
405 #[test]
406 fn unknown_context_normalizes() {
407 assert_eq!(normalize_context("weird:thing"), "unknown");
408 assert_eq!(
409 normalize_context("query_param:embedded_json"),
410 "query_param"
411 );
412 assert_eq!(normalize_context("header"), "header");
413 }
414
415 #[test]
416 fn raw_view_only_patterns_need_the_raw_view() {
417 let v = detect("/search?q=%2e%2e%2f", "url_path", &corpus_config());
419 assert!(
420 v.threats
421 .iter()
422 .any(|t| matches!(t, Threat::Regex(r) if r.category == "path_traversal"))
423 );
424 }
425
426 #[test]
427 fn regex_anomaly_sums_weights() {
428 let v = detect("SELECT * FROM users", "request_body", &corpus_config());
430 assert!(v.is_threat);
431 let semantic_side = detect(&mixed_content(), "request_body", &corpus_config());
434 let semantic_threat = semantic_side
435 .threats
436 .iter()
437 .find(|t| matches!(t, Threat::Semantic(_)))
438 .expect("the mixed payload carries a semantic threat");
439 let anomaly: f64 = v
440 .threats
441 .iter()
442 .chain(std::iter::once(semantic_threat))
443 .filter_map(|t| match t {
444 Threat::Regex(r) => Some(r.weight),
445 Threat::Semantic(_) => None,
446 })
447 .sum();
448 assert!((anomaly - 1.0).abs() < 1e-9, "anomaly {anomaly}");
449
450 let mixed = detect(&mixed_content(), "request_body", &corpus_config());
452 assert!(
453 mixed
454 .threats
455 .iter()
456 .any(|t| matches!(t, Threat::Semantic(_)))
457 );
458 let mixed_anomaly: f64 = mixed
459 .threats
460 .iter()
461 .filter_map(|t| match t {
462 Threat::Regex(r) => Some(r.weight),
463 Threat::Semantic(_) => None,
464 })
465 .sum();
466 assert!(mixed_anomaly > 0.0, "regex hits still carry weight");
467 assert!(mixed.is_threat);
468 }
469
470 fn mixed_content() -> String {
471 format!(
472 "select union insert update delete drop from where order group having concat \
473 substring database table column (1 OR 1=1) {}",
474 "A".repeat(120)
475 )
476 }
477
478 #[test]
479 fn an_embedded_json_leaf_context_keeps_its_suffix() {
480 let v = detect(
483 "SELECT * FROM users",
484 "request_body:embedded_json",
485 &corpus_config(),
486 );
487 assert!(v.is_threat);
488 }
489
490 #[test]
491 fn a_short_base64_view_scans_as_an_additive_pass() {
492 let v = detect("JHt9", "request_body", &corpus_config());
496 assert!(v.is_threat, "unexpected verdict: {v:?}");
497 assert!(
498 v.threats
499 .iter()
500 .any(|t| matches!(t, Threat::Regex(r) if r.category == "cmd_injection")),
501 "verdict: {v:?}"
502 );
503 }
504
505 #[test]
506 fn binary_raw_content_never_yields_semantic_threats() {
507 let processed = "\0".repeat(16);
509 assert!(
510 semantic_threats(&processed, &processed, &corpus_config()).is_empty(),
511 "binary content is exempt from the semantic pass"
512 );
513 }
514
515 #[test]
516 fn an_exhausted_decode_budget_appends_its_marker_threat() {
517 let content = format!("%25{}", "25".repeat(15));
521 let v = detect(&content, "url_path", &corpus_config());
522 assert!(
523 v.threats.iter().any(|t| matches!(
524 t,
525 Threat::Regex(r) if r.pattern == "decode_budget_exhausted"
526 )),
527 "verdict: {v:?}"
528 );
529 }
530}
531
532#[cfg(test)]
533mod gap_tests {
534 use super::*;
535
536 fn corpus_config() -> DetectConfig {
537 DetectConfig {
538 max_content_length: 10_000,
539 max_full_scan_bytes: 262_144,
540 preserve_attack_patterns: true,
541 semantic_threshold: 0.7,
542 threat_score_threshold: 1.0,
543 binary_min_run_length: 16,
544 max_scan_values: 512,
545 max_scan_chars: 65_536,
546 max_json_depth: 32,
547 }
548 }
549
550 #[test]
551 fn a_semantic_fallback_threat_reports_suspicious() {
552 let mut content = String::from("from where drop select ");
555 content.push_str(&"A".repeat(120));
556 content.push_str(" ({}$x==) foo(");
557 let v = detect(&content, "request_body", &corpus_config());
558 assert!(v.is_threat, "verdict: {v:?}");
559 assert!(
560 v.threats.iter().any(
561 |t| matches!(t, Threat::Semantic(s) if s.attack_type == "suspicious" && s.fallback)
562 ),
563 "verdict: {v:?}"
564 );
565 }
566
567 #[test]
568 fn a_mixed_verdict_scores_the_semantic_maximum_against_the_anomaly() {
569 let content = format!(
572 "select union insert update delete drop from where order group having concat \
573 substring database table column (1 OR 1=1) {}",
574 "A".repeat(120)
575 );
576 let v = detect(&content, "request_body", &corpus_config());
577 assert!(v.is_threat);
578 assert!(
579 v.threats
580 .iter()
581 .any(|t| matches!(t, Threat::Regex(r) if r.category == "sqli"))
582 );
583 assert!(v.threats.iter().any(|t| matches!(t, Threat::Semantic(_))));
584 let semantic_max = v
585 .threats
586 .iter()
587 .filter_map(|t| match t {
588 Threat::Semantic(s) => Some(s.score),
589 Threat::Regex(_) => None,
590 })
591 .fold(0.0_f64, f64::max);
592 let anomaly = v
593 .threats
594 .iter()
595 .filter_map(|t| match t {
596 Threat::Regex(r) => Some(r.weight),
597 Threat::Semantic(_) => None,
598 })
599 .sum::<f64>();
600 assert!((v.threat_score - anomaly.max(semantic_max).min(1.0)).abs() < 1e-9);
601 }
602}