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