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/// A wrapper for creating a directory, also optionally setting a SELinux label.
391/// The provided `skip` parameter is a device/inode that we will ignore (and not traverse).
392pub(crate) fn ensure_dir_labeled_recurse(
393    root: &Dir,
394    path: &mut Utf8PathBuf,
395    policy: &ostree::SePolicy,
396    skip: Option<(libc::dev_t, libc::ino64_t)>,
397) -> Result<()> {
398    // Juggle the cap-std requirement for relative paths vs the libselinux
399    // requirement for absolute paths by special casing the empty string "" as "."
400    // just for the initial directory enumeration.
401    let path_for_read = if path.as_str().is_empty() {
402        Utf8Path::new(".")
403    } else {
404        &*path
405    };
406
407    let mut n = 0u64;
408
409    let metadata = root.symlink_metadata(path_for_read)?;
410    match ensure_labeled(root, path, &metadata, policy)? {
411        SELinuxLabelState::Unlabeled => {
412            n += 1;
413        }
414        SELinuxLabelState::Unsupported => return Ok(()),
415        SELinuxLabelState::Labeled => {}
416    }
417
418    for ent in root.read_dir(path_for_read)? {
419        let ent = ent?;
420        let metadata = ent.metadata()?;
421        if let Some((skip_dev, skip_ino)) = skip.as_ref().copied() {
422            if (metadata.dev(), metadata.ino()) == (skip_dev, skip_ino) {
423                tracing::debug!("Skipping dev={skip_dev} inode={skip_ino}");
424                continue;
425            }
426        }
427        let name = ent.file_name();
428        let name = name
429            .to_str()
430            .ok_or_else(|| anyhow::anyhow!("Invalid non-UTF-8 filename: {name:?}"))?;
431        path.push(name);
432
433        if metadata.is_dir() {
434            ensure_dir_labeled_recurse(root, path, policy, skip)?;
435        } else {
436            match ensure_labeled(root, path, &metadata, policy)? {
437                SELinuxLabelState::Unlabeled => {
438                    n += 1;
439                }
440                SELinuxLabelState::Unsupported => break,
441                SELinuxLabelState::Labeled => {}
442            }
443        }
444        path.pop();
445    }
446
447    if n > 0 {
448        tracing::debug!("Relabeled {n} objects in {path}");
449    }
450    Ok(())
451}
452
453/// A wrapper for creating a directory, also optionally setting a SELinux label.
454pub(crate) fn ensure_dir_labeled(
455    root: &Dir,
456    destname: impl AsRef<Utf8Path>,
457    as_path: Option<&Utf8Path>,
458    mode: rustix::fs::Mode,
459    policy: Option<&ostree::SePolicy>,
460) -> Result<()> {
461    use std::borrow::Cow;
462
463    let destname = destname.as_ref();
464    // Special case the empty string
465    let local_destname = if destname.as_str().is_empty() {
466        ".".into()
467    } else {
468        destname
469    };
470    tracing::debug!("Labeling {local_destname}");
471    let label = policy
472        .map(|policy| {
473            let as_path = as_path
474                .map(Cow::Borrowed)
475                .unwrap_or_else(|| Utf8Path::new("/").join(destname).into());
476            require_label(policy, &as_path, libc::S_IFDIR | mode.as_raw_mode())
477        })
478        .transpose()
479        .with_context(|| format!("Labeling {local_destname}"))?;
480    tracing::trace!("Label for {local_destname} is {label:?}");
481
482    root.ensure_dir_with(local_destname, &DirBuilder::new())
483        .with_context(|| format!("Opening {local_destname}"))?;
484    let dirfd = cap_std_ext::cap_primitives::fs::open(
485        &root.as_filelike_view(),
486        local_destname.as_std_path(),
487        OpenOptions::new().read(true),
488    )
489    .context("opendir")?;
490    let dirfd = dirfd.as_fd();
491    rustix::fs::fchmod(dirfd, mode).context("fchmod")?;
492    if let Some(label) = label {
493        set_security_selinux(dirfd, label.as_bytes())?;
494    }
495
496    Ok(())
497}
498
499/// A wrapper for atomically writing a file, also optionally setting a SELinux label.
500pub(crate) fn atomic_replace_labeled<F>(
501    root: &Dir,
502    destname: impl AsRef<Utf8Path>,
503    mode: rustix::fs::Mode,
504    policy: Option<&ostree::SePolicy>,
505    f: F,
506) -> Result<()>
507where
508    F: FnOnce(&mut std::io::BufWriter<cap_std_ext::cap_tempfile::TempFile>) -> Result<()>,
509{
510    let destname = destname.as_ref();
511    let label = policy
512        .map(|policy| {
513            let abs_destname = Utf8Path::new("/").join(destname);
514            require_label(policy, &abs_destname, libc::S_IFREG | mode.as_raw_mode())
515        })
516        .transpose()?;
517
518    root.atomic_replace_with(destname, |w| {
519        // Peel through the bufwriter to get the fd
520        let fd = w.get_mut();
521        let fd = fd.as_file_mut();
522        let fd = fd.as_fd();
523        // Apply the target mode bits
524        rustix::fs::fchmod(fd, mode).context("fchmod")?;
525        // If we have a label, apply it
526        if let Some(label) = label {
527            tracing::debug!("Setting label for {destname} to {label}");
528            set_security_selinux(fd, label.as_bytes())?;
529        } else {
530            tracing::debug!("No label for {destname}");
531        }
532        // Finally call the underlying writer function
533        f(w)
534    })
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use gio::glib::Variant;
541
542    #[test]
543    fn test_selinux_xattr() {
544        let notfound: &[&[(&[u8], &[u8])]] = &[&[], &[(b"foo", b"bar")]];
545        for case in notfound {
546            assert!(!xattrs_have_selinux(&Variant::from(case)));
547        }
548        let found: &[(&[u8], &[u8])] = &[(b"foo", b"bar"), (SELINUX_XATTR, b"foo_t")];
549        assert!(xattrs_have_selinux(&Variant::from(found)));
550    }
551}