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. record_target_error returns Err exactly when
183        // health is critical, so we use its return value directly instead of
184        // re-querying is_healthy() — no computed-but-discarded result.
185        // Backoff implicitly handled by caller observing returned delay.
186        if let Feedback::TargetError(ref msg) = feedback
187            && self.evolution.record_target_error(msg.clone()).is_err()
188        {
189            return LoopAction::Terminate(TerminationReason::TargetHealthCritical);
190        }
191
192        match self.phase {
193            Phase::DifferentialProbing => {
194                if let Feedback::Blocked | Feedback::Passed = feedback {
195                    // Differential probing results are consumed externally via record_probe
196                }
197                if self.probe_queue.is_empty() || self.probes_completed >= self.min_probes {
198                    self.phase = Phase::Evolution;
199                    return self.step(Feedback::Passed); // Transition
200                }
201                let probe = self.probe_queue.remove(0);
202                LoopAction::SendProbe(probe)
203            }
204            Phase::Evolution => {
205                if self.eval_queue.is_empty() {
206                    let remaining = self
207                        .budget
208                        .max_requests
209                        .saturating_sub(self.evolution.request_count);
210                    let batch_size = 4_usize.min(remaining).max(1);
211                    self.eval_queue = self.evolution.batch_candidates(batch_size);
212                    if self.eval_queue.is_empty() {
213                        self.phase = Phase::Done;
214                        return LoopAction::Terminate(TerminationReason::BudgetExhausted);
215                    }
216                }
217                let (_idx, chrom) = self.eval_queue.remove(0);
218                LoopAction::SendPayload(chrom)
219            }
220            Phase::Done => LoopAction::Terminate(TerminationReason::BudgetExhausted),
221        }
222    }
223
224    /// Suggested delay before the next request, based on target health.
225    #[must_use]
226    pub fn suggested_delay_ms(&self) -> u64 {
227        if self.evolution.target_health.in_backoff() {
228            self.evolution.target_health.backoff().as_millis() as u64
229        } else {
230            0
231        }
232    }
233}
234
235impl Default for IntelligenceLoop {
236    fn default() -> Self {
237        Self::new(20)
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn new_loop_default() {
247        let il = IntelligenceLoop::default();
248        assert_eq!(il.probes_completed(), 0);
249        assert_eq!(il.feedback_count(), 0);
250        assert!(!il.has_sufficient_data());
251    }
252
253    #[test]
254    fn generate_probes_not_empty() {
255        let il = IntelligenceLoop::default();
256        let probes = il.generate_probes();
257        assert!(!probes.is_empty());
258    }
259
260    #[test]
261    fn generate_quick_probes_smaller() {
262        let _il = IntelligenceLoop::default();
263        let full = generate_probes();
264        let quick = generate_quick_probes();
265        assert!(quick.len() < full.len());
266    }
267
268    #[test]
269    fn record_probe_increments() {
270        let mut il = IntelligenceLoop::default();
271        let probes = il.generate_quick_probes();
272        il.record_probe(&probes[0], true);
273        assert_eq!(il.probes_completed(), 1);
274    }
275
276    #[test]
277    fn sufficient_data_after_min_probes() {
278        let mut il = IntelligenceLoop::with_budget(10, 5, Budget::default());
279        let probes = il.generate_probes();
280        for (i, probe) in probes.iter().enumerate() {
281            il.record_probe(probe, i % 3 == 0);
282            if i >= 4 {
283                break;
284            }
285        }
286        assert!(il.has_sufficient_data());
287    }
288
289    #[test]
290    fn evolution_feedback_loop() {
291        let mut il = IntelligenceLoop::new(10);
292        if let Some((idx, _)) = il.next_candidate() {
293            il.record_feedback(idx, true);
294            assert_eq!(il.feedback_count(), 1);
295        }
296    }
297
298    #[test]
299    fn evolve_doesnt_panic() {
300        let mut il = IntelligenceLoop::new(10);
301        for _ in 0..5 {
302            if let Some((idx, _)) = il.next_candidate() {
303                il.record_feedback(idx, true);
304            }
305        }
306        il.evolve();
307        assert!(il.next_candidate().is_some());
308    }
309
310    #[test]
311    fn waf_report_not_empty_after_probes() {
312        let mut il = IntelligenceLoop::default();
313        let probes = il.generate_quick_probes();
314        for probe in &probes {
315            il.record_probe(probe, true);
316        }
317        let report = il.waf_report();
318        assert!(!report.is_empty());
319    }
320
321    #[test]
322    fn suggested_evasions_from_differential() {
323        let mut il = IntelligenceLoop::default();
324        let probes = generate_probes();
325        for probe in &probes {
326            let is_sql = format!("{:?}", probe.tests).contains("Sql");
327            il.record_probe(probe, is_sql);
328        }
329        let suggestions = il.suggested_evasions();
330        assert!(!suggestions.is_empty());
331    }
332
333    #[test]
334    fn diversity_score_valid_range() {
335        let il = IntelligenceLoop::new(10);
336        let score = il.diversity();
337        assert!((0.0..=1.0).contains(&score));
338    }
339
340    #[test]
341    fn step_state_machine_transitions() {
342        let mut il = IntelligenceLoop::with_budget(10, 2, Budget::default());
343        // Should start with differential probes
344        let action = il.step(Feedback::Passed);
345        assert!(matches!(action, LoopAction::SendProbe(_)));
346
347        il.record_probe(&generate_probes()[0], true);
348        let action2 = il.step(Feedback::Blocked);
349        assert!(matches!(action2, LoopAction::SendProbe(_)));
350
351        il.record_probe(&generate_probes()[1], false);
352        // Now should transition to evolution
353        let action3 = il.step(Feedback::Passed);
354        assert!(matches!(action3, LoopAction::SendPayload(_)));
355    }
356
357    #[test]
358    fn step_terminates_on_target_error() {
359        let mut il = IntelligenceLoop::with_budget(10, 0, Budget::default());
360        // Skip to evolution
361        for _ in 0..10 {
362            if let LoopAction::SendPayload(_) = il.step(Feedback::Passed) {
363                // Target error
364                let term = il.step(Feedback::TargetError("503".into()));
365                // After first error we backoff, not terminate immediately
366                assert!(matches!(
367                    term,
368                    LoopAction::SendPayload(_) | LoopAction::Terminate(_)
369                ));
370                return;
371            }
372        }
373    }
374
375    #[test]
376    fn step_terminates_when_budget_exhausted() {
377        let mut il = IntelligenceLoop::with_budget(
378            5,
379            0,
380            Budget {
381                max_requests: 3,
382                ..Budget::default()
383            },
384        );
385        // Burn through budget quickly
386        let mut sent = 0;
387        for _ in 0..20 {
388            match il.step(Feedback::Passed) {
389                LoopAction::SendProbe(_)
390                | LoopAction::SendPayload(_)
391                | LoopAction::SaveCheckpoint => {
392                    sent += 1;
393                }
394                LoopAction::Terminate(TerminationReason::BudgetExhausted) => {
395                    break;
396                }
397                LoopAction::Terminate(other) => {
398                    panic!("unexpected termination: {other:?}");
399                }
400            }
401        }
402        // Should terminate before sending too many
403        assert!(sent <= 5, "sent {sent} requests but budget was 3");
404    }
405
406    #[test]
407    fn suggested_delay_zero_when_healthy() {
408        let il = IntelligenceLoop::default();
409        assert_eq!(il.suggested_delay_ms(), 0);
410    }
411
412    #[test]
413    fn suggested_delay_nonzero_after_target_errors() {
414        let mut il = IntelligenceLoop::with_budget(10, 0, Budget::default());
415        // Skip to evolution and bombard with errors
416        for _ in 0..50 {
417            if let LoopAction::SendPayload(_) = il.step(Feedback::Passed) {
418                il.step(Feedback::TargetError("503".into()));
419            }
420        }
421        // After enough errors backoff should kick in
422        let delay = il.suggested_delay_ms();
423        // Note: delay may be zero if health recovered, but we at least
424        // exercise the code path without panicking.
425        let _ = delay;
426    }
427
428    #[test]
429    fn always_blocking_oracle_still_terminates() {
430        // Adversarial scenario: every single payload is blocked.
431        // The engine must still terminate gracefully via budget exhaustion.
432        let mut il = IntelligenceLoop::with_budget(
433            5,
434            0,
435            Budget {
436                max_requests: 50,
437                max_generations: 10,
438                ..Budget::default()
439            },
440        );
441        let mut iterations = 0;
442        while let LoopAction::SendProbe(_)
443        | LoopAction::SendPayload(_)
444        | LoopAction::SaveCheckpoint = il.step(Feedback::Blocked)
445        {
446            iterations += 1;
447            if iterations > 500 {
448                panic!("engine did not terminate within 500 iterations (budget was 50)");
449            }
450        }
451    }
452}