secunit_core/risks/model.rs
1//! Strongly-typed risk event envelope, per-type payloads, and the folded
2//! [`RiskState`].
3//!
4//! Every type here maps 1:1 to `risk-event.schema.json` /
5//! `risk-index.schema.json`. Keep field names aligned with the schemas —
6//! `serde` is the load path, and the on-disk line is the chained, hashed
7//! source of truth, so the JSON shape is a contract, not an implementation
8//! detail.
9
10use std::collections::BTreeMap;
11
12use chrono::{DateTime, NaiveDate, Utc};
13use serde::{Deserialize, Serialize};
14
15// ---------- shared primitives -----------------------------------------------
16
17/// Risk severity. Ordered most- to least-severe in declaration order so
18/// callers can compare and sort.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum Severity {
22 Critical,
23 High,
24 Medium,
25 Low,
26 Info,
27}
28
29/// Lifecycle status. The legal transitions between these are enforced by
30/// [`crate::risks::fold::validate_transition`]; see `docs/risks.md`.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum Status {
34 /// Initial state set by `opened`, and the state after `reopened`.
35 Open,
36 InProgress,
37 Remediated,
38 /// Transient lifecycle marker emitted by a `reopened` event; the fold
39 /// resolves it back to `Open`.
40 Reopened,
41 AcceptedException,
42 FalsePositive,
43}
44
45impl Status {
46 /// Still-open lifecycle states — the risk demands ongoing work.
47 /// Exhaustive on purpose: a new variant must decide its partition
48 /// here, not fall through a consumer's wildcard arm.
49 pub fn is_open(self) -> bool {
50 match self {
51 Status::Open | Status::InProgress | Status::Reopened => true,
52 Status::Remediated | Status::AcceptedException | Status::FalsePositive => false,
53 }
54 }
55
56 /// Terminal states. Note: SLA surfacing is deliberately narrower —
57 /// `secunit risks list --past-sla` still shows an accepted-exception
58 /// whose date has lapsed (see `cmd/risks.rs::is_past_sla`), so don't
59 /// substitute this for that check.
60 pub fn is_closed(self) -> bool {
61 !self.is_open()
62 }
63
64 /// The on-disk kebab-case spelling used in `status-changed` payloads
65 /// and the index.
66 pub fn as_str(self) -> &'static str {
67 match self {
68 Status::Open => "open",
69 Status::InProgress => "in-progress",
70 Status::Remediated => "remediated",
71 Status::Reopened => "reopened",
72 Status::AcceptedException => "accepted-exception",
73 Status::FalsePositive => "false-positive",
74 }
75 }
76}
77
78/// `{ model, skill }` when an agent appended the event; `None` for a direct
79/// operator action.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct Agent {
82 pub model: String,
83 pub skill: String,
84}
85
86/// Binds a risk to immutable evidence by content hash. The fingerprint
87/// `<control_id>:<finding_id>` is the risk's cross-run identity.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct FindingRef {
90 pub control_id: String,
91 pub run_id: String,
92 pub manifest_sha256: String,
93 pub finding_id: String,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub body_path: Option<String>,
96}
97
98impl FindingRef {
99 /// `<control_id>:<finding_id>` — the cross-run identity of the risk this
100 /// finding produced.
101 pub fn fingerprint(&self) -> String {
102 format!("{}:{}", self.control_id, self.finding_id)
103 }
104}
105
106/// A tracker mirror created by sync-out, recorded by `external-linked`.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct ExternalLink {
109 pub system: String,
110 pub external_id: String,
111 pub url: String,
112}
113
114// ---------- event envelope --------------------------------------------------
115
116/// One line of `events.jsonl`: the immutable envelope plus its hash-chain
117/// fields. `data` is flattened so the on-disk shape is
118/// `{seq, ts, actor, agent, type, prev_sha256, data}` (the `type`/`data`
119/// pair is produced by [`EventData`]'s adjacent tagging).
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct EventEnvelope {
122 /// 1-based, monotonic within the file.
123 pub seq: u64,
124 /// ISO-8601 UTC.
125 pub ts: DateTime<Utc>,
126 /// Operator handle responsible for the change.
127 pub actor: String,
128 /// `{ model, skill }` when an agent appended this; `null` otherwise.
129 #[serde(default)]
130 pub agent: Option<Agent>,
131 /// SHA-256 of the previous canonicalised line; `None` on `seq: 1`. This
132 /// is the per-risk hash chain.
133 pub prev_sha256: Option<String>,
134 /// The typed payload (`type` + `data` on the wire).
135 #[serde(flatten)]
136 pub data: EventData,
137}
138
139/// Back-compat alias: the envelope *is* the event. Callers may use either
140/// name; `RiskEvent` reads better at use sites that talk about "events".
141pub type RiskEvent = EventEnvelope;
142
143/// The per-`type` payload. Adjacently tagged so it serialises as
144/// `{"type": "...", "data": { ... }}`, matching `risk-event.schema.json`.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(tag = "type", content = "data", rename_all = "kebab-case")]
147pub enum EventData {
148 /// Creates the risk; status → `open`.
149 Opened {
150 finding_ref: FindingRef,
151 title: String,
152 severity: Severity,
153 impact: u8,
154 likelihood: u8,
155 affected_systems: Vec<String>,
156 sla_days: u32,
157 due_at: NaiveDate,
158 },
159 OwnerAssigned {
160 owner: String,
161 },
162 /// Supersedes the score.
163 ScoreChanged {
164 impact: u8,
165 likelihood: u8,
166 severity: Severity,
167 reason: String,
168 },
169 /// Overrides the SLA due date.
170 SlaSet {
171 due_at: NaiveDate,
172 basis: String,
173 },
174 /// Moves through the status machine.
175 StatusChanged {
176 from: Status,
177 to: Status,
178 reason: String,
179 },
180 /// Appends another finding ref — how a risk re-observed in a later run
181 /// is recorded as *persisting*.
182 EvidenceLinked {
183 finding_ref: FindingRef,
184 },
185 /// Records the tracker mirror created by sync-out.
186 ExternalLinked {
187 system: String,
188 external_id: String,
189 url: String,
190 },
191 /// Advisory inbound status from a tracker. Never authoritative.
192 ExternalStatusObserved {
193 system: String,
194 status: String,
195 observed_at: DateTime<Utc>,
196 },
197 /// Free-text note; no state change.
198 Note {
199 text: String,
200 },
201 /// Shorthand for `status-changed → remediated` with evidence.
202 Remediated {
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 resolved_run_ref: Option<FindingRef>,
205 note: String,
206 },
207 /// `remediated → open`.
208 Reopened {
209 reason: String,
210 },
211 /// Status → `accepted-exception`.
212 ExceptionDocumented {
213 rationale: String,
214 approved_by: String,
215 expires_at: NaiveDate,
216 },
217}
218
219impl EventData {
220 /// The on-disk `type` discriminant string (kebab-case), useful for
221 /// timelines and diagnostics without re-serialising.
222 pub fn type_str(&self) -> &'static str {
223 match self {
224 EventData::Opened { .. } => "opened",
225 EventData::OwnerAssigned { .. } => "owner-assigned",
226 EventData::ScoreChanged { .. } => "score-changed",
227 EventData::SlaSet { .. } => "sla-set",
228 EventData::StatusChanged { .. } => "status-changed",
229 EventData::EvidenceLinked { .. } => "evidence-linked",
230 EventData::ExternalLinked { .. } => "external-linked",
231 EventData::ExternalStatusObserved { .. } => "external-status-observed",
232 EventData::Note { .. } => "note",
233 EventData::Remediated { .. } => "remediated",
234 EventData::Reopened { .. } => "reopened",
235 EventData::ExceptionDocumented { .. } => "exception-documented",
236 }
237 }
238}
239
240// ---------- folded state ----------------------------------------------------
241
242/// The current state of a risk, produced by folding its events in `seq`
243/// order. Nothing here is stored on disk — it is always recomputed from the
244/// log. See [`crate::risks::fold::fold`].
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct RiskState {
247 /// Current lifecycle status.
248 pub status: Status,
249 pub title: String,
250 pub severity: Severity,
251 pub impact: u8,
252 pub likelihood: u8,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub owner: Option<String>,
255 /// SLA due date — defaulted from `opened`, overridable by `sla-set` and
256 /// recomputed on `score-changed` when an `sla_days` basis is in effect.
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub due_at: Option<NaiveDate>,
259 /// SLA window in days, carried from `opened`; used to recompute `due_at`
260 /// when the score changes.
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub sla_days: Option<u32>,
263 pub affected_systems: Vec<String>,
264 /// Every finding ref bound to the risk, in append order. The first is
265 /// the originating finding; later ones mark re-observation (persisting).
266 pub finding_refs: Vec<FindingRef>,
267 /// Tracker mirrors created by sync-out.
268 pub external: Vec<ExternalLink>,
269 /// Latest advisory status per external system. Never authoritative.
270 #[serde(default)]
271 pub external_status: BTreeMap<String, String>,
272 /// When the risk reached `remediated`, if it has.
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub resolved_at: Option<DateTime<Utc>>,
275 /// When an `accepted-exception` expires, if one is in effect.
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub exception_expires_at: Option<NaiveDate>,
278}
279
280impl RiskState {
281 /// `<control_id>:<finding_id>` of the originating finding — the risk's
282 /// stable cross-run identity. `None` only for a degenerate empty log.
283 pub fn fingerprint(&self) -> Option<String> {
284 self.finding_refs.first().map(FindingRef::fingerprint)
285 }
286
287 /// The control id that produced the risk (from the first finding ref).
288 pub fn source_control(&self) -> Option<&str> {
289 self.finding_refs.first().map(|f| f.control_id.as_str())
290 }
291
292 /// The run id the risk was first observed in (from the first finding
293 /// ref).
294 pub fn first_run_id(&self) -> Option<&str> {
295 self.finding_refs.first().map(|f| f.run_id.as_str())
296 }
297}