Skip to content

Hold an image shut until its first boot has set a password - #12

Merged
marcos-mendez merged 11 commits into
19.xfrom
fix/webmin-auth-hardening
Sep 29, 2026
Merged

marcos-mendez merged 11 commits into
19.xfrom
fix/webmin-auth-hardening

Conversation

@marcos-mendez

@marcos-mendez marcos-mendez commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #11.

The problem

A built appliance has accounts before its first boot has set a password on
any of them, and three things in the build made that state more open than it
has to be.

conf/turnkey.d/rootpass, for a build given no ROOT_PASS, wrote a
password field that a submitted password can match, rather than one that
matches nothing until a password is set. The PAM stack the webmin package
installs carries nullok, which lets an account whose password is not set
be authenticated without one. And the web interface starts with the rest of
the boot, before the first boot scripts that configure the appliance have
run at all.

Established before changing anything

Why the placeholder existed, and why it cannot simply become !.
/etc/default/inithooks ships SUDOADMIN=false, so firstboot.d/29sudoadmin
runs turnkey-sudoadmin off --disable-setpass on every first boot, before
30rootpass. That path calls passwd --unlock root. Measured against
shadow in a scratch root:

for field in '!' '*' 'U6aMy0wojraho' '!<hash>' ''; do
    unshare -r passwd --root "$dir" -u root; done

field '!'       exit 3, unchanged, "unlocking the password would
                result in a passwordless account"
field '*'       exit 0, unchanged
field '!<hash>' exit 0, '<hash>'

turnkey-sudoadmin runs under bash -e, so exit 3 there ends the hook
before permitrootlogin_ssh, restart_sshd and set_status, and
/etc/sudoadmin/state is never written. Locking the account is therefore
the fix that breaks the first boot, which docs/traps.md puts at the top of
the list. * is what the account gets: crypt() returns it for no input,
passwd --unlock accepts it, and bin/setpass.py sets the real password
with chpasswd, which replaces the field whatever it held.

What the placeholder was for. Nothing in the first boot authenticates
against it. sshd ships PermitRootLogin yes but OpenSSH defaults
PermitEmptyPasswords no, so it was never an SSH login. The first boot
dialog runs on tty1 with Conflicts=getty@tty1.service, so there is no
login prompt to answer during it either. What it bought was a console login
on an image whose first boot has not run.

Why not allow= or a loopback bind in miniserv.conf. Both outlive the
first boot: nothing removes them, so an allow= written at build time locks
out remote administration for good, and a loopback bind has to be rewritten
and the service restarted by something later. Holding the unit back needs no
undoing, so that is what is here instead.

The change

  • conf/turnkey.d/rootpass writes *, with the reason ! is not used in a
    comment. The same script expanded ROOT_PASS against the build directory
    before handing it to chpasswd, so a password holding a glob character
    was not the password that got set; it is quoted now.

  • conf/turnkey.d/webmin-pam, new, takes nullok out of the stack and
    fails rather than report success if the stack is absent or the option
    survives.

  • conf/turnkey.d/webmin-enable holds the web interface back. webmin.service
    gains a condition on the marker inithooks writes when its boot scripts are
    done, and a path unit starts it when the marker appears. The conditions are
    systemd triggering conditions, an OR, so an image with no inithooks at all
    is not left without a web interface; the path unit triggers a one shot that
    stays active, so it fires once and does not restart Webmin behind an
    administrator who stopped it.

  • conf/samba-rootpass, which runs after rootpass on a Samba appliance and
    wrote the old field back, writes * too, quotes the password and no
    longer traces it into the build log.

