Skip to main content

wafrift_types/
verdict.rs

1//! WAF response verdict taxonomy.
2//!
3//! This module defines the typed classification of HTTP responses
4//! from a WAF-protected target. Verdicts are consumed by the strategy
5//! engine to decide which evasion pipeline to try next.
6
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10/// A classification signal that contributed to a verdict.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum Signal {
13    /// HTTP status code observation.
14    StatusCode { code: u16, expected: u16 },
15    /// Body contained a known block-page marker.
16    BodyMarker(String),
17    /// Body contained a known success marker.
18    SuccessMarker(String),
19    /// Response time anomaly relative to baseline.
20    ResponseTimeAnomaly { baseline_ms: u64, actual_ms: u64 },
21    /// Connection behavior anomaly.
22    ConnectionBehavior(ConnectionBehavior),
23    /// HTTP/2 GOAWAY frame observed.
24    H2Goaway(String),
25    /// Baseline fingerprint drift detected.
26    FingerprintDrift(String),
27    /// Challenge platform identifier detected in body.
28    ChallengePlatform(String),
29}
30
31impl fmt::Display for Signal {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            Self::StatusCode { code, expected } => {
35                write!(f, "status {code} (expected {expected})")
36            }
37            Self::BodyMarker(m) => write!(f, "body marker: {m}"),
38            Self::SuccessMarker(m) => write!(f, "success marker: {m}"),
39            Self::ResponseTimeAnomaly {
40                baseline_ms,
41                actual_ms,
42            } => {
43                write!(f, "response time {actual_ms}ms (baseline {baseline_ms}ms)")
44            }
45            Self::ConnectionBehavior(c) => write!(f, "connection: {c}"),
46            Self::H2Goaway(reason) => write!(f, "h2 goaway: {reason}"),
47            Self::FingerprintDrift(d) => write!(f, "fingerprint drift: {d}"),
48            Self::ChallengePlatform(p) => write!(f, "challenge platform: {p}"),
49        }
50    }
51}
52
53/// Connection behavior anomalies that influence verdict classification.
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55pub enum ConnectionBehavior {
56    /// TCP connection was reset (RST) before or during response.
57    TcpReset,
58    /// Server returned 200 OK but immediately closed the connection.
59    OkWithImmediateClose,
60    /// Server returned 200 OK with a block-page body.
61    OkWithBlockPage,
62    /// Standard graceful close after full response.
63    GracefulClose,
64    /// Connection timeout.
65    Timeout,
66    /// TLS alert or handshake failure.
67    TlsError,
68}
69
70impl fmt::Display for ConnectionBehavior {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self {
73            Self::TcpReset => f.write_str("TCP reset"),
74            Self::OkWithImmediateClose => f.write_str("200 OK with immediate close"),
75            Self::OkWithBlockPage => f.write_str("200 OK with block page"),
76            Self::GracefulClose => f.write_str("graceful close"),
77            Self::Timeout => f.write_str("timeout"),
78            Self::TlsError => f.write_str("TLS error"),
79        }
80    }
81}
82
83/// Extracted block reason from a WAF response.
84#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
85pub enum BlockReason {
86    /// A specific WAF rule ID was triggered.
87    RuleId(String),
88    /// A category of rule was triggered (e.g., "`SQLi`", "XSS").
89    RuleCategory(String),
90    /// Vendor-specific block reason string.
91    VendorReason(String),
92    /// IP reputation block.
93    IpReputation,
94    /// Geographic block.
95    GeoBlock,
96    /// Custom block page matched.
97    CustomBlockPage(String),
98    /// Unknown / not extractable.
99    Unknown,
100}
101
102impl fmt::Display for BlockReason {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            Self::RuleId(id) => write!(f, "rule {id}"),
106            Self::RuleCategory(c) => write!(f, "category {c}"),
107            Self::VendorReason(r) => write!(f, "vendor: {r}"),
108            Self::IpReputation => f.write_str("IP reputation"),
109            Self::GeoBlock => f.write_str("geo block"),
110            Self::CustomBlockPage(p) => write!(f, "block page: {p}"),
111            Self::Unknown => f.write_str("unknown reason"),
112        }
113    }
114}
115
116/// WAF response verdict — the output of the response oracle.
117#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
118pub enum Verdict {
119    /// Request was blocked by the WAF.
120    Blocked {
121        /// Optional extracted reason for the block.
122        reason: Option<BlockReason>,
123        /// Signals that led to this verdict.
124        signals: Vec<Signal>,
125    },
126    /// Request was allowed through.
127    Allowed {
128        /// Signals that led to this verdict.
129        signals: Vec<Signal>,
130    },
131    /// Rate limit was applied (429 or 503 with rate-limit headers).
132    RateLimited {
133        /// Signals that led to this verdict.
134        signals: Vec<Signal>,
135    },
136    /// A challenge (CAPTCHA, JS challenge) was returned.
137    /// Strategy should use a challenge solver or pick a different bypass.
138    ChallengeRequired {
139        /// Challenge platform detected (e.g., "cloudflare-challenge", "recaptcha").
140        platform: Option<String>,
141        /// Signals that led to this verdict.
142        signals: Vec<Signal>,
143    },
144    /// Server error (5xx) not attributable to the WAF.
145    ServerError {
146        /// Signals that led to this verdict.
147        signals: Vec<Signal>,
148    },
149    /// Soft block — partial body redaction or modified response.
150    Partial {
151        /// Why the response was considered partial.
152        reason: Option<BlockReason>,
153        /// Signals that led to this verdict.
154        signals: Vec<Signal>,
155    },
156    /// Conflicting signals — multiple plausible verdicts.
157    Ambiguous {
158        /// Competing verdicts and their supporting signals.
159        competing: Vec<(Verdict, Vec<Signal>)>,
160        /// Human-readable explanation of the conflict.
161        explanation: String,
162    },
163}
164
165impl Verdict {
166    /// Create a simple `Blocked` verdict with no reason.
167    #[must_use]
168    pub fn blocked(signals: Vec<Signal>) -> Self {
169        Self::Blocked {
170            reason: None,
171            signals,
172        }
173    }
174
175    /// Create a `Blocked` verdict with a specific reason.
176    #[must_use]
177    pub fn blocked_with_reason(reason: BlockReason, signals: Vec<Signal>) -> Self {
178        Self::Blocked {
179            reason: Some(reason),
180            signals,
181        }
182    }
183
184    /// Create an `Allowed` verdict.
185    #[must_use]
186    pub fn allowed(signals: Vec<Signal>) -> Self {
187        Self::Allowed { signals }
188    }
189
190    /// Create a `RateLimited` verdict.
191    #[must_use]
192    pub fn rate_limited(signals: Vec<Signal>) -> Self {
193        Self::RateLimited { signals }
194    }
195
196    /// Create a `ChallengeRequired` verdict.
197    #[must_use]
198    pub fn challenge_required(platform: Option<String>, signals: Vec<Signal>) -> Self {
199        Self::ChallengeRequired { platform, signals }
200    }
201
202    /// Create a `ServerError` verdict.
203    #[must_use]
204    pub fn server_error(signals: Vec<Signal>) -> Self {
205        Self::ServerError { signals }
206    }
207
208    /// Create a `Partial` verdict.
209    #[must_use]
210    pub fn partial(reason: Option<BlockReason>, signals: Vec<Signal>) -> Self {
211        Self::Partial { reason, signals }
212    }
213
214    /// Returns true if this verdict represents a hard block.
215    #[must_use]
216    pub fn is_blocked(&self) -> bool {
217        matches!(self, Self::Blocked { .. })
218    }
219
220    /// Returns true if this verdict requires a challenge solver.
221    #[must_use]
222    pub fn is_challenge(&self) -> bool {
223        matches!(self, Self::ChallengeRequired { .. })
224    }
225
226    /// Returns true if this verdict is ambiguous.
227    #[must_use]
228    pub fn is_ambiguous(&self) -> bool {
229        matches!(self, Self::Ambiguous { .. })
230    }
231
232    /// Returns true if the request was allowed (or at least not blocked).
233    #[must_use]
234    pub fn is_allowed(&self) -> bool {
235        matches!(self, Self::Allowed { .. })
236    }
237
238    /// Get all signals attached to this verdict.
239    #[must_use]
240    pub fn signals(&self) -> Vec<Signal> {
241        match self {
242            Self::Blocked { signals, .. }
243            | Self::Allowed { signals }
244            | Self::RateLimited { signals }
245            | Self::ChallengeRequired { signals, .. }
246            | Self::ServerError { signals }
247            | Self::Partial { signals, .. } => signals.clone(),
248            Self::Ambiguous { competing, .. } => {
249                competing.iter().flat_map(|(v, _)| v.signals()).collect()
250            }
251        }
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn verdict_blocked_creation() {
261        let v = Verdict::blocked(vec![Signal::StatusCode {
262            code: 403,
263            expected: 200,
264        }]);
265        assert!(v.is_blocked());
266        assert!(!v.is_allowed());
267    }
268
269    #[test]
270    fn verdict_challenge_creation() {
271        let v = Verdict::challenge_required(
272            Some("cloudflare".into()),
273            vec![Signal::ChallengePlatform("cloudflare".into())],
274        );
275        assert!(v.is_challenge());
276        assert!(!v.is_blocked());
277    }
278
279    #[test]
280    fn verdict_ambiguous_signals_flatten() {
281        let v = Verdict::Ambiguous {
282            competing: vec![
283                (
284                    Verdict::blocked(vec![Signal::BodyMarker("denied".into())]),
285                    vec![Signal::BodyMarker("denied".into())],
286                ),
287                (
288                    Verdict::allowed(vec![Signal::SuccessMarker("ok".into())]),
289                    vec![Signal::SuccessMarker("ok".into())],
290                ),
291            ],
292            explanation: "conflict".into(),
293        };
294        let signals = v.signals();
295        assert_eq!(signals.len(), 2);
296        assert!(signals.contains(&Signal::BodyMarker("denied".into())));
297        assert!(signals.contains(&Signal::SuccessMarker("ok".into())));
298    }
299
300    #[test]
301    fn block_reason_display() {
302        assert_eq!(BlockReason::RuleId("1001".into()).to_string(), "rule 1001");
303        assert_eq!(BlockReason::IpReputation.to_string(), "IP reputation");
304    }
305}