Skip to main content

prt_core/core/
alerts.rs

1//! Alert rule evaluation engine.
2//!
3//! Matches [`AlertRuleConfig`] conditions against port entries and
4//! returns triggered alerts. The TUI layer handles the actual
5//! actions (bell, highlight).
6
7use crate::config::AlertRuleConfig;
8use crate::model::{ConnectionState, EntryStatus, TrackedEntry};
9
10/// The action to perform when an alert fires.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum AlertAction {
13    /// Ring terminal bell (BEL character).
14    Bell,
15    /// Highlight the row in the table.
16    Highlight,
17}
18
19/// A triggered alert: the index of the matching entry and the action.
20#[derive(Debug, Clone)]
21pub struct FiredAlert {
22    /// Index into the entries slice.
23    pub entry_index: usize,
24    /// What to do about it.
25    pub action: AlertAction,
26}
27
28/// Evaluate all alert rules against the current entries.
29///
30/// Returns a list of fired alerts. Bell alerts only fire on `New`
31/// entries to avoid ringing every 2 seconds.
32pub fn evaluate(rules: &[AlertRuleConfig], entries: &[TrackedEntry]) -> Vec<FiredAlert> {
33    let mut alerts = Vec::new();
34
35    for (i, entry) in entries.iter().enumerate() {
36        for rule in rules {
37            if matches_rule(rule, entry, entries) {
38                let action = parse_action(&rule.action);
39                // Bell only on New entries (not every tick)
40                if action == AlertAction::Bell && entry.status != EntryStatus::New {
41                    continue;
42                }
43                alerts.push(FiredAlert {
44                    entry_index: i,
45                    action,
46                });
47            }
48        }
49    }
50
51    alerts
52}
53
54/// Check if a single entry matches a rule's conditions.
55/// All specified conditions must match (AND logic).
56fn matches_rule(
57    rule: &AlertRuleConfig,
58    entry: &TrackedEntry,
59    all_entries: &[TrackedEntry],
60) -> bool {
61    if let Some(port) = rule.port {
62        if entry.entry.local_port() != port {
63            return false;
64        }
65    }
66
67    if let Some(ref process) = rule.process {
68        if !entry
69            .entry
70            .process
71            .name
72            .to_lowercase()
73            .contains(&process.to_lowercase())
74        {
75            return false;
76        }
77    }
78
79    if let Some(ref state) = rule.state {
80        let entry_state = parse_state(state);
81        if let Some(expected) = entry_state {
82            if entry.entry.state != expected {
83                return false;
84            }
85        }
86    }
87
88    if let Some(threshold) = rule.connections_gt {
89        let pid = entry.entry.process.pid;
90        let count = all_entries
91            .iter()
92            .filter(|e| e.entry.process.pid == pid)
93            .count();
94        if count <= threshold {
95            return false;
96        }
97    }
98
99    true
100}
101
102fn parse_action(s: &str) -> AlertAction {
103    match s.to_lowercase().as_str() {
104        "bell" => AlertAction::Bell,
105        _ => AlertAction::Highlight,
106    }
107}
108
109fn parse_state(s: &str) -> Option<ConnectionState> {
110    match s.to_uppercase().as_str() {
111        "LISTEN" => Some(ConnectionState::Listen),
112        "ESTABLISHED" => Some(ConnectionState::Established),
113        "TIME_WAIT" => Some(ConnectionState::TimeWait),
114        "CLOSE_WAIT" => Some(ConnectionState::CloseWait),
115        "SYN_SENT" => Some(ConnectionState::SynSent),
116        "SYN_RECV" => Some(ConnectionState::SynRecv),
117        "FIN_WAIT1" => Some(ConnectionState::FinWait1),
118        "FIN_WAIT2" => Some(ConnectionState::FinWait2),
119        "CLOSING" => Some(ConnectionState::Closing),
120        "LAST_ACK" => Some(ConnectionState::LastAck),
121        "CLOSED" => Some(ConnectionState::Closed),
122        _ => None,
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::model::{ConnectionState, EntryStatus, PortEntry, ProcessInfo, Protocol};
130    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
131    use std::time::Instant;
132
133    fn make_entry(port: u16, pid: u32, name: &str, state: ConnectionState) -> TrackedEntry {
134        TrackedEntry {
135            entry: PortEntry {
136                protocol: Protocol::Tcp,
137                local_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
138                remote_addr: None,
139                state,
140                process: ProcessInfo {
141                    pid,
142                    name: name.into(),
143                    path: None,
144                    cmdline: None,
145                    user: None,
146                    parent_pid: None,
147                    parent_name: None,
148                },
149            },
150            status: EntryStatus::Unchanged,
151            seen_at: Instant::now(),
152            first_seen: None,
153            suspicious: Vec::new(),
154            container_name: None,
155            service_name: None,
156        }
157    }
158
159    fn make_new_entry(port: u16, pid: u32, name: &str, state: ConnectionState) -> TrackedEntry {
160        let mut e = make_entry(port, pid, name, state);
161        e.status = EntryStatus::New;
162        e
163    }
164
165    #[test]
166    fn port_rule_matches() {
167        let rule = AlertRuleConfig {
168            port: Some(22),
169            action: "highlight".into(),
170            ..Default::default()
171        };
172        let entries = vec![make_entry(22, 1, "sshd", ConnectionState::Listen)];
173        let alerts = evaluate(&[rule], &entries);
174        assert_eq!(alerts.len(), 1);
175        assert_eq!(alerts[0].action, AlertAction::Highlight);
176    }
177
178    #[test]
179    fn port_rule_no_match() {
180        let rule = AlertRuleConfig {
181            port: Some(22),
182            action: "highlight".into(),
183            ..Default::default()
184        };
185        let entries = vec![make_entry(80, 1, "nginx", ConnectionState::Listen)];
186        assert!(evaluate(&[rule], &entries).is_empty());
187    }
188
189    #[test]
190    fn process_rule_matches_case_insensitive() {
191        let rule = AlertRuleConfig {
192            process: Some("Python".into()),
193            action: "highlight".into(),
194            ..Default::default()
195        };
196        let entries = vec![make_entry(8000, 1, "python3", ConnectionState::Listen)];
197        assert_eq!(evaluate(&[rule], &entries).len(), 1);
198    }
199
200    #[test]
201    fn state_rule_matches() {
202        let rule = AlertRuleConfig {
203            state: Some("LISTEN".into()),
204            action: "highlight".into(),
205            ..Default::default()
206        };
207        let entries = vec![
208            make_entry(80, 1, "nginx", ConnectionState::Listen),
209            make_entry(81, 2, "curl", ConnectionState::Established),
210        ];
211        let alerts = evaluate(&[rule], &entries);
212        assert_eq!(alerts.len(), 1);
213        assert_eq!(alerts[0].entry_index, 0);
214    }
215
216    #[test]
217    fn connections_gt_rule() {
218        let rule = AlertRuleConfig {
219            connections_gt: Some(1),
220            action: "highlight".into(),
221            ..Default::default()
222        };
223        // PID 1 has 2 connections, PID 2 has 1
224        let entries = vec![
225            make_entry(80, 1, "nginx", ConnectionState::Listen),
226            make_entry(443, 1, "nginx", ConnectionState::Listen),
227            make_entry(8080, 2, "node", ConnectionState::Listen),
228        ];
229        let alerts = evaluate(&[rule], &entries);
230        // PID 1's entries match (2 > 1), PID 2 does not (1 <= 1)
231        assert_eq!(alerts.len(), 2);
232    }
233
234    #[test]
235    fn bell_only_on_new_entries() {
236        let rule = AlertRuleConfig {
237            port: Some(22),
238            action: "bell".into(),
239            ..Default::default()
240        };
241        // Unchanged entry — bell should NOT fire
242        let entries = vec![make_entry(22, 1, "sshd", ConnectionState::Listen)];
243        assert!(evaluate(std::slice::from_ref(&rule), &entries).is_empty());
244
245        // New entry — bell should fire
246        let entries = vec![make_new_entry(22, 1, "sshd", ConnectionState::Listen)];
247        let alerts = evaluate(std::slice::from_ref(&rule), &entries);
248        assert_eq!(alerts.len(), 1);
249        assert_eq!(alerts[0].action, AlertAction::Bell);
250    }
251
252    #[test]
253    fn combined_conditions_are_and() {
254        let rule = AlertRuleConfig {
255            port: Some(80),
256            process: Some("python".into()),
257            state: Some("LISTEN".into()),
258            action: "highlight".into(),
259            ..Default::default()
260        };
261        // Matches all conditions
262        let entries = vec![make_entry(80, 1, "python3", ConnectionState::Listen)];
263        assert_eq!(evaluate(std::slice::from_ref(&rule), &entries).len(), 1);
264
265        // Wrong port
266        let entries = vec![make_entry(8080, 1, "python3", ConnectionState::Listen)];
267        assert!(evaluate(std::slice::from_ref(&rule), &entries).is_empty());
268
269        // Wrong process
270        let entries = vec![make_entry(80, 1, "nginx", ConnectionState::Listen)];
271        assert!(evaluate(std::slice::from_ref(&rule), &entries).is_empty());
272    }
273
274    #[test]
275    fn empty_rules_no_alerts() {
276        let entries = vec![make_entry(80, 1, "nginx", ConnectionState::Listen)];
277        assert!(evaluate(&[], &entries).is_empty());
278    }
279
280    #[test]
281    fn empty_entries_no_alerts() {
282        let rule = AlertRuleConfig {
283            port: Some(22),
284            action: "bell".into(),
285            ..Default::default()
286        };
287        assert!(evaluate(&[rule], &[]).is_empty());
288    }
289
290    #[test]
291    fn parse_action_variants() {
292        assert_eq!(parse_action("bell"), AlertAction::Bell);
293        assert_eq!(parse_action("BELL"), AlertAction::Bell);
294        assert_eq!(parse_action("highlight"), AlertAction::Highlight);
295        assert_eq!(parse_action("unknown"), AlertAction::Highlight);
296        assert_eq!(parse_action(""), AlertAction::Highlight);
297    }
298}