What each part does, asked of the real pam_unix on libpam 1.7.0-5 (the
appliance's): the old field let any password in, without a prompt,
through any stack carrying nullok, which includes Debian's common-auth.
* refuses everything through every stack, so rootpass is the change
that holds the account shut. Removing nullok from Webmin's stack also
closes the old field there on 1.7 (not on libpam 1.5, where the empty
password then gets in); common-auth keeps it, #13. The hold keeps the
interface down until the first boot's scripts have run, and the marker
means they ran, not that they succeeded.

Tests, and what is real in them

58 bats. The question "would this password get in" is put to Linux-PAM
itself: tests/pam-authenticate calls pam_authenticate through libpam
over the scratch image's own stack, in a private mount namespace with the
scratch shadow and passwd bind mounted over the real ones, so pam_unix
and its unix_chkpwd helper are the installed ones. (The first version
modelled pam_unix by hand, and review found the model wrong.) What
pam_unix makes of the crypt of the empty string differs between libpam
1.5 and 1.7, so the five tests that depend on it run on 1.7 and skip by
name elsewhere; the CI runner has 1.5.3. Whether the interface would start
is answered by systemd-analyze condition over the packaged
webmin.service and the drop-in together, and both are handed to
systemd-analyze verify.

The shadow tools and smbpasswd are stubbed: usermod, chpasswd and
passwd chroot into the root they are given, so an ordinary user cannot
aim them at a scratch tree. Each stub carries the behaviour it reproduces
and the command that measured it.

tests/before-firstboot.bats runs the three scripts over one image in the
order a build runs them and asks the whole question of the result, through
Webmin's stack and through common-auth. With this branch's webmin-pam
and the previous rootpass and webmin-enable, it fails 4 of its 6
tests; with only rootpass reverted, 1. tests/rootpass.bats fails 5 of
9 against the previous script, tests/samba-rootpass.bats 4 of 6.

Coverage: 100 percent, 48 of 48 lines over the five measured files, kcov 43.
tests/coverage.sh measures a list rather than one file. rootpass and
webmin-enable moved from /bin/sh to /bin/bash because kcov measures
bash and not dash and decision 0003 gives no exemption for a file a change
touches; rootpass uses no bash construct. The gate stays at 100.

What this does not do

Nothing is built, signed or published, and no build lock was taken, so no
appliance has been booted with this. The end to end verdict belongs to an
appliance boot test.

The marker the unit waits for is /run/inithooks-complete, which today's
inithooks already writes, so this stands on its own and needs no ordering
against Keel-Linux/inithooks#17 or #18. The first boot that reaches it
records that in /var/lib/webmin-after-firstboot/opened, and every boot
after starts Webmin without waiting. A first boot that never finishes keeps
Webmin shut until systemctl start webmin-after-firstboot.service is run
from a console.

common-auth still carries nullok: #13.

…t boot

Three things the build leaves behind were more permissive than an appliance
needs between the end of the build and the end of its first boot.

conf/turnkey.d/rootpass, for a build given no ROOT_PASS, wrote a password
field that a submitted password can match. It now writes one that no
password matches. The value is deliberately not '!': shadow reads a field
of exactly '!' as a locked account, and 'passwd --unlock root', which
turnkey-sudoadmin runs on every first boot, refuses to unlock it, exits 3
and leaves the rest of that hook undone. Measured in a scratch root with
'unshare -r passwd --root'. The first boot writes over the field with
chpasswd as it always did, which replaces it whatever it held.

The same script expanded ROOT_PASS against the build directory before
handing it to chpasswd, so a password holding a glob character was not the
password that was set.

conf/turnkey.d/webmin-pam is new and takes 'nullok' out of the PAM stack
the webmin package installs. The option lets an account whose password is
not set be authenticated without one; no appliance account is meant to be
reachable that way and nothing in an appliance asked for it. The script
fails rather than report success if the stack is absent or if the option
survives.

conf/turnkey.d/webmin-enable holds the web interface back until the first
boot scripts have run. webmin.service gains a condition on the marker
inithooks writes when its boot scripts are done, and a path unit starts it
when that marker appears. The conditions are systemd's triggering
conditions, an OR, so an image carrying no inithooks at all is not left
without a web interface; the path unit triggers a one shot that stays
active, so it fires once and does not restart Webmin behind an
administrator who stopped it.

Tests, 36 bats over the four measured conf scripts, assert behaviour and
not configuration. Whether a password gets in is answered by the real
crypt(3), the function pam_unix compares with, inside the two rules
Linux-PAM applies around it; those rules were confirmed against
unix_chkpwd, the helper pam_unix runs, with a scratch shadow file bind
mounted over /etc/shadow under 'unshare -r -m'. Whether the web interface
would start is answered by 'systemd-analyze condition', and the units it
writes are handed to 'systemd-analyze verify'. The shadow tools are stubbed
because usermod, chpasswd and passwd chroot into the root they are given
and cannot be aimed at a scratch tree by an ordinary user; each stub
carries the behaviour it reproduces and the command that measured it.

tests/before-firstboot.bats runs the three scripts over one image in the
order a build runs them and asks the whole question of the result, because
no single one of them owns the answer.

tests/coverage.sh measures a list of files rather than one. rootpass moved
from /bin/sh to /bin/bash so that kcov can measure it, and uses no bash
construct.

Closes #11.
@marcos-mendez
marcos-mendez force-pushed the fix/webmin-auth-hardening branch from 751acc1 to 5596a8e Compare September 28, 2026 09:09
The core changelog gains a bullet per change: the password field a build
with no ROOT_PASS leaves, the glob expansion of a build time ROOT_PASS,
the nullok removal and the wait on the first boot scripts.

COVERAGE.md records the branch: 100 percent over four measured files, 39
of 39 lines under kcov 43, 36 bats, with what is measured for real and
what is stubbed spelled out. The gate stays at 100.
@marcos-mendez
marcos-mendez force-pushed the fix/webmin-auth-hardening branch from 5596a8e to d9dcbea Compare September 28, 2026 09:15
@marcos-mendez

Copy link
Copy Markdown
Collaborator Author

Reviewed as a security change. I re-measured every claim rather than reading
them; where I disagree it is because a measurement disagreed, and the
commands are given so you can settle it either way.

Reproduced as stated: the passwd --unlock table (! exits 3 and leaves
the field, * and a hash exit 0 and leave the field, !<hash> exits 0 and
strips the !), measured with unshare -r passwd --root; that
turnkey-sudoadmin runs user_state root unlock under bash -e ahead of
restart_sshd and set_status, so the exit 3 really does end that hook;
that bin/setpass.py pipes to chpasswd and nothing in inithooks or
confconsole reads the field; crypt() returns *0 for * on every input I
tried; coverage 100.00 over the four files, 39/39, and tests/rootpass.bats
failing 5 of 9 against the previous script.


HIGH — tests/helpers.bash:83-104, and the prose derived from it.
blank_equivalent and authenticates are a hand-written model of
pam_unix, and the model is missing two rules the module has. In
verify_pwd_hash() (libpam 1.7.0-5, modules/pam_unix/passverify.c:80-92)
an empty submitted password is refused outright, before any comparison, when
the stack does not carry nullok; and a stored field whose first character
is * or ! is refused outright whatever was submitted. The helper models
neither, so it reports at least one acceptance the module does not, and the
suite is green against a pam_unix that does not exist. That is
docs/traps.md, "Asserting the configuration is not asserting the
behaviour", with the stub half of "the test stub has to be as impolite as
the program".

The instrument that would have caught it is sound; it was just fed wrong.
pam_read_passwords() (libpam/include/pam_inline.h) reads NUL-separated
strings, so

printf '%s' "$password" | /usr/sbin/unix_chkpwd root nullok

delivers nothing and the helper tests the empty password whatever you piped
in — which is also why that sandbox looked as though it "refused every real
comparison". It does not; the user namespace is not the problem. Add the
terminator and a correct password returns 0:

printf '%s\0' "$password" | /usr/sbin/unix_chkpwd root nullok|nonull

with a scratch shadow bind-mounted over /etc/shadow under unshare -r -m.
That gives a real table over both options and every field you care about.
Please correct the helper to carry both rules, re-run, and re-derive the
"each does part of it" sentence in the PR body and the wording in
changes/turnkey.changelog from the result — one half of it does not
survive the corrected model.

HIGH — conf/turnkey.d/webmin-enable:17-22: there is no way back, and
nothing says so.
/run/inithooks-complete is written by inithooks' run
at the end of the everyboot pass, on every boot, and /run is a tmpfs. So
this does not gate one boot; it gates every boot for the life of the
appliance, behind inithooks.service reaching that line. If it ever does
not — docs/traps.md entry 1 is a first boot that blocks forever in one
hook, hit three times here — the appliance has no web interface and no
console, on every boot, and systemctl start webmin exits 0 and starts
nothing, because systemd counts an unmet condition as a skipped, successful
start. At minimum the changelog and the comment need the recovery line
(touch /run/inithooks-complete, or start
webmin-after-firstboot.service). Better would be a second thing the
drop-in accepts that means "this is not the first boot", so a later boot
does not inherit the first boot's gate.

MEDIUM — conf/turnkey.d/webmin-enable: the marker does not mean what the
hold wants it to mean.
exec_scripts logs a failing script and carries on,
and the marker is touched afterwards regardless, so a boot in which
30rootpass exits non-zero still opens the interface. That is survivable
only because rootpass now writes a field nothing matches — which makes
rootpass, not the hold, the change carrying the weight. The PR body reads
the other way round; worth correcting, because it changes what a future
reader thinks is safe to weaken.

MEDIUM — conf/turnkey.d/webmin-pam:19: one stack out of the set.
/etc/pam.d/common-auth on a built core carries pam_unix.so nullok as
well — it is Debian's default — and it is the stack nearly everything else
includes. "No appliance account is meant to be reachable that way and
nothing in an appliance asked for the option" applies to it at least as
strongly as to the one file this script edits. Either widen the script or
open the follow-up and name it here.

MEDIUM — conf/samba-rootpass:7,10: both of this PR's defects, untouched.
It carries the same placeholder and the same unquoted $ROOT_PASS expansion
this PR fixes two files away. "No Keel appliance builds it" is a good reason
not to ask an appliance boot about it; it is not a reason to leave the
second copy in the tree while the first is being corrected, and the PR body
points straight at it. It is two lines plus a look at smbpasswd -n.

LOW — conf/turnkey.d/webmin-enable: the drop-in is never verified
against the unit it modifies.
systemd-analyze verify is handed the two
new units; the drop-in is only ever read by systemd-analyze condition over
its own Condition lines. A packaged webmin.service that one day carries a
plain Condition*= of its own would AND with these two and never be
satisfied. Verifying webmin.service with the drop-in in place costs one
line.

LOW — tests/before-firstboot.bats: the "four of five" is not reproducible
as written.
Against the previous scripts with webmin-pam absent
altogether, all five die at status 127 on the missing script. Keeping
webmin-pam and reverting only the other two gives exactly the four you
quote. Say which baseline the number is against.

LOW — tests/coverage.sh: a three-way conflict, and not a mechanical
one.
#5 and #8 both rewrite this file into a targets=("file:suite") shape
with one kcov run per file; this branch rewrites it into one run over a
measured list. Whichever lands second has to be rewritten, not rebased.
This is the security change — land it first and let the other two adopt
whichever shape wins.


The change does what it sets out to do, and the three parts are the right
three. The findings above are about what the tests prove, what happens on
the boot after the one this was written for, and one file left half-done.
None of them is a reason to keep the current state.

Warning

marcos-mendez and others added 5 commits September 29, 2026 03:23
The helpers answered "would this password get in" with a hand written model
of pam_unix: crypt() through perl wrapped in two rules. The model was
missing rules the module has, and reported at least one acceptance the
module does not make: with a blank equivalent field and no nullok it let
the empty password in, and pam_unix refuses it.

tests/pam-authenticate now asks Linux-PAM itself. It loads libpam.so.0
through ctypes, starts the scratch image's own stack with
pam_start_confdir and calls pam_authenticate, in a user and mount
namespace where the scratch shadow and passwd files are bind mounted over
/etc/shadow and /etc/passwd. pam_unix runs in that process as root of the
namespace, which is the path Webmin's miniserv takes; unix_chkpwd, the
helper for unprivileged callers, is not the same path and does not give
the same answers (with nullok and a blank equivalent field it refuses a
non-empty password, and the in process module accepts any password
without asking for one). The fail delay is handed to the application and
ignored, which changes the time and not the verdict.

The verdict is read from the PAM return code the client prints, never
from an exit status, because unshare and mount exit 1 when they fail and
a sandbox that does not work must not read as a refusal. Refutations use
a new 'refuses' helper for the same reason, never '! authenticates'.
Mutation checked: with an unshare that exits 1, every test that asks PAM
fails.

tests/pam-unix.bats pins the facts the other suites rest on, measured on
libpam 1.7.0-5:

    field '' or 'U6aMy0wojraho', nullok     any password, no prompt
    same fields, no nullok                  every password refused
    field starting '*' or '!', either       every password refused

One test did not survive the real module, and it was the claim that
removing nullok leaves the empty password to the field. It does not: on
the webmin stack, without nullok, the old placeholder refuses every
password. What the field is for is every stack that keeps nullok, and
Debian's common-auth does; tests/fixtures/pam.d-common-auth is that file
as a built core carries it, and the rewritten test shows the old field
letting anything in through it and '*' refusing everything.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
The previous commit's sandbox needs a user namespace that may mount. The
Ubuntu runner the CI uses makes the namespace but its AppArmor policy
leaves it without the right to mount, and every test that asks PAM
failed there, loudly, which is what the verdict parsing is for.

sandbox_mount_ns tries the unprivileged namespace first and falls back to
'sudo --non-interactive unshare --mount' where sudo needs no password,
which the runner has. Either way the bind mounts live in a private mount
namespace and are never visible outside it, and the module asked is the
same one. Where neither is available it says so and the tests fail; they
do not skip.

Both paths measured locally: 42 of 42, and no shadow mount left behind.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
Webmin's drop-in waited for /run/inithooks-complete, which inithooks
writes at the end of the everyboot pass on every boot, on a tmpfs. So the
hold did not gate one boot: it gated every boot for the life of the
appliance behind inithooks.service reaching that line, and a boot where it
did not (a hook blocked on a console nobody is attached to, docs/traps.md
entry 1) had no web interface, while 'systemctl start webmin' exited 0 and
started nothing, because an unmet condition is a skipped, successful start.

The one shot the path unit triggers now records that the interface was
opened, in /var/lib/webmin-after-firstboot/opened, before it starts
Webmin, and the drop-in accepts that record as a third triggering
condition. The first boot is held as before; every boot after one that
got to the end of its scripts starts Webmin with the rest of the boot. The
build writes no record, and a test says so.

The script's comment now says what the marker does not mean (scripts run,
not scripts succeeded; rootpass's field is what holds an account shut on a
first boot that failed to set a password) and gives the recovery for a
first boot that never finishes: 'systemctl start
webmin-after-firstboot.service', which opens it for that boot and every
one after.

