Skip to main content

opseclint_core/
model.rs

1//! Core data types: the knowledge base schema (deserialized from
2//! `data/knowledge.json`) and the runtime analysis results.
3
4use serde::{Deserialize, Serialize};
5
6use crate::matcher::Matcher;
7
8/// A single ATT&CK technique reference.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Technique {
11    /// The ATT&CK technique id, including any sub-technique: `T1059.001`.
12    pub id: String,
13    /// The technique's ATT&CK name, e.g. `PowerShell`.
14    pub name: String,
15}
16
17/// A representative detection signal (e.g. a Sigma rule the action would trip).
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Detection {
20    /// Where the detection comes from — `Sigma`, a vendor, an internal ruleset.
21    pub source: String,
22    /// The rule's name or title. Representative of published logic rather than a
23    /// literal rule id, unless the finding was enriched from a real ruleset.
24    pub rule: String,
25    /// How confident the knowledge base is that this detection covers the
26    /// action: `high`, `medium`, or `low`. An authored judgement, not a measured
27    /// one — [`verdict`](Detection::verdict) is the measured field.
28    pub confidence: String,
29    /// When enriched from a real ruleset, whether the rule would actually fire
30    /// on the matched command: `fires`, `no-fire`, or `indeterminate (…)`.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub verdict: Option<String>,
33}
34
35/// A non-execution event (network / file / registry) correlated back — by
36/// process id — to the execution that caused it, confirming a piece of the
37/// telemetry the entry predicts. `class` is a short tag (`network` / `file` /
38/// `registry`); `detail` is the human phrase rendered under the finding.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SideEffect {
41    /// Short event-class tag: `network`, `file`, or `registry`.
42    pub class: String,
43    /// The human-readable phrase describing what was observed.
44    pub detail: String,
45}
46
47/// One entry in the knowledge base: a rule that maps a shell action to the
48/// techniques it implements, the telemetry it emits, and the detections that
49/// would fire.
50///
51/// Matching is driven by the structured [`Matcher`] under the required `match`
52/// key. (The legacy substring fields `command` / `args_contains` /
53/// `raw_contains` were removed once every knowledge base finished migrating.)
54#[derive(Debug, Clone, Deserialize)]
55pub struct KbEntry {
56    /// Stable kebab-case identifier, unique within its knowledge base. Surfaces
57    /// as a finding's [`rule_id`](Finding::rule_id).
58    pub id: String,
59    /// The structured matcher that decides whether this entry applies to a line.
60    #[serde(rename = "match")]
61    pub matcher: Matcher,
62    /// A representative command line this entry should match, used to synthesize
63    /// an example event for `--verify-detections` / `--scaffold` and to drive the
64    /// self-consistency guard. Required for entries whose matcher uses a `regex`
65    /// leaf (a pattern cannot be reversed into a literal); optional otherwise,
66    /// where it overrides the literal-derived representative.
67    #[serde(default)]
68    pub example: Option<String>,
69    /// One line describing what a defender would observe — written from the
70    /// defender's side, not the operator's.
71    pub description: String,
72    /// The ATT&CK technique(s) this action implements.
73    pub techniques: Vec<Technique>,
74    /// The concrete host events this action produces, in the platform's own
75    /// vocabulary (`Sysmon EID 1`, `auditd execve`, `ESF NOTIFY_EXEC`, …).
76    #[serde(default)]
77    pub telemetry: Vec<String>,
78    /// Representative detections that would fire. Authored claims — run
79    /// `--verify-detections` against a real ruleset to find out which hold.
80    #[serde(default)]
81    pub detections: Vec<Detection>,
82    /// Detectability on a 0-100 scale: how likely this action is to surface in
83    /// defensive telemetry. Higher = louder.
84    pub noise: u8,
85}
86
87impl KbEntry {
88    /// A representative command line this entry matches: the author-supplied
89    /// `example` when present, otherwise one derived from the matcher's literals.
90    /// `None` only for a bare matcher with neither — which the self-consistency
91    /// guard rejects.
92    pub fn representative_line(&self) -> Option<String> {
93        self.example
94            .clone()
95            .or_else(|| self.matcher.representative_line())
96    }
97}
98
99/// The deserialized knowledge base.
100#[derive(Debug, Clone, Deserialize)]
101pub struct KnowledgeBase {
102    /// The platform this base models, as a display string.
103    pub platform: String,
104    /// The base's own caveat: what it assumes about the host's collection, and
105    /// what it does not claim. Carried into every [`Report`] so the caveat
106    /// travels with the result instead of living in documentation.
107    #[serde(default)]
108    pub note: String,
109    /// Every modeled action, in file order.
110    pub entries: Vec<KbEntry>,
111}
112
113impl KnowledgeBase {
114    /// Enforce cross-field invariants after deserialization: an entry whose
115    /// matcher uses a `regex` leaf must supply an `example` (a pattern cannot be
116    /// reversed into a representative for verification/scaffolding).
117    pub fn validate(&self) -> Result<(), String> {
118        for e in &self.entries {
119            if e.matcher.has_regex() && e.example.is_none() {
120                return Err(format!(
121                    "entry `{}` uses a regex leaf but has no `example`",
122                    e.id
123                ));
124            }
125            if let Some(event) = &e.matcher.event {
126                event
127                    .validate()
128                    .map_err(|m| format!("entry `{}`: {m}", e.id))?;
129            }
130        }
131        Ok(())
132    }
133}
134
135/// Detectability bucket derived from a numeric noise score.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "lowercase")]
138pub enum Severity {
139    /// Noise 0-24: little or nothing distinctive reaches the sensor.
140    Low,
141    /// Noise 25-49: observable, but unlikely to stand out on its own.
142    Medium,
143    /// Noise 50-74: distinctive telemetry a tuned ruleset should catch.
144    High,
145    /// Noise 75-100: loud, and widely covered by published detections.
146    Critical,
147}
148
149impl Severity {
150    /// The bucket a 0-100 detectability score falls in.
151    pub fn from_noise(noise: u8) -> Self {
152        match noise {
153            0..=24 => Severity::Low,
154            25..=49 => Severity::Medium,
155            50..=74 => Severity::High,
156            _ => Severity::Critical,
157        }
158    }
159
160    /// The uppercase display label: `LOW`, `MEDIUM`, `HIGH`, `CRITICAL`.
161    pub fn label(self) -> &'static str {
162        match self {
163            Severity::Low => "LOW",
164            Severity::Medium => "MEDIUM",
165            Severity::High => "HIGH",
166            Severity::Critical => "CRITICAL",
167        }
168    }
169}
170
171/// The sensor events one EDR product would surface for a finding, derived by
172/// classifying its native telemetry into event classes (see `edr.rs`).
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct EdrMapping {
175    /// Human-readable vendor label, e.g. "CrowdStrike Falcon".
176    pub vendor: String,
177    /// Sensor events / hunting tables for this vendor, deduplicated.
178    pub events: Vec<String>,
179}
180
181/// A single detection-coverage finding tied to a source line.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct Finding {
184    /// 1-based line of the input this finding came from. For ingested
185    /// telemetry, the 1-based record number instead.
186    pub line: usize,
187    /// The source text that produced the finding — the command line as written.
188    pub source: String,
189    /// The [`KbEntry::id`] that matched.
190    pub rule_id: String,
191    /// What a defender would observe, from the matched entry.
192    pub description: String,
193    /// The ATT&CK technique(s) this action implements.
194    pub techniques: Vec<Technique>,
195    /// The concrete host events this action produces.
196    pub telemetry: Vec<String>,
197    /// Detections that would fire. Authored claims from the knowledge base
198    /// unless the report was enriched from a real ruleset, in which case each
199    /// carries a [`verdict`](Detection::verdict).
200    pub detections: Vec<Detection>,
201    /// EDR sensor-event mappings, populated only when `--edr` is requested.
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub edr: Vec<EdrMapping>,
204    /// Non-execution events (network / file / registry) correlated by process id
205    /// to the execution this finding came from — confirmed secondary telemetry.
206    /// Populated only for ingested telemetry; empty for predictive analysis.
207    #[serde(default, skip_serializing_if = "Vec::is_empty")]
208    pub observed_side_effects: Vec<SideEffect>,
209    /// Detectability on a 0-100 scale: how strongly this action surfaces in
210    /// defensive telemetry. Higher = louder. Not a severity or a risk score —
211    /// a quiet action is not a safe one.
212    pub noise: u8,
213    /// The bucket [`noise`](Finding::noise) falls in.
214    pub severity: Severity,
215    /// The command this finding was matched from, kept for rule-logic
216    /// evaluation (coverage gaps). Not serialized.
217    #[serde(skip)]
218    pub matched_command: Option<crate::parser::Command>,
219    /// The real recorded event fields when this finding came from ingested
220    /// telemetry, so Sigma evaluation can consult fields a command line cannot
221    /// supply (`ParentImage`, `User`, `IntegrityLevel`, …). `None` for predictive
222    /// (text) analysis. Shared (`Arc`) so the several findings a single record
223    /// produces point at one event map rather than each deep-cloning it. Not
224    /// serialized.
225    #[serde(skip)]
226    pub observed_event: Option<std::sync::Arc<std::collections::HashMap<String, String>>>,
227}
228
229/// The full report for an analyzed input.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct Report {
232    /// The platform analyzed against, from [`KnowledgeBase::platform`].
233    pub platform: String,
234    /// The knowledge base's caveat about what it assumes and does not claim,
235    /// carried through from [`KnowledgeBase::note`]. Surface it alongside the
236    /// findings: it is what keeps an empty `findings` from reading as proof
237    /// that nothing would be seen.
238    #[serde(default)]
239    pub note: String,
240    /// Every match, deduplicated per line and ranked loudest-first.
241    ///
242    /// An empty vector means no *modeled* action matched — the knowledge base
243    /// covers a bounded set, so this is not evidence that the input is
244    /// invisible.
245    pub findings: Vec<Finding>,
246    /// The loudest [`Finding::noise`] in the report, or 0 when there are none.
247    pub max_noise: u8,
248    /// How many logical lines were analyzed, including ones that matched
249    /// nothing — the denominator that makes a finding count meaningful.
250    #[serde(default)]
251    pub lines_analyzed: usize,
252}
253
254impl Report {
255    /// The bucket of the loudest finding in this report.
256    pub fn max_severity(&self) -> Severity {
257        Severity::from_noise(self.max_noise)
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn kb_with(matcher_json: &str, example: Option<&str>) -> KnowledgeBase {
266        let matcher: Matcher = serde_json::from_str(matcher_json).expect("matcher parses");
267        KnowledgeBase {
268            platform: "linux".into(),
269            note: String::new(),
270            entries: vec![KbEntry {
271                id: "x".into(),
272                matcher,
273                example: example.map(str::to_string),
274                description: "d".into(),
275                techniques: vec![],
276                telemetry: vec![],
277                detections: vec![],
278                noise: 10,
279            }],
280        }
281    }
282
283    #[test]
284    fn validate_requires_example_for_regex_entries() {
285        // A regex entry without an example is rejected...
286        assert!(
287            kb_with(r#"{ "line": { "regex": "foo" } }"#, None)
288                .validate()
289                .is_err()
290        );
291        // ...with one it is accepted, and non-regex entries never need one.
292        assert!(
293            kb_with(r#"{ "line": { "regex": "foo" } }"#, Some("foobar"))
294                .validate()
295                .is_ok()
296        );
297        assert!(
298            kb_with(r#"{ "line": { "contains": "foo" } }"#, None)
299                .validate()
300                .is_ok()
301        );
302    }
303}