@@ -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) ]
776863mod 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 ,
0 commit comments