1use serde::Serialize;
26
27use crate::namespace_probe::NamespaceReading;
28use crate::sandbox::EnforcementTier;
29
30pub const PRIVATE_PROC_FAILURE_PREFIX: &str =
32 "sandbox: could not mount this session's private /proc:";
33
34pub const PID_NAMESPACE_MECHANISM: &str = "pid-namespace";
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum ProcView {
40 Private,
42 Host(HostProcReason),
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum HostProcReason {
49 NoKernelContainment,
51 Unconfined,
53 MountRefused(String),
55 NotProbed,
57}
58
59impl ProcView {
60 #[must_use]
62 pub fn is_private(&self) -> bool {
63 matches!(self, Self::Private)
64 }
65
66 #[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 #[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
139pub struct ProcessViewReport {
140 pub private: bool,
142 pub mounted_by: Option<&'static str>,
144 pub not_private_because: Option<String>,
146}
147
148#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum ProcMountStep {
178 UnshareMount,
179 MakePrivate,
180 MountProc,
181 OpenProc,
182}
183
184impl ProcMountStep {
185 #[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#[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 pub(crate) unsafe fn mount_private_proc(
232 enter_mount_namespace: bool,
233 ) -> Result<RawFd, (ProcMountStep, i32)> {
234 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}