Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

passwd: scrub last-changed timestamp from system accounts in /etc/shadow #3174

Merged
merged 2 commits into from
Oct 15, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion rust/src/bwrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down
1 change: 1 addition & 0 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions rust/src/nameservice/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@

pub(crate) mod group;
pub(crate) mod passwd;
pub(crate) mod shadow;
157 changes: 157 additions & 0 deletions rust/src/nameservice/shadow.rs
Original file line number Diff line number Diff line change
@@ -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,
jeamland marked this conversation as resolved.
Show resolved Hide resolved
/// encrypted password
pub(crate) pwdp: String,
/// days (from Jan 1, 1970) since password was last changed
pub(crate) lstchg: Option<u32>,
/// days before which password may not be changed
pub(crate) min: Option<u32>,
/// days after which password must be changed
pub(crate) max: Option<u32>,
/// days before password is to expire that user is warned of pending password expiration
pub(crate) warn: Option<u32>,
/// days after password expires that account is considered inactive and disabled
pub(crate) inact: Option<u32>,
/// date (in days since Jan 1, 1970) when account will be disabled
pub(crate) expire: Option<u32>,
/// reserved for future use
pub(crate) flag: String,
}

fn u32_or_none(value: &str) -> Result<Option<u32>, std::num::ParseIntError> {
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value.parse()?))
}
}

fn number_or_empty(value: Option<u32>) -> 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<str>) -> Option<Self> {
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<Vec<ShadowEntry>> {
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#"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-note: we did start using indoc recently, so later on it would be a good idea to do a pass on the whole nameservice module and switch it over (to avoid the limitations of raw strings and indentation).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you want to hold this up on that or would that be ok as a separate change?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can go in a followup PR, as it affects all of nameservice anyway. It was mostly a reminder to myself, as I realized that you used some other code as a template, which should be fixed as well.

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);
}
}
47 changes: 47 additions & 0 deletions rust/src/normalization.rs
Original file line number Diff line number Diff line change
@@ -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<String> = std::env::var("SOURCE_DATE_EPOCH").ok();
static ref SOURCE_DATE_EPOCH: Option<i64> = SOURCE_DATE_EPOCH_RAW
.as_ref()
.map(|s| s.parse::<i64>().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<()> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you have some time in the near future before context-switching to something else, I'd really appreciate having a unit test covering the relevant cases for this function as well.

// 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('*') {
jeamland marked this conversation as resolved.
Show resolved Hide resolved
entry.lstchg = None;
}

entry.to_writer(&mut shadow)?;
}

Ok(())
}
6 changes: 6 additions & 0 deletions rust/src/passwd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(())
}

Expand Down