sesame/doctor/
memory.rs

1//! Memory protection checks — memfd_secret, core limits, swap exposure.
2
3use super::{Check, Status};
4
5pub fn checks() -> Vec<Check> {
6    let mut results = Vec::new();
7
8    // mem.backend — memfd_secret vs mmap fallback.
9    let backend = match core_memory::ProtectedAlloc::new(1) {
10        Ok(probe) if probe.is_secret_mem() => (
11            "memfd_secret",
12            Status::Pass,
13            "Pages removed from kernel direct map",
14        ),
15        Ok(_) => (
16            "mmap fallback",
17            Status::Fail,
18            "Secret pages visible in kernel direct map — CONFIG_SECRETMEM may be disabled",
19        ),
20        Err(_) => (
21            "allocation failed",
22            Status::Fail,
23            "Secure memory probe failed — check RLIMIT_MEMLOCK",
24        ),
25    };
26    results.push(Check {
27        id: "mem.backend".into(),
28        category: "memory",
29        status: backend.1,
30        value: backend.0.into(),
31        description: backend.2.into(),
32    });
33
34    // mem.config_secretmem — kernel config check.
35    let secretmem = check_kernel_config("CONFIG_SECRETMEM");
36    results.push(Check {
37        id: "mem.config_secretmem".into(),
38        category: "memory",
39        status: match secretmem.as_deref() {
40            Some("y") => Status::Pass,
41            _ => Status::Fail,
42        },
43        value: secretmem.unwrap_or_else(|| "unknown".into()),
44        description: "Kernel CONFIG_SECRETMEM enables memfd_secret syscall".into(),
45    });
46
47    // mem.core_limit — check each daemon's core dump limit.
48    let core_ok = check_all_daemon_limits("Max core file size", "0");
49    results.push(Check {
50        id: "mem.core_limit".into(),
51        category: "memory",
52        status: if core_ok { Status::Pass } else { Status::Fail },
53        value: if core_ok {
54            "0 (disabled)".into()
55        } else {
56            "nonzero (core dumps possible)".into()
57        },
58        description: "LimitCORE=0 prevents secret material in core dumps".into(),
59    });
60
61    // mem.swap_usage — check if any daemon has pages in swap.
62    let swap_kb = total_daemon_swap();
63    results.push(Check {
64        id: "mem.swap_usage".into(),
65        category: "memory",
66        status: if swap_kb == 0 {
67            Status::Pass
68        } else {
69            Status::Warn
70        },
71        value: format!("{swap_kb} kB"),
72        description: if swap_kb > 0 {
73            "Daemon pages in swap — secret material may be on disk".into()
74        } else {
75            String::new()
76        },
77    });
78
79    results
80}
81
82/// Check a kernel config option from /proc/config.gz or /boot/config-*.
83fn check_kernel_config(key: &str) -> Option<String> {
84    // Try /proc/config.gz first (requires CONFIG_IKCONFIG_PROC).
85    if let Ok(output) = std::process::Command::new("zgrep")
86        .arg(format!("^{key}="))
87        .arg("/proc/config.gz")
88        .output()
89        && output.status.success()
90    {
91        let line = String::from_utf8_lossy(&output.stdout);
92        return line.trim().split('=').nth(1).map(|v| v.to_string());
93    }
94
95    // Fallback: /boot/config-$(uname -r).
96    let uname = std::process::Command::new("uname")
97        .arg("-r")
98        .output()
99        .ok()?;
100    let release = String::from_utf8_lossy(&uname.stdout).trim().to_string();
101    let config_path = format!("/boot/config-{release}");
102    let content = std::fs::read_to_string(config_path).ok()?;
103    for line in content.lines() {
104        if let Some(rest) = line.strip_prefix(&format!("{key}=")) {
105            return Some(rest.to_string());
106        }
107    }
108    None
109}
110
111/// Check that all daemon PIDs have a specific `/proc/<pid>/limits` value.
112fn check_all_daemon_limits(limit_name: &str, expected: &str) -> bool {
113    let pids = daemon_pids();
114    if pids.is_empty() {
115        return false;
116    }
117    pids.iter().all(|pid| {
118        let path = format!("/proc/{pid}/limits");
119        let Ok(content) = std::fs::read_to_string(path) else {
120            return false;
121        };
122        content
123            .lines()
124            .any(|line| line.contains(limit_name) && line.contains(expected))
125    })
126}
127
128/// Sum VmSwap across all daemon PIDs.
129fn total_daemon_swap() -> u64 {
130    daemon_pids()
131        .iter()
132        .filter_map(|pid| {
133            let path = format!("/proc/{pid}/status");
134            let content = std::fs::read_to_string(path).ok()?;
135            for line in content.lines() {
136                if let Some(rest) = line.strip_prefix("VmSwap:") {
137                    let num = rest.split_whitespace().next()?;
138                    return num.parse::<u64>().ok();
139                }
140            }
141            None
142        })
143        .sum()
144}
145
146/// Get PIDs of all running open-sesame daemons.
147fn daemon_pids() -> Vec<u32> {
148    let binaries = [
149        "daemon-profile",
150        "daemon-secrets",
151        "daemon-wm",
152        "daemon-launcher",
153        "daemon-clipboard",
154        "daemon-input",
155        "daemon-snippets",
156    ];
157    binaries
158        .iter()
159        .filter_map(|name| {
160            let output = std::process::Command::new("pidof")
161                .arg(name)
162                .output()
163                .ok()?;
164            if output.status.success() {
165                String::from_utf8_lossy(&output.stdout)
166                    .split_whitespace()
167                    .next()?
168                    .parse()
169                    .ok()
170            } else {
171                None
172            }
173        })
174        .collect()
175}