Skip to main content

wafrift_evolution/
advisor.rs

1//! WAF-aware strategy advisor.
2//!
3//! Consults the detected WAF and response fingerprint drift to
4//! recommend the optimal evasion strategy for the next request.
5
6use serde::Deserialize;
7use wafrift_detect::response_fingerprint::FingerprintDrift;
8use wafrift_detect::waf_detect::DetectedWaf;
9use wafrift_encoding::encoding;
10
11/// A recommended evasion plan based on WAF detection.
12#[derive(Debug, Clone, Default)]
13pub struct EvasionPlan {
14    /// Recommended encoding strategies, in priority order.
15    pub encoding_strategies: Vec<encoding::Strategy>,
16    /// Whether grammar mutations should be applied.
17    pub use_grammar: bool,
18    /// Whether header obfuscation should be applied.
19    pub use_header_obfuscation: bool,
20    /// Whether content-type switching should be applied.
21    pub use_content_type_switch: bool,
22    /// Whether smuggling should be attempted.
23    pub use_smuggling: bool,
24    /// Whether H2 evasion should be attempted.
25    pub use_h2: bool,
26    /// Rationale for each recommendation.
27    pub rationale: Vec<String>,
28}
29
30/// TOML schema for advisor rules.
31#[derive(Debug, Clone, Deserialize)]
32pub struct AdvisorRules {
33    #[serde(default)]
34    pub waf: Vec<WafAdviceRule>,
35}
36
37#[derive(Debug, Clone, Deserialize)]
38pub struct WafAdviceRule {
39    pub name: String,
40    #[serde(default)]
41    pub aliases: Vec<String>,
42    #[serde(default)]
43    pub encoding_strategies: Vec<String>,
44    #[serde(default)]
45    pub use_grammar: bool,
46    #[serde(default)]
47    pub use_header_obfuscation: bool,
48    #[serde(default)]
49    pub use_content_type_switch: bool,
50    #[serde(default)]
51    pub use_smuggling: bool,
52    #[serde(default)]
53    pub use_h2: bool,
54    #[serde(default)]
55    pub rationale: String,
56}
57
58static DEFAULT_ADVISOR_TOML: &str = r#"
59[[waf]]
60name = "Cloudflare"
61encoding_strategies = ["OverlongUtf8", "DoubleUrlEncode", "UnicodeEncode", "ChunkedSplit"]
62use_content_type_switch = true
63use_smuggling = false
64use_h2 = true
65rationale = "cloudflare: prioritizing overlong UTF-8 and unicode, avoiding smuggling"
66
67[[waf]]
68name = "AWS WAF"
69encoding_strategies = ["CaseAlternation", "SqlCommentInsertion", "UnicodeEncode"]
70use_content_type_switch = true
71use_grammar = true
72rationale = "aws waf: regex-heavy, case alternation and comment insertion effective"
73
74[[waf]]
75name = "ModSecurity"
76aliases = ["CRS", "OWASP CRS"]
77encoding_strategies = ["SqlCommentInsertion", "WhitespaceInsertion", "DoubleUrlEncode", "CaseAlternation"]
78use_grammar = true
79use_content_type_switch = true
80rationale = "modsecurity/crs: comment insertion and whitespace bypass CRS anomaly scoring"
81
82[[waf]]
83name = "Imperva/Incapsula"
84encoding_strategies = ["TripleUrlEncode", "OverlongUtf8", "ChunkedSplit"]
85use_smuggling = true
86use_h2 = true
87rationale = "imperva: deep inspection, using triple encoding and smuggling paths"
88
89[[waf]]
90name = "Akamai"
91encoding_strategies = ["DoubleUrlEncode", "UnicodeEncode", "ParameterPollution"]
92use_content_type_switch = true
93use_grammar = true
94rationale = "akamai: parameter pollution and unicode effective at edge"
95
96[[waf]]
97name = "F5 BIG-IP"
98encoding_strategies = ["CaseAlternation", "SqlCommentInsertion", "DoubleUrlEncode"]
99use_smuggling = true
100rationale = "f5 big-ip: smuggling historically effective, case alternation bypasses ASM"
101"#;
102
103fn parse_strategy(name: &str) -> Option<encoding::Strategy> {
104    match name {
105        "UrlEncode" => Some(encoding::Strategy::UrlEncode),
106        "DoubleUrlEncode" => Some(encoding::Strategy::DoubleUrlEncode),
107        "TripleUrlEncode" => Some(encoding::Strategy::TripleUrlEncode),
108        "UnicodeEncode" => Some(encoding::Strategy::UnicodeEncode),
109        "HtmlEntityEncode" => Some(encoding::Strategy::HtmlEntityEncode),
110        "CaseAlternation" => Some(encoding::Strategy::CaseAlternation),
111        "WhitespaceInsertion" => Some(encoding::Strategy::WhitespaceInsertion),
112        "SqlCommentInsertion" => Some(encoding::Strategy::SqlCommentInsertion),
113        "NullByteInsertion" => None, // Not present in encoding crate
114        "OverlongUtf8" => Some(encoding::Strategy::OverlongUtf8),
115        "ChunkedSplit" => Some(encoding::Strategy::ChunkedSplit),
116        "ParameterPollution" => None, // Not present in encoding crate
117        _ => None,
118    }
119}
120
121fn load_default_rules() -> AdvisorRules {
122    toml::from_str(DEFAULT_ADVISOR_TOML).expect("embedded advisor TOML is valid")
123}
124
125fn match_waf(name: &str, rules: &AdvisorRules) -> Option<WafAdviceRule> {
126    let lower = name.to_lowercase();
127    for rule in &rules.waf {
128        if rule.name.to_lowercase() == lower {
129            return Some(rule.clone());
130        }
131        for alias in &rule.aliases {
132            if alias.to_lowercase() == lower || lower.contains(&alias.to_lowercase()) {
133                return Some(rule.clone());
134            }
135        }
136        if lower.contains(&rule.name.to_lowercase()) {
137            return Some(rule.clone());
138        }
139    }
140    None
141}
142
143/// Generate an evasion plan based on detected WAF.
144#[must_use]
145pub fn advise(waf: Option<&DetectedWaf>, drift: Option<&FingerprintDrift>) -> EvasionPlan {
146    let mut plan = default_plan();
147    let rules = load_default_rules();
148
149    if let Some(detected) = waf {
150        if let Some(rule) = match_waf(&detected.name, &rules) {
151            apply_rule(&mut plan, &rule);
152        } else {
153            // Unknown WAF: be aggressive
154            plan.encoding_strategies = encoding::all_strategies();
155            plan.use_smuggling = true;
156            plan.use_h2 = true;
157            plan.rationale.push(format!(
158                "unknown WAF '{}': trying all techniques",
159                detected.name
160            ));
161        }
162    }
163
164    if let Some(d) = drift {
165        adapt_to_drift(&mut plan, d);
166    }
167
168    plan
169}
170
171fn apply_rule(plan: &mut EvasionPlan, rule: &WafAdviceRule) {
172    plan.encoding_strategies = rule
173        .encoding_strategies
174        .iter()
175        .filter_map(|s| parse_strategy(s))
176        .collect();
177    plan.use_grammar = rule.use_grammar;
178    plan.use_header_obfuscation = rule.use_header_obfuscation;
179    plan.use_content_type_switch = rule.use_content_type_switch;
180    plan.use_smuggling = rule.use_smuggling;
181    plan.use_h2 = rule.use_h2;
182    plan.rationale.push(rule.rationale.clone());
183}
184
185fn default_plan() -> EvasionPlan {
186    EvasionPlan {
187        encoding_strategies: vec![
188            encoding::Strategy::DoubleUrlEncode,
189            encoding::Strategy::UnicodeEncode,
190            encoding::Strategy::CaseAlternation,
191        ],
192        use_grammar: true,
193        use_header_obfuscation: true,
194        use_content_type_switch: true,
195        use_smuggling: false,
196        use_h2: false,
197        rationale: vec!["no WAF detected, using balanced defaults".into()],
198    }
199}
200
201fn adapt_to_drift(plan: &mut EvasionPlan, drift: &FingerprintDrift) {
202    if drift.likely_blocked {
203        if !plan
204            .encoding_strategies
205            .contains(&encoding::Strategy::TripleUrlEncode)
206        {
207            plan.encoding_strategies
208                .push(encoding::Strategy::TripleUrlEncode);
209        }
210        if !plan
211            .encoding_strategies
212            .contains(&encoding::Strategy::OverlongUtf8)
213        {
214            plan.encoding_strategies
215                .push(encoding::Strategy::OverlongUtf8);
216        }
217        plan.use_grammar = true;
218        plan.use_smuggling = true;
219        plan.rationale.push(format!(
220            "response drift {:.0}% suggests blocking, escalating",
221            drift.score * 100.0
222        ));
223    }
224    if drift.changed.contains(&"body_length") && !drift.likely_blocked {
225        plan.use_content_type_switch = true;
226        plan.rationale
227            .push("body length drift without block: WAF may be modifying response".into());
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn default_plan_is_balanced() {
237        let plan = advise(None, None);
238        assert!(plan.use_grammar);
239        assert!(plan.use_header_obfuscation);
240        assert!(!plan.use_smuggling);
241        assert!(!plan.encoding_strategies.is_empty());
242    }
243
244    #[test]
245    fn cloudflare_avoids_smuggling() {
246        let waf = DetectedWaf {
247            name: "Cloudflare".into(),
248            confidence: 0.9,
249            indicators: vec!["cf-ray header".into()],
250        };
251        let plan = advise(Some(&waf), None);
252        assert!(!plan.use_smuggling);
253        assert!(plan.use_h2);
254        assert!(
255            plan.encoding_strategies
256                .contains(&encoding::Strategy::OverlongUtf8)
257        );
258    }
259
260    #[test]
261    fn case_insensitive_matching() {
262        let waf = DetectedWaf {
263            name: "cloudflare".into(),
264            confidence: 0.9,
265            indicators: vec![],
266        };
267        let plan = advise(Some(&waf), None);
268        assert!(!plan.use_smuggling);
269    }
270
271    #[test]
272    fn substring_matching() {
273        let waf = DetectedWaf {
274            name: "AWS WAF v2".into(),
275            confidence: 0.9,
276            indicators: vec![],
277        };
278        let plan = advise(Some(&waf), None);
279        assert!(plan.use_grammar);
280    }
281
282    #[test]
283    fn f5_enables_smuggling() {
284        let waf = DetectedWaf {
285            name: "F5 BIG-IP".into(),
286            confidence: 0.8,
287            indicators: vec!["server: bigip".into()],
288        };
289        let plan = advise(Some(&waf), None);
290        assert!(plan.use_smuggling);
291    }
292
293    #[test]
294    fn drift_escalates_encoding() {
295        let drift = FingerprintDrift {
296            score: 0.7,
297            changed: vec!["status_code", "body_content"],
298            likely_blocked: true,
299        };
300        let plan = advise(None, Some(&drift));
301        assert!(plan.use_grammar);
302        assert!(plan.use_smuggling);
303        assert!(
304            plan.encoding_strategies
305                .contains(&encoding::Strategy::TripleUrlEncode)
306        );
307    }
308
309    #[test]
310    fn unknown_waf_tries_everything() {
311        let waf = DetectedWaf {
312            name: "SomeNewWAF".into(),
313            confidence: 0.5,
314            indicators: vec!["unknown header".into()],
315        };
316        let plan = advise(Some(&waf), None);
317        assert!(plan.use_smuggling);
318        assert!(plan.use_h2);
319        assert!(plan.encoding_strategies.len() > 5);
320    }
321}