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.
111    pub fn record_feedback(&mut self, chromosome_index: usize, passed: bool) {
112        let _ = self.evolution.record_feedback(chromosome_index, passed);
113        self.feedback_count += 1;
114    }
115
116    /// Record rich verdict feedback.
117    pub fn record_verdict(&mut self, chromosome_index: usize, verdict: &OracleVerdict) {
118        let _ = self.evolution.record_verdict(chromosome_index, verdict);
119        self.feedback_count += 1;
120    }
121
122    /// Evolve the population to the next generation.
123    pub fn evolve(&mut self) {
124        self.evolution.evolve();
125    }
126
127    /// Get the best-performing technique combination.
128    #[must_use]
129    pub fn best_combination(&self) -> Option<&Chromosome> {
130        self.evolution.best()
131    }
132
133    /// Number of differential probes completed.
134    #[must_use]
135    pub fn probes_completed(&self) -> usize {
136        self.probes_completed
137    }
138
139    /// Number of evolution feedback events recorded.
140    #[must_use]
141    pub fn feedback_count(&self) -> usize {
142        self.feedback_count
143    }
144
145    /// Population diversity score.
146    #[must_use]
147    pub fn diversity(&self) -> f64 {
148        self.evolution.diversity_score()
149    }
150
151    /// Check if enough probes have been completed for a meaningful analysis.
152    #[must_use]
153    pub fn has_sufficient_data(&self) -> bool {
154        self.probes_completed >= self.min_probes
155    }
156
157    /// Step the state machine forward given the latest feedback.
158    ///
159    /// This is the primary orchestration API. Call it repeatedly,
160    /// performing the action it returns and feeding the result back
161    /// as the next feedback.
162    pub fn step(&mut self, feedback: Feedback) -> LoopAction {
163        if self.evolution.should_terminate() {
164            return LoopAction::Terminate(TerminationReason::BudgetExhausted);
165        }
166
167        // Handle target errors
168        if let Feedback::TargetError(ref msg) = feedback {
169            let _ = self.evolution.record_target_error(msg.clone());
170            if !self.evolution.target_health.is_healthy() {
171                return LoopAction::Terminate(TerminationReason::TargetHealthCritical);
172            }
173            // Backoff implicitly handled by caller observing returned delay
174        }
175
176        match self.phase {
177            Phase::DifferentialProbing => {
178                if let Feedback::Blocked | Feedback::Passed = feedback {
179                    // Differential probing results are consumed externally via record_probe
180                }
181                if self.probe_queue.is_empty() || self.probes_completed >= self.min_probes {
182                    self.phase = Phase::Evolution;
183                    return self.step(Feedback::Passed); // Transition
184                }
185                let probe = self.probe_queue.remove(0);
186                LoopAction::SendProbe(probe)
187            }
188            Phase::Evolution => {
189                if self.eval_queue.is_empty() {
190                    let remaining = self
191                        .budget
192                        .max_requests
193                        .saturating_sub(self.evolution.request_count);
194                    let batch_size = 4_usize.min(remaining).max(1);
195                    self.eval_queue = self.evolution.batch_candidates(batch_size);
196                    if self.eval_queue.is_empty() {
197                        self.phase = Phase::Done;
198                        return LoopAction::Terminate(TerminationReason::BudgetExhausted);
199                    }
200                }
201                let (_idx, chrom) = self.eval_queue.remove(0);
202                LoopAction::SendPayload(chrom)
203            }
204            Phase::Done => LoopAction::Terminate(TerminationReason::BudgetExhausted),
205        }
206    }
207
208    /// Suggested delay before the next request, based on target health.
209    #[must_use]
210    pub fn suggested_delay_ms(&self) -> u64 {
211        if self.evolution.target_health.in_backoff() {
212            self.evolution.target_health.backoff().as_millis() as u64
213        } else {
214            0
215        }
216    }
217}
218
219impl Default for IntelligenceLoop {
220    fn default() -> Self {
221        Self::new(20)
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn new_loop_default() {
231        let il = IntelligenceLoop::default();
232        assert_eq!(il.probes_completed(), 0);
233        assert_eq!(il.feedback_count(), 0);
234        assert!(!il.has_sufficient_data());
235    }
236
237    #[test]
238    fn generate_probes_not_empty() {
239        let il = IntelligenceLoop::default();
240        let probes = il.generate_probes();
241        assert!(!probes.is_empty());
242    }
243
244    #[test]
245    fn generate_quick_probes_smaller() {
246        let _il = IntelligenceLoop::default();
247        let full = generate_probes();
248        let quick = generate_quick_probes();
249        assert!(quick.len() < full.len());
250    }
251
252    #[test]
253    fn record_probe_increments() {
254        let mut il = IntelligenceLoop::default();
255        let probes = il.generate_quick_probes();
256        il.record_probe(&probes[0], true);
257        assert_eq!(il.probes_completed(), 1);
258    }
259
260    #[test]
261    fn sufficient_data_after_min_probes() {
262        let mut il = IntelligenceLoop::with_budget(10, 5, Budget::default());
263        let probes = il.generate_probes();
264        for (i, probe) in probes.iter().enumerate() {
265            il.record_probe(probe, i % 3 == 0);
266            if i >= 4 {
267                break;
268            }
269        }
270        assert!(il.has_sufficient_data());
271    }
272
273    #[test]
274    fn evolution_feedback_loop() {
275        let mut il = IntelligenceLoop::new(10);
276        if let Some((idx, _)) = il.next_candidate() {
277            il.record_feedback(idx, true);
278            assert_eq!(il.feedback_count(), 1);
279        }
280    }
281
282    #[test]
283    fn evolve_doesnt_panic() {
284        let mut il = IntelligenceLoop::new(10);
285        for _ in 0..5 {
286            if let Some((idx, _)) = il.next_candidate() {
287                il.record_feedback(idx, true);
288            }
289        }
290        il.evolve();
291        assert!(il.next_candidate().is_some());
292    }
293
294    #[test]
295    fn waf_report_not_empty_after_probes() {
296        let mut il = IntelligenceLoop::default();
297        let probes = il.generate_quick_probes();
298        for probe in &probes {
299            il.record_probe(probe, true);
300        }
301        let report = il.waf_report();
302        assert!(!report.is_empty());
303    }
304
305    #[test]
306    fn suggested_evasions_from_differential() {
307        let mut il = IntelligenceLoop::default();
308        let probes = generate_probes();
309        for probe in &probes {
310            let is_sql = format!("{:?}", probe.tests).contains("Sql");
311            il.record_probe(probe, is_sql);
312        }
313        let suggestions = il.suggested_evasions();
314        assert!(!suggestions.is_empty());
315    }
316
317    #[test]
318    fn diversity_score_valid_range() {
319        let il = IntelligenceLoop::new(10);
320        let score = il.diversity();
321        assert!((0.0..=1.0).contains(&score));
322    }
323
324    #[test]
325    fn step_state_machine_transitions() {
326        let mut il = IntelligenceLoop::with_budget(10, 2, Budget::default());
327        // Should start with differential probes
328        let action = il.step(Feedback::Passed);
329        assert!(matches!(action, LoopAction::SendProbe(_)));
330
331        il.record_probe(&generate_probes()[0], true);
332        let action2 = il.step(Feedback::Blocked);
333        assert!(matches!(action2, LoopAction::SendProbe(_)));
334
335        il.record_probe(&generate_probes()[1], false);
336        // Now should transition to evolution
337        let action3 = il.step(Feedback::Passed);
338        assert!(matches!(action3, LoopAction::SendPayload(_)));
339    }
340
341    #[test]
342    fn step_terminates_on_target_error() {
343        let mut il = IntelligenceLoop::with_budget(10, 0, Budget::default());
344        // Skip to evolution
345        for _ in 0..10 {
346            if let LoopAction::SendPayload(_) = il.step(Feedback::Passed) {
347                // Target error
348                let term = il.step(Feedback::TargetError("503".into()));
349                // After first error we backoff, not terminate immediately
350                assert!(matches!(
351                    term,
352                    LoopAction::SendPayload(_) | LoopAction::Terminate(_)
353                ));
354                return;
355            }
356        }
357    }
358}