1use 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#[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#[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 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 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#[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#[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#[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#[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#[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#[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}