Skip to content

fix: harden account lifecycle, schedulers and signed releases - #45

Merged
xxvcc merged 10 commits into
mainfrom
fix/v2.10.7-expiry-encoding
Sep 26, 2026
Merged

xxvcc merged 10 commits into
mainfrom
fix/v2.10.7-expiry-encoding

Conversation

@xxvcc

@xxvcc xxvcc commented Sep 8, 2026 •

Copy link
Copy Markdown
Owner

Account creation, rollback and revocation now retain identity and retry evidence until cleanup is confirmed. The change also fixes expired-account encoding, accounts for real SSH listener ports and conditional policy, preserves pending sshd removals, creates delivered key files privately without overwriting existing paths, and requires complete process credentials before declaring a UID empty.

Automatic revocation now verifies both the recorded deadline and a persistent scheduler backend. Non-systemd hosts can use their native atd service even when systemctl is installed. Helper and mirror-transfer failures terminate owned process groups before reaping their leaders, including output-limit, inherited-pipe and signal paths. Release downloads consistently enforce file-size limits in normal and POSIX Bash, and x/crypto is updated to v0.56.0.

Validation: full non-root race suite; isolated root race integration with real account tools and bind mounts; real atd enablement, cancellation and overdue dispatch after daemon restart; vet and Staticcheck with and without integration tags; shell syntax/ShellCheck, all workflow actionlint checks, receiver signal/process tests, dependency verification and vulnerability scan; static amd64/arm64 builds and version probes. A separate disposable systemd/sshd canary checks SSH PTY, passwordless sudo, container reboot, timer restoration, automatic revoke and cleanup.

xxvcc and others added 8 commits September 7, 2026 01:18
DisableLogin issued chage -E 1970-01-01. chage stores that field as days since
the epoch, so the literal date encoded to 0 in the eighth /etc/shadow field —
verified against this host's chage, which writes 0 for 1970-01-01 and 1 for
1970-01-02. shadow(5) on the same host says of that value: "The value 0 should
not be used as it is interpreted as either an account with no expiration, or as
an expiration on Jan 1, 1970." shadow's isexpired() takes the first reading; it
requires sp_expire > 0 before it will call an account expired.

DisableLogin's own comment states why that matters: expiry is the effective gate
for a key-based account, because locking the password does not stop a public-key
login. Writing the one encoding shadow calls ambiguous into the field that gate
depends on is the whole defect.

The constant's comment shows the intent was already there — it declined to pass
a literal "0" as chage's argument because that reads ambiguously next to -E -1.
It simply moved the same ambiguity one layer down, into the stored value. The
date is now one day past the epoch: equally and unambiguously in the past, and
stored as 1.

The new test pins the invariant rather than the literal: it decodes the constant
to the stored day count and requires it to be both positive and in the past. It
was mutation-checked against the old value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ensureSubordinateIDsAbsent compared only the login name, while subuid(5) and
subgid(5) define the first field as "login name or UID" — and the manual page
recommends the numeric form on hosts with many entries, so a config-management
or container tool writing it is ordinary rather than exotic. A numeric-owner
entry therefore read as clean, ReconcileAccountDatabaseAfterDeletion reported
the database reconciled, and the caller dropped the registry row that was the
residue's last recovery pointer. The callers already hold the account's numeric
identity, so no exported signature changes.

Grant's pre-reload rollback skipped the reload whenever no removal marker
predated the call, on the invariant that the running daemon cannot have seen a
file this call created. WriteRootFile replaces an existing drop-in in place, and
one left by an earlier granted-and-reloaded call may already be in daemon
memory. Unlinking it without asking sshd to re-read left the daemon enforcing a
grant whose file this tool had removed — a divergence between what it reports
and what the host does. The rollback now recognises the replaced-file case.

