1use serde::Deserialize;
7use wafrift_detect::response_fingerprint::FingerprintDrift;
8use wafrift_detect::waf_detect::DetectedWaf;
9use wafrift_encoding::encoding;
10
11#[derive(Debug, Clone, Default)]
13pub struct EvasionPlan {
14 pub encoding_strategies: Vec<encoding::Strategy>,
16 pub use_grammar: bool,
18 pub use_header_obfuscation: bool,
20 pub use_content_type_switch: bool,
22 pub use_smuggling: bool,
24 pub use_h2: bool,
26 pub rationale: Vec<String>,
28}
29
30#[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, "OverlongUtf8" => Some(encoding::Strategy::OverlongUtf8),
115 "ChunkedSplit" => Some(encoding::Strategy::ChunkedSplit),
116 "ParameterPollution" => None, _ => 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#[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 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}