1use crate::patterns::binary;
25use crate::patterns::{self, RegexThreat, ViewFilter};
26use crate::preprocessor;
27use crate::scan_budget::{
28 DEFAULT_MAX_SCAN_SECONDS, SCAN_TIME_BUDGET_EXHAUSTED_PATTERN, ScanTimeBudget,
29};
30use crate::semantic::{self, AnalysisResult, AttackKeywords, AttackStructures};
31
32#[derive(Debug, Clone, Copy)]
34pub struct DetectConfig {
35 pub max_content_length: usize,
37 pub max_full_scan_bytes: usize,
39 pub preserve_attack_patterns: bool,
41 pub semantic_threshold: f64,
43 pub threat_score_threshold: f64,
45 pub binary_min_run_length: usize,
50 pub max_scan_values: usize,
55 pub max_scan_chars: usize,
60 pub max_json_depth: usize,
64}
65
66impl Default for DetectConfig {
67 fn default() -> Self {
75 Self {
76 max_content_length: 10_000,
77 max_full_scan_bytes: 262_144,
78 preserve_attack_patterns: true,
79 semantic_threshold: 0.7,
80 threat_score_threshold: 1.0,
81 binary_min_run_length: 16,
82 max_scan_values: 512,
83 max_scan_chars: 65_536,
84 max_json_depth: 32,
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq)]
91pub enum Threat {
92 Regex(RegexThreat),
93 Semantic(SemanticThreat),
94 PatternTimeout(RegexThreat),
99}
100
101#[derive(Debug, Clone, PartialEq)]
103pub struct SemanticThreat {
104 pub attack_type: String,
105 pub score: f64,
108 pub fallback: bool,
111 pub analysis: AnalysisResult,
112}
113
114#[derive(Debug, Clone, PartialEq)]
116pub struct DetectVerdict {
117 pub is_threat: bool,
118 pub threat_score: f64,
119 pub threats: Vec<Threat>,
120 pub original_length: usize,
121 pub processed_length: usize,
122}
123
124const KNOWN_CONTEXTS: &[&str] = &[
125 "query_param",
126 "header",
127 "url_path",
128 "request_body",
129 "unknown",
130];
131pub const EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX: &str = ":embedded_json";
132
133#[must_use]
136pub fn normalize_context(context: &str) -> &str {
137 let head = context.split(':').next().unwrap_or("");
138 if KNOWN_CONTEXTS.contains(&head) {
139 head
140 } else {
141 "unknown"
142 }
143}
144
145fn regex_anomaly(regex_threats: &[RegexThreat]) -> f64 {
146 regex_threats.iter().map(|t| t.weight).sum()
147}
148
149#[must_use]
152fn scan_pass(
153 view_content: &str,
154 filter: ViewFilter,
155 normalized_context: &str,
156 validator_context: &str,
157 budget: &mut ScanTimeBudget,
158) -> Vec<RegexThreat> {
159 let skip_filter = matches!(normalized_context, "unknown" | "request_body");
160 patterns::scan_view_budgeted(
161 view_content,
162 filter,
163 normalized_context,
164 skip_filter,
165 validator_context,
166 budget,
167 )
168}
169
170#[must_use]
173fn drop_view_duplicate_threats(
174 seen_threats: &[RegexThreat],
175 new_threats: Vec<RegexThreat>,
176) -> Vec<RegexThreat> {
177 let mut seen: std::collections::HashSet<(String, String)> = seen_threats
178 .iter()
179 .map(|threat| (threat.pattern.clone(), threat.match_text.clone()))
180 .collect();
181 let mut kept = Vec::new();
182 for threat in new_threats {
183 if seen.insert((threat.pattern.clone(), threat.match_text.clone())) {
184 kept.push(threat);
185 }
186 }
187 kept
188}
189
190fn semantic_threats(
191 processed: &str,
192 raw_content: &str,
193 config: &DetectConfig,
194) -> Vec<SemanticThreat> {
195 if binary::looks_like_binary_content(raw_content) {
196 return Vec::new();
197 }
198 let semantic_budget = config.max_content_length;
199 let input: String = processed.chars().take(semantic_budget).collect();
200 let analysis = semantic::analyze(
201 &input,
202 &AttackKeywords::default(),
203 &AttackStructures::default(),
204 );
205 let score = semantic::get_threat_score(&analysis);
206
207 let mut threats = Vec::new();
208 if score > config.semantic_threshold {
209 let mut probs: Vec<(&str, f64)> = analysis
210 .attack_probabilities
211 .iter()
212 .map(|(k, v)| (*k, *v))
213 .collect();
214 probs.sort_unstable_by(|a, b| a.0.cmp(b.0));
215 for (attack_type, probability) in probs {
216 if probability >= config.semantic_threshold {
217 threats.push(SemanticThreat {
218 attack_type: attack_type.to_owned(),
219 score: probability,
220 fallback: false,
221 analysis: analysis.clone(),
222 });
223 }
224 }
225 if threats.is_empty() && score >= config.semantic_threshold {
226 threats.push(SemanticThreat {
227 attack_type: "suspicious".to_owned(),
228 score,
229 fallback: true,
230 analysis,
231 });
232 }
233 }
234 threats
235}
236
237#[must_use]
240pub fn detect(content: &str, request_context: &str, config: &DetectConfig) -> DetectVerdict {
241 detect_with_semantic_skip(content, request_context, config, false)
242}
243
244#[must_use]
250pub fn detect_with_semantic_skip(
251 content: &str,
252 request_context: &str,
253 config: &DetectConfig,
254 skip_semantic: bool,
255) -> DetectVerdict {
256 let mut budget = ScanTimeBudget::new(DEFAULT_MAX_SCAN_SECONDS);
257 detect_with_budget(content, request_context, config, skip_semantic, &mut budget)
258}
259
260#[must_use]
266pub fn detect_with_budget(
267 content: &str,
268 request_context: &str,
269 config: &DetectConfig,
270 skip_semantic: bool,
271 budget: &mut ScanTimeBudget,
272) -> DetectVerdict {
273 let original_length = content.chars().count();
274 let (processed, decoded, decode_budget_exhausted) = preprocessor::preprocess_with_decoded(
275 content,
276 config.max_full_scan_bytes,
277 config.preserve_attack_patterns,
278 config.max_content_length,
279 );
280 let processed_length = processed.chars().count();
281
282 let normalized = normalize_context(request_context);
283 let validator_context = if request_context.ends_with(EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX) {
284 format!("{normalized}{EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX}")
285 } else {
286 normalized.to_owned()
287 };
288
289 let mut regex_threats: Vec<RegexThreat> = Vec::new();
290
291 regex_threats.extend(scan_pass(
293 &processed,
294 ViewFilter::Processed,
295 normalized,
296 &validator_context,
297 budget,
298 ));
299
300 let raw_view = preprocessor::preprocess_signal_preserving(
302 content,
303 config.max_full_scan_bytes,
304 config.preserve_attack_patterns,
305 config.max_content_length,
306 );
307 let raw_pass = scan_pass(
308 &raw_view,
309 ViewFilter::Raw,
310 normalized,
311 &validator_context,
312 budget,
313 );
314 regex_threats.extend(drop_view_duplicate_threats(®ex_threats, raw_pass));
319
320 if let Some(threat) = patterns::decoded_view_traversal_threat(&processed, &raw_view) {
322 regex_threats.push(threat);
323 }
324
325 let url_decoded_view = preprocessor::truncate_safely(
328 &decoded,
329 config.max_full_scan_bytes,
330 config.preserve_attack_patterns,
331 config.max_content_length,
332 );
333 regex_threats.extend(scan_pass(
334 &url_decoded_view,
335 ViewFilter::UrlDecoded,
336 normalized,
337 &validator_context,
338 budget,
339 ));
340
341 if decode_budget_exhausted {
342 regex_threats.push(patterns::decode_budget_exhausted_threat());
343 }
344
345 let additive_view = preprocessor::short_base64_additive_view(
347 content,
348 config.max_full_scan_bytes,
349 config.preserve_attack_patterns,
350 config.max_content_length,
351 );
352 if !additive_view.is_empty() {
353 regex_threats.extend(scan_pass(
354 &additive_view,
355 ViewFilter::All,
356 normalized,
357 &validator_context,
358 budget,
359 ));
360 }
361
362 let semantic = if skip_semantic {
363 Vec::new()
364 } else {
365 semantic_threats(&processed, content, config)
366 };
367
368 score_verdict(
369 regex_threats,
370 semantic,
371 config,
372 original_length,
373 processed_length,
374 )
375}
376
377fn score_verdict(
380 regex_threats: Vec<RegexThreat>,
381 semantic: Vec<SemanticThreat>,
382 config: &DetectConfig,
383 original_length: usize,
384 processed_length: usize,
385) -> DetectVerdict {
386 let anomaly = regex_anomaly(®ex_threats);
387 let is_threat = anomaly >= config.threat_score_threshold || !semantic.is_empty();
388
389 let threat_score = if regex_threats.is_empty() && semantic.is_empty() {
390 0.0
391 } else {
392 let semantic_max = semantic.iter().map(|s| s.score).fold(0.0_f64, f64::max);
393 (anomaly.max(semantic_max)).min(1.0)
394 };
395
396 let mut threats: Vec<Threat> = regex_threats
397 .into_iter()
398 .map(|threat| {
399 if threat.pattern == SCAN_TIME_BUDGET_EXHAUSTED_PATTERN {
400 Threat::PatternTimeout(threat)
401 } else {
402 Threat::Regex(threat)
403 }
404 })
405 .collect();
406 threats.extend(semantic.into_iter().map(Threat::Semantic));
407
408 DetectVerdict {
409 is_threat,
410 threat_score,
411 threats,
412 original_length,
413 processed_length,
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 fn corpus_config() -> DetectConfig {
422 DetectConfig {
423 max_content_length: 10_000,
424 max_full_scan_bytes: 262_144,
425 preserve_attack_patterns: true,
426 semantic_threshold: 0.7,
427 threat_score_threshold: 1.0,
428 binary_min_run_length: 16,
429 max_scan_values: 512,
430 max_scan_chars: 65_536,
431 max_json_depth: 32,
432 }
433 }
434
435 #[test]
436 fn the_config_defaults_carry_the_reference_values() {
437 let config = DetectConfig::default();
438 assert_eq!(config.max_content_length, 10_000);
439 assert_eq!(config.max_full_scan_bytes, 262_144);
440 assert!(config.preserve_attack_patterns);
441 assert!((config.semantic_threshold - 0.7).abs() < f64::EPSILON);
442 assert!((config.threat_score_threshold - 1.0).abs() < f64::EPSILON);
443 assert_eq!(config.binary_min_run_length, 16);
444 assert_eq!(config.max_scan_values, 512);
445 assert_eq!(config.max_scan_chars, 65_536);
446 assert_eq!(config.max_json_depth, 32);
447 }
448
449 #[test]
450 fn traversal_in_url_path_context() {
451 let v = detect("/files?name=../../etc/passwd", "url_path", &corpus_config());
452 assert!(v.is_threat);
453 assert!((v.threat_score - 1.0).abs() < f64::EPSILON);
454 assert_eq!(v.threats.len(), 2);
455 assert_eq!(v.original_length, 28);
456 assert_eq!(v.processed_length, 28);
457 }
458
459 #[test]
460 fn benign_content_is_clean() {
461 let v = detect("hello world", "request_body", &corpus_config());
462 assert!(!v.is_threat);
463 assert!((v.threat_score - 0.0).abs() < f64::EPSILON);
464 assert!(v.threats.is_empty());
465 }
466
467 fn is_sqli_comment_threat(t: &Threat) -> bool {
470 matches!(t, Threat::Regex(r) if r.category == "sqli" && r.pattern.contains("/\\*"))
471 }
472
473 #[test]
474 fn context_filtering_gates_sqli_narrow() {
475 let hit = detect("a/**/b", "request_body", &corpus_config());
477 assert!(hit.threats.iter().any(is_sqli_comment_threat));
478 let miss = detect("/files?name=../../etc/passwd", "url_path", &corpus_config());
480 assert!(
481 miss.threats
482 .iter()
483 .any(|t| matches!(t, Threat::Regex(r) if r.category == "dir_traversal"))
484 );
485 assert!(!miss.threats.iter().any(is_sqli_comment_threat));
486 }
487
488 #[test]
489 fn unknown_context_normalizes() {
490 assert_eq!(normalize_context("weird:thing"), "unknown");
491 assert_eq!(
492 normalize_context("query_param:embedded_json"),
493 "query_param"
494 );
495 assert_eq!(normalize_context("header"), "header");
496 }
497
498 #[test]
499 fn raw_view_only_patterns_need_the_raw_view() {
500 let v = detect("/search?q=%2e%2e%2f", "url_path", &corpus_config());
502 assert!(
503 v.threats
504 .iter()
505 .any(|t| matches!(t, Threat::Regex(r) if r.category == "path_traversal"))
506 );
507 }
508
509 #[test]
510 fn regex_anomaly_sums_weights() {
511 let v = detect("SELECT * FROM users", "request_body", &corpus_config());
513 assert!(v.is_threat);
514 let semantic_side = detect(&mixed_content(), "request_body", &corpus_config());
517 let semantic_threat = semantic_side
518 .threats
519 .iter()
520 .find(|t| matches!(t, Threat::Semantic(_)))
521 .expect("the mixed payload carries a semantic threat");
522 let anomaly: f64 = v
523 .threats
524 .iter()
525 .chain(std::iter::once(semantic_threat))
526 .filter_map(|t| match t {
527 Threat::Regex(r) | Threat::PatternTimeout(r) => Some(r.weight),
528 Threat::Semantic(_) => None,
529 })
530 .sum();
531 assert!((anomaly - 1.0).abs() < 1e-9, "anomaly {anomaly}");
532
533 let mixed = detect(&mixed_content(), "request_body", &corpus_config());
535 assert!(
536 mixed
537 .threats
538 .iter()
539 .any(|t| matches!(t, Threat::Semantic(_)))
540 );
541 let mixed_anomaly: f64 = mixed
542 .threats
543 .iter()
544 .filter_map(|t| match t {
545 Threat::Regex(r) | Threat::PatternTimeout(r) => Some(r.weight),
546 Threat::Semantic(_) => None,
547 })
548 .sum();
549 assert!(mixed_anomaly > 0.0, "regex hits still carry weight");
550 assert!(mixed.is_threat);
551 }
552
553 fn mixed_content() -> String {
554 format!(
555 "select union insert update delete drop from where order group having concat \
556 substring database table column (1 OR 1=1) {}",
557 "A".repeat(120)
558 )
559 }
560
561 #[test]
562 fn an_embedded_json_leaf_context_keeps_its_suffix() {
563 let v = detect(
566 "SELECT * FROM users",
567 "request_body:embedded_json",
568 &corpus_config(),
569 );
570 assert!(v.is_threat);
571 }
572
573 #[test]
574 fn a_short_base64_view_scans_as_an_additive_pass() {
575 let v = detect("JHt9", "request_body", &corpus_config());
579 assert!(v.is_threat, "unexpected verdict: {v:?}");
580 assert!(
581 v.threats
582 .iter()
583 .any(|t| matches!(t, Threat::Regex(r) if r.category == "cmd_injection")),
584 "verdict: {v:?}"
585 );
586 }
587
588 #[test]
589 fn binary_raw_content_never_yields_semantic_threats() {
590 let processed = "\0".repeat(16);
592 assert!(
593 semantic_threats(&processed, &processed, &corpus_config()).is_empty(),
594 "binary content is exempt from the semantic pass"
595 );
596 }
597
598 #[test]
599 fn an_exhausted_decode_budget_appends_its_marker_threat() {
600 let content = format!("%25{}", "25".repeat(15));
604 let v = detect(&content, "url_path", &corpus_config());
605 assert!(
606 v.threats.iter().any(|t| matches!(
607 t,
608 Threat::Regex(r) if r.pattern == "decode_budget_exhausted"
609 )),
610 "verdict: {v:?}"
611 );
612 }
613}
614
615#[cfg(test)]
616mod gap_tests {
617 use super::*;
618
619 fn corpus_config() -> DetectConfig {
620 DetectConfig {
621 max_content_length: 10_000,
622 max_full_scan_bytes: 262_144,
623 preserve_attack_patterns: true,
624 semantic_threshold: 0.7,
625 threat_score_threshold: 1.0,
626 binary_min_run_length: 16,
627 max_scan_values: 512,
628 max_scan_chars: 65_536,
629 max_json_depth: 32,
630 }
631 }
632
633 #[test]
634 fn a_semantic_fallback_threat_reports_suspicious() {
635 let mut content = String::from("from where drop select ");
638 content.push_str(&"A".repeat(120));
639 content.push_str(" ({}$x==) foo(");
640 let v = detect(&content, "request_body", &corpus_config());
641 assert!(v.is_threat, "verdict: {v:?}");
642 assert!(
643 v.threats.iter().any(
644 |t| matches!(t, Threat::Semantic(s) if s.attack_type == "suspicious" && s.fallback)
645 ),
646 "verdict: {v:?}"
647 );
648 }
649
650 #[test]
651 fn a_mixed_verdict_scores_the_semantic_maximum_against_the_anomaly() {
652 let content = format!(
655 "select union insert update delete drop from where order group having concat \
656 substring database table column (1 OR 1=1) {}",
657 "A".repeat(120)
658 );
659 let v = detect(&content, "request_body", &corpus_config());
660 assert!(v.is_threat);
661 assert!(
662 v.threats
663 .iter()
664 .any(|t| matches!(t, Threat::Regex(r) if r.category == "sqli"))
665 );
666 assert!(v.threats.iter().any(|t| matches!(t, Threat::Semantic(_))));
667 let semantic_max = v
668 .threats
669 .iter()
670 .filter_map(|t| match t {
671 Threat::Semantic(s) => Some(s.score),
672 Threat::Regex(_) | Threat::PatternTimeout(_) => None,
673 })
674 .fold(0.0_f64, f64::max);
675 let anomaly = v
676 .threats
677 .iter()
678 .filter_map(|t| match t {
679 Threat::Regex(r) | Threat::PatternTimeout(r) => Some(r.weight),
680 Threat::Semantic(_) => None,
681 })
682 .sum::<f64>();
683 assert!((v.threat_score - anomaly.max(semantic_max).min(1.0)).abs() < 1e-9);
684 }
685}
686
687#[cfg(test)]
688mod scan_budget_tests {
689 use super::*;
690 use crate::scan_budget::{SCAN_TIME_BUDGET_EXHAUSTED_PATTERN, ScanTimeBudget};
691 use std::cell::Cell;
692
693 thread_local! {
694 static NOW: Cell<f64> = const { Cell::new(0.0) };
695 }
696
697 fn fake_clock() -> f64 {
699 NOW.with(|now| {
700 now.set(now.get() + 1.0);
701 now.get()
702 })
703 }
704
705 fn budget_threats(verdict: &DetectVerdict) -> Vec<&RegexThreat> {
706 verdict
707 .threats
708 .iter()
709 .filter_map(|t| match t {
710 Threat::PatternTimeout(r) => Some(r),
711 _ => None,
712 })
713 .collect()
714 }
715
716 #[test]
717 fn an_exhausted_budget_records_the_reference_pattern_timeout_threat() {
718 let mut budget = ScanTimeBudget::with_clock(Some(0.1), fake_clock);
719 let verdict = detect_with_budget(
720 "hello world",
721 "query_param",
722 &DetectConfig::default(),
723 false,
724 &mut budget,
725 );
726 let threats = budget_threats(&verdict);
727 assert!(!threats.is_empty(), "verdict: {verdict:?}");
728 let threat = threats[0];
729 assert_eq!(threat.pattern, SCAN_TIME_BUDGET_EXHAUSTED_PATTERN);
730 assert_eq!(threat.match_text, "");
731 assert_eq!(threat.position, 0);
732 assert_eq!(threat.category, "custom");
733 assert!((threat.weight - 1.0).abs() < f64::EPSILON);
734 assert!(verdict.is_threat, "the weighted threat blocks");
735 assert!(
736 !verdict
737 .threats
738 .iter()
739 .any(|t| matches!(t, Threat::Regex(_))),
740 "the scan stopped after the first pattern"
741 );
742 }
743
744 #[test]
745 fn the_budget_is_shared_across_values() {
746 let mut budget = ScanTimeBudget::with_clock(Some(2.5), fake_clock);
747 let config = DetectConfig::default();
748 let first = detect_with_budget("hello", "query_param", &config, false, &mut budget);
749 let second = detect_with_budget("world", "query_param", &config, false, &mut budget);
750 let recorded = budget_threats(&first)
751 .into_iter()
752 .chain(budget_threats(&second))
753 .any(|t| t.pattern == SCAN_TIME_BUDGET_EXHAUSTED_PATTERN);
754 assert!(recorded);
755 assert!(second.is_threat);
756 }
757
758 #[test]
759 fn within_budget_nothing_changes() {
760 let config = DetectConfig::default();
761 let benign = detect("page=2&sort=asc", "query_param", &config);
762 assert!(!benign.is_threat);
763 assert!(budget_threats(&benign).is_empty());
764
765 let attack = detect("<script>alert(1)</script>", "query_param", &config);
766 assert!(attack.is_threat);
767 assert!(budget_threats(&attack).is_empty());
768 assert!(
769 attack
770 .threats
771 .iter()
772 .any(|t| matches!(t, Threat::Regex(r) if r.category == "xss"))
773 );
774 }
775
776 #[test]
777 fn a_zero_cap_exhausts_before_the_first_pattern() {
778 let verdict = detect_with_budget(
779 "hello world",
780 "query_param",
781 &DetectConfig::default(),
782 false,
783 &mut ScanTimeBudget::new(0.0),
784 );
785 assert_eq!(budget_threats(&verdict).len(), 2, "verdict: {verdict:?}");
787 assert!(
788 verdict
789 .threats
790 .iter()
791 .all(|t| matches!(t, Threat::PatternTimeout(_)))
792 );
793 }
794}