mirror of
https://github.com/uutils/shadow.git
synced 2026-10-16 02:48:14 -07:00
0.5.0
253
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0516184c31 |
release: 0.5.0 (#303)
Fourteen tools since 0.4.0 -- sg, chgpasswd, newusers, vipw, vigr, pwconv, pwunconv, grpconv, grpunconv, login, newuidmap, newgidmap and expiry, plus usermod's subordinate-id options -- so a minor bump. The platform document's static-musl sections are brought up to the twenty-eight tools: login joins the tools PAM takes away, expiry keeps reporting there and only its forced change needs PAM, chage and the two id-map tools never authenticate through PAM, and the root-only set is seventeen. The NSS list grows to eleven with expiry, newuidmap, newgidmap and login's getpwnam. -c YESCRYPT is refused by chgpasswd and newusers as it is by chpasswd. Also the first release whose static archive works setuid for a user, which is recorded under Fixed.0.5.0 |
||
|
|
b94a012a81 |
shadow-core: read AT_EXECFN through getauxval, not a run-time lookup (#302)
The static musl archive aborted every setuid invocation with "argv[0] does not match executed binary". Deployed as `make install-multicall` deploys it, setuid root and reached through symlinks, no user could run passwd, chfn, chsh, newgrp, sg or gpasswd; root, for whom the check does not run, saw nothing wrong. Every static archive published so far, 0.2.2 through 0.4.0, has the defect. The glibc archives never did. The check compared the basename of argv[0] with AT_EXECFN obtained from rustix's `param` module. rustix reaches getauxval(3) by name at run time, through dlsym(3), so that it can tolerate a glibc older than 2.16. In a statically linked binary a symbol is present only if something links it in, and a lookup by name links nothing in: the archive carried no getauxval at all, rustix returned an empty string, and the tool's name never matched it. Instrumenting the abort showed argv0="passwd" against execfn=""; a probe that calls libc::getauxval directly made rustix's own lookup succeed too, the symbol being linked by then. verify_argv0_matches_execfn now calls libc::getauxval(AT_EXECFN) itself, which links the symbol and needs no lookup. glibc 2.34, the floor of the glibc archives, and musl both provide it. A null result still fails closed. Verified on the rebuilt archive in an Alpine container, installed 4755 root with symlinks, as an unprivileged user: passwd -S answers, a spoofed argv[0] (exec -a chsh passwd) is still refused, expiry -c is silent on a fresh account and refuses an expired one, expiry -f points at passwd(1), sg runs its command. The Static musl archive CI job now installs the archive setuid root, links passwd to it and runs passwd -S as nobody. Nothing exercised the static binary in setuid context before: the whole suite runs as root, where the check is skipped. Also silences, with the reason, the libc crate's musl deprecation warnings on the utmp calls: stubs are the documented behaviour of the static build. |
||
|
|
53f56cc49d |
Pin dependencies (#276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> |
||
|
|
1e9f2d04ce |
dist: stop counting the tools in the manifest comment (#301)
The count was 14 when it was written and is 28 now; a number in a comment is one more thing to keep in step for no benefit. |
||
|
|
c6b65c9ef3 |
ci: one verification path per change (#300)
Local gates are where a change is verified before it is pushed; the workflows are the safety net for contributors and the trace check. They were doing three things that only repeated a result already in hand: - a run on every push to main. A squash merge produces exactly the tree of the pull-request tip that just passed, so this rebuilt a known-green tree. Main is now exercised once a night instead, which also catches toolchain and base-image drift between merges, and on demand. - both the old and the new run when a pull request was force-pushed. A concurrency group cancels the superseded one; scheduled runs are never cancelled. - `dist plan` on every pull request (release.yml). It re-parsed the dist manifest, which the tag-triggered pipeline does anyway, and it fired alongside the CI workflow on every update. `pr-run-mode = "skip"` and release.yml regenerated with dist 0.32.0. The static musl archive, the release step that can actually break, is still built by ci.yml on every pull request. Measured before the change: merging nine stacked pull requests fired 48 runs, about 260 runner-minutes, every one on a tree already tested. |
||
|
|
7e28940874 |
expiry, and usermod's subordinate-id options (#299)
* usermod: add and remove subordinate id ranges GNU usermod has -v/--add-subuids, -V/--del-subuids, -w/--add-subgids and -W/--del-subgids; ours had none of them, which came to light while writing the tests for newuidmap. The range helpers live in shadow_core::subid so whatever next touches /etc/subuid reads the same file the same way. The behaviour was established by running the GNU tool: a range the user already holds is not added twice; a removal trims an entry it overlaps, splits one it cuts through the middle of, deletes one it covers, and ignores a range never held; nothing checks other users' entries, since an administrator may share a range on purpose; and an invalid range -- reversed, not two numbers, above the 32-bit ids the kernel maps -- is exit 3 with the GNU wording, before any file is written. Entries are keyed by the login name given on the command line and are not renamed by -l, as GNU's are not: a range is a grant to a name. -V is GNU's letter for the deletion, so clap's own -V for --version had to go; --version stays. * expiry: check and enforce password expiration expiry is the twenty-eighth tool and the last the Scope table lists as in scope. It judges the caller's own /etc/shadow line as login would: silent when all is well, a warning inside warn_days, a forced change through PAM when the password has expired, a refusal when the account has. Shell profiles run it so a session that did not come through login -- a display manager, an ssh key -- still meets the aging policy. The rules are in shadow_core::shadow::Aging, one function over the aging fields with checked arithmetic, since every input is read from a file anyone who can write /etc/shadow chooses. Its order is login's: account expiry first, then a last change of 0 or an aged-out password, then the warning window, with a maximum age of 10000 or more meaning never as chage -l prints it. A locked password is a policy of its own and is not reported. The GNU tool is setgid shadow -- enough to read the file, no more -- and the change goes through PAM as the caller. The per-tool install ships it the same way, a new install class. The single setuid binary keeps euid 0 for it, having nothing narrower to offer. Under --prefix it reports and never changes anything, since a PAM change would act on this system's account of the same name; that is also what lets the required-change path be asserted in the integration tests without a prompt, while the e2e suite drives the prompt itself on a real user under a pty and checks the hash changed. groupmems is not coming. Debian 13 and Fedora do not ship it, and on Ubuntu 24.04 every action fails against PAM's default for want of a /etc/pam.d/groupmems: a tool one distribution ships and none can use. The Scope table says so. Verified: make check clean with the unprivileged run; 935 tests on alpine and 950 on fedora with PAM; 410 e2e assertions against a real install; 59 GNU comparisons; 35 arm64 assertions under emulation. * shadow-core: stop asking nscd to invalidate a shadow cache it never had Every passwd, chage and chpasswd ended with `nscd -i shadow`, and on a host running nscd that printed "nscd: 'shadow' is not a known database" on the user's terminal each time. nscd caches passwd, group, hosts, services and netgroup; the passwd entries it holds carry the `x` placeholder, so a password change leaves nothing of nscd's stale. Only the databases nscd knows are passed to it now. sss_cache still gets -U for a shadow change, which is right: sssd does cache what a password change affects. |
||
|
|
aa987bb390 |
newuidmap, newgidmap: set the id maps of a user namespace (#298)
The setuid helpers that write /proc/<pid>/uid_map and gid_map: what Podman and rootless Docker call for every container they start. The kernel lets an unprivileged process map exactly one id into a namespace it created, its own; everything a container needs beyond that takes the privilege these carry, and they hand it out only within the ranges /etc/subuid and /etc/subgid grant the caller. Fedora ships them in shadow-utils; with these two, every tool the two reference distributions ship from the suite exists here. What is checked before anything is written was established by running the GNU helpers against a real user namespace, never by reading them: the operands must be a target and plain-decimal triples, a zero count is the overflow message; every outside range must sit inside one granted range -- an entry by login name or by numeric uid -- or be the caller's own id mapped once; root is exempt from neither, as newuidmap(1) says; and the target process must belong to the caller by real uid, account uid and the owner of /proc/<pid>, with all three named when they disagree. Overlapping ranges are refused by name; the kernel would refuse them with a bare EINVAL, so the message costs nothing and changes no accepted outcome. The map file is opened through a descriptor held on /proc/<pid>, so a pid recycled between the check and the write cannot redirect it, and the `fd:N` form takes such a descriptor from a caller that did its own checks. It is re-opened through /proc/self/fd rather than adopted, which keeps the tool free of unsafe. The tests do the real thing. A test user creates a namespace with unshare and the helper runs as that user, unprivileged, against it: the own-id mapping lands and the kernel's uid_map reads it back, the second write is refused by the kernel and reported, the fd form works, and every refusal is checked against the same live target. That runs on glibc and musl alike. The Debian image gains the uidmap package so tests/gnu-compat.sh can hold the helpers against GNU's. They take no --help -- any bad command line is the usage text and exit 1 -- so the comparison is on the refusals the two share rather than on help. Verified: make check clean with the unprivileged run; 912 tests on alpine and 934 on fedora with PAM; 393 e2e assertions against a real install, the kernel-visible writes included; 57 GNU comparisons; 34 arm64 assertions under emulation. |
||
|
|
4ac1bdfe32 |
login: begin a session on the system (#297)
* shadow-core: take /etc/.pwd.lock before the per-file lock FileLock took the per-file `.lock` first and `/etc/.pwd.lock` second. Two tools working on different files at once could then meet in the classic shape: one holding passwd.lock and waiting for .pwd.lock, the other holding shadow.lock and waiting for passwd.lock's holder to let go of .pwd.lock. Neither can move; both sit out the fifteen-second timeout; both fail -- on a system that was merely busy. Sampling the lock files every 250 ms while the whole suite ran showed it plainly: a chpasswd held shadow.lock for 28 s and a useradd held passwd, group and gshadow for 14 s, each waiting on the other, and seven tests failed with "timed out" on one lock or the other. Three runs after the change: no failures, and the suite back from 35-50 s to 17 s. .pwd.lock now comes first, the order lckpwdf(3) and the GNU tools use and the only one that cannot deadlock: whoever holds it is the only process asking for per-file locks, so it never waits for anyone who is waiting for it. If the per-file lock then cannot be taken, the .pwd.lock reference is given back, so nothing is held on failure. * useradd: lock the shadow file with the others, and write it first useradd wrote /etc/passwd and the group files in one transaction, then opened /etc/shadow on its own afterwards -- under a comment claiming the first locks were still held, which they were not: commit_all releases them. A shadow lock that timed out at that point left the account half made: a passwd line with no shadow line, which a second useradd then reported as already existing. That is exactly what turned up when the login tests put the suite under load. The shadow file is now locked with passwd, group and gshadow before anything is written, and written first, for the reason pwconv writes shadow first: a failure between the writes must never leave a passwd line whose hash is nowhere. The old write_shadow_entry helper stays for its unit test only. A regression test puts a directory where the shadow file should be and checks that useradd fails with /etc/passwd untouched. Before this change the passwd line was written and the failure came after. * login: begin a session on the system login is the twenty-fifth tool and the one uutils/login was archived towards. It is what getty runs on a terminal: it prompts for a name and, through the `login` PAM service, a password; refuses after LOGIN_RETRIES failures or LOGIN_TIMEOUT seconds; opens the PAM session and takes its environment; records the login in utmp and wtmp; hands the terminal to the user; runs the login shell as the user; and when the shell exits, closes the session and records the logout. Which login to match was a question. Debian 13 and Ubuntu 25.10 take login from util-linux, but every release in service -- Ubuntu 20.04, 22.04 and 24.04, Debian 12 -- ships shadow's; the two were run side by side on real pseudo-terminals and agree on everything that matters. This implements the option set they share (-p, -f, -h, name) plus util-linux's -H and --help, which conflict with nothing. There is no -h for help: -h has been the remote host since telnetd. -r, the rlogin protocol, is refused. util-linux's vhangup is not reproduced; shadow's login, which the installed base runs, does not do it either. A wrong password and an unknown name get the same words, after the same delay. -f skips authentication and with it the auth stack, pam_nologin included, as both tools do. A missing home falls back to / with a notice when DEFAULT_HOME allows. The shell runs as a child so the session can be closed afterwards; it gets a clean signal mask, login keeps its own, and groups, gid and uid are dropped in that order in one pre_exec. shadow-core grew what login needed and nothing had: pam setcred, open_session, close_session, user and environment; utmp/wtmp recording; getpwnam through NSS; a getty-style spawn_with_controlling_tty; spawn_as_user; and a watchdog. Without the pam feature the applet says PAM is not compiled in and exits: a login that cannot authenticate is not a login, and the static musl archive is documented accordingly. The tests drive the real binary on a pseudo-terminal the way getty would, answering each prompt once it appears: autologin, the environment with and without -p, a wrong then a right password, an unknown user, the retry limit, the missing home, and the terminal handover. Writing them under the full parallel suite is what exposed the two fixes committed before this one. Verified: make check clean with the unprivileged run; 892 tests on alpine and 882 on fedora; 368 e2e assertions against a real install, including the password path on a pty; 52 GNU comparisons; 32 arm64 assertions under emulation. * docker: give the Fedora image the login PAM service Fedora ships /etc/pam.d/login in util-linux, which the test image did not install. With no service file PAM falls back to `other`, which on Fedora denies everything: `login -f` failed its account check and the password path was refused before any prompt, so every login test failed there and only there. util-linux is now installed, which is what any real Fedora has. The login tests then run through Fedora's own stack -- pam_selinux, pam_loginuid, pam_namespace -- and pass, which makes it the second PAM configuration the session code is exercised against rather than a gap. |
||
|
|
d9abeb3d98 |
README: state the project's scope and its departures from GNU shadow (#296)
Until now the repository had no written scope: no statement of which tools it means to ship, which it will not, or why a given behaviour differs from the GNU tool. Every other project in the organisation at this stage has one, and the questions it answers are the ones a reviewer, a packager and a contributor each ask first. The denominator is the binary set of the upstream suite as the distributions package it -- established from package metadata and man pages, never from the upstream source, which this project does not read. Debian and Fedora do not ship the same set; the 22 they agree on are all implemented, and the remainder is listed with its status. Debian's packaging scripts and Fedora's adduser symlink are named as out of scope so nobody files them as missing. Three tools the GNU suite ships are provided elsewhere in the organisation and are deliberately not duplicated: nologin in uutils/util-linux, su in sudo-rs, and login here -- uutils/login was archived pointing at this repository, so login is inherited rather than optional. This is the rule procps and util-linux already apply between themselves. The departures from GNU are collected in one place with the rule behind them: differences are bugs, except where reproducing a behaviour would quietly weaken the system, and then the refusal is loud, names the alternative, and is documented in the man page. Each entry already exists in a man page; the README now says they are one policy and not five accidents. |
||
|
|
ea2c85cf6c |
pwconv, pwunconv, grpconv, grpunconv: convert to and from shadow files (#295)
* pwconv, pwunconv, grpconv, grpunconv: convert to and from shadow files
These four complete the set both Debian and Fedora ship: every tool the two
distributions agree on now exists here. pwconv moves password hashes out of
the world-readable /etc/passwd into /etc/shadow, creating it if need be, and
reconciles the two; pwunconv merges them back and removes /etc/shadow;
grpconv and grpunconv do the same for the group files. They are one engine
with a selector, and the other three crates are the selector.
The order of writes is the substance of the change. pwconv writes shadow
before passwd: the hashes are copied into the shadow file and only then
replaced by `x`, so a failure between the two writes leaves them where they
were rather than nowhere. pwunconv writes passwd before it removes shadow:
for a moment the hashes exist twice, which is recoverable, where the other
order would have a moment with none. The removal happens while the lock on
the shadow file is still held, so no other tool can slip in between the last
read of the file and its disappearance.
What pwconv does was established by running the GNU tool:
- A hash found in passwd beside an existing shadow line is the newer of the
two -- it got there through a tool or an edit that did not know about
shadow -- so it replaces the shadow hash and the change is dated; the
aging is kept.
- A passwd line with no shadow line gets one, dated today, with the aging
from PASS_MIN_DAYS, PASS_MAX_DAYS and PASS_WARN_AGE. The hash field is
copied as it stands, an `x` included: an `x` in the hash field matches no
password, which is the honest state of an account whose hash was lost.
- A shadow line for an account no longer in passwd is dropped.
- A consistent system is left byte for byte alone.
A shadow file this run creates is made 0640 root:shadow, the layout the
distributions ship, with the `shadow` group looked up in the tree being
converted rather than through the name service: under --prefix or --root the
host's shadow group says nothing about the target's. Where the tree has no
such group the file stays 0600 root, the atomic writer's default.
The e2e suite uses the other tools as witnesses rather than only reading
files back: after pwunconv and pwconv the hash is byte for byte what it was,
passwd -S still reports the account as having a password, and after
grpunconv and grpconv sg still admits a non-member with the group password
and still refuses a wrong one.
Verified: 878 tests on debian, alpine and fedora; make check clean including
the unprivileged run; 356 e2e assertions against a real install; 51 GNU
comparisons; 31 arm64 assertions under emulation, the round trip included.
* tests: hand children their stdin as a file, not a pipe
The integration binary died on two CI runners with SIGPIPE: "write on a
pipe with no one to read". Not a failing test -- the test runner itself was
killed, part way through, and every test after it never ran.
Two things combine. The batch tools exit as soon as an input line is bad,
so a child fed "staff\n" has closed its stdin within a millisecond of
starting. And the `uumain` that `#[uucore::main]` generates resets SIGPIPE
to its default disposition every time it runs, in-process, for GNU pipeline
compatibility; several tests call `uumain` in-process -- deliberately, where
the tool would exec -- so once any of them has run, the whole test binary
has SIGPIPE at default. From then on a stdin write that lands after the
child has gone is not an EPIPE the helper could handle but a signal that
ends the process. The window is a few microseconds wide, which is why it
showed up on loaded CI runners and never locally.
The helpers now write the input to a file in the prefix tree and hand the
child that file as its stdin. A file has no reader to lose, so the class is
gone regardless of what the signal disposition is at the time. Applied to
chpasswd's helper too, which had the same shape and the same latent hazard.
Fifteen runs of the integration binary at sixteen threads after the change:
no failures. The race could not be reproduced on demand before it, so that
number is not a before-and-after; the diagnosis rests on uucore's source and
on the two CI logs.
|
||
|
|
8575f629a8 |
vipw, vigr: edit the account files under the suite's lock (#294)
vipw and vigr are the nineteenth and twentieth tools, and the fourth and
fifth of the nine that both Debian and Fedora ship and we did not. They
hand /etc/passwd, /etc/shadow, /etc/group or /etc/gshadow to the
administrator's editor under the same lock every other tool takes, and
install the result atomically. vigr is vipw with the group file as its
default -- the GNU suite ships it as a symlink -- so it is a crate that calls
uu_vipw::run with a different default and nothing else.
The reason the tool exists is the lock, so that is what the e2e suite proves:
with the editor open on /etc/passwd, a concurrent useradd waits rather than
failing or racing, and both changes land.
Two deliberate departures from GNU, both established by running its binary:
- The edited file is parsed before it is installed. GNU installs whatever
the editor saved: a line with no colons went in, and so did
`evil::0:0:::/bin/sh`, a UID 0 account with an empty password field.
Here a file the rest of the suite could not read back is refused. The
check is structural -- pwck and grpck still judge the contents -- and
when it refuses, the edit is kept beside the file rather than discarded.
An administrator whose ten minutes of work vanished would use a raw
editor next time, which is the outcome this tool exists to prevent.
- Changes are detected by content, not by timestamp. GNU compares
modification times in whole seconds, so an edit saved within the same
second as the copy was made is silently thrown away. The first probe of
the GNU tool hit exactly that: every scripted edit came back
"unchanged" until the script was slowed down.
The working copy sits beside the target as passwd.edit, created under a
zeroed umask with the original's mode and ownership, so a copy of
/etc/shadow is never more readable than /etc/shadow. A stale copy from a
crashed run is replaced, and the editor is handed the live contents.
vipw blocks SIGINT and friends for the whole edit so a Ctrl-C aimed at the
editor cannot kill it holding the lock. That mask would be inherited by the
editor, which then could not be interrupted at all, so
shadow_core::process::spawn_with_signals_unblocked starts the child with a
clean mask -- one pre_exec in the module where unsafe is allowed.
Verified: 852 tests on debian, alpine and fedora; make check clean including
the unprivileged run; 319 e2e assertions against a real install; 47 GNU
comparisons; 24 arm64 assertions under emulation.
|
||
|
|
5db310fc88 |
newusers: create or update accounts in batch (#293)
newusers is the eighteenth tool and the third of the nine that both Debian
and Fedora ship and we did not. One input line of seven fields produces a
whole account: the passwd record, the hashed shadow record, a group, and the
home directory with the skeleton copied in.
Every line is parsed, every name validated, every group resolved and every
password hashed before anything is written. A batch with one bad line leaves
the system exactly as it was, including for the good lines above it -- the
failure mode is "nothing happened", not "the first two hundred accounts
exist and the rest do not". That is the whole reason to reach for this tool
over a shell loop around useradd.
Home directory creation moved to shadow_core::home rather than being copied.
useradd's version forces the umask so the requested mode is exact, and hands
over ownership through a descriptor opened O_NOFOLLOW so nobody who can write
the parent can swap in a symlink between the mkdir and the chown. A second
copy of that is a second chance to get it wrong, and the mistake would be
silent. useradd now calls the shared one and its own tests still pass.
Three deliberate divergences, all established by running the GNU tool:
- An empty password field is refused before anything is written. Hashing
"" gives a valid hash that a bare Enter matches. GNU passes the empty
field to PAM, which refuses it *after* creating the account, leaving a
half-made account behind -- verified.
- A pw_gid naming a group that does not exist is refused. GNU falls back to
the user's own ID and creates no group, so the account is left pointing
at a GID that is not there, which grpck then reports.
- A missing parent directory is created, as useradd -b does. GNU's newusers
fails there, which makes it disagree with GNU's own useradd on the same
path.
--badname is implemented rather than stubbed: it drops the portability rules
on the login name while keeping the checks that stop a name from corrupting
the file or being read as an option, which is what makes the flag safe to
offer at all. It is what lets a domain-qualified name from a directory join
through.
The e2e suite re-hashes the stored field with its own salt and compares, so a
tool that wrote a well-formed hash of the wrong string would fail; the
negative control, the same check against a different password, is asserted to
fail. An earlier version of that assertion ended in `|| true` and was
therefore always green -- worth saying, because it is the failure mode these
checks exist to catch.
Verified: 828 tests on debian, alpine and fedora; make check clean, including
the unprivileged run; 297 e2e assertions against a real install; 45 GNU
comparisons; 22 arm64 assertions under emulation.
|
||
|
|
f37939d9fc |
chgpasswd: set group passwords in batch (#292)
* chgpasswd: set group passwords in batch
chgpasswd is chpasswd's counterpart for groups: it reads group:password
lines from stdin and applies them. It is the seventeenth tool and the second
of the nine that both Debian and Fedora ship and we did not.
It follows chpasswd's rule, which is what makes a batch tool safe to run
unattended: every line is parsed, every group resolved and every password
hashed before any file is written, so a batch naming one group that does not
exist changes nothing rather than applying the lines above it and stopping.
The two account files are then written in one transaction.
Where the hash goes was established by running the GNU tool, not by reading
it. With /etc/gshadow present the hash belongs there and /etc/group keeps the
`x` placeholder -- /etc/group is world-readable. Without it the hash goes
into /etc/group, and no gshadow file is created: conjuring one up would
change how every other tool on the host reads group passwords.
Three deliberate divergences, all refusals:
- `-c NONE`, which GNU honours by storing the password as clear text in
/etc/gshadow. A readable group password is worth no more than none at
all, and `-e` already writes a field verbatim when that is what is
wanted. The message says so, or an operator would just try `-c MD5`.
- `-m` and `-c MD5`/`-c DES`, as chpasswd already refuses them.
- An empty password in plaintext mode: hashing "" yields a valid hash that
a bare Enter matches, which is a group anyone can enter rather than a
group with no password. `-e ''` remains the way to clear the field.
A field containing a colon is refused, since it would split the gshadow line
and corrupt the file. The GNU tool refuses it too and likewise leaves the
file untouched, so this is agreement, not divergence.
The e2e suite checks the tools against each other rather than only against
the files: the password chgpasswd sets is the one sg then accepts from a
non-member, and a wrong one is refused. A hash written in a format nothing
can verify would pass a file-shape assertion and fail this one.
Verified: 798 tests on debian, alpine and fedora; make check clean; 273 e2e
assertions against a real install; 44 GNU comparisons.
* tests: run the suite as an unprivileged user too
Sixteen of the chgpasswd tests assumed root. They passed locally because
every container in docker-compose.yml runs as root, and failed in the one CI
job that does not.
Worse than the two that failed outright were the ones that passed for the
wrong reason: a test asserting exit 1 got exit 1 from "permission denied"
rather than from the condition it meant to check, so it was green while
testing nothing. Only the two expecting exit 0 gave the game away.
The guard itself is what chpasswd already does -- skip_unless_root() on every
test that writes an account file. The two that only exercise argument parsing
keep running everywhere, since clap answers before the root check.
The gap that let this through was local, so the fix is too: `make
test-unprivileged` re-runs the already-built test binaries as an ordinary
user, and `make check` now does it after the root run. Removing one guard
again makes it fail with "Permission denied", which is how it was verified.
SHADOW_TEST_REQUIRE_ROOT is cleared for that run on purpose: it exists to
turn a skip into a failure when the suite *is* root, which is the opposite
case.
|
||
|
|
651ed5c62b |
sg: run a command with a different primary group (#291)
sg is the sixteenth tool, and the first of the nine that both Debian and
Fedora ship and we did not. It runs a single command with a different
primary group: newgrp for one command instead of for a whole shell.
sg and newgrp are not merely similar. They decide who may enter a group by
the same rules and enter it the same way, and the distributions ship sg as a
symlink to the setuid newgrp binary -- one implementation, dispatched on
argv[0]. So the shared half moves into shadow_core::group_switch and both
tools call it, which is what keeps them from drifting apart. The multicall
binary already dispatches on argv[0], so it needs only the new applet.
sg execs the command rather than forking and waiting for it. The shell then
is the process, so the command's exit status reaches the caller unaltered:
`sg staff -c 'exit 7'` exits 7 and a command that cannot be run exits 127,
without sg reconstructing a wait status. This matches what the GNU tool does
and costs a process.
Writing the shared module turned up two things wrong in newgrp:
- It refused a group with no password without prompting, and prompted when
the group had one. Whether a prompt appeared therefore told any caller
which groups have passwords set, which is a list of the ones worth
attacking. Both tools now prompt either way and refuse afterwards.
- It rebuilt the supplementary group list with initgroups() alone, which
builds it from the member lists in /etc/group -- where a primary group
never appears. `newgrp staff` therefore dropped the group the caller
started in, costing them access to their own files until they left the
shell. The original group is added back, which needs setgroups(2) and so
has to happen before the setuid privilege is given up.
Both were established by running the GNU tools, not by reading them.
sg is setuid for the reason newgrp is: a caller who is not a member has to
be checked against the group password in /etc/gshadow, which is root-only.
Removing sg from the setuid list fails six assertions in the e2e suite --
including for a plain member, since initgroups() itself needs the privilege
-- and nothing else in the suite covers it.
Verified: 763 tests on debian, alpine and fedora; 255 e2e assertions against
a real setuid install, covering a member, a non-member with the group
password, a wrong password, a passwordless group and exit-status
propagation; 43 GNU comparisons; 19 arm64 assertions under emulation.
|
||
|
|
604b96aa6b |
tests: cross-build for arm64 and actually run it (#290)
The release has published an arm64 archive since 0.3.0 and nothing had ever executed one. CI builds it on a native arm64 runner and ships it, so a binary that was broken on that architecture would reach a user before anyone ran it; the checks so far went as far as the ELF header and the linked libraries. make test-arm64 cross-compiles the multicall binary and runs it under qemu user-mode emulation against a prefixed tree: useradd with a home and a supplementary group, chpasswd producing a SHA-512 hash, chage computing its dates, gpasswd adding a member, usermod renaming across files, userdel -r, and pwck reading back what the others wrote. Eighteen assertions, all passing on the current tree. Emulation is enough for what actually differs across architectures -- integer widths, struct layouts, and the crypt and PAM FFI boundaries -- and it needs no arm64 machine and no binfmt handler on the host, which rootless Docker cannot register anyway. The cross toolchain is installed by the target rather than baked into the image: it is 32 packages, and every other job would carry them for a check that runs occasionally. Also confirmed the published 0.4.0 arm64 archive runs, by the same route. |
||
|
|
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.0.4.0 |
||
|
|
cd2e1571ad |
docs: record gpasswd in the changelog (#288)
The tool merged in #282 without an entry, and the Unreleased section was empty. Credited to its author. |
||
|
|
f3a20b2043 |
tests: cover gpasswd as a group administrator (#287)
The reason gpasswd is setuid is that someone who is not root, but is named an administrator of one group, manages that group's membership. Nothing in the suite exercised a non-root caller of it, and that is the only place the omission shows: the multicall binary drops privilege for every applet not on its setuid list, so leaving gpasswd off that list produces a permission error there and nowhere else. It was off the list when the tool arrived. Twelve assertions: an administrator adds a member without being root and the change lands in both group and gshadow; a plain member may not change the membership, may not appoint administrators and may not use --root; and the administrator list is unchanged after all three refusals. Checked that the test bites by taking gpasswd back off the setuid list and rebuilding -- the administrator is then refused and the group file is untouched. Worth saying that the first attempt at that check appeared to show the fix was unnecessary; the build output had been suppressed and a stale binary was installed. |
||
|
|
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>
|
||
|
|
72d73276a6 |
release: tell dist the container is apt-based and arm64-native (#286)
Two more things the container needed, both visible in `dist plan` and neither in the generated workflow. The dependency install step was emitted empty: dist infers a package manager only for images it knows, and for anything else leaves it unset and installs nothing, so the build failed on -lpam. The container entry now says package-manager = "apt". The arm64 entry also has to say host = aarch64-unknown-linux-musl. Left at the default it describes an x86-64 container, so dist concluded it was cross-compiling and added a cargo-zigbuild install that shells out to dnf, which an apt image has not got. Checked by reading the install script dist plans for each target rather than by tagging again.0.3.1 |
||
|
|
5c3f58b875 |
release: give the build container the tools the workflow needs (#285)
The 0.3.1 pipeline failed before it compiled anything: a bare ubuntu:22.04 has no git, and the generated workflow runs `git config` as its first step, then installs Rust with curl. buildpack-deps:22.04 carries git, curl and a C toolchain, is still Ubuntu 22.04, and produces the same GLIBC_2.34 floor -- verified by building in it and dumping the versioned symbols, and by running the result on a glibc 2.35 host. No release was published for the 0.3.1 tag, so the tag is moved rather than the version burned. |
||
|
|
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. |
||
|
|
84402354a4 |
release: 0.3.0 (#283)
Thirty-eight commits since 0.2.2, none of them in the changelog: the
Unreleased section described the musl archive and nothing else. It now
covers the lot, under the headings the file already uses.
The release itself is the reason to do this now. The aarch64 archive
support merged on Friday and has never run: the configuration is
verified and dist plan lists all three archives, but no tag has yet
driven the pipeline.
Highlights, all documented in full in the changelog:
- --root performs a real chroot(2) in every tool, where eight of the
thirteen treated it as a path prefix.
- passwd -l is idempotent, and chage -l prints what chage(1) prints.
- chpasswd takes its hashing scheme from ENCRYPT_METHOD.
- chsh no longer answers differently for a path that exists and one
that does not, which made a setuid-root tool a filesystem oracle.
- login.defs numbers are read in octal and hexadecimal as documented.
- ID allocation consults the name service.
- An aarch64 archive, and the user-facing tools install into bin.
0.3.0
|
||
|
|
921f8551f6 |
Update fedora Docker tag to v46 (#274)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> |
||
|
|
acc1771bc2 |
tests: exercise the PAM stack against known permit and deny services (#281)
The only PAM coverage was the deployment image authenticating a real account. That cannot exercise a *refusal* without relying on a wrong password being wrong, and it cannot tell a refusal apart from a conversation that never reached the stack -- which is exactly the failure that broke password changes earlier this week and took three rounds to find. The glibc images now define two throwaway services, one backed by pam_permit and one by pam_deny, and shadow-core tests the wrapper against both: the permit stack authenticates and passes account management, the deny stack refuses with the stack's own answer rather than a conversation error, and a service with no configuration at all does not authenticate. The tests skip where the services are absent, which is any machine but the containers. CI also gains an all-features clippy pass. The completions feature and the shadow-core feature gates were compiled by no job, so a lint or a compile error in them could reach main unnoticed. make check runs it too. |