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 /// The on-disk kebab-case spelling used in `status-changed` payloads
47 /// and the index.
48 pub fn as_str(self) -> &'static str {
49 match self {
50 Status::Open => "open",
51 Status::InProgress => "in-progress",
52 Status::Remediated => "remediated",
53 Status::Reopened => "reopened",
54 Status::AcceptedException => "accepted-exception",
55 Status::FalsePositive => "false-positive",
56 }
57 }
58}
59
60/// `{ model, skill }` when an agent appended the event; `None` for a direct
61/// operator action.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Agent {
64 pub model: String,
65 pub skill: String,
66}
67
68/// Binds a risk to immutable evidence by content hash. The fingerprint
69/// `<control_id>:<finding_id>` is the risk's cross-run identity.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct FindingRef {
72 pub control_id: String,
73 pub run_id: String,
74 pub manifest_sha256: String,
75 pub finding_id: String,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub body_path: Option<String>,
78}
79
80impl FindingRef {
81 /// `<control_id>:<finding_id>` — the cross-run identity of the risk this
82 /// finding produced.
83 pub fn fingerprint(&self) -> String {
84 format!("{}:{}", self.control_id, self.finding_id)
85 }
86}
87
88/// A tracker mirror created by sync-out, recorded by `external-linked`.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct ExternalLink {
91 pub system: String,
92 pub external_id: String,
93 pub url: String,
94}
95
96// ---------- event envelope --------------------------------------------------
97
98/// One line of `events.jsonl`: the immutable envelope plus its hash-chain
99/// fields. `data` is flattened so the on-disk shape is
100/// `{seq, ts, actor, agent, type, prev_sha256, data}` (the `type`/`data`
101/// pair is produced by [`EventData`]'s adjacent tagging).
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct EventEnvelope {
104 /// 1-based, monotonic within the file.
105 pub seq: u64,
106 /// ISO-8601 UTC.
107 pub ts: DateTime<Utc>,
108 /// Operator handle responsible for the change.
109 pub actor: String,
110 /// `{ model, skill }` when an agent appended this; `null` otherwise.
111 #[serde(default)]
112 pub agent: Option<Agent>,
113 /// SHA-256 of the previous canonicalised line; `None` on `seq: 1`. This
114 /// is the per-risk hash chain.
115 pub prev_sha256: Option<String>,
116 /// The typed payload (`type` + `data` on the wire).
117 #[serde(flatten)]
118 pub data: EventData,
119}
120
121/// Back-compat alias: the envelope *is* the event. Callers may use either
122/// name; `RiskEvent` reads better at use sites that talk about "events".
123pub type RiskEvent = EventEnvelope;
124
125/// The per-`type` payload. Adjacently tagged so it serialises as
126/// `{"type": "...", "data": { ... }}`, matching `risk-event.schema.json`.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(tag = "type", content = "data", rename_all = "kebab-case")]
129pub enum EventData {
130 /// Creates the risk; status → `open`.
131 Opened {
132 finding_ref: FindingRef,
133 title: String,
134 severity: Severity,
135 impact: u8,
136 likelihood: u8,
137 affected_systems: Vec<String>,
138 sla_days: u32,
139 due_at: NaiveDate,
140 },
141 OwnerAssigned {
142 owner: String,
143 },
144 /// Supersedes the score.
145 ScoreChanged {
146 impact: u8,
147 likelihood: u8,
148 severity: Severity,
149 reason: String,
150 },
151 /// Overrides the SLA due date.
152 SlaSet {
153 due_at: NaiveDate,
154 basis: String,
155 },
156 /// Moves through the status machine.
157 StatusChanged {
158 from: Status,
159 to: Status,
160 reason: String,
161 },
162 /// Appends another finding ref — how a risk re-observed in a later run
163 /// is recorded as *persisting*.
164 EvidenceLinked {
165 finding_ref: FindingRef,
166 },
167 /// Records the tracker mirror created by sync-out.
168 ExternalLinked {
169 system: String,
170 external_id: String,
171 url: String,
172 },
173 /// Advisory inbound status from a tracker. Never authoritative.
174 ExternalStatusObserved {
175 system: String,
176 status: String,
177 observed_at: DateTime<Utc>,
178 },
179 /// Free-text note; no state change.
180 Note {
181 text: String,
182 },
183 /// Shorthand for `status-changed → remediated` with evidence.
184 Remediated {
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 resolved_run_ref: Option<FindingRef>,
187 note: String,
188 },
189 /// `remediated → open`.
190 Reopened {
191 reason: String,
192 },
193 /// Status → `accepted-exception`.
194 ExceptionDocumented {
195 rationale: String,
196 approved_by: String,
197 expires_at: NaiveDate,
198 },
199}
200
201impl EventData {
202 /// The on-disk `type` discriminant string (kebab-case), useful for
203 /// timelines and diagnostics without re-serialising.
204 pub fn type_str(&self) -> &'static str {
205 match self {
206 EventData::Opened { .. } => "opened",
207 EventData::OwnerAssigned { .. } => "owner-assigned",
208 EventData::ScoreChanged { .. } => "score-changed",
209 EventData::SlaSet { .. } => "sla-set",
210 EventData::StatusChanged { .. } => "status-changed",
211 EventData::EvidenceLinked { .. } => "evidence-linked",
212 EventData::ExternalLinked { .. } => "external-linked",
213 EventData::ExternalStatusObserved { .. } => "external-status-observed",
214 EventData::Note { .. } => "note",
215 EventData::Remediated { .. } => "remediated",
216 EventData::Reopened { .. } => "reopened",
217 EventData::ExceptionDocumented { .. } => "exception-documented",
218 }
219 }
220}
221
222// ---------- folded state ----------------------------------------------------
223
224/// The current state of a risk, produced by folding its events in `seq`
225/// order. Nothing here is stored on disk — it is always recomputed from the
226/// log. See [`crate::risks::fold::fold`].
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct RiskState {
229 /// Current lifecycle status.
230 pub status: Status,
231 pub title: String,
232 pub severity: Severity,
233 pub impact: u8,
234 pub likelihood: u8,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub owner: Option<String>,
237 /// SLA due date — defaulted from `opened`, overridable by `sla-set` and
238 /// recomputed on `score-changed` when an `sla_days` basis is in effect.
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub due_at: Option<NaiveDate>,
241 /// SLA window in days, carried from `opened`; used to recompute `due_at`
242 /// when the score changes.
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub sla_days: Option<u32>,
245 pub affected_systems: Vec<String>,
246 /// Every finding ref bound to the risk, in append order. The first is
247 /// the originating finding; later ones mark re-observation (persisting).
248 pub finding_refs: Vec<FindingRef>,
249 /// Tracker mirrors created by sync-out.
250 pub external: Vec<ExternalLink>,
251 /// Latest advisory status per external system. Never authoritative.
252 #[serde(default)]
253 pub external_status: BTreeMap<String, String>,
254 /// When the risk reached `remediated`, if it has.
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub resolved_at: Option<DateTime<Utc>>,
257 /// When an `accepted-exception` expires, if one is in effect.
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub exception_expires_at: Option<NaiveDate>,
260}
261
262impl RiskState {
263 /// `<control_id>:<finding_id>` of the originating finding — the risk's
264 /// stable cross-run identity. `None` only for a degenerate empty log.
265 pub fn fingerprint(&self) -> Option<String> {
266 self.finding_refs.first().map(FindingRef::fingerprint)
267 }
268
269 /// The control id that produced the risk (from the first finding ref).
270 pub fn source_control(&self) -> Option<&str> {
271 self.finding_refs.first().map(|f| f.control_id.as_str())
272 }
273
274 /// The run id the risk was first observed in (from the first finding
275 /// ref).
276 pub fn first_run_id(&self) -> Option<&str> {
277 self.finding_refs.first().map(|f| f.run_id.as_str())
278 }
279}