Skip to main content

wafrift_evolution/
intelligence.rs

1//! Intelligence loop — connects differential analysis, evolution, and strategy.
2
3use crate::differential::{DifferentialResult, Probe, generate_probes, generate_quick_probes};
4use crate::evolution::{Chromosome, EvolutionEngine};
5use crate::types::{Budget, Feedback, LoopAction, OracleVerdict, TerminationReason};
6
7/// Scanner state machine phases.
8#[derive(Debug, Clone, PartialEq, Eq)]
9enum Phase {
10    DifferentialProbing,
11    Evolution,
12    Done,
13}
14
15/// Intelligence loop connecting differential analysis with evolutionary tuning.
16#[derive(Debug, Clone)]
17pub struct IntelligenceLoop {
18    differential: DifferentialResult,
19    evolution: EvolutionEngine,
20    probes_completed: usize,
21    feedback_count: usize,
22    phase: Phase,
23    min_probes: usize,
24    probe_queue: Vec<Probe>,
25    eval_queue: Vec<(usize, Chromosome)>,
26    budget: Budget,
27}
28
29impl IntelligenceLoop {
30    /// Create a new intelligence loop with the given evolution population size.
31    #[must_use]
32    pub fn new(population_size: usize) -> Self {
33        Self::with_budget(population_size, 10, Budget::default())
34    }
35
36    /// Create with configurable minimum probes and budget.
37    #[must_use]
38    pub fn with_budget(population_size: usize, min_probes: usize, budget: Budget) -> Self {
39        let mut evolution = EvolutionEngine::new(population_size);
40        evolution.budget = budget;
41        Self {
42            differential: DifferentialResult::new(),
43            evolution,
44            probes_completed: 0,
45            feedback_count: 0,
46            phase: Phase::DifferentialProbing,
47            min_probes,
48            probe_queue: generate_probes(),
49            eval_queue: Vec::new(),
50            budget,
51        }
52    }
53
54    /// Generate the full set of differential analysis probes, respecting budget.
55    #[must_use]
56    pub fn generate_probes(&self) -> Vec<Probe> {
57        if self.probe_queue.len()
58            > self
59                .budget
60                .max_requests
61                .saturating_sub(self.probes_completed)
62        {
63            generate_quick_probes()
64        } else {
65            generate_probes()
66        }
67    }
68
69    /// Generate a minimal probe set for quick analysis.
70    #[must_use]
71    pub fn generate_quick_probes(&self) -> Vec<Probe> {
72        generate_quick_probes()
73    }
74
75    /// Record a differential probe result.
76    pub fn record_probe(&mut self, probe: &Probe, was_blocked: bool) {
77        self.differential.record(probe, was_blocked);
78        self.probes_completed += 1;
79    }
80
81    /// Get the differential analysis results.
82    #[must_use]
83    pub fn differential_results(&self) -> &DifferentialResult {
84        &self.differential
85    }
86
87    /// Get recommended evasion strategies based on differential analysis.
88    #[must_use]
89    pub fn suggested_evasions(&self) -> Vec<String> {
90        self.differential.suggest_evasions()
91    }
92
93    /// Get a human-readable report of what the WAF blocks.
94    #[must_use]
95    pub fn waf_report(&self) -> String {
96        self.differential.report()
97    }
98
99    /// Get the next technique combination to try from the evolution engine.
100    #[must_use]
101    pub fn next_candidate(&mut self) -> Option<(usize, &Chromosome)> {
102        self.evolution.next_candidate()
103    }
104
105    /// Request a batch of evolved candidates.
106    pub fn batch_candidates(&mut self, n: usize) -> Vec<(usize, Chromosome)> {
107        self.evolution.batch_candidates(n)
108    }
109
110    /// Record evolution feedback. An out-of-range `chromosome_index`
111    /// indicates a state-machine bug between caller and engine — log
112    /// loudly via tracing rather than swallowing the error silently.
113    pub fn record_feedback(&mut self, chromosome_index: usize, passed: bool) {
114        if let Err(e) = self.evolution.record_feedback(chromosome_index, passed) {
115            tracing::warn!(
116                ?e,
117                chromosome_index,
118                "evolution.record_feedback rejected — likely stale chromosome index"
119            );
120        }
121        self.feedback_count += 1;
122    }
123
124    /// Record rich verdict feedback. Same error semantics as
125    /// `record_feedback`.
126    pub fn record_verdict(&mut self, chromosome_index: usize, verdict: &OracleVerdict) {
127        if let Err(e) = self.evolution.record_verdict(chromosome_index, verdict) {
128            tracing::warn!(
129                ?e,
130                chromosome_index,
131                "evolution.record_verdict rejected — likely stale chromosome index"
132            );
133        }
134        self.feedback_count += 1;
135    }
136
137    /// Evolve the population to the next generation.
138    pub fn evolve(&mut self) {
139        self.evolution.evolve();
140    }
141
142    /// Get the best-performing technique combination.
143    #[must_use]
144    pub fn best_combination(&self) -> Option<&Chromosome> {
145        self.evolution.best()
146    }
147
148    /// Number of differential probes completed.
149    #[must_use]
150    pub fn probes_completed(&self) -> usize {
151        self.probes_completed
152    }
153
154    /// Number of evolution feedback events recorded.
155    #[must_use]
156    pub fn feedback_count(&self) -> usize {
157        self.feedback_count
158    }
159
160    /// Population diversity score.
161    #[must_use]
162    pub fn diversity(&self) -> f64 {
163        self.evolution.diversity_score()
164    }
165
166    /// Check if enough probes have been completed for a meaningful analysis.
167    #[must_use]
168    pub fn has_sufficient_data(&self) -> bool {
169        self.probes_completed >= self.min_probes
170    }
171
172    /// Step the state machine forward given the latest feedback.
173    ///
174    /// This is the primary orchestration API. Call it repeatedly,
175    /// performing the action it returns and feeding the result back
176    /// as the next feedback.
177    pub fn step(&mut self, feedback: Feedback) -> LoopAction {
178        if self.evolution.should_terminate() {
179            return LoopAction::Terminate(TerminationReason::BudgetExhausted);
180        }
181
182        // Handle target errors
183        if let Feedback::TargetError(ref msg) = feedback {
184            let _ = self.evolution.record_target_error(msg.clone());
185            if !self.evolution.target_health.is_healthy() {
186                return LoopAction::Terminate(TerminationReason::TargetHealthCritical);
187            }
188            // Backoff implicitly handled by caller observing returned delay
189        }
190
191        match self.phase {
192            Phase::DifferentialProbing => {
193                if let Feedback::Blocked | Feedback::Passed = feedback {
194                    // Differential probing results are consumed externally via record_probe
195                }
196                if self.probe_queue.is_empty() || self.probes_completed >= self.min_probes {
197                    self.phase = Phase::Evolution;
198                    return self.step(Feedback::Passed); // Transition
199                }
200                let probe = self.probe_queue.remove(0);
201                LoopAction::SendProbe(probe)
202            }
203            Phase::Evolution => {
204                if self.eval_queue.is_empty() {
205                    let remaining = self
206                        .budget
207                        .max_requests
208                        .saturating_sub(self.evolution.request_count);
209                    let batch_size = 4_usize.min(remaining).max(1);
210                    self.eval_queue = self.evolution.batch_candidates(batch_size);
211                    if self.eval_queue.is_empty() {
212                        self.phase = Phase::Done;
213                        return LoopAction::Terminate(TerminationReason::BudgetExhausted);
214                    }
215                }
216                let (_idx, chrom) = self.eval_queue.remove(0);
217                LoopAction::SendPayload(chrom)
218            }
219            Phase::Done => LoopAction::Terminate(TerminationReason::BudgetExhausted),
220        }
221    }
222
223    /// Suggested delay before the next request, based on target health.
224    #[must_use]
225    pub fn suggested_delay_ms(&self) -> u64 {
226        if self.evolution.target_health.in_backoff() {
227            self.evolution.target_health.backoff().as_millis() as u64
228        } else {
229            0
230        }
231    }
232}
233
234impl Default for IntelligenceLoop {
235    fn default() -> Self {
236        Self::new(20)
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn new_loop_default() {
246        let il = IntelligenceLoop::default();
247        assert_eq!(il.probes_completed(), 0);
248        assert_eq!(il.feedback_count(), 0);
249        assert!(!il.has_sufficient_data());
250    }
251
252    #[test]
253    fn generate_probes_not_empty() {
254        let il = IntelligenceLoop::default();
255        let probes = il.generate_probes();
256        assert!(!probes.is_empty());
257    }
258
259    #[test]
260    fn generate_quick_probes_smaller() {
261        let _il = IntelligenceLoop::default();
262        let full = generate_probes();
263        let quick = generate_quick_probes();
264        assert!(quick.len() < full.len());
265    }
266
267    #[test]
268    fn record_probe_increments() {
269        let mut il = IntelligenceLoop::default();
270        let probes = il.generate_quick_probes();
271        il.record_probe(&probes[0], true);
272        assert_eq!(il.probes_completed(), 1);
273    }
274
275    #[test]
276    fn sufficient_data_after_min_probes() {
277        let mut il = IntelligenceLoop::with_budget(10, 5, Budget::default());
278        let probes = il.generate_probes();
279        for (i, probe) in probes.iter().enumerate() {
280            il.record_probe(probe, i % 3 == 0);
281            if i >= 4 {
282                break;
283            }
284        }
285        assert!(il.has_sufficient_data());
286    }
287
288    #[test]
289    fn evolution_feedback_loop() {
290        let mut il = IntelligenceLoop::new(10);
291        if let Some((idx, _)) = il.next_candidate() {
292            il.record_feedback(idx, true);
293            assert_eq!(il.feedback_count(), 1);
294        }
295    }
296
297    #[test]
298    fn evolve_doesnt_panic() {
299        let mut il = IntelligenceLoop::new(10);
300        for _ in 0..5 {
301            if let Some((idx, _)) = il.next_candidate() {
302                il.record_feedback(idx, true);
303            }
304        }
305        il.evolve();
306        assert!(il.next_candidate().is_some());
307    }
308
309    #[test]
310    fn waf_report_not_empty_after_probes() {
311        let mut il = IntelligenceLoop::default();
312        let probes = il.generate_quick_probes();
313        for probe in &probes {
314            il.record_probe(probe, true);
315        }
316        let report = il.waf_report();
317        assert!(!report.is_empty());
318    }
319
320    #[test]
321    fn suggested_evasions_from_differential() {
322        let mut il = IntelligenceLoop::default();
323        let probes = generate_probes();
324        for probe in &probes {
325            let is_sql = format!("{:?}", probe.tests).contains("Sql");
326            il.record_probe(probe, is_sql);
327        }
328        let suggestions = il.suggested_evasions();
329        assert!(!suggestions.is_empty());
330    }
331
332    #[test]
333    fn diversity_score_valid_range() {
334        let il = IntelligenceLoop::new(10);
335        let score = il.diversity();
336        assert!((0.0..=1.0).contains(&score));
337    }
338
339    #[test]
340    fn step_state_machine_transitions() {
341        let mut il = IntelligenceLoop::with_budget(10, 2, Budget::default());
342        // Should start with differential probes
343        let action = il.step(Feedback::Passed);
344        assert!(matches!(action, LoopAction::SendProbe(_)));
345
346        il.record_probe(&generate_probes()[0], true);
347        let action2 = il.step(Feedback::Blocked);
348        assert!(matches!(action2, LoopAction::SendProbe(_)));
349
350        il.record_probe(&generate_probes()[1], false);
351        // Now should transition to evolution
352        let action3 = il.step(Feedback::Passed);
353        assert!(matches!(action3, LoopAction::SendPayload(_)));
354    }
355
356    #[test]
357    fn step_terminates_on_target_error() {
358        let mut il = IntelligenceLoop::with_budget(10, 0, Budget::default());
359        // Skip to evolution
360        for _ in 0..10 {
361            if let LoopAction::SendPayload(_) = il.step(Feedback::Passed) {
362                // Target error
363                let term = il.step(Feedback::TargetError("503".into()));
364                // After first error we backoff, not terminate immediately
365                assert!(matches!(
366                    term,
367                    LoopAction::SendPayload(_) | LoopAction::Terminate(_)
368                ));
369                return;
370            }
371        }
372    }
373
374    #[test]
375    fn step_terminates_when_budget_exhausted() {
376        let mut il = IntelligenceLoop::with_budget(5, 0, Budget {
377            max_requests: 3,
378            ..Budget::default()
379        });
380        // Burn through budget quickly
381        let mut sent = 0;
382        for _ in 0..20 {
383            match il.step(Feedback::Passed) {
384                LoopAction::SendProbe(_) | LoopAction::SendPayload(_) | LoopAction::SaveCheckpoint => {
385                    sent += 1;
386                }
387                LoopAction::Terminate(TerminationReason::BudgetExhausted) => {
388                    break;
389                }
390                LoopAction::Terminate(other) => {
391                    panic!("unexpected termination: {other:?}");
392                }
393            }
394        }
395        // Should terminate before sending too many
396        assert!(sent <= 5, "sent {sent} requests but budget was 3");
397    }
398
399    #[test]
400    fn suggested_delay_zero_when_healthy() {
401        let il = IntelligenceLoop::default();
402        assert_eq!(il.suggested_delay_ms(), 0);
403    }
404
405    #[test]
406    fn suggested_delay_nonzero_after_target_errors() {
407        let mut il = IntelligenceLoop::with_budget(10, 0, Budget::default());
408        // Skip to evolution and bombard with errors
409        for _ in 0..50 {
410            if let LoopAction::SendPayload(_) = il.step(Feedback::Passed) {
411                il.step(Feedback::TargetError("503".into()));
412            }
413        }
414        // After enough errors backoff should kick in
415        let delay = il.suggested_delay_ms();
416        // Note: delay may be zero if health recovered, but we at least
417        // exercise the code path without panicking.
418        let _ = delay;
419    }
420
421    #[test]
422    fn always_blocking_oracle_still_terminates() {
423        // Adversarial scenario: every single payload is blocked.
424        // The engine must still terminate gracefully via budget exhaustion.
425        let mut il = IntelligenceLoop::with_budget(
426            5,
427            0,
428            Budget {
429                max_requests: 50,
430                max_generations: 10,
431                ..Budget::default()
432            },
433        );
434        let mut iterations = 0;
435        while let LoopAction::SendProbe(_) | LoopAction::SendPayload(_) | LoopAction::SaveCheckpoint = il.step(Feedback::Blocked) {
436            iterations += 1;
437            if iterations > 500 {
438                panic!("engine did not terminate within 500 iterations (budget was 50)");
439            }
440        }
441    }
442}