Skip to main content

capsule_runtime/
proc_view.rs

1//! The `/proc` a capsule command sees: a private `procfs` of the session's own PID namespace, or
2//! the host's.
3//!
4//! Every command on a Linux kernel tier runs inside its session's PID namespace
5//! ([`crate::network_namespace`] creates it with the network namespace; each command's relay
6//! enters it in `sandbox::linux_enforce`). Where the host lets a process of that namespace mount
7//! `proc`, the command mounts one over `/proc` in a private mount namespace, and on `scoped` and
8//! `sealed` Landlock grants `ReadFile | ReadDir` beneath it — never `Execute`, never a write right.
9//!
10//! | View | What a command reaches at `/proc` |
11//! |---|---|
12//! | [`ProcView::Private`] | the session's own processes and the kernel's world-readable files (`cpuinfo`, `meminfo`, `stat`, `/proc/sys`, …); no host process |
13//! | [`ProcView::Host`] | the host's `/proc`; on `scoped` and `sealed` every open beneath it is `EACCES` |
14//!
15//! [`proc_view`] is the one decision, made from the session's [`crate::sandbox::HostProbe`]: the
16//! private view exactly when the probe's private-procfs rehearsal mounted one
17//! ([`crate::namespace_probe::NamespaceReading::private_proc`]). A session's
18//! [`crate::sandbox::ShellEnforcement`] carries the answer and its
19//! [`crate::containment::ScopeReport`] says it, both from the same probe.
20//!
21//! `hidepid` masks, within the private view, every process the command may not `ptrace`-read:
22//! the namespace's holder (pid 1, which holds capabilities the command does not) and, under
23//! Landlock, a process of another command, whose domain is not the reader's.
24
25use serde::Serialize;
26
27use crate::namespace_probe::NamespaceReading;
28use crate::sandbox::EnforcementTier;
29
30/// What every failure to mount the private `/proc` at spawn starts with.
31pub const PRIVATE_PROC_FAILURE_PREFIX: &str =
32    "sandbox: could not mount this session's private /proc:";
33
34/// The `mounted_by` wire name of the private view.
35pub const PID_NAMESPACE_MECHANISM: &str = "pid-namespace";
36
37/// Which `/proc` a session's commands see.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum ProcView {
40    /// A `procfs` of the session's PID namespace, mounted over `/proc` by each command.
41    Private,
42    /// The host's `/proc`, for the reason given.
43    Host(HostProcReason),
44}
45
46/// Why a session's commands see the host's `/proc`.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum HostProcReason {
49    /// No kernel containment: macOS and every non-Linux host, and the environment-only tier.
50    NoKernelContainment,
51    /// The session runs unconfined, outside every namespace.
52    Unconfined,
53    /// The probe's private-procfs rehearsal failed: the failure, as `mur doctor` prints it.
54    MountRefused(String),
55    /// The report was built without a namespace probe to decide from.
56    NotProbed,
57}
58
59impl ProcView {
60    /// Whether the commands see a private `/proc`.
61    #[must_use]
62    pub fn is_private(&self) -> bool {
63        matches!(self, Self::Private)
64    }
65
66    /// The mechanism's wire name, `null` in the report for the host's `/proc`.
67    #[must_use]
68    pub fn mounted_by(&self) -> Option<&'static str> {
69        match self {
70            Self::Private => Some(PID_NAMESPACE_MECHANISM),
71            Self::Host(_) => None,
72        }
73    }
74}
75
76impl HostProcReason {
77    /// The reason in words an operator can act on: `not_private_because` in the report.
78    #[must_use]
79    pub fn describe(&self) -> String {
80        match self {
81            Self::NoKernelContainment => "this host has no kernel containment for capsule \
82                 commands, so they see the host's own /proc"
83                .to_string(),
84            Self::Unconfined => {
85                "the session runs unconfined, so commands see the host's own /proc".to_string()
86            }
87            Self::MountRefused(failure) => format!(
88                "this host cannot mount a private /proc in the session's PID namespace \
89                 ({failure}), so commands keep the host's /proc, and on scoped and sealed every \
90                 open beneath it is refused"
91            ),
92            Self::NotProbed => "no namespace probe decided this view".to_string(),
93        }
94    }
95}
96
97/// The view a session on the tier `applied` gets from the namespace reading its probe took.
98#[must_use]
99pub(crate) fn proc_view(applied: EnforcementTier, reading: &NamespaceReading) -> ProcView {
100    match applied {
101        EnforcementTier::Unconfined => ProcView::Host(HostProcReason::Unconfined),
102        EnforcementTier::EnvironmentOnly => ProcView::Host(HostProcReason::NoKernelContainment),
103        EnforcementTier::KernelFull
104        | EnforcementTier::KernelSealed
105        | EnforcementTier::KernelSeccompOnly => {
106            if !cfg!(target_os = "linux") {
107                return ProcView::Host(HostProcReason::NoKernelContainment);
108            }
109            if reading.private_proc_created() {
110                ProcView::Private
111            } else {
112                ProcView::Host(HostProcReason::MountRefused(
113                    reading
114                        .private_proc
115                        .failure_summary()
116                        .or_else(|| reading.egress_namespace_failure().map(|f| f.summary()))
117                        .unwrap_or_else(|| "the rehearsal was not attempted".to_string()),
118                ))
119            }
120        }
121    }
122}
123
124/// The view an enforcement or report that was handed no probe states for the tier `applied`.
125#[must_use]
126pub(crate) fn tier_proc_view(applied: EnforcementTier) -> ProcView {
127    match applied {
128        EnforcementTier::Unconfined => ProcView::Host(HostProcReason::Unconfined),
129        EnforcementTier::EnvironmentOnly => ProcView::Host(HostProcReason::NoKernelContainment),
130        _ if !cfg!(target_os = "linux") => ProcView::Host(HostProcReason::NoKernelContainment),
131        _ => ProcView::Host(HostProcReason::NotProbed),
132    }
133}
134
135/// The `/proc` a session's commands see — `--explain-scope`'s `Process view` block,
136/// `session_start.effective_grants.process_view` and `mur doctor`'s section, all from
137/// [`process_view_report`]. Field names are the JSON keys; every field is always serialized.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
139pub struct ProcessViewReport {
140    /// Whether commands see a private `/proc` of the session's PID namespace.
141    pub private: bool,
142    /// The mechanism: `pid-namespace` for the private view, `null` for the host's.
143    pub mounted_by: Option<&'static str>,
144    /// Why commands see the host's `/proc`; `null` for the private view.
145    pub not_private_because: Option<String>,
146}
147
148/// The report of `view`. Pure.
149#[must_use]
150pub fn process_view_report(view: &ProcView) -> ProcessViewReport {
151    ProcessViewReport {
152        private: view.is_private(),
153        mounted_by: view.mounted_by(),
154        not_private_because: match view {
155            ProcView::Private => None,
156            ProcView::Host(reason) => Some(reason.describe()),
157        },
158    }
159}
160
161/// The `Process view` block of `--explain-scope` and `mur doctor`. Ends with a newline.
162#[must_use]
163pub fn render_process_view(report: &ProcessViewReport) -> String {
164    match (report.mounted_by, &report.not_private_because) {
165        (Some(mechanism), _) => format!(
166            "Process view\n  /proc:     private ({mechanism}) — the session's own processes and \
167             the kernel's world-readable files; no host process\n  grant:     read and list \
168             (scoped, sealed); never execute or write\n"
169        ),
170        (None, Some(reason)) => format!("Process view\n  /proc:     host — {reason}\n"),
171        (None, None) => "Process view\n  /proc:     host\n".to_string(),
172    }
173}
174
175/// One step of mounting the private `/proc` in a command's `pre_exec` window.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum ProcMountStep {
178    UnshareMount,
179    MakePrivate,
180    MountProc,
181    OpenProc,
182}
183
184impl ProcMountStep {
185    /// The call, as the spawn failure names it.
186    #[must_use]
187    pub fn describe(self) -> &'static str {
188        match self {
189            Self::UnshareMount => "unshare(CLONE_NEWNS)",
190            Self::MakePrivate => "making / MS_REC|MS_PRIVATE",
191            Self::MountProc => "mounting proc over /proc",
192            Self::OpenProc => "opening the new /proc",
193        }
194    }
195}
196
197/// `<prefix> <step> failed: <errno>`, the spawn failure of a step. Allocates: called only on the
198/// failure path, as every other step's message in the `pre_exec` window is.
199#[must_use]
200pub fn describe_mount_failure(step: ProcMountStep, errno: i32) -> String {
201    format!(
202        "{PRIVATE_PROC_FAILURE_PREFIX} {} failed: {} ({})",
203        step.describe(),
204        crate::network_namespace::errno_label(errno),
205        crate::network_namespace::errno_words(errno)
206    )
207}
208
209#[cfg(target_os = "linux")]
210pub(crate) use linux::mount_private_proc;
211
212#[cfg(target_os = "linux")]
213#[allow(unsafe_code)]
214mod linux {
215    use std::os::fd::RawFd;
216
217    use super::ProcMountStep;
218
219    /// Mounts a `procfs` of the calling process's PID namespace over `/proc` and returns an
220    /// `O_PATH | O_DIRECTORY | O_CLOEXEC` descriptor of its root, for the Landlock rule.
221    ///
222    /// With `enter_mount_namespace`, first enters a private mount namespace with private
223    /// propagation, for a spawn whose `/tmp` mapping did not already. The mount is `MS_NOSUID |
224    /// MS_NODEV | MS_NOEXEC`, with the first of [`crate::sealed::PROC_HIDEPID_OPTIONS`] the kernel
225    /// accepts.
226    ///
227    /// # Safety
228    /// Post-`fork()`, pre-exec child context only, inside the session's user and PID namespaces
229    /// and before the seccomp filter (which denies `unshare` and `mount`). Every call is
230    /// async-signal-safe and nothing allocates.
231    pub(crate) unsafe fn mount_private_proc(
232        enter_mount_namespace: bool,
233    ) -> Result<RawFd, (ProcMountStep, i32)> {
234        // SAFETY: the caller's contract covers this whole body; every pointer is a live local or
235        // a static C string.
236        unsafe {
237            let errno = || *libc::__errno_location();
238            if enter_mount_namespace {
239                if libc::unshare(libc::CLONE_NEWNS) != 0 {
240                    return Err((ProcMountStep::UnshareMount, errno()));
241                }
242                if libc::mount(
243                    std::ptr::null(),
244                    c"/".as_ptr(),
245                    std::ptr::null(),
246                    libc::MS_REC | libc::MS_PRIVATE,
247                    std::ptr::null(),
248                ) != 0
249                {
250                    return Err((ProcMountStep::MakePrivate, errno()));
251                }
252            }
253            let mut mount_errno = 0;
254            let mut mounted = false;
255            for option in crate::sealed::PROC_HIDEPID_OPTIONS {
256                let mut buffer = [0u8; 32];
257                let bytes = option.as_bytes();
258                if bytes.len() >= buffer.len() {
259                    continue;
260                }
261                buffer[..bytes.len()].copy_from_slice(bytes);
262                let data = if bytes.is_empty() {
263                    std::ptr::null()
264                } else {
265                    buffer.as_ptr().cast::<libc::c_void>()
266                };
267                if libc::mount(
268                    c"proc".as_ptr(),
269                    c"/proc".as_ptr(),
270                    c"proc".as_ptr(),
271                    libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC,
272                    data,
273                ) == 0
274                {
275                    mounted = true;
276                    break;
277                }
278                mount_errno = errno();
279            }
280            if !mounted {
281                return Err((ProcMountStep::MountProc, mount_errno));
282            }
283            let fd = libc::open(
284                c"/proc".as_ptr(),
285                libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,
286            );
287            if fd < 0 {
288                return Err((ProcMountStep::OpenProc, errno()));
289            }
290            Ok(fd)
291        }
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::namespace_probe::MountAttempt;
299
300    fn reading(private_proc: MountAttempt) -> NamespaceReading {
301        NamespaceReading {
302            private_proc,
303            ..NamespaceReading::not_linux()
304        }
305    }
306
307    #[test]
308    fn unconfined_and_environment_only_sessions_keep_the_host_proc_with_their_own_reason() {
309        let created = reading(MountAttempt::Created);
310        assert_eq!(
311            proc_view(EnforcementTier::Unconfined, &created),
312            ProcView::Host(HostProcReason::Unconfined)
313        );
314        assert_eq!(
315            proc_view(EnforcementTier::EnvironmentOnly, &created),
316            ProcView::Host(HostProcReason::NoKernelContainment)
317        );
318    }
319
320    #[cfg(target_os = "linux")]
321    #[test]
322    fn a_kernel_tier_gets_the_private_view_exactly_when_the_rehearsal_mounted_one() {
323        use crate::namespace_probe::{MountAttemptError, MountStep};
324
325        for tier in [
326            EnforcementTier::KernelFull,
327            EnforcementTier::KernelSealed,
328            EnforcementTier::KernelSeccompOnly,
329        ] {
330            assert_eq!(
331                proc_view(tier, &reading(MountAttempt::Created)),
332                ProcView::Private
333            );
334            let refused = proc_view(
335                tier,
336                &reading(MountAttempt::Failed(MountAttemptError::Step {
337                    step: MountStep::MountProc,
338                    errno: libc::EPERM,
339                })),
340            );
341            let ProcView::Host(HostProcReason::MountRefused(summary)) = &refused else {
342                panic!("{refused:?}");
343            };
344            assert!(summary.contains("EPERM"), "{summary}");
345            let report = process_view_report(&refused);
346            assert!(!report.private);
347            assert_eq!(report.mounted_by, None);
348            assert!(report
349                .not_private_because
350                .as_deref()
351                .is_some_and(|reason| reason.contains("mounting proc over /proc")));
352        }
353    }
354
355    #[test]
356    fn the_report_serializes_every_field_and_renders_one_block() {
357        let report = process_view_report(&ProcView::Private);
358        let json = serde_json::to_value(&report).unwrap();
359        assert_eq!(
360            json,
361            serde_json::json!({
362                "private": true,
363                "mounted_by": "pid-namespace",
364                "not_private_because": null,
365            })
366        );
367        let rendered = render_process_view(&report);
368        assert!(rendered.starts_with("Process view\n"), "{rendered}");
369        assert!(rendered.contains("private (pid-namespace)"), "{rendered}");
370        assert!(rendered.contains("read and list"), "{rendered}");
371        let host = render_process_view(&process_view_report(&ProcView::Host(
372            HostProcReason::Unconfined,
373        )));
374        assert!(
375            host.contains("/proc:     host — the session runs unconfined"),
376            "{host}"
377        );
378    }
379
380    #[test]
381    fn a_mount_failure_names_its_step_and_errno_under_the_prefix() {
382        let message = describe_mount_failure(ProcMountStep::MountProc, 1);
383        assert!(
384            message.starts_with(PRIVATE_PROC_FAILURE_PREFIX),
385            "{message}"
386        );
387        assert!(
388            message.contains("mounting proc over /proc failed: EPERM"),
389            "{message}"
390        );
391    }
392}