termivar_scanner/defense/
transition.rs1use super::fingerprint::DefenseProduct;
10use super::state::{DefensePosture, DefenseState};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PostureShift {
15 Unchanged,
17 Escalated,
19 Deescalated,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum DefenseTransitionKind {
27 NoChange,
29 DefenseEngaged,
31 DefenseRelaxed,
33 DefenseReconfigured,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct DefenseTransition {
41 posture_shift: PostureShift,
42 newly_blocking: bool,
43 newly_rate_limited: bool,
44 status_changed: bool,
45 fingerprint_changed: bool,
46 control_product: Option<DefenseProduct>,
47 candidate_product: Option<DefenseProduct>,
48 kind: DefenseTransitionKind,
49}
50
51impl DefenseTransition {
52 pub fn between(control: &DefenseState, candidate: &DefenseState) -> Self {
57 let posture_shift = match candidate.posture().cmp(&control.posture()) {
58 std::cmp::Ordering::Greater => PostureShift::Escalated,
59 std::cmp::Ordering::Less => PostureShift::Deescalated,
60 std::cmp::Ordering::Equal => PostureShift::Unchanged,
61 };
62
63 let newly_blocking = candidate.posture() == DefensePosture::Blocking
64 && control.posture() != DefensePosture::Blocking;
65 let newly_rate_limited = candidate.is_rate_limited() && !control.is_rate_limited();
66 let status_changed = control.status_signal() != candidate.status_signal();
67
68 let control_product = control.fingerprint().map(|print| print.product());
69 let candidate_product = candidate.fingerprint().map(|print| print.product());
70 let fingerprint_changed = control_product != candidate_product;
71
72 let kind = match posture_shift {
73 PostureShift::Escalated => DefenseTransitionKind::DefenseEngaged,
74 PostureShift::Deescalated => DefenseTransitionKind::DefenseRelaxed,
75 PostureShift::Unchanged => {
76 if fingerprint_changed || status_changed || newly_rate_limited {
77 DefenseTransitionKind::DefenseReconfigured
78 } else {
79 DefenseTransitionKind::NoChange
80 }
81 },
82 };
83
84 Self {
85 posture_shift,
86 newly_blocking,
87 newly_rate_limited,
88 status_changed,
89 fingerprint_changed,
90 control_product,
91 candidate_product,
92 kind,
93 }
94 }
95
96 pub const fn posture_shift(&self) -> PostureShift {
98 self.posture_shift
99 }
100
101 pub const fn kind(&self) -> DefenseTransitionKind {
103 self.kind
104 }
105
106 pub const fn is_newly_blocking(&self) -> bool {
108 self.newly_blocking
109 }
110
111 pub const fn is_newly_rate_limited(&self) -> bool {
113 self.newly_rate_limited
114 }
115
116 pub const fn status_changed(&self) -> bool {
118 self.status_changed
119 }
120
121 pub const fn fingerprint_changed(&self) -> bool {
124 self.fingerprint_changed
125 }
126
127 pub const fn control_product(&self) -> Option<DefenseProduct> {
129 self.control_product
130 }
131
132 pub const fn candidate_product(&self) -> Option<DefenseProduct> {
134 self.candidate_product
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 fn state(status: u16, headers: &[(&str, &str)], body: &str) -> DefenseState {
143 DefenseState::observe(status, headers, body)
144 }
145
146 #[test]
147 fn open_to_blocking_is_an_engaged_escalation() {
148 let control = state(200, &[], "ok");
149 let candidate = state(403, &[], "forbidden");
150 let transition = DefenseTransition::between(&control, &candidate);
151
152 assert_eq!(transition.posture_shift(), PostureShift::Escalated);
153 assert_eq!(transition.kind(), DefenseTransitionKind::DefenseEngaged);
154 assert!(transition.is_newly_blocking());
155 assert!(transition.status_changed());
156 }
157
158 #[test]
159 fn blocking_to_open_is_relaxed() {
160 let control = state(403, &[], "forbidden");
161 let candidate = state(200, &[], "ok");
162 let transition = DefenseTransition::between(&control, &candidate);
163
164 assert_eq!(transition.posture_shift(), PostureShift::Deescalated);
165 assert_eq!(transition.kind(), DefenseTransitionKind::DefenseRelaxed);
166 assert!(!transition.is_newly_blocking());
167 }
168
169 #[test]
170 fn identical_open_states_show_no_change() {
171 let control = state(200, &[("Server", "nginx")], "ok");
172 let candidate = state(200, &[("Server", "nginx")], "ok");
173 let transition = DefenseTransition::between(&control, &candidate);
174
175 assert_eq!(transition.posture_shift(), PostureShift::Unchanged);
176 assert_eq!(transition.kind(), DefenseTransitionKind::NoChange);
177 assert!(!transition.fingerprint_changed());
178 }
179
180 #[test]
181 fn same_posture_with_a_new_fingerprint_is_reconfigured() {
182 let control = state(200, &[("Retry-After", "5")], "slow down");
184 let candidate = state(200, &[("Retry-After", "5"), ("CF-RAY", "abc")], "slow down");
185 let transition = DefenseTransition::between(&control, &candidate);
186
187 assert_eq!(transition.posture_shift(), PostureShift::Unchanged);
188 assert_eq!(
189 transition.kind(),
190 DefenseTransitionKind::DefenseReconfigured
191 );
192 assert!(transition.fingerprint_changed());
193 assert_eq!(transition.control_product(), None);
194 assert_eq!(
195 transition.candidate_product(),
196 Some(super::DefenseProduct::Cloudflare)
197 );
198 }
199
200 #[test]
201 fn newly_rate_limited_without_a_block_is_reconfigured() {
202 let control = state(200, &[], "ok");
203 let candidate = state(429, &[], "slow down");
204 let transition = DefenseTransition::between(&control, &candidate);
205
206 assert_eq!(transition.posture_shift(), PostureShift::Escalated);
208 assert_eq!(transition.kind(), DefenseTransitionKind::DefenseEngaged);
209 assert!(transition.is_newly_rate_limited());
210 }
211
212 #[test]
213 fn transition_is_deterministic() {
214 let control = state(200, &[], "ok");
215 let candidate = state(403, &[("CF-RAY", "x")], "access denied");
216 let first = DefenseTransition::between(&control, &candidate);
217 let second = DefenseTransition::between(&control, &candidate);
218 assert_eq!(first, second);
219 }
220}