Tests, written first and failing on the previous script for the two later
boot cases and the recovery:

- the unit and its drop-in are handed to systemd together:
  tests/fixtures/webmin.service is the unit webmin 2.660.turnkey0 installs
  (read from a built container), 'systemd-analyze verify' reads it with the
  drop-in in place, and the verdict on the conditions is taken over both
  files, so a plain Condition in the packaged unit, which would AND with
  these, is seen;
- the one shot's ExecStart lines are run in order, with systemctl stubbed
  and mkdir and touch real, and the next boot is a boot with an empty /run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
…assword out of the log

conf/samba-rootpass carried both of the defects this branch fixes in
conf/turnkey.d/rootpass, and a third of its own:

- with no ROOT_PASS it wrote 'U6aMy0wojraho', the crypt() of the empty
  string, and it runs after rootpass (mk/turnkey/fileserver.mk lists it in
  COMMON_CONF), so a Samba appliance had rootpass's '*' written back over.
  Through a stack carrying nullok, Debian's common-auth among them, the
  real pam_unix lets any password in against that field;
- ROOT_PASS was expanded unquoted, against the build directory;
- '#!/bin/sh -ex' traced 'echo root:$ROOT_PASS' and the smbpasswd feed
  into the build log, with the password in them.

It now writes '*', quotes the password and does not trace. The shebang is
bash, for kcov, as rootpass's is. 'smbpasswd -a -n' is unchanged: a null
Samba password is refused unless smb.conf sets 'null passwords = yes', and
nothing in this tree does.

