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