mirror of
https://github.com/uutils/shadow.git
synced 2026-10-16 02:48:14 -07:00
0.4.0
18
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e33793cc74 |
release: 0.4.0 (#289)
gpasswd is a fifteenth tool, so a minor bump rather than a patch. Also corrects the three static-musl sections for it. gpasswd is setuid and authenticates a group administrator against the group password through crypt(3) rather than PAM, so unlike chfn and chsh it keeps working in the musl build; it resolves the caller through getpwuid_r, so it joins the five tools that do not see directory users there; and it can neither verify nor set a yescrypt group password. The counts around those paragraphs were left at fourteen tools. The README's multicall paragraph still named four setuid applets. |
||
|
|
8361bf512b |
gpasswd: added implementation of gpasswd (#282)
* gpasswd: added implementation of gpasswd
* gpasswd: rebase onto main and port to the current APIs
Rebases jlesage's implementation onto a main that moved a long way under
it, and carries it onto the interfaces that landed since the branch was
cut. The tool's behaviour is unchanged and still matches GNU on every
path probed.
What the rebase had to resolve:
- shadow-rs.spec and debian/ were revived by the merge; both were
deleted because neither could build.
- The standalone dispatch() and hand-written --list predate the single
applets() table, which now drives both. gpasswd joins the table.
- The five shadow-core parser features it asked for no longer exist.
- The fourteen [[test]] targets are one; test_gpasswd joins it.
- README, Makefile and PLATFORM-SUPPORT described the pre-#239
privilege model, in which every applet ran with euid 0.
What changed beyond the rebase:
- gpasswd keeps its privilege in the multicall build. It is setuid in
GNU and the branch marks it setuid for the per-tool install, but
SETUID_APPLETS still listed four names, so the multicall build would
have dropped privilege and no group administrator could have used it
-- silently.
- Comments in /etc/group survive. It read entries only and wrote them
back, which erases every comment, blank line and NIS compatibility
line in the file. The locked-file transaction keeps them, and commits
group and gshadow together so a bad value cannot leave the pair
disagreeing.
- The duplicated EchoGuard and password reader are gone, replaced by
shadow_core::tty, which blocks interrupts for the read so Ctrl-C at
the prompt cannot leave the terminal with echo off.
- The print macros are replaced. They panic when the stream is closed,
which a setuid-root tool must not do part way through a change.
These were the only five in the repository and no lint caught them,
so clippy::print_stdout and print_stderr are now denied
workspace-wide, with the test crate exempt.
The GNU comparison suite gains gpasswd: the unknown-group, unknown-user
and non-member exit codes, all three of which match.
CONTRIBUTING's licence list also said ISC, BSD-2-Clause, CC0-1.0, Zlib
and MPL-2.0 were acceptable, which stopped being true when deny.toml was
narrowed to the graph. It now says what deny.toml enforces.
---------
Co-authored-by: Pierre Warnier <pierre@warnier.net>
|
||
|
|
e09e4bccac |
release: build the glibc archives on 22.04, and drop a test that could hang (#284)
Two defects in what shipped yesterday. 0.3.0's glibc archives require GLIBC_2.39 and will not start on Debian 12, Ubuntu 22.04 or RHEL 9 -- all of which ran 0.2.2. Pinning the release runner to Ubuntu 24.04 in #272 let Rust's std link pidfd_spawnp and pidfd_getpid; those two are the only symbols in the binary above 2.34. The build now happens inside an ubuntu:22.04 container on the same runner, which is where the container support in dist earns its keep: the runner stays current while the floor goes back to GLIBC_2.34, exactly what 0.2.2 required. Verified by building in that container and dumping the versioned symbols, and by running the result on a glibc 2.35 host where the 0.3.0 archive refuses to start. The static musl archive was never affected. The PAM test for an unconfigured service could hang rather than fail. On Debian /etc/pam.d/other falls through to common-auth, which is pam_unix, so it prompts: the conversation then blocks reading stdin. CI never saw it because its stdin is not a terminal, but `make check` on a developer's machine hung indefinitely. The test is removed with a note saying why, and the permit and deny tests -- which are the ones that matter and which never converse -- stay. |
||
|
|
9cd5eef4ac |
Install layout, aarch64 archives, pinned images, and a pre-push hook worth keeping (#272)
* hooks: stop reproducing CI in pre-push The hook ran fmt, clippy and the test suite on Debian, Alpine and Fedora in series, on every push. That is several minutes for a push that may carry nothing: pushing the 0.2.2 tag moved one object and still blocked for over two minutes. The predictable result was that every push in the last batch of work used --no-verify, and a hook that is always skipped protects nothing while still costing time whenever someone forgets to skip it. It now runs the workspace tests once, on Debian, and returns immediately for a push that carries no new commits -- a tag, a branch deletion, or a branch the remote already has. git hands the hook one line per ref on stdin; the ranges it names are asked for a single commit, and if there are none there is nothing to test. Everything the hook dropped is already a required check on the pull request, running in parallel with retries: fmt, clippy and the tests with and without pam, MSRV, cargo-deny, the static musl archive, the three-distro matrix as root, and the end-to-end deployment image. Closes #231. * install: put the user-facing tools in bin, not sbin All fourteen binaries went to $(PREFIX)/sbin, which is not on a normal user's PATH: passwd was 'command not found' for exactly the people it is setuid for. GNU shadow splits them, verified against the Debian package -- passwd, chage, chfn, chsh and newgrp in /usr/bin, the other nine in /usr/sbin. Both install targets do the same now, and the multicall symlinks in bin point at the absolute path of the binary in sbin. chage is in bin for 'chage -l', the one mode a user may run on their own account. The deployment suite follows each tool to the directory it belongs in, and asserts that all five are reachable by name for an unprivileged user, which is the property the split exists for. make test also runs the suite with pam now. install ships binaries built with it, so a run without it covered a build nobody ships. * docker: pin the base images, drop the unused nextest download rust:latest, rust:alpine and fedora:latest meant a new toolchain or distro release landed on main with no pull request and no signal, and clippy gains lints between Rust releases. They are pinned to what the images resolve to today -- rust 1.98 on Trixie and Alpine 3.23, Fedora 44 -- and Renovate's Dockerfile manager proposes the bumps, so a toolchain change arrives as a reviewable PR that CI has run. Each image also downloaded cargo-nextest from get.nexte.st/latest on every build: unpinned, unverified, and never invoked -- nothing in the repository runs 'cargo nextest'. cargo-deny, which is used, keeps its pinned version and checksum. * deny: allow exactly the licences the graph uses The allow list carried ISC, BSD-2-Clause, CC0-1.0 and Zlib, none of which appears anywhere in the dependency graph. A list wider than the graph means a crate arriving under a new licence passes silently; the point of the check is that such an arrival is a decision someone records. It is now MIT, Apache-2.0, BSD-3-Clause (subtle, for the constant-time comparison in shadow-core::crypt) and Unicode-3.0 (tinystr and unicode-ident, via uucore's fluent stack), taken from 'cargo deny --all-features list'. Two crates carry a copyleft option in a dual licence and are accepted under the other half; that is now written down rather than left for the next reader to work out. All four checks pass with the narrower list. * ci: run the advisory workflow on a schedule only It also ran on every push touching a manifest or the lockfile, where the Cargo Deny job in ci.yml had already checked the same advisory database alongside licences and bans. Two tools reporting the same finding on the same commit is noise. What the pull request job cannot do is notice an advisory published against code nobody has touched, which is the reason to keep a scheduled run. That is now all this workflow does, plus manual dispatch. * packaging: remove the RPM spec and debian directory Neither can build. debian/ has no changelog at all, so dpkg-buildpackage stops before it starts: dpkg-buildpackage: error: cannot open file debian/changelog and shadow-rs.spec pins version 0.2.0 against a 0.2.2 workspace, points at the pre-transfer repository URL, and lists a %files layout that 'make install' does not produce -- it names the multicall binary, which only install-multicall builds, and puts every tool in sbin, which is no longer where half of them go. Shipping packaging that claims to work and does not is worse than shipping none, and this would have produced a package declaring 'Provides: passwd, login' on a system whose real passwd it replaced. Releases are the archives dist publishes; a distribution packager writes these against their own policy anyway. * release: publish an aarch64 archive The pipeline shipped x86_64 only. arm64 servers and Apple-silicon containers are common enough that a missing archive is a real gap. It builds on a native arm64 runner rather than cross-compiling, which is what makes it straightforward: shadow-core::crypt links crypt(3) and the pam feature links libpam, so a cross build would need an arm64 sysroot carrying both, while a native runner installs them from apt like any other build. dist has picked a native runner for aarch64 since 0.30. Both runners are pinned to 24.04. dist's defaults are ubuntu-22.04 and ubuntu-22.04-arm, and 22.04 enters deprecation on 2026-09-17 with the image unsupported from 2027-04-17; the pin keeps the two archives on the same generation and off an image that is about to start failing. The higher runner raises the glibc floor for the dynamically linked archives, which is what the static musl archive is there for. release.yml is regenerated by 'dist generate'; 'dist plan' now lists three archives and resolves ubuntu-24.04-arm for the arm64 job. Closes #222. |
||
|
|
4e539357a1 |
shadow-core: remove duplicated lookups, fix login.defs radices, allocate around NSS (#271)
* shadow-core: delete the duplicated NSS entry type and the unused surface process::PwEntry was a field-by-field twin of passwd::PasswdEntry, copied across at every NSS lookup. getpwuid now returns the parser's own type: an NSS entry and a file entry describe the same account, and a second struct for it only bought a copy per call. lookup_username goes with it, and lookup_username_by_uid becomes one line. The tty module had its own signal-mask guard beside hardening's SignalBlocker, and the PAM conversation had its own controlling-terminal lookup. There is now one of each. error::ShadowResult and ShadowError::Permission had no users at all, and display_message carried an _is_error parameter it never read. harden_process returned a sanitized environment that every one of its thirteen callers discarded, which read as though the process environment had been dealt with. It has not been and is not: the in-process PAM and NSS modules still see what the caller set. The function no longer claims otherwise, and the callers no longer bind a value to throw away. Adds gid_exists, the group-side companion to getpwuid, for the allocator. * shadow-core: read login.defs numbers in the radices the man page documents login.defs(5) writes numbers 'in decimal, or in octal with a leading 0, or in hexadecimal with a leading 0x'. get_i64 parsed decimal only, which is not a rejection but a silent misreading: UID_MIN 01000 means 512 and came back as 1000, so useradd would allocate from a range the administrator had reserved. UMASK 022 and HOME_MODE 0750 have the same shape and will be read the day they are honoured. LoginDefs also gains Default, an empty configuration, which is what a caller wants when the file is missing: every lookup returns None and the tool falls back to its own default. * useradd, groupadd: never allocate an ID the name service already knows /etc/passwd is not the only source of accounts. An ID that is free in the file may belong to an LDAP, SSSD or systemd-homed user, and handing it out gives two accounts one ID -- which the kernel cannot tell apart, so the new local user ends up owning the directory user's files. Allocation now asks NSS as well as the file. Which sources count is an explicit Scope rather than a hidden default, because a --prefix or --root run manages another system's files: the name service running here describes this one, so asking it would skip IDs that are free on the target and claim none that are taken there. SysRoot::is_prefixed decides. uid_range and gid_range were the same function twice over different keys and are now one, the system ranges being the same keys with a SYS_ prefix. The allocation tests moved from next_uid to next_free. Once next_uid consults NSS its answer depends on which accounts exist on the machine running the test, and on a typical container 1000 and 1001 do. The policy is tested where it lives, and two tests assert the property that actually matters against the live system: an allocated ID is in range, not in the entries given, and unknown to NSS. * audit: write to /dev/log instead of forking logger for every event Each account event spawned /usr/bin/logger and waited for it, to write a few dozen bytes to a socket. A setuid-root tool should spawn as little as it can: the fork inherited the process state, depended on a binary being installed at a fixed path, and blocked the caller. The record now goes straight to /dev/log as an RFC 3164 datagram, and carries the terminal it came from instead of a literal '?'. Failure stays silent in every direction. A machine with no syslog daemon is a normal machine, and a record that cannot be written must not fail the operation that produced it. The deployment suite runs rsyslogd and greps the log for the creation and deletion records, so this is covered rather than assumed. Also corrects atomic's claim that its temp-file names are unique 'across threads' in a single-threaded crate; what they separate is successive writes within one process, which usermod does four of in a row. * docs: record the hardening changes and what --root really does sysroot's module documentation said every tool offering --root performs a real chroot. Five of the thirteen do; the other eight fold it into the same resolver --prefix uses, so an absolute path read out of a record resolves against / rather than the target. That is a real divergence worth its own change and is filed as #270; the documentation now says which tools do which and points there. * cargo: only gate what changes linkage; stop forcing applets in with pam Five of shadow-core's features -- shadow, group, gshadow, login-defs, subid -- gated modules that pull in no dependency. Gating them bought nothing and cost real confusion: cargo test -p shadow-core compiled a fraction of the crate and reported a pass for it. It runs 181 tests now where it ran 20, without a single one being written. uid_alloc loses its cfg(all(group, login-defs)) with them. What remains behind a feature is what actually changes the build: pam (links libpam), crypt (links libcrypt on glibc, and now pulls subtle, which nothing else uses) and landlock. In the root manifest, pam = ["passwd/pam", ...] force-enabled the three applets that can use PAM, so asking for PAM in a build that had deliberately left them out dragged them back in. The optional-dependency form pam = ["passwd?/pam", ...] applies the feature if the applet is there and does nothing if it is not. The feat_* aliases and the feat_phase* groups are gone. Every one was a rename of a feature cargo already provides -- feat_passwd = ["passwd"] -- and nothing outside the manifest had referred to them since the phases they were named for finished. Verified against all four delivery paths: make build and make build-multicall still link libpam, the static musl archive still builds and reports all fourteen applets, and a --no-default-features --features pam build now produces a binary with no applets at all and no warnings, which is the case the ? was added for. |
||
|
|
6442faf970 |
chage/passwd/chpasswd/newgrp: aging bounds, input rules and newgrp - (#269)
* chage, passwd: one date module, GNU's -l output, and exit 1 for an unknown login
The Hinnant calendar algorithms were written out three times. They now
live in shadow-core::date, which also gained the inverse direction and
the two display forms, with the arithmetic checked.
That matters here: 'chage -l' adds lastchg + max + inactive, three values
read from a file anyone who can write /etc/shadow chooses. Plain addition
wraps in release builds and panics in debug ones. The sum is now bounded
and an unrepresentable date shows as 'never', which is what GNU displays
for a field it cannot make sense of.
Two -l rules were wrong, both verified against GNU shadow 4.17 in the
Debian image:
- A last change of 0 is passwd -e's 'must change at next login' marker,
not a date. GNU prints 'password must be changed' on all three
password lines; we printed 1970 dates for the two derived ones.
- Expiry is disabled at a maximum age of 10000 days, not 99999. -M 9999
shows a date, -M 10000 shows never.
Both tools also exited the wrong code for an unknown login: chage 15,
which chage(1) reserves for 'can't find the shadow password file', and
passwd 3, which is for an unexpected failure. GNU exits 1 for both.
chage gains --prefix, so its behaviour can be exercised without touching
the live files. The display tests, which asserted only that a string
contained '1970' and that printing did not panic, now assert the exact
output line by line.
* chpasswd: honour ENCRYPT_METHOD, reject the flag pairs the man page forbids
The default hashing scheme was hard-coded SHA-512 while ENCRYPT_METHOD in
login.defs is how a distribution picks one for the whole system. On
Debian, which sets YESCRYPT, every password set through this tool was
weaker than the one the same user would get from passwd. -c still
overrides, and an unreadable or unusable configuration falls back to
SHA-512 rather than refusing to set a password.
Two flag combinations chpasswd(8) documents as errors were accepted and
quietly did nothing: -s without -c (a rounds count with no scheme to
apply it to) and -e with -c (a scheme for input that is already hashed).
Both are now usage errors, and usage errors exit 2 as the man page says.
The batch is also resolved before it is applied: an unknown login in the
middle of a list used to leave the first half written. Membership is
looked up through an index rather than a scan per pair, which took the
critical section -- held with signals blocked -- out of quadratic time.
Also drops the setuid(0) before the write, which raised the real uid and
made caller_is_root() answer true for the rest of the process, and owns
each input line in a zeroizing buffer: every one of them holds a
password.
Adds --prefix.
* newgrp: implement '-', and stop making every shell a login shell
newgrp(1) is 'newgrp [-] [group]'. The dash was not implemented, so
'newgrp - docker' looked for a group called '-' and reported it missing.
Worse, every invocation execd a login shell (argv[0] = -sh) whether or
not the dash was given. newgrp(1) says the current environment and
working directory are kept without it, so the profile files were being
re-read on each 'newgrp docker', in an environment they had already been
applied to.
Now: without '-', a non-login shell inheriting the environment; with '-',
a login shell, chdir to the home directory, and an environment built
explicitly (HOME, SHELL, USER, LOGNAME, PATH, plus TERM and the locale
variables a real login inherits from the terminal). That needs execve,
added to shadow-core::process, because std::env::set_var is unsafe and
process-global in edition 2024.
Membership and password sources were checked against GNU shadow 4.17
rather than changed on assumption, with four groups covering the cases:
membership comes from /etc/group and the password from /etc/gshadow, and
a user listed only in gshadow's member list is not admitted. That is what
this already did, so it stays.
The duplicated EchoGuard is gone; the prompt goes through the shared
terminal helper, which blocks interrupts for the read. The env
sanitization call that computed a value and dropped it is gone too, with
a note on why newgrp deliberately does not sanitize.
The parse tests now exercise the operand grammar instead of asserting
that clap stores a string.
* shadow-core: scrub the crypt(3) password copy, identify the caller to PAM
crypt(3) takes C strings, so a copy of the password has to exist.
CString::new made that copy on the heap and freed it without zeroing:
the caller's Zeroizing<String> scrubs its own buffer and never learns
about this one. The copy is now owned in a zeroizing buffer whose
capacity is reserved exactly, since a reallocation would leave a first
copy behind unzeroed.
The PAM response cleanup zeroed with ptr::write_bytes immediately before
free(). That is a dead store, and the optimiser is entitled to delete it,
leaving the password in the heap. Volatile writes and a compiler fence
cannot be elided.
PAM_TTY and PAM_RUSER were never set, so pam_unix and pam_faillock logged
tty=? with no requesting user, which makes their records useless for
tracing a failed authentication and stops pam_faillock's per-tty handling
from working at all. Both are now set when the handle is created, best
effort: a tool with no controlling terminal must still be able to
authenticate.
* docs: rewrite the chage, chpasswd and newgrp man pages; cover the rules end to end
None of the three explained the rules that actually decide what happens:
what a last change of 0 means in the chage output and where the 'never'
threshold sits, that chpasswd takes its scheme from ENCRYPT_METHOD and
which flag pairs are errors, or what newgrp's dash does and where
membership and the group password are each read from. All three now carry
value restrictions, configuration, exit status, files and worked
examples.
The deployment suite gains thirty-five assertions against the real
binaries: the three 'password must be changed' lines, a date at -M 9999
and 'never' at -M 10000, -1 clearing a field while -5 is refused, exit 1
for an unknown login from both chage and passwd, chpasswd following
ENCRYPT_METHOD through two changes and -c overriding it, the two usage
errors, --prefix on both tools leaving the live files untouched, and
newgrp keeping the environment without '-' and reinitializing it with.
|
||
|
|
b6c318d1d5 |
chfn/chsh: interactive mode, and fix a setuid path oracle (#268)
* shadow-core: add a shared terminal helper
Reading a line and reading a password were open-coded in pam.rs, and
every prompt outside a PAM conversation had no helper at all.
tty.rs holds all of it: prompt_line, read_password and read_stdin_line,
with the EchoGuard that restores the terminal. Ctrl-C at a password
prompt used to leave echo disabled -- the signal terminates the process
without unwinding, so the guard's destructor never ran. The read now
blocks SIGINT, SIGQUIT and SIGTSTP for its duration, which is what
readpassphrase(3) does, and restores the mask on the way out.
The prompt prefers /dev/tty and falls back to stderr and stdin when
there is no controlling terminal (ENXIO/ENODEV), the case that broke
password changes under su -c.
* chfn: prompt for fields, and check values before asking for a password
chfn(1) with no field option prompts for each field with its current
value; ours refused with 'no flags specified'. It now prompts, and an
empty answer keeps the field.
Three fixes come with it:
- CHFN_RESTRICT is enforced on the values, not on which options were
passed. Interactive input went through unrestricted, and a field the
caller may not change is now not prompted for at all.
- Values are validated before the PAM conversation, so a rejected
value no longer costs the caller a password prompt.
- mutate_passwd no longer calls setuid(0). euid 0 is all the lock and
the atomic write need; raising the real uid made caller_is_root(),
which is deliberately real-uid based, answer true for everyone.
The five sub-fields are now one Changes value with the collect,
validate, permission and apply steps on it, which is what let uumain
shrink rather than grow.
* chsh: prompt for the shell, and stop leaking path existence
Three changes to the self-service path.
chsh(1) with no -s prompts for the new shell with the current one in
brackets; ours refused with 'no shell specified'. Pressing ENTER, or
repeating the current value, is now a successful no-op.
An empty shell is accepted. passwd(5) gives the field no meaning of its
own and login falls back to /bin/sh, so -s '' selects the system default
rather than naming a program that does not exist.
The order of the two checks on a shell now matters and is documented.
chsh is setuid-root, so Path::exists answers with root's view of the
filesystem: testing existence first made the tool an oracle for paths
the caller cannot stat, one probe at a time --
chsh -s /root/.ssh/id_ed25519
answering 'is not listed in /etc/shells' for a file that exists and
'does not exist' for one that does not. For a non-root caller the
membership test now runs first and discloses nothing, /etc/shells being
world-readable. Root still bypasses the list but not existence.
Validation also moved ahead of the PAM conversation, so a refused shell
no longer costs a password prompt, and mutate_passwd no longer calls
setuid(0): euid 0 is enough, and raising the real uid made
caller_is_root() answer true for every caller.
* docs: rewrite the chfn and chsh man pages; cover self-service end to end
The man pages documented neither interactive mode, nor CHFN_RESTRICT,
nor the restricted-account rule, nor what an empty shell field means,
and chfn's still said a field option was mandatory. Both now carry
permissions, value restrictions, configuration, files and worked
examples, and chsh explains why the two shell checks run in the order
they do.
The deployment suite gains eighteen assertions against the real setuid
binaries: interactive chfn and chsh through PAM, an empty answer as a
no-op, CHFN_RESTRICT enforced on interactive input, the empty shell
field, and the oracle itself -- an existing and a missing path under
/root must give a non-root caller the same answer, with no password
prompt before it.
|
||
|
|
d14c7e3746 |
docs: bring the hardening roadmap up to date (#261)
The roadmap still described the state before this round of work: it did not mention that rewrites preserve owner, group and label, that the account files are guarded by /etc/.pwd.lock as well, that fields are validated before they can reach a record, that the multicall binary drops privilege for applets that should not have it, or that Landlock is deliberately kept off the PAM path. It also listed the privilege drop during PAM without the distinction that matters: chfn and chsh drop for authentication, passwd must not, because pam_unix needs euid 0 to rewrite the shadow file. The entries are grouped by what they protect rather than by the order they were added, and the three known gaps -- terminal echo after an interrupt, seccomp, and the process's own environment -- are listed with the issues that track them. |
||
|
|
52c2debe8b |
release: publish a labelled static musl archive (#224) (#237)
* shadow-core: link libcrypt only where it exists; refuse pam on static musl musl implements crypt(3) inside libc and ships no libcrypt. Asking for -lcrypt there made a cross-build for x86_64-unknown-linux-musl fall through to the host's glibc-built libxcrypt and fail on glibc-only symbols; Alpine only worked because musl-dev ships an empty libcrypt.a. Linux-PAM loads its modules with dlopen, which static musl does not have, and no distribution ships libpam.a. Building the pam feature into a crt-static musl binary is therefore a configuration error; reject it at compile time instead of producing a binary whose authentication path can never work. The crypt tests stated the musl behaviour wrongly (musl does honour rounds=; it lacks yescrypt). They now assert the real behaviour per target instead of skipping. * release: publish a static musl archive next to the glibc one dist cannot vary cargo features per target and pam must be off for musl, so the musl archive is built by 'make dist-musl' and attached by dist as an extra artifact of the same release. The target enforces the lockfile, checks the binary has no DT_NEEDED entry and packs it with the licence, changelog, README and PLATFORM-SUPPORT.md so the trade-off travels with the binary. The archive name carries a -static suffix so it is never mistaken for the glibc build. CI runs the same make target on every pull request, smoke-tests the archive, runs shadow-core's unit tests against static musl and checks that the pam feature is rejected there, so a tag is never the first time the archive is built. * docs: describe both release archives and what the static musl one gives up The README now lists the two archives side by side with the one question a reader has to answer (is PAM, NSS or yescrypt needed on this host?), and PLATFORM-SUPPORT.md is rewritten around the archive that now exists: how it is built, which CI job guards it, and the exact effect of each gap. CONTRIBUTING listed two unsafe-permitted modules; there are three. * chore: use the canonical uutils/shadow repository URL The repository is uutils/shadow; the old shadow-rs name is a redirect. The README badges already used the new name while package metadata, the --help footer and SECURITY.md still pointed at the old one. |
||
|
|
9f197e0e50 |
chfn, chsh: authenticate the caller before applying a change (#235)
* shadow-core: move PrivDrop out of passwd The RAII guard that drops the effective UID for the PAM conversation lived inside uu_passwd, but it is process-level privilege handling that any setuid tool needs. Move it next to the seteuid wrapper it calls. Two changes while moving it: the error type becomes io::Result rather than PasswdError so other tools can use it, and the failure-to-restore message goes through uucore::show_error! instead of a hardcoded "passwd:" prefix, so it names whichever tool is running. * chfn, chsh: authenticate the caller before applying a change Both tools are installed setuid-root. They correctly restrict a non-root caller to their own account, checked against the real UID — but they never verify who is asking. Anyone with access to an unlocked session could change that user's login shell or GECOS field, and changing a login shell is a foothold. GNU prompts for the password here, and distributions already ship the service definitions we were ignoring: /etc/pam.d/chfn and /etc/pam.d/chsh both let root through with pam_rootok and fall back to common-auth for everyone else. CONTRIBUTING treats a difference from GNU as a bug, so this is one. Authenticate through PAM for non-root callers, reusing the pattern passwd already uses: drop to the real UID for the conversation so modules see the actual caller, then authenticate and check account validity. Without the pam feature there is no way to verify anyone, so the tools refuse rather than apply an unauthenticated change. That is a behaviour change for that build only, and the safe direction for a setuid binary. It also means every install path must enable the feature — 'make build' only enabled it for passwd, which would have shipped two setuid tools that reject all non-root use. Fixed, and the end-to-end assertion now covers all three tools instead of passwd alone. CI gained 'cargo test --features pam': the feature changes behaviour rather than only adding code, so both configurations need testing. |
||
|
|
cec6872425 |
docs: document platform support and what a musl build loses (#232)
The README justified the glibc-only release with a single sentence about PAM. Two further gaps turned up while evaluating musl properly, and both matter for account-management tools: - no NSS. getpwuid_r backs current_username(), used by passwd, chfn, chsh, chage and newgrp. glibc answers through its NSS modules and sees LDAP/SSSD/AD users; musl has no module system and reads /etc/passwd, so directory users are invisible. - no yescrypt. musl's crypt(3) covers DES, MD5, SHA-256, SHA-512 with rounds= and bcrypt, byte-identical to libxcrypt, but not $y$ — which is the default hash on Debian 12+ and Ubuntu 24.04. Add docs/PLATFORM-SUPPORT.md covering release targets, the test matrix, the three musl gaps, how to build for musl anyway, and why libxcrypt (LGPL-2.1+) cannot fill the crypt gap. Point README, CONTRIBUTING and the dist config at it. |
||
|
|
d31c689b3f |
cli: identify shadow-rs as part of uutils (#161) (#162)
* cli: identify shadow-rs as part of uutils in --help and --version Per #161, every tool now reports `(uutils shadow-rs) <ver>` in its --version output and links to the project in --help via a shared shadow_core::cli module. While auditing the same uu_app() blocks for residual similarity to GNU shadow-utils man-page phrasing, the medium-confidence findings from a clean-room similarity check were also rewritten: 118 clap .help() and .about() strings across all 14 tools now use phrasing derived from the in-tree behavior of each flag, not from upstream documentation. Output strings that scripts grep for (chage -l column headings, pwck/grpck diagnostic lines) were left intentionally untouched, since drop-in compatibility is part of the project's contract. - shadow-core: new cli module with VERSION and AFTER_HELP constants - 14 tools: wire .version() and .after_help() in every uu_app() - 14 tools: rewrite 118 flag-help and .about() strings - shadow-rs multicall: handle --help/--version/-h/-V with project ID - fmt + clippy + tests pass on debian, alpine, fedora (574 passed, 0 failed) Closes #161 * docs: record clean-room audit results (2026-05-04) Adds an evidence document for the clean-room similarity audit that prompted the flag-help rewrite in the previous commit. Headline: 0 verbatim matches in 486 user-facing strings. The audit protocol is reproducible and described in the doc, including the schema-constrained output and post-run command-log scan that ensure no upstream string crosses back into the workspace. * cli: correct help/version text flagged in review - multicall --version/--help now use the shared (uutils shadow-rs) identifier instead of (uutils), matching the tool crates; clarify the symlink invocation line in the help banner. - --root help for the SysRoot tools (userdel, useradd, usermod, groupadd, groupdel, groupmod, pwck, grpck) no longer claims a chroot(2): these only resolve system files under a path prefix. Reword to match, and rename the metavar CHROOT_DIR -> ROOT_DIR. - userdel --force: documented as accepted-for-compatibility (no effect) rather than promising behavior the code does not implement. - groupadd -K: scope the help to the GID-range keys actually honored. - passwd --repository: note only the local files backend is supported. - passwd --stdin: reads password input generally (auth may consume the current password too), not just the new password. - chpasswd -m: state that MD5 is rejected, not supported. - usermod --append: restore the note that -a is only effective with -G. - clean-room audit doc: cite CONTRIBUTING.md (the in-tree policy) instead of the gitignored CLAUDE.md. |
||
|
|
42d701e25f |
docs: backfill CHANGELOG and SECURITY-HARDENING for review rounds
CHANGELOG [Unreleased]: - Add Security section with review round 2 fixes (PAM zeroization, initgroups, SignalBlocker scoping, UmaskGuard !Send/!Sync, targeted newgrp hardening, atomic_write retry, crypt thread-safety docs, centralized hardening, println panic fix, unwind table suppression) - Update 0.1.0 test count from 460+ to 580+, fuzz targets from 4 to 6 SECURITY-HARDENING.md: - Add 7 implemented items from review round 2 |
||
|
|
0a33f2186d |
review: address 5 Copilot review comments
- Fix function name: sanitize_env() → sanitized_env() / harden_process() - Fix SIGINT handler: was removed, not implemented - Fix user enumeration: early permission check, not constant-time PAM - Fix Rust module paths: shadow-core → shadow_core (crate vs module) |
||
|
|
4259dc7cd0 |
docs: fix stale claims, add missing -p flag to usermod man page
- README: fix passwd flag count (17→16), add Landlock mention, add Hardened goal, update usermod description with -p, fix past tense - CONTRIBUTING: fix unsafe description to match actual workspace policy - CHANGELOG: add [Unreleased] section for post-v0.1.0 changes - OPENBSD-REFERENCE: move all implemented items to correct section - SECURITY-HARDENING: mark all 21 items as implemented - man/usermod: add -p/--password flag documentation |
||
|
|
81d0e1776d |
docs/fixes: man pages, benchmarks, BSD research, Copilot findings
Fixes #56 — 14 man pages in docs/man/ (markdown format). Fixes #67 — 8 Copilot findings fixed: - useradd: proper date validation (Feb 31 rejected) - userdel/usermod: --root wired to SysRoot - usermod: --expiredate validates input - usermod: --login validates name + updates shadow/group - userdel: -f separated from -r behavior - useradd: home dir resolved through SysRoot - skel: preserves directory permissions Fixes #69 — benchmark script (benches/benchmark.sh) Fixes #70 — FreeBSD/NetBSD reference (docs/FREEBSD-NETBSD-REFERENCE.md) 456 tests, zero clippy warnings. |
||
|
|
89bc4b4e6b | docs: update OpenBSD reference with full audit findings (13 findings) | ||
|
|
39ae9647e8 | docs: add OpenBSD security reference and hardening roadmap |