tests/samba-rootpass.bats, 6 tests, 4 of them failing on the previous
script: nothing authenticates through the webmin stack or common-auth, the
unlock runs first and the field it leaves is one unlock accepts, a glob is
set as written, and the password is in neither output stream. smbpasswd
is a stub that records its arguments and what it was fed. The file joins
the measured list at 100 percent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
…libpam

The CI runner (Ubuntu 24.04, libpam 1.5.3) failed five tests that pass on
Debian 13 (libpam 1.7.0-5), all of them about the old placeholder
'U6aMy0wojraho', the crypt() of the empty string. The module is not the
same module:

- pam_unix runs unix_chkpwd for every shadowed account, as root too
  (get_account_info returns PAM_UNIX_RUN_HELPER outside the helper). The
  first commit of this series said the in process path does not; it does,
  and the helpers and tests/pam-authenticate now say so.
- Before asking for a password, _unix_blankpasswd() hands the helper an
  empty one, with nullok when the stack has it. If the helper accepts, the
  account is authenticated without being asked. Measured by bind mounting a
  helper that always refuses over unix_chkpwd in the sandbox: the
  "any password, no prompt" verdict turns into a prompt and a refusal.
- In 1.7, verify_pwd_hash() refuses an empty password without nullok and
  otherwise compares it; under nullok the empty password matches
  'U6aMy0wojraho', so any password gets in. In 1.5 it refuses the empty
  password under nullok unless the field is empty, and compares it
  without, so there the same field lets the empty password in when nullok
  is gone. (Linux-PAM v1.5.3 and v1.7.0, modules/pam_unix/passverify.c.)

