Skip to main content

memf_linux/
heuristics.rs

1//! Pure heuristic classifiers for Linux forensic artifacts.
2//!
3//! This module consolidates all `classify_*` functions from the individual
4//! walker modules into one discoverable, collectively-testable location.
5//!
6//! Each function is a pure heuristic: it takes only primitive values and
7//! returns `bool` or a tuple — no `ObjectReader` dependency.
8//!
9//! The original walker modules re-export every symbol from here so all
10//! existing call sites continue to compile unchanged.
11
12// ---------------------------------------------------------------------------
13// BPF program classification
14// ---------------------------------------------------------------------------
15
16/// Classify whether a BPF program type/name combination is suspicious.
17///
18/// Returns `true` for kprobe, lsm, raw_tracepoint_writable programs, and
19/// unnamed tracing/raw_tracepoint programs.
20pub fn classify_bpf_program(prog_type: &str, name: &str) -> bool {
21    match prog_type {
22        // Unnamed tracing/raw_tracepoint programs suggest evasion.
23        "tracing" | "raw_tracepoint" => name.is_empty(),
24
25        // kprobe, raw_tracepoint_writable, and lsm are always suspicious:
26        // kprobe hooks arbitrary kernel functions; raw_tracepoint_writable can
27        // modify tracepoint arguments; lsm programs can override security decisions.
28        "kprobe" | "raw_tracepoint_writable" | "lsm" => true,
29
30        // Everything else (socket_filter, xdp, tracepoint, etc.) is
31        // considered benign by default at the type level.
32        _ => false,
33    }
34}
35
36// ---------------------------------------------------------------------------
37// Capabilities classification
38// ---------------------------------------------------------------------------
39
40/// Capability bit constants (from include/uapi/linux/capability.h).
41const CAP_NET_RAW: u64 = 1 << 13;
42const CAP_SYS_MODULE: u64 = 1 << 16;
43const CAP_SYS_PTRACE: u64 = 1 << 19;
44const CAP_SYS_ADMIN: u64 = 1 << 21;
45
46/// Capabilities considered suspicious when held by a non-root process.
47const SUSPICIOUS_CAPS: &[(u64, &str)] = &[
48    (CAP_SYS_ADMIN, "CAP_SYS_ADMIN"),
49    (CAP_SYS_PTRACE, "CAP_SYS_PTRACE"),
50    (CAP_SYS_MODULE, "CAP_SYS_MODULE"),
51    (CAP_NET_RAW, "CAP_NET_RAW"),
52];
53
54/// Classify whether a non-root process holds suspicious Linux capabilities.
55///
56/// Returns `(is_suspicious, suspicious_cap_names)`. Root (uid == 0) is never
57/// flagged.
58pub fn classify_capabilities(effective: u64, uid: u32) -> (bool, Vec<String>) {
59    // Root is never suspicious -- it's expected to have all caps.
60    if uid == 0 {
61        return (false, Vec::new());
62    }
63
64    let mut suspicious_names = Vec::new();
65    for &(cap_bit, cap_label) in SUSPICIOUS_CAPS {
66        if effective & cap_bit != 0 {
67            suspicious_names.push(cap_label.to_string());
68        }
69    }
70
71    let is_suspicious = !suspicious_names.is_empty();
72    (is_suspicious, suspicious_names)
73}
74
75// ---------------------------------------------------------------------------
76// Cgroup classification
77// ---------------------------------------------------------------------------
78
79/// Classify whether a cgroup path indicates a container runtime.
80///
81/// Returns `(in_container, container_id)`. Recognises Docker, LXC, Kubernetes
82/// and containerd path prefixes.
83pub fn classify_cgroup(path: &str) -> (bool, String) {
84    const RUNTIME_PREFIXES: &[&str] = &["/docker/", "/lxc/", "/kubepods/", "/containerd/"];
85
86    for prefix in RUNTIME_PREFIXES {
87        if let Some(idx) = path.find(prefix) {
88            let after_prefix = &path[idx + prefix.len()..];
89            // Extract the container ID: take everything up to the next '/' or end.
90            let id = after_prefix.split('/').next().unwrap_or("").to_string();
91            return (true, id);
92        }
93    }
94
95    (false, String::new())
96}
97
98// ---------------------------------------------------------------------------
99// AF-info hook classification
100// ---------------------------------------------------------------------------
101
102/// Classify whether a network protocol handler function pointer has been hooked.
103///
104/// Returns `true` when the address is non-zero and outside the kernel text range
105/// `[kernel_start, kernel_end]`.
106pub fn classify_afinfo_hook(hook_addr: u64, kernel_start: u64, kernel_end: u64) -> bool {
107    if hook_addr == 0 {
108        return false;
109    }
110    !(kernel_start <= hook_addr && hook_addr <= kernel_end)
111}
112
113// ---------------------------------------------------------------------------
114// Shared credentials classification
115// ---------------------------------------------------------------------------
116
117/// Heuristic: PIDs <= 2 are typically kernel threads (idle, kthreadd).
118fn is_likely_kernel_thread_heuristic(pid: u32) -> bool {
119    pid <= 2
120}
121
122/// Classify whether shared `struct cred` pointers indicate credential theft.
123///
124/// Returns `true` when a non-kernel-thread shares credentials with init (PID 1)
125/// or when unrelated processes share credentials.
126pub fn classify_shared_creds(pid: u32, shared_with: &[u32], uid: u32) -> bool {
127    // Sharing with init (pid 1) by a non-kernel-thread is suspicious.
128    if shared_with.contains(&1) && pid != 1 {
129        // uid 0 kernel threads sharing with init is expected (kernel cred)
130        if uid == 0 && is_likely_kernel_thread_heuristic(pid) {
131            return false;
132        }
133        return true;
134    }
135
136    // If all participants are uid-0 kernel threads, benign.
137    if uid == 0 && is_likely_kernel_thread_heuristic(pid) {
138        return false;
139    }
140
141    // Conservatively flag any remaining sharing as suspicious.
142    !shared_with.is_empty()
143}
144
145// ---------------------------------------------------------------------------
146// IDT entry classification
147// ---------------------------------------------------------------------------
148
149/// Classify whether an IDT handler address has been hooked.
150///
151/// Returns `true` when the address is non-zero and outside `[kernel_start, kernel_end]`.
152pub fn classify_idt_entry(handler_addr: u64, kernel_start: u64, kernel_end: u64) -> bool {
153    if handler_addr == 0 {
154        return false;
155    }
156    !(kernel_start <= handler_addr && handler_addr <= kernel_end)
157}
158
159// ---------------------------------------------------------------------------
160// Container escape classification
161// ---------------------------------------------------------------------------
162
163/// Kernel thread comm prefixes that are never suspicious.
164const KERNEL_THREAD_COMMS: &[&str] = &["kthread", "kworker", "migration", "ksoftirqd", "rcu_"];
165
166/// Classify whether a process indicator suggests a container escape attempt.
167///
168/// Returns `false` for kernel threads regardless of indicator.
169pub fn classify_container_escape(comm: &str, indicator: &str) -> bool {
170    let is_kernel = KERNEL_THREAD_COMMS
171        .iter()
172        .any(|prefix| comm.starts_with(prefix));
173    if is_kernel {
174        return false;
175    }
176    matches!(indicator, "namespace_mismatch" | "host_mount_access")
177}
178
179// ---------------------------------------------------------------------------
180// Deleted executable classification
181// ---------------------------------------------------------------------------
182
183/// Package manager process names considered benign even when running deleted executables.
184const KNOWN_BENIGN_COMMS: &[&str] = &[
185    "apt",
186    "apt-get",
187    "apt-check",
188    "aptd",
189    "dpkg",
190    "dpkg-deb",
191    "yum",
192    "dnf",
193    "rpm",
194    "rpmdb",
195    "packagekitd",
196    "unattended-upgr",
197];
198
199/// Classify whether a process running from a deleted executable is suspicious.
200///
201/// Returns `false` for kernel threads, package manager processes, and processes
202/// with empty paths/names.
203pub fn classify_deleted_exe(exe_path: &str, comm: &str) -> bool {
204    // Not deleted at all -> not suspicious
205    if !exe_path.contains("(deleted)") {
206        return false;
207    }
208
209    // Empty exe path -> kernel thread, not suspicious
210    if exe_path.is_empty() {
211        return false;
212    }
213
214    // Empty comm -> likely kernel thread, not suspicious
215    if comm.is_empty() {
216        return false;
217    }
218
219    // Check against known-benign process names
220    let comm_lower = comm.to_lowercase();
221    for &benign in KNOWN_BENIGN_COMMS {
222        if comm_lower == benign {
223            return false;
224        }
225    }
226
227    // All other deleted executables are suspicious
228    true
229}
230
231// ---------------------------------------------------------------------------
232// Hidden dentry classification
233// ---------------------------------------------------------------------------
234
235/// File extensions considered suspicious when found in linked dentries.
236const SUSPICIOUS_EXTENSIONS: &[&str] = &[".so", ".py", ".sh", ".elf", ".bin"];
237
238/// Classify whether a dentry is hidden or suspicious.
239///
240/// Returns `true` when `nlink == 0` (unlinked file still mapped) or when the
241/// filename has a suspicious extension despite being linked.
242pub fn classify_hidden_dentry(nlink: u32, filename: &str) -> bool {
243    // Empty filename → kernel internal file, not suspicious.
244    if filename.is_empty() {
245        return false;
246    }
247
248    let name_lower = filename.to_lowercase();
249
250    // File still in the directory tree → check only for suspicious extensions.
251    if nlink > 0 {
252        return SUSPICIOUS_EXTENSIONS
253            .iter()
254            .any(|ext| name_lower.ends_with(ext));
255    }
256
257    // nlink == 0 → file is unlinked (hidden), always suspicious.
258    true
259}
260
261// ---------------------------------------------------------------------------
262// eBPF map classification
263// ---------------------------------------------------------------------------
264
265/// eBPF map name substrings associated with known rootkits.
266const SUSPICIOUS_MAP_NAMES: &[&str] = &[
267    "rootkit",
268    "hide_",
269    "hook",
270    "intercept",
271    "stealth",
272    "secret",
273    "covert",
274    "keylog",
275    "exfil",
276];
277
278/// Classify whether an eBPF map is suspicious.
279///
280/// Flags high-risk map types (perf_event_array=3, ringbuf=26) and maps whose
281/// names match known rootkit patterns.
282pub fn classify_ebpf_map(map_type: u32, name: &str, _value_size: u32) -> bool {
283    let name_lower = name.to_lowercase();
284    let suspicious_name = SUSPICIOUS_MAP_NAMES.iter().any(|p| name_lower.contains(p));
285
286    // perf_event_array (3) and ringbuf (26) are high-risk exfiltration channels
287    let high_risk_type = matches!(map_type, 3 | 26);
288
289    suspicious_name || high_risk_type
290}
291
292// ---------------------------------------------------------------------------
293// Ftrace hook classification
294// ---------------------------------------------------------------------------
295
296/// Classify whether an ftrace function pointer is outside the kernel text range.
297///
298/// Returns `true` when `func < stext || func >= etext`.
299pub fn classify_ftrace_hook(func: u64, stext: u64, etext: u64) -> bool {
300    func < stext || func >= etext
301}
302
303// ---------------------------------------------------------------------------
304// Futex classification
305// ---------------------------------------------------------------------------
306
307/// Classify whether a futex entry is suspicious.
308///
309/// Returns `true` for excessive waiter counts (> 1000) or kernel-space keys
310/// owned by a userspace process.
311pub fn classify_futex(key_address: u64, owner_pid: u32, waiter_count: u32) -> bool {
312    waiter_count > 1000 || (key_address > 0x7FFF_FFFF_FFFF && owner_pid > 0)
313}
314
315// ---------------------------------------------------------------------------
316// io_uring classification
317// ---------------------------------------------------------------------------
318
319/// io_uring opcode for sending a message (IORING_OP_SENDMSG).
320const IORING_OP_SENDMSG: u8 = 9;
321/// io_uring opcode for receiving a message (IORING_OP_RECVMSG).
322const IORING_OP_RECVMSG: u8 = 10;
323/// io_uring opcode for establishing a connection (IORING_OP_CONNECT).
324const IORING_OP_CONNECT: u8 = 16;
325
326/// Sensitive opcodes that bypass seccomp when used with an active filter.
327const SENSITIVE_OPCODES: &[u8] = &[IORING_OP_SENDMSG, IORING_OP_RECVMSG, IORING_OP_CONNECT];
328
329/// Classify whether an io_uring submission is suspicious.
330///
331/// Returns `false` when seccomp is disabled; returns `true` when seccomp is
332/// active and the opcode list contains a sensitive syscall.
333pub fn classify_io_uring(opcodes: &[u8], seccomp_mode: u32) -> bool {
334    if seccomp_mode == 0 {
335        return false;
336    }
337    opcodes.iter().any(|op| SENSITIVE_OPCODES.contains(op))
338}
339
340// ---------------------------------------------------------------------------
341// I/O memory region classification
342// ---------------------------------------------------------------------------
343
344/// Classify whether an `/proc/iomem` region entry is suspicious.
345///
346/// Flags empty names on large regions, non-ASCII names, and regions that
347/// overlap the kernel text range without the expected name.
348pub fn classify_iomem(name: &str, start: u64, end: u64) -> bool {
349    // Empty name on a large region (> 1 MiB) is suspicious.
350    let size = end.saturating_sub(start);
351    if name.is_empty() && size > 1024 * 1024 {
352        return true;
353    }
354
355    // Name with unusual characters (control chars or non-ASCII) is suspicious.
356    if name.chars().any(|c| c.is_control() || !c.is_ascii()) {
357        return true;
358    }
359
360    // Region overlapping kernel text range but not named "Kernel code".
361    #[allow(clippy::items_after_statements)]
362    const KERNEL_TEXT_START: u64 = 0xffff_ffff_8100_0000;
363    #[allow(clippy::items_after_statements)]
364    const KERNEL_TEXT_END: u64 = 0xffff_ffff_8200_0000;
365    if start < KERNEL_TEXT_END && end > KERNEL_TEXT_START && name != "Kernel code" {
366        return true;
367    }
368
369    false
370}
371
372// ---------------------------------------------------------------------------
373// Kernel timer classification
374// ---------------------------------------------------------------------------
375
376/// Classify whether a kernel timer callback is outside the kernel text range.
377///
378/// Returns `false` for null pointers; `true` when the callback is outside
379/// `[kernel_start, kernel_end]`.
380pub fn classify_kernel_timer(function: u64, kernel_start: u64, kernel_end: u64) -> bool {
381    if function == 0 {
382        return false;
383    }
384    // Suspicious if outside kernel text range
385    !(function >= kernel_start && function <= kernel_end)
386}
387
388// ---------------------------------------------------------------------------
389// Keyboard notifier classification
390// ---------------------------------------------------------------------------
391
392/// Classify whether a keyboard notifier callback is outside the kernel text range.
393///
394/// Returns `true` when `notifier_call < stext || notifier_call >= etext`.
395pub fn classify_notifier(notifier_call: u64, stext: u64, etext: u64) -> bool {
396    notifier_call < stext || notifier_call >= etext
397}
398
399// ---------------------------------------------------------------------------
400// Kernel message classification
401// ---------------------------------------------------------------------------
402
403/// Suspicious patterns in kernel log messages.
404const SUSPICIOUS_KMSG_PATTERNS: &[&str] = &[
405    "rootkit",
406    "hide",
407    "call trace",
408    "kernel bug",
409    "general protection",
410];
411
412/// Classify whether a kernel log message matches known suspicious patterns.
413pub fn classify_kmsg(text: &str) -> bool {
414    let lower = text.to_lowercase();
415    SUSPICIOUS_KMSG_PATTERNS.iter().any(|p| lower.contains(p))
416}
417
418// ---------------------------------------------------------------------------
419// Kernel thread classification
420// ---------------------------------------------------------------------------
421
422/// Minimum address for the kernel address space on x86_64.
423const KERNEL_SPACE_MIN: u64 = 0xFFFF_0000_0000_0000;
424
425/// Check whether a name looks like random hex characters (rootkit-generated).
426///
427/// Returns `true` if the name contains a run of 8+ hex digits.
428fn looks_like_hex_name(name: &str) -> bool {
429    let mut run = 0u32;
430    for ch in name.chars() {
431        if ch.is_ascii_hexdigit() {
432            run += 1;
433            if run >= 8 {
434                return true;
435            }
436        } else {
437            run = 0;
438        }
439    }
440    false
441}
442
443/// Classify whether a kernel thread entry looks suspicious.
444///
445/// Returns `(is_suspicious, reason)`. Flags unnamed threads, threads with
446/// userspace start-function addresses, and hex-pattern names.
447pub fn classify_kthread(name: &str, start_fn_addr: u64) -> (bool, Option<String>) {
448    // Check 1: unnamed kernel thread
449    if name.is_empty() {
450        return (true, Some("unnamed kernel thread".into()));
451    }
452
453    // Check 1b: known-benign kernel thread comm prefix — short-circuit to benign
454    if KERNEL_THREAD_COMMS.iter().any(|p| name.starts_with(p)) {
455        return (false, None);
456    }
457
458    // Check 2: start function in userspace range
459    if start_fn_addr != 0 && start_fn_addr < KERNEL_SPACE_MIN {
460        return (
461            true,
462            Some(format!(
463                "thread function at userspace address {start_fn_addr:#x}"
464            )),
465        );
466    }
467
468    // Check 3: name looks like random hex (rootkit-generated)
469    if looks_like_hex_name(name) {
470        return (
471            true,
472            Some(format!("name '{name}' contains suspicious hex pattern")),
473        );
474    }
475
476    (false, None)
477}
478
479// ---------------------------------------------------------------------------
480// LD_PRELOAD classification
481// ---------------------------------------------------------------------------
482
483/// Parse a colon-or-whitespace-separated LD_PRELOAD value into individual paths.
484fn parse_ld_preload(value: &str) -> Vec<String> {
485    value
486        .split(|c: char| c == ':' || c.is_ascii_whitespace())
487        .filter(|s| !s.is_empty())
488        .map(String::from)
489        .collect()
490}
491
492/// Check whether a single library path looks suspicious.
493fn is_suspicious_ld_path(path: &str, safe_prefixes: &[&str]) -> bool {
494    if path.starts_with("/tmp/") || path == "/tmp" {
495        return true;
496    }
497    if path.starts_with("/dev/shm/") || path == "/dev/shm" {
498        return true;
499    }
500    if path
501        .split('/')
502        .any(|component| !component.is_empty() && component.starts_with('.'))
503    {
504        return true;
505    }
506    if !safe_prefixes.iter().any(|prefix| path.starts_with(prefix)) {
507        return true;
508    }
509    false
510}
511
512/// Classify whether an `LD_PRELOAD` value references a suspicious library path.
513///
514/// Returns `true` when any library in the colon/space-separated list resides
515/// outside standard system library directories or in staging directories.
516pub fn classify_ld_preload(value: &str) -> bool {
517    const SAFE_PREFIXES: &[&str] = &[
518        "/usr/lib/",
519        "/usr/lib64/",
520        "/usr/lib32/",
521        "/usr/local/lib/",
522        "/usr/local/lib64/",
523        "/lib/",
524        "/lib64/",
525        "/lib32/",
526    ];
527
528    let libraries = parse_ld_preload(value);
529    libraries
530        .iter()
531        .any(|lib| is_suspicious_ld_path(lib, SAFE_PREFIXES))
532}
533
534// ---------------------------------------------------------------------------
535// Shared library classification
536// ---------------------------------------------------------------------------
537
538/// Classify whether a mapped library path is suspicious.
539///
540/// Flags deleted libraries, libraries in `/tmp`, `/dev/shm`, and libraries
541/// with suspicious extensions.
542pub fn classify_library(lib_path: &str) -> bool {
543    let path = lib_path.trim();
544
545    // Unlinked libraries still mapped in memory.
546    if path.ends_with("(deleted)") {
547        return true;
548    }
549
550    // Strip " (deleted)" suffix for remaining checks.
551    let clean = path.strip_suffix(" (deleted)").unwrap_or(path);
552
553    // World-writable staging directories.
554    if clean.starts_with("/tmp/")
555        || clean == "/tmp"
556        || clean.starts_with("/dev/shm/")
557        || clean == "/dev/shm"
558        || clean.starts_with("/var/tmp/")
559        || clean == "/var/tmp"
560    {
561        return true;
562    }
563
564    // Hidden file (basename starts with '.').
565    if let Some(basename) = clean.rsplit('/').next() {
566        if basename.starts_with('.') && !basename.is_empty() {
567            return true;
568        }
569    }
570
571    // Not a standard shared library name.
572    if !std::path::Path::new(clean)
573        .extension()
574        .is_some_and(|e| e.eq_ignore_ascii_case("so"))
575        && !clean.contains(".so.")
576    {
577        return true;
578    }
579
580    false
581}
582
583// ---------------------------------------------------------------------------
584// memfd classification
585// ---------------------------------------------------------------------------
586
587/// Known-benign memfd name prefixes.
588const BENIGN_MEMFD_PREFIXES: &[&str] = &[
589    "shm",
590    "pulseaudio",
591    "wayland",
592    "dbus",
593    "chrome",
594    "firefox",
595    "v8",
596];
597
598/// Suspicious memfd name substrings (case-insensitive).
599const SUSPICIOUS_MEMFD_NAMES: &[&str] =
600    &["payload", "shellcode", "stage", "loader", "inject", "hack"];
601
602/// Classify whether a `memfd_create` file is suspicious.
603///
604/// Executable anonymous memory is always suspicious. Empty names and names
605/// matching known rootkit patterns are also flagged.
606pub fn classify_memfd(name: &str, is_executable: bool) -> bool {
607    // Executable anonymous memory is always suspicious.
608    if is_executable {
609        return true;
610    }
611
612    let name_lower = name.to_lowercase();
613
614    // Known-benign prefixes override everything else.
615    for prefix in BENIGN_MEMFD_PREFIXES {
616        if name_lower.starts_with(prefix) {
617            return false;
618        }
619    }
620
621    // Empty name → evasion attempt.
622    if name.is_empty() {
623        return true;
624    }
625
626    // Suspicious substrings.
627    for s in SUSPICIOUS_MEMFD_NAMES {
628        if name_lower.contains(s) {
629            return true;
630        }
631    }
632
633    false
634}
635
636// ---------------------------------------------------------------------------
637// Kernel module visibility classification
638// ---------------------------------------------------------------------------
639
640/// Classify whether a kernel module is hidden by cross-referencing three views.
641///
642/// Returns `true` when the module is present in at least one view but absent
643/// from at least one other (partial visibility = hidden).
644pub fn classify_module_visibility(
645    in_module_list: bool,
646    in_kobj_list: bool,
647    in_memory_map: bool,
648) -> bool {
649    let present_count = [in_module_list, in_kobj_list, in_memory_map]
650        .iter()
651        .filter(|&&v| v)
652        .count();
653
654    // Hidden if present in at least one view but not all three
655    present_count > 0 && present_count < 3
656}
657
658// ---------------------------------------------------------------------------
659// Mount classification
660// ---------------------------------------------------------------------------
661
662/// Classify whether a mount entry is suspicious.
663///
664/// Flags unusual tmpfs/ramfs mounts and overlay mounts outside known container
665/// runtime paths.
666pub fn classify_mount(fs_type: &str, dev_name: &str, mnt_root: &str) -> bool {
667    let _ = dev_name;
668    match fs_type {
669        "tmpfs" | "ramfs" => {
670            !matches!(
671                mnt_root,
672                "/tmp" | "/run" | "/dev/shm" | "/run/lock" | "/run/user" | "/"
673            ) && !mnt_root.starts_with("/run/")
674                && !mnt_root.starts_with("/tmp/")
675                && !mnt_root.starts_with("/dev/")
676        }
677        "overlay" | "overlayfs" => {
678            !mnt_root.starts_with("/var/lib/docker") && !mnt_root.starts_with("/var/lib/containerd")
679        }
680        _ => false,
681    }
682}
683
684// ---------------------------------------------------------------------------
685// OOM victim classification
686// ---------------------------------------------------------------------------
687
688/// Process names considered suspicious OOM victims (security/monitoring daemons).
689const SUSPICIOUS_OOM_NAMES: &[&str] = &[
690    "auditd",
691    "sshd",
692    "systemd",
693    "journald",
694    "rsyslogd",
695    "containerd",
696    "dockerd",
697];
698
699/// Classify whether an OOM-killed process is suspicious.
700///
701/// Flags processes with names matching known attacker tools and processes with
702/// very low PIDs (< 100).
703pub fn classify_oom_victim(comm: &str, pid: u32) -> bool {
704    let lower = comm.to_ascii_lowercase();
705    SUSPICIOUS_OOM_NAMES.iter().any(|n| lower.contains(n)) || pid < 100
706}
707
708// ---------------------------------------------------------------------------
709// PAM hook classification
710// ---------------------------------------------------------------------------
711
712/// Known system PAM library directory prefixes.
713const SYSTEM_LIB_PREFIXES: &[&str] =
714    &["/lib", "/usr/lib", "/usr/lib64", "/lib64", "/usr/local/lib"];
715
716/// Classify whether a PAM library path is suspicious.
717///
718/// Returns `true` when the path contains "pam" (case-insensitive) and does not
719/// start with a known system library directory.
720pub fn classify_pam_hook(path: &str) -> bool {
721    if path.is_empty() {
722        return false;
723    }
724    let lower = path.to_lowercase();
725    if !lower.contains("pam") {
726        return false;
727    }
728    !SYSTEM_LIB_PREFIXES
729        .iter()
730        .any(|prefix| path.starts_with(prefix))
731}
732
733// ---------------------------------------------------------------------------
734// perf_event classification
735// ---------------------------------------------------------------------------
736
737/// Classify whether a `perf_event` is suspicious.
738///
739/// Flags RAW PMU access (type 4) and certain cache event configurations (type 3).
740pub fn classify_perf_event(event_type: u32, config: u64) -> bool {
741    match event_type {
742        3 => (config & 0xFF) <= 2, // L1D (0) or LL (2) cache events
743        4 => true,                 // RAW PMU access always suspicious from userspace
744        _ => false,
745    }
746}
747
748// ---------------------------------------------------------------------------
749// psaux classification
750// ---------------------------------------------------------------------------
751
752/// Linux `PF_KTHREAD` flag — set on kernel threads.
753const PF_KTHREAD: u64 = 0x0020_0000;
754/// Process virtual size threshold above which a process is suspicious.
755const VSIZE_ABUSE_THRESHOLD: u64 = 100 * 1024 * 1024 * 1024;
756
757/// Classify whether process auxiliary state is suspicious.
758///
759/// Flags impossible combinations: zombie root processes, non-root kernel
760/// threads, and processes with extremely large virtual address spaces.
761pub fn classify_psaux(state: u64, uid: u32, flags: u64, vsize: u64) -> bool {
762    if state == 16 && uid == 0 {
763        return true;
764    }
765    if (flags & PF_KTHREAD) != 0 && uid != 0 {
766        return true;
767    }
768    if vsize > VSIZE_ABUSE_THRESHOLD {
769        return true;
770    }
771    false
772}
773
774// ---------------------------------------------------------------------------
775// ptrace classification
776// ---------------------------------------------------------------------------
777
778/// Well-known debugger/tracer binaries that are expected to ptrace.
779const KNOWN_DEBUGGERS: &[&str] = &["gdb", "lldb", "strace", "ltrace", "valgrind", "perf"];
780
781/// High-value target processes — tracing these by a non-debugger is suspicious.
782const HIGH_VALUE_TARGETS: &[&str] = &["sshd", "login", "passwd", "sudo", "su", "gpg-agent"];
783
784/// Classify whether a ptrace relationship is suspicious.
785///
786/// Flags tracers with empty names, tracers of high-value system processes, and
787/// self-tracing processes.
788pub fn classify_ptrace(tracer_name: &str, tracee_name: &str) -> bool {
789    if tracer_name.is_empty() {
790        return true;
791    }
792    if KNOWN_DEBUGGERS.contains(&tracer_name) {
793        return false;
794    }
795    if HIGH_VALUE_TARGETS.contains(&tracee_name) {
796        return true;
797    }
798    if tracer_name == tracee_name {
799        return true;
800    }
801    false
802}
803
804// ---------------------------------------------------------------------------
805// Raw socket classification
806// ---------------------------------------------------------------------------
807
808/// Known-benign process names that legitimately use `AF_PACKET` sockets.
809const BENIGN_AF_PACKET: &[&str] = &[
810    "tcpdump",
811    "wireshark",
812    "dumpcap",
813    "dhclient",
814    "dhcpcd",
815    "arping",
816    "ping",
817    "ping6",
818];
819
820/// Known-benign process names that legitimately use `SOCK_RAW` sockets.
821const BENIGN_SOCK_RAW: &[&str] = &["ping", "ping6", "traceroute", "traceroute6", "arping"];
822
823/// Classify whether a raw socket is suspicious.
824///
825/// Promiscuous sockets are always suspicious. AF_PACKET sockets owned by
826/// non-standard utilities are flagged.
827pub fn classify_raw_socket(comm: &str, socket_type: &str, is_promiscuous: bool) -> bool {
828    if is_promiscuous {
829        return true;
830    }
831
832    let comm_lower = comm.to_lowercase();
833
834    if socket_type == "AF_PACKET" {
835        return !BENIGN_AF_PACKET.iter().any(|&b| comm_lower == b);
836    }
837
838    if socket_type == "SOCK_RAW" {
839        return !BENIGN_SOCK_RAW.iter().any(|&b| comm_lower == b);
840    }
841
842    false
843}
844
845// ---------------------------------------------------------------------------
846// Signal handler classification
847// ---------------------------------------------------------------------------
848
849/// Classify whether a signal handler configuration is suspicious.
850///
851/// Flags SIG_IGN for SIGTERM/SIGHUP (anti-termination), custom handlers for
852/// SIGSEGV (self-healing), and any SIGKILL handler (rootkit indicator).
853pub fn classify_signal_handler(signal: u32, handler: u64) -> bool {
854    match signal {
855        // SIGTERM or SIGHUP ignored -> anti-termination
856        15 | 1 => handler == 1,
857        // SIGSEGV with custom handler -> self-healing malware
858        11 => handler != 0 && handler != 1,
859        // SIGKILL tampered -> kernel rootkit (normally impossible)
860        9 => handler != 0,
861        _ => false,
862    }
863}
864
865// ---------------------------------------------------------------------------
866// systemd unit classification
867// ---------------------------------------------------------------------------
868
869/// ExecStart patterns considered suspicious.
870const SUSPICIOUS_EXEC_PATTERNS: &[&str] = &[
871    "/tmp/",
872    "/dev/shm/",
873    "/var/tmp/",
874    "curl",
875    "wget",
876    "bash -c",
877    "sh -c",
878    "python",
879    "perl",
880    "ruby",
881    "nc ",
882    "ncat",
883    "base64",
884];
885
886/// ExecStart prefixes considered safe.
887const SAFE_EXEC_PREFIXES: &[&str] = &["/usr/", "/bin/", "/sbin/", "/lib/"];
888
889/// Known safe unit name prefixes.
890const KNOWN_SAFE_UNITS: &[&str] = &["systemd-", "NetworkManager", "dbus", "cron", "ssh"];
891
892/// Unit file extensions used for hex-name detection.
893const UNIT_EXTENSIONS: &[&str] = &[".service", ".timer", ".socket", ".path", ".mount"];
894
895/// Classify whether a systemd unit is suspicious.
896///
897/// Returns `false` for known-safe unit names and safe `ExecStart` prefixes.
898pub fn classify_systemd_unit(unit_name: &str, exec_start: &str) -> bool {
899    // Known safe units are never suspicious.
900    if KNOWN_SAFE_UNITS
901        .iter()
902        .any(|prefix| unit_name.starts_with(prefix))
903    {
904        return false;
905    }
906
907    // Safe ExecStart prefix — not suspicious.
908    if SAFE_EXEC_PREFIXES
909        .iter()
910        .any(|prefix| exec_start.starts_with(prefix))
911    {
912        return false;
913    }
914
915    // Suspicious ExecStart patterns.
916    if SUSPICIOUS_EXEC_PATTERNS
917        .iter()
918        .any(|pat| exec_start.contains(pat))
919    {
920        return true;
921    }
922
923    // Randomized name: strip extension, check if remainder is 8+ lowercase hex chars.
924    let stem = UNIT_EXTENSIONS
925        .iter()
926        .find_map(|ext| unit_name.strip_suffix(ext))
927        .unwrap_or(unit_name);
928    if stem.len() >= 8
929        && stem
930            .chars()
931            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
932    {
933        return true;
934    }
935
936    false
937}
938
939// ---------------------------------------------------------------------------
940// tmpfs file classification
941// ---------------------------------------------------------------------------
942
943/// Classify whether a tmpfs file is suspicious.
944///
945/// Flags executable regular files and hidden files (names starting with `.`).
946pub fn classify_tmpfs_file(filename: &str, mode: u32) -> bool {
947    // S_IFREG = 0o100000; S_IFMT = 0o170000
948    let is_regular_file = (mode & 0o170_000) == 0o100_000;
949    let is_exec = is_regular_file && (mode & 0o111) != 0;
950    let is_hidden = filename.starts_with('.') && filename.len() > 1;
951    is_exec || is_hidden
952}
953
954// ---------------------------------------------------------------------------
955// Unix socket classification
956// ---------------------------------------------------------------------------
957
958/// Classify whether a Unix domain socket is suspicious.
959///
960/// Flags abstract sockets owned by high-uid processes and sockets in staging
961/// directories.
962pub fn classify_unix_socket(path: &str, owner_pid: u32) -> bool {
963    let is_abstract = path.is_empty() || path.starts_with('@');
964    if is_abstract && owner_pid >= 1000 {
965        return true;
966    }
967    if path.starts_with("/tmp") || path.starts_with("/dev/shm") {
968        return true;
969    }
970    false
971}
972
973// ---------------------------------------------------------------------------
974// Zombie/orphan classification
975// ---------------------------------------------------------------------------
976
977/// Daemon names considered suspicious when found as orphan processes.
978const SUSPICIOUS_DAEMON_NAMES: &[&str] = &[
979    "sshd",
980    "httpd",
981    "nginx",
982    "apache",
983    "mysqld",
984    "postgres",
985    "redis",
986    "memcached",
987    "mongod",
988    "named",
989    "bind",
990    "cupsd",
991    "cron",
992    "atd",
993];
994
995/// Classify whether a zombie or orphan process is suspicious.
996///
997/// Flags zombie processes re-parented to init and orphan processes with names
998/// matching known attacker tools.
999pub fn classify_zombie_orphan(is_zombie: bool, is_orphan: bool, ppid: u32, comm: &str) -> bool {
1000    if is_zombie && ppid == 1 {
1001        return true;
1002    }
1003    if is_orphan {
1004        let lower = comm.to_lowercase();
1005        if SUSPICIOUS_DAEMON_NAMES
1006            .iter()
1007            .any(|&name| lower.contains(name))
1008        {
1009            return true;
1010        }
1011    }
1012    false
1013}
1014
1015// ---------------------------------------------------------------------------
1016// Tests
1017// ---------------------------------------------------------------------------
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022
1023    // --- classify_bpf_program ---
1024
1025    #[test]
1026    fn heuristics_bpf_kprobe_is_suspicious() {
1027        assert!(classify_bpf_program("kprobe", "my_hook"));
1028    }
1029
1030    #[test]
1031    fn heuristics_bpf_lsm_is_suspicious() {
1032        assert!(classify_bpf_program("lsm", ""));
1033    }
1034
1035    #[test]
1036    fn heuristics_bpf_xdp_benign() {
1037        assert!(!classify_bpf_program("xdp", "firewall"));
1038    }
1039
1040    #[test]
1041    fn heuristics_bpf_unnamed_tracing_suspicious() {
1042        assert!(classify_bpf_program("tracing", ""));
1043    }
1044
1045    #[test]
1046    fn heuristics_bpf_named_tracing_benign() {
1047        assert!(!classify_bpf_program("tracing", "named_prog"));
1048    }
1049
1050    // --- classify_capabilities ---
1051
1052    #[test]
1053    fn heuristics_capabilities_root_never_suspicious() {
1054        let (susp, names) = classify_capabilities(u64::MAX, 0);
1055        assert!(!susp);
1056        assert!(names.is_empty());
1057    }
1058
1059    #[test]
1060    fn heuristics_capabilities_non_root_sys_admin_suspicious() {
1061        let cap_sys_admin: u64 = 1 << 21;
1062        let (susp, names) = classify_capabilities(cap_sys_admin, 1000);
1063        assert!(susp);
1064        assert!(!names.is_empty());
1065    }
1066
1067    #[test]
1068    fn heuristics_capabilities_non_root_no_caps_benign() {
1069        let (susp, names) = classify_capabilities(0, 1000);
1070        assert!(!susp);
1071        assert!(names.is_empty());
1072    }
1073
1074    // --- classify_cgroup ---
1075
1076    #[test]
1077    fn heuristics_cgroup_docker_detected() {
1078        let (in_container, id) = classify_cgroup("/docker/abc123def456");
1079        assert!(in_container);
1080        assert_eq!(id, "abc123def456");
1081    }
1082
1083    #[test]
1084    fn heuristics_cgroup_bare_root_not_container() {
1085        let (in_container, id) = classify_cgroup("/");
1086        assert!(!in_container);
1087        assert!(id.is_empty());
1088    }
1089
1090    // --- classify_afinfo_hook ---
1091
1092    #[test]
1093    fn heuristics_afinfo_null_not_hooked() {
1094        assert!(!classify_afinfo_hook(0, 0xffff0000, 0xffff8000));
1095    }
1096
1097    #[test]
1098    fn heuristics_afinfo_in_range_benign() {
1099        assert!(!classify_afinfo_hook(0xffff1000, 0xffff0000, 0xffff8000));
1100    }
1101
1102    #[test]
1103    fn heuristics_afinfo_outside_range_suspicious() {
1104        assert!(classify_afinfo_hook(
1105            0x0000_dead_beef,
1106            0xffff0000,
1107            0xffff8000
1108        ));
1109    }
1110
1111    // --- classify_shared_creds ---
1112
1113    #[test]
1114    fn heuristics_shared_creds_userspace_shares_with_init_suspicious() {
1115        assert!(classify_shared_creds(500, &[1], 1000));
1116    }
1117
1118    #[test]
1119    fn heuristics_shared_creds_empty_list_benign() {
1120        assert!(!classify_shared_creds(500, &[], 1000));
1121    }
1122
1123    #[test]
1124    fn heuristics_shared_creds_kernel_thread_shares_with_init_benign() {
1125        // pid 2 (kthreadd), uid 0 shares with init — expected
1126        assert!(!classify_shared_creds(2, &[1], 0));
1127    }
1128
1129    // --- classify_idt_entry ---
1130
1131    #[test]
1132    fn heuristics_idt_null_not_hooked() {
1133        assert!(!classify_idt_entry(0, 0xffff0000, 0xffff8000));
1134    }
1135
1136    #[test]
1137    fn heuristics_idt_in_kernel_range_benign() {
1138        assert!(!classify_idt_entry(0xffff2000, 0xffff0000, 0xffff8000));
1139    }
1140
1141    #[test]
1142    fn heuristics_idt_outside_range_suspicious() {
1143        assert!(classify_idt_entry(0x1234, 0xffff0000, 0xffff8000));
1144    }
1145
1146    // --- classify_container_escape ---
1147
1148    #[test]
1149    fn heuristics_container_escape_namespace_mismatch_suspicious() {
1150        assert!(classify_container_escape("bash", "namespace_mismatch"));
1151    }
1152
1153    #[test]
1154    fn heuristics_container_escape_kernel_thread_benign() {
1155        assert!(!classify_container_escape(
1156            "kworker/0:0",
1157            "namespace_mismatch"
1158        ));
1159    }
1160
1161    #[test]
1162    fn heuristics_container_escape_unknown_indicator_benign() {
1163        assert!(!classify_container_escape("bash", "some_other_thing"));
1164    }
1165
1166    // --- classify_deleted_exe ---
1167
1168    #[test]
1169    fn heuristics_deleted_exe_not_deleted_benign() {
1170        assert!(!classify_deleted_exe("/usr/bin/bash", "bash"));
1171    }
1172
1173    #[test]
1174    fn heuristics_deleted_exe_suspicious() {
1175        assert!(classify_deleted_exe("/tmp/evil (deleted)", "evil"));
1176    }
1177
1178    #[test]
1179    fn heuristics_deleted_exe_empty_comm_benign() {
1180        assert!(!classify_deleted_exe("/tmp/x (deleted)", ""));
1181    }
1182
1183    // --- classify_hidden_dentry ---
1184
1185    #[test]
1186    fn heuristics_hidden_dentry_nlink_zero_suspicious() {
1187        assert!(classify_hidden_dentry(0, "normal.txt"));
1188    }
1189
1190    #[test]
1191    fn heuristics_hidden_dentry_empty_filename_benign() {
1192        assert!(!classify_hidden_dentry(0, ""));
1193    }
1194
1195    #[test]
1196    fn heuristics_hidden_dentry_linked_no_suspicious_ext_benign() {
1197        assert!(!classify_hidden_dentry(1, "readme.txt"));
1198    }
1199
1200    // --- classify_ebpf_map ---
1201
1202    #[test]
1203    fn heuristics_ebpf_map_ringbuf_suspicious() {
1204        // map_type 26 = ringbuf — high-risk exfiltration channel
1205        assert!(classify_ebpf_map(26, "benign_name", 8));
1206    }
1207
1208    #[test]
1209    fn heuristics_ebpf_map_perf_event_array_suspicious() {
1210        assert!(classify_ebpf_map(3, "benign_name", 8));
1211    }
1212
1213    #[test]
1214    fn heuristics_ebpf_map_hash_benign_name_benign() {
1215        // map_type 1 = hash, benign name
1216        assert!(!classify_ebpf_map(1, "counters", 8));
1217    }
1218
1219    // --- classify_ftrace_hook ---
1220
1221    #[test]
1222    fn heuristics_ftrace_in_text_benign() {
1223        assert!(!classify_ftrace_hook(0x1000, 0x1000, 0x2000));
1224    }
1225
1226    #[test]
1227    fn heuristics_ftrace_outside_text_suspicious() {
1228        assert!(classify_ftrace_hook(0x500, 0x1000, 0x2000));
1229    }
1230
1231    // --- classify_futex ---
1232
1233    #[test]
1234    fn heuristics_futex_high_waiter_count_suspicious() {
1235        assert!(classify_futex(0x1000, 0, 1001));
1236    }
1237
1238    #[test]
1239    fn heuristics_futex_normal_benign() {
1240        assert!(!classify_futex(0x1000, 0, 5));
1241    }
1242
1243    #[test]
1244    fn heuristics_futex_kernel_key_userspace_owner_suspicious() {
1245        assert!(classify_futex(0xffff_0000_0000, 1234, 0));
1246    }
1247
1248    // --- classify_io_uring ---
1249
1250    #[test]
1251    fn heuristics_io_uring_no_seccomp_benign() {
1252        assert!(!classify_io_uring(&[1, 2, 3], 0));
1253    }
1254
1255    #[test]
1256    fn heuristics_io_uring_no_opcodes_benign() {
1257        assert!(!classify_io_uring(&[], 1));
1258    }
1259
1260    // --- classify_iomem ---
1261
1262    #[test]
1263    fn heuristics_iomem_kernel_code_name_benign() {
1264        assert!(!classify_iomem(
1265            "Kernel code",
1266            0xffff_ffff_8100_0000,
1267            0xffff_ffff_8180_0000
1268        ));
1269    }
1270
1271    #[test]
1272    fn heuristics_iomem_empty_name_small_region_benign() {
1273        // Under 1 MiB — not suspicious
1274        assert!(!classify_iomem("", 0, 1024));
1275    }
1276
1277    #[test]
1278    fn heuristics_iomem_empty_name_large_region_suspicious() {
1279        assert!(classify_iomem("", 0, 2 * 1024 * 1024));
1280    }
1281
1282    // --- classify_kernel_timer ---
1283
1284    #[test]
1285    fn heuristics_kernel_timer_null_benign() {
1286        assert!(!classify_kernel_timer(0, 0xffff0000, 0xffff8000));
1287    }
1288
1289    #[test]
1290    fn heuristics_kernel_timer_in_range_benign() {
1291        assert!(!classify_kernel_timer(0xffff1000, 0xffff0000, 0xffff8000));
1292    }
1293
1294    #[test]
1295    fn heuristics_kernel_timer_outside_range_suspicious() {
1296        assert!(classify_kernel_timer(0x1234, 0xffff0000, 0xffff8000));
1297    }
1298
1299    // --- classify_notifier ---
1300
1301    #[test]
1302    fn heuristics_notifier_in_text_benign() {
1303        assert!(!classify_notifier(0x1000, 0x1000, 0x2000));
1304    }
1305
1306    #[test]
1307    fn heuristics_notifier_below_stext_suspicious() {
1308        assert!(classify_notifier(0x500, 0x1000, 0x2000));
1309    }
1310
1311    // --- classify_kmsg ---
1312
1313    #[test]
1314    fn heuristics_kmsg_normal_message_benign() {
1315        assert!(!classify_kmsg("USB device connected"));
1316    }
1317
1318    // --- classify_kthread ---
1319
1320    #[test]
1321    fn heuristics_kthread_empty_name_suspicious() {
1322        let (susp, reason) = classify_kthread("", 0xffff_8000_0000);
1323        assert!(susp);
1324        assert!(reason.is_some());
1325    }
1326
1327    #[test]
1328    fn heuristics_kthread_named_kernel_fn_benign() {
1329        let (susp, _) = classify_kthread("kworker/0:0", 0xffff_8000_1234);
1330        assert!(!susp);
1331    }
1332
1333    // --- classify_ld_preload ---
1334
1335    #[test]
1336    fn heuristics_ld_preload_tmp_path_suspicious() {
1337        assert!(classify_ld_preload("/tmp/evil.so"));
1338    }
1339
1340    #[test]
1341    fn heuristics_ld_preload_system_lib_benign() {
1342        assert!(!classify_ld_preload("/usr/lib/libfoo.so"));
1343    }
1344
1345    // --- classify_library ---
1346
1347    #[test]
1348    fn heuristics_library_deleted_suspicious() {
1349        assert!(classify_library("/usr/lib/libfoo.so (deleted)"));
1350    }
1351
1352    #[test]
1353    fn heuristics_library_normal_benign() {
1354        assert!(!classify_library("/usr/lib/libc.so.6"));
1355    }
1356
1357    #[test]
1358    fn heuristics_library_tmp_suspicious() {
1359        assert!(classify_library("/tmp/inject.so"));
1360    }
1361
1362    // --- classify_memfd ---
1363
1364    #[test]
1365    fn heuristics_memfd_executable_suspicious() {
1366        assert!(classify_memfd("legit_name", true));
1367    }
1368
1369    #[test]
1370    fn heuristics_memfd_empty_name_suspicious() {
1371        assert!(classify_memfd("", false));
1372    }
1373
1374    // --- classify_module_visibility ---
1375
1376    #[test]
1377    fn heuristics_module_visibility_all_present_benign() {
1378        assert!(!classify_module_visibility(true, true, true));
1379    }
1380
1381    #[test]
1382    fn heuristics_module_visibility_partial_hidden() {
1383        assert!(classify_module_visibility(true, false, true));
1384    }
1385
1386    #[test]
1387    fn heuristics_module_visibility_all_absent_benign() {
1388        // Not found anywhere — not suspicious, just absent
1389        assert!(!classify_module_visibility(false, false, false));
1390    }
1391
1392    // --- classify_mount ---
1393
1394    #[test]
1395    fn heuristics_mount_known_tmpfs_root_benign() {
1396        assert!(!classify_mount("tmpfs", "tmpfs", "/tmp"));
1397    }
1398
1399    #[test]
1400    fn heuristics_mount_unknown_tmpfs_suspicious() {
1401        assert!(classify_mount("tmpfs", "tmpfs", "/secret_staging"));
1402    }
1403
1404    #[test]
1405    fn heuristics_mount_ext4_benign() {
1406        assert!(!classify_mount("ext4", "/dev/sda1", "/"));
1407    }
1408
1409    // --- classify_oom_victim ---
1410
1411    #[test]
1412    fn heuristics_oom_victim_low_pid_suspicious() {
1413        assert!(classify_oom_victim("bash", 5));
1414    }
1415
1416    #[test]
1417    fn heuristics_oom_victim_normal_benign() {
1418        assert!(!classify_oom_victim("chrome", 5000));
1419    }
1420
1421    // --- classify_pam_hook ---
1422
1423    #[test]
1424    fn heuristics_pam_hook_empty_benign() {
1425        assert!(!classify_pam_hook(""));
1426    }
1427
1428    #[test]
1429    fn heuristics_pam_hook_system_lib_benign() {
1430        assert!(!classify_pam_hook("/lib/x86_64-linux-gnu/libpam.so.0"));
1431    }
1432
1433    #[test]
1434    fn heuristics_pam_hook_tmp_suspicious() {
1435        assert!(classify_pam_hook("/tmp/fakepam.so"));
1436    }
1437
1438    // --- classify_perf_event ---
1439
1440    #[test]
1441    fn heuristics_perf_event_raw_pmu_suspicious() {
1442        assert!(classify_perf_event(4, 0));
1443    }
1444
1445    #[test]
1446    fn heuristics_perf_event_software_benign() {
1447        assert!(!classify_perf_event(1, 0));
1448    }
1449
1450    // --- classify_psaux ---
1451
1452    #[test]
1453    fn heuristics_psaux_zombie_root_suspicious() {
1454        // state=16 (zombie), uid=0
1455        assert!(classify_psaux(16, 0, 0, 0));
1456    }
1457
1458    #[test]
1459    fn heuristics_psaux_normal_process_benign() {
1460        assert!(!classify_psaux(1, 1000, 0, 4096));
1461    }
1462
1463    // --- classify_ptrace ---
1464
1465    #[test]
1466    fn heuristics_ptrace_empty_tracer_suspicious() {
1467        assert!(classify_ptrace("", "bash"));
1468    }
1469
1470    #[test]
1471    fn heuristics_ptrace_gdb_tracing_bash_benign() {
1472        assert!(!classify_ptrace("gdb", "bash"));
1473    }
1474
1475    // --- classify_raw_socket ---
1476
1477    #[test]
1478    fn heuristics_raw_socket_promiscuous_suspicious() {
1479        assert!(classify_raw_socket("tcpdump", "AF_PACKET", true));
1480    }
1481
1482    #[test]
1483    fn heuristics_raw_socket_tcpdump_not_promiscuous_benign() {
1484        assert!(!classify_raw_socket("tcpdump", "AF_PACKET", false));
1485    }
1486
1487    // --- classify_signal_handler ---
1488
1489    #[test]
1490    fn heuristics_signal_handler_sigterm_ignored_suspicious() {
1491        // handler=1 (SIG_IGN) for SIGTERM (15)
1492        assert!(classify_signal_handler(15, 1));
1493    }
1494
1495    #[test]
1496    fn heuristics_signal_handler_sigterm_default_benign() {
1497        assert!(!classify_signal_handler(15, 0));
1498    }
1499
1500    #[test]
1501    fn heuristics_signal_handler_sigkill_nonzero_suspicious() {
1502        assert!(classify_signal_handler(9, 0x1234));
1503    }
1504
1505    // --- classify_systemd_unit ---
1506
1507    #[test]
1508    fn heuristics_systemd_unit_suspicious_exec_start() {
1509        assert!(classify_systemd_unit("evil.service", "/tmp/backdoor.sh"));
1510    }
1511
1512    // --- classify_tmpfs_file ---
1513
1514    #[test]
1515    fn heuristics_tmpfs_file_executable_regular_suspicious() {
1516        // S_IFREG | executable: 0o100755
1517        assert!(classify_tmpfs_file("payload", 0o100_755));
1518    }
1519
1520    #[test]
1521    fn heuristics_tmpfs_file_hidden_suspicious() {
1522        assert!(classify_tmpfs_file(".hidden", 0o100_644));
1523    }
1524
1525    #[test]
1526    fn heuristics_tmpfs_file_normal_benign() {
1527        assert!(!classify_tmpfs_file("readme.txt", 0o100_644));
1528    }
1529
1530    // --- classify_unix_socket ---
1531
1532    #[test]
1533    fn heuristics_unix_socket_abstract_high_uid_suspicious() {
1534        // Abstract socket (empty path), owner_pid >= 1000
1535        assert!(classify_unix_socket("", 1234));
1536    }
1537
1538    #[test]
1539    fn heuristics_unix_socket_system_path_benign() {
1540        assert!(!classify_unix_socket("/var/run/docker.sock", 500));
1541    }
1542
1543    #[test]
1544    fn heuristics_unix_socket_tmp_suspicious() {
1545        assert!(classify_unix_socket("/tmp/evil.sock", 500));
1546    }
1547
1548    // --- classify_zombie_orphan ---
1549
1550    #[test]
1551    fn heuristics_zombie_orphan_reparented_to_init_suspicious() {
1552        assert!(classify_zombie_orphan(true, false, 1, "bash"));
1553    }
1554
1555    #[test]
1556    fn heuristics_zombie_orphan_normal_benign() {
1557        assert!(!classify_zombie_orphan(false, false, 1234, "chrome"));
1558    }
1559}