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).unwrap_or_else(|e| {
123        tracing::warn!(error = %e, "embedded advisor TOML failed to parse; returning empty rules");
124        AdvisorRules { waf: Vec::new() }
125    })
126}
127
128fn match_waf(name: &str, rules: &AdvisorRules) -> Option<WafAdviceRule> {
129    let lower = name.to_lowercase();
130    for rule in &rules.waf {
131        if rule.name.to_lowercase() == lower {
132            return Some(rule.clone());
133        }
134        for alias in &rule.aliases {
135            if alias.to_lowercase() == lower || lower.contains(&alias.to_lowercase()) {
136                return Some(rule.clone());
137            }
138        }
139        if lower.contains(&rule.name.to_lowercase()) {
140            return Some(rule.clone());
141        }
142    }
143    None
144}
145
146/// Generate an evasion plan based on detected WAF.
147#[must_use]
148pub fn advise(waf: Option<&DetectedWaf>, drift: Option<&FingerprintDrift>) -> EvasionPlan {
149    let mut plan = default_plan();
150    let rules = load_default_rules();
151
152    if let Some(detected) = waf {
153        if let Some(rule) = match_waf(&detected.name, &rules) {
154            apply_rule(&mut plan, &rule);
155        } else {
156            // Unknown WAF: be aggressive
157            plan.encoding_strategies = encoding::all_strategies().to_vec();
158            plan.use_smuggling = true;
159            plan.use_h2 = true;
160            plan.rationale.push(format!(
161                "unknown WAF '{}': trying all techniques",
162                detected.name
163            ));
164        }
165    }
166
167    if let Some(d) = drift {
168        adapt_to_drift(&mut plan, d);
169    }
170
171    plan
172}
173
174fn apply_rule(plan: &mut EvasionPlan, rule: &WafAdviceRule) {
175    plan.encoding_strategies = rule
176        .encoding_strategies
177        .iter()
178        .filter_map(|s| parse_strategy(s))
179        .collect();
180    plan.use_grammar = rule.use_grammar;
181    plan.use_header_obfuscation = rule.use_header_obfuscation;
182    plan.use_content_type_switch = rule.use_content_type_switch;
183    plan.use_smuggling = rule.use_smuggling;
184    plan.use_h2 = rule.use_h2;
185    plan.rationale.push(rule.rationale.clone());
186}
187
188fn default_plan() -> EvasionPlan {
189    EvasionPlan {
190        encoding_strategies: vec![
191            encoding::Strategy::DoubleUrlEncode,
192            encoding::Strategy::UnicodeEncode,
193            encoding::Strategy::CaseAlternation,
194        ],
195        use_grammar: true,
196        use_header_obfuscation: true,
197        use_content_type_switch: true,
198        use_smuggling: false,
199        use_h2: false,
200        rationale: vec!["no WAF detected, using balanced defaults".into()],
201    }
202}
203
204fn adapt_to_drift(plan: &mut EvasionPlan, drift: &FingerprintDrift) {
205    if drift.likely_blocked {
206        if !plan
207            .encoding_strategies
208            .contains(&encoding::Strategy::TripleUrlEncode)
209        {
210            plan.encoding_strategies
211                .push(encoding::Strategy::TripleUrlEncode);
212        }
213        if !plan
214            .encoding_strategies
215            .contains(&encoding::Strategy::OverlongUtf8)
216        {
217            plan.encoding_strategies
218                .push(encoding::Strategy::OverlongUtf8);
219        }
220        plan.use_grammar = true;
221        plan.use_smuggling = true;
222        plan.rationale.push(format!(
223            "response drift {:.0}% suggests blocking, escalating",
224            drift.score * 100.0
225        ));
226    }
227    if drift.changed.contains(&"body_length") && !drift.likely_blocked {
228        plan.use_content_type_switch = true;
229        plan.rationale
230            .push("body length drift without block: WAF may be modifying response".into());
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn default_plan_is_balanced() {
240        let plan = advise(None, None);
241        assert!(plan.use_grammar);
242        assert!(plan.use_header_obfuscation);
243        assert!(!plan.use_smuggling);
244        assert!(!plan.encoding_strategies.is_empty());
245    }
246
247    #[test]
248    fn cloudflare_avoids_smuggling() {
249        let waf = DetectedWaf {
250            name: "Cloudflare".into(),
251            confidence: 0.9,
252            indicators: vec!["cf-ray header".into()],
253        };
254        let plan = advise(Some(&waf), None);
255        assert!(!plan.use_smuggling);
256        assert!(plan.use_h2);
257        assert!(
258            plan.encoding_strategies
259                .contains(&encoding::Strategy::OverlongUtf8)
260        );
261    }
262
263    #[test]
264    fn case_insensitive_matching() {
265        let waf = DetectedWaf {
266            name: "cloudflare".into(),
267            confidence: 0.9,
268            indicators: vec![],
269        };
270        let plan = advise(Some(&waf), None);
271        assert!(!plan.use_smuggling);
272    }
273
274    #[test]
275    fn substring_matching() {
276        let waf = DetectedWaf {
277            name: "AWS WAF v2".into(),
278            confidence: 0.9,
279            indicators: vec![],
280        };
281        let plan = advise(Some(&waf), None);
282        assert!(plan.use_grammar);
283    }
284
285    #[test]
286    fn f5_enables_smuggling() {
287        let waf = DetectedWaf {
288            name: "F5 BIG-IP".into(),
289            confidence: 0.8,
290            indicators: vec!["server: bigip".into()],
291        };
292        let plan = advise(Some(&waf), None);
293        assert!(plan.use_smuggling);
294    }
295
296    #[test]
297    fn drift_escalates_encoding() {
298        let drift = FingerprintDrift {
299            score: 0.7,
300            changed: vec!["status_code", "body_content"],
301            likely_blocked: true,
302        };
303        let plan = advise(None, Some(&drift));
304        assert!(plan.use_grammar);
305        assert!(plan.use_smuggling);
306        assert!(
307            plan.encoding_strategies
308                .contains(&encoding::Strategy::TripleUrlEncode)
309        );
310    }
311
312    #[test]
313    fn unknown_waf_tries_everything() {
314        let waf = DetectedWaf {
315            name: "SomeNewWAF".into(),
316            confidence: 0.5,
317            indicators: vec!["unknown header".into()],
318        };
319        let plan = advise(Some(&waf), None);
320        assert!(plan.use_smuggling);
321        assert!(plan.use_h2);
322        assert!(plan.encoding_strategies.len() > 5);
323    }
324}