Skip to main content

wafrift_evolution/differential/
probe.rs

1/// A single probe in the differential analysis.
2#[derive(Debug, Clone, PartialEq)]
3pub struct Probe {
4    /// The probe payload to inject into a parameter.
5    pub payload: String,
6    /// What this probe is testing for.
7    pub tests: ProbeTarget,
8    /// Human-readable explanation.
9    pub description: String,
10    /// Whether this probe SHOULD be blocked by a well-configured WAF.
11    pub expected_blocked: bool,
12}
13
14/// What aspect of WAF detection a probe is testing.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ProbeTarget {
17    /// Tests if the WAF blocks a specific SQL keyword.
18    SqlKeyword(String),
19    /// Tests if the WAF blocks SQL comparison operators.
20    SqlOperator(String),
21    /// Tests if the WAF blocks SQL comment syntax.
22    SqlComment(String),
23    /// Tests if the WAF blocks SQL string delimiters.
24    SqlQuote,
25    /// Tests if the WAF blocks a tautology pattern.
26    SqlTautology(String),
27    /// Tests if the WAF blocks XSS-related HTML tags.
28    XssTag(String),
29    /// Tests if the WAF blocks JavaScript event handlers.
30    XssEvent(String),
31    /// Tests if the WAF blocks JavaScript execution functions.
32    XssExecFunction(String),
33    /// Tests if the WAF blocks command injection separators.
34    CmdSeparator(String),
35    /// Tests if the WAF blocks specific shell commands.
36    CmdCommand(String),
37    /// Tests if the WAF blocks file path patterns.
38    CmdPath(String),
39    /// Baseline probe that should never be blocked.
40    Baseline,
41}
42
43/// Generate the full set of differential analysis probes.
44#[must_use]
45pub fn generate_probes() -> Vec<Probe> {
46    let mut probes = Vec::new();
47    probes.push(baseline_probe("test_value_12345", "baseline benign value"));
48    probes.extend(sql_keyword_probes());
49    probes.extend(sql_operator_probes());
50    probes.extend(sql_comment_probes());
51    probes.push(Probe {
52        payload: "'".into(),
53        tests: ProbeTarget::SqlQuote,
54        description: "SQL single quote".into(),
55        expected_blocked: true,
56    });
57    probes.extend(sql_tautology_probes());
58    probes.extend(xss_tag_probes());
59    probes.extend(xss_event_probes());
60    probes.extend(xss_function_probes());
61    probes.extend(command_separator_probes());
62    probes.extend(command_name_probes());
63    probes.extend(command_path_probes());
64    probes
65}
66
67pub(crate) fn baseline_probe(payload: &str, description: &str) -> Probe {
68    Probe {
69        payload: payload.into(),
70        tests: ProbeTarget::Baseline,
71        description: description.into(),
72        expected_blocked: false,
73    }
74}
75
76pub(crate) fn sql_keyword_probes() -> Vec<Probe> {
77    build_probes(
78        &[
79            "SELECT",
80            "UNION",
81            "INSERT",
82            "UPDATE",
83            "DELETE",
84            "DROP",
85            "FROM",
86            "WHERE",
87            "ORDER BY",
88            "GROUP BY",
89            "HAVING",
90            "SLEEP",
91            "BENCHMARK",
92            "WAITFOR",
93        ],
94        |keyword| Probe {
95            payload: format!("test {keyword} value"),
96            tests: ProbeTarget::SqlKeyword(keyword.to_string()),
97            description: format!("SQL keyword: {keyword}"),
98            expected_blocked: true,
99        },
100    )
101}
102
103pub(crate) fn sql_operator_probes() -> Vec<Probe> {
104    build_probes(
105        &[
106            "=", "!=", "<>", "LIKE", "IN(", "BETWEEN", "IS NULL", "REGEXP",
107        ],
108        |operator| Probe {
109            payload: format!("test{operator}test"),
110            tests: ProbeTarget::SqlOperator(operator.to_string()),
111            description: format!("SQL operator: {operator}"),
112            expected_blocked: true,
113        },
114    )
115}
116
117pub(crate) fn sql_comment_probes() -> Vec<Probe> {
118    build_probes(&["--", "#", "/***/", "-- -", "--+"], |comment| Probe {
119        payload: format!("test{comment}test"),
120        tests: ProbeTarget::SqlComment(comment.to_string()),
121        description: format!("SQL comment: {comment}"),
122        expected_blocked: true,
123    })
124}
125
126pub(crate) fn sql_tautology_probes() -> Vec<Probe> {
127    build_probes(
128        &[
129            "1=1",
130            "1 LIKE 1",
131            "'a'='a'",
132            "1 BETWEEN 0 AND 2",
133            "1 IN(1)",
134            "true",
135        ],
136        |tautology| Probe {
137            payload: tautology.to_string(),
138            tests: ProbeTarget::SqlTautology(tautology.to_string()),
139            description: format!("SQL tautology: {tautology}"),
140            expected_blocked: true,
141        },
142    )
143}
144
145pub(crate) fn xss_tag_probes() -> Vec<Probe> {
146    [
147        ("script", "<script>", true),
148        ("img", "<img src=x>", false),
149        ("svg", "<svg>", false),
150        ("iframe", "<iframe>", true),
151        ("body", "<body>", false),
152        ("details", "<details>", false),
153        ("input", "<input>", false),
154        ("marquee", "<marquee>", false),
155        ("video", "<video>", false),
156        ("object", "<object>", false),
157        ("math", "<math>", false),
158        ("style", "<style>", false),
159    ]
160    .into_iter()
161    .map(|(name, payload, expected_blocked)| Probe {
162        payload: payload.into(),
163        tests: ProbeTarget::XssTag(name.into()),
164        description: format!("XSS tag: {name}"),
165        expected_blocked,
166    })
167    .collect()
168}
169
170pub(crate) fn xss_event_probes() -> Vec<Probe> {
171    build_probes(
172        &[
173            "onerror",
174            "onload",
175            "onclick",
176            "onfocus",
177            "onmouseover",
178            "ontoggle",
179            "onbegin",
180            "onstart",
181            "onsubmit",
182        ],
183        |event| Probe {
184            payload: format!("<x {event}=1>"),
185            tests: ProbeTarget::XssEvent(event.to_string()),
186            description: format!("XSS event: {event}"),
187            expected_blocked: true,
188        },
189    )
190}
191
192pub(crate) fn xss_function_probes() -> Vec<Probe> {
193    [
194        ("alert", "alert(1)", true),
195        ("confirm", "confirm(1)", false),
196        ("prompt", "prompt(1)", false),
197        ("eval", "eval('x')", true),
198        ("Function", "Function('x')()", false),
199        ("constructor", "[].constructor.constructor('x')()", false),
200        ("setTimeout", "setTimeout('x')", false),
201    ]
202    .into_iter()
203    .map(|(name, payload, expected_blocked)| Probe {
204        payload: payload.into(),
205        tests: ProbeTarget::XssExecFunction(name.into()),
206        description: format!("XSS function: {name}"),
207        expected_blocked,
208    })
209    .collect()
210}
211
212pub(crate) fn command_separator_probes() -> Vec<Probe> {
213    build_probes(&[";", "|", "||", "&&", "`", "$("], |separator| Probe {
214        payload: format!("test{separator}test"),
215        tests: ProbeTarget::CmdSeparator(separator.to_string()),
216        description: format!("CMD separator: {separator}"),
217        expected_blocked: true,
218    })
219}
220
221pub(crate) fn command_name_probes() -> Vec<Probe> {
222    build_probes(
223        &["cat", "ls", "id", "whoami", "wget", "curl", "ping", "nc"],
224        |command| Probe {
225            payload: command.to_string(),
226            tests: ProbeTarget::CmdCommand(command.to_string()),
227            description: format!("CMD command: {command}"),
228            expected_blocked: false,
229        },
230    )
231}
232
233pub(crate) fn command_path_probes() -> Vec<Probe> {
234    build_probes(
235        &[
236            "/etc/passwd",
237            "/etc/shadow",
238            "/proc/self/environ",
239            "/bin/sh",
240        ],
241        |path| Probe {
242            payload: path.to_string(),
243            tests: ProbeTarget::CmdPath(path.to_string()),
244            description: format!("CMD path: {path}"),
245            expected_blocked: true,
246        },
247    )
248}
249
250fn build_probes<T, F>(items: &[T], builder: F) -> Vec<Probe>
251where
252    T: Copy,
253    F: Fn(T) -> Probe,
254{
255    items.iter().copied().map(builder).collect()
256}
257
258#[cfg(test)]
259mod tests {
260    use super::{ProbeTarget, generate_probes};
261
262    #[test]
263    fn generate_probes_has_baseline() {
264        let probes = generate_probes();
265        assert!(
266            probes
267                .iter()
268                .any(|probe| probe.tests == ProbeTarget::Baseline)
269        );
270    }
271
272    #[test]
273    fn generate_probes_covers_all_categories() {
274        let probes = generate_probes();
275        assert!(
276            probes
277                .iter()
278                .any(|probe| matches!(probe.tests, ProbeTarget::SqlKeyword(_)))
279        );
280        assert!(
281            probes
282                .iter()
283                .any(|probe| matches!(probe.tests, ProbeTarget::SqlOperator(_)))
284        );
285        assert!(
286            probes
287                .iter()
288                .any(|probe| matches!(probe.tests, ProbeTarget::SqlComment(_)))
289        );
290        assert!(
291            probes
292                .iter()
293                .any(|probe| matches!(probe.tests, ProbeTarget::SqlQuote))
294        );
295        assert!(
296            probes
297                .iter()
298                .any(|probe| matches!(probe.tests, ProbeTarget::SqlTautology(_)))
299        );
300        assert!(
301            probes
302                .iter()
303                .any(|probe| matches!(probe.tests, ProbeTarget::XssTag(_)))
304        );
305        assert!(
306            probes
307                .iter()
308                .any(|probe| matches!(probe.tests, ProbeTarget::XssEvent(_)))
309        );
310        assert!(
311            probes
312                .iter()
313                .any(|probe| matches!(probe.tests, ProbeTarget::XssExecFunction(_)))
314        );
315        assert!(
316            probes
317                .iter()
318                .any(|probe| matches!(probe.tests, ProbeTarget::CmdSeparator(_)))
319        );
320        assert!(
321            probes
322                .iter()
323                .any(|probe| matches!(probe.tests, ProbeTarget::CmdCommand(_)))
324        );
325        assert!(
326            probes
327                .iter()
328                .any(|probe| matches!(probe.tests, ProbeTarget::CmdPath(_)))
329        );
330    }
331
332    #[test]
333    fn generate_probes_has_many() {
334        let probes = generate_probes();
335        assert!(
336            probes.len() >= 60,
337            "expected 60+ probes, got {}",
338            probes.len()
339        );
340    }
341
342    #[test]
343    fn probes_have_descriptions() {
344        let probes = generate_probes();
345        for probe in &probes {
346            assert!(
347                !probe.description.is_empty(),
348                "probe should have description"
349            );
350            assert!(
351                !probe.payload.is_empty() || probe.tests == ProbeTarget::Baseline,
352                "probe should have payload"
353            );
354        }
355    }
356
357    #[test]
358    fn sql_quote_expected_blocked() {
359        let probes = generate_probes();
360        let quote = probes
361            .iter()
362            .find(|p| matches!(p.tests, ProbeTarget::SqlQuote));
363        assert!(quote.is_some());
364        assert!(
365            quote.unwrap().expected_blocked,
366            "SQL quote should be expected blocked"
367        );
368    }
369}