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!("Failed to enter install_t (running as {current}) - use BOOTC_SETENFORCE0_FALLBACK=1 to override");
171    };
172    Ok(g)
173}
174
175/// A thin wrapper for loading a SELinux policy that maps "policy nonexistent" to None.
176pub(crate) fn new_sepolicy_at(fd: impl AsFd) -> Result<Option<ostree::SePolicy>> {
177    let fd = fd.as_fd();
178    let cancellable = gio::Cancellable::NONE;
179    let sepolicy = ostree::SePolicy::new_at(fd.as_raw_fd(), cancellable)?;
180    let r = if sepolicy.csum().is_none() {
181        None
182    } else {
183        Some(sepolicy)
184    };
185    Ok(r)
186}
187
188#[context("Setting SELinux permissive mode")]
189#[allow(dead_code)]
190pub(crate) fn selinux_set_permissive(permissive: bool) -> Result<()> {
191    let enforce_path = &Utf8Path::new(SELINUXFS).join("enforce");
192    if !enforce_path.exists() {
193        return Ok(());
194    }
195    let mut f = std::fs::File::options().write(true).open(enforce_path)?;
196    f.write_all(if permissive { b"0" } else { b"1" })?;
197    tracing::debug!(
198        "Set SELinux mode: {}",
199        if permissive {
200            "permissive"
201        } else {
202            "enforcing"
203        }
204    );
205    Ok(())
206}
207
208/// Check if the ostree-formatted extended attributes include a security.selinux value.
209pub(crate) fn xattrs_have_selinux(xattrs: &ostree::glib::Variant) -> bool {
210    let n = xattrs.n_children();
211    for i in 0..n {
212        let child = xattrs.child_value(i);
213        let key = child.child_value(0);
214        let key = key.data_as_bytes();
215        if key == SELINUX_XATTR {
216            return true;
217        }
218    }
219    false
220}
221
222/// Look up the label for a path in a policy, and error if one is not found.
223pub(crate) fn require_label(
224    policy: &ostree::SePolicy,
225    destname: &Utf8Path,
226    mode: u32,
227) -> Result<ostree::glib::GString> {
228    policy
229        .label(destname.as_str(), mode, ostree::gio::Cancellable::NONE)?
230        .ok_or_else(|| {
231            anyhow::anyhow!(
232                "No label found in policy '{:?}' for {destname})",
233                policy.csum()
234            )
235        })
236}
237
238/// A thin wrapper for invoking fsetxattr(security.selinux)
239pub(crate) fn set_security_selinux(fd: std::os::fd::BorrowedFd, label: &[u8]) -> Result<()> {
240    rustix::fs::fsetxattr(
241        fd,
242        "security.selinux",
243        label,
244        rustix::fs::XattrFlags::empty(),
245    )
246    .context("fsetxattr(security.selinux)")
247}
248
249/// The labeling state; "unsupported" is distinct as we need to handle
250/// cases like the ESP which don't support labeling.
251pub(crate) enum SELinuxLabelState {
252    Unlabeled,
253    Unsupported,
254    Labeled,
255}
256
257/// Query the SELinux labeling for a particular path
258pub(crate) fn has_security_selinux(root: &Dir, path: &Utf8Path) -> Result<SELinuxLabelState> {
259    // TODO: avoid hardcoding a max size here
260    let mut buf = [0u8; 2048];
261    let fdpath = format!("/proc/self/fd/{}/{path}", root.as_raw_fd());
262    match rustix::fs::lgetxattr(fdpath, "security.selinux", &mut buf) {
263        Ok(_) => Ok(SELinuxLabelState::Labeled),
264        Err(rustix::io::Errno::OPNOTSUPP) => Ok(SELinuxLabelState::Unsupported),
265        Err(rustix::io::Errno::NODATA) => Ok(SELinuxLabelState::Unlabeled),
266        Err(e) => Err(e).with_context(|| format!("Failed to look up context for {path:?}")),
267    }
268}
269
270/// Directly set the `security.selinux` extended attribute on the target
271/// path. Symbolic links are not followed for the target.
272///
273/// Note that this API will work even if SELinux is disabled.
274pub(crate) fn set_security_selinux_path(root: &Dir, path: &Utf8Path, label: &[u8]) -> Result<()> {
275    let fdpath = format!("/proc/self/fd/{}/", root.as_raw_fd());
276    let fdpath = &Path::new(&fdpath).join(path);
277    rustix::fs::lsetxattr(
278        fdpath,
279        "security.selinux",
280        label,
281        rustix::fs::XattrFlags::empty(),
282    )?;
283    Ok(())
284}
285
286/// Given a policy, ensure the target file path has a security.selinux label.
287/// If the path already is labeled, this function is a no-op, even if
288/// the policy would default to a different label.
289pub(crate) fn ensure_labeled(
290    root: &Dir,
291    path: &Utf8Path,
292    metadata: &Metadata,
293    policy: &ostree::SePolicy,
294) -> Result<SELinuxLabelState> {
295    let r = has_security_selinux(root, path)?;
296    if matches!(r, SELinuxLabelState::Unlabeled) {
297        relabel(root, metadata, path, None, policy)?;
298    }
299    Ok(r)
300}
301
302/// Given the policy, relabel the target file or directory.
303/// Optionally, an override for the path can be provided
304/// to set the label as if the target has that filename.
305pub(crate) fn relabel(
306    root: &Dir,
307    metadata: &Metadata,
308    path: &Utf8Path,
309    as_path: Option<&Utf8Path>,
310    policy: &ostree::SePolicy,
311) -> Result<()> {
312    assert!(!path.starts_with("/"));
313    let as_path = as_path
314        .map(Cow::Borrowed)
315        .unwrap_or_else(|| Utf8Path::new("/").join(path).into());
316    let label = require_label(policy, &as_path, metadata.mode())?;
317    tracing::trace!("Setting label for {path} to {label}");
318    set_security_selinux_path(root, &path, label.as_bytes())
319}
320
321pub(crate) fn relabel_recurse_inner(
322    root: &Dir,
323    path: &mut Utf8PathBuf,
324    mut as_path: Option<&mut Utf8PathBuf>,
325    policy: &ostree::SePolicy,
326) -> Result<()> {
327    // Relabel this directory
328    let self_meta = root.dir_metadata()?;
329    relabel(
330        root,
331        &self_meta,
332        path,
333        as_path.as_ref().map(|p| p.as_path()),
334        policy,
335    )?;
336
337    // Relabel all children
338    for ent in root.read_dir(&path)? {
339        let ent = ent?;
340        let metadata = ent.metadata()?;
341        let name = ent.file_name();
342        let name = name
343            .to_str()
344            .ok_or_else(|| anyhow::anyhow!("Invalid non-UTF-8 filename: {name:?}"))?;
345        // Extend both copies of the path
346        path.push(name);
347        if let Some(p) = as_path.as_mut() {
348            p.push(name);
349        }
350
351        if metadata.is_dir() {
352            let as_path = as_path.as_deref_mut();
353            relabel_recurse_inner(root, path, as_path, policy)?;
354        } else {
355            let as_path = as_path.as_ref().map(|p| p.as_path());
356            relabel(root, &metadata, &path, as_path, policy)?
357        }
358        // Trim what we added to the path
359        let r = path.pop();
360        assert!(r);
361        if let Some(p) = as_path.as_mut() {
362            let r = p.pop();
363            assert!(r);
364        }
365    }
366
367    Ok(())
368}
369
370/// Recursively relabel the target directory.
371pub(crate) fn relabel_recurse(
372    root: &Dir,
373    path: impl AsRef<Utf8Path>,
374    as_path: Option<&Utf8Path>,
375    policy: &ostree::SePolicy,
376) -> Result<()> {
377    let mut path = path.as_ref().to_owned();
378    // This path must be relative, as we access via cap-std
379    assert!(!path.starts_with("/"));
380    let mut as_path = as_path.map(|v| v.to_owned());
381    // But the as_path must be absolute, if provided
382    if let Some(as_path) = as_path.as_deref() {
383        assert!(as_path.starts_with("/"));
384    }
385    relabel_recurse_inner(root, &mut path, as_path.as_mut(), policy)
386}
387
388/// A wrapper for creating a directory, also optionally setting a SELinux label.
389/// The provided `skip` parameter is a device/inode that we will ignore (and not traverse).
390pub(crate) fn ensure_dir_labeled_recurse(
391    root: &Dir,
392    path: &mut Utf8PathBuf,
393    policy: &ostree::SePolicy,
394    skip: Option<(libc::dev_t, libc::ino64_t)>,
395) -> Result<()> {
396    // Juggle the cap-std requirement for relative paths vs the libselinux
397    // requirement for absolute paths by special casing the empty string "" as "."
398    // just for the initial directory enumeration.
399    let path_for_read = if path.as_str().is_empty() {
400        Utf8Path::new(".")
401    } else {
402        &*path
403    };
404
405    let mut n = 0u64;
406
407    let metadata = root.symlink_metadata(path_for_read)?;
408    match ensure_labeled(root, path, &metadata, policy)? {
409        SELinuxLabelState::Unlabeled => {
410            n += 1;
411        }
412        SELinuxLabelState::Unsupported => return Ok(()),
413        SELinuxLabelState::Labeled => {}
414    }
415
416    for ent in root.read_dir(path_for_read)? {
417        let ent = ent?;
418        let metadata = ent.metadata()?;
419        if let Some((skip_dev, skip_ino)) = skip.as_ref().copied() {
420            if (metadata.dev(), metadata.ino()) == (skip_dev, skip_ino) {
421                tracing::debug!("Skipping dev={skip_dev} inode={skip_ino}");
422                continue;
423            }
424        }
425        let name = ent.file_name();
426        let name = name
427            .to_str()
428            .ok_or_else(|| anyhow::anyhow!("Invalid non-UTF-8 filename: {name:?}"))?;
429        path.push(name);
430
431        if metadata.is_dir() {
432            ensure_dir_labeled_recurse(root, path, policy, skip)?;
433        } else {
434            match ensure_labeled(root, path, &metadata, policy)? {
435                SELinuxLabelState::Unlabeled => {
436                    n += 1;
437                }
438                SELinuxLabelState::Unsupported => break,
439                SELinuxLabelState::Labeled => {}
440            }
441        }
442        path.pop();
443    }
444
445    if n > 0 {
446        tracing::debug!("Relabeled {n} objects in {path}");
447    }
448    Ok(())
449}
450
451/// A wrapper for creating a directory, also optionally setting a SELinux label.
452pub(crate) fn ensure_dir_labeled(
453    root: &Dir,
454    destname: impl AsRef<Utf8Path>,
455    as_path: Option<&Utf8Path>,
456    mode: rustix::fs::Mode,
457    policy: Option<&ostree::SePolicy>,
458) -> Result<()> {
459    use std::borrow::Cow;
460
461    let destname = destname.as_ref();
462    // Special case the empty string
463    let local_destname = if destname.as_str().is_empty() {
464        ".".into()
465    } else {
466        destname
467    };
468    tracing::debug!("Labeling {local_destname}");
469    let label = policy
470        .map(|policy| {
471            let as_path = as_path
472                .map(Cow::Borrowed)
473                .unwrap_or_else(|| Utf8Path::new("/").join(destname).into());
474            require_label(policy, &as_path, libc::S_IFDIR | mode.as_raw_mode())
475        })
476        .transpose()
477        .with_context(|| format!("Labeling {local_destname}"))?;
478    tracing::trace!("Label for {local_destname} is {label:?}");
479
480    root.ensure_dir_with(local_destname, &DirBuilder::new())
481        .with_context(|| format!("Opening {local_destname}"))?;
482    let dirfd = cap_std_ext::cap_primitives::fs::open(
483        &root.as_filelike_view(),
484        local_destname.as_std_path(),
485        OpenOptions::new().read(true),
486    )
487    .context("opendir")?;
488    let dirfd = dirfd.as_fd();
489    rustix::fs::fchmod(dirfd, mode).context("fchmod")?;
490    if let Some(label) = label {
491        set_security_selinux(dirfd, label.as_bytes())?;
492    }
493
494    Ok(())
495}
496
497/// A wrapper for atomically writing a file, also optionally setting a SELinux label.
498pub(crate) fn atomic_replace_labeled<F>(
499    root: &Dir,
500    destname: impl AsRef<Utf8Path>,
501    mode: rustix::fs::Mode,
502    policy: Option<&ostree::SePolicy>,
503    f: F,
504) -> Result<()>
505where
506    F: FnOnce(&mut std::io::BufWriter<cap_std_ext::cap_tempfile::TempFile>) -> Result<()>,
507{
508    let destname = destname.as_ref();
509    let label = policy
510        .map(|policy| {
511            let abs_destname = Utf8Path::new("/").join(destname);
512            require_label(policy, &abs_destname, libc::S_IFREG | mode.as_raw_mode())
513        })
514        .transpose()?;
515
516    root.atomic_replace_with(destname, |w| {
517        // Peel through the bufwriter to get the fd
518        let fd = w.get_mut();
519        let fd = fd.as_file_mut();
520        let fd = fd.as_fd();
521        // Apply the target mode bits
522        rustix::fs::fchmod(fd, mode).context("fchmod")?;
523        // If we have a label, apply it
524        if let Some(label) = label {
525            tracing::debug!("Setting label for {destname} to {label}");
526            set_security_selinux(fd, label.as_bytes())?;
527        } else {
528            tracing::debug!("No label for {destname}");
529        }
530        // Finally call the underlying writer function
531        f(w)
532    })
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use gio::glib::Variant;
539
540    #[test]
541    fn test_selinux_xattr() {
542        let notfound: &[&[(&[u8], &[u8])]] = &[&[], &[(b"foo", b"bar")]];
543        for case in notfound {
544            assert!(!xattrs_have_selinux(&Variant::from(case)));
545        }
546        let found: &[(&[u8], &[u8])] = &[(b"foo", b"bar"), (SELINUX_XATTR, b"foo_t")];
547        assert!(xattrs_have_selinux(&Variant::from(found)));
548    }
549}