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 /// Supersedes the title.
170 Retitled {
171 title: String,
172 reason: String,
173 },
174 /// Overrides the SLA due date.
175 SlaSet {
176 due_at: NaiveDate,
177 basis: String,
178 },
179 /// Moves through the status machine.
180 StatusChanged {
181 from: Status,
182 to: Status,
183 reason: String,
184 },
185 /// Appends another finding ref — how a risk re-observed in a later run
186 /// is recorded as *persisting*.
187 EvidenceLinked {
188 finding_ref: FindingRef,
189 },
190 /// Records the tracker mirror created by sync-out.
191 ExternalLinked {
192 system: String,
193 external_id: String,
194 url: String,
195 },
196 /// Advisory inbound status from a tracker. Never authoritative.
197 ExternalStatusObserved {
198 system: String,
199 status: String,
200 observed_at: DateTime<Utc>,
201 },
202 /// Free-text note; no state change.
203 Note {
204 text: String,
205 },
206 /// Shorthand for `status-changed → remediated` with evidence.
207 Remediated {
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 resolved_run_ref: Option<FindingRef>,
210 note: String,
211 },
212 /// `remediated → open`.
213 Reopened {
214 reason: String,
215 },
216 /// Status → `accepted-exception`.
217 ExceptionDocumented {
218 rationale: String,
219 approved_by: String,
220 expires_at: NaiveDate,
221 },
222}
223
224impl EventData {
225 /// The on-disk `type` discriminant string (kebab-case), useful for
226 /// timelines and diagnostics without re-serialising.
227 pub fn type_str(&self) -> &'static str {
228 match self {
229 EventData::Opened { .. } => "opened",
230 EventData::OwnerAssigned { .. } => "owner-assigned",
231 EventData::ScoreChanged { .. } => "score-changed",
232 EventData::Retitled { .. } => "retitled",
233 EventData::SlaSet { .. } => "sla-set",
234 EventData::StatusChanged { .. } => "status-changed",
235 EventData::EvidenceLinked { .. } => "evidence-linked",
236 EventData::ExternalLinked { .. } => "external-linked",
237 EventData::ExternalStatusObserved { .. } => "external-status-observed",
238 EventData::Note { .. } => "note",
239 EventData::Remediated { .. } => "remediated",
240 EventData::Reopened { .. } => "reopened",
241 EventData::ExceptionDocumented { .. } => "exception-documented",
242 }
243 }
244}
245
246// ---------- folded state ----------------------------------------------------
247
248/// The current state of a risk, produced by folding its events in `seq`
249/// order. Nothing here is stored on disk — it is always recomputed from the
250/// log. See [`crate::risks::fold::fold`].
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct RiskState {
253 /// Current lifecycle status.
254 pub status: Status,
255 pub title: String,
256 pub severity: Severity,
257 pub impact: u8,
258 pub likelihood: u8,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub owner: Option<String>,
261 /// SLA due date — defaulted from `opened`, overridable by `sla-set` and
262 /// recomputed on `score-changed` when an `sla_days` basis is in effect.
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub due_at: Option<NaiveDate>,
265 /// SLA window in days, carried from `opened`; used to recompute `due_at`
266 /// when the score changes.
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub sla_days: Option<u32>,
269 pub affected_systems: Vec<String>,
270 /// Every finding ref bound to the risk, in append order. The first is
271 /// the originating finding; later ones mark re-observation (persisting).
272 pub finding_refs: Vec<FindingRef>,
273 /// Tracker mirrors created by sync-out.
274 pub external: Vec<ExternalLink>,
275 /// Latest advisory status per external system. Never authoritative.
276 #[serde(default)]
277 pub external_status: BTreeMap<String, String>,
278 /// When the risk reached `remediated`, if it has.
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub resolved_at: Option<DateTime<Utc>>,
281 /// When an `accepted-exception` expires, if one is in effect.
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub exception_expires_at: Option<NaiveDate>,
284}
285
286impl RiskState {
287 /// `<control_id>:<finding_id>` of the originating finding — the risk's
288 /// stable cross-run identity. `None` only for a degenerate empty log.
289 pub fn fingerprint(&self) -> Option<String> {
290 self.finding_refs.first().map(FindingRef::fingerprint)
291 }
292
293 /// The control id that produced the risk (from the first finding ref).
294 pub fn source_control(&self) -> Option<&str> {
295 self.finding_refs.first().map(|f| f.control_id.as_str())
296 }
297
298 /// The run id the risk was first observed in (from the first finding
299 /// ref).
300 pub fn first_run_id(&self) -> Option<&str> {
301 self.finding_refs.first().map(|f| f.run_id.as_str())
302 }
303}