Skip to main content

termivar_scanner/
threat_intelligence.rs

1//! Offline threat-intelligence records, catalogs, and rule predicates.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** opt-in via `threat-intel`.
6//! - **Execution:** no repository runtime caller (not on any default path).
7//! - **Default `termivar scan`:** no.
8//! - **Support:** experimental data models.
9//!
10//! This module does not fetch a feed, correlate observations, emit an alert,
11//! execute an alert action, or persist data. Catalog queries inspect only
12//! caller-supplied records. Alert rules evaluate one explicit severity
13//! predicate and return a pure evaluation record.
14
15use serde::{Deserialize, Deserializer, Serialize, Serializer};
16use std::collections::BTreeMap;
17
18mod cvss_score {
19    use super::*;
20
21    fn is_valid(value: f32) -> bool {
22        value.is_finite() && (0.0..=10.0).contains(&value)
23    }
24
25    pub fn serialize<S>(value: &f32, serializer: S) -> Result<S::Ok, S::Error>
26    where
27        S: Serializer,
28    {
29        if !is_valid(*value) {
30            return Err(serde::ser::Error::custom(
31                "CVSS score must be finite and in 0..=10",
32            ));
33        }
34        serializer.serialize_f32(*value)
35    }
36
37    pub fn deserialize<'de, D>(deserializer: D) -> Result<f32, D::Error>
38    where
39        D: Deserializer<'de>,
40    {
41        let value = f32::deserialize(deserializer)?;
42        if !is_valid(value) {
43            return Err(serde::de::Error::custom(
44                "CVSS score must be finite and in 0..=10",
45            ));
46        }
47        Ok(value)
48    }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52pub enum ThreatFeedSource {
53    #[serde(rename = "nvd")]
54    NVD,
55    #[serde(rename = "cisa")]
56    CISA,
57    #[serde(rename = "exploit_db")]
58    ExploitDB,
59    #[serde(rename = "mitre_att&ck")]
60    MitreAttack,
61    #[serde(rename = "custom")]
62    Custom,
63}
64
65impl ThreatFeedSource {
66    pub fn as_str(self) -> &'static str {
67        match self {
68            Self::NVD => "nvd",
69            Self::CISA => "cisa",
70            Self::ExploitDB => "exploit_db",
71            Self::MitreAttack => "mitre_att&ck",
72            Self::Custom => "custom",
73        }
74    }
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78pub struct CVERecord {
79    pub cve_id: String,
80    pub title: String,
81    pub description: String,
82    #[serde(with = "cvss_score")]
83    pub cvss_score: f32,
84    pub published_date: u64,
85    pub updated_date: u64,
86    pub affected_products: Vec<String>,
87    pub exploit_available: bool,
88    pub active_exploitation: bool,
89}
90
91/// A CVE record carried a non-finite score or a score outside `0..=10`.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct InvalidCvssScore;
94
95impl CVERecord {
96    pub fn has_valid_cvss_score(&self) -> bool {
97        self.cvss_score.is_finite() && (0.0..=10.0).contains(&self.cvss_score)
98    }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct ThreatFeedEntry {
103    pub entry_id: String,
104    pub source: ThreatFeedSource,
105    pub threat_type: String,
106    pub severity: ThreatSeverity,
107    pub description: String,
108    pub indicators: Vec<String>,
109    pub last_updated: u64,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
113pub enum ThreatSeverity {
114    #[serde(rename = "low")]
115    Low,
116    #[serde(rename = "medium")]
117    Medium,
118    #[serde(rename = "high")]
119    High,
120    #[serde(rename = "critical")]
121    Critical,
122}
123
124impl ThreatSeverity {
125    pub fn as_str(self) -> &'static str {
126        match self {
127            Self::Low => "low",
128            Self::Medium => "medium",
129            Self::High => "high",
130            Self::Critical => "critical",
131        }
132    }
133
134    pub fn score(self) -> u8 {
135        match self {
136            Self::Low => 1,
137            Self::Medium => 2,
138            Self::High => 3,
139            Self::Critical => 4,
140        }
141    }
142}
143
144/// In-memory catalog of caller-supplied CVE records.
145#[derive(Debug, Clone, Default)]
146pub struct CveCatalog {
147    records: BTreeMap<String, CVERecord>,
148}
149
150impl CveCatalog {
151    pub fn new() -> Self {
152        Self::default()
153    }
154
155    pub fn record(&mut self, record: CVERecord) -> Result<Option<CVERecord>, InvalidCvssScore> {
156        if !record.has_valid_cvss_score() {
157            return Err(InvalidCvssScore);
158        }
159        Ok(self.records.insert(record.cve_id.clone(), record))
160    }
161
162    pub fn get(&self, cve_id: &str) -> Option<&CVERecord> {
163        self.records.get(cve_id)
164    }
165
166    /// Filters valid caller-supplied scores using an explicit valid threshold.
167    pub fn records_at_or_above_cvss(&self, minimum: f32) -> Option<Vec<&CVERecord>> {
168        if !minimum.is_finite() || !(0.0..=10.0).contains(&minimum) {
169            return None;
170        }
171        Some(
172            self.records
173                .values()
174                .filter(|record| record.cvss_score >= minimum)
175                .collect(),
176        )
177    }
178
179    /// Returns records whose caller-supplied exploit flags contain evidence.
180    pub fn records_with_reported_exploit_evidence(&self) -> Vec<&CVERecord> {
181        self.records
182            .values()
183            .filter(|record| record.exploit_available || record.active_exploitation)
184            .collect()
185    }
186
187    pub fn len(&self) -> usize {
188        self.records.len()
189    }
190
191    pub fn is_empty(&self) -> bool {
192        self.records.is_empty()
193    }
194}
195
196/// In-memory catalog of caller-supplied feed-entry records.
197#[derive(Debug, Clone, Default)]
198pub struct ThreatFeedCatalog {
199    entries: BTreeMap<String, ThreatFeedEntry>,
200}
201
202impl ThreatFeedCatalog {
203    pub fn new() -> Self {
204        Self::default()
205    }
206
207    pub fn record(&mut self, entry: ThreatFeedEntry) -> Option<ThreatFeedEntry> {
208        self.entries.insert(entry.entry_id.clone(), entry)
209    }
210
211    pub fn entries_from(&self, source: ThreatFeedSource) -> Vec<&ThreatFeedEntry> {
212        self.entries
213            .values()
214            .filter(|entry| entry.source == source)
215            .collect()
216    }
217
218    pub fn entries_at_or_above(&self, minimum: ThreatSeverity) -> Vec<&ThreatFeedEntry> {
219        self.entries
220            .values()
221            .filter(|entry| entry.severity >= minimum)
222            .collect()
223    }
224
225    pub fn entries_updated_at_or_after(&self, timestamp: u64) -> Vec<&ThreatFeedEntry> {
226        self.entries
227            .values()
228            .filter(|entry| entry.last_updated >= timestamp)
229            .collect()
230    }
231
232    pub fn len(&self) -> usize {
233        self.entries.len()
234    }
235
236    pub fn is_empty(&self) -> bool {
237        self.entries.is_empty()
238    }
239}
240
241/// Action requested by a matching rule. Evaluation does not execute it.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243pub enum AlertAction {
244    #[serde(rename = "notify")]
245    Notify,
246    #[serde(rename = "isolate")]
247    Isolate,
248    #[serde(rename = "block")]
249    Block,
250    #[serde(rename = "escalate")]
251    Escalate,
252    #[serde(rename = "report")]
253    Report,
254}
255
256impl AlertAction {
257    pub fn as_str(self) -> &'static str {
258        match self {
259            Self::Notify => "notify",
260            Self::Isolate => "isolate",
261            Self::Block => "block",
262            Self::Escalate => "escalate",
263            Self::Report => "report",
264        }
265    }
266}
267
268/// A single implemented predicate: enabled and severity at or above threshold.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270pub struct AlertRule {
271    pub rule_id: String,
272    pub name: String,
273    pub severity_threshold: ThreatSeverity,
274    pub enabled: bool,
275    pub requested_actions: Vec<AlertAction>,
276}
277
278impl AlertRule {
279    pub fn evaluate(&self, observed_severity: ThreatSeverity) -> AlertEvaluation {
280        let matched = self.enabled && observed_severity >= self.severity_threshold;
281        AlertEvaluation {
282            rule_id: self.rule_id.clone(),
283            observed_severity,
284            matched,
285            requested_actions: if matched {
286                self.requested_actions.clone()
287            } else {
288                Vec::new()
289            },
290        }
291    }
292}
293
294/// Pure rule-evaluation result. No alert was delivered and no action was run.
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct AlertEvaluation {
297    pub rule_id: String,
298    pub observed_severity: ThreatSeverity,
299    pub matched: bool,
300    pub requested_actions: Vec<AlertAction>,
301}
302
303/// In-memory rule catalog. It stores rules, not emitted alerts.
304#[derive(Debug, Clone, Default)]
305pub struct AlertRuleCatalog {
306    rules: BTreeMap<String, AlertRule>,
307}
308
309impl AlertRuleCatalog {
310    pub fn new() -> Self {
311        Self::default()
312    }
313
314    pub fn record_rule(&mut self, rule: AlertRule) -> Option<AlertRule> {
315        self.rules.insert(rule.rule_id.clone(), rule)
316    }
317
318    pub fn evaluate_all(&self, observed_severity: ThreatSeverity) -> Vec<AlertEvaluation> {
319        self.rules
320            .values()
321            .map(|rule| rule.evaluate(observed_severity))
322            .collect()
323    }
324
325    pub fn matching_rules(&self, observed_severity: ThreatSeverity) -> Vec<&AlertRule> {
326        self.rules
327            .values()
328            .filter(|rule| rule.enabled && observed_severity >= rule.severity_threshold)
329            .collect()
330    }
331
332    pub fn len(&self) -> usize {
333        self.rules.len()
334    }
335
336    pub fn is_empty(&self) -> bool {
337        self.rules.is_empty()
338    }
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342pub struct ThreatActorProfile {
343    pub actor_id: String,
344    pub name: String,
345    pub aliases: Vec<String>,
346    pub techniques: Vec<String>,
347    pub infrastructure: Vec<String>,
348    pub last_seen: u64,
349    pub threat_level: ThreatSeverity,
350}
351
352/// In-memory catalog of caller-supplied actor profiles.
353#[derive(Debug, Clone, Default)]
354pub struct ThreatActorCatalog {
355    actors: BTreeMap<String, ThreatActorProfile>,
356}
357
358impl ThreatActorCatalog {
359    pub fn new() -> Self {
360        Self::default()
361    }
362
363    pub fn record(&mut self, actor: ThreatActorProfile) -> Option<ThreatActorProfile> {
364        self.actors.insert(actor.actor_id.clone(), actor)
365    }
366
367    pub fn get(&self, actor_id: &str) -> Option<&ThreatActorProfile> {
368        self.actors.get(actor_id)
369    }
370
371    pub fn actors_at_or_above(&self, minimum: ThreatSeverity) -> Vec<&ThreatActorProfile> {
372        self.actors
373            .values()
374            .filter(|actor| actor.threat_level >= minimum)
375            .collect()
376    }
377
378    pub fn len(&self) -> usize {
379        self.actors.len()
380    }
381
382    pub fn is_empty(&self) -> bool {
383        self.actors.is_empty()
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    fn cve(id: &str, score: f32) -> CVERecord {
392        CVERecord {
393            cve_id: id.into(),
394            title: "fixture".into(),
395            description: "fixture".into(),
396            cvss_score: score,
397            published_date: 1,
398            updated_date: 2,
399            affected_products: Vec::new(),
400            exploit_available: false,
401            active_exploitation: false,
402        }
403    }
404
405    #[test]
406    fn cve_catalog_queries_records_without_claiming_correlation() {
407        let mut catalog = CveCatalog::new();
408        catalog.record(cve("valid", 9.0)).unwrap();
409        assert_eq!(catalog.record(cve("invalid", 11.0)), Err(InvalidCvssScore));
410
411        let records = catalog.records_at_or_above_cvss(8.0).unwrap();
412        assert_eq!(records.len(), 1);
413        assert_eq!(records[0].cve_id, "valid");
414        assert_eq!(catalog.records_at_or_above_cvss(f32::NAN), None);
415    }
416
417    #[test]
418    fn cvss_score_round_trips_and_rejects_invalid_wire_values() {
419        let valid = cve("CVE-1", 7.5);
420        let json = serde_json::to_string(&valid).unwrap();
421        assert_eq!(serde_json::from_str::<CVERecord>(&json).unwrap(), valid);
422
423        assert!(serde_json::to_string(&cve("CVE-2", f32::NAN)).is_err());
424        let invalid = r#"{"cve_id":"CVE-3","title":"fixture","description":"fixture","cvss_score":10.1,"published_date":1,"updated_date":2,"affected_products":[],"exploit_available":false,"active_exploitation":false}"#;
425        assert!(serde_json::from_str::<CVERecord>(invalid).is_err());
426    }
427
428    #[test]
429    fn catalog_queries_are_ordered_by_record_id() {
430        let mut cves = CveCatalog::new();
431        cves.record(cve("CVE-Z", 9.0)).unwrap();
432        cves.record(cve("CVE-A", 9.0)).unwrap();
433        let ids: Vec<_> = cves
434            .records_at_or_above_cvss(0.0)
435            .unwrap()
436            .into_iter()
437            .map(|record| record.cve_id.as_str())
438            .collect();
439        assert_eq!(ids, vec!["CVE-A", "CVE-Z"]);
440
441        let mut rules = AlertRuleCatalog::new();
442        for id in ["z-rule", "a-rule"] {
443            rules.record_rule(AlertRule {
444                rule_id: id.into(),
445                name: id.into(),
446                severity_threshold: ThreatSeverity::Low,
447                enabled: true,
448                requested_actions: Vec::new(),
449            });
450        }
451        let rule_ids: Vec<_> = rules
452            .evaluate_all(ThreatSeverity::Low)
453            .into_iter()
454            .map(|evaluation| evaluation.rule_id)
455            .collect();
456        assert_eq!(rule_ids, vec!["a-rule", "z-rule"]);
457
458        let mut feeds = ThreatFeedCatalog::new();
459        for id in ["z-entry", "a-entry"] {
460            feeds.record(ThreatFeedEntry {
461                entry_id: id.into(),
462                source: ThreatFeedSource::CISA,
463                threat_type: "fixture".into(),
464                severity: ThreatSeverity::High,
465                description: "fixture".into(),
466                indicators: Vec::new(),
467                last_updated: 1,
468            });
469        }
470        let entry_ids: Vec<_> = feeds
471            .entries_at_or_above(ThreatSeverity::Low)
472            .into_iter()
473            .map(|entry| entry.entry_id.as_str())
474            .collect();
475        assert_eq!(entry_ids, vec!["a-entry", "z-entry"]);
476
477        let mut actors = ThreatActorCatalog::new();
478        for id in ["z-actor", "a-actor"] {
479            actors.record(ThreatActorProfile {
480                actor_id: id.into(),
481                name: id.into(),
482                aliases: Vec::new(),
483                techniques: Vec::new(),
484                infrastructure: Vec::new(),
485                last_seen: 1,
486                threat_level: ThreatSeverity::High,
487            });
488        }
489        let actor_ids: Vec<_> = actors
490            .actors_at_or_above(ThreatSeverity::Low)
491            .into_iter()
492            .map(|actor| actor.actor_id.as_str())
493            .collect();
494        assert_eq!(actor_ids, vec!["a-actor", "z-actor"]);
495    }
496
497    #[test]
498    fn disabled_and_below_threshold_rules_do_not_match() {
499        let disabled = AlertRule {
500            rule_id: "disabled".into(),
501            name: "disabled".into(),
502            severity_threshold: ThreatSeverity::Low,
503            enabled: false,
504            requested_actions: vec![AlertAction::Block],
505        };
506        let enabled = AlertRule {
507            rule_id: "enabled".into(),
508            name: "enabled".into(),
509            severity_threshold: ThreatSeverity::Critical,
510            enabled: true,
511            requested_actions: vec![AlertAction::Notify],
512        };
513
514        assert!(!disabled.evaluate(ThreatSeverity::Critical).matched);
515        assert!(disabled
516            .evaluate(ThreatSeverity::Critical)
517            .requested_actions
518            .is_empty());
519        assert!(!enabled.evaluate(ThreatSeverity::High).matched);
520        assert!(enabled
521            .evaluate(ThreatSeverity::High)
522            .requested_actions
523            .is_empty());
524    }
525
526    #[test]
527    fn matching_rule_returns_requested_actions_without_execution_claim() {
528        let rule = AlertRule {
529            rule_id: "rule".into(),
530            name: "fixture".into(),
531            severity_threshold: ThreatSeverity::High,
532            enabled: true,
533            requested_actions: vec![AlertAction::Escalate],
534        };
535        let evaluation = rule.evaluate(ThreatSeverity::Critical);
536        assert!(evaluation.matched);
537        assert_eq!(evaluation.requested_actions, vec![AlertAction::Escalate]);
538    }
539}