Skip to main content

killer/klr/
runner.rs

1//! The test runner: expands attacks (checks + fuzz mutations) into concrete
2//! tests and executes them, optionally across a pool of worker threads.
3
4use std::thread;
5
6use crate::attacks::http::HttpClient;
7use crate::klr::ast::{Attack, Expectation, Value};
8use crate::klr::interpreter::{Interpreter, RunConfig};
9use crate::results::AttackOutcome;
10
11/// Expand a list of attacks into concrete tests: `check` clauses become
12/// expectations, and each `mutate` generator becomes one or more variants.
13pub fn expand_all(attacks: &[Attack]) -> Vec<Attack> {
14    attacks.iter().flat_map(expand_attack).collect()
15}
16
17/// Expand a single attack into one or more concrete tests.
18pub fn expand_attack(attack: &Attack) -> Vec<Attack> {
19    // 1. Fold `check <name>` clauses into expectations.
20    let mut base = attack.clone();
21    for c in &attack.checks {
22        base.expectations.extend(check_expectations(c));
23    }
24    base.checks.clear();
25
26    // 2. Expand `mutate <field> { generators }` into variants.
27    if base.mutations.is_empty() {
28        return vec![base];
29    }
30    let mutations = std::mem::take(&mut base.mutations);
31    let mut variants = Vec::new();
32    for m in &mutations {
33        for generator in &m.generators {
34            let values = crate::fuzz::generate(generator);
35            let multi = values.len() > 1;
36            for (i, val) in values.into_iter().enumerate() {
37                let mut v = base.clone();
38                set_send_field(&mut v.send, &m.field, &val);
39                let label = if multi {
40                    format!("{generator}#{}", i + 1)
41                } else {
42                    generator.clone()
43                };
44                v.name = format!("{} [{}={}]", base.name, m.field, label);
45                variants.push(v);
46            }
47        }
48    }
49    if variants.is_empty() {
50        vec![base]
51    } else {
52        variants
53    }
54}
55
56/// Run all attacks (after expansion) and return their outcomes in order.
57///
58/// `workers` > 1 splits the work across scoped threads; the client must be
59/// `Sync` (the built-in [`crate::attacks::http::StdHttpClient`] is).
60pub fn run_all<C: HttpClient + Sync>(
61    attacks: &[Attack],
62    client: &C,
63    config: &RunConfig,
64    workers: usize,
65) -> Vec<AttackOutcome> {
66    let expanded = expand_all(attacks);
67
68    if workers <= 1 || expanded.len() <= 1 {
69        let interp = Interpreter::new(client, config.clone());
70        return interp.run(&expanded);
71    }
72
73    let worker_count = workers.min(expanded.len());
74    let chunks = split_indexed(&expanded, worker_count);
75
76    let mut indexed: Vec<(usize, AttackOutcome)> = thread::scope(|scope| {
77        let handles: Vec<_> = chunks
78            .into_iter()
79            .map(|chunk| {
80                scope.spawn(move || {
81                    let interp = Interpreter::new(client, config.clone());
82                    chunk
83                        .into_iter()
84                        .map(|(idx, attack)| {
85                            (idx, interp.run(std::slice::from_ref(attack)).remove(0))
86                        })
87                        .collect::<Vec<_>>()
88                })
89            })
90            .collect();
91
92        handles
93            .into_iter()
94            .flat_map(|h| h.join().expect("worker thread panicked"))
95            .collect()
96    });
97
98    indexed.sort_by_key(|(i, _)| *i);
99    indexed.into_iter().map(|(_, o)| o).collect()
100}
101
102/// Split items into `n` contiguous chunks, each carrying original indices.
103fn split_indexed(items: &[Attack], n: usize) -> Vec<Vec<(usize, &Attack)>> {
104    let n = n.max(1);
105    let mut chunks: Vec<Vec<(usize, &Attack)>> = vec![Vec::new(); n];
106    for (i, item) in items.iter().enumerate() {
107        chunks[i % n].push((i, item));
108    }
109    chunks
110}
111
112/// Map a `check <name>` clause to the expectations it implies.
113fn check_expectations(name: &str) -> Vec<Expectation> {
114    match name.to_ascii_lowercase().as_str() {
115        "authentication" | "auth" => vec![Expectation::Named {
116            name: "requires_auth".to_string(),
117            expected: true,
118        }],
119        "rate_limit" | "rate_limiting" | "ratelimit" => vec![Expectation::BlockedAfter(50)],
120        "injection" | "sql_injection" | "sqli" => vec![Expectation::Named {
121            name: "no_sql_error".to_string(),
122            expected: true,
123        }],
124        // Unknown checks still become a named expectation so the interpreter can
125        // report them as unevaluated. It never invents a passing result for one.
126        other => vec![Expectation::Named {
127            name: other.to_string(),
128            expected: true,
129        }],
130    }
131}
132
133/// Set (or insert) a `send` field to a string value.
134fn set_send_field(send: &mut Vec<(String, Value)>, field: &str, value: &str) {
135    let v = Value::Str(value.to_string());
136    if let Some(entry) = send.iter_mut().find(|(k, _)| k == field) {
137        entry.1 = v;
138    } else {
139        send.push((field.to_string(), v));
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::klr::parser::parse;
147
148    #[test]
149    fn mutation_expands_into_variants() {
150        let src = r#"
151attack duplicate_payment {
152    request POST "/payment"
153    send { amount = 100 }
154    mutate amount {
155        negative_numbers
156        huge_values
157        decimals
158    }
159}
160"#;
161        let program = parse(src).unwrap();
162        let expanded = expand_all(&program.attacks);
163        // 2 negatives + 2 huge + 2 decimals = 6 variants.
164        assert_eq!(expanded.len(), 6);
165        // Each variant mutates the `amount` field.
166        for v in &expanded {
167            let amount = v.send.iter().find(|(k, _)| k == "amount").unwrap();
168            assert!(matches!(amount.1, Value::Str(_)));
169            assert!(v.name.contains("amount="));
170        }
171    }
172
173    #[test]
174    fn check_becomes_expectation() {
175        let src = r#"
176test login_security {
177    endpoint "/login"
178    check authentication
179}
180"#;
181        let program = parse(src).unwrap();
182        let expanded = expand_all(&program.attacks);
183        assert_eq!(expanded.len(), 1);
184        assert!(expanded[0].checks.is_empty());
185        assert_eq!(
186            expanded[0].expectations,
187            vec![Expectation::Named {
188                name: "requires_auth".to_string(),
189                expected: true
190            }]
191        );
192    }
193
194    #[test]
195    fn repeat_block_multiplies_request_count() {
196        let src = r#"
197repeat 100 {
198    attack login {
199        target "/login"
200    }
201}
202"#;
203        let program = parse(src).unwrap();
204        assert_eq!(program.attacks.len(), 1);
205        assert_eq!(program.attacks[0].repeat, Some(100));
206    }
207
208    #[test]
209    fn unknown_check_expands_to_a_named_expectation() {
210        // `check csrf` has no built-in expansion. It must still reach the
211        // interpreter under its own name so it can be reported as unevaluated,
212        // rather than being dropped (which would leave the attack assertion-free).
213        let src = r#"
214test csrf_protection {
215    endpoint "/transfer"
216    check csrf
217}
218"#;
219        let program = parse(src).unwrap();
220        let expanded = expand_all(&program.attacks);
221        assert_eq!(
222            expanded[0].expectations,
223            vec![Expectation::Named {
224                name: "csrf".to_string(),
225                expected: true
226            }]
227        );
228    }
229
230    #[test]
231    fn split_covers_all_items_once() {
232        // Build 10 trivial attacks and ensure chunking preserves every index.
233        let attacks: Vec<Attack> = (0..10).map(|i| Attack::empty(format!("a{i}"), 1)).collect();
234        let chunks = split_indexed(&attacks, 3);
235        let mut seen: Vec<usize> = chunks
236            .iter()
237            .flat_map(|c| c.iter().map(|(i, _)| *i))
238            .collect();
239        seen.sort();
240        assert_eq!(seen, (0..10).collect::<Vec<_>>());
241    }
242}