Skip to main content

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}