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).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#[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 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}