dwara_core/dataplane/
waf.rs

1//! WAF-lite heuristic filtering (DW-051).
2//!
3//! A lightweight, pattern-matching web application filter that inspects
4//! the request path, query string, selected headers, and body (when JSON
5//! or form-urlencoded) for common SQL-injection, XSS, and path-traversal
6//! signatures. This is NOT a full WAF — it is a first-line traffic filter
7//! that rejects obvious attack signatures before authentication or rate
8//! limiting.
9//!
10//! ## Request-path position
11//!
12//! The WAF check runs AFTER the route method allowlist and BEFORE the
13//! route limits (DW-027): a content filter that should reject malicious
14//! requests before any resource is spent on auth or rate limiting. It
15//! inspects the ORIGINAL request (before path rewrite / transforms).
16//!
17//! ## Inspection targets
18//!
19//! - **Path**: the original request URI path (after routing match, before
20//!   rewrite).
21//! - **Query string**: the raw query string.
22//! - **Headers**: User-Agent, Referer, Cookie, X-Forwarded-For.
23//! - **Body**: when the content type is `application/json` or
24//!   `application/x-www-form-urlencoded`, up to `max_body_inspect_bytes`.
25//!
26//! ## Match result
27//!
28//! A [`WafMatch`] carries the filter category, the pattern that matched,
29//! the inspection target, and a truncated value preview (max 64 chars —
30//! never the full payload, which could be huge or sensitive). In
31//! `dry_run` mode the match is logged and the request continues; otherwise
32//! a 403 `waf_blocked` error envelope is returned.
33
34use std::pin::Pin;
35
36use bytes::Bytes;
37use http_body_util::BodyExt as _;
38use http_body_util::Full;
39use hyper::body::Body;
40use regex::Regex;
41
42use crate::config::RouteWaf;
43
44/// The filter category a matched pattern belongs to.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum WafFilter {
47    Sqli,
48    Xss,
49    PathTraversal,
50}
51
52impl WafFilter {
53    pub fn as_str(&self) -> &'static str {
54        match self {
55            WafFilter::Sqli => "sqli",
56            WafFilter::Xss => "xss",
57            WafFilter::PathTraversal => "path_traversal",
58        }
59    }
60}
61
62/// The inspection target where a match was found.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum WafTarget {
65    Path,
66    Query,
67    Header,
68    Body,
69}
70
71impl WafTarget {
72    pub fn as_str(&self) -> &'static str {
73        match self {
74            WafTarget::Path => "path",
75            WafTarget::Query => "query",
76            WafTarget::Header => "header",
77            WafTarget::Body => "body",
78        }
79    }
80}
81
82/// A WAF match result: the filter, pattern, target, and a truncated
83/// value preview for logging. The preview is capped at 64 chars and
84/// never carries the full payload (which could be huge or sensitive).
85#[derive(Debug, Clone)]
86pub struct WafMatch {
87    pub filter: WafFilter,
88    pub pattern: String,
89    pub target: WafTarget,
90    pub value_preview: String,
91}
92
93/// Truncate a value to 64 chars for safe logging.
94fn truncate_preview(s: &str) -> String {
95    const MAX_PREVIEW: usize = 64;
96    if s.len() <= MAX_PREVIEW {
97        s.to_string()
98    } else {
99        // Walk char boundaries so we never split a multi-byte char.
100        let mut end = MAX_PREVIEW;
101        while end > 0 && !s.is_char_boundary(end) {
102            end -= 1;
103        }
104        format!("{}...", &s[..end])
105    }
106}
107
108/// Built-in SQLi signatures (case-insensitive).
109const SQLI_PATTERNS: &[&str] = &[
110    r"(?i)union\s+select",
111    r"(?i)\bor\s+1\s*=\s*1\b",
112    r"(?i)'\s*;\s*drop\s+table",
113    r"(?i)'\s*;\s*delete\s+from",
114    r"(?i)'\s*;\s*insert\s+into",
115    r"(?i)'\s*;\s*update\s+set",
116    r"(?i)--\s*$",
117    r"(?i)--\s",
118    r"(?i)xp_cmdshell",
119    r"(?i)0x[0-9a-f]{8,}\b",
120    r"(?i)'\s*or\s*'",
121    r"(?i)'\s*and\s*'",
122    r"(?i);\s*select\s+",
123    r"(?i)\bexec\s*\(",
124    r"(?i)\bwaitfor\s+delay\b",
125    r"(?i)\bsleep\s*\(",
126    r"(?i)\bbenchmark\s*\(",
127    r"(?i)\bload_file\s*\(",
128    r"(?i)\binto\s+outfile\b",
129    r"(?i)\bgroup\s+by\s+\d+",
130    r"(?i)'\s*=\s*'",
131];
132
133/// Built-in XSS signatures (case-insensitive).
134const XSS_PATTERNS: &[&str] = &[
135    r"(?i)<script",
136    r"(?i)javascript:",
137    r"(?i)onerror\s*=",
138    r"(?i)onload\s*=",
139    r"(?i)onclick\s*=",
140    r"(?i)onmouseover\s*=",
141    r"(?i)<iframe",
142    r"(?i)<object",
143    r"(?i)<embed",
144    r"(?i)document\.cookie",
145    r"(?i)eval\s*\(",
146    r"(?i)alert\s*\(",
147    r"(?i)<img[^>]+src\s*=",
148    r"(?i)<svg[^>]+onload",
149    r"(?i)String\.fromCharCode",
150    r"(?i)\\x3cscript",
151    r"(?i)&lt;script",
152    r"(?i)<body[^>]+onload",
153    r"(?i)expression\s*\(",
154];
155
156/// Built-in path-traversal signatures.
157const PATH_TRAVERSAL_PATTERNS: &[&str] = &[
158    r"\.\./",
159    r"\.\.\\",
160    r"(?i)%2e%2e%2f",
161    r"(?i)%2e%2e/",
162    r"(?i)%2e%2e%5c",
163    r"\.\.%2f",
164    r"(?i)\.\.//",
165    r"(?i)\.\.\\",
166    r"(?i)\.\.;/",
167    r"%00",
168    r"(?i)%252e%252e%252f",
169    r"(?i)/etc/passwd",
170    r"(?i)/etc/shadow",
171    r"(?i)c:\\windows\\",
172    r"(?i)\\windows\\win\.ini",
173    r"(?i)/proc/self/",
174];
175
176/// Compile the built-in patterns for a filter category into regexes.
177fn builtin_patterns(filter: WafFilter) -> &'static [&'static str] {
178    match filter {
179        WafFilter::Sqli => SQLI_PATTERNS,
180        WafFilter::Xss => XSS_PATTERNS,
181        WafFilter::PathTraversal => PATH_TRAVERSAL_PATTERNS,
182    }
183}
184
185/// A compiled WAF generation: the regex sets for each enabled filter
186/// category, plus any custom patterns. Built once per config generation
187/// and reused across requests.
188pub struct WafGeneration {
189    filters: Vec<WafFilterEntry>,
190    max_body_inspect_bytes: u64,
191    dry_run: bool,
192}
193
194struct WafFilterEntry {
195    filter: WafFilter,
196    patterns: Vec<Regex>,
197}
198
199impl WafGeneration {
200    /// Build a WAF generation from the route config. Returns `None` when
201    /// the WAF is disabled (no inspection should run).
202    pub fn from_config(waf: &RouteWaf) -> Option<Self> {
203        if !waf.enabled {
204            return None;
205        }
206        // Resolve the filter set: an empty list defaults to all three.
207        let filter_names: Vec<WafFilter> = if waf.filters.is_empty() {
208            vec![WafFilter::Sqli, WafFilter::Xss, WafFilter::PathTraversal]
209        } else {
210            waf.filters
211                .iter()
212                .filter_map(|f| match f.as_str() {
213                    "sqli" => Some(WafFilter::Sqli),
214                    "xss" => Some(WafFilter::Xss),
215                    "path_traversal" => Some(WafFilter::PathTraversal),
216                    _ => None,
217                })
218                .collect()
219        };
220        let mut entries = Vec::with_capacity(filter_names.len());
221        for filter in filter_names {
222            let mut patterns: Vec<Regex> = builtin_patterns(filter)
223                .iter()
224                .map(|p| Regex::new(p).expect("built-in WAF pattern compiles"))
225                .collect();
226            // Custom patterns are appended to every enabled filter
227            // category (they are additional signatures the operator
228            // chose to add).
229            for custom in &waf.custom_patterns {
230                if let Ok(re) = Regex::new(custom) {
231                    patterns.push(re);
232                }
233            }
234            entries.push(WafFilterEntry { filter, patterns });
235        }
236        Some(WafGeneration {
237            filters: entries,
238            max_body_inspect_bytes: waf.max_body_inspect_bytes,
239            dry_run: waf.dry_run,
240        })
241    }
242
243    /// Whether this WAF generation is in dry-run (audit-log-only) mode.
244    pub fn dry_run(&self) -> bool {
245        self.dry_run
246    }
247
248    /// The configured body inspection byte cap (0 = no body inspection).
249    pub fn max_body_inspect_bytes(&self) -> u64 {
250        self.max_body_inspect_bytes
251    }
252
253    /// Inspect a single string value against all enabled filter patterns.
254    /// Returns the first match (if any). The `target` and `value` are
255    /// used to populate the [`WafMatch`].
256    fn inspect_value(&self, target: WafTarget, value: &str) -> Option<WafMatch> {
257        for entry in &self.filters {
258            for pat in &entry.patterns {
259                if pat.is_match(value) {
260                    return Some(WafMatch {
261                        filter: entry.filter,
262                        pattern: pat.as_str().to_string(),
263                        target,
264                        value_preview: truncate_preview(value),
265                    });
266                }
267            }
268        }
269        None
270    }
271
272    /// Inspect the request head: path, query string, and selected
273    /// headers. Returns the first match (if any). Synchronous — no body
274    /// access.
275    pub fn inspect_head(
276        &self,
277        path: &str,
278        query: Option<&str>,
279        headers: &hyper::HeaderMap,
280    ) -> Option<WafMatch> {
281        // Path (the ORIGINAL request path, before rewrite).
282        if let Some(m) = self.inspect_value(WafTarget::Path, path) {
283            return Some(m);
284        }
285        // Query string (raw).
286        if let Some(q) = query {
287            if let Some(m) = self.inspect_value(WafTarget::Query, q) {
288                return Some(m);
289            }
290        }
291        // Selected headers: User-Agent, Referer, Cookie, X-Forwarded-For.
292        for name in &["user-agent", "referer", "cookie", "x-forwarded-for"] {
293            if let Some(val) = headers.get(*name).and_then(|v| v.to_str().ok()) {
294                if let Some(m) = self.inspect_value(WafTarget::Header, val) {
295                    return Some(m);
296                }
297            }
298        }
299        None
300    }
301
302    /// Inspect a buffered body slice. Returns the first match (if any).
303    pub fn inspect_body_slice(&self, body: &[u8]) -> Option<WafMatch> {
304        // The body is inspected as UTF-8 lossy — patterns match on the
305        // decoded text, not raw bytes (the common case for JSON and
306        // form-urlencoded bodies).
307        let text = String::from_utf8_lossy(body);
308        self.inspect_value(WafTarget::Body, &text)
309    }
310}
311
312/// The headers that signal a body worth inspecting: JSON or
313/// form-urlencoded content types.
314pub fn should_inspect_body(headers: &hyper::HeaderMap) -> bool {
315    if let Some(ct) = headers
316        .get(hyper::header::CONTENT_TYPE)
317        .and_then(|v| v.to_str().ok())
318    {
319        let ct = ct.to_ascii_lowercase();
320        ct.starts_with("application/json")
321            || ct.starts_with("application/x-www-form-urlencoded")
322            || ct.starts_with("text/plain")
323    } else {
324        false
325    }
326}
327
328/// The outcome of body inspection: either a match was found (and the
329/// body is reconstructed for forwarding), or no match was found. The
330/// reconstructed body is a concrete type so the caller can continue
331/// the request path regardless of the original body's type.
332pub struct BodyInspectionResult {
333    /// The reconstructed body for forwarding (buffered prefix + any
334    /// remaining stream). When the body fit within the inspection cap,
335    /// this is the full body as `Full<Bytes>`. When the body exceeded
336    /// the cap, this is the buffered prefix followed by the remaining
337    /// stream via [`WafBody`].
338    pub body: WafBody,
339    /// The match found during inspection, if any.
340    pub match_found: Option<WafMatch>,
341}
342
343/// A body that replays buffered bytes (inspected by the WAF) then
344/// streams the remaining original body. When the entire body fit within
345/// the inspection cap, `rest` is `None` and the body is fully buffered.
346pub enum WafBody {
347    /// Fully buffered body (fit within the inspection cap).
348    Full(Full<Bytes>),
349    /// Partially buffered: the inspected prefix followed by the
350    /// remaining stream.
351    Partial {
352        prefix: Bytes,
353        rest: Pin<
354            Box<dyn Body<Data = Bytes, Error = Box<dyn std::error::Error + Send + Sync>> + Send>,
355        >,
356    },
357}
358
359impl WafBody {
360    /// Create a fully-buffered WAF body.
361    pub fn full(bytes: Bytes) -> Self {
362        WafBody::Full(Full::new(bytes))
363    }
364}
365
366impl Body for WafBody {
367    type Data = Bytes;
368    type Error = Box<dyn std::error::Error + Send + Sync>;
369
370    fn poll_frame(
371        self: Pin<&mut Self>,
372        cx: &mut std::task::Context<'_>,
373    ) -> std::task::Poll<Option<Result<hyper::body::Frame<Bytes>, Self::Error>>> {
374        let this = self.get_mut();
375        match this {
376            WafBody::Full(b) => Pin::new(b).poll_frame(cx).map_err(|e| match e {}),
377            WafBody::Partial { prefix, rest } => {
378                if !prefix.is_empty() {
379                    let chunk = prefix.clone();
380                    *prefix = Bytes::new();
381                    return std::task::Poll::Ready(Some(Ok(hyper::body::Frame::data(chunk))));
382                }
383                rest.as_mut().poll_frame(cx)
384            }
385        }
386    }
387
388    fn is_end_stream(&self) -> bool {
389        match self {
390            WafBody::Full(b) => b.is_end_stream(),
391            WafBody::Partial { prefix, rest } => prefix.is_empty() && rest.is_end_stream(),
392        }
393    }
394
395    fn size_hint(&self) -> hyper::body::SizeHint {
396        match self {
397            WafBody::Full(b) => b.size_hint(),
398            WafBody::Partial { prefix, rest } => {
399                let mut hint = rest.size_hint();
400                if let Some(exact) = hint.exact() {
401                    hint.set_exact(exact + prefix.len() as u64);
402                }
403                hint
404            }
405        }
406    }
407}
408
409/// Inspect the request body for WAF signatures. Buffers up to
410/// `max_body_inspect_bytes` from the body, runs the pattern match on
411/// the buffered slice, and returns the reconstructed body for
412/// forwarding. When the body exceeds the cap, only the prefix is
413/// inspected (a malicious payload beyond the cap is not caught — the
414/// trade-off for a bounded inspection cost).
415pub async fn inspect_body<B>(body: B, gen: &WafGeneration) -> BodyInspectionResult
416where
417    B: Body<Data = Bytes> + Send + 'static,
418    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
419{
420    let cap = gen.max_body_inspect_bytes();
421    let mut body = Box::pin(body);
422    let mut buf: Vec<u8> = Vec::new();
423    let mut exceeded_cap = false;
424
425    loop {
426        if cap > 0 && buf.len() as u64 >= cap {
427            exceeded_cap = true;
428            break;
429        }
430        match body.frame().await {
431            Some(Ok(frame)) => {
432                let Ok(data) = frame.into_data() else {
433                    continue; // trailer frame: dropped
434                };
435                if cap > 0 && buf.len() as u64 + data.len() as u64 > cap {
436                    // Only buffer up to the cap; the rest stays in the
437                    // stream. We take the portion that fits and leave
438                    // the remainder in the body.
439                    let remaining = cap as usize - buf.len();
440                    buf.extend_from_slice(&data[..remaining]);
441                    // Re-wrap the unconsumed portion + the rest of the
442                    // body into a chain. Since we already consumed this
443                    // frame, we need to prepend the unconsumed bytes.
444                    let unconsumed = data.slice(remaining..);
445                    let rest_body = PrefixedBody {
446                        prefix: Some(unconsumed),
447                        rest: body,
448                    };
449                    let match_found = gen.inspect_body_slice(&buf);
450                    let body_out = WafBody::Partial {
451                        prefix: Bytes::from(buf),
452                        rest: Box::pin(rest_body),
453                    };
454                    return BodyInspectionResult {
455                        body: body_out,
456                        match_found,
457                    };
458                }
459                buf.extend_from_slice(&data);
460            }
461            Some(Err(_)) => {
462                // Body error: stop inspecting, forward what we have.
463                break;
464            }
465            None => break,
466        }
467    }
468
469    let match_found = gen.inspect_body_slice(&buf);
470    let buffered = Bytes::from(buf);
471
472    let body_out = if exceeded_cap {
473        // The body had more data; `body` still holds the remaining
474        // stream. We need to forward buffered prefix + rest. The error
475        // type is mapped to BoxError for type-erasure.
476        WafBody::Partial {
477            prefix: buffered,
478            rest: Box::pin(body.map_err(|e| e.into())),
479        }
480    } else {
481        // The whole body fit within the cap (or the body ended).
482        WafBody::Full(Full::new(buffered))
483    };
484
485    BodyInspectionResult {
486        body: body_out,
487        match_found,
488    }
489}
490
491/// A body wrapper that yields an optional buffered prefix followed by
492/// the remaining stream. Used when the WAF inspection cap is reached
493/// mid-frame: the unconsumed portion of the breaking frame is prepended
494/// to the rest of the body.
495struct PrefixedBody<B: Body> {
496    prefix: Option<Bytes>,
497    rest: Pin<Box<B>>,
498}
499
500impl<B> Body for PrefixedBody<B>
501where
502    B: Body<Data = Bytes>,
503    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
504{
505    type Data = Bytes;
506    type Error = Box<dyn std::error::Error + Send + Sync>;
507
508    fn poll_frame(
509        self: Pin<&mut Self>,
510        cx: &mut std::task::Context<'_>,
511    ) -> std::task::Poll<Option<Result<hyper::body::Frame<Bytes>, Self::Error>>> {
512        let this = self.get_mut();
513        if let Some(prefix) = this.prefix.take() {
514            if !prefix.is_empty() {
515                return std::task::Poll::Ready(Some(Ok(hyper::body::Frame::data(prefix))));
516            }
517        }
518        this.rest.as_mut().poll_frame(cx).map_err(|e| e.into())
519    }
520
521    fn is_end_stream(&self) -> bool {
522        self.prefix.as_ref().is_none_or(|p| p.is_empty()) && self.rest.is_end_stream()
523    }
524
525    fn size_hint(&self) -> hyper::body::SizeHint {
526        let mut hint = self.rest.size_hint();
527        if let Some(prefix) = &self.prefix {
528            if let Some(exact) = hint.exact() {
529                hint.set_exact(exact + prefix.len() as u64);
530            }
531        }
532        hint
533    }
534}
535
536// --- SEC-08 (#212): OWASP CRS-compatible rule engine -----------------------
537
538use crate::config::GlobalWaf;
539
540/// A compiled CRS rule: the compiled regex, metadata, and the
541/// resolved target set. Built once per config generation.
542pub struct CompiledCrsRule {
543    pub id: u32,
544    pub severity: u8,
545    pub phase: u8,
546    pub tags: Vec<String>,
547    pattern: Regex,
548    targets: Vec<CrsTarget>,
549    transformations: Vec<Transformation>,
550}
551
552/// CRS inspection targets (resolved from string names).
553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554enum CrsTarget {
555    Path,
556    Query,
557    Headers,
558    Body,
559}
560
561/// CRS transformations applied before pattern matching.
562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
563enum Transformation {
564    Lowercase,
565    UrlDecode,
566    HtmlEntityDecode,
567    CompressWhitespace,
568    RemoveWhitespace,
569    UrlDecodeUni,
570}
571
572impl Transformation {
573    fn from_name(name: &str) -> Option<Self> {
574        match name {
575            "lowercase" => Some(Transformation::Lowercase),
576            "url_decode" => Some(Transformation::UrlDecode),
577            "html_entity_decode" => Some(Transformation::HtmlEntityDecode),
578            "compress_whitespace" => Some(Transformation::CompressWhitespace),
579            "remove_whitespace" => Some(Transformation::RemoveWhitespace),
580            "url_decode_uni" => Some(Transformation::UrlDecodeUni),
581            _ => None,
582        }
583    }
584
585    fn apply(&self, input: &str) -> String {
586        match self {
587            Transformation::Lowercase => input.to_lowercase(),
588            Transformation::UrlDecode => percent_decode(input).unwrap_or_else(|| input.to_string()),
589            Transformation::HtmlEntityDecode => html_entity_decode(input),
590            Transformation::CompressWhitespace => compress_whitespace(input),
591            Transformation::RemoveWhitespace => {
592                input.chars().filter(|c| !c.is_whitespace()).collect()
593            }
594            Transformation::UrlDecodeUni => {
595                percent_decode(input).unwrap_or_else(|| input.to_string())
596            }
597        }
598    }
599}
600
601/// Percent-decode a URL-encoded string.
602fn percent_decode(input: &str) -> Option<String> {
603    let bytes = input.as_bytes();
604    let mut out = Vec::with_capacity(bytes.len());
605    let mut i = 0;
606    while i < bytes.len() {
607        if bytes[i] == b'%' && i + 2 < bytes.len() {
608            if let Ok(b) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?, 16) {
609                out.push(b);
610                i += 3;
611                continue;
612            }
613        }
614        out.push(bytes[i]);
615        i += 1;
616    }
617    String::from_utf8(out).ok()
618}
619
620/// Decode HTML entities (< > & &#xx; etc.).
621fn html_entity_decode(input: &str) -> String {
622    let mut result = String::with_capacity(input.len());
623    let mut chars = input.chars().peekable();
624    while let Some(c) = chars.next() {
625        if c == '&' {
626            let mut entity = String::new();
627            let mut found = false;
628            for ec in chars.by_ref() {
629                entity.push(ec);
630                if ec == ';' {
631                    found = true;
632                    break;
633                }
634                if entity.len() > 10 {
635                    break;
636                }
637            }
638            if found {
639                match entity.as_str() {
640                    "lt;" => result.push('<'),
641                    "gt;" => result.push('>'),
642                    "amp;" => result.push('&'),
643                    "quot;" => result.push('"'),
644                    "apos;" => result.push('\''),
645                    _ => {
646                        // Numeric entity: &#NN; or &#xNN;
647                        if let Some(num) =
648                            entity.strip_prefix("#").and_then(|s| s.strip_suffix(";"))
649                        {
650                            if let Some(hex) = num.strip_prefix("x") {
651                                if let Ok(code) = u32::from_str_radix(hex, 16) {
652                                    if let Some(ch) = char::from_u32(code) {
653                                        result.push(ch);
654                                        continue;
655                                    }
656                                }
657                            } else if let Ok(code) = num.parse::<u32>() {
658                                if let Some(ch) = char::from_u32(code) {
659                                    result.push(ch);
660                                    continue;
661                                }
662                            }
663                        }
664                        // Unknown entity: keep as-is
665                        result.push('&');
666                        result.push_str(&entity);
667                    }
668                }
669            } else {
670                result.push('&');
671                result.push_str(&entity);
672            }
673        } else {
674            result.push(c);
675        }
676    }
677    result
678}
679
680/// Collapse consecutive whitespace into single spaces.
681fn compress_whitespace(input: &str) -> String {
682    let mut result = String::with_capacity(input.len());
683    let mut prev_ws = false;
684    for c in input.chars() {
685        if c.is_whitespace() {
686            if !prev_ws {
687                result.push(' ');
688                prev_ws = true;
689            }
690        } else {
691            result.push(c);
692            prev_ws = false;
693        }
694    }
695    result
696}
697
698/// A compiled CRS WAF generation: the rule set, exclusions, and
699/// anomaly threshold. Built once per config generation.
700pub struct CrsGeneration {
701    rules: Vec<CompiledCrsRule>,
702    anomaly_threshold: u32,
703    max_body_inspect_bytes: u64,
704    dry_run: bool,
705}
706
707/// The result of CRS evaluation: the total anomaly score and the
708/// list of matching rule IDs (for logging).
709#[derive(Debug, Clone)]
710pub struct CrsResult {
711    pub score: u32,
712    pub matched_rules: Vec<u32>,
713    pub blocked: bool,
714}
715
716impl CrsGeneration {
717    /// Build a CRS generation from the global WAF config. Returns
718    /// `None` when the WAF is disabled or has no rules.
719    pub fn from_config(waf: &GlobalWaf) -> Option<Self> {
720        if !waf.enabled || waf.rules.is_empty() {
721            return None;
722        }
723        let exclude_ids: std::collections::HashSet<u32> =
724            waf.exclude_rule_ids.iter().copied().collect();
725        let exclude_tags: std::collections::HashSet<&str> =
726            waf.exclude_tags.iter().map(|s| s.as_str()).collect();
727        let mut compiled = Vec::with_capacity(waf.rules.len());
728        for rule in &waf.rules {
729            // Skip excluded rules by ID.
730            if exclude_ids.contains(&rule.id) {
731                continue;
732            }
733            // Skip excluded rules by tag.
734            if rule.tags.iter().any(|t| exclude_tags.contains(t.as_str())) {
735                continue;
736            }
737            // Compile the regex pattern.
738            let pattern = match Regex::new(&rule.pattern) {
739                Ok(re) => re,
740                Err(_) => continue,
741            };
742            // Resolve targets.
743            let targets = rule
744                .targets
745                .iter()
746                .filter_map(|t| match t.as_str() {
747                    "path" => Some(CrsTarget::Path),
748                    "query" => Some(CrsTarget::Query),
749                    "header" | "headers" => Some(CrsTarget::Headers),
750                    "body" => Some(CrsTarget::Body),
751                    _ => None,
752                })
753                .collect();
754            // Resolve transformations.
755            let transformations = rule
756                .transformations
757                .iter()
758                .filter_map(|t| Transformation::from_name(t.as_str()))
759                .collect();
760            compiled.push(CompiledCrsRule {
761                id: rule.id,
762                severity: rule.severity,
763                phase: rule.phase,
764                tags: rule.tags.clone(),
765                pattern,
766                targets,
767                transformations,
768            });
769        }
770        if compiled.is_empty() {
771            return None;
772        }
773        Some(CrsGeneration {
774            rules: compiled,
775            anomaly_threshold: waf.anomaly_threshold,
776            max_body_inspect_bytes: waf.max_body_inspect_bytes,
777            dry_run: waf.dry_run,
778        })
779    }
780
781    /// Whether this CRS generation is in dry-run mode.
782    pub fn dry_run(&self) -> bool {
783        self.dry_run
784    }
785
786    /// The configured body inspection byte cap.
787    pub fn max_body_inspect_bytes(&self) -> u64 {
788        self.max_body_inspect_bytes
789    }
790
791    /// Apply transformations to a value before matching.
792    fn transform(&self, rule: &CompiledCrsRule, value: &str) -> String {
793        let mut result = value.to_string();
794        for t in &rule.transformations {
795            result = t.apply(&result);
796        }
797        result
798    }
799
800    /// Inspect a single value against a rule's pattern (after
801    /// transformations).
802    fn rule_matches(&self, rule: &CompiledCrsRule, value: &str) -> bool {
803        let transformed = self.transform(rule, value);
804        rule.pattern.is_match(&transformed)
805    }
806
807    /// Evaluate phase-1 rules (path, query, headers). Returns the
808    /// accumulated anomaly score and matched rule IDs.
809    pub fn evaluate_phase1(
810        &self,
811        path: &str,
812        query: Option<&str>,
813        headers: &hyper::HeaderMap,
814    ) -> CrsResult {
815        let mut score: u32 = 0;
816        let mut matched = Vec::new();
817        for rule in &self.rules {
818            if rule.phase != 1 {
819                continue;
820            }
821            let mut hit = false;
822            for target in &rule.targets {
823                match target {
824                    CrsTarget::Path => {
825                        if self.rule_matches(rule, path) {
826                            hit = true;
827                            break;
828                        }
829                    }
830                    CrsTarget::Query => {
831                        if let Some(q) = query {
832                            if self.rule_matches(rule, q) {
833                                hit = true;
834                                break;
835                            }
836                        }
837                    }
838                    CrsTarget::Headers => {
839                        for name in &["user-agent", "referer", "cookie", "x-forwarded-for"] {
840                            if let Some(val) = headers.get(*name).and_then(|v| v.to_str().ok()) {
841                                if self.rule_matches(rule, val) {
842                                    hit = true;
843                                    break;
844                                }
845                            }
846                        }
847                        if hit {
848                            break;
849                        }
850                    }
851                    CrsTarget::Body => {}
852                }
853            }
854            if hit {
855                score += rule.severity as u32;
856                matched.push(rule.id);
857            }
858        }
859        let blocked = score >= self.anomaly_threshold;
860        CrsResult {
861            score,
862            matched_rules: matched,
863            blocked,
864        }
865    }
866
867    /// Evaluate phase-2 rules (body). Returns the additional anomaly
868    /// score and matched rule IDs from body inspection.
869    pub fn evaluate_phase2(&self, body: &str) -> CrsResult {
870        let mut score: u32 = 0;
871        let mut matched = Vec::new();
872        for rule in &self.rules {
873            if rule.phase != 2 {
874                continue;
875            }
876            if !rule.targets.contains(&CrsTarget::Body) {
877                continue;
878            }
879            if self.rule_matches(rule, body) {
880                score += rule.severity as u32;
881                matched.push(rule.id);
882            }
883        }
884        let blocked = score >= self.anomaly_threshold;
885        CrsResult {
886            score,
887            matched_rules: matched,
888            blocked,
889        }
890    }
891}
892
893/// Build a CRS generation from the gateway config (if the global WAF
894/// is enabled). Convenience wrapper for the proxy path.
895pub fn crs_from_gateway(gateway: &crate::config::Gateway) -> Option<CrsGeneration> {
896    gateway.waf.as_ref().and_then(CrsGeneration::from_config)
897}