So on the libpam an appliance runs, removing nullok alone closed the old
field, and on 1.5 it did not; '*' is refused by both, through any stack.

require_measured_libpam skips a test whose verdict is version dependent,
naming both versions, when libpam-modules here is not 1.7. Five tests call
it; the facts that hold on both (an empty field, '*' and '!', a real hash)
and every test of the scripts themselves do not. Mutation checked by
setting the measured version to 1.5: exactly those five skip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
marcos-mendez and others added 3 commits September 29, 2026 03:37
The changelog said removing nullok stops a wrong password and the field
stops the empty one. Asked of the real module, on the libpam an appliance
runs, the old field with nullok let any password in, the field '*' closes
it through every stack, and removing nullok from Webmin's stack narrows
what reaches it. The rootpass entry now says it is the change that holds
the account shut, and why; the webmin-pam entry says what it does and that
common-auth keeps the option (#13, opened for it, with
why it needs a pam-auth-update profile and not an edit). The webmin-enable
entry gains the later boots and the recovery, and samba-rootpass gets its
own.

COVERAGE.md: five files, 48 of 48 lines, 57 bats, and the paragraph on
what is real rewritten: libpam through pam_authenticate, the version
dependence and the five tests the CI runner skips because of it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
tests/before-firstboot.bats claims to ask a built image the only question
worth asking, and asked it through Webmin's stack only. Measured against
the previous rootpass with this branch's webmin-pam, on libpam 1.7, all
its password tests passed: removing nullok from that one stack is enough
to close the old field there. So nothing in the file would notice rootpass
going back to 'U6aMy0wojraho'.

