secunit_core/risks/fold.rs
1//! The deterministic left-fold over a risk's events and the status machine
2//! that governs lifecycle transitions.
3//!
4//! The fold is a pure function of the event list: last-write-wins per field,
5//! status follows the latest lifecycle event, finding refs accumulate. The
6//! status machine (see `docs/risks.md`) is enforced at append time so an
7//! illegal `status-changed` never lands in the log.
8
9use chrono::Duration;
10
11use super::model::{EventData, RiskEvent, RiskState, Status};
12
13/// An attempted lifecycle transition that the status machine rejects.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct TransitionError {
16 pub from: Status,
17 pub to: Status,
18}
19
20impl std::fmt::Display for TransitionError {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 write!(
23 f,
24 "illegal status transition: {} → {}",
25 self.from.as_str(),
26 self.to.as_str()
27 )
28 }
29}
30
31impl std::error::Error for TransitionError {}
32
33/// The status machine, as drawn in `docs/risks.md`:
34///
35/// ```text
36/// ┌─────────────► accepted-exception
37/// │ │
38/// opened ──► open ──► in-progress ──► remediated ──► reopened ──► open
39/// │ │ │
40/// └────────────┴──────────────┴──► false-positive
41/// ```
42///
43/// Returns `Ok(())` if moving `from → to` is legal, else a
44/// [`TransitionError`]. A no-op (`from == to`) is rejected — a
45/// `status-changed` event must actually change the status.
46pub fn validate_transition(from: Status, to: Status) -> Result<(), TransitionError> {
47 use Status::*;
48 let ok = match (from, to) {
49 // Any non-terminal state may be ruled a false positive.
50 (Open | InProgress | Remediated, FalsePositive) => true,
51 // Open or in-progress may be accepted as a documented exception.
52 (Open | InProgress, AcceptedException) => true,
53 // Forward progress.
54 (Open, InProgress) => true,
55 (InProgress, Remediated) => true,
56 // A remediation can be undone.
57 (Remediated, Reopened) => true,
58 // The transient Reopened marker resolves back to Open. (The fold
59 // also normalises Reopened → Open, but allow it explicitly so a
60 // direct status-changed to/from it round-trips.)
61 (Reopened, Open) => true,
62 // A reopened risk that re-enters work goes straight to in-progress,
63 // and an accepted exception that lapses can be reopened to open.
64 (Reopened, InProgress) => true,
65 (AcceptedException, Open) => true,
66 // Everything else (including no-op and all terminal-state exits) is
67 // rejected.
68 _ => false,
69 };
70 if ok {
71 Ok(())
72 } else {
73 Err(TransitionError { from, to })
74 }
75}
76
77/// Fold events in `seq` order into the current [`RiskState`].
78///
79/// Deterministic and total: the first event MUST be `opened` (callers
80/// guarantee this — [`super::store::open`] writes it), and the result is a
81/// pure function of the slice. The caller is responsible for passing events
82/// already sorted by `seq`; [`super::store::load_events`] does this.
83///
84/// # Panics
85///
86/// Panics if the first event is not `opened`, since a log that doesn't start
87/// with `opened` is structurally corrupt and cannot produce a coherent
88/// state. Loaders validate this before folding.
89pub fn fold(events: &[RiskEvent]) -> RiskState {
90 let first = events.first().expect("fold: empty event log");
91 let mut state = seed(first);
92 for ev in &events[1..] {
93 apply(&mut state, ev);
94 }
95 state
96}
97
98/// [`fold`] restricted to events dated at or before `cutoff` — the risk's
99/// state as of that day's end. Borrows the slice, never clones events.
100/// Returns `None` when no event qualifies. Same contract as [`fold`]
101/// otherwise: the first qualifying event must be `opened`.
102pub fn fold_at(events: &[RiskEvent], cutoff: chrono::NaiveDate) -> Option<RiskState> {
103 let mut iter = events.iter().filter(|e| e.ts.date_naive() <= cutoff);
104 let first = iter.next()?;
105 let mut state = seed(first);
106 for ev in iter {
107 apply(&mut state, ev);
108 }
109 Some(state)
110}
111
112/// Seed the state from the leading `opened` event.
113///
114/// # Panics
115///
116/// Panics when the event is not `opened` — see [`fold`]'s contract.
117fn seed(first: &RiskEvent) -> RiskState {
118 match &first.data {
119 EventData::Opened {
120 finding_ref,
121 title,
122 severity,
123 impact,
124 likelihood,
125 affected_systems,
126 sla_days,
127 due_at,
128 } => RiskState {
129 status: Status::Open,
130 title: title.clone(),
131 severity: *severity,
132 impact: *impact,
133 likelihood: *likelihood,
134 owner: None,
135 due_at: Some(*due_at),
136 sla_days: Some(*sla_days),
137 affected_systems: affected_systems.clone(),
138 finding_refs: vec![finding_ref.clone()],
139 external: Vec::new(),
140 external_status: Default::default(),
141 resolved_at: None,
142 exception_expires_at: None,
143 },
144 other => panic!(
145 "fold: first event must be `opened`, got `{}`",
146 other.type_str()
147 ),
148 }
149}
150
151fn apply(state: &mut RiskState, ev: &RiskEvent) {
152 match &ev.data {
153 // Already consumed as the seed; a second `opened` is ignored (the
154 // schema/append protocol prevents it landing in the first place).
155 EventData::Opened { .. } => {}
156 EventData::OwnerAssigned { owner } => {
157 state.owner = Some(owner.clone());
158 }
159 EventData::ScoreChanged {
160 impact,
161 likelihood,
162 severity,
163 ..
164 } => {
165 state.impact = *impact;
166 state.likelihood = *likelihood;
167 state.severity = *severity;
168 // Recompute due_at if the SLA derives from an sla_days window
169 // anchored on the open date. We anchor on the originating
170 // finding's risk-open day, which is the first event's date.
171 if let Some(days) = state.sla_days {
172 if let Some(first_ref_day) = open_day(state) {
173 state.due_at = Some(first_ref_day + Duration::days(days as i64));
174 }
175 }
176 }
177 EventData::SlaSet { due_at, .. } => {
178 state.due_at = Some(*due_at);
179 // An explicit override detaches due_at from the sla_days basis.
180 state.sla_days = None;
181 }
182 EventData::StatusChanged { to, .. } => {
183 set_status(state, *to, ev);
184 }
185 EventData::EvidenceLinked { finding_ref } => {
186 state.finding_refs.push(finding_ref.clone());
187 }
188 EventData::ExternalLinked {
189 system,
190 external_id,
191 url,
192 } => {
193 state.external.push(super::model::ExternalLink {
194 system: system.clone(),
195 external_id: external_id.clone(),
196 url: url.clone(),
197 });
198 }
199 EventData::ExternalStatusObserved { system, status, .. } => {
200 // Advisory only — recorded, never authoritative over `status`.
201 state.external_status.insert(system.clone(), status.clone());
202 }
203 EventData::Note { .. } => {}
204 EventData::Remediated { .. } => {
205 set_status(state, Status::Remediated, ev);
206 }
207 EventData::Reopened { .. } => {
208 // remediated → open (Reopened is the transient marker; resolve
209 // straight to Open and clear the resolution timestamp).
210 state.status = Status::Open;
211 state.resolved_at = None;
212 }
213 EventData::ExceptionDocumented { expires_at, .. } => {
214 state.status = Status::AcceptedException;
215 state.exception_expires_at = Some(*expires_at);
216 }
217 }
218}
219
220/// Apply a status change, normalising the transient `Reopened` marker to
221/// `Open` and maintaining `resolved_at`.
222fn set_status(state: &mut RiskState, to: Status, ev: &RiskEvent) {
223 match to {
224 Status::Remediated => {
225 state.status = Status::Remediated;
226 state.resolved_at = Some(ev.ts);
227 }
228 Status::Reopened | Status::Open => {
229 state.status = Status::Open;
230 state.resolved_at = None;
231 }
232 other => {
233 state.status = other;
234 }
235 }
236}
237
238/// The risk's open day, derived from the originating finding's `run_id`
239/// (YYYY-MM-DD prefix), used as the anchor for recomputing `due_at`.
240fn open_day(state: &RiskState) -> Option<chrono::NaiveDate> {
241 let run_id = &state.finding_refs.first()?.run_id;
242 run_id
243 .get(0..10)
244 .and_then(|s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
245}