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    /// 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}