The new test asks through common-auth as a built core carries it, which
keeps nullok (#13), and fails against the previous rootpass. With it the
file fails 4 of its 6 tests against the previous rootpass and
webmin-enable, and 1 (this one) against the previous rootpass alone.
COVERAGE.md counts 58 bats.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
…ning

19.x gained #8 and #10, and tests/coverage.sh in #8's shape: one kcov run
per measured file, each with the suite that exercises it. This branch had
rewritten it into one run over a list; its files move into #8's shape as
targets (samba-rootpass, rootpass, webmin-enable, webmin-pam, each with its
own suite, all at 100), and the two suites that measure no file of their
own, before-firstboot.bats and pam-unix.bats, run after the loop so they
still gate. The changelog keeps both sides' entries; COVERAGE.md keeps this
branch's section under 19.x's baseline heading.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
@marcos-mendez

Copy link
Copy Markdown
Collaborator Author

Review addressed in 7ba662a..7d4ef76, all new commits. The tests now call pam_authenticate through the real libpam over the scratch stack. On libpam 1.7 the old field let any password in without a prompt through any nullok stack, so * is what holds the account shut. The PR body and changelog now say that, and common-auth is #13. Webmin now opens for good once a first boot completes, with a recovery line, and samba-rootpass is fixed. CI runs libpam 1.5.3, so the 5 version-specific tests skip there by name.

@marcos-mendez
marcos-mendez merged commit 2ad42ce into 19.x Sep 29, 2026
1 check passed
marcos-mendez added a commit that referenced this pull request Sep 29, 2026
Hold an image shut until its first boot has set a password
@marcos-mendez
marcos-mendez deleted the fix/webmin-auth-hardening branch September 29, 2026 06:45
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.

Harden what a built image leaves in its accounts and in the Webmin authentication path

1 participant