Skip to content

Commit 488a48b

Browse files
committed
lint: warn on potential UID/GID drift in /etc; ignore tmpfiles chown coverage (z/Z) (#1562)
Signed-off-by: Rishi Jat <[email protected]>
1 parent df2da1a commit 488a48b

File tree

2 files changed

+209
-0
lines changed

2 files changed

+209
-0
lines changed

crates/lib/src/lints.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,93 @@ fn check_boot(root: &Dir, config: &LintExecutionConfig) -> LintResult {
772772
format_lint_err_from_items(config, header, items)
773773
}
774774

775+
/// Lint for potential uid/gid drift for files under /etc.
776+
/// Warn if files/dirs in /etc are owned by a non-root uid/gid and there is no
777+
/// corresponding tmpfiles.d chown (z/Z) entry covering them.
778+
#[distributed_slice(LINTS)]
779+
static LINT_ETC_UID_DRIFT: Lint = Lint::new_warning(
780+
"etc-uid-drift",
781+
indoc! { r#"
782+
Check for files in /etc owned by non-root users or groups which lack corresponding
783+
systemd tmpfiles.d 'z' or 'Z' entries to chown them at boot. Ownership encoded
784+
in the container may drift across upgrades if /etc is persistent.
785+
786+
This check ignores paths covered by tmpfiles.d chown entries.
787+
"# },
788+
check_etc_uid_drift,
789+
);
790+
791+
fn check_etc_uid_drift(root: &Dir, config: &LintExecutionConfig) -> LintResult {
792+
// Load chown-affecting tmpfiles entries
793+
let ch = bootc_tmpfiles::read_tmpfiles_chowners(root)?;
794+
// Build sets of fixed numeric uids/gids from sysusers
795+
let mut fixed_uids = BTreeSet::new();
796+
let mut fixed_gids = BTreeSet::new();
797+
for ent in bootc_sysusers::read_sysusers(root)? {
798+
match ent {
799+
bootc_sysusers::SysusersEntry::User { uid, .. } => {
800+
if let Some(bootc_sysusers::IdSource::Numeric(n)) = uid {
801+
fixed_uids.insert(n);
802+
}
803+
}
804+
bootc_sysusers::SysusersEntry::Group { id, .. } => {
805+
if let Some(bootc_sysusers::IdSource::Numeric(n)) = id {
806+
fixed_gids.insert(n);
807+
}
808+
}
809+
bootc_sysusers::SysusersEntry::Range { .. } => {}
810+
}
811+
}
812+
let Some(etcd) = root.open_dir_optional("etc")? else {
813+
return lint_ok();
814+
};
815+
// We'll collect problematic items
816+
let mut problems: BTreeSet<std::path::PathBuf> = BTreeSet::new();
817+
// Depth-first walk under /etc
818+
let mut stack: Vec<(Dir, std::path::PathBuf)> = vec![(etcd, std::path::PathBuf::from("/etc"))];
819+
while let Some((dir, abspath)) = stack.pop() {
820+
for ent in dir.entries()? {
821+
let ent = ent?;
822+
let name = ent.file_name();
823+
let child_rel = abspath.join(&name);
824+
// Convert absolute path to Path for chown coverage check
825+
let child_abs_path = child_rel.as_path();
826+
let fty = ent.file_type()?;
827+
if fty.is_symlink() {
828+
// Symlinks are not meaningful for ownership drift
829+
continue;
830+
}
831+
let meta = ent.metadata()?;
832+
// Recurse into subdirectories
833+
if meta.is_dir() {
834+
// Avoid traversing mount points
835+
let rel = child_rel.strip_prefix("/").unwrap();
836+
if let Some(subdir) = root.open_dir_noxdev(rel)? {
837+
stack.push((subdir, child_rel));
838+
}
839+
}
840+
let uid = meta.uid();
841+
let gid = meta.gid();
842+
// uid/gid of 0 (root) is always fine; others are fine if pinned numerically in sysusers
843+
let is_potential_drift = (uid != 0 && !fixed_uids.contains(&uid))
844+
|| (gid != 0 && !fixed_gids.contains(&gid));
845+
if is_potential_drift {
846+
// Ignore if covered by tmpfiles chown
847+
if ch.covers(child_abs_path) {
848+
continue;
849+
}
850+
problems.insert(child_rel);
851+
}
852+
}
853+
}
854+
if problems.is_empty() {
855+
return lint_ok();
856+
}
857+
let header = "Potential uid/gid drift in /etc (non-root-owned without tmpfiles chown)";
858+
let items = problems.iter().map(|p| PathQuotedDisplay::new(p.as_path()));
859+
format_lint_err_from_items(config, header, items)
860+
}
861+
775862
#[cfg(test)]
776863
mod tests {
777864
use std::sync::LazyLock;
@@ -982,6 +1069,44 @@ mod tests {
9821069
Ok(())
9831070
}
9841071

1072+
#[test]
1073+
fn test_etc_uid_drift() -> Result<()> {
1074+
let root = &fixture()?;
1075+
// Prepare minimal directories
1076+
root.create_dir_all("usr/lib/tmpfiles.d")?;
1077+
root.create_dir_all("etc/sub")?;
1078+
// Create files/dirs with root:root ownership by default (uid/gid of the builder),
1079+
// but emulate non-root by changing permissions via metadata is not possible in tmpfs here.
1080+
// Instead, simulate by writing a tmpfiles chown covering one, and ensure uncovered path warns.
1081+
root.atomic_write(
1082+
"usr/lib/tmpfiles.d/test.conf",
1083+
"Z /etc/sub - - - -",
1084+
)?;
1085+
1086+
// Create two paths under /etc: one covered by Z /etc/sub, one not
1087+
root.create_dir_all("etc/sub/covered")?;
1088+
root.create_dir_all("etc/uncovered")?;
1089+
1090+
let config = &LintExecutionConfig { no_truncate: true };
1091+
// Since we cannot alter uid/gid in this test easily, fake non-root by relying on the logic
1092+
// that checks coverage first: insert the uncovered path manually into problems by ensuring
1093+
// meta.uid()!=0 or gid!=0. We can't change it; so guard: if current uid/gid is 0, skip test.
1094+
// Instead, run the full lint; it will only flag if files show as non-root. On typical test
1095+
// envs uid!=0, so it should emit uncovered /etc/uncovered and ignore /etc/sub/*.
1096+
let r = check_etc_uid_drift(root, config)?;
1097+
match r {
1098+
Ok(()) => {
1099+
// If running as root, we can't validate drift; accept pass
1100+
}
1101+
Err(e) => {
1102+
let s = e.to_string();
1103+
assert!(s.contains("/etc/uncovered"), "{s}");
1104+
assert!(!s.contains("/etc/sub/covered"), "{s}");
1105+
}
1106+
}
1107+
Ok(())
1108+
}
1109+
9851110
fn run_recursive_lint(
9861111
root: &Dir,
9871112
f: LintRecursiveFn,

crates/tmpfiles/src/lib.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,90 @@ fn tmpfiles_entry_get_path(line: &str) -> Result<PathBuf> {
507507
unescape_path(&mut it)
508508
}
509509

510+
/// A parsed tmpfiles entry kind and path of interest.
511+
#[derive(Debug, Clone, PartialEq, Eq)]
512+
pub struct TmpfilesEntryRef {
513+
/// The entry type character (e.g. 'd', 'L', 'z', 'Z', ...)
514+
pub kind: char,
515+
/// The target path for the entry
516+
pub path: PathBuf,
517+
}
518+
519+
/// Chown-affecting tmpfiles.d entries summary
520+
#[derive(Debug, Default, Clone)]
521+
pub struct TmpfilesChowners {
522+
/// Exact chown entries (non-recursive) i.e. kind 'z'
523+
pub exact: BTreeSet<PathBuf>,
524+
/// Recursive chown entries (apply to all children) i.e. kind 'Z'
525+
pub recursive: BTreeSet<PathBuf>,
526+
}
527+
528+
impl TmpfilesChowners {
529+
/// Returns true if a chown entry would apply to the specified absolute path
530+
pub fn covers(&self, p: &Path) -> bool {
531+
if self.exact.contains(p) {
532+
return true;
533+
}
534+
// For recursive entries, any ancestor match qualifies
535+
for anc in p.ancestors() {
536+
if self.recursive.contains(anc) {
537+
return true;
538+
}
539+
}
540+
false
541+
}
542+
}
543+
544+
fn tmpfiles_entry_get_kind(line: &str) -> Option<char> {
545+
let mut it = line.bytes();
546+
// Skip leading whitespace
547+
while let Some(c) = it.next() {
548+
if !c.is_ascii_whitespace() {
549+
return Some(c as char);
550+
}
551+
}
552+
None
553+
}
554+
555+
/// Read tmpfiles.d entries and return only those affecting chown operations (z/Z)
556+
pub fn read_tmpfiles_chowners(rootfs: &Dir) -> Result<TmpfilesChowners> {
557+
let Some(tmpfiles_dir) = rootfs.open_dir_optional(TMPFILESD)? else {
558+
return Ok(Default::default());
559+
};
560+
let mut out = TmpfilesChowners::default();
561+
for entry in tmpfiles_dir.entries()? {
562+
let entry = entry?;
563+
// Only process .conf files
564+
let name = entry.file_name();
565+
let path = Path::new(&name);
566+
if path.extension() != Some(OsStr::new("conf")) {
567+
continue;
568+
}
569+
let r = BufReader::new(entry.open()?);
570+
for line in r.lines() {
571+
let line = line?;
572+
if line.is_empty() || line.starts_with('#') {
573+
continue;
574+
}
575+
let Some(kind) = tmpfiles_entry_get_kind(&line) else { continue };
576+
if kind != 'z' && kind != 'Z' {
577+
continue;
578+
}
579+
let path = tmpfiles_entry_get_path(&line)?;
580+
match kind {
581+
'z' => {
582+
out.exact.insert(path);
583+
}
584+
'Z' => {
585+
out.recursive.insert(path);
586+
}
587+
_ => unreachable!(),
588+
}
589+
}
590+
}
591+
Ok(out)
592+
}
593+
510594
#[cfg(test)]
511595
mod tests {
512596
use super::*;

0 commit comments

Comments
 (0)