bootc_lib/
lsm.rs

1use std::borrow::Cow;
2use std::io::Write;
3use std::os::fd::AsRawFd;
4use std::os::unix::process::CommandExt;
5use std::path::Path;
6use std::process::Command;
7
8use anyhow::{Context, Result};
9use bootc_utils::CommandRunExt;
10use camino::{Utf8Path, Utf8PathBuf};
11use cap_std::fs::Dir;
12use cap_std::fs::{DirBuilder, OpenOptions};
13use cap_std::io_lifetimes::AsFilelike;
14use cap_std_ext::cap_std;
15use cap_std_ext::cap_std::fs::{Metadata, MetadataExt};
16use cap_std_ext::dirext::CapStdExtDirExt;
17use fn_error_context::context;
18use ostree_ext::gio;
19use ostree_ext::ostree;
20use rustix::fd::AsFd;
21
22/// The mount path for selinux
23const SELINUXFS: &str = "/sys/fs/selinux";
24/// The SELinux xattr
25const SELINUX_XATTR: &[u8] = b"security.selinux\0";
26const SELF_CURRENT: &str = "/proc/self/attr/current";
27
28#[context("Querying selinux availability")]
29pub(crate) fn selinux_enabled() -> Result<bool> {
30    Path::new("/proc/1/root/sys/fs/selinux/enforce")
31        .try_exists()
32        .map_err(Into::into)
33}
34
35/// Get the current process SELinux security context
36fn get_current_security_context() -> Result<String> {
37    std::fs::read_to_string(SELF_CURRENT).with_context(|| format!("Reading {SELF_CURRENT}"))
38}
39
40/// Check if the current process has the capability to write SELinux security
41/// contexts unknown to the current policy. In SELinux terms this capability is
42/// gated under `mac_admin` (admin control over SELinux state), and in the Fedora
43/// policy at least it's part of `install_t`.
44#[context("Testing install_t")]
45fn test_install_t() -> Result<bool> {
46    let tmpf = tempfile::NamedTempFile::new()?;
47    // Our implementation here writes a label which is always unknown to the current policy
48    // to verify that we have the capability to do so.
49    let st = Command::new("chcon")
50        .args(["-t", "invalid_bootcinstall_testlabel_t"])
51        .arg(tmpf.path())
52        .stderr(std::process::Stdio::null())
53        .status()?;
54    Ok(st.success())
55}
56
57/// Ensure that the current process has the capability to write SELinux security
58/// contexts unknown to the current policy.
59///
60/// See [`test_install_t`] above for how we check for that capability.
61///
62/// In the general case of both upgrade or install, we may e.g. jump major versions
63/// or even operating systems, and we need the ability to write arbitrary labels.
64/// If the current process doesn't already have `mac_admin/install_t` then we
65/// make a new temporary copy of our binary, and give it the same label as /usr/bin/ostree,
66/// which in Fedora derivatives at least was already historically labeled with
67/// the correct install_t label.
68///
69/// However, if you maintain a bootc operating system with SELinux, you should from
70/// the start ensure that /usr/bin/bootc has the correct capabilities.
71#[context("Ensuring selinux install_t type")]
72pub(crate) fn selinux_ensure_install() -> Result<bool> {
73    let guardenv = "_bootc_selinuxfs_mounted";
74    let current = get_current_security_context()?;
75    tracing::debug!("Current security context is {current}");
76    if let Some(p) = std::env::var_os(guardenv) {
77        let p = Path::new(&p);
78        if p.exists() {
79            tracing::debug!("Removing temporary file");
80            std::fs::remove_file(p).context("Removing {p:?}")?;
81        } else {
82            tracing::debug!("Assuming we now have a privileged (e.g. install_t) label");
83        }
84        return test_install_t();
85    }
86    if test_install_t()? {
87        tracing::debug!("We have install_t");
88        return Ok(true);
89    }
90    tracing::debug!("Lacking install_t capabilities; copying self to temporary file for re-exec");
91    // OK now, we always copy our binary to a tempfile, set its security context
92    // to match that of /usr/bin/ostree, and then re-exec.  This is really a gross
93    // hack; we can't always rely on https://github.com/fedora-selinux/selinux-policy/pull/1500/commits/67eb283c46d35a722636d749e5b339615fe5e7f5
94    let mut tmpf = tempfile::NamedTempFile::new()?;
95    let srcpath = std::env::current_exe()?;
96    let mut src = std::fs::File::open(&srcpath)?;
97    let meta = src.metadata()?;
98    std::io::copy(&mut src, &mut tmpf).context("Copying self to tempfile for selinux re-exec")?;
99    tmpf.as_file_mut()
100        .set_permissions(meta.permissions())
101        .context("Setting permissions of tempfile")?;
102    let container_root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
103    let policy = ostree::SePolicy::new_at(container_root.as_raw_fd(), gio::Cancellable::NONE)?;
104    let label = require_label(&policy, "/usr/bin/ostree".into(), libc::S_IFREG | 0o755)?;
105    set_security_selinux(tmpf.as_fd(), label.as_bytes())?;
106    let tmpf: Utf8PathBuf = tmpf.keep()?.1.try_into().unwrap();
107    tracing::debug!("Created {tmpf:?}");
108
109    let mut cmd = Command::new(&tmpf);
110    cmd.env(guardenv, tmpf);
111    cmd.env(bootc_utils::reexec::ORIG, srcpath);
112    cmd.args(std::env::args_os().skip(1));
113    cmd.arg0(bootc_utils::NAME);
114    cmd.log_debug();
115    Err(anyhow::Error::msg(cmd.exec()).context("execve"))
116}
117
118/// Query whether SELinux is apparently enabled in the target root
119pub(crate) fn have_selinux_policy(root: &Dir) -> Result<bool> {
120    // TODO use ostree::SePolicy and query policy name
121    root.try_exists("etc/selinux/config").map_err(Into::into)
122}
123
124/// A type which will reset SELinux back to enforcing mode when dropped.
125/// This is a workaround for the deep difficulties in trying to reliably
126/// gain the `mac_admin` permission (install_t).
127#[must_use]
128#[derive(Debug)]
129#[allow(dead_code)]
130pub(crate) struct SetEnforceGuard(Option<()>);
131
132impl SetEnforceGuard {
133    pub(crate) fn new() -> Self {
134        SetEnforceGuard(Some(()))
135    }
136
137    #[allow(dead_code)]
138    pub(crate) fn consume(mut self) -> Result<()> {
139        // SAFETY: The option cannot have been consumed until now
140        self.0.take().unwrap();
141        // This returns errors
142        selinux_set_permissive(false)
143    }
144}
145
146impl Drop for SetEnforceGuard {
147    fn drop(&mut self) {
148        // A best-effort attempt to re-enable enforcement on drop (installation failure)
149        if let Some(()) = self.0.take() {
150            let _ = selinux_set_permissive(false);
151        }
152    }
153}
154
155/// Try to enter the install_t domain, but if we can't do that, then
156/// just setenforce 0.
157#[context("Ensuring selinux install_t type")]
158pub(crate) fn selinux_ensure_install_or_setenforce() -> Result<Option<SetEnforceGuard>> {
159    // If the process already has install_t, exit early
160    // Note that this may re-exec the entire process
161    if selinux_ensure_install()? {
162        return Ok(None);
163    }
164    let g = if std::env::var_os("BOOTC_SETENFORCE0_FALLBACK").is_some() {
165        tracing::warn!("Failed to enter install_t; temporarily setting permissive mode");
166        selinux_set_permissive(true)?;
167        Some(SetEnforceGuard::new())
168    } else {
169        let current = get_current_security_context()?;
170        anyhow::bail!(
171            "Failed to enter install_t (running as {current}) - use BOOTC_SETENFORCE0_FALLBACK=1 to override"
172        );
173    };
174    Ok(g)
175}
176
177/// A thin wrapper for loading a SELinux policy that maps "policy nonexistent" to None.
178pub(crate) fn new_sepolicy_at(fd: impl AsFd) -> Result<Option<ostree::SePolicy>> {
179    let fd = fd.as_fd();
180    let cancellable = gio::Cancellable::NONE;
181    let sepolicy = ostree::SePolicy::new_at(fd.as_raw_fd(), cancellable)?;
182    let r = if sepolicy.csum().is_none() {
183        None
184    } else {
185        Some(sepolicy)
186    };
187    Ok(r)
188}
189
190#[context("Setting SELinux permissive mode")]
191#[allow(dead_code)]
192pub(crate) fn selinux_set_permissive(permissive: bool) -> Result<()> {
193    let enforce_path = &Utf8Path::new(SELINUXFS).join("enforce");
194    if !enforce_path.exists() {
195        return Ok(());
196    }
197    let mut f = std::fs::File::options().write(true).open(enforce_path)?;
198    f.write_all(if permissive { b"0" } else { b"1" })?;
199    tracing::debug!(
200        "Set SELinux mode: {}",
201        if permissive {
202            "permissive"
203        } else {
204            "enforcing"
205        }
206    );
207    Ok(())
208}
209
210/// Check if the ostree-formatted extended attributes include a security.selinux value.
211pub(crate) fn xattrs_have_selinux(xattrs: &ostree::glib::Variant) -> bool {
212    let n = xattrs.n_children();
213    for i in 0..n {
214        let child = xattrs.child_value(i);
215        let key = child.child_value(0);
216        let key = key.data_as_bytes();
217        if key == SELINUX_XATTR {
218            return true;
219        }
220    }
221    false
222}
223
224/// Look up the label for a path in a policy, and error if one is not found.
225pub(crate) fn require_label(
226    policy: &ostree::SePolicy,
227    destname: &Utf8Path,
228    mode: u32,
229) -> Result<ostree::glib::GString> {
230    policy
231        .label(destname.as_str(), mode, ostree::gio::Cancellable::NONE)?
232        .ok_or_else(|| {
233            anyhow::anyhow!(
234                "No label found in policy '{:?}' for {destname})",
235                policy.csum()
236            )
237        })
238}
239
240/// A thin wrapper for invoking fsetxattr(security.selinux)
241pub(crate) fn set_security_selinux(fd: std::os::fd::BorrowedFd, label: &[u8]) -> Result<()> {
242    rustix::fs::fsetxattr(
243        fd,
244        "security.selinux",
245        label,
246        rustix::fs::XattrFlags::empty(),
247    )
248    .context("fsetxattr(security.selinux)")
249}
250
251/// The labeling state; "unsupported" is distinct as we need to handle
252/// cases like the ESP which don't support labeling.
253pub(crate) enum SELinuxLabelState {
254    Unlabeled,
255    Unsupported,
256    Labeled,
257}
258
259/// Query the SELinux labeling for a particular path
260pub(crate) fn has_security_selinux(root: &Dir, path: &Utf8Path) -> Result<SELinuxLabelState> {
261    // TODO: avoid hardcoding a max size here
262    let mut buf = [0u8; 2048];
263    let fdpath = format!("/proc/self/fd/{}/{path}", root.as_raw_fd());
264    match rustix::fs::lgetxattr(fdpath, "security.selinux", &mut buf) {
265        Ok(_) => Ok(SELinuxLabelState::Labeled),
266        Err(rustix::io::Errno::OPNOTSUPP) => Ok(SELinuxLabelState::Unsupported),
267        Err(rustix::io::Errno::NODATA) => Ok(SELinuxLabelState::Unlabeled),
268        Err(e) => Err(e).with_context(|| format!("Failed to look up context for {path:?}")),
269    }
270}
271
272/// Directly set the `security.selinux` extended attribute on the target
273/// path. Symbolic links are not followed for the target.
274///
275/// Note that this API will work even if SELinux is disabled.
276pub(crate) fn set_security_selinux_path(root: &Dir, path: &Utf8Path, label: &[u8]) -> Result<()> {
277    let fdpath = format!("/proc/self/fd/{}/", root.as_raw_fd());
278    let fdpath = &Path::new(&fdpath).join(path);
279    rustix::fs::lsetxattr(
280        fdpath,
281        "security.selinux",
282        label,
283        rustix::fs::XattrFlags::empty(),
284    )?;
285    Ok(())
286}
287
288/// Given a policy, ensure the target file path has a security.selinux label.
289/// If the path already is labeled, this function is a no-op, even if
290/// the policy would default to a different label.
291pub(crate) fn ensure_labeled(
292    root: &Dir,
293    path: &Utf8Path,
294    metadata: &Metadata,
295    policy: &ostree::SePolicy,
296) -> Result<SELinuxLabelState> {
297    let r = has_security_selinux(root, path)?;
298    if matches!(r, SELinuxLabelState::Unlabeled) {
299        relabel(root, metadata, path, None, policy)?;
300    }
301    Ok(r)
302}
303
304/// Given the policy, relabel the target file or directory.
305/// Optionally, an override for the path can be provided
306/// to set the label as if the target has that filename.
307pub(crate) fn relabel(
308    root: &Dir,
309    metadata: &Metadata,
310    path: &Utf8Path,
311    as_path: Option<&Utf8Path>,
312    policy: &ostree::SePolicy,
313) -> Result<()> {
314    assert!(!path.starts_with("/"));
315    let as_path = as_path
316        .map(Cow::Borrowed)
317        .unwrap_or_else(|| Utf8Path::new("/").join(path).into());
318    let label = require_label(policy, &as_path, metadata.mode())?;
319    tracing::trace!("Setting label for {path} to {label}");
320    set_security_selinux_path(root, &path, label.as_bytes())
321}
322
323pub(crate) fn relabel_recurse_inner(
324    root: &Dir,
325    path: &mut Utf8PathBuf,
326    mut as_path: Option<&mut Utf8PathBuf>,
327    policy: &ostree::SePolicy,
328) -> Result<()> {
329    // Relabel this directory
330    let self_meta = root.dir_metadata()?;
331    relabel(
332        root,
333        &self_meta,
334        path,
335        as_path.as_ref().map(|p| p.as_path()),
336        policy,
337    )?;
338
339    // Relabel all children
340    for ent in root.read_dir(&path)? {
341        let ent = ent?;
342        let metadata = ent.metadata()?;
343        let name = ent.file_name();
344        let name = name
345            .to_str()
346            .ok_or_else(|| anyhow::anyhow!("Invalid non-UTF-8 filename: {name:?}"))?;
347        // Extend both copies of the path
348        path.push(name);
349        if let Some(p) = as_path.as_mut() {
350            p.push(name);
351        }
352
353        if metadata.is_dir() {
354            let as_path = as_path.as_deref_mut();
355            relabel_recurse_inner(root, path, as_path, policy)?;
356        } else {
357            let as_path = as_path.as_ref().map(|p| p.as_path());
358            relabel(root, &metadata, &path, as_path, policy)?
359        }
360        // Trim what we added to the path
361        let r = path.pop();
362        assert!(r);
363        if let Some(p) = as_path.as_mut() {
364            let r = p.pop();
365            assert!(r);
366        }
367    }
368
369    Ok(())
370}
371
372/// Recursively relabel the target directory.
373pub(crate) fn relabel_recurse(
374    root: &Dir,
375    path: impl AsRef<Utf8Path>,
376    as_path: Option<&Utf8Path>,
377    policy: &ostree::SePolicy,
378) -> Result<()> {
379    let mut path = path.as_ref().to_owned();
380    // This path must be relative, as we access via cap-std
381    assert!(!path.starts_with("/"));
382    let mut as_path = as_path.map(|v| v.to_owned());
383    // But the as_path must be absolute, if provided
384    if let Some(as_path) = as_path.as_deref() {
385        assert!(as_path.starts_with("/"));
386    }
387    relabel_recurse_inner(root, &mut path, as_path.as_mut(), policy)
388}
389
390/// Recursively ensure all files under a directory have SELinux labels.
391/// Uses the `walk` API with `noxdev` and `skip_mountpoints` to avoid crossing
392/// mount point boundaries
393/// (e.g. into sysfs, procfs, etc.).
394/// The provided `skip` parameter is a device/inode pair that we will ignore
395/// (and not traverse into).
396pub(crate) fn ensure_dir_labeled_recurse(
397    root: &Dir,
398    path: &mut Utf8PathBuf,
399    policy: &ostree::SePolicy,
400    skip: Option<(libc::dev_t, libc::ino64_t)>,
401) -> Result<()> {
402    use cap_std_ext::dirext::WalkConfiguration;
403    use std::ops::ControlFlow;
404
405    // Juggle the cap-std requirement for relative paths vs the libselinux
406    // requirement for absolute paths by special casing the empty string "" as "."
407    // just for the initial directory enumeration.
408    let path_for_read = if path.as_str().is_empty() {
409        Utf8Path::new(".")
410    } else {
411        &*path
412    };
413
414    let mut n = 0u64;
415
416    // Label the starting directory itself; the walk API only visits children.
417    let metadata = root.symlink_metadata(path_for_read)?;
418    match ensure_labeled(root, path, &metadata, policy)? {
419        SELinuxLabelState::Unlabeled => {
420            n += 1;
421        }
422        SELinuxLabelState::Unsupported => return Ok(()),
423        SELinuxLabelState::Labeled => {}
424    }
425    let config = WalkConfiguration::default()
426        .noxdev()
427        .skip_mountpoints()
428        .path_base(path_for_read.as_std_path());
429
430    root.open_dir(path_for_read)?
431        .walk::<_, anyhow::Error>(&config, |component| {
432            let metadata = component.entry.metadata()?;
433
434            // Check if this entry should be skipped
435            if let Some((skip_dev, skip_ino)) = skip {
436                if (metadata.dev(), metadata.ino()) == (skip_dev, skip_ino) {
437                    tracing::debug!("Skipping dev={skip_dev} inode={skip_ino}");
438                    // For directories, Break skips traversal into the directory
439                    // but continues with the next sibling. For non-directories,
440                    // Break would skip all remaining siblings, so use Continue
441                    // to skip only this entry.
442                    if component.file_type.is_dir() {
443                        return Ok(ControlFlow::Break(()));
444                    } else {
445                        return Ok(ControlFlow::Continue(()));
446                    }
447                }
448            }
449
450            let path = Utf8Path::from_path(component.path)
451                .ok_or_else(|| anyhow::anyhow!("Invalid non-UTF-8 path: {:?}", component.path))?;
452
453            match ensure_labeled(root, path, &metadata, policy)? {
454                SELinuxLabelState::Unlabeled => {
455                    n += 1;
456                }
457                // We check for Unsupported on the starting directory above,
458                // and the walk uses noxdev + skip_mountpoints to stay on
459                // the same filesystem, so hitting Unsupported here is
460                // unexpected.
461                SELinuxLabelState::Unsupported => {
462                    anyhow::bail!(
463                        "Unexpected SELinuxLabelState::Unsupported during walk at {path}"
464                    );
465                }
466                SELinuxLabelState::Labeled => {}
467            }
468
469            Ok(ControlFlow::Continue(()))
470        })?;
471
472    if n > 0 {
473        tracing::debug!("Relabeled {n} objects in {path}");
474    }
475    Ok(())
476}
477
478/// A wrapper for creating a directory, also optionally setting a SELinux label.
479pub(crate) fn ensure_dir_labeled(
480    root: &Dir,
481    destname: impl AsRef<Utf8Path>,
482    as_path: Option<&Utf8Path>,
483    mode: rustix::fs::Mode,
484    policy: Option<&ostree::SePolicy>,
485) -> Result<()> {
486    use std::borrow::Cow;
487
488    let destname = destname.as_ref();
489    // Special case the empty string
490    let local_destname = if destname.as_str().is_empty() {
491        ".".into()
492    } else {
493        destname
494    };
495    tracing::debug!("Labeling {local_destname}");
496    let label = policy
497        .map(|policy| {
498            let as_path = as_path
499                .map(Cow::Borrowed)
500                .unwrap_or_else(|| Utf8Path::new("/").join(destname).into());
501            require_label(policy, &as_path, libc::S_IFDIR | mode.as_raw_mode())
502        })
503        .transpose()
504        .with_context(|| format!("Labeling {local_destname}"))?;
505    tracing::trace!("Label for {local_destname} is {label:?}");
506
507    root.ensure_dir_with(local_destname, &DirBuilder::new())
508        .with_context(|| format!("Opening {local_destname}"))?;
509    let dirfd = cap_std_ext::cap_primitives::fs::open(
510        &root.as_filelike_view(),
511        local_destname.as_std_path(),
512        OpenOptions::new().read(true),
513    )
514    .context("opendir")?;
515    let dirfd = dirfd.as_fd();
516    rustix::fs::fchmod(dirfd, mode).context("fchmod")?;
517    if let Some(label) = label {
518        set_security_selinux(dirfd, label.as_bytes())?;
519    }
520
521    Ok(())
522}
523
524/// A wrapper for atomically writing a file, also optionally setting a SELinux label.
525pub(crate) fn atomic_replace_labeled<F>(
526    root: &Dir,
527    destname: impl AsRef<Utf8Path>,
528    mode: rustix::fs::Mode,
529    policy: Option<&ostree::SePolicy>,
530    f: F,
531) -> Result<()>
532where
533    F: FnOnce(&mut std::io::BufWriter<cap_std_ext::cap_tempfile::TempFile>) -> Result<()>,
534{
535    let destname = destname.as_ref();
536    let label = policy
537        .map(|policy| {
538            let abs_destname = Utf8Path::new("/").join(destname);
539            require_label(policy, &abs_destname, libc::S_IFREG | mode.as_raw_mode())
540        })
541        .transpose()?;
542
543    root.atomic_replace_with(destname, |w| {
544        // Peel through the bufwriter to get the fd
545        let fd = w.get_mut();
546        let fd = fd.as_file_mut();
547        let fd = fd.as_fd();
548        // Apply the target mode bits
549        rustix::fs::fchmod(fd, mode).context("fchmod")?;
550        // If we have a label, apply it
551        if let Some(label) = label {
552            tracing::debug!("Setting label for {destname} to {label}");
553            set_security_selinux(fd, label.as_bytes())?;
554        } else {
555            tracing::debug!("No label for {destname}");
556        }
557        // Finally call the underlying writer function
558        f(w)
559    })
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use gio::glib::Variant;
566
567    #[test]
568    fn test_selinux_xattr() {
569        let notfound: &[&[(&[u8], &[u8])]] = &[&[], &[(b"foo", b"bar")]];
570        for case in notfound {
571            assert!(!xattrs_have_selinux(&Variant::from(case)));
572        }
573        let found: &[(&[u8], &[u8])] = &[(b"foo", b"bar"), (SELINUX_XATTR, b"foo_t")];
574        assert!(xattrs_have_selinux(&Variant::from(found)));
575    }
576}