Both tests were mutation-checked. The sshd one also documents a trap it hit
first: Grant validates once before writing and once after, and failing the
pre-check refuses the call before anything is written, so no rollback runs at
all and the test proves nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sshPortFromSshdT read only `port` from sshd -T. OpenSSH's ListenAddress with an
explicit port creates a listener without adding to options->ports, and
fill_default_server_options still leaves ports[0] = 22, so a host configured with
only `ListenAddress 0.0.0.0:2222` reports `port 22`. Verified against OpenSSH 9.2
on this host: that config prints port 22 alongside listenaddress 0.0.0.0:2222,
while `Port 2222` prints both as 2222. The invite therefore told the collaborator
to connect to a port nothing was listening on — confidently, with none of the
UNVERIFIED signalling the tool uses when it cannot establish something.

sshd -T renders every listener as a listenaddress carrying an explicit port,
including the defaults, so the listener set is complete and authoritative. One
distinct port is the answer; several are the operator's decision and fail closed
for an explicit --port; an entry that cannot be parsed voids the whole set rather
than silently narrowing it.

RejectUnder split mountinfo with strings.Fields, which splits on unicode.IsSpace.
The kernel escapes exactly four bytes inside path fields — fs/proc_namespace.c
passes " \t\n\\" to seq_dentry and seq_path_root — so \v, \f and \r arrive
literally and added a field, shifting the positional mountpoint. The line
"... /home/u/x\v/etc /home/u/hidden ..." then read /etc as the mountpoint, an
unrelated path, and the check reported no mount under /home/u for a root that had
one. That check exists to stop a recursive removal at a mount boundary.

The mountinfo test earned its payload the hard way: an earlier version used a
byte sequence whose split halves were not valid paths, so the old parser tripped
its mountpoint validation and the test passed against both implementations. Only
a payload whose shifted field is itself a plausible path outside the root
demonstrates the false negative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An sshd config line beginning with '=' carries an empty keyword through
parseSSHDDirective, and the Match/Include scan skipped it with the branch meant
for blank lines. sshd honours such a line: verified on OpenSSH 9.2, a config
carrying "=Match Address 203.0.113.0/24" then "PubkeyAuthentication no" reports
pubkeyauthentication no for a matching connection and yes without it. The scan
therefore missed a connection-scoped Match and let an invite claim a login sshd
would deny. A line with arguments but no keyword is now unmodellable rather than
blank, and the scan fails closed.

ensureAtd's systemd branch uses `enable --now`; the OpenRC and sysvinit branches
only started atd. Those branches are reached exactly on hosts with no systemd,
where the at fallback IS the auto-revoke mechanism, so every queued revocation
stopped firing after a reboot. Both now also arm it, best effort, with the status
probe still deciding the result.

CheckIntegrity required the sequence to cover the rows as soon as a sequence file
existed beside a legacy header, contradicting its own doc comment and Init, whose
migration path reseeds from the highest recorded UID. The reported error is not
ErrIdentitySequenceMissing and the registry is not v5, so neither
recover-identity-sequence nor RepairMissingIdentitySequence would act on it: the
operator was told to restore from backup for a host Init fixes on the next run.

Two invite registry writes treated a *fsutil.DurabilityError as a plain failure.
AtomicWriteFileAt renames the file into place and only then fsyncs the directory,
so that error means the row is already on disk. The creation-intent row was
abandoned with tx.registered still false and no release cleanup registered, and
the pending-to-completed row left tx.rec on the superseded shape that
persistDeletionStarted compares strictly — aborting invite's own rollback. Both
adopt the committed row and then fail, matching how sudoers, sshdconf and
schedule already handle this primitive.

The invite pair has no test: App.Registry is a concrete *registry.Store and
fsutil exposes no durability hook, so reaching the case would mean adding a seam
to a privileged path solely for testing. The other three are covered and were
mutation-checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pgrade

