Skip to main content

memf_linux/
cgroups.rs

1//! Linux cgroup membership enumeration for container forensics.
2//!
3//! Enumerates cgroup memberships for processes to identify container isolation
4//! (Docker, LXC, Kubernetes pods) and resource limits. Forensically significant
5//! for detecting containerized malware or container escapes.
6//!
7//! MITRE ATT&CK T1610 — Deploy Container.
8
9use memf_core::object_reader::ObjectReader;
10use memf_format::PhysicalMemoryProvider;
11
12use crate::{ProcessInfo, Result};
13
14/// Cgroup membership information extracted from a process's `task_struct`.
15#[derive(Debug, Clone, serde::Serialize)]
16pub struct CgroupInfo {
17    /// Process ID.
18    pub pid: u64,
19    /// Process command name (from `task_struct.comm`).
20    pub comm: String,
21    /// Full cgroup path (e.g., "/docker/abc123.../").
22    pub cgroup_path: String,
23    /// Cgroup controller names (e.g., "cpu,memory,blkio").
24    pub controllers: String,
25    /// Whether this process is running inside a container.
26    pub is_containerized: bool,
27    /// Extracted container ID (64-char hex for Docker, or shorter slug).
28    pub container_id: String,
29    /// Whether the cgroup membership is suspicious (container escape indicator).
30    pub is_suspicious: bool,
31}
32
33/// Classify a cgroup path to detect container membership and extract container ID.
34///
35/// Returns `(is_containerized, container_id)`.
36///
37/// A process is classified as containerized if its cgroup path contains any of:
38/// - `/docker/`   — Docker container
39/// - `/lxc/`      — LXC container
40/// - `/kubepods/` — Kubernetes pod
41/// - `/containerd/` — containerd-managed container
42///
43/// For Docker containers, the container ID is the 64-character hex string
44/// following `/docker/`. For other runtimes, the segment after the runtime
45/// prefix is extracted as the container ID.
46pub use crate::heuristics::classify_cgroup;
47
48/// Classify whether a cgroup path is suspicious (potential container escape).
49///
50/// Suspicious conditions:
51/// - Cgroup path is root `"/"` for a non-init process (PID != 1): suggests
52///   the process escaped its cgroup namespace.
53/// - Cgroup path contains `"privileged"`: indicates a privileged container
54///   which weakens isolation boundaries.
55fn is_suspicious_cgroup(path: &str, pid: u64) -> bool {
56    // Root cgroup for non-init process suggests escape.
57    if path == "/" && pid != 1 {
58        return true;
59    }
60
61    // Privileged containers weaken isolation.
62    if path.contains("privileged") {
63        return true;
64    }
65
66    false
67}
68
69/// Walk cgroup membership information for each process in the provided list.
70///
71/// Reads `task_struct.cgroups` (pointer to `css_set`) for each process,
72/// then traverses `css_set.cg_links` to find `cgroup_subsys_state` entries.
73/// Reads the cgroup path from the `cgroup.kn.name` chain. Classifies each
74/// process for containerization and suspicious indicators.
75///
76/// Processes whose cgroup information is unreadable are silently skipped
77/// (graceful degradation).
78pub fn walk_cgroups<P: PhysicalMemoryProvider>(
79    reader: &ObjectReader<P>,
80    processes: &[ProcessInfo],
81) -> Result<Vec<CgroupInfo>> {
82    // Resolve required field offsets; graceful degradation if missing.
83    let cgroups_offset = match reader.symbols().field_offset("task_struct", "cgroups") {
84        Some(off) => off,
85        None => return Ok(Vec::new()),
86    };
87
88    // css_set.subsys is an array of pointers to cgroup_subsys_state.
89    // We need css_set to get a cgroup_subsys_state pointer, then follow
90    // cgroup_subsys_state.cgroup -> cgroup.kn -> kernfs_node.name.
91    // If offsets are missing we fall back to an empty path.
92    let subsys_offset = reader
93        .symbols()
94        .field_offset("css_set", "subsys")
95        .unwrap_or(0x10);
96
97    let css_cgroup_offset = reader
98        .symbols()
99        .field_offset("cgroup_subsys_state", "cgroup")
100        .unwrap_or(0x08);
101
102    let cgroup_kn_offset = reader
103        .symbols()
104        .field_offset("cgroup", "kn")
105        .unwrap_or(0x48);
106
107    let kn_name_offset = reader
108        .symbols()
109        .field_offset("kernfs_node", "name")
110        .unwrap_or(0x48);
111
112    let kn_parent_offset = reader
113        .symbols()
114        .field_offset("kernfs_node", "parent")
115        .unwrap_or(0x10);
116
117    let mut results = Vec::new();
118
119    for proc in processes {
120        let task_addr = proc.vaddr;
121
122        // Read task_struct.cgroups pointer -> css_set.
123        let css_set_ptr: u64 = match reader.read_bytes(task_addr + cgroups_offset, 8) {
124            Ok(b) if b.len() == 8 => b[..8].try_into().map_or(0, u64::from_le_bytes),
125            _ => continue,
126        };
127        if css_set_ptr == 0 {
128            continue;
129        }
130
131        // Read first subsys pointer from css_set.subsys[0].
132        let css_ptr: u64 = match reader.read_bytes(css_set_ptr + subsys_offset, 8) {
133            Ok(b) if b.len() == 8 => b[..8].try_into().map_or(0, u64::from_le_bytes),
134            _ => continue,
135        };
136        if css_ptr == 0 {
137            continue;
138        }
139
140        // Follow cgroup_subsys_state -> cgroup.
141        let cgroup_ptr: u64 = match reader.read_bytes(css_ptr + css_cgroup_offset, 8) {
142            Ok(b) if b.len() == 8 => b[..8].try_into().map_or(0, u64::from_le_bytes),
143            _ => continue,
144        };
145        if cgroup_ptr == 0 {
146            continue;
147        }
148
149        // Read kernfs_node pointer from cgroup.kn.
150        let kn_ptr: u64 = match reader.read_bytes(cgroup_ptr + cgroup_kn_offset, 8) {
151            Ok(b) if b.len() == 8 => b[..8].try_into().map_or(0, u64::from_le_bytes),
152            _ => continue,
153        };
154
155        // Walk the kernfs_node parent chain to reconstruct the path.
156        let cgroup_path = if kn_ptr == 0 {
157            "/".to_string()
158        } else {
159            build_kernfs_path(reader, kn_ptr, kn_name_offset, kn_parent_offset)
160        };
161
162        let (is_containerized, container_id) = classify_cgroup(&cgroup_path);
163        let is_suspicious = is_suspicious_cgroup(&cgroup_path, proc.pid);
164
165        // Controllers: use empty string — would require walking cgroup_subsys array.
166        let controllers = String::new();
167
168        results.push(CgroupInfo {
169            pid: proc.pid,
170            comm: proc.comm.clone(),
171            cgroup_path,
172            controllers,
173            is_containerized,
174            container_id,
175            is_suspicious,
176        });
177    }
178
179    Ok(results)
180}
181
182/// Walk a `kernfs_node` parent chain and reconstruct a path string.
183///
184/// Reads the `name` pointer at each node, then follows `parent` until
185/// the pointer is null or cycles back. Returns `"/"` on failure.
186fn build_kernfs_path<P: PhysicalMemoryProvider>(
187    reader: &ObjectReader<P>,
188    kn_ptr: u64,
189    name_offset: u64,
190    parent_offset: u64,
191) -> String {
192    let mut segments: Vec<String> = Vec::new();
193    let mut current = kn_ptr;
194    let mut seen = std::collections::HashSet::new();
195
196    for _ in 0..32 {
197        if current == 0 || !seen.insert(current) {
198            break;
199        }
200
201        // Read name pointer (char *).
202        let name_ptr: u64 = match reader.read_bytes(current + name_offset, 8) {
203            Ok(b) if b.len() == 8 => b[..8].try_into().map_or(0, u64::from_le_bytes),
204            _ => break,
205        };
206
207        // Read up to 256 bytes from the name pointer.
208        let name = if name_ptr != 0 {
209            match reader.read_bytes(name_ptr, 256) {
210                Ok(bytes) => {
211                    let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
212                    String::from_utf8_lossy(&bytes[..end]).into_owned()
213                }
214                Err(_) => break,
215            }
216        } else {
217            break;
218        };
219
220        if name.is_empty() || name == "/" {
221            break;
222        }
223
224        segments.push(name);
225
226        // Follow parent pointer.
227        current = match reader.read_bytes(current + parent_offset, 8) {
228            Ok(b) if b.len() == 8 => b[..8].try_into().map_or(0, u64::from_le_bytes),
229            _ => break,
230        };
231    }
232
233    if segments.is_empty() {
234        return "/".to_string();
235    }
236
237    // Segments are leaf-to-root; reverse and join.
238    segments.reverse();
239    format!("/{}", segments.join("/"))
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    // -----------------------------------------------------------------------
247    // classify_cgroup tests
248    // -----------------------------------------------------------------------
249
250    #[test]
251    fn classify_docker_container() {
252        let path = "/system.slice/docker/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/init.scope";
253        let (is_container, id) = classify_cgroup(path);
254        assert!(
255            is_container,
256            "Docker path should be classified as containerized"
257        );
258        assert_eq!(
259            id,
260            "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
261        );
262    }
263
264    #[test]
265    fn classify_lxc_container() {
266        let path = "/lxc/my-container/init.scope";
267        let (is_container, id) = classify_cgroup(path);
268        assert!(
269            is_container,
270            "LXC path should be classified as containerized"
271        );
272        assert_eq!(id, "my-container");
273    }
274
275    #[test]
276    fn classify_kubepods_container() {
277        let path = "/kubepods/burstable/pod1234abcd-ef56-7890/container-id-here";
278        let (is_container, id) = classify_cgroup(path);
279        assert!(
280            is_container,
281            "Kubepods path should be classified as containerized"
282        );
283        assert_eq!(id, "burstable");
284    }
285
286    #[test]
287    fn classify_containerd_container() {
288        let path = "/system.slice/containerd/abc123def456";
289        let (is_container, id) = classify_cgroup(path);
290        assert!(
291            is_container,
292            "containerd path should be classified as containerized"
293        );
294        assert_eq!(id, "abc123def456");
295    }
296
297    #[test]
298    fn classify_host_process_not_containerized() {
299        let path = "/system.slice/sshd.service";
300        let (is_container, id) = classify_cgroup(path);
301        assert!(
302            !is_container,
303            "Host sshd should NOT be classified as containerized"
304        );
305        assert!(
306            id.is_empty(),
307            "Non-container should have empty container ID"
308        );
309    }
310
311    #[test]
312    fn classify_root_path_not_containerized() {
313        let path = "/";
314        let (is_container, id) = classify_cgroup(path);
315        assert!(
316            !is_container,
317            "Root path should NOT be classified as containerized"
318        );
319        assert!(id.is_empty());
320    }
321
322    // -----------------------------------------------------------------------
323    // is_suspicious_cgroup tests
324    // -----------------------------------------------------------------------
325
326    #[test]
327    fn suspicious_root_cgroup_non_init() {
328        // PID 42 in root cgroup "/" is suspicious (potential escape).
329        assert!(
330            is_suspicious_cgroup("/", 42),
331            "Non-init process in root cgroup should be suspicious"
332        );
333    }
334
335    #[test]
336    fn not_suspicious_root_cgroup_init() {
337        // PID 1 (init) in root cgroup "/" is expected.
338        assert!(
339            !is_suspicious_cgroup("/", 1),
340            "Init process in root cgroup should NOT be suspicious"
341        );
342    }
343
344    #[test]
345    fn suspicious_privileged_container() {
346        let path = "/docker/abc123/privileged";
347        assert!(
348            is_suspicious_cgroup(path, 100),
349            "Privileged container cgroup should be suspicious"
350        );
351    }
352
353    #[test]
354    fn not_suspicious_normal_container() {
355        let path = "/docker/abc123def456/init.scope";
356        assert!(
357            !is_suspicious_cgroup(path, 100),
358            "Normal Docker container cgroup should NOT be suspicious"
359        );
360    }
361
362    #[test]
363    fn not_suspicious_normal_host_service() {
364        let path = "/system.slice/sshd.service";
365        assert!(
366            !is_suspicious_cgroup(path, 500),
367            "Normal host service should NOT be suspicious"
368        );
369    }
370
371    // -----------------------------------------------------------------------
372    // CgroupInfo struct tests
373    // -----------------------------------------------------------------------
374
375    #[test]
376    fn cgroup_info_serializes_to_json() {
377        let info = CgroupInfo {
378            pid: 42,
379            comm: "nginx".to_string(),
380            cgroup_path: "/docker/abc123/init.scope".to_string(),
381            controllers: "cpu,memory".to_string(),
382            is_containerized: true,
383            container_id: "abc123".to_string(),
384            is_suspicious: false,
385        };
386        let json = serde_json::to_string(&info).unwrap();
387        assert!(json.contains("\"pid\":42"));
388        assert!(json.contains("\"is_containerized\":true"));
389        assert!(json.contains("\"container_id\":\"abc123\""));
390    }
391
392    #[test]
393    fn classify_and_suspicious_combined() {
394        // Docker path that is also privileged.
395        let path = "/docker/deadbeef01234567/privileged";
396        let (is_container, id) = classify_cgroup(path);
397        let suspicious = is_suspicious_cgroup(path, 99);
398        assert!(is_container);
399        assert_eq!(id, "deadbeef01234567");
400        assert!(
401            suspicious,
402            "Privileged Docker container should be suspicious"
403        );
404    }
405
406    // -----------------------------------------------------------------------
407    // classify_cgroup: additional edge cases
408    // -----------------------------------------------------------------------
409
410    #[test]
411    fn classify_empty_path_not_containerized() {
412        let (is_container, id) = classify_cgroup("");
413        assert!(!is_container);
414        assert!(id.is_empty());
415    }
416
417    #[test]
418    fn classify_docker_at_root_level() {
419        // Docker cgroup directly at /docker/<id>
420        let path = "/docker/abc123";
421        let (is_container, id) = classify_cgroup(path);
422        assert!(is_container);
423        assert_eq!(id, "abc123");
424    }
425
426    #[test]
427    fn classify_docker_id_no_trailing_slash() {
428        // Path ends right after container ID
429        let path = "/docker/feedcafe1234";
430        let (is_container, id) = classify_cgroup(path);
431        assert!(is_container);
432        assert_eq!(id, "feedcafe1234");
433    }
434
435    #[test]
436    fn classify_kubepods_nested_id() {
437        // kubepods with nested path: first segment after /kubepods/ is "besteffort"
438        let path = "/kubepods/besteffort/podXYZ/container123";
439        let (is_container, id) = classify_cgroup(path);
440        assert!(is_container);
441        assert_eq!(id, "besteffort");
442    }
443
444    #[test]
445    fn classify_containerd_empty_after_prefix() {
446        // Unusual: /containerd/ with nothing after
447        let path = "/containerd/";
448        let (is_container, id) = classify_cgroup(path);
449        assert!(is_container);
450        // After /containerd/ and split('/'), first element is ""
451        assert_eq!(id, "");
452    }
453
454    // -----------------------------------------------------------------------
455    // is_suspicious_cgroup: boundary tests
456    // -----------------------------------------------------------------------
457
458    #[test]
459    fn not_suspicious_non_root_path_pid_1() {
460        // PID 1 in a non-root path is NOT suspicious
461        assert!(!is_suspicious_cgroup("/system.slice/init.scope", 1));
462    }
463
464    #[test]
465    fn not_suspicious_root_cgroup_pid_0() {
466        // PID 0 (idle thread) in root cgroup — unusual but pid != 1 check matters
467        // pid=0 IS != 1, so it IS suspicious
468        assert!(is_suspicious_cgroup("/", 0));
469    }
470
471    #[test]
472    fn suspicious_privileged_in_any_path() {
473        // The word "privileged" anywhere in path is suspicious regardless of PID
474        assert!(is_suspicious_cgroup("/kubepods/privileged/pod1", 1));
475    }
476
477    // -----------------------------------------------------------------------
478    // walk_cgroups: missing field offset → empty Vec
479    // -----------------------------------------------------------------------
480
481    #[test]
482    fn walk_cgroups_no_cgroups_field_returns_empty() {
483        use crate::ProcessInfo;
484        use memf_core::test_builders::{PageTableBuilder, SyntheticPhysMem};
485        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
486        use memf_symbols::isf::IsfResolver;
487        use memf_symbols::test_builders::IsfBuilder;
488
489        // task_struct without a "cgroups" field → walk_cgroups returns Ok(empty)
490        let isf = IsfBuilder::new()
491            .add_struct("task_struct", 128)
492            .add_field("task_struct", "pid", 0, "int")
493            // deliberately no "cgroups" field
494            .build_json();
495
496        let resolver = IsfResolver::from_value(&isf).unwrap();
497        let (cr3, mem) = PageTableBuilder::new().build();
498        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
499        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
500
501        let processes: Vec<ProcessInfo> = vec![];
502        let result = walk_cgroups(&reader, &processes).unwrap();
503        assert!(result.is_empty());
504    }
505
506    #[test]
507    fn walk_cgroups_empty_process_list_returns_empty() {
508        use crate::ProcessInfo;
509        use memf_core::test_builders::{PageTableBuilder, SyntheticPhysMem};
510        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
511        use memf_symbols::isf::IsfResolver;
512        use memf_symbols::test_builders::IsfBuilder;
513
514        let isf = IsfBuilder::new()
515            .add_struct("task_struct", 128)
516            .add_field("task_struct", "pid", 0, "int")
517            .add_field("task_struct", "cgroups", 64, "pointer")
518            .build_json();
519
520        let resolver = IsfResolver::from_value(&isf).unwrap();
521        let (cr3, mem) = PageTableBuilder::new().build();
522        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
523        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
524
525        // Empty process list → empty results regardless of offsets
526        let processes: Vec<ProcessInfo> = vec![];
527        let result = walk_cgroups(&reader, &processes).unwrap();
528        assert!(result.is_empty());
529    }
530
531    // -----------------------------------------------------------------------
532    // walk_cgroups: cgroups field present, process list non-empty,
533    // css_set pointer == 0 → body runs but skips the process
534    // -----------------------------------------------------------------------
535
536    #[test]
537    fn walk_cgroups_css_set_null_produces_no_output() {
538        use crate::ProcessInfo;
539        use memf_core::object_reader::ObjectReader;
540        use memf_core::test_builders::{flags as ptflags, PageTableBuilder, SyntheticPhysMem};
541        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
542        use memf_symbols::isf::IsfResolver;
543        use memf_symbols::test_builders::IsfBuilder;
544
545        let task_vaddr: u64 = 0xFFFF_8800_0050_0000;
546        let task_paddr: u64 = 0x0060_0000;
547        let cgroups_offset = 64u64;
548
549        let mut page = [0u8; 4096];
550        // cgroups pointer at offset 64 = 0 (NULL → skip)
551        page[cgroups_offset as usize..cgroups_offset as usize + 8]
552            .copy_from_slice(&0u64.to_le_bytes());
553
554        let isf = IsfBuilder::new()
555            .add_struct("task_struct", 128)
556            .add_field("task_struct", "cgroups", 64, "pointer")
557            .build_json();
558
559        let resolver = IsfResolver::from_value(&isf).unwrap();
560        let (cr3, mem) = PageTableBuilder::new()
561            .map_4k(task_vaddr, task_paddr, ptflags::WRITABLE)
562            .write_phys(task_paddr, &page)
563            .build();
564        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
565        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
566
567        let processes = vec![ProcessInfo {
568            pid: 42,
569            ppid: 1,
570            comm: "bash".to_string(),
571            state: crate::ProcessState::Running,
572            vaddr: task_vaddr,
573            cr3: None,
574            start_time: 0,
575        }];
576
577        let result = walk_cgroups(&reader, &processes).unwrap();
578        assert!(
579            result.is_empty(),
580            "process with css_set==NULL should produce no cgroup output"
581        );
582    }
583
584    // -----------------------------------------------------------------------
585    // walk_cgroups: css_set non-null, subsys_ptr (css_ptr) == 0 → skips process
586    // Exercises lines after the css_set_ptr != 0 check.
587    // -----------------------------------------------------------------------
588
589    #[test]
590    fn walk_cgroups_css_ptr_null_skips_process() {
591        use memf_core::object_reader::ObjectReader;
592        use memf_core::test_builders::{flags as ptflags, PageTableBuilder, SyntheticPhysMem};
593        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
594        use memf_symbols::isf::IsfResolver;
595        use memf_symbols::test_builders::IsfBuilder;
596
597        // task_addr holds:  cgroups_offset(64) → css_set_vaddr (non-zero)
598        // css_set_vaddr holds: subsys_offset(0x10) → 0  (null css_ptr → skip)
599        let task_vaddr: u64 = 0xFFFF_8800_0070_0000;
600        let task_paddr: u64 = 0x0070_0000;
601        let cssset_vaddr: u64 = 0xFFFF_8800_0071_0000;
602        let cssset_paddr: u64 = 0x0071_0000;
603        let cgroups_offset: u64 = 64;
604        let subsys_offset: u64 = 0x10;
605
606        let mut task_page = [0u8; 4096];
607        task_page[cgroups_offset as usize..cgroups_offset as usize + 8]
608            .copy_from_slice(&cssset_vaddr.to_le_bytes());
609
610        let mut cssset_page = [0u8; 4096];
611        // subsys[0] at subsys_offset = 0 → css_ptr is null → process skipped
612        cssset_page[subsys_offset as usize..subsys_offset as usize + 8]
613            .copy_from_slice(&0u64.to_le_bytes());
614
615        let isf = IsfBuilder::new()
616            .add_struct("task_struct", 256)
617            .add_field("task_struct", "cgroups", 64, "pointer")
618            .add_struct("css_set", 256)
619            .add_field("css_set", "subsys", 0x10, "pointer")
620            .build_json();
621
622        let resolver = IsfResolver::from_value(&isf).unwrap();
623        let (cr3, mem) = PageTableBuilder::new()
624            .map_4k(task_vaddr, task_paddr, ptflags::WRITABLE)
625            .write_phys(task_paddr, &task_page)
626            .map_4k(cssset_vaddr, cssset_paddr, ptflags::WRITABLE)
627            .write_phys(cssset_paddr, &cssset_page)
628            .build();
629        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
630        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
631
632        let processes = vec![ProcessInfo {
633            pid: 55,
634            ppid: 1,
635            comm: "bash".to_string(),
636            state: crate::ProcessState::Running,
637            vaddr: task_vaddr,
638            cr3: None,
639            start_time: 0,
640        }];
641
642        let result = walk_cgroups(&reader, &processes).unwrap();
643        assert!(result.is_empty(), "null css_ptr should skip the process");
644    }
645
646    // -----------------------------------------------------------------------
647    // walk_cgroups: css_set and css_ptr non-null, cgroup_ptr == 0 → skips process
648    // Exercises the cgroup_ptr == 0 guard.
649    // -----------------------------------------------------------------------
650
651    #[test]
652    fn walk_cgroups_cgroup_ptr_null_skips_process() {
653        use memf_core::object_reader::ObjectReader;
654        use memf_core::test_builders::{flags as ptflags, PageTableBuilder, SyntheticPhysMem};
655        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
656        use memf_symbols::isf::IsfResolver;
657        use memf_symbols::test_builders::IsfBuilder;
658
659        let task_vaddr: u64 = 0xFFFF_8800_0072_0000;
660        let task_paddr: u64 = 0x0072_0000;
661        let cssset_vaddr: u64 = 0xFFFF_8800_0073_0000;
662        let cssset_paddr: u64 = 0x0073_0000;
663        let css_vaddr: u64 = 0xFFFF_8800_0074_0000;
664        let css_paddr: u64 = 0x0074_0000;
665        let cgroups_offset: u64 = 64;
666        let subsys_offset: u64 = 0x10;
667        let css_cgroup_offset: u64 = 0x08;
668
669        let mut task_page = [0u8; 4096];
670        task_page[cgroups_offset as usize..cgroups_offset as usize + 8]
671            .copy_from_slice(&cssset_vaddr.to_le_bytes());
672
673        let mut cssset_page = [0u8; 4096];
674        cssset_page[subsys_offset as usize..subsys_offset as usize + 8]
675            .copy_from_slice(&css_vaddr.to_le_bytes());
676
677        let mut css_page = [0u8; 4096];
678        // cgroup_subsys_state.cgroup at offset 0x08 = 0 (null → skip)
679        css_page[css_cgroup_offset as usize..css_cgroup_offset as usize + 8]
680            .copy_from_slice(&0u64.to_le_bytes());
681
682        let isf = IsfBuilder::new()
683            .add_struct("task_struct", 256)
684            .add_field("task_struct", "cgroups", 64, "pointer")
685            .add_struct("css_set", 256)
686            .add_field("css_set", "subsys", 0x10, "pointer")
687            .add_struct("cgroup_subsys_state", 256)
688            .add_field("cgroup_subsys_state", "cgroup", 0x08, "pointer")
689            .build_json();
690
691        let resolver = IsfResolver::from_value(&isf).unwrap();
692        let (cr3, mem) = PageTableBuilder::new()
693            .map_4k(task_vaddr, task_paddr, ptflags::WRITABLE)
694            .write_phys(task_paddr, &task_page)
695            .map_4k(cssset_vaddr, cssset_paddr, ptflags::WRITABLE)
696            .write_phys(cssset_paddr, &cssset_page)
697            .map_4k(css_vaddr, css_paddr, ptflags::WRITABLE)
698            .write_phys(css_paddr, &css_page)
699            .build();
700        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
701        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
702
703        let processes = vec![ProcessInfo {
704            pid: 66,
705            ppid: 1,
706            comm: "bash".to_string(),
707            state: crate::ProcessState::Running,
708            vaddr: task_vaddr,
709            cr3: None,
710            start_time: 0,
711        }];
712
713        let result = walk_cgroups(&reader, &processes).unwrap();
714        assert!(result.is_empty(), "null cgroup_ptr should skip the process");
715    }
716
717    // -----------------------------------------------------------------------
718    // walk_cgroups: full path to kn_ptr == 0 → cgroup_path = "/" → result pushed
719    // Exercises kn_ptr==0 branch → build_kernfs_path not called → "/" path.
720    // -----------------------------------------------------------------------
721
722    #[test]
723    fn walk_cgroups_kn_ptr_zero_produces_root_path_result() {
724        use memf_core::object_reader::ObjectReader;
725        use memf_core::test_builders::{flags as ptflags, PageTableBuilder, SyntheticPhysMem};
726        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
727        use memf_symbols::isf::IsfResolver;
728        use memf_symbols::test_builders::IsfBuilder;
729
730        let task_vaddr: u64 = 0xFFFF_8800_0075_0000;
731        let task_paddr: u64 = 0x0075_0000;
732        let cssset_vaddr: u64 = 0xFFFF_8800_0076_0000;
733        let cssset_paddr: u64 = 0x0076_0000;
734        let css_vaddr: u64 = 0xFFFF_8800_0077_0000;
735        let css_paddr: u64 = 0x0077_0000;
736        let cgroup_vaddr: u64 = 0xFFFF_8800_0078_0000;
737        let cgroup_paddr: u64 = 0x0078_0000;
738
739        let cgroups_offset: u64 = 64;
740        let subsys_offset: u64 = 0x10;
741        let css_cgroup_offset: u64 = 0x08;
742        let cgroup_kn_offset: u64 = 0x48;
743
744        let mut task_page = [0u8; 4096];
745        task_page[cgroups_offset as usize..cgroups_offset as usize + 8]
746            .copy_from_slice(&cssset_vaddr.to_le_bytes());
747
748        let mut cssset_page = [0u8; 4096];
749        cssset_page[subsys_offset as usize..subsys_offset as usize + 8]
750            .copy_from_slice(&css_vaddr.to_le_bytes());
751
752        let mut css_page = [0u8; 4096];
753        css_page[css_cgroup_offset as usize..css_cgroup_offset as usize + 8]
754            .copy_from_slice(&cgroup_vaddr.to_le_bytes());
755
756        let mut cgroup_page = [0u8; 4096];
757        // kn at offset 0x48 = 0 → kn_ptr == 0 → cgroup_path = "/"
758        cgroup_page[cgroup_kn_offset as usize..cgroup_kn_offset as usize + 8]
759            .copy_from_slice(&0u64.to_le_bytes());
760
761        let isf = IsfBuilder::new()
762            .add_struct("task_struct", 256)
763            .add_field("task_struct", "cgroups", 64, "pointer")
764            .add_struct("css_set", 256)
765            .add_field("css_set", "subsys", 0x10, "pointer")
766            .add_struct("cgroup_subsys_state", 256)
767            .add_field("cgroup_subsys_state", "cgroup", 0x08, "pointer")
768            .add_struct("cgroup", 512)
769            .add_field("cgroup", "kn", 0x48, "pointer")
770            .build_json();
771
772        let resolver = IsfResolver::from_value(&isf).unwrap();
773        let (cr3, mem) = PageTableBuilder::new()
774            .map_4k(task_vaddr, task_paddr, ptflags::WRITABLE)
775            .write_phys(task_paddr, &task_page)
776            .map_4k(cssset_vaddr, cssset_paddr, ptflags::WRITABLE)
777            .write_phys(cssset_paddr, &cssset_page)
778            .map_4k(css_vaddr, css_paddr, ptflags::WRITABLE)
779            .write_phys(css_paddr, &css_page)
780            .map_4k(cgroup_vaddr, cgroup_paddr, ptflags::WRITABLE)
781            .write_phys(cgroup_paddr, &cgroup_page)
782            .build();
783        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
784        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
785
786        let processes = vec![ProcessInfo {
787            pid: 77,
788            ppid: 1,
789            comm: "bash".to_string(),
790            state: crate::ProcessState::Running,
791            vaddr: task_vaddr,
792            cr3: None,
793            start_time: 0,
794        }];
795
796        let result = walk_cgroups(&reader, &processes).unwrap();
797        // kn_ptr==0 → cgroup_path = "/" → is_suspicious_cgroup("/", 77) = true (pid≠1)
798        assert_eq!(result.len(), 1, "full chain resolved → one result pushed");
799        assert_eq!(result[0].cgroup_path, "/");
800        assert_eq!(result[0].pid, 77);
801        assert!(
802            result[0].is_suspicious,
803            "root cgroup for non-init pid is suspicious"
804        );
805    }
806
807    // -----------------------------------------------------------------------
808    // walk_cgroups: full chain with non-zero kn_ptr → build_kernfs_path called
809    // Exercises build_kernfs_path body (lines 205-253): name pointer readable,
810    // parent pointer readable, loop walks one node then hits a null parent.
811    // -----------------------------------------------------------------------
812
813    #[test]
814    fn walk_cgroups_kn_ptr_nonzero_builds_path() {
815        use memf_core::object_reader::ObjectReader;
816        use memf_core::test_builders::{flags as ptflags, PageTableBuilder, SyntheticPhysMem};
817        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
818        use memf_symbols::isf::IsfResolver;
819        use memf_symbols::test_builders::IsfBuilder;
820
821        // Memory layout (all physical addrs < 16 MB):
822        //   task        @ task_vaddr  / task_paddr
823        //   css_set     @ cssset_vaddr / cssset_paddr
824        //   css         @ css_vaddr   / css_paddr
825        //   cgroup_node @ cgroup_vaddr / cgroup_paddr
826        //   kn_node     @ kn_vaddr    / kn_paddr        (kernfs_node)
827        //   name_str    @ name_vaddr  / name_paddr       ("docker\0")
828        //
829        // Offsets (all using defaults / ISF-specified):
830        //   task.cgroups       @ 64
831        //   css_set.subsys     @ 0x10
832        //   cgroup_ss.cgroup   @ 0x08
833        //   cgroup.kn          @ 0x48
834        //   kernfs_node.name   @ 0x48  (pointer to name string)
835        //   kernfs_node.parent @ 0x10  (pointer to parent node, null = root)
836
837        let task_vaddr: u64 = 0xFFFF_8800_0079_0000;
838        let task_paddr: u64 = 0x0079_0000;
839        let cssset_vaddr: u64 = 0xFFFF_8800_007A_0000;
840        let cssset_paddr: u64 = 0x007A_0000;
841        let css_vaddr: u64 = 0xFFFF_8800_007B_0000;
842        let css_paddr: u64 = 0x007B_0000;
843        let cgroup_vaddr: u64 = 0xFFFF_8800_007C_0000;
844        let cgroup_paddr: u64 = 0x007C_0000;
845        let kn_vaddr: u64 = 0xFFFF_8800_007D_0000;
846        let kn_paddr: u64 = 0x007D_0000;
847        let name_vaddr: u64 = 0xFFFF_8800_007E_0000;
848        let name_paddr: u64 = 0x007E_0000;
849
850        let cgroups_offset: u64 = 64;
851        let subsys_offset: u64 = 0x10;
852        let css_cgroup_offset: u64 = 0x08;
853        let cgroup_kn_offset: u64 = 0x48;
854        let kn_name_offset: u64 = 0x48;
855
856        // task page
857        let mut task_page = [0u8; 4096];
858        task_page[cgroups_offset as usize..cgroups_offset as usize + 8]
859            .copy_from_slice(&cssset_vaddr.to_le_bytes());
860
861        // css_set page
862        let mut cssset_page = [0u8; 4096];
863        cssset_page[subsys_offset as usize..subsys_offset as usize + 8]
864            .copy_from_slice(&css_vaddr.to_le_bytes());
865
866        // css (cgroup_subsys_state) page
867        let mut css_page = [0u8; 4096];
868        css_page[css_cgroup_offset as usize..css_cgroup_offset as usize + 8]
869            .copy_from_slice(&cgroup_vaddr.to_le_bytes());
870
871        // cgroup page: kn @ 0x48 = kn_vaddr (non-zero)
872        let mut cgroup_page = [0u8; 4096];
873        cgroup_page[cgroup_kn_offset as usize..cgroup_kn_offset as usize + 8]
874            .copy_from_slice(&kn_vaddr.to_le_bytes());
875
876        // kernfs_node page:
877        //   name   @ kn_name_offset   = name_vaddr (pointer to name string)
878        //   parent @ kn_parent_offset = 0          (null = root, stops walk)
879        let mut kn_page = [0u8; 4096];
880        kn_page[kn_name_offset as usize..kn_name_offset as usize + 8]
881            .copy_from_slice(&name_vaddr.to_le_bytes());
882        // parent already 0
883
884        // name string page: "docker\0"
885        let mut name_page = [0u8; 4096];
886        name_page[..7].copy_from_slice(b"docker\0");
887
888        let isf = IsfBuilder::new()
889            .add_struct("task_struct", 256)
890            .add_field("task_struct", "cgroups", 64u64, "pointer")
891            .add_struct("css_set", 256)
892            .add_field("css_set", "subsys", 0x10u64, "pointer")
893            .add_struct("cgroup_subsys_state", 256)
894            .add_field("cgroup_subsys_state", "cgroup", 0x08u64, "pointer")
895            .add_struct("cgroup", 512)
896            .add_field("cgroup", "kn", 0x48u64, "pointer")
897            .add_struct("kernfs_node", 512)
898            .add_field("kernfs_node", "name", 0x48u64, "pointer")
899            .add_field("kernfs_node", "parent", 0x10u64, "pointer")
900            .build_json();
901
902        let resolver = IsfResolver::from_value(&isf).unwrap();
903        let (cr3, mem) = PageTableBuilder::new()
904            .map_4k(task_vaddr, task_paddr, ptflags::WRITABLE)
905            .write_phys(task_paddr, &task_page)
906            .map_4k(cssset_vaddr, cssset_paddr, ptflags::WRITABLE)
907            .write_phys(cssset_paddr, &cssset_page)
908            .map_4k(css_vaddr, css_paddr, ptflags::WRITABLE)
909            .write_phys(css_paddr, &css_page)
910            .map_4k(cgroup_vaddr, cgroup_paddr, ptflags::WRITABLE)
911            .write_phys(cgroup_paddr, &cgroup_page)
912            .map_4k(kn_vaddr, kn_paddr, ptflags::WRITABLE)
913            .write_phys(kn_paddr, &kn_page)
914            .map_4k(name_vaddr, name_paddr, ptflags::WRITABLE)
915            .write_phys(name_paddr, &name_page)
916            .build();
917
918        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
919        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
920
921        let processes = vec![crate::ProcessInfo {
922            pid: 99,
923            ppid: 1,
924            comm: "nginx".to_string(),
925            state: crate::ProcessState::Running,
926            vaddr: task_vaddr,
927            cr3: None,
928            start_time: 0,
929        }];
930
931        let result = walk_cgroups(&reader, &processes).unwrap();
932        assert_eq!(result.len(), 1, "full chain should produce one CgroupInfo");
933        // build_kernfs_path reads "docker" as the leaf segment, parent=null → stops
934        // segments = ["docker"], reversed = ["docker"] → path = "/docker"
935        assert_eq!(result[0].cgroup_path, "/docker");
936        assert_eq!(result[0].pid, 99);
937        // /docker is not containerized (no container ID extracted this way) but
938        // classify_cgroup("/docker") → matches /docker/ prefix only if there's more after,
939        // actually "/docker" does not match "/docker/" since there's no trailing /id
940        // so is_containerized = false, is_suspicious = false (path != "/" and no "privileged")
941        assert!(!result[0].is_suspicious);
942    }
943
944    // -----------------------------------------------------------------------
945    // CgroupInfo: Debug + Clone
946    // -----------------------------------------------------------------------
947
948    #[test]
949    fn cgroup_info_clone_and_debug() {
950        let info = CgroupInfo {
951            pid: 1,
952            comm: "init".to_string(),
953            cgroup_path: "/".to_string(),
954            controllers: String::new(),
955            is_containerized: false,
956            container_id: String::new(),
957            is_suspicious: false,
958        };
959        let cloned = info.clone();
960        assert_eq!(cloned.pid, 1);
961        let dbg = format!("{cloned:?}");
962        assert!(dbg.contains("init"));
963    }
964}