1use crate::config::AlertRuleConfig;
8use crate::model::{ConnectionState, EntryStatus, TrackedEntry};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum AlertAction {
13 Bell,
15 Highlight,
17}
18
19#[derive(Debug, Clone)]
21pub struct FiredAlert {
22 pub entry_index: usize,
24 pub action: AlertAction,
26}
27
28pub 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 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
54fn 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 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 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 let entries = vec![make_entry(22, 1, "sshd", ConnectionState::Listen)];
243 assert!(evaluate(std::slice::from_ref(&rule), &entries).is_empty());
244
245 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 let entries = vec![make_entry(80, 1, "python3", ConnectionState::Listen)];
263 assert_eq!(evaluate(std::slice::from_ref(&rule), &entries).len(), 1);
264
265 let entries = vec![make_entry(8080, 1, "python3", ConnectionState::Listen)];
267 assert!(evaluate(std::slice::from_ref(&rule), &entries).is_empty());
268
269 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}