Skip to main content

agentzero_core/security/
syscall_anomaly.rs

1//! Syscall anomaly detection for shell command output.
2//!
3//! Monitors shell command output for syscall-related patterns (strace output,
4//! audit logs, denied operations) and flags anomalies against a configurable
5//! baseline. Supports alert budgets and cooldown to prevent alert fatigue.
6
7use std::collections::HashMap;
8use std::time::Instant;
9
10/// A single detected syscall event parsed from command output.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct SyscallEvent {
13    pub syscall: String,
14    pub denied: bool,
15    pub raw_line: String,
16}
17
18/// Verdict from the anomaly detector.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum AnomalyVerdict {
21    /// No anomaly detected.
22    Clean,
23    /// Anomalies were detected.
24    Alert { alerts: Vec<String> },
25    /// Alert budget exhausted — further alerts suppressed until cooldown.
26    Suppressed { reason: String },
27}
28
29/// Configuration for the syscall anomaly detector.
30#[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
64/// Stateful syscall anomaly detector.
65///
66/// Tracks event rates, alert budgets, and cooldown windows.
67pub struct SyscallAnomalyDetector {
68    config: SyscallAnomalyConfig,
69    /// Counts of events within the current minute window.
70    total_events_this_window: u32,
71    denied_events_this_window: u32,
72    alerts_this_window: u32,
73    /// When the current window started.
74    window_start: Instant,
75    /// Cooldown: if set, alerts are suppressed until this instant.
76    cooldown_until: Option<Instant>,
77    /// Counts of each syscall seen (for reporting).
78    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    /// Reset the rate-limiting window if a minute has elapsed.
95    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    /// Check if we're in cooldown.
106    fn in_cooldown(&self) -> bool {
107        self.cooldown_until
108            .map(|until| Instant::now() < until)
109            .unwrap_or(false)
110    }
111
112    /// Analyze shell command output for syscall anomalies.
113    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            // Check if syscall is in baseline
145            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        // Rate limit checks
164        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        // Enforce alert budget
183        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        // In strict mode, include all alerts; otherwise deduplicate
197        if !self.config.strict_mode {
198            alerts.dedup();
199        }
200
201        AnomalyVerdict::Alert { alerts }
202    }
203
204    /// Return the accumulated syscall counts.
205    pub fn syscall_counts(&self) -> &HashMap<String, u32> {
206        &self.syscall_counts
207    }
208
209    /// Reset all state.
210    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
220/// Parse command output for syscall events.
221///
222/// Recognises:
223/// - strace-style lines: `openat(AT_FDCWD, "/etc/passwd", O_RDONLY) = 3`
224/// - audit log lines: `type=SYSCALL ... syscall=59 ... denied`
225/// - seccomp lines: `audit: seccomp ... syscall=read ...`
226pub 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        // strace-style: `syscall_name(args...) = result`
236        if let Some(event) = parse_strace_line(trimmed) {
237            events.push(event);
238            continue;
239        }
240
241        // audit/seccomp-style: `type=SYSCALL ... syscall=NAME ...`
242        if let Some(event) = parse_audit_line(trimmed) {
243            events.push(event);
244            continue;
245        }
246    }
247
248    events
249}
250
251/// Parse a strace-style line like `openat(AT_FDCWD, "/etc/passwd", O_RDONLY) = 3`
252fn parse_strace_line(line: &str) -> Option<SyscallEvent> {
253    let paren_idx = line.find('(')?;
254    let syscall_name = line[..paren_idx].trim();
255
256    // Filter out lines that don't look like syscall names
257    if syscall_name.is_empty() || syscall_name.contains(' ') || syscall_name.len() > 32 {
258        return None;
259    }
260
261    // All lowercase + underscore is the typical pattern for syscall names
262    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
281/// Parse an audit/seccomp log line.
282fn parse_audit_line(line: &str) -> Option<SyscallEvent> {
283    // Look for `syscall=NAME` or `syscall=NUMBER`
284    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    // Map well-known syscall numbers to names (Linux x86_64)
297    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        // First two alerts should go through
467        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        // Third should be suppressed
477        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        // First two denied events — alerts for denied syscall
520        detector.analyze(denied);
521        detector.analyze(denied);
522
523        // Third denied event should trigger rate limit alert
524        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}