deleteAndFinalize discarded the QuarantineUntil parse error, leaving the zero
time, which is before every clock reading — so a corrupt deadline chose the
quarantined teardown, the one that skips the synchronous drain.
honorExistingQuarantine already refuses the same value; this reading must not be
the weaker of the two.

The quarantine handoff reported "the account is disabled and retained" for every
failure, including the one from beginIdentityQuarantine's first step, which is
DisableLogin. A distinguishable error now separates them, so the operator is not
told the door is shut when it may still be open.

shouldAskLang compared argv against the bare "--yes"/"-y" tokens. Go's flag
package also accepts --yes=true, -y=1 and so on for the boolean flag every
mutating subcommand registers, so those runs still met the first-run language
prompt, which stops them.

The --remove-users gate counted every entry in plan.accounts, which is a union
of witnesses rather than live accounts: a stale v1 row, an orphaned sudoers
drop-in, an orphaned timer and a stale v2 row all appear without a passwd entry.
A host with no live account was told to pass the mass-deletion flag to authorize
deletions that were not going to happen.

The official-mirror upgrade path treated latest.json as the sole version
selector, so an index offering something older than the installed release was
indistinguishable from being current: silent, exit 0, no record. It now warns
and audits, while still declining to install.

main indexed os.Args[1:] unguarded; the runtime builds that slice as
make([]string, argc), so an exec with argc == 0 panicked with a goroutine dump
instead of printing usage. cmd/lta-release already guards the same way.

Three of the six have mutation-checked tests. The uninstall gate, the mirror
downgrade report and the argc guard do not: the first two need fixtures well
beyond the change, and main() is not reachable from a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…giene

The audit record's actor is whatever SUDO_USER says, and its uid field records
geteuid(), which is 0 for every run. Neither identifies the human when the name
is in question. The record now also carries /proc/self/loginuid, set once per
login session by PAM, with the unset sentinel (4294967295) recorded as absent
rather than as a uid.

ReconcileAccountDatabaseAfterDeletion authorized groupdel on any positive GID.
The account protection and the identity allocator share a floor at 1000; the
group removal is just as destructive and now reads the same constant.

schedule's orphan scan used os.ReadDir, which resolves the path, while the
sudoers and sshd managers open every directory with O_NOFOLLOW|O_DIRECTORY. A
replaced unit directory would have been enumerated as if it were ours.

install.sh executes the downloaded candidate twice with stdin still attached to
the pipe carrying the rest of the installer, on the documented curl | sh path.
Both invocations now read /dev/null.

bounded_copy computed `ulimit -f` blocks as 1024 bytes. Bash uses 512-byte
blocks in POSIX or sh mode — measured on this host: 1024 under bash, 512 under
`bash --posix` — so the effective cap was halved and files within the limit were
rejected. It now probes /proc/self/limits in a command-substitution child, the
way install.sh documents and already does; the caller's own limit is unaffected.
The change was made in the canonical .inc and re-synced into the three release
scripts.

publish-release.sh runs chmod without listing it in its preflight, and scrubs an
exhaustive list of trust-affecting variables without GNUPGHOME. atomic_write's
handler closed fd after fdopen had already closed it, so a descriptor opened in
between — directory_fd, immediately below — could inherit that number and be
closed out from under its owner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eanup helper

integrationtest.removeUser runs `userdel -r -f` as root from roughly thirty
fixtures — the exact flag combination internal/user documents as unsafe for the
production path — and deleted whatever name it was handed. It now refuses
anything outside this suite's own naming namespace, so a typo or a fixture
reusing a real login cannot take that account and its home directory with it.

uninstallApp built its Scheduler with only the v1 legacy prefix while production
schedule.New() also sweeps QuarantineUnitPrefix, leaving the one namespace that
names a disabled-but-still-present account untested by every uninstall case.

expiry.LockInstant restates Date's own assumption that chage reads the date as
UTC, so every test built on it agreed with Date by construction. A new test asks
chage instead, against a throwaway root, under TZ=UTC, Pacific/Kiritimati
(UTC+14) and Pacific/Honolulu (UTC-10). Measured: this shadow-utils stores the
same sp_expire in all three, so the UTC model holds here — but a shadow that
parsed the date locally would now be caught rather than assumed away.

TestDropInRestoresScopeForLaterIncludedFiles could not fail: deleting the
managed file's trailing `Match all` leaves its query answering "no" all the
same, because OpenSSH 9.2 on this host ends Match scope at the file boundary
instead of carrying it into the next file of the Include glob — the opposite of
the behaviour the package doc gives as the guard's reason. The measurement is
now recorded in the test, the guard is kept for older sshd, and its presence in
the rendered drop-in is pinned directly so removing it fails something.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The group, gshadow and subordinate-ID parsers treated every line that was not
exactly N colon-separated fields as malformed. glibc's nss_files skips blank and
'#' lines, and both glibc and shadow-utils accept the '+'/'-' NIS compatibility
entries, so a host carrying one hard-failed every sequential invite over a line
that names no group. Writing a test for the integration suite's own group reader
found the identical rule missing there too.

revoke's picker printed the raw seven-column table while status, manageUsers and
cleanupExpired all fall back to the numbered vertical view — it was the one
picker that could garble the rows it asks the operator to choose from. The
uninstall plan table listed an --ignore-foreign-markers account as live under
"The uninstall will remove:", because it had no access to the options that
decide what is excused.

published_at was validated for shape and then used only to rebuild canonical
bytes, constraining nothing about which release the mirror names. A timestamp
ahead of now cannot describe a published release; staleness itself stays a
judgement this code cannot make without a trusted clock, and the version lower
bound added earlier covers a rolled-back index. The receiver's stable-installer
scan hashed one file per published release under the deployment lock with no
deadline, and rrsync ran without -munge while the mandated profile enables
--links.

The invite password was minted as an immutable Go string and copied into several
more, none of which can be zeroed, while the key path clears everything it can.
It is bytes end to end now: cleared after rendering, and the chpasswd line built
and cleared in the function that owns it.

probeVersion is the only privileged write-and-exec that works by name rather
than through a pinned directory descriptor. Restructuring it to openat/fexecve
is a larger change than this warrants; binding its two resolutions of the install
directory to one inode closes the window between the safety verdict and the
write without that risk, and the deviation is now recorded where it happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread internal/audit/audit.go Fixed
xxvcc and others added 2 commits September 8, 2026 16:29
Two CI failures, both mine.

Narrowing the --remove-users gate to live accounts was wrong. It broke
TestUninstallWithAccountsRefusesNonInteractivelyWithoutTheFlag, whose fixture
carries a registry row with no passwd entry behind it and whose prose states the
intent: the gate mirrors --fix-sshd, so a run nobody is watching never removes a
registry row, a sudoers drop-in or a scheduled task implicitly either. The audit
finding read the demand as naming deletions that will not happen; the test says
the flag is about what it authorizes, not about what each host happens to hold.
Reverted, with that reasoning recorded where the count is made.

TestGrantRollbackReloadsWhenItReplacedALiveDropIn went into the untagged test
file, so CI's non-root job ran it against a t.TempDir() owned by the runner and
Grant refused the directory before reaching anything under test. It moves to the
root fixtures, which skip when not root and chown the directory. Local runs were
all root, which is exactly the trap the repo's own guidance warns about; the
whole changed set has now been re-run as an unprivileged user the way CI does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@xxvcc xxvcc changed the title fix: close the remaining audit backlog (32 findings) fix: harden account lifecycle, schedulers and signed releases Sep 26, 2026
@xxvcc
xxvcc merged commit 01de7e8 into main Sep 26, 2026
8 checks passed
@xxvcc
xxvcc deleted the fix/v2.10.7-expiry-encoding branch September 26, 2026 05:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants