22 Commits
Author SHA1 Message Date
Pierre Warnier 881aad13c7 shadow-rs: drop the setuid privilege for applets that are not meant to have it (#239)
* shadow-rs: drop the setuid privilege for applets that are not meant to have it

The per-tool install marks only passwd, chfn, chsh and newgrp setuid; a
multicall install has to make the one binary setuid, which gave every
applet euid 0. Any local user could run 'shadow-rs pwck -s' or
'grpck -s' and have /etc/passwd, /etc/shadow, /etc/group and /etc/gshadow
rewritten as root. Before an applet outside that set runs, the binary now
calls setuid(getuid()), so the real, effective and saved uid are all the
caller's and the two layouts share one privilege model. It fails closed if
the kernel does not take the privilege away.

Dispatch and --list are driven by one table instead of two hand-kept
lists, and an exit code outside 0..=255 no longer clamps to success.

The deployment test checks that testrunner's pwck -s and grpck -s leave
the files' inode and ownership untouched.

* docs: the multicall binary keeps euid 0 for the four self-service tools only
2026-09-04 10:24:35 +02:00
Pierre Warnier 7364ca5817 shadow-core: keep ownership on rewrite, refuse field injection, never abort on '..' (#238)
* shadow-core: keep owner, group and label when rewriting account files

atomic_write copied only the mode onto the replacement file, so the new
inode belonged to the effective uid and gid of the process. Any change by
an administrator turned /etc/shadow from root:shadow into root:root,
which breaks every sgid-shadow authenticator such as unix_chkpwd, and a
rewrite performed by a setuid tool left the file owned by the caller's
group. The owner and group are now taken from the original's descriptor
and applied with fchown before anything is written; a failure there
aborts the write. The SELinux label is copied the same way, best effort,
which is what the never-compiled selinux module was meant to do, so that
module and its feature flag go.

The writer now resolves a symlinked target so the file behind the link
is replaced rather than the link, and buffers output instead of issuing
one write(2) per field.

* shadow-core: make path resolution total

SysRoot::resolve scanned the joined path for '..' and hit unreachable!()
when it found one, but the prefix comes from --prefix and the relative
part can be a home directory read from /etc/passwd, so the panic was
reachable by an unprivileged caller of setuid passwd and by any
useradd -d containing '..'. Resolution is now a plain join; callers that
accept a path from the command line validate it themselves.

* useradd, usermod, groupadd, groupmod: refuse field separators and control characters

A value containing ':' added a field and a value containing a newline
added a record: useradd -c $'x\nevil::0:0::/:/bin/sh' created a
passwordless UID 0 account, and -c 'a:b' left /etc/passwd unreadable
for every tool. The four tools now validate every string that lands in
a passwd, shadow, group or gshadow field before locking anything and
exit 3, and useradd additionally requires -d and -k to be absolute paths
without '..' since they are created and chowned as root.

shadow-core's writers perform the same check on every entry, so a tool
that forgets cannot corrupt a file either.

* shadow-core: disable core dumps with PR_SET_DUMPABLE

RLIMIT_CORE=0 is ignored when cores are piped to a handler, which is how
systemd-coredump and apport collect them, so a root-run chpasswd with a
stdin full of plaintext passwords could still leave a core behind.

* docs: record the account-file hardening
2026-09-04 10:19:28 +02:00
Pierre Warnier 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.
2026-09-04 10:04:38 +02:00
Pierre Warnier 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.
2026-09-03 13:46:56 +02:00
Pierre Warnier beb22f64ae docs: correct the stated reason musl is not published (#229)
The README and the dist config both claimed musl is unavailable because
crypt(3) is not part of the static musl target. That is wrong: musl
implements crypt() inside libc —

    $ nm .../x86_64-unknown-linux-musl/.../libc.a | grep ' T crypt'
    0000000000000000 T crypt

The link failure comes from `#[link(name = "crypt")]` emitting -lcrypt,
which finds no libcrypt.a in Rust's musl sysroot and falls through to the
host's glibc libxcrypt. Gating that attribute on the target env is enough
to produce a working static binary.

The genuine blocker is PAM: it dlopens its modules, which a fully static
binary cannot do, so a musl artifact would ship without interactive
password changes. State that instead, and point at #224.
2026-09-03 13:28:34 +02:00
Pierre Warnier 90faf4cc86 ci: reject AI tooling artifacts in pull requests (#219)
* ci: reject AI tooling artifacts in pull requests

The project's public face carries no AI tooling attribution, but nothing
enforced it: a session trailer reached a merged commit message and a pull
request body before anyone noticed.

Add a check that runs on pull requests and inspects only what the PR
changes — commit messages, PR title and body, added lines, and any
attempt to track the local-only guidance and review-export files that
are meant to stay gitignored. History is untouched and unrelated files
are not scanned. The clean-room audit records are exempt: they must
describe their own methodology to serve as compliance evidence.

Also drop the tooling credits from README and CHANGELOG.

* ci: fix base-ref fetch in the artifact check

`--depth=0` is rejected by git, and the extra fetch was redundant with
the full-history checkout. Fetch the base branch plainly and diff against
FETCH_HEAD.
2026-09-03 12:06:56 +02:00
Pierre Warnier a5c5e816ea ci: publish release binaries with dist (#207) (#216)
Releases carried no binaries (v0.2.1 shipped zero assets). Add dist
(formerly cargo-dist) 0.32.0 to build and upload them on tag push.

Scope: only the workspace root package is distributed, producing the
single shadow-rs multicall binary that provides all 14 tools via argv[0]
— the same layout as `make install-multicall`. The 14 per-tool crates
carry `dist = false` so a release is one archive per target rather than
fifteen. `[dist.binaries]` pins the archive to `shadow-rs`, keeping the
feature-gated `shadow-rs-completions` helper out of it.

Targets are glibc-only for now: shadow-core::crypt links crypt(3) via
`#[link(name = "crypt")]`, and the self-contained musl target provides
no libcrypt, so an x86_64-unknown-linux-musl cross-build fails at link
time. (Alpine CI passes only because it installs a system libcrypt.)
`[dist.dependencies.apt]` installs libcrypt-dev on the runner for the
same reason.

`[profile.dist]` inherits the hardened release profile, so published
binaries keep LTO, stripped symbols and panic = "abort".

README documents deploying the archive (symlinks + setuid) and why musl
is not published yet.

Verified locally: `dist plan` and `dist build --artifacts=local` produce
uu_shadow-x86_64-unknown-linux-gnu.tar.gz containing shadow-rs plus
README/CHANGELOG/LICENSE and a .sha256; the extracted binary reports
`shadow-rs (uutils shadow-rs) 0.2.1`.

Closes #207.

Claude-Session: https://claude.ai/code/session_011f42R6SMAfQXufTgdjSuhU
2026-09-03 11:54:24 +02:00
oech3 4274bac2d1 README.md: strip -rs from project name 2026-06-09 18:28:51 +09:00
Pierre Warnier 95f8f2d917 readme: fix license badge HTTP → HTTPS 2026-04-22 17:37:07 +02:00
Pierre Warnier 3e6dab9b18 docs: update all URLs from shadow-utils-rs to uutils org
Repo transferred to uutils/shadow-rs. Update all references:
- README.md: CI badge, license badge, clone URL
- Cargo.toml: repository, homepage
- CONTRIBUTING.md: clone URL
- SECURITY.md: advisory URL
- shadow-rs.spec: source URL
- CLAUDE.md: PR review API URLs, kanban board
2026-04-22 15:31:42 +02:00
Pierre Warnier d89b4aaa64 Makefile: drop PROFILE var, fix stale comments
Address Copilot review round 2:

- Remove PROFILE variable and hardcode --release / target/release/.
  Cargo maps "dev" profile to target/debug/ (not target/dev/), so
  PROFILE=dev would silently break install. Nobody installs debug
  builds of setuid-root tools.

- Fix stale comments in Makefile and README that referenced the old
  "chmod on symlink follows through" approach. The current code uses
  install -Dm4755 directly.
2026-04-16 10:43:14 +02:00
Pierre Warnier 698eaf7cf3 make install: default to standalone per-tool binaries (#138)
Switch the default install to build and install 14 standalone per-tool
binaries with a least-privilege setuid layout matching GNU shadow-utils.
Only passwd/chfn/chsh/newgrp are setuid-root; the other 10 tools are 0755.

The previous multicall install is preserved as `make install-multicall`
for container/embedded use cases where the ~14x disk savings matter and
the enlarged setuid attack surface is acceptable.

The old layout chmod'd symlinks pointing to the multicall binary, which
(because chmod follows symlinks) marked the underlying ELF setuid-root.
That meant all 14 tools ran with euid=0 when invoked via symlink. Each
tool's internal getuid() check was defense-in-depth, not OS-level least
privilege. The standalone layout gives the latter.

Raised by @oech3 in uutils/coreutils#11828.
2026-04-15 14:19:21 +02:00
Pierre Warnier 347349721a README: credit Claude Code as main writer, add links for all AI tools 2026-04-03 16:19:35 +02:00
Pierre Warnier 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
2026-04-03 15:16:41 +02:00
Pierre Warnier 95d2b2478a docs: add CI/MSRV badges, fix stale reference
- Add CI and MSRV badges to README
- Remove "(when created)" from CODE_OF_CONDUCT.md reference in CONTRIBUTING.md
2026-04-03 14:45:17 +02:00
Pierre Warnier 8ee65a7552 phase3: implement groupadd, groupdel, groupmod, grpck, chfn, chsh, newgrp
All 14 shadow-utils tools are now implemented.

groupadd: create groups with auto GID allocation, system groups, force mode.
groupdel: delete groups, checks for primary group usage.
groupmod: modify GID, rename, password changes.
grpck: verify /etc/group and /etc/gshadow integrity.
chfn: change GECOS sub-fields (name, room, phone).
chsh: change login shell with /etc/shells validation.
newgrp: change effective group with crypt(3) password verification.

449 tests, zero clippy warnings, all 14 tools in multicall binary.
2026-03-24 10:47:07 +01:00
Pierre Warnier ed5bb0b71a phase2: implement useradd, userdel, usermod, chpasswd, chage
Fixes #57 — useradd: create user accounts with all core flags
  (-c, -d, -e, -f, -g, -G, -m, -M, -k, -N, -o, -p, -r, -s, -u, -U).
  UID/GID allocation, home dir creation, skel copy, shadow entry.

Fixes #62 — userdel: remove user from passwd/shadow/group/gshadow.
  Flags: -f, -r, -P. Removes from membership lists.

Fixes #63 — usermod: modify user properties.
  Flags: -c, -d, -e, -f, -g, -G, -a, -L, -U, -l, -s, -u, -P.
  Lock/unlock via shadow, group membership management.

Fixes #64 — chpasswd: batch password change from stdin.
  Reads username:password pairs, updates /etc/shadow.
  Supports -e (pre-encrypted), -R (chroot).

Fixes #65 — chage: password aging management.
  All flags: -d, -E, -I, -l, -m, -M, -R, -W.
  List mode (-l) shows aging info. Matches GNU output format.

364 tests, zero clippy warnings.
2026-03-24 10:19:52 +01:00
Pierre Warnier 07d328ec87 pwck: address Copilot review — sort fix, -r flag, permissions check 2026-03-24 09:58:50 +01:00
Pierre Warnier be3d6a6c4e docs: add credits for OpenBSD, sudo-rs, uucore, Copilot, Gemini 2026-03-23 16:18:10 +01:00
Pierre Warnier 3ed6188aea tests: add proptest, edge cases, fuzz targets, integration tests, docs
Fixes #11 — tests/by-util/test_passwd.rs: 15 integration tests
  following uutils convention (status format, exit codes, lock/unlock
  cycle, aging, lifecycle, quiet mode, error cases).

Fixes #12 — fuzz targets for all parsers:
  fuzz_passwd_parse, fuzz_shadow_parse, fuzz_login_defs_parse,
  fuzz_validate_username. All must not panic on any input.

Fixes #15 — proptest round-trip tests for PasswdEntry, ShadowEntry:
  generate random valid entries, serialize, parse back, compare.

Fixes #16 — parser edge case tests:
  wrong field counts, empty files, roundtrip via file I/O, negative
  values, boundary values, unicode rejection, null bytes, mixed
  whitespace, duplicate keys, key-only lines.

Also: CONTRIBUTING.md, SECURITY.md created. README.md and CLAUDE.md
updated to reflect uucore integration and current project state.

142 tests passing on Debian/Alpine/Fedora, zero clippy warnings.
2026-03-23 14:11:19 +01:00
Pierre Warnier 202acc16c9 README: rewrite following uutils format, add local Docker git hooks
README.md: project overview with CVE motivation, goals, tool status
table, Docker build/test/lint instructions, architecture diagram,
test matrix, contributing guidelines with GPL clean-room warning.

hooks/: local CI via Docker (no GitHub Actions, no cloud):
- pre-commit: cargo fmt + clippy on Debian (~5s)
- pre-push: fmt + clippy + tests on Debian/Alpine/Fedora (~30s)
- install.sh: symlinks hooks into .git/hooks/
2026-03-23 13:13:36 +01:00
Pierre Warnier eabec36e06 Initial commit 2026-03-23 10:56:43 +01:00