Skip to main content

ipfrs_network/
security_monitor.rs

1//! NetworkSecurityMonitor — Real-time network security monitoring with anomaly
2//! detection, threat scoring, and incident management.
3//!
4//! # Overview
5//!
6//! This module provides production-grade security monitoring for a peer-to-peer
7//! network. It tracks [`SecurityEvent`]s per peer, computes time-decaying
8//! [`ThreatScore`]s, and automatically opens [`SecurityIncident`]s when a
9//! peer's score crosses a configurable threshold.
10//!
11//! ## Scoring Model
12//!
13//! Each [`ThreatLevel`] contributes a fixed number of points to a peer's score:
14//!
15//! | Level    | Points |
16//! |----------|--------|
17//! | None     | 0      |
18//! | Low      | 5      |
19//! | Medium   | 15     |
20//! | High     | 30     |
21//! | Critical | 50     |
22//!
23//! Scores decay by 10 % per elapsed hour (integer hours):
24//! `score *= 0.9 ^ hours_since_last_update`.
25//!
26//! ## Event ID
27//!
28//! Event IDs are computed with FNV-1a over the concatenation of
29//! `peer_id + threat_type_name + timestamp_string`.
30
31use std::collections::{HashMap, VecDeque};
32
33// ---------------------------------------------------------------------------
34// FNV-1a helpers
35// ---------------------------------------------------------------------------
36
37/// FNV-1a 64-bit offset basis.
38const FNV1A_OFFSET: u64 = 14_695_981_039_346_656_037;
39/// FNV-1a 64-bit prime.
40const FNV1A_PRIME: u64 = 1_099_511_628_211;
41
42/// Compute FNV-1a 64-bit hash over an arbitrary byte slice.
43fn fnv1a(data: &[u8]) -> u64 {
44    let mut hash = FNV1A_OFFSET;
45    for &byte in data {
46        hash ^= byte as u64;
47        hash = hash.wrapping_mul(FNV1A_PRIME);
48    }
49    hash
50}
51
52/// Compute a deterministic event ID from peer id, threat type name, and timestamp.
53fn compute_event_id(peer_id: &str, threat_type_name: &str, timestamp: u64) -> u64 {
54    let mut raw = String::with_capacity(
55        peer_id.len() + threat_type_name.len() + 20, // 20 bytes is enough for u64
56    );
57    raw.push_str(peer_id);
58    raw.push_str(threat_type_name);
59    raw.push_str(&timestamp.to_string());
60    fnv1a(raw.as_bytes())
61}
62
63// ---------------------------------------------------------------------------
64// ThreatType
65// ---------------------------------------------------------------------------
66
67/// The category of a detected threat.
68#[derive(Debug, Clone, PartialEq, Eq, Hash)]
69pub enum ThreatType {
70    /// Sybil attack — a single entity controls many pseudo-identities.
71    Sybil,
72    /// Eclipse attack — routing table is flooded with attacker-controlled peers.
73    Eclipse,
74    /// Distributed Denial of Service.
75    DDoS,
76    /// Man-in-the-Middle interception or tampering.
77    ManInTheMiddle,
78    /// Replay of previously captured messages.
79    ReplayAttack,
80    /// Deliberate poisoning of routing tables.
81    RoutingPoison,
82    /// Modification of content in transit or at rest.
83    DataTampering,
84}
85
86impl ThreatType {
87    /// Return the canonical string name used in ID hashing.
88    pub fn name(&self) -> &'static str {
89        match self {
90            ThreatType::Sybil => "Sybil",
91            ThreatType::Eclipse => "Eclipse",
92            ThreatType::DDoS => "DDoS",
93            ThreatType::ManInTheMiddle => "ManInTheMiddle",
94            ThreatType::ReplayAttack => "ReplayAttack",
95            ThreatType::RoutingPoison => "RoutingPoison",
96            ThreatType::DataTampering => "DataTampering",
97        }
98    }
99}
100
101// ---------------------------------------------------------------------------
102// ThreatLevel
103// ---------------------------------------------------------------------------
104
105/// Ordered severity of a detected threat. `Critical > High > Medium > Low > None`.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum ThreatLevel {
108    /// No threat — informational / clean baseline event.
109    None,
110    /// Low-severity anomaly; worth tracking but unlikely harmful.
111    Low,
112    /// Medium-severity anomaly; investigation recommended.
113    Medium,
114    /// High-severity threat; immediate attention required.
115    High,
116    /// Critical threat; automatic incident creation and possible ban.
117    Critical,
118}
119
120impl ThreatLevel {
121    /// Score points contributed to a peer's [`ThreatScore`] by one event at
122    /// this level.
123    pub fn score_contribution(self) -> f64 {
124        match self {
125            ThreatLevel::None => 0.0,
126            ThreatLevel::Low => 5.0,
127            ThreatLevel::Medium => 15.0,
128            ThreatLevel::High => 30.0,
129            ThreatLevel::Critical => 50.0,
130        }
131    }
132
133    /// Integer rank used for comparison.
134    fn rank(self) -> u8 {
135        match self {
136            ThreatLevel::None => 0,
137            ThreatLevel::Low => 1,
138            ThreatLevel::Medium => 2,
139            ThreatLevel::High => 3,
140            ThreatLevel::Critical => 4,
141        }
142    }
143}
144
145impl PartialOrd for ThreatLevel {
146    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
147        Some(self.cmp(other))
148    }
149}
150
151impl Ord for ThreatLevel {
152    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
153        self.rank().cmp(&other.rank())
154    }
155}
156
157// ---------------------------------------------------------------------------
158// SecurityEvent
159// ---------------------------------------------------------------------------
160
161/// A single security-relevant observation about a peer.
162#[derive(Debug, Clone)]
163pub struct SecurityEvent {
164    /// Deterministic FNV-1a ID: hash of `peer_id + threat_type.name() + timestamp`.
165    pub id: u64,
166    /// The peer that triggered this event.
167    pub peer_id: String,
168    /// Category of threat observed.
169    pub threat_type: ThreatType,
170    /// Severity of the threat.
171    pub threat_level: ThreatLevel,
172    /// Human-readable description.
173    pub description: String,
174    /// Unix epoch in milliseconds at which the event was recorded.
175    pub timestamp: u64,
176    /// Supporting evidence strings (packet hashes, CIDs, log snippets, …).
177    pub evidence: Vec<String>,
178}
179
180impl SecurityEvent {
181    /// Create a new `SecurityEvent`, computing the ID automatically.
182    pub fn new(
183        peer_id: String,
184        threat_type: ThreatType,
185        threat_level: ThreatLevel,
186        description: String,
187        timestamp: u64,
188        evidence: Vec<String>,
189    ) -> Self {
190        let id = compute_event_id(&peer_id, threat_type.name(), timestamp);
191        SecurityEvent {
192            id,
193            peer_id,
194            threat_type,
195            threat_level,
196            description,
197            timestamp,
198            evidence,
199        }
200    }
201}
202
203// ---------------------------------------------------------------------------
204// ThreatScore
205// ---------------------------------------------------------------------------
206
207/// Aggregated threat score for a peer, with time-based decay.
208#[derive(Debug, Clone)]
209pub struct ThreatScore {
210    /// The peer this score belongs to.
211    pub peer_id: String,
212    /// Current score in the range `[0.0, 100.0]`.  100.0 = definitely malicious.
213    pub score: f64,
214    /// Number of events that have contributed to this score.
215    pub contributing_events: u32,
216    /// Timestamp (ms since epoch) when the score was last updated.
217    pub last_updated: u64,
218}
219
220impl ThreatScore {
221    /// Create a fresh score for `peer_id`, starting at 0.
222    pub fn new(peer_id: String, now: u64) -> Self {
223        ThreatScore {
224            peer_id,
225            score: 0.0,
226            contributing_events: 0,
227            last_updated: now,
228        }
229    }
230
231    /// Add `points` to the score, capping at 100.0, and record the update time.
232    pub fn add(&mut self, points: f64, now: u64) {
233        self.score = (self.score + points).min(100.0);
234        self.contributing_events = self.contributing_events.saturating_add(1);
235        self.last_updated = now;
236    }
237}
238
239// ---------------------------------------------------------------------------
240// IncidentStatus
241// ---------------------------------------------------------------------------
242
243/// Lifecycle status of a [`SecurityIncident`].
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub enum IncidentStatus {
246    /// Newly created; not yet assigned.
247    Open,
248    /// Assigned and actively being investigated.
249    Investigating,
250    /// Confirmed and resolved.
251    Resolved,
252    /// Marked as a false positive; no further action required.
253    FalsePositive,
254}
255
256// ---------------------------------------------------------------------------
257// SecurityIncident
258// ---------------------------------------------------------------------------
259
260/// A group of related security events that warrants coordinated response.
261#[derive(Debug, Clone)]
262pub struct SecurityIncident {
263    /// FNV-1a ID computed at creation time.
264    pub id: u64,
265    /// IDs of the [`SecurityEvent`]s that constitute this incident.
266    pub events: Vec<u64>,
267    /// Current lifecycle status.
268    pub status: IncidentStatus,
269    /// When the incident was created (ms epoch).
270    pub created_at: u64,
271    /// When the incident was resolved, if applicable.
272    pub resolved_at: Option<u64>,
273}
274
275// ---------------------------------------------------------------------------
276// SecurityMonitorStats
277// ---------------------------------------------------------------------------
278
279/// Aggregate statistics snapshot from [`NetworkSecurityMonitor`].
280#[derive(Debug, Clone)]
281pub struct SecurityMonitorStats {
282    /// Total events currently buffered.
283    pub total_events: usize,
284    /// Number of incidents with status `Open` or `Investigating`.
285    pub open_incidents: usize,
286    /// Number of incidents with status `Resolved` or `FalsePositive`.
287    pub resolved_incidents: usize,
288    /// Number of peers with a current score ≥ 50.
289    pub high_threat_peers: usize,
290    /// Mean current score across all tracked peers (0.0 if none).
291    pub avg_threat_score: f64,
292}
293
294// ---------------------------------------------------------------------------
295// NetworkSecurityMonitor
296// ---------------------------------------------------------------------------
297
298/// Real-time network security monitor.
299///
300/// Maintains a rolling event buffer, per-peer threat scores with exponential
301/// decay, and incident tracking.  All timestamps are expected as milliseconds
302/// since Unix epoch.
303pub struct NetworkSecurityMonitor {
304    /// Rolling buffer of recent security events.
305    events: VecDeque<SecurityEvent>,
306    /// Per-peer threat scores.
307    scores: HashMap<String, ThreatScore>,
308    /// All incidents, ordered by creation time.
309    incidents: Vec<SecurityIncident>,
310    /// Maximum number of events held in the rolling buffer.
311    max_events: usize,
312    /// Score threshold above which an incident is automatically created.
313    threat_threshold: f64,
314    /// Monotonically increasing counter for generating incident IDs.
315    next_id: u64,
316}
317
318impl NetworkSecurityMonitor {
319    // -----------------------------------------------------------------------
320    // Construction
321    // -----------------------------------------------------------------------
322
323    /// Create a new monitor with the given capacity and auto-incident threshold.
324    ///
325    /// # Arguments
326    ///
327    /// * `max_events`       – Maximum events kept in the rolling buffer.
328    /// * `threat_threshold` – Score level (0..100) that triggers automatic
329    ///   incident creation.  Defaults to 50.0 if `0.0` is supplied.
330    pub fn new(max_events: usize, threat_threshold: f64) -> Self {
331        let threshold = if threat_threshold <= 0.0 {
332            50.0
333        } else {
334            threat_threshold
335        };
336        NetworkSecurityMonitor {
337            events: VecDeque::with_capacity(max_events.min(4096)),
338            scores: HashMap::new(),
339            incidents: Vec::new(),
340            max_events,
341            threat_threshold: threshold,
342            next_id: 1,
343        }
344    }
345
346    /// Convenience constructor with sensible defaults.
347    pub fn default_monitor() -> Self {
348        Self::new(10_000, 50.0)
349    }
350
351    // -----------------------------------------------------------------------
352    // Event recording
353    // -----------------------------------------------------------------------
354
355    /// Record a security event, update the peer's threat score, and
356    /// auto-create an incident if the score exceeds the threshold.
357    ///
358    /// Returns the ID of the newly created event.
359    pub fn record_event(
360        &mut self,
361        peer_id: String,
362        threat_type: ThreatType,
363        level: ThreatLevel,
364        description: String,
365        evidence: Vec<String>,
366        now: u64,
367    ) -> u64 {
368        // Build and store the event.
369        let event = SecurityEvent::new(
370            peer_id.clone(),
371            threat_type,
372            level,
373            description,
374            now,
375            evidence,
376        );
377        let event_id = event.id;
378
379        // Enforce rolling-buffer capacity.
380        if self.events.len() >= self.max_events {
381            self.events.pop_front();
382        }
383        self.events.push_back(event);
384
385        // Update peer score.
386        let points = level.score_contribution();
387        let score_entry = self
388            .scores
389            .entry(peer_id.clone())
390            .or_insert_with(|| ThreatScore::new(peer_id.clone(), now));
391
392        // Apply decay first so the new points are added to the decayed base.
393        Self::apply_decay_inner(score_entry, now);
394        score_entry.add(points, now);
395
396        let current_score = score_entry.score;
397
398        // Auto-create incident when threshold crossed and no open incident
399        // already exists for this peer.
400        if current_score >= self.threat_threshold && !self.has_open_incident_for_peer(&peer_id) {
401            let peer_events: Vec<u64> = self
402                .events
403                .iter()
404                .filter(|e| e.peer_id == peer_id)
405                .map(|e| e.id)
406                .collect();
407            self.create_incident(peer_events, now);
408        }
409
410        event_id
411    }
412
413    // -----------------------------------------------------------------------
414    // Threat scoring
415    // -----------------------------------------------------------------------
416
417    /// Return the current decay-adjusted threat score for `peer_id`.
418    /// Returns `0.0` for unknown peers.
419    pub fn threat_score(&mut self, peer_id: &str, now: u64) -> f64 {
420        match self.scores.get_mut(peer_id) {
421            None => 0.0,
422            Some(score) => {
423                Self::apply_decay_inner(score, now);
424                score.score
425            }
426        }
427    }
428
429    /// Apply hourly 10 % decay to a [`ThreatScore`].
430    ///
431    /// `score *= 0.9 ^ floor((now - last_updated) / 3_600_000)`
432    ///
433    /// This is a pure (no-`self`) helper so it can be called with an
434    /// `&mut ThreatScore` extracted from the map while the monitor is also
435    /// borrowed.
436    pub fn apply_decay(score: &mut ThreatScore, now: u64) {
437        Self::apply_decay_inner(score, now);
438    }
439
440    fn apply_decay_inner(score: &mut ThreatScore, now: u64) {
441        if now <= score.last_updated {
442            return;
443        }
444        let elapsed_ms = now - score.last_updated;
445        let hours = elapsed_ms / 3_600_000;
446        if hours == 0 {
447            return;
448        }
449        // 0.9^hours
450        let factor = 0.9_f64.powi(hours as i32);
451        score.score *= factor;
452        score.last_updated = now;
453    }
454
455    // -----------------------------------------------------------------------
456    // Query helpers
457    // -----------------------------------------------------------------------
458
459    /// Return the top `n` [`ThreatScore`] references, sorted by score descending,
460    /// after applying decay at the given timestamp.
461    pub fn top_threats(&mut self, n: usize, now: u64) -> Vec<&ThreatScore> {
462        // Apply decay to all entries.
463        for score in self.scores.values_mut() {
464            Self::apply_decay_inner(score, now);
465        }
466        let mut refs: Vec<&ThreatScore> = self.scores.values().collect();
467        refs.sort_by(|a, b| {
468            b.score
469                .partial_cmp(&a.score)
470                .unwrap_or(std::cmp::Ordering::Equal)
471        });
472        refs.truncate(n);
473        refs
474    }
475
476    /// All events recorded for `peer_id`, in insertion order.
477    pub fn events_for_peer(&self, peer_id: &str) -> Vec<&SecurityEvent> {
478        self.events
479            .iter()
480            .filter(|e| e.peer_id == peer_id)
481            .collect()
482    }
483
484    /// All events of the given threat type, in insertion order.
485    pub fn events_by_threat_type(&self, threat_type: &ThreatType) -> Vec<&SecurityEvent> {
486        self.events
487            .iter()
488            .filter(|e| &e.threat_type == threat_type)
489            .collect()
490    }
491
492    /// All events with a timestamp ≥ `timestamp`.
493    pub fn events_since(&self, timestamp: u64) -> Vec<&SecurityEvent> {
494        self.events
495            .iter()
496            .filter(|e| e.timestamp >= timestamp)
497            .collect()
498    }
499
500    // -----------------------------------------------------------------------
501    // Incident management
502    // -----------------------------------------------------------------------
503
504    /// Create a new incident from a list of event IDs.  Returns the new incident's ID.
505    pub fn create_incident(&mut self, events: Vec<u64>, now: u64) -> u64 {
506        let id = self.next_incident_id(now, events.len() as u64);
507        let incident = SecurityIncident {
508            id,
509            events,
510            status: IncidentStatus::Open,
511            created_at: now,
512            resolved_at: None,
513        };
514        self.incidents.push(incident);
515        id
516    }
517
518    /// Update the status of incident `incident_id`.  Returns `true` on success.
519    pub fn update_incident_status(&mut self, incident_id: u64, status: IncidentStatus) -> bool {
520        match self.incidents.iter_mut().find(|i| i.id == incident_id) {
521            None => false,
522            Some(incident) => {
523                incident.status = status;
524                true
525            }
526        }
527    }
528
529    /// All incidents with status `Open` or `Investigating`.
530    pub fn open_incidents(&self) -> Vec<&SecurityIncident> {
531        self.incidents
532            .iter()
533            .filter(|i| {
534                i.status == IncidentStatus::Open || i.status == IncidentStatus::Investigating
535            })
536            .collect()
537    }
538
539    // -----------------------------------------------------------------------
540    // Maintenance
541    // -----------------------------------------------------------------------
542
543    /// Remove all events and the score entry for `peer_id`.
544    /// Returns the number of events removed.
545    pub fn clear_peer_history(&mut self, peer_id: &str) -> usize {
546        let before = self.events.len();
547        self.events.retain(|e| e.peer_id != peer_id);
548        let removed = before - self.events.len();
549        self.scores.remove(peer_id);
550        removed
551    }
552
553    // -----------------------------------------------------------------------
554    // Statistics
555    // -----------------------------------------------------------------------
556
557    /// Compute a statistics snapshot at the given timestamp (applying decay
558    /// before computing averages).
559    pub fn stats(&mut self, now: u64) -> SecurityMonitorStats {
560        // Apply decay to all scores.
561        for score in self.scores.values_mut() {
562            Self::apply_decay_inner(score, now);
563        }
564
565        let total_events = self.events.len();
566        let open_incidents = self.open_incidents().len();
567        let resolved_incidents = self
568            .incidents
569            .iter()
570            .filter(|i| {
571                i.status == IncidentStatus::Resolved || i.status == IncidentStatus::FalsePositive
572            })
573            .count();
574
575        let high_threat_peers = self.scores.values().filter(|s| s.score >= 50.0).count();
576
577        let avg_threat_score = if self.scores.is_empty() {
578            0.0
579        } else {
580            let sum: f64 = self.scores.values().map(|s| s.score).sum();
581            sum / self.scores.len() as f64
582        };
583
584        SecurityMonitorStats {
585            total_events,
586            open_incidents,
587            resolved_incidents,
588            high_threat_peers,
589            avg_threat_score,
590        }
591    }
592
593    // -----------------------------------------------------------------------
594    // Private helpers
595    // -----------------------------------------------------------------------
596
597    /// Check whether an open or investigating incident already exists that
598    /// contains at least one event for `peer_id`.
599    fn has_open_incident_for_peer(&self, peer_id: &str) -> bool {
600        // Collect event IDs for this peer.
601        let peer_event_ids: std::collections::HashSet<u64> = self
602            .events
603            .iter()
604            .filter(|e| e.peer_id == peer_id)
605            .map(|e| e.id)
606            .collect();
607
608        self.incidents.iter().any(|inc| {
609            (inc.status == IncidentStatus::Open || inc.status == IncidentStatus::Investigating)
610                && inc.events.iter().any(|eid| peer_event_ids.contains(eid))
611        })
612    }
613
614    /// Generate a new, unique incident ID using FNV-1a over the counter + now.
615    fn next_incident_id(&mut self, now: u64, extra: u64) -> u64 {
616        let counter = self.next_id;
617        self.next_id = self.next_id.wrapping_add(1);
618        let mut data = [0u8; 24];
619        data[0..8].copy_from_slice(&counter.to_le_bytes());
620        data[8..16].copy_from_slice(&now.to_le_bytes());
621        data[16..24].copy_from_slice(&extra.to_le_bytes());
622        fnv1a(&data)
623    }
624}
625
626// ---------------------------------------------------------------------------
627// Tests
628// ---------------------------------------------------------------------------
629
630#[cfg(test)]
631mod tests {
632    use crate::security_monitor::{
633        IncidentStatus, NetworkSecurityMonitor, SecurityEvent, ThreatLevel, ThreatScore, ThreatType,
634    };
635
636    // ------------------------------------------------------------------
637    // Helper
638    // ------------------------------------------------------------------
639
640    fn make_monitor() -> NetworkSecurityMonitor {
641        NetworkSecurityMonitor::new(100, 50.0)
642    }
643
644    const T0: u64 = 1_700_000_000_000; // arbitrary epoch ms
645
646    fn record(
647        mon: &mut NetworkSecurityMonitor,
648        peer: &str,
649        tt: ThreatType,
650        level: ThreatLevel,
651    ) -> u64 {
652        mon.record_event(
653            peer.to_owned(),
654            tt,
655            level,
656            "test".to_owned(),
657            vec!["evidence".to_owned()],
658            T0,
659        )
660    }
661
662    // ------------------------------------------------------------------
663    // ThreatLevel ordering
664    // ------------------------------------------------------------------
665
666    #[test]
667    fn threat_level_ordering_none_is_smallest() {
668        assert!(ThreatLevel::None < ThreatLevel::Low);
669    }
670
671    #[test]
672    fn threat_level_ordering_critical_is_largest() {
673        assert!(ThreatLevel::Critical > ThreatLevel::High);
674    }
675
676    #[test]
677    fn threat_level_ordering_full_chain() {
678        let levels = [
679            ThreatLevel::None,
680            ThreatLevel::Low,
681            ThreatLevel::Medium,
682            ThreatLevel::High,
683            ThreatLevel::Critical,
684        ];
685        for window in levels.windows(2) {
686            assert!(window[0] < window[1]);
687        }
688    }
689
690    #[test]
691    fn threat_level_eq() {
692        assert_eq!(ThreatLevel::High, ThreatLevel::High);
693        assert_ne!(ThreatLevel::Low, ThreatLevel::Medium);
694    }
695
696    // ------------------------------------------------------------------
697    // Score contributions
698    // ------------------------------------------------------------------
699
700    #[test]
701    fn score_contribution_none_is_zero() {
702        assert_eq!(ThreatLevel::None.score_contribution(), 0.0);
703    }
704
705    #[test]
706    fn score_contribution_values() {
707        assert_eq!(ThreatLevel::Low.score_contribution(), 5.0);
708        assert_eq!(ThreatLevel::Medium.score_contribution(), 15.0);
709        assert_eq!(ThreatLevel::High.score_contribution(), 30.0);
710        assert_eq!(ThreatLevel::Critical.score_contribution(), 50.0);
711    }
712
713    // ------------------------------------------------------------------
714    // Event IDs
715    // ------------------------------------------------------------------
716
717    #[test]
718    fn event_id_is_deterministic() {
719        let e1 = SecurityEvent::new(
720            "peer1".to_owned(),
721            ThreatType::Sybil,
722            ThreatLevel::Low,
723            "desc".to_owned(),
724            T0,
725            vec![],
726        );
727        let e2 = SecurityEvent::new(
728            "peer1".to_owned(),
729            ThreatType::Sybil,
730            ThreatLevel::Low,
731            "desc".to_owned(),
732            T0,
733            vec![],
734        );
735        assert_eq!(e1.id, e2.id);
736    }
737
738    #[test]
739    fn event_id_differs_for_different_peers() {
740        let e1 = SecurityEvent::new(
741            "peerA".to_owned(),
742            ThreatType::DDoS,
743            ThreatLevel::High,
744            "x".to_owned(),
745            T0,
746            vec![],
747        );
748        let e2 = SecurityEvent::new(
749            "peerB".to_owned(),
750            ThreatType::DDoS,
751            ThreatLevel::High,
752            "x".to_owned(),
753            T0,
754            vec![],
755        );
756        assert_ne!(e1.id, e2.id);
757    }
758
759    #[test]
760    fn event_id_differs_for_different_timestamps() {
761        let e1 = SecurityEvent::new(
762            "peer1".to_owned(),
763            ThreatType::Eclipse,
764            ThreatLevel::Medium,
765            "x".to_owned(),
766            T0,
767            vec![],
768        );
769        let e2 = SecurityEvent::new(
770            "peer1".to_owned(),
771            ThreatType::Eclipse,
772            ThreatLevel::Medium,
773            "x".to_owned(),
774            T0 + 1,
775            vec![],
776        );
777        assert_ne!(e1.id, e2.id);
778    }
779
780    // ------------------------------------------------------------------
781    // record_event / threat_score
782    // ------------------------------------------------------------------
783
784    #[test]
785    fn record_event_returns_nonzero_id() {
786        let mut mon = make_monitor();
787        let id = record(&mut mon, "peer1", ThreatType::Sybil, ThreatLevel::Low);
788        assert_ne!(id, 0);
789    }
790
791    #[test]
792    fn threat_score_unknown_peer_is_zero() {
793        let mut mon = make_monitor();
794        assert_eq!(mon.threat_score("ghost", T0), 0.0);
795    }
796
797    #[test]
798    fn threat_score_accumulates_after_low_event() {
799        let mut mon = make_monitor();
800        record(&mut mon, "peer1", ThreatType::Sybil, ThreatLevel::Low);
801        let score = mon.threat_score("peer1", T0);
802        assert!((score - 5.0).abs() < 1e-9);
803    }
804
805    #[test]
806    fn threat_score_accumulates_multiple_events() {
807        let mut mon = make_monitor();
808        record(&mut mon, "peer1", ThreatType::DDoS, ThreatLevel::Low); // +5
809        record(&mut mon, "peer1", ThreatType::DDoS, ThreatLevel::Medium); // +15
810        let score = mon.threat_score("peer1", T0);
811        assert!((score - 20.0).abs() < 1e-9);
812    }
813
814    #[test]
815    fn threat_score_capped_at_100() {
816        let mut mon = make_monitor();
817        for _ in 0..10 {
818            record(&mut mon, "peer1", ThreatType::DDoS, ThreatLevel::Critical); // +50 each
819        }
820        let score = mon.threat_score("peer1", T0);
821        assert!(score <= 100.0);
822    }
823
824    // ------------------------------------------------------------------
825    // Decay
826    // ------------------------------------------------------------------
827
828    #[test]
829    fn decay_no_change_within_same_hour() {
830        let mut score = ThreatScore::new("p".to_owned(), T0);
831        score.add(40.0, T0);
832        NetworkSecurityMonitor::apply_decay(&mut score, T0 + 3_599_999);
833        assert!((score.score - 40.0).abs() < 1e-9);
834    }
835
836    #[test]
837    fn decay_one_hour_reduces_by_10_percent() {
838        let mut score = ThreatScore::new("p".to_owned(), T0);
839        score.add(100.0, T0);
840        NetworkSecurityMonitor::apply_decay(&mut score, T0 + 3_600_000);
841        let expected = 100.0 * 0.9;
842        assert!((score.score - expected).abs() < 1e-6);
843    }
844
845    #[test]
846    fn decay_two_hours() {
847        let mut score = ThreatScore::new("p".to_owned(), T0);
848        score.add(100.0, T0);
849        NetworkSecurityMonitor::apply_decay(&mut score, T0 + 7_200_000);
850        let expected = 100.0 * 0.9_f64.powi(2);
851        assert!((score.score - expected).abs() < 1e-6);
852    }
853
854    #[test]
855    fn decay_updates_last_updated_timestamp() {
856        let t1 = T0 + 3_600_000;
857        let mut score = ThreatScore::new("p".to_owned(), T0);
858        score.add(50.0, T0);
859        NetworkSecurityMonitor::apply_decay(&mut score, t1);
860        assert_eq!(score.last_updated, t1);
861    }
862
863    #[test]
864    fn decay_noop_when_now_equals_last_updated() {
865        let mut score = ThreatScore::new("p".to_owned(), T0);
866        score.add(60.0, T0);
867        NetworkSecurityMonitor::apply_decay(&mut score, T0);
868        assert!((score.score - 60.0).abs() < 1e-9);
869    }
870
871    // ------------------------------------------------------------------
872    // Auto-incident creation
873    // ------------------------------------------------------------------
874
875    #[test]
876    fn auto_incident_created_when_threshold_crossed() {
877        let mut mon = NetworkSecurityMonitor::new(100, 50.0);
878        // 2× Critical = 100 pts → should cross threshold 50
879        record(
880            &mut mon,
881            "bad_peer",
882            ThreatType::Eclipse,
883            ThreatLevel::Critical,
884        );
885        let open = mon.open_incidents();
886        assert_eq!(open.len(), 1);
887    }
888
889    #[test]
890    fn no_duplicate_incident_for_same_peer() {
891        let mut mon = NetworkSecurityMonitor::new(100, 10.0);
892        // Each Critical adds 50; threshold is 10
893        record(
894            &mut mon,
895            "bad_peer",
896            ThreatType::Sybil,
897            ThreatLevel::Critical,
898        );
899        record(
900            &mut mon,
901            "bad_peer",
902            ThreatType::Sybil,
903            ThreatLevel::Critical,
904        );
905        let open = mon.open_incidents();
906        assert_eq!(open.len(), 1);
907    }
908
909    #[test]
910    fn no_incident_below_threshold() {
911        let mut mon = NetworkSecurityMonitor::new(100, 80.0);
912        record(&mut mon, "peer1", ThreatType::DDoS, ThreatLevel::Low); // +5
913        assert!(mon.open_incidents().is_empty());
914    }
915
916    // ------------------------------------------------------------------
917    // Incident lifecycle
918    // ------------------------------------------------------------------
919
920    #[test]
921    fn create_incident_manually() {
922        let mut mon = make_monitor();
923        let id = mon.create_incident(vec![1, 2, 3], T0);
924        assert_ne!(id, 0);
925        assert_eq!(mon.open_incidents().len(), 1);
926    }
927
928    #[test]
929    fn update_incident_status_to_investigating() {
930        let mut mon = make_monitor();
931        let id = mon.create_incident(vec![], T0);
932        assert!(mon.update_incident_status(id, IncidentStatus::Investigating));
933        let open = mon.open_incidents();
934        assert_eq!(open.len(), 1);
935        assert_eq!(open[0].status, IncidentStatus::Investigating);
936    }
937
938    #[test]
939    fn update_incident_status_to_resolved() {
940        let mut mon = make_monitor();
941        let id = mon.create_incident(vec![], T0);
942        mon.update_incident_status(id, IncidentStatus::Resolved);
943        assert!(mon.open_incidents().is_empty());
944    }
945
946    #[test]
947    fn update_incident_status_false_positive() {
948        let mut mon = make_monitor();
949        let id = mon.create_incident(vec![], T0);
950        mon.update_incident_status(id, IncidentStatus::FalsePositive);
951        assert!(mon.open_incidents().is_empty());
952    }
953
954    #[test]
955    fn update_incident_unknown_id_returns_false() {
956        let mut mon = make_monitor();
957        assert!(!mon.update_incident_status(9_999_999, IncidentStatus::Resolved));
958    }
959
960    // ------------------------------------------------------------------
961    // Query methods
962    // ------------------------------------------------------------------
963
964    #[test]
965    fn events_for_peer_returns_only_matching() {
966        let mut mon = make_monitor();
967        record(&mut mon, "peerA", ThreatType::Sybil, ThreatLevel::Low);
968        record(&mut mon, "peerB", ThreatType::DDoS, ThreatLevel::High);
969        let evs = mon.events_for_peer("peerA");
970        assert_eq!(evs.len(), 1);
971        assert_eq!(evs[0].peer_id, "peerA");
972    }
973
974    #[test]
975    fn events_for_peer_empty_for_unknown() {
976        let mon = make_monitor();
977        assert!(mon.events_for_peer("nobody").is_empty());
978    }
979
980    #[test]
981    fn events_by_threat_type_filters_correctly() {
982        let mut mon = make_monitor();
983        record(&mut mon, "p1", ThreatType::Sybil, ThreatLevel::Low);
984        record(&mut mon, "p2", ThreatType::DDoS, ThreatLevel::High);
985        record(&mut mon, "p3", ThreatType::Sybil, ThreatLevel::Medium);
986        let sybil = mon.events_by_threat_type(&ThreatType::Sybil);
987        assert_eq!(sybil.len(), 2);
988        for e in sybil {
989            assert_eq!(e.threat_type, ThreatType::Sybil);
990        }
991    }
992
993    #[test]
994    fn events_since_filters_by_timestamp() {
995        let mut mon = make_monitor();
996        mon.record_event(
997            "p1".to_owned(),
998            ThreatType::DDoS,
999            ThreatLevel::Low,
1000            "x".to_owned(),
1001            vec![],
1002            T0,
1003        );
1004        mon.record_event(
1005            "p2".to_owned(),
1006            ThreatType::DDoS,
1007            ThreatLevel::Low,
1008            "y".to_owned(),
1009            vec![],
1010            T0 + 5000,
1011        );
1012        let recent = mon.events_since(T0 + 1);
1013        assert_eq!(recent.len(), 1);
1014        assert_eq!(recent[0].peer_id, "p2");
1015    }
1016
1017    #[test]
1018    fn events_since_includes_equal_timestamp() {
1019        let mut mon = make_monitor();
1020        mon.record_event(
1021            "p1".to_owned(),
1022            ThreatType::Sybil,
1023            ThreatLevel::Low,
1024            "x".to_owned(),
1025            vec![],
1026            T0,
1027        );
1028        let evs = mon.events_since(T0);
1029        assert_eq!(evs.len(), 1);
1030    }
1031
1032    // ------------------------------------------------------------------
1033    // top_threats
1034    // ------------------------------------------------------------------
1035
1036    #[test]
1037    fn top_threats_returns_highest_scores_first() {
1038        let mut mon = make_monitor();
1039        record(&mut mon, "low", ThreatType::Sybil, ThreatLevel::Low); // 5
1040        record(&mut mon, "high", ThreatType::DDoS, ThreatLevel::High); // 30
1041        record(&mut mon, "med", ThreatType::Eclipse, ThreatLevel::Medium); // 15
1042        let top = mon.top_threats(3, T0);
1043        assert_eq!(top[0].peer_id, "high");
1044        assert_eq!(top[1].peer_id, "med");
1045        assert_eq!(top[2].peer_id, "low");
1046    }
1047
1048    #[test]
1049    fn top_threats_n_larger_than_peers() {
1050        let mut mon = make_monitor();
1051        record(&mut mon, "p1", ThreatType::Sybil, ThreatLevel::Low);
1052        let top = mon.top_threats(10, T0);
1053        assert_eq!(top.len(), 1);
1054    }
1055
1056    // ------------------------------------------------------------------
1057    // clear_peer_history
1058    // ------------------------------------------------------------------
1059
1060    #[test]
1061    fn clear_peer_history_removes_events_and_score() {
1062        let mut mon = make_monitor();
1063        record(&mut mon, "peer1", ThreatType::Sybil, ThreatLevel::High);
1064        record(&mut mon, "peer1", ThreatType::DDoS, ThreatLevel::Medium);
1065        record(&mut mon, "peer2", ThreatType::Eclipse, ThreatLevel::Low);
1066        let removed = mon.clear_peer_history("peer1");
1067        assert_eq!(removed, 2);
1068        assert_eq!(mon.threat_score("peer1", T0), 0.0);
1069        assert_eq!(mon.events_for_peer("peer1").len(), 0);
1070        assert_eq!(mon.events_for_peer("peer2").len(), 1);
1071    }
1072
1073    #[test]
1074    fn clear_peer_history_unknown_peer_returns_zero() {
1075        let mut mon = make_monitor();
1076        assert_eq!(mon.clear_peer_history("nobody"), 0);
1077    }
1078
1079    // ------------------------------------------------------------------
1080    // Rolling buffer
1081    // ------------------------------------------------------------------
1082
1083    #[test]
1084    fn rolling_buffer_evicts_oldest_when_full() {
1085        let mut mon = NetworkSecurityMonitor::new(3, 200.0); // high threshold to avoid incidents
1086        let id1 = record(&mut mon, "p", ThreatType::Sybil, ThreatLevel::None);
1087        let _id2 = record(&mut mon, "p", ThreatType::DDoS, ThreatLevel::None);
1088        let _id3 = record(&mut mon, "p", ThreatType::Eclipse, ThreatLevel::None);
1089        let _id4 = record(&mut mon, "p", ThreatType::ReplayAttack, ThreatLevel::None);
1090        // id1 should have been evicted
1091        let present: Vec<u64> = mon.events_for_peer("p").iter().map(|e| e.id).collect();
1092        assert!(!present.contains(&id1));
1093        assert_eq!(present.len(), 3);
1094    }
1095
1096    // ------------------------------------------------------------------
1097    // Stats
1098    // ------------------------------------------------------------------
1099
1100    #[test]
1101    fn stats_empty_monitor() {
1102        let mut mon = make_monitor();
1103        let s = mon.stats(T0);
1104        assert_eq!(s.total_events, 0);
1105        assert_eq!(s.open_incidents, 0);
1106        assert_eq!(s.resolved_incidents, 0);
1107        assert_eq!(s.high_threat_peers, 0);
1108        assert_eq!(s.avg_threat_score, 0.0);
1109    }
1110
1111    #[test]
1112    fn stats_counts_open_incidents() {
1113        let mut mon = make_monitor();
1114        mon.create_incident(vec![], T0);
1115        mon.create_incident(vec![], T0);
1116        let s = mon.stats(T0);
1117        assert_eq!(s.open_incidents, 2);
1118    }
1119
1120    #[test]
1121    fn stats_counts_resolved_incidents() {
1122        let mut mon = make_monitor();
1123        let id = mon.create_incident(vec![], T0);
1124        mon.update_incident_status(id, IncidentStatus::Resolved);
1125        let s = mon.stats(T0);
1126        assert_eq!(s.resolved_incidents, 1);
1127        assert_eq!(s.open_incidents, 0);
1128    }
1129
1130    #[test]
1131    fn stats_high_threat_peers_threshold_50() {
1132        let mut mon = NetworkSecurityMonitor::new(100, 200.0); // prevent auto-incident
1133        record(&mut mon, "clean", ThreatType::Sybil, ThreatLevel::Low); // 5
1134        record(&mut mon, "dirty", ThreatType::DDoS, ThreatLevel::Critical); // 50
1135        let s = mon.stats(T0);
1136        assert_eq!(s.high_threat_peers, 1);
1137    }
1138
1139    #[test]
1140    fn stats_avg_threat_score() {
1141        let mut mon = NetworkSecurityMonitor::new(100, 200.0);
1142        record(&mut mon, "p1", ThreatType::Sybil, ThreatLevel::Low); // 5
1143        record(&mut mon, "p2", ThreatType::DDoS, ThreatLevel::Medium); // 15
1144        let s = mon.stats(T0);
1145        let expected_avg = (5.0 + 15.0) / 2.0;
1146        assert!((s.avg_threat_score - expected_avg).abs() < 1e-6);
1147    }
1148
1149    // ------------------------------------------------------------------
1150    // ThreatType names
1151    // ------------------------------------------------------------------
1152
1153    #[test]
1154    fn threat_type_names_are_correct() {
1155        assert_eq!(ThreatType::Sybil.name(), "Sybil");
1156        assert_eq!(ThreatType::Eclipse.name(), "Eclipse");
1157        assert_eq!(ThreatType::DDoS.name(), "DDoS");
1158        assert_eq!(ThreatType::ManInTheMiddle.name(), "ManInTheMiddle");
1159        assert_eq!(ThreatType::ReplayAttack.name(), "ReplayAttack");
1160        assert_eq!(ThreatType::RoutingPoison.name(), "RoutingPoison");
1161        assert_eq!(ThreatType::DataTampering.name(), "DataTampering");
1162    }
1163
1164    // ------------------------------------------------------------------
1165    // Default monitor
1166    // ------------------------------------------------------------------
1167
1168    #[test]
1169    fn default_monitor_threshold_is_50() {
1170        let mut mon = NetworkSecurityMonitor::default_monitor();
1171        // A single Critical event scores 50; just at the threshold.
1172        record(&mut mon, "p", ThreatType::Sybil, ThreatLevel::Critical);
1173        // Score == 50 → incident should have been created.
1174        assert_eq!(mon.open_incidents().len(), 1);
1175    }
1176
1177    #[test]
1178    fn zero_threshold_replaced_with_50() {
1179        let mon = NetworkSecurityMonitor::new(10, 0.0);
1180        // Cannot access the field directly, but we can verify via stats
1181        // that no panics occur.
1182        let _ = format!("{:?}", mon.events_for_peer("x"));
1183    }
1184
1185    // ------------------------------------------------------------------
1186    // Incident events list
1187    // ------------------------------------------------------------------
1188
1189    #[test]
1190    fn incident_contains_peer_event_ids() {
1191        let mut mon = NetworkSecurityMonitor::new(100, 10.0);
1192        let eid = record(&mut mon, "p", ThreatType::DDoS, ThreatLevel::Critical);
1193        let incidents = mon.open_incidents();
1194        assert_eq!(incidents.len(), 1);
1195        assert!(incidents[0].events.contains(&eid));
1196    }
1197
1198    // ------------------------------------------------------------------
1199    // Multiple peers do not interfere
1200    // ------------------------------------------------------------------
1201
1202    #[test]
1203    fn different_peers_have_independent_scores() {
1204        let mut mon = make_monitor();
1205        record(&mut mon, "alice", ThreatType::Sybil, ThreatLevel::High); // 30
1206        record(&mut mon, "bob", ThreatType::DDoS, ThreatLevel::Medium); // 15
1207        assert!((mon.threat_score("alice", T0) - 30.0).abs() < 1e-9);
1208        assert!((mon.threat_score("bob", T0) - 15.0).abs() < 1e-9);
1209    }
1210}