From 754f03f1ca3825bcbd5bd4909f9bb0ba7c235cc0 Mon Sep 17 00:00:00 2001 From: Benno Rice Date: Thu, 14 Oct 2021 13:12:29 +1100 Subject: [PATCH 1/2] nameservice: Add data structures and parsers for shadow(5) --- rust/src/nameservice/mod.rs | 1 + rust/src/nameservice/shadow.rs | 157 +++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 rust/src/nameservice/shadow.rs diff --git a/rust/src/nameservice/mod.rs b/rust/src/nameservice/mod.rs index 65f6387f45..14386251ae 100644 --- a/rust/src/nameservice/mod.rs +++ b/rust/src/nameservice/mod.rs @@ -4,3 +4,4 @@ pub(crate) mod group; pub(crate) mod passwd; +pub(crate) mod shadow; diff --git a/rust/src/nameservice/shadow.rs b/rust/src/nameservice/shadow.rs new file mode 100644 index 0000000000..1a6af30999 --- /dev/null +++ b/rust/src/nameservice/shadow.rs @@ -0,0 +1,157 @@ +//! Helpers for [shadowed password file](https://man7.org/linux/man-pages/man5/shadow.5.html). +// Copyright (C) 2021 Oracle and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use anyhow::{anyhow, Context, Result}; +use std::io::{BufRead, Write}; + +/// Entry from shadow file. +// Field names taken from (presumably glibc's) /usr/include/shadow.h, descriptions adapted +// from the [shadow(3) manual page](https://man7.org/linux/man-pages/man3/shadow.3.html). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ShadowEntry { + /// user login name + pub(crate) namp: String, + /// encrypted password + pub(crate) pwdp: String, + /// days (from Jan 1, 1970) since password was last changed + pub(crate) lstchg: Option, + /// days before which password may not be changed + pub(crate) min: Option, + /// days after which password must be changed + pub(crate) max: Option, + /// days before password is to expire that user is warned of pending password expiration + pub(crate) warn: Option, + /// days after password expires that account is considered inactive and disabled + pub(crate) inact: Option, + /// date (in days since Jan 1, 1970) when account will be disabled + pub(crate) expire: Option, + /// reserved for future use + pub(crate) flag: String, +} + +fn u32_or_none(value: &str) -> Result, std::num::ParseIntError> { + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value.parse()?)) + } +} + +fn number_or_empty(value: Option) -> String { + if let Some(number) = value { + format!("{}", number) + } else { + "".to_string() + } +} + +impl ShadowEntry { + /// Parse a single shadow entry. + pub fn parse_line(s: impl AsRef) -> Option { + let mut parts = s.as_ref().splitn(9, ':'); + + let entry = Self { + namp: parts.next()?.to_string(), + pwdp: parts.next()?.to_string(), + lstchg: u32_or_none(parts.next()?).ok()?, + min: u32_or_none(parts.next()?).ok()?, + max: u32_or_none(parts.next()?).ok()?, + warn: u32_or_none(parts.next()?).ok()?, + inact: u32_or_none(parts.next()?).ok()?, + expire: u32_or_none(parts.next()?).ok()?, + flag: parts.next()?.to_string(), + }; + Some(entry) + } + + /// Serialize entry to writer, as a shadow line. + pub fn to_writer(&self, writer: &mut impl Write) -> Result<()> { + std::writeln!( + writer, + "{}:{}:{}:{}:{}:{}:{}:{}:{}", + self.namp, + self.pwdp, + number_or_empty(self.lstchg), + number_or_empty(self.min), + number_or_empty(self.max), + number_or_empty(self.warn), + number_or_empty(self.inact), + number_or_empty(self.expire), + self.flag + ) + .with_context(|| "failed to write shadow entry") + } +} + +pub(crate) fn parse_shadow_content(content: impl BufRead) -> Result> { + let mut entries = vec![]; + for (line_num, line) in content.lines().enumerate() { + let input = + line.with_context(|| format!("failed to read shadow entry at line {}", line_num))?; + + // Skip empty and comment lines + if input.is_empty() || input.starts_with('#') { + continue; + } + + let entry = ShadowEntry::parse_line(&input).ok_or_else(|| { + anyhow!( + "failed to parse shadow entry at line {}, content: {}", + line_num, + &input + ) + })?; + entries.push(entry); + } + Ok(entries) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufReader, Cursor}; + + fn mock_shadow_entry() -> ShadowEntry { + ShadowEntry { + namp: "salty".to_string(), + pwdp: "$6$saltSaltSALTNaCl$xe5LZGFlek53CPFwe2piIPeSiGANoYoinUDuQW0qydXyvoYKVmL2WRLqDZHXkbnpoAHqL0yali94NRcURtEaoQ".to_string(), + lstchg: Some(18912), + min: Some(0), + max: Some(99999), + warn: Some(7), + inact: None, + expire: None, + flag: "".to_string(), + } + } + + #[test] + fn test_parse_lines() { + let content = r#" +root:*:18912:0:99999:7::: +daemon:*:18474:0:99999:7::: + +salty:$6$saltSaltSALTNaCl$xe5LZGFlek53CPFwe2piIPeSiGANoYoinUDuQW0qydXyvoYKVmL2WRLqDZHXkbnpoAHqL0yali94NRcURtEaoQ:18912:0:99999:7::: + +# Dummy comment +systemd-coredump:!!::::::: +systemd-resolve:!!::::::: +rngd:!!::::::: +"#; + + let input = BufReader::new(Cursor::new(content)); + let entries = parse_shadow_content(input).unwrap(); + assert_eq!(entries.len(), 6); + assert_eq!(entries[2], mock_shadow_entry()); + } + + #[test] + fn test_write_entry() { + let entry = mock_shadow_entry(); + let expected = b"salty:$6$saltSaltSALTNaCl$xe5LZGFlek53CPFwe2piIPeSiGANoYoinUDuQW0qydXyvoYKVmL2WRLqDZHXkbnpoAHqL0yali94NRcURtEaoQ:18912:0:99999:7:::\n"; + let mut buf = Vec::new(); + entry.to_writer(&mut buf).unwrap(); + assert_eq!(&buf, expected); + } +} From 26f5ef80e9470c083004dd5beb96b1658fd9345f Mon Sep 17 00:00:00 2001 From: Benno Rice Date: Fri, 1 Oct 2021 11:52:45 +1000 Subject: [PATCH 2/2] compose: remove lstchg values from [/usr]/etc/shadow The format of /etc/shadow contains several fields relating to password aging. One of these, `lstchg`, contains a timestamp that represents the epoch day of the last password change. Tools that don't respect SOURCE_DATE_EPOCH, which includes systemd's `sysusers` tool, will set this to a value based on the current time of the build. This causes a lack of reproducibility. The `lstchg` field can be safely made empty. This disables the password aging features. This change rewrites /etc/shadow to remove all `lstchg` values. This removal could be made conditional on the entry containing an encrypted password field indicating that the account is either locked or otherwise restricted from using passwords, such as `*` or anything starting with `!`. --- rust/src/bwrap.rs | 3 ++- rust/src/lib.rs | 1 + rust/src/normalization.rs | 47 +++++++++++++++++++++++++++++++++++++++ rust/src/passwd.rs | 6 +++++ 4 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 rust/src/normalization.rs diff --git a/rust/src/bwrap.rs b/rust/src/bwrap.rs index 441b41ef08..1029dcdd94 100644 --- a/rust/src/bwrap.rs +++ b/rust/src/bwrap.rs @@ -4,6 +4,7 @@ use crate::cxxrsutil::*; use crate::ffi::BubblewrapMutability; +use crate::normalization; use anyhow::{Context, Result}; use fn_error_context::context; use openat_ext::OpenatDirExt; @@ -175,7 +176,7 @@ impl Bubblewrap { let path_var = Path::new(PATH_VAR); launcher.set_environ(&[lang_var, path_var]); - if let Ok(source_date_epoch) = std::env::var("SOURCE_DATE_EPOCH") { + if let Some(source_date_epoch) = normalization::source_date_epoch_raw() { launcher.setenv("SOURCE_DATE_EPOCH", source_date_epoch, true); } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 1fd0faf3c6..837db99ee1 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -651,6 +651,7 @@ pub(crate) use self::live::*; pub mod modularity; pub(crate) use self::modularity::*; mod nameservice; +mod normalization; mod origin; pub(crate) use self::origin::*; mod passwd; diff --git a/rust/src/normalization.rs b/rust/src/normalization.rs new file mode 100644 index 0000000000..97068a8f66 --- /dev/null +++ b/rust/src/normalization.rs @@ -0,0 +1,47 @@ +//! Functions for normalising various parts of the build. +//! The general goal is for the same input to generate the +//! same ostree commit hash each time. + +// Copyright (C) 2021 Oracle and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::nameservice::shadow::parse_shadow_content; +use anyhow::Result; +use fn_error_context::context; +use lazy_static::lazy_static; +use std::io::{BufReader, Seek, SeekFrom}; + +lazy_static! { + static ref SOURCE_DATE_EPOCH_RAW: Option = std::env::var("SOURCE_DATE_EPOCH").ok(); + static ref SOURCE_DATE_EPOCH: Option = SOURCE_DATE_EPOCH_RAW + .as_ref() + .map(|s| s.parse::().expect("bad number in SOURCE_DATE_EPOCH")); +} + +pub(crate) fn source_date_epoch_raw() -> Option<&'static str> { + SOURCE_DATE_EPOCH_RAW.as_ref().map(|s| s.as_str()) +} + +#[context("Rewriting /etc/shadow to remove lstchg field")] +pub(crate) fn normalize_etc_shadow(rootfs: &openat::Dir) -> Result<()> { + // Read in existing entries. + let mut shadow = rootfs.update_file("usr/etc/shadow", 0o400)?; + let entries = parse_shadow_content(BufReader::new(&mut shadow))?; + + // Go back to the start and truncate the file. + shadow.seek(SeekFrom::Start(0))?; + shadow.set_len(0)?; + + for mut entry in entries { + // Entries starting with `!` or `*` indicate accounts that are + // either locked or not using passwords. The last password + // change value can be safely blanked for these. + if entry.pwdp.starts_with('!') || entry.pwdp.starts_with('*') { + entry.lstchg = None; + } + + entry.to_writer(&mut shadow)?; + } + + Ok(()) +} diff --git a/rust/src/passwd.rs b/rust/src/passwd.rs index 2f741bec57..9239cfbbb9 100644 --- a/rust/src/passwd.rs +++ b/rust/src/passwd.rs @@ -5,6 +5,7 @@ use crate::cxxrsutil::*; use crate::ffiutil; use crate::nameservice; +use crate::normalization; use crate::treefile::{CheckGroups, CheckPasswd, Treefile}; use anyhow::{anyhow, Context, Result}; use fn_error_context::context; @@ -68,6 +69,11 @@ pub fn complete_rpm_layering(rootfs_dfd: i32) -> CxxResult<()> { let rootfs = ffiutil::ffi_view_openat_dir(rootfs_dfd); complete_pwgrp(&rootfs)?; + // /etc/shadow ends up with a timestamp in it thanks to the `lstchg` + // field. This can be made empty safely, especially for accounts that + // are locked. + normalization::normalize_etc_shadow(&rootfs)?; + Ok(()) }