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(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#[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 regex_threats.extend(scan_pass(
209 &processed,
210 ViewFilter::Processed,
211 normalized,
212 &validator_context,
213 ));
214
215 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 regex_threats.extend(drop_view_duplicate_threats(®ex_threats, raw_pass));
228
229 if let Some(threat) = patterns::decoded_view_traversal_threat(&processed, &raw_view) {
231 regex_threats.push(threat);
232 }
233
234 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 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(®ex_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 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 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 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}