Skip to main content

wafrift_evolution/differential/
probe.rs

1// Probe data is community-contributed via
2// `crates/evolution/rules/probes/differential.toml` and compiled in by build.rs.
3// Adding a new SQL keyword / XSS tag / command name is a one-line PR with no Rust.
4include!(concat!(env!("OUT_DIR"), "/differential_data.rs"));
5
6/// A single probe in the differential analysis.
7#[derive(Debug, Clone, PartialEq)]
8pub struct Probe {
9    /// The probe payload to inject into a parameter.
10    pub payload: String,
11    /// What this probe is testing for.
12    pub tests: ProbeTarget,
13    /// Human-readable explanation.
14    pub description: String,
15    /// Whether this probe SHOULD be blocked by a well-configured WAF.
16    pub expected_blocked: bool,
17}
18
19/// What aspect of WAF detection a probe is testing.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum ProbeTarget {
22    /// Tests if the WAF blocks a specific SQL keyword.
23    SqlKeyword(String),
24    /// Tests if the WAF blocks SQL comparison operators.
25    SqlOperator(String),
26    /// Tests if the WAF blocks SQL comment syntax.
27    SqlComment(String),
28    /// Tests if the WAF blocks SQL string delimiters.
29    SqlQuote,
30    /// Tests if the WAF blocks a tautology pattern.
31    SqlTautology(String),
32    /// Tests if the WAF blocks XSS-related HTML tags.
33    XssTag(String),
34    /// Tests if the WAF blocks JavaScript event handlers.
35    XssEvent(String),
36    /// Tests if the WAF blocks JavaScript execution functions.
37    XssExecFunction(String),
38    /// Tests if the WAF blocks command injection separators.
39    CmdSeparator(String),
40    /// Tests if the WAF blocks specific shell commands.
41    CmdCommand(String),
42    /// Tests if the WAF blocks file path patterns.
43    CmdPath(String),
44    /// Baseline probe that should never be blocked.
45    Baseline,
46}
47
48/// Generate the full set of differential analysis probes.
49///
50/// # SAFETY / authorization contract
51///
52/// Probe payloads are NOT inert. They contain genuinely exploitable
53/// strings (`alert(1)`, `eval('x')`, `1=1`, `/etc/passwd`, `;`, `|`,
54/// `||`) — that is the point: a WAF that doesn't block them is the
55/// signal we're measuring. If the WAF fails to block AND the upstream
56/// application is vulnerable, the probe IS the attack. Inert marker
57/// strings (e.g. `wafrift_xss_probe_123`) don't trigger any WAF rule
58/// and would defeat the purpose of differential probing.
59///
60/// **Caller responsibility.** Only call this against:
61///   1. A WAF in front of a known-non-vulnerable backend you control
62///      (the bench target's `kennethreitz/httpbin` fits — it
63///      doesn't actually run JS, exec sh, or query SQL), OR
64///   2. A target you have explicit written authorization to attack.
65///
66/// Wafrift cannot enforce this; the operator must.
67#[must_use]
68pub fn generate_probes() -> Vec<Probe> {
69    let mut probes = Vec::new();
70    probes.push(baseline_probe(BASELINE_PAYLOAD, BASELINE_DESCRIPTION));
71    probes.extend(sql_keyword_probes());
72    probes.extend(sql_operator_probes());
73    probes.extend(sql_comment_probes());
74    probes.push(Probe {
75        payload: "'".into(),
76        tests: ProbeTarget::SqlQuote,
77        description: "SQL single quote".into(),
78        expected_blocked: true,
79    });
80    probes.extend(sql_tautology_probes());
81    probes.extend(xss_tag_probes());
82    probes.extend(xss_event_probes());
83    probes.extend(xss_function_probes());
84    probes.extend(command_separator_probes());
85    probes.extend(command_name_probes());
86    probes.extend(command_path_probes());
87    probes
88}
89
90pub(crate) fn baseline_probe(payload: &str, description: &str) -> Probe {
91    Probe {
92        payload: payload.into(),
93        tests: ProbeTarget::Baseline,
94        description: description.into(),
95        expected_blocked: false,
96    }
97}
98
99pub(crate) fn sql_keyword_probes() -> Vec<Probe> {
100    SQL_KEYWORDS
101        .iter()
102        .map(|&keyword| Probe {
103            payload: format!("test {keyword} value"),
104            tests: ProbeTarget::SqlKeyword(keyword.to_string()),
105            description: format!("SQL keyword: {keyword}"),
106            expected_blocked: true,
107        })
108        .collect()
109}
110
111pub(crate) fn sql_operator_probes() -> Vec<Probe> {
112    SQL_OPERATORS
113        .iter()
114        .map(|&operator| Probe {
115            payload: format!("test{operator}test"),
116            tests: ProbeTarget::SqlOperator(operator.to_string()),
117            description: format!("SQL operator: {operator}"),
118            expected_blocked: true,
119        })
120        .collect()
121}
122
123pub(crate) fn sql_comment_probes() -> Vec<Probe> {
124    SQL_COMMENTS
125        .iter()
126        .map(|&comment| Probe {
127            payload: format!("test{comment}test"),
128            tests: ProbeTarget::SqlComment(comment.to_string()),
129            description: format!("SQL comment: {comment}"),
130            expected_blocked: true,
131        })
132        .collect()
133}
134
135pub(crate) fn sql_tautology_probes() -> Vec<Probe> {
136    SQL_TAUTOLOGIES
137        .iter()
138        .map(|&tautology| Probe {
139            payload: tautology.to_string(),
140            tests: ProbeTarget::SqlTautology(tautology.to_string()),
141            description: format!("SQL tautology: {tautology}"),
142            expected_blocked: true,
143        })
144        .collect()
145}
146
147pub(crate) fn xss_tag_probes() -> Vec<Probe> {
148    XSS_TAGS
149        .iter()
150        .map(|&(name, payload, expected_blocked)| Probe {
151            payload: payload.into(),
152            tests: ProbeTarget::XssTag(name.into()),
153            description: format!("XSS tag: {name}"),
154            expected_blocked,
155        })
156        .collect()
157}
158
159pub(crate) fn xss_event_probes() -> Vec<Probe> {
160    XSS_EVENTS
161        .iter()
162        .map(|&event| Probe {
163            payload: format!("<x {event}=1>"),
164            tests: ProbeTarget::XssEvent(event.to_string()),
165            description: format!("XSS event: {event}"),
166            expected_blocked: true,
167        })
168        .collect()
169}
170
171pub(crate) fn xss_function_probes() -> Vec<Probe> {
172    XSS_FUNCTIONS
173        .iter()
174        .map(|&(name, payload, expected_blocked)| Probe {
175            payload: payload.into(),
176            tests: ProbeTarget::XssExecFunction(name.into()),
177            description: format!("XSS function: {name}"),
178            expected_blocked,
179        })
180        .collect()
181}
182
183pub(crate) fn command_separator_probes() -> Vec<Probe> {
184    COMMAND_SEPARATORS
185        .iter()
186        .map(|&separator| Probe {
187            payload: format!("test{separator}test"),
188            tests: ProbeTarget::CmdSeparator(separator.to_string()),
189            description: format!("CMD separator: {separator}"),
190            expected_blocked: true,
191        })
192        .collect()
193}
194
195pub(crate) fn command_name_probes() -> Vec<Probe> {
196    COMMAND_NAMES
197        .iter()
198        .map(|&command| Probe {
199            payload: command.to_string(),
200            tests: ProbeTarget::CmdCommand(command.to_string()),
201            description: format!("CMD command: {command}"),
202            expected_blocked: false,
203        })
204        .collect()
205}
206
207pub(crate) fn command_path_probes() -> Vec<Probe> {
208    COMMAND_PATHS
209        .iter()
210        .map(|&path| Probe {
211            payload: path.to_string(),
212            tests: ProbeTarget::CmdPath(path.to_string()),
213            description: format!("CMD path: {path}"),
214            expected_blocked: true,
215        })
216        .collect()
217}
218
219#[cfg(test)]
220mod tests {
221    use super::{ProbeTarget, generate_probes};
222
223    #[test]
224    fn generate_probes_has_baseline() {
225        let probes = generate_probes();
226        assert!(
227            probes
228                .iter()
229                .any(|probe| probe.tests == ProbeTarget::Baseline)
230        );
231    }
232
233    #[test]
234    fn generate_probes_covers_all_categories() {
235        let probes = generate_probes();
236        assert!(
237            probes
238                .iter()
239                .any(|probe| matches!(probe.tests, ProbeTarget::SqlKeyword(_)))
240        );
241        assert!(
242            probes
243                .iter()
244                .any(|probe| matches!(probe.tests, ProbeTarget::SqlOperator(_)))
245        );
246        assert!(
247            probes
248                .iter()
249                .any(|probe| matches!(probe.tests, ProbeTarget::SqlComment(_)))
250        );
251        assert!(
252            probes
253                .iter()
254                .any(|probe| matches!(probe.tests, ProbeTarget::SqlQuote))
255        );
256        assert!(
257            probes
258                .iter()
259                .any(|probe| matches!(probe.tests, ProbeTarget::SqlTautology(_)))
260        );
261        assert!(
262            probes
263                .iter()
264                .any(|probe| matches!(probe.tests, ProbeTarget::XssTag(_)))
265        );
266        assert!(
267            probes
268                .iter()
269                .any(|probe| matches!(probe.tests, ProbeTarget::XssEvent(_)))
270        );
271        assert!(
272            probes
273                .iter()
274                .any(|probe| matches!(probe.tests, ProbeTarget::XssExecFunction(_)))
275        );
276        assert!(
277            probes
278                .iter()
279                .any(|probe| matches!(probe.tests, ProbeTarget::CmdSeparator(_)))
280        );
281        assert!(
282            probes
283                .iter()
284                .any(|probe| matches!(probe.tests, ProbeTarget::CmdCommand(_)))
285        );
286        assert!(
287            probes
288                .iter()
289                .any(|probe| matches!(probe.tests, ProbeTarget::CmdPath(_)))
290        );
291    }
292
293    #[test]
294    fn generate_probes_has_many() {
295        let probes = generate_probes();
296        assert!(
297            probes.len() >= 60,
298            "expected 60+ probes, got {}",
299            probes.len()
300        );
301    }
302
303    #[test]
304    fn probes_have_descriptions() {
305        let probes = generate_probes();
306        for probe in &probes {
307            assert!(
308                !probe.description.is_empty(),
309                "probe should have description"
310            );
311            assert!(
312                !probe.payload.is_empty() || probe.tests == ProbeTarget::Baseline,
313                "probe should have payload"
314            );
315        }
316    }
317
318    #[test]
319    fn sql_quote_expected_blocked() {
320        let probes = generate_probes();
321        let quote = probes
322            .iter()
323            .find(|p| matches!(p.tests, ProbeTarget::SqlQuote));
324        assert!(quote.is_some());
325        assert!(
326            quote.unwrap().expected_blocked,
327            "SQL quote should be expected blocked"
328        );
329    }
330}