Skip to main content

termivar_scanner/defense/
policy.rs

1//! Deterministic escalation policy over observed defense evidence.
2//!
3//! This module maps a [`DefenseState`] — and, when available, the
4//! [`DefenseTransition`] from a control to a candidate — into a typed
5//! [`DefenseResponse`] the planner can act on. It is the single place that turns
6//! observation into a *recommendation*, and it deliberately recommends rather
7//! than acts: it never selects a payload or an evasion technique. The planner
8//! remains responsible for choosing (or not choosing) a strategy.
9
10use super::state::{DefensePosture, DefenseState};
11use super::transition::DefenseTransition;
12
13/// Recommended planner reaction to observed defensive behavior.
14///
15/// Ordering is meaningful: a more restrictive response is greater, so a caller
16/// can combine several observations by taking the maximum.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18#[non_exhaustive]
19pub enum DefenseResponse {
20    /// No defensive reaction was observed; proceed normally.
21    Proceed,
22    /// Defensive infrastructure is present but nothing was blocked; proceed and
23    /// record the observation.
24    Observe,
25    /// Rate limiting is in effect; the planner should reduce its request cadence
26    /// before continuing.
27    Backoff,
28    /// The candidate request provoked a block the control did not; the planner
29    /// should reconsider its strategy rather than repeat the same request.
30    Reconsider,
31    /// A standing hard block or challenge; the planner should stop this line.
32    Halt,
33}
34
35/// Recommends a planner reaction from a single observation and an optional
36/// control-to-candidate transition.
37///
38/// The recommendation is a pure function of its inputs. When a transition shows
39/// the candidate was newly blocked while the control was not, the block is
40/// attributed to the candidate request and the response is [`Reconsider`];
41/// a block present without that attribution is a standing [`Halt`].
42///
43/// [`Reconsider`]: DefenseResponse::Reconsider
44/// [`Halt`]: DefenseResponse::Halt
45pub fn recommend(state: &DefenseState, transition: Option<&DefenseTransition>) -> DefenseResponse {
46    if transition.is_some_and(DefenseTransition::is_newly_blocking) {
47        return DefenseResponse::Reconsider;
48    }
49    if state.posture() == DefensePosture::Blocking {
50        return DefenseResponse::Halt;
51    }
52    if state.is_rate_limited() {
53        return DefenseResponse::Backoff;
54    }
55    if state.posture() == DefensePosture::Suspected {
56        return DefenseResponse::Observe;
57    }
58    DefenseResponse::Proceed
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    fn state(status: u16, headers: &[(&str, &str)], body: &str) -> DefenseState {
66        DefenseState::observe(status, headers, body)
67    }
68
69    #[test]
70    fn an_open_response_proceeds() {
71        let open = state(200, &[("Server", "nginx")], "ok");
72        assert_eq!(recommend(&open, None), DefenseResponse::Proceed);
73    }
74
75    #[test]
76    fn a_present_but_non_blocking_defense_is_observed() {
77        let suspected = state(200, &[("x-amzn-requestid", "id")], "ok");
78        assert_eq!(recommend(&suspected, None), DefenseResponse::Observe);
79    }
80
81    #[test]
82    fn rate_limiting_recommends_backoff() {
83        let limited = state(429, &[], "slow down");
84        assert_eq!(recommend(&limited, None), DefenseResponse::Backoff);
85    }
86
87    #[test]
88    fn a_standing_block_halts() {
89        let blocked = state(403, &[], "forbidden");
90        assert_eq!(recommend(&blocked, None), DefenseResponse::Halt);
91    }
92
93    #[test]
94    fn a_candidate_provoked_block_is_reconsidered_not_halted() {
95        let control = state(200, &[], "ok");
96        let candidate = state(403, &[], "forbidden");
97        let transition = DefenseTransition::between(&control, &candidate);
98        // The block is attributed to the candidate, so the planner should change
99        // strategy rather than treat the whole line as blocked.
100        assert_eq!(
101            recommend(&candidate, Some(&transition)),
102            DefenseResponse::Reconsider
103        );
104    }
105
106    #[test]
107    fn a_pre_existing_block_still_halts_even_with_a_transition() {
108        let control = state(403, &[], "forbidden");
109        let candidate = state(403, &[], "forbidden");
110        let transition = DefenseTransition::between(&control, &candidate);
111        // Both legs are blocked, so the block is not attributed to the candidate.
112        assert_eq!(
113            recommend(&candidate, Some(&transition)),
114            DefenseResponse::Halt
115        );
116    }
117
118    #[test]
119    fn responses_order_by_restrictiveness() {
120        assert!(DefenseResponse::Halt > DefenseResponse::Reconsider);
121        assert!(DefenseResponse::Reconsider > DefenseResponse::Backoff);
122        assert!(DefenseResponse::Backoff > DefenseResponse::Observe);
123        assert!(DefenseResponse::Observe > DefenseResponse::Proceed);
124    }
125
126    #[test]
127    fn recommendation_is_deterministic() {
128        let control = state(200, &[], "ok");
129        let candidate = state(403, &[("CF-RAY", "x")], "access denied");
130        let transition = DefenseTransition::between(&control, &candidate);
131        let first = recommend(&candidate, Some(&transition));
132        let second = recommend(&candidate, Some(&transition));
133        assert_eq!(first, second);
134    }
135}