1use std::collections::HashMap;
8use std::time::Instant;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct SyscallEvent {
13 pub syscall: String,
14 pub denied: bool,
15 pub raw_line: String,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum AnomalyVerdict {
21 Clean,
23 Alert { alerts: Vec<String> },
25 Suppressed { reason: String },
27}
28
29#[derive(Debug, Clone)]
31pub struct SyscallAnomalyConfig {
32 pub enabled: bool,
33 pub strict_mode: bool,
34 pub alert_on_unknown_syscall: bool,
35 pub max_denied_events_per_minute: u32,
36 pub max_total_events_per_minute: u32,
37 pub max_alerts_per_minute: u32,
38 pub alert_cooldown_secs: u64,
39 pub baseline_syscalls: Vec<String>,
40}
41
42impl Default for SyscallAnomalyConfig {
43 fn default() -> Self {
44 Self {
45 enabled: true,
46 strict_mode: false,
47 alert_on_unknown_syscall: true,
48 max_denied_events_per_minute: 5,
49 max_total_events_per_minute: 120,
50 max_alerts_per_minute: 30,
51 alert_cooldown_secs: 20,
52 baseline_syscalls: vec![
53 "read".to_string(),
54 "write".to_string(),
55 "openat".to_string(),
56 "close".to_string(),
57 "execve".to_string(),
58 "futex".to_string(),
59 ],
60 }
61 }
62}
63
64pub struct SyscallAnomalyDetector {
68 config: SyscallAnomalyConfig,
69 total_events_this_window: u32,
71 denied_events_this_window: u32,
72 alerts_this_window: u32,
73 window_start: Instant,
75 cooldown_until: Option<Instant>,
77 syscall_counts: HashMap<String, u32>,
79}
80
81impl SyscallAnomalyDetector {
82 pub fn new(config: SyscallAnomalyConfig) -> Self {
83 Self {
84 config,
85 total_events_this_window: 0,
86 denied_events_this_window: 0,
87 alerts_this_window: 0,
88 window_start: Instant::now(),
89 cooldown_until: None,
90 syscall_counts: HashMap::new(),
91 }
92 }
93
94 fn maybe_reset_window(&mut self) {
96 let elapsed = self.window_start.elapsed().as_secs();
97 if elapsed >= 60 {
98 self.total_events_this_window = 0;
99 self.denied_events_this_window = 0;
100 self.alerts_this_window = 0;
101 self.window_start = Instant::now();
102 }
103 }
104
105 fn in_cooldown(&self) -> bool {
107 self.cooldown_until
108 .map(|until| Instant::now() < until)
109 .unwrap_or(false)
110 }
111
112 pub fn analyze(&mut self, command_output: &str) -> AnomalyVerdict {
114 if !self.config.enabled {
115 return AnomalyVerdict::Clean;
116 }
117
118 self.maybe_reset_window();
119
120 if self.in_cooldown() {
121 return AnomalyVerdict::Suppressed {
122 reason: "alert cooldown active".to_string(),
123 };
124 }
125
126 let events = parse_syscall_events(command_output);
127 if events.is_empty() {
128 return AnomalyVerdict::Clean;
129 }
130
131 let mut alerts = Vec::new();
132
133 for event in &events {
134 self.total_events_this_window += 1;
135 *self
136 .syscall_counts
137 .entry(event.syscall.clone())
138 .or_insert(0) += 1;
139
140 if event.denied {
141 self.denied_events_this_window += 1;
142 }
143
144 let is_known = self
146 .config
147 .baseline_syscalls
148 .iter()
149 .any(|b| b == &event.syscall);
150
151 if !is_known && self.config.alert_on_unknown_syscall {
152 alerts.push(format!(
153 "unknown syscall '{}' not in baseline",
154 event.syscall
155 ));
156 }
157
158 if event.denied {
159 alerts.push(format!("denied syscall: {}", event.syscall));
160 }
161 }
162
163 if self.denied_events_this_window > self.config.max_denied_events_per_minute {
165 alerts.push(format!(
166 "denied event rate {} exceeds limit {}/min",
167 self.denied_events_this_window, self.config.max_denied_events_per_minute
168 ));
169 }
170
171 if self.total_events_this_window > self.config.max_total_events_per_minute {
172 alerts.push(format!(
173 "total event rate {} exceeds limit {}/min",
174 self.total_events_this_window, self.config.max_total_events_per_minute
175 ));
176 }
177
178 if alerts.is_empty() {
179 return AnomalyVerdict::Clean;
180 }
181
182 self.alerts_this_window += 1;
184 if self.alerts_this_window > self.config.max_alerts_per_minute {
185 self.cooldown_until = Some(
186 Instant::now() + std::time::Duration::from_secs(self.config.alert_cooldown_secs),
187 );
188 return AnomalyVerdict::Suppressed {
189 reason: format!(
190 "alert budget exhausted ({}/min), cooldown {}s",
191 self.config.max_alerts_per_minute, self.config.alert_cooldown_secs
192 ),
193 };
194 }
195
196 if !self.config.strict_mode {
198 alerts.dedup();
199 }
200
201 AnomalyVerdict::Alert { alerts }
202 }
203
204 pub fn syscall_counts(&self) -> &HashMap<String, u32> {
206 &self.syscall_counts
207 }
208
209 pub fn reset(&mut self) {
211 self.total_events_this_window = 0;
212 self.denied_events_this_window = 0;
213 self.alerts_this_window = 0;
214 self.window_start = Instant::now();
215 self.cooldown_until = None;
216 self.syscall_counts.clear();
217 }
218}
219
220pub fn parse_syscall_events(output: &str) -> Vec<SyscallEvent> {
227 let mut events = Vec::new();
228
229 for line in output.lines() {
230 let trimmed = line.trim();
231 if trimmed.is_empty() {
232 continue;
233 }
234
235 if let Some(event) = parse_strace_line(trimmed) {
237 events.push(event);
238 continue;
239 }
240
241 if let Some(event) = parse_audit_line(trimmed) {
243 events.push(event);
244 continue;
245 }
246 }
247
248 events
249}
250
251fn parse_strace_line(line: &str) -> Option<SyscallEvent> {
253 let paren_idx = line.find('(')?;
254 let syscall_name = line[..paren_idx].trim();
255
256 if syscall_name.is_empty() || syscall_name.contains(' ') || syscall_name.len() > 32 {
258 return None;
259 }
260
261 if !syscall_name
263 .chars()
264 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
265 {
266 return None;
267 }
268
269 let denied = line.contains("EACCES")
270 || line.contains("EPERM")
271 || line.contains("= -1 EACCES")
272 || line.contains("= -1 EPERM");
273
274 Some(SyscallEvent {
275 syscall: syscall_name.to_string(),
276 denied,
277 raw_line: line.to_string(),
278 })
279}
280
281fn parse_audit_line(line: &str) -> Option<SyscallEvent> {
283 let syscall_prefix = "syscall=";
285 let idx = line.find(syscall_prefix)?;
286 let after = &line[idx + syscall_prefix.len()..];
287 let syscall_token: String = after
288 .chars()
289 .take_while(|c| c.is_alphanumeric() || *c == '_')
290 .collect();
291
292 if syscall_token.is_empty() {
293 return None;
294 }
295
296 let syscall_name = match syscall_token.as_str() {
298 "0" => "read".to_string(),
299 "1" => "write".to_string(),
300 "2" => "open".to_string(),
301 "3" => "close".to_string(),
302 "59" => "execve".to_string(),
303 "56" => "clone".to_string(),
304 "57" => "fork".to_string(),
305 "62" => "kill".to_string(),
306 "101" => "ptrace".to_string(),
307 "257" => "openat".to_string(),
308 other => other.to_string(),
309 };
310
311 let denied = line.contains("denied")
312 || line.contains("DENIED")
313 || line.contains("blocked")
314 || line.contains("action=blocked");
315
316 Some(SyscallEvent {
317 syscall: syscall_name,
318 denied,
319 raw_line: line.to_string(),
320 })
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn parse_strace_basic() {
329 let line = r#"openat(AT_FDCWD, "/etc/passwd", O_RDONLY) = 3"#;
330 let events = parse_syscall_events(line);
331 assert_eq!(events.len(), 1);
332 assert_eq!(events[0].syscall, "openat");
333 assert!(!events[0].denied);
334 }
335
336 #[test]
337 fn parse_strace_denied() {
338 let line =
339 r#"openat(AT_FDCWD, "/root/.ssh/id_rsa", O_RDONLY) = -1 EACCES (Permission denied)"#;
340 let events = parse_syscall_events(line);
341 assert_eq!(events.len(), 1);
342 assert_eq!(events[0].syscall, "openat");
343 assert!(events[0].denied);
344 }
345
346 #[test]
347 fn parse_strace_eperm() {
348 let line = r#"kill(1234, SIGKILL) = -1 EPERM (Operation not permitted)"#;
349 let events = parse_syscall_events(line);
350 assert_eq!(events.len(), 1);
351 assert_eq!(events[0].syscall, "kill");
352 assert!(events[0].denied);
353 }
354
355 #[test]
356 fn parse_audit_line_with_number() {
357 let line = "type=SYSCALL msg=audit(1234): arch=c000003e syscall=59 success=yes";
358 let events = parse_syscall_events(line);
359 assert_eq!(events.len(), 1);
360 assert_eq!(events[0].syscall, "execve");
361 assert!(!events[0].denied);
362 }
363
364 #[test]
365 fn parse_audit_line_denied() {
366 let line = "audit: seccomp syscall=101 action=blocked denied";
367 let events = parse_syscall_events(line);
368 assert_eq!(events.len(), 1);
369 assert_eq!(events[0].syscall, "ptrace");
370 assert!(events[0].denied);
371 }
372
373 #[test]
374 fn parse_audit_line_with_name() {
375 let line = "seccomp: syscall=connect denied";
376 let events = parse_syscall_events(line);
377 assert_eq!(events.len(), 1);
378 assert_eq!(events[0].syscall, "connect");
379 assert!(events[0].denied);
380 }
381
382 #[test]
383 fn parse_no_syscall_events() {
384 let output = "total 64\ndrwxr-xr-x 10 user staff 320 Feb 28 12:00 .\n";
385 let events = parse_syscall_events(output);
386 assert!(events.is_empty());
387 }
388
389 #[test]
390 fn parse_multiple_strace_lines() {
391 let output = r#"read(3, "hello", 5) = 5
392write(1, "hello", 5) = 5
393close(3) = 0
394"#;
395 let events = parse_syscall_events(output);
396 assert_eq!(events.len(), 3);
397 assert_eq!(events[0].syscall, "read");
398 assert_eq!(events[1].syscall, "write");
399 assert_eq!(events[2].syscall, "close");
400 }
401
402 #[test]
403 fn detector_clean_baseline_events() {
404 let config = SyscallAnomalyConfig::default();
405 let mut detector = SyscallAnomalyDetector::new(config);
406
407 let output = r#"read(3, "data", 1024) = 1024
408write(1, "output", 6) = 6
409close(3) = 0
410"#;
411 let verdict = detector.analyze(output);
412 assert_eq!(verdict, AnomalyVerdict::Clean);
413 }
414
415 #[test]
416 fn detector_flags_unknown_syscall() {
417 let config = SyscallAnomalyConfig::default();
418 let mut detector = SyscallAnomalyDetector::new(config);
419
420 let output = r#"ptrace(PTRACE_ATTACH, 1234) = 0"#;
421 match detector.analyze(output) {
422 AnomalyVerdict::Alert { alerts } => {
423 assert!(alerts.iter().any(|a| a.contains("ptrace")));
424 }
425 other => panic!("expected Alert, got {other:?}"),
426 }
427 }
428
429 #[test]
430 fn detector_flags_denied_event() {
431 let config = SyscallAnomalyConfig::default();
432 let mut detector = SyscallAnomalyDetector::new(config);
433
434 let output =
435 r#"openat(AT_FDCWD, "/root/.ssh/id_rsa", O_RDONLY) = -1 EACCES (Permission denied)"#;
436 match detector.analyze(output) {
437 AnomalyVerdict::Alert { alerts } => {
438 assert!(alerts.iter().any(|a| a.contains("denied")));
439 }
440 other => panic!("expected Alert, got {other:?}"),
441 }
442 }
443
444 #[test]
445 fn detector_disabled_always_clean() {
446 let config = SyscallAnomalyConfig {
447 enabled: false,
448 ..Default::default()
449 };
450 let mut detector = SyscallAnomalyDetector::new(config);
451 let output = r#"ptrace(PTRACE_ATTACH, 1234) = 0"#;
452 assert_eq!(detector.analyze(output), AnomalyVerdict::Clean);
453 }
454
455 #[test]
456 fn detector_alert_budget_exhaustion() {
457 let config = SyscallAnomalyConfig {
458 max_alerts_per_minute: 2,
459 alert_cooldown_secs: 10,
460 ..Default::default()
461 };
462 let mut detector = SyscallAnomalyDetector::new(config);
463
464 let bad_output = r#"ptrace(PTRACE_ATTACH, 1234) = 0"#;
465
466 assert!(matches!(
468 detector.analyze(bad_output),
469 AnomalyVerdict::Alert { .. }
470 ));
471 assert!(matches!(
472 detector.analyze(bad_output),
473 AnomalyVerdict::Alert { .. }
474 ));
475
476 match detector.analyze(bad_output) {
478 AnomalyVerdict::Suppressed { reason } => {
479 assert!(reason.contains("budget exhausted"));
480 }
481 other => panic!("expected Suppressed, got {other:?}"),
482 }
483 }
484
485 #[test]
486 fn detector_reset_clears_state() {
487 let config = SyscallAnomalyConfig::default();
488 let mut detector = SyscallAnomalyDetector::new(config);
489
490 let output = r#"ptrace(PTRACE_ATTACH, 1234) = 0"#;
491 detector.analyze(output);
492 assert!(!detector.syscall_counts().is_empty());
493
494 detector.reset();
495 assert!(detector.syscall_counts().is_empty());
496 }
497
498 #[test]
499 fn detector_no_alert_when_unknown_disabled() {
500 let config = SyscallAnomalyConfig {
501 alert_on_unknown_syscall: false,
502 ..Default::default()
503 };
504 let mut detector = SyscallAnomalyDetector::new(config);
505
506 let output = r#"connect(3, {sa_family=AF_INET}, 16) = 0"#;
507 assert_eq!(detector.analyze(output), AnomalyVerdict::Clean);
508 }
509
510 #[test]
511 fn detector_denied_rate_limit() {
512 let config = SyscallAnomalyConfig {
513 max_denied_events_per_minute: 2,
514 ..Default::default()
515 };
516 let mut detector = SyscallAnomalyDetector::new(config);
517
518 let denied = r#"openat(AT_FDCWD, "/secret", O_RDONLY) = -1 EACCES (Permission denied)"#;
519 detector.analyze(denied);
521 detector.analyze(denied);
522
523 match detector.analyze(denied) {
525 AnomalyVerdict::Alert { alerts } => {
526 assert!(alerts.iter().any(|a| a.contains("denied event rate")));
527 }
528 other => panic!("expected rate limit Alert, got {other:?}"),
529 }
530 }
531}