Fix: Complete SSH bootstrap hardening
This commit is contained in:
@@ -138,10 +138,16 @@ steps, each checked and skipped when already true:
|
|||||||
exists, use it.
|
exists, use it.
|
||||||
2. Ensure the user has a password (needed for sudo) — `passwd` interactively
|
2. Ensure the user has a password (needed for sudo) — `passwd` interactively
|
||||||
if none is set.
|
if none is set.
|
||||||
3. Copy root's `authorized_keys` to the user when the user has none.
|
3. Copy root's `authorized_keys` only after OpenSSH parses every key line. The
|
||||||
4. Offer sshd hardening (yes/no, default yes): drop-in under
|
destination directory and file are created as the target UID at `0700/0600`
|
||||||
`/etc/ssh/sshd_config.d/` with `PermitRootLogin no`,
|
and revalidated before hardening is available.
|
||||||
`PasswordAuthentication no`, then reload sshd. Skipped when already set.
|
4. Offer sshd hardening (yes/no, default yes): atomically install
|
||||||
|
`/etc/ssh/sshd_config.d/00-panama.conf` with `PermitRootLogin no`,
|
||||||
|
`PasswordAuthentication no`, and `KbdInteractiveAuthentication no`. Run
|
||||||
|
`sshd -t` plus effective root/target `sshd -T -C` checks before reloading the
|
||||||
|
detected unit. Restore a prior regular file with metadata on failure. A
|
||||||
|
missing unit or unsupported existing drop-in leaves SSH unchanged and
|
||||||
|
bootstrap continues. The later binding transaction design owns the details.
|
||||||
5. Move/clone the checkout under the user's home, chown it, and re-exec
|
5. Move/clone the checkout under the user's home, chown it, and re-exec
|
||||||
`install --server` as that user.
|
`install --server` as that user.
|
||||||
|
|
||||||
|
|||||||
@@ -99,10 +99,19 @@ Before it offers SSH hardening, it copies a safe root key when possible or
|
|||||||
verifies the target key. The target user's `.ssh` must be owned by that user at
|
verifies the target key. The target user's `.ssh` must be owned by that user at
|
||||||
`0700`, and `authorized_keys` must be owned by that user at `0600`. Without a
|
`0700`, and `authorized_keys` must be owned by that user at `0600`. Without a
|
||||||
verified target key, SSH hardening is unavailable and the bootstrap continues.
|
verified target key, SSH hardening is unavailable and the bootstrap continues.
|
||||||
|
Every non-comment key line must parse with `ssh-keygen`. Root-key destination
|
||||||
|
creation and writing run as the target UID, followed by the same owner, mode,
|
||||||
|
and key checks. Do not replace that with root writes or assume the user's
|
||||||
|
primary group matches the username.
|
||||||
|
|
||||||
Accepted hardening uses an atomic same-directory `sshd_config.d` drop-in,
|
Accepted hardening uses atomic same-directory `00-panama.conf` with exactly
|
||||||
runs `sshd -t`, then reloads the detected SSH unit. Validation or reload
|
`PermitRootLogin no`, `PasswordAuthentication no`, and
|
||||||
failure restores the previous drop-in before it retries validation and reload;
|
`KbdInteractiveAuthentication no`. A pre-existing symlink or non-regular
|
||||||
failed recovery stops the handoff with manual recovery instructions. The
|
object makes hardening unavailable, as does a missing SSH unit. Panama runs
|
||||||
fixture contracts exercise those branches. `panama test --safe` never reloads
|
`sshd -t`, then checks effective root and target-user policy with `sshd -T -C`
|
||||||
a live daemon, so it is not live-host proof.
|
before reloading the detected unit. Validation or reload failure restores a
|
||||||
|
prior regular file with its metadata before it retries validation and reload.
|
||||||
|
Failed recovery stops the handoff with instructions that distinguish a prior
|
||||||
|
file from no prior file. The fixture contracts also cover declined hardening
|
||||||
|
and interrupted preparation. `panama test --safe` never reloads a live daemon,
|
||||||
|
so it is not live-host proof.
|
||||||
|
|||||||
@@ -45,15 +45,24 @@ That command also works from a brand-new VPS's **root** login. It creates or
|
|||||||
reuses your sudo-enabled user, then copies a safe root key when it can or
|
reuses your sudo-enabled user, then copies a safe root key when it can or
|
||||||
verifies the target key before offering SSH hardening. A verified target key
|
verifies the target key before offering SSH hardening. A verified target key
|
||||||
means the target user owns `.ssh` with mode `0700` and `authorized_keys` with
|
means the target user owns `.ssh` with mode `0700` and `authorized_keys` with
|
||||||
mode `0600`. SSH hardening is unavailable without a verified target key, and
|
mode `0600`. Every non-comment line in `authorized_keys` must be valid OpenSSH
|
||||||
the install continues without it.
|
key material that `ssh-keygen` can parse. Root-key destination writes run as
|
||||||
|
the target user, and Panama rechecks the resulting owner, modes, and keys. SSH
|
||||||
|
hardening is unavailable without a verified target key or installed SSH unit,
|
||||||
|
and the install continues without it. Declining hardening also leaves SSH
|
||||||
|
unchanged.
|
||||||
|
|
||||||
When you accept hardening, Panama makes an atomic same-directory drop-in,
|
When you accept hardening, Panama uses an atomic same-directory drop-in named
|
||||||
validates the complete SSH configuration with `sshd -t`, then reloads the
|
`00-panama.conf`, whose early filename gives it safer precedence. A pre-existing
|
||||||
detected SSH unit. If validation or reload fails, it restores the previous
|
symlink or non-regular object makes hardening unavailable. The effective policy
|
||||||
drop-in and validates and reloads that restored configuration; recovery that
|
is exactly `PermitRootLogin no`, `PasswordAuthentication no`, and
|
||||||
cannot complete stops the handoff and prints the manual recovery command. The
|
`KbdInteractiveAuthentication no`. Panama validates syntax with `sshd -t` and
|
||||||
fixture contracts exercise these branches. No real daemon reload runs under
|
checks `sshd -T -C` for both root and target-user contexts before it reloads
|
||||||
|
the detected SSH unit. If validation or reload fails, it restores the previous
|
||||||
|
drop-in regular file with its metadata and validates and reloads that restored
|
||||||
|
configuration. Recovery that cannot complete stops the handoff and prints the
|
||||||
|
right manual command for either a prior file or no prior file. The fixture
|
||||||
|
contracts test these branches. No real daemon reload runs under
|
||||||
`panama test --safe`, so that suite is not live-host proof.
|
`panama test --safe`, so that suite is not live-host proof.
|
||||||
|
|
||||||
After that, it hands off to a normal install as the new user.
|
After that, it hands off to a normal install as the new user.
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ system_path() {
|
|||||||
printf '%s%s\n' "$BOOT_ROOT" "$path"
|
printf '%s%s\n' "$BOOT_ROOT" "$path"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
valid_authorized_keys() {
|
||||||
|
local keys="$1" line saw_key=0
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
if [[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! ssh-keygen -l -f /dev/stdin >/dev/null 2>&1 <<<"$line"; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
saw_key=1
|
||||||
|
done <"$keys"
|
||||||
|
(( saw_key ))
|
||||||
|
}
|
||||||
|
|
||||||
safe_authorized_keys() {
|
safe_authorized_keys() {
|
||||||
local username="$1" user_home="$2" uid ssh_dir keys
|
local username="$1" user_home="$2" uid ssh_dir keys
|
||||||
uid="$(id -u "$username")" || return 1
|
uid="$(id -u "$username")" || return 1
|
||||||
@@ -57,7 +71,7 @@ safe_authorized_keys() {
|
|||||||
[[ -d "$ssh_dir" && ! -L "$ssh_dir" && -f "$keys" && ! -L "$keys" ]] || return 1
|
[[ -d "$ssh_dir" && ! -L "$ssh_dir" && -f "$keys" && ! -L "$keys" ]] || return 1
|
||||||
[[ "$(stat -Lc '%u:%a' "$ssh_dir")" == "$uid:700" ]] || return 1
|
[[ "$(stat -Lc '%u:%a' "$ssh_dir")" == "$uid:700" ]] || return 1
|
||||||
[[ "$(stat -Lc '%u:%a' "$keys")" == "$uid:600" ]] || return 1
|
[[ "$(stat -Lc '%u:%a' "$keys")" == "$uid:600" ]] || return 1
|
||||||
grep -qEv '^[[:space:]]*(#|$)' "$keys"
|
valid_authorized_keys "$keys"
|
||||||
}
|
}
|
||||||
|
|
||||||
safe_root_authorized_keys() {
|
safe_root_authorized_keys() {
|
||||||
@@ -65,7 +79,7 @@ safe_root_authorized_keys() {
|
|||||||
keys="$(system_path /root/.ssh/authorized_keys)" || return 1
|
keys="$(system_path /root/.ssh/authorized_keys)" || return 1
|
||||||
[[ -f "$keys" && ! -L "$keys" ]] || return 1
|
[[ -f "$keys" && ! -L "$keys" ]] || return 1
|
||||||
[[ "$(stat -Lc '%u:%a' "$keys")" == '0:600' ]] || return 1
|
[[ "$(stat -Lc '%u:%a' "$keys")" == '0:600' ]] || return 1
|
||||||
grep -qEv '^[[:space:]]*(#|$)' "$keys"
|
valid_authorized_keys "$keys"
|
||||||
}
|
}
|
||||||
|
|
||||||
detect_ssh_unit() {
|
detect_ssh_unit() {
|
||||||
@@ -81,8 +95,9 @@ detect_ssh_unit() {
|
|||||||
|
|
||||||
restore_ssh_dropin() {
|
restore_ssh_dropin() {
|
||||||
local restore
|
local restore
|
||||||
if [[ -n "${ssh_backup:-}" && -e "$ssh_backup" ]]; then
|
if (( ssh_had_prior )); then
|
||||||
restore="$(mktemp --tmpdir="$sshd_dir" .90-panama.XXXXXX.restore)" || return 1
|
[[ -n "$ssh_backup" && -f "$ssh_backup" && ! -L "$ssh_backup" ]] || return 1
|
||||||
|
restore="$(mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.restore)" || return 1
|
||||||
if ! cp -a -- "$ssh_backup" "$restore"; then
|
if ! cp -a -- "$ssh_backup" "$restore"; then
|
||||||
remove_ssh_artifact "$restore" || true
|
remove_ssh_artifact "$restore" || true
|
||||||
return 1
|
return 1
|
||||||
@@ -106,7 +121,7 @@ restore_ssh_transaction_traps() {
|
|||||||
|
|
||||||
remove_ssh_artifact() {
|
remove_ssh_artifact() {
|
||||||
local artifact="$1"
|
local artifact="$1"
|
||||||
[[ -n "$artifact" && -e "$artifact" ]] || return 0
|
[[ -n "$artifact" && ( -e "$artifact" || -L "$artifact" ) ]] || return 0
|
||||||
if rm -f -- "$artifact"; then
|
if rm -f -- "$artifact"; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -115,121 +130,164 @@ remove_ssh_artifact() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print_ssh_recovery() {
|
||||||
|
if (( ssh_had_prior )); then
|
||||||
|
printf 'SSH rollback needs manual recovery. Backup: %s\n' "$ssh_backup" >&2
|
||||||
|
printf ' cp -a -- %q %q\n' "$ssh_backup" "$ssh_dropin" >&2
|
||||||
|
else
|
||||||
|
printf 'SSH rollback needs manual recovery. No prior drop-in existed.\n' >&2
|
||||||
|
printf ' rm -f -- %q\n' "$ssh_dropin" >&2
|
||||||
|
fi
|
||||||
|
printf ' sshd -t\n' >&2
|
||||||
|
printf ' systemctl reload %s\n' "$ssh_unit" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
policy_is_no() {
|
||||||
|
local policy="$1" setting="$2"
|
||||||
|
awk -v setting="$setting" '
|
||||||
|
$1 == setting { count += 1; if ($2 != "no") bad = 1 }
|
||||||
|
END { exit count != 1 || bad }
|
||||||
|
' <<<"$policy"
|
||||||
|
}
|
||||||
|
|
||||||
|
effective_ssh_policy_is_hardened() {
|
||||||
|
local username="$1" root_policy target_policy context
|
||||||
|
context='host=localhost,addr=127.0.0.1'
|
||||||
|
root_policy="$(sshd -T -C "user=root,$context")" || return 1
|
||||||
|
policy_is_no "$root_policy" permitrootlogin || return 1
|
||||||
|
policy_is_no "$root_policy" passwordauthentication || return 1
|
||||||
|
policy_is_no "$root_policy" kbdinteractiveauthentication || return 1
|
||||||
|
|
||||||
|
target_policy="$(sshd -T -C "user=$username,$context")" || return 1
|
||||||
|
policy_is_no "$target_policy" passwordauthentication || return 1
|
||||||
|
policy_is_no "$target_policy" kbdinteractiveauthentication
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback_ssh_transaction() {
|
||||||
|
local reload_restored="$1" rollback_failed=0
|
||||||
|
restore_ssh_dropin || rollback_failed=1
|
||||||
|
sshd -t || rollback_failed=1
|
||||||
|
if (( reload_restored )); then
|
||||||
|
systemctl reload "$ssh_unit" || rollback_failed=1
|
||||||
|
fi
|
||||||
|
ssh_transaction_state=""
|
||||||
|
restore_ssh_transaction_traps
|
||||||
|
if (( rollback_failed )); then
|
||||||
|
print_ssh_recovery
|
||||||
|
else
|
||||||
|
remove_ssh_artifact "$ssh_backup" || true
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_ssh_transaction_exit() {
|
||||||
|
if [[ "$ssh_transaction_state" == preparing \
|
||||||
|
|| ( "$ssh_transaction_state" == activating && -e "$ssh_candidate" ) ]]; then
|
||||||
|
remove_ssh_artifact "$ssh_candidate" || true
|
||||||
|
remove_ssh_artifact "$ssh_backup" || true
|
||||||
|
elif [[ "$ssh_transaction_state" == activating || "$ssh_transaction_state" == activated ]]; then
|
||||||
|
restore_ssh_dropin || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
handle_ssh_transaction_signal() {
|
handle_ssh_transaction_signal() {
|
||||||
local signal_status="$1"
|
local signal_status="$1"
|
||||||
trap - INT TERM
|
trap - INT TERM
|
||||||
if [[ -n "$ssh_candidate" && -e "$ssh_candidate" ]]; then
|
if [[ "$ssh_transaction_state" == preparing \
|
||||||
ssh_transaction_active=0
|
|| ( "$ssh_transaction_state" == activating && -e "$ssh_candidate" ) ]]; then
|
||||||
|
ssh_transaction_state=""
|
||||||
restore_ssh_transaction_traps
|
restore_ssh_transaction_traps
|
||||||
remove_ssh_artifact "$ssh_candidate" || true
|
remove_ssh_artifact "$ssh_candidate" || true
|
||||||
remove_ssh_artifact "$ssh_backup" || true
|
remove_ssh_artifact "$ssh_backup" || true
|
||||||
exit "$signal_status"
|
|
||||||
fi
|
|
||||||
rollback_failed=0
|
|
||||||
restore_ssh_dropin || rollback_failed=1
|
|
||||||
sshd -t || rollback_failed=1
|
|
||||||
systemctl reload "$ssh_unit" || rollback_failed=1
|
|
||||||
ssh_transaction_active=0
|
|
||||||
restore_ssh_transaction_traps
|
|
||||||
if (( rollback_failed )); then
|
|
||||||
printf 'SSH rollback needs manual recovery. Backup: %s\n' "$ssh_backup" >&2
|
|
||||||
printf ' cp -a -- %q %q\n' "$ssh_backup" "$ssh_dropin" >&2
|
|
||||||
printf ' sshd -t\n' >&2
|
|
||||||
printf ' systemctl reload %s\n' "$ssh_unit" >&2
|
|
||||||
else
|
else
|
||||||
remove_ssh_artifact "$ssh_backup" || true
|
rollback_ssh_transaction 1 || true
|
||||||
fi
|
fi
|
||||||
exit "$signal_status"
|
exit "$signal_status"
|
||||||
}
|
}
|
||||||
|
|
||||||
harden_server_ssh() {
|
harden_server_ssh() {
|
||||||
local username="$1" user_home="$2" sshd_dir ssh_dropin harden ssh_unit
|
local username="$1" user_home="$2" sshd_dir ssh_dropin harden ssh_unit
|
||||||
local ssh_candidate="" ssh_backup="" rollback_failed=0
|
local ssh_candidate="" ssh_backup="" ssh_had_prior=0
|
||||||
local ssh_transaction_active=0
|
local ssh_transaction_state=""
|
||||||
local ssh_saved_exit_trap ssh_saved_int_trap ssh_saved_term_trap
|
local ssh_saved_exit_trap ssh_saved_int_trap ssh_saved_term_trap
|
||||||
sshd_dir="$(system_path /etc/ssh/sshd_config.d)" || return 1
|
sshd_dir="$(system_path /etc/ssh/sshd_config.d)" || return 1
|
||||||
ssh_dropin="$sshd_dir/90-panama.conf"
|
ssh_dropin="$sshd_dir/00-panama.conf"
|
||||||
|
|
||||||
printf 'Harden sshd (disable root login and password auth)? [Y/n]: '
|
if [[ -L "$ssh_dropin" || ( -e "$ssh_dropin" && ! -f "$ssh_dropin" ) ]]; then
|
||||||
|
printf 'SSH hardening unavailable: %s is not a regular file\n' "$ssh_dropin" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! ssh_unit="$(detect_ssh_unit)"; then
|
||||||
|
echo "SSH hardening unavailable: neither sshd.service nor ssh.service is installed" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Harden sshd (disable root, password, and keyboard-interactive authentication)? [Y/n]: '
|
||||||
read -r harden </dev/tty || harden=""
|
read -r harden </dev/tty || harden=""
|
||||||
if [[ "$harden" =~ ^[Nn] ]]; then
|
if [[ "$harden" =~ ^[Nn] ]]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! ssh_unit="$(detect_ssh_unit)"; then
|
ssh_saved_exit_trap="$(trap -p EXIT)"
|
||||||
echo "SSH hardening failed: neither sshd.service nor ssh.service exists" >&2
|
ssh_saved_int_trap="$(trap -p INT)"
|
||||||
|
ssh_saved_term_trap="$(trap -p TERM)"
|
||||||
|
ssh_transaction_state=preparing
|
||||||
|
trap 'handle_ssh_transaction_exit' EXIT
|
||||||
|
trap 'handle_ssh_transaction_signal 130' INT
|
||||||
|
trap 'handle_ssh_transaction_signal 143' TERM
|
||||||
|
|
||||||
|
if ! ssh_candidate="$(umask 077; mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.tmp)"; then
|
||||||
|
ssh_transaction_state=""
|
||||||
|
restore_ssh_transaction_traps
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
if ! printf 'PermitRootLogin no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\n' >"$ssh_candidate"; then
|
||||||
ssh_candidate="$(umask 077; mktemp --tmpdir="$sshd_dir" .90-panama.XXXXXX.tmp)" || return 1
|
ssh_transaction_state=""
|
||||||
if ! printf 'PermitRootLogin no\nPasswordAuthentication no\n' >"$ssh_candidate"; then
|
restore_ssh_transaction_traps
|
||||||
remove_ssh_artifact "$ssh_candidate" || true
|
remove_ssh_artifact "$ssh_candidate" || true
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -e "$ssh_dropin" ]]; then
|
if [[ -e "$ssh_dropin" ]]; then
|
||||||
ssh_backup="$(umask 077; mktemp --tmpdir="$sshd_dir" .90-panama.XXXXXX.backup)" || {
|
ssh_had_prior=1
|
||||||
|
if ! ssh_backup="$(umask 077; mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.backup)"; then
|
||||||
|
ssh_transaction_state=""
|
||||||
|
restore_ssh_transaction_traps
|
||||||
remove_ssh_artifact "$ssh_candidate" || true
|
remove_ssh_artifact "$ssh_candidate" || true
|
||||||
return 1
|
return 1
|
||||||
}
|
fi
|
||||||
if ! cat -- "$ssh_dropin" >"$ssh_backup"; then
|
if ! cp -a -- "$ssh_dropin" "$ssh_backup"; then
|
||||||
|
ssh_transaction_state=""
|
||||||
|
restore_ssh_transaction_traps
|
||||||
remove_ssh_artifact "$ssh_candidate" || true
|
remove_ssh_artifact "$ssh_candidate" || true
|
||||||
remove_ssh_artifact "$ssh_backup" || true
|
remove_ssh_artifact "$ssh_backup" || true
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ssh_saved_exit_trap="$(trap -p EXIT)"
|
ssh_transaction_state=activating
|
||||||
ssh_saved_int_trap="$(trap -p INT)"
|
|
||||||
ssh_saved_term_trap="$(trap -p TERM)"
|
|
||||||
trap 'if [[ "${ssh_transaction_active:-0}" == 1 ]]; then restore_ssh_dropin || true; fi' EXIT
|
|
||||||
trap 'handle_ssh_transaction_signal 130' INT
|
|
||||||
trap 'handle_ssh_transaction_signal 143' TERM
|
|
||||||
ssh_transaction_active=1
|
|
||||||
|
|
||||||
if ! mv -f -- "$ssh_candidate" "$ssh_dropin"; then
|
if ! mv -f -- "$ssh_candidate" "$ssh_dropin"; then
|
||||||
ssh_transaction_active=0
|
ssh_transaction_state=""
|
||||||
restore_ssh_transaction_traps
|
restore_ssh_transaction_traps
|
||||||
remove_ssh_artifact "$ssh_candidate" || true
|
remove_ssh_artifact "$ssh_candidate" || true
|
||||||
remove_ssh_artifact "$ssh_backup" || true
|
remove_ssh_artifact "$ssh_backup" || true
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
ssh_candidate=""
|
ssh_candidate=""
|
||||||
|
ssh_transaction_state=activated
|
||||||
|
|
||||||
if ! sshd -t; then
|
if ! sshd -t || ! effective_ssh_policy_is_hardened "$username"; then
|
||||||
restore_ssh_dropin || rollback_failed=1
|
rollback_ssh_transaction 0 || true
|
||||||
sshd -t || rollback_failed=1
|
|
||||||
ssh_transaction_active=0
|
|
||||||
restore_ssh_transaction_traps
|
|
||||||
if (( rollback_failed )); then
|
|
||||||
printf 'SSH rollback needs manual recovery. Backup: %s\n' "$ssh_backup" >&2
|
|
||||||
printf ' cp -a -- %q %q\n' "$ssh_backup" "$ssh_dropin" >&2
|
|
||||||
printf ' sshd -t\n' >&2
|
|
||||||
printf ' systemctl reload %s\n' "$ssh_unit" >&2
|
|
||||||
else
|
|
||||||
remove_ssh_artifact "$ssh_backup" || true
|
|
||||||
fi
|
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! systemctl reload "$ssh_unit"; then
|
if ! systemctl reload "$ssh_unit"; then
|
||||||
restore_ssh_dropin || rollback_failed=1
|
rollback_ssh_transaction 1 || true
|
||||||
sshd -t || rollback_failed=1
|
|
||||||
systemctl reload "$ssh_unit" || rollback_failed=1
|
|
||||||
ssh_transaction_active=0
|
|
||||||
restore_ssh_transaction_traps
|
|
||||||
if (( rollback_failed )); then
|
|
||||||
printf 'SSH rollback needs manual recovery. Backup: %s\n' "$ssh_backup" >&2
|
|
||||||
printf ' cp -a -- %q %q\n' "$ssh_backup" "$ssh_dropin" >&2
|
|
||||||
printf ' sshd -t\n' >&2
|
|
||||||
printf ' systemctl reload %s\n' "$ssh_unit" >&2
|
|
||||||
else
|
|
||||||
remove_ssh_artifact "$ssh_backup" || true
|
|
||||||
fi
|
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ssh_transaction_active=0
|
ssh_transaction_state=""
|
||||||
restore_ssh_transaction_traps
|
restore_ssh_transaction_traps
|
||||||
remove_ssh_artifact "$ssh_backup" || return 1
|
remove_ssh_artifact "$ssh_backup" || return 1
|
||||||
echo "Wrote $ssh_dropin; make sure your key works before logging out."
|
echo "Wrote $ssh_dropin; make sure your key works before logging out."
|
||||||
@@ -305,7 +363,6 @@ if [[ "$(id -u)" -eq 0 ]]; then
|
|||||||
&& ! -L "$user_ssh_dir" ]] && safe_root_authorized_keys; then
|
&& ! -L "$user_ssh_dir" ]] && safe_root_authorized_keys; then
|
||||||
copy_root_key=0
|
copy_root_key=0
|
||||||
if [[ ! -e "$user_ssh_dir" ]]; then
|
if [[ ! -e "$user_ssh_dir" ]]; then
|
||||||
mkdir -p "$user_ssh_dir"
|
|
||||||
copy_root_key=1
|
copy_root_key=1
|
||||||
elif [[ ! -d "$user_ssh_dir" \
|
elif [[ ! -d "$user_ssh_dir" \
|
||||||
|| "$(stat -Lc '%u:%a' "$user_ssh_dir")" != "$(id -u "$username"):700" ]]; then
|
|| "$(stat -Lc '%u:%a' "$user_ssh_dir")" != "$(id -u "$username"):700" ]]; then
|
||||||
@@ -315,15 +372,19 @@ if [[ "$(id -u)" -eq 0 ]]; then
|
|||||||
fi
|
fi
|
||||||
if (( copy_root_key )); then
|
if (( copy_root_key )); then
|
||||||
echo "Copying root's authorized_keys to $username"
|
echo "Copying root's authorized_keys to $username"
|
||||||
cp "$(system_path /root/.ssh/authorized_keys)" "$user_keys"
|
root_keys="$(system_path /root/.ssh/authorized_keys)"
|
||||||
chmod 700 "$user_ssh_dir"
|
if ! runuser -u "$username" -- install -d -m 0700 -- "$user_ssh_dir" \
|
||||||
chmod 600 "$user_keys"
|
|| ! runuser -u "$username" -- install -m 0600 -- /dev/stdin "$user_keys" \
|
||||||
chown "$username:$username" "$user_ssh_dir" "$user_keys"
|
<"$root_keys"; then
|
||||||
|
echo "SSH hardening unavailable: could not install root's key for $username" >&2
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if safe_authorized_keys "$username" "$user_home"; then
|
if safe_authorized_keys "$username" "$user_home"; then
|
||||||
if ! harden_server_ssh "$username" "$user_home"; then
|
harden_status=0
|
||||||
|
harden_server_ssh "$username" "$user_home" || harden_status=$?
|
||||||
|
if (( harden_status != 0 && harden_status != 2 )); then
|
||||||
echo "SSH hardening failed; stopping before install handoff." >&2
|
echo "SSH hardening failed; stopping before install handoff." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -14,9 +14,21 @@
|
|||||||
|
|
||||||
- Preserve the public `boot --server` command and the non-root desktop bootstrap path.
|
- Preserve the public `boot --server` command and the non-root desktop bootstrap path.
|
||||||
- Do not reload a real SSH service, edit `/etc/ssh`, create a real account, or run a live privileged check.
|
- Do not reload a real SSH service, edit `/etc/ssh`, create a real account, or run a live privileged check.
|
||||||
- A missing or unsafe target key keeps root/password access unchanged and continues bootstrap.
|
- A missing or unsafe target key, missing SSH unit, declined prompt, or unsupported
|
||||||
|
pre-existing Panama drop-in keeps root/password access unchanged and continues bootstrap.
|
||||||
- Candidate validation or reload failure restores the prior drop-in and stops before clone/install handoff.
|
- Candidate validation or reload failure restores the prior drop-in and stops before clone/install handoff.
|
||||||
- Exact safe state is target ownership plus `.ssh` mode `0700` and `authorized_keys` mode `0600`; symlinks, root UID, relative homes, blank/comment-only keys, and foreign ownership are refused.
|
- Exact safe state is target ownership plus `.ssh` mode `0700` and `authorized_keys`
|
||||||
|
mode `0600`; symlinks, root UID, relative homes, blank/comment-only keys,
|
||||||
|
malformed non-comment lines, and foreign ownership are refused.
|
||||||
|
- The installed policy is `00-panama.conf` with `PermitRootLogin no`,
|
||||||
|
`PasswordAuthentication no`, and `KbdInteractiveAuthentication no`.
|
||||||
|
- Before reload, `sshd -t` and root/target `sshd -T -C` checks must prove the
|
||||||
|
desired effective policy. Earlier main-config precedence therefore fails closed.
|
||||||
|
- Root-key destination creation and writing run as the target UID. Revalidation
|
||||||
|
follows, and no same-named primary group is assumed.
|
||||||
|
- Existing regular drop-ins retain complete metadata on rollback. Symlinks,
|
||||||
|
directories, FIFOs, and other non-regular objects make hardening unavailable.
|
||||||
|
- Transaction traps are armed before the first candidate or backup artifact.
|
||||||
- `PANAMA_BOOT_FIXTURE_ROOT` is accepted only by a real non-root process whose stubbed `id -u` reports root. Real root plus that variable must fail closed.
|
- `PANAMA_BOOT_FIXTURE_ROOT` is accepted only by a real non-root process whose stubbed `id -u` reports root. Real root plus that variable must fail closed.
|
||||||
- The new public contract is hermetic and must run under `panama test --safe`.
|
- The new public contract is hermetic and must run under `panama test --safe`.
|
||||||
- Preserve the user's unstaged `/home/gib/.local/share/Panama/config/bash/.bashrc` change outside this worktree.
|
- Preserve the user's unstaged `/home/gib/.local/share/Panama/config/bash/.bashrc` change outside this worktree.
|
||||||
@@ -75,6 +87,9 @@ Run one table row per unsafe state:
|
|||||||
missing
|
missing
|
||||||
empty
|
empty
|
||||||
comment-only
|
comment-only
|
||||||
|
malformed-key
|
||||||
|
mixed-valid-and-malformed-key
|
||||||
|
malformed-root-key
|
||||||
ssh-directory-symlink
|
ssh-directory-symlink
|
||||||
authorized-keys-symlink
|
authorized-keys-symlink
|
||||||
directory-wrong-mode
|
directory-wrong-mode
|
||||||
@@ -124,11 +139,22 @@ safe_authorized_keys() {
|
|||||||
[[ -d "$ssh_dir" && ! -L "$ssh_dir" && -f "$keys" && ! -L "$keys" ]] || return 1
|
[[ -d "$ssh_dir" && ! -L "$ssh_dir" && -f "$keys" && ! -L "$keys" ]] || return 1
|
||||||
[[ "$(stat -Lc '%u:%a' "$ssh_dir")" == "$uid:700" ]] || return 1
|
[[ "$(stat -Lc '%u:%a' "$ssh_dir")" == "$uid:700" ]] || return 1
|
||||||
[[ "$(stat -Lc '%u:%a' "$keys")" == "$uid:600" ]] || return 1
|
[[ "$(stat -Lc '%u:%a' "$keys")" == "$uid:600" ]] || return 1
|
||||||
grep -qEv '^[[:space:]]*(#|$)' "$keys"
|
valid_authorized_keys "$keys"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Resolve the target home, `/root/.ssh/authorized_keys`, and `/etc/ssh/sshd_config.d` through `system_path`. A copyable root key must be a non-symlinked regular file owned by UID 0, mode `0600`, with at least one nonblank/non-comment line. Never overwrite an existing `authorized_keys`. When it is absent, either create a missing `.ssh` or require an existing `.ssh` to already be a real directory owned by the target UID with mode `0700`; then copy only the key and apply ownership/mode to those two paths. Never use `chown -R`. If `safe_authorized_keys` still fails, print the unavailable message and skip the prompt/transaction.
|
`valid_authorized_keys` skips blank/comment lines, requires at least one remaining
|
||||||
|
line, and runs `ssh-keygen -l` on every remaining line. Resolve the target home,
|
||||||
|
`/root/.ssh/authorized_keys`, and `/etc/ssh/sshd_config.d` through `system_path`.
|
||||||
|
A copyable root key must be a non-symlinked regular file owned by UID 0, mode
|
||||||
|
`0600`, whose non-comment lines all parse. Never overwrite an existing
|
||||||
|
`authorized_keys`. When it is absent, either create a missing `.ssh` or require
|
||||||
|
an existing `.ssh` to already be a real directory owned by the target UID with
|
||||||
|
mode `0700`. Run final directory creation and key writing as the target UID,
|
||||||
|
then revalidate ownership, modes, and key parsing. Do not chown the destination
|
||||||
|
or assume the user's primary group is named after the user. If
|
||||||
|
`safe_authorized_keys` still fails, print the unavailable message and skip the
|
||||||
|
prompt/transaction.
|
||||||
|
|
||||||
- [ ] **Step 4: Add the hermetic manifest entry and run focused checks**
|
- [ ] **Step 4: Add the hermetic manifest entry and run focused checks**
|
||||||
|
|
||||||
@@ -181,12 +207,20 @@ Extend the command state with `SSHD_RESULTS` and `RELOAD_RESULTS`, consumed one
|
|||||||
success-without-prior-dropin: validate=0 reload=0
|
success-without-prior-dropin: validate=0 reload=0
|
||||||
success-replaces-prior-dropin: validate=0 reload=0
|
success-replaces-prior-dropin: validate=0 reload=0
|
||||||
candidate-invalid: validate=1,0 reload=<none>
|
candidate-invalid: validate=1,0 reload=<none>
|
||||||
|
effective-root-policy-conflict: syntax=0 root-policy=conflict rollback-validate=0 reload=<none>
|
||||||
|
effective-target-policy-conflict: syntax=0 root-policy=safe target-policy=conflict rollback-validate=0 reload=<none>
|
||||||
candidate-reload-fails: validate=0,0 reload=1,0
|
candidate-reload-fails: validate=0,0 reload=1,0
|
||||||
rollback-validation-fails: validate=0,1 reload=1
|
rollback-validation-fails: validate=0,1 reload=1
|
||||||
rollback-reload-fails: validate=0,0 reload=1,1
|
rollback-reload-fails: validate=0,0 reload=1,1
|
||||||
```
|
```
|
||||||
|
|
||||||
Assert the desired two-line content, validation before reload, only the detected unit, byte-for-byte restoration, restored validation/reload ordering, nonzero status and no install handoff on every transactional failure, no `*.tmp`/`*.backup` residue on success, and retained backup plus recovery commands when rollback fails.
|
Assert the desired three-line content; syntax and root/target effective validation
|
||||||
|
before reload; only the detected unit; content and complete metadata restoration;
|
||||||
|
restored validation/reload ordering; nonzero status and no install handoff on every
|
||||||
|
transactional failure; no `*.tmp`/`*.backup` residue on success; and retained backup
|
||||||
|
or no-prior-file removal instructions when rollback fails. Add declined-hardening,
|
||||||
|
missing-unit, symlink/directory/FIFO drop-in, target-UID normalization, candidate and
|
||||||
|
backup preparation signals, and actual-root fixture-guard cases.
|
||||||
|
|
||||||
- [ ] **Step 2: Run the focused contract and confirm it fails on current code**
|
- [ ] **Step 2: Run the focused contract and confirm it fails on current code**
|
||||||
|
|
||||||
@@ -212,9 +246,9 @@ detect_ssh_unit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
restore_ssh_dropin() {
|
restore_ssh_dropin() {
|
||||||
if [[ -n "${ssh_backup:-}" && -e "$ssh_backup" ]]; then
|
if (( ssh_had_prior )); then
|
||||||
local restore
|
local restore
|
||||||
restore="$(mktemp --tmpdir="$sshd_dir" .90-panama.XXXXXX.restore)" || return 1
|
restore="$(mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.restore)" || return 1
|
||||||
cp -a -- "$ssh_backup" "$restore"
|
cp -a -- "$ssh_backup" "$restore"
|
||||||
mv -f -- "$restore" "$ssh_dropin"
|
mv -f -- "$restore" "$ssh_dropin"
|
||||||
else
|
else
|
||||||
@@ -223,7 +257,15 @@ restore_ssh_dropin() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Create the candidate with `umask 077; mktemp --tmpdir="$sshd_dir" .90-panama.XXXXXX.tmp`, write exact desired content, and preserve an existing final file in a collision-safe same-directory `mktemp` name ending `.backup`, not `.conf`. Save any prior `EXIT`, `INT`, and `TERM` traps, then arm transaction traps before `mv -f` activates the candidate. The EXIT handler restores only while `ssh_transaction_active=1`; every success or handled failure path restores the prior traps before returning.
|
Reject an existing `00-panama.conf` unless it is a non-symlink regular file. Save
|
||||||
|
the prior `EXIT`, `INT`, and `TERM` traps and arm state-aware preparation cleanup
|
||||||
|
before creating any artifact. Create the candidate with
|
||||||
|
`umask 077; mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.tmp`, write the exact
|
||||||
|
three-line desired content, and preserve an existing final file with `cp -a` in a
|
||||||
|
collision-safe same-directory `mktemp` name ending `.backup`, not `.conf`.
|
||||||
|
Atomically activate with `mv -f`. Preparation signals remove known artifacts without
|
||||||
|
touching the final path; activated signals restore, validate, and reload. Every
|
||||||
|
success or handled failure path restores the prior traps before returning.
|
||||||
|
|
||||||
After activation:
|
After activation:
|
||||||
|
|
||||||
@@ -233,6 +275,11 @@ if ! sshd -t; then
|
|||||||
sshd -t || rollback_failed=1
|
sshd -t || rollback_failed=1
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
if ! effective_ssh_policy_is_hardened "$username"; then
|
||||||
|
restore_ssh_dropin
|
||||||
|
sshd -t || rollback_failed=1
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
if ! systemctl reload "$ssh_unit"; then
|
if ! systemctl reload "$ssh_unit"; then
|
||||||
restore_ssh_dropin
|
restore_ssh_dropin
|
||||||
sshd -t || rollback_failed=1
|
sshd -t || rollback_failed=1
|
||||||
@@ -241,7 +288,13 @@ if ! systemctl reload "$ssh_unit"; then
|
|||||||
fi
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
On clean success set `ssh_transaction_active=0`, clear the local traps, and remove the backup. On rollback failure keep the backup and print its absolute path plus `sshd -t` and `systemctl reload UNIT` recovery commands. Do not continue to clone/install after a hardening transaction returns nonzero.
|
`effective_ssh_policy_is_hardened` uses `sshd -T -C` for root and target
|
||||||
|
contexts. Root must report all three denials; target must report both authentication
|
||||||
|
denials. On clean success clear the transaction state, restore traps, and remove the
|
||||||
|
backup. On rollback failure with a prior file, keep the metadata-preserving backup
|
||||||
|
and print its absolute path plus validation/reload commands. With no prior file,
|
||||||
|
print `rm -f -- /etc/ssh/sshd_config.d/00-panama.conf`, `sshd -t`, and the detected
|
||||||
|
reload command. Do not continue to clone/install after a transactional failure.
|
||||||
|
|
||||||
- [ ] **Step 4: Verify failure status, cleanup, and old public behavior**
|
- [ ] **Step 4: Verify failure status, cleanup, and old public behavior**
|
||||||
|
|
||||||
@@ -281,7 +334,11 @@ git commit -m "Fix: Roll back failed SSH hardening"
|
|||||||
|
|
||||||
- [ ] **Step 1: Write the documentation assertions first**
|
- [ ] **Step 1: Write the documentation assertions first**
|
||||||
|
|
||||||
Extend `tests/setup/readme-contract` to require nearby root-bootstrap prose containing all of: verified target key, `sshd -t`, atomic drop-in, reload rollback, and hardening unavailable without a key. Reject wording that says Panama merely writes the file or that reload failure is ignored.
|
Extend `tests/setup/readme-contract` to require nearby root-bootstrap prose containing
|
||||||
|
all of: OpenSSH-parsed target keys, `00-panama.conf`, all three denials, `sshd -t`,
|
||||||
|
root/target `sshd -T`, atomic installation, reload rollback, and hardening unavailable
|
||||||
|
without a key. Reject wording that says Panama merely writes the file or that reload
|
||||||
|
failure is ignored.
|
||||||
|
|
||||||
- [ ] **Step 2: Run the README contract and confirm the old prose fails**
|
- [ ] **Step 2: Run the README contract and confirm the old prose fails**
|
||||||
|
|
||||||
@@ -295,7 +352,11 @@ Expected: nonzero until README describes the transactional behavior.
|
|||||||
|
|
||||||
- [ ] **Step 3: Update the documentation without claiming a live reload**
|
- [ ] **Step 3: Update the documentation without claiming a live reload**
|
||||||
|
|
||||||
State plainly that Panama copies or verifies the target key, offers hardening only with exact safe ownership/modes, validates the complete config, reloads the detected unit, and restores the previous drop-in on failure. State that fixture contracts test these paths and no real daemon reload runs under `panama test --safe`.
|
State plainly that Panama copies or verifies every target key line with OpenSSH,
|
||||||
|
offers hardening only with exact safe ownership/modes and a supported regular
|
||||||
|
drop-in, validates syntax and effective root/target policy, reloads the detected
|
||||||
|
unit, and restores the previous file with metadata on failure. State that fixture
|
||||||
|
contracts test these paths and no real daemon reload runs under `panama test --safe`.
|
||||||
|
|
||||||
- [ ] **Step 4: Run the plan gate**
|
- [ ] **Step 4: Run the plan gate**
|
||||||
|
|
||||||
|
|||||||
+42
-18
@@ -33,8 +33,11 @@ real firewall, install a real package, start a service, or apply changes to a se
|
|||||||
- Safe target SSH state means a non-root account, an absolute home, a real `.ssh`
|
- Safe target SSH state means a non-root account, an absolute home, a real `.ssh`
|
||||||
directory owned by the target UID with mode `0700`, and a nonempty regular
|
directory owned by the target UID with mode `0700`, and a nonempty regular
|
||||||
`authorized_keys` file owned by the target UID with mode `0600`. Symlinks are
|
`authorized_keys` file owned by the target UID with mode `0600`. Symlinks are
|
||||||
refused. Panama may create and normalize files it copied from root, but it does
|
refused. Every nonblank, non-comment key line must parse with OpenSSH tooling.
|
||||||
not take ownership of an unsafe pre-existing target path.
|
Panama may create and normalize files it copied from root, but final destination
|
||||||
|
creation and writing run as the target UID. It does not take ownership of an
|
||||||
|
unsafe pre-existing target path or assume that the user's primary group has the
|
||||||
|
same name as the user.
|
||||||
- Failed SSH reload rollback includes restored-config validation and a reload of the
|
- Failed SSH reload rollback includes restored-config validation and a reload of the
|
||||||
restored configuration, because a command can apply state and still return
|
restored configuration, because a command can apply state and still return
|
||||||
nonzero.
|
nonzero.
|
||||||
@@ -92,16 +95,21 @@ Before offering hardening, Panama verifies:
|
|||||||
absolute and nonempty;
|
absolute and nonempty;
|
||||||
- neither the home-relative `.ssh` path nor `authorized_keys` is a symlink;
|
- neither the home-relative `.ssh` path nor `authorized_keys` is a symlink;
|
||||||
- `.ssh` and `authorized_keys` have the exact ownership and modes in Decisions;
|
- `.ssh` and `authorized_keys` have the exact ownership and modes in Decisions;
|
||||||
- `authorized_keys` contains at least one nonblank, non-comment line;
|
- `authorized_keys` contains at least one nonblank, non-comment line, and OpenSSH
|
||||||
- exactly one installed SSH unit is detected, preferring `sshd.service` and falling
|
parses every such line as a public key;
|
||||||
back to `ssh.service` only when the first unit is absent.
|
- an installed SSH unit is detected, preferring `sshd.service` and falling back to
|
||||||
|
`ssh.service` only when the first unit is absent.
|
||||||
|
|
||||||
If the target has no key and root has a safe regular key file, Panama copies only that
|
If the target has no key and root has a safe regular key file, Panama copies only that
|
||||||
file, creates `.ssh`, applies `0700/0600`, and changes ownership only on those two
|
file. The target UID creates or normalizes `.ssh` at `0700` and writes
|
||||||
paths. It does not recursively take ownership of an existing directory tree.
|
`authorized_keys` at `0600` through an already-open root-key input. Panama then
|
||||||
|
revalidates exact UID ownership, modes, and OpenSSH key parsing. It does not chown a
|
||||||
|
target-controlled path or assume a same-named primary group.
|
||||||
|
|
||||||
If the preconditions fail, Panama prints why hardening is unavailable, keeps
|
If the key preconditions fail, no SSH unit is installed, or the Panama drop-in path
|
||||||
root/password authentication unchanged, and continues the clone/install handoff.
|
already names a symlink or non-regular object, Panama prints why hardening is
|
||||||
|
unavailable, keeps root/password authentication unchanged, and continues the
|
||||||
|
clone/install handoff. Declining the prompt has the same unchanged-state outcome.
|
||||||
|
|
||||||
### Transaction
|
### Transaction
|
||||||
|
|
||||||
@@ -110,22 +118,32 @@ The desired drop-in is exactly:
|
|||||||
```text
|
```text
|
||||||
PermitRootLogin no
|
PermitRootLogin no
|
||||||
PasswordAuthentication no
|
PasswordAuthentication no
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
```
|
```
|
||||||
|
|
||||||
Panama creates the candidate with `umask 077` and `mktemp` in
|
Panama creates the candidate with `umask 077` and `mktemp` in
|
||||||
`/etc/ssh/sshd_config.d`. Its temporary name does not end in `.conf`, so the normal
|
`/etc/ssh/sshd_config.d`. Its temporary name does not end in `.conf`, so the normal
|
||||||
include glob cannot activate it early. It preserves an existing
|
include glob cannot activate it early. It refuses a pre-existing Panama path unless
|
||||||
`90-panama.conf` in the same directory, arms EXIT/INT/TERM rollback, and atomically
|
it is a non-symlink regular file. It preserves an existing `00-panama.conf`, including
|
||||||
renames the candidate over the final path.
|
its ownership, mode, timestamps, ACLs, and extended attributes, in the same directory.
|
||||||
|
State-aware EXIT/INT/TERM cleanup is armed before the first candidate or backup
|
||||||
|
artifact, and the candidate is atomically renamed over the final path.
|
||||||
|
|
||||||
It then runs `sshd -t` against the complete active configuration and reloads only the
|
It then runs `sshd -t` against the complete active configuration. Before reload,
|
||||||
detected unit. Success disarms rollback and removes the backup.
|
`sshd -T -C` must report `permitrootlogin no`, `passwordauthentication no`, and
|
||||||
|
`kbdinteractiveauthentication no` for the root context. The target-user context must
|
||||||
|
report both authentication directives as `no`. This fails closed when an earlier
|
||||||
|
main-config directive wins despite the precedence-safe filename. Panama reloads only
|
||||||
|
the detected unit after every check passes. Success disarms rollback and removes the
|
||||||
|
backup.
|
||||||
|
|
||||||
On validation failure, Panama restores or removes the new drop-in, validates the
|
On validation failure, Panama restores or removes the new drop-in, validates the
|
||||||
restored configuration, and returns nonzero without reloading the rejected candidate.
|
restored configuration, and returns nonzero without reloading the rejected candidate.
|
||||||
On reload failure, Panama restores the previous drop-in, validates it, reloads the
|
On reload failure, Panama restores the previous drop-in, validates it, reloads the
|
||||||
restored unit, and returns nonzero. A rollback validation/reload failure preserves the
|
restored unit, and returns nonzero. A rollback validation/reload failure preserves the
|
||||||
backup and prints its path plus exact recovery commands.
|
backup and prints its path plus exact recovery commands. When no prior file existed,
|
||||||
|
recovery instead instructs the operator to remove `00-panama.conf`, run `sshd -t`,
|
||||||
|
and reload the detected unit.
|
||||||
|
|
||||||
Existing drop-ins go through the same desired-content, validation, and reload path;
|
Existing drop-ins go through the same desired-content, validation, and reload path;
|
||||||
mere existence is not treated as proof of hardening.
|
mere existence is not treated as proof of hardening.
|
||||||
@@ -143,12 +161,18 @@ and `systemctl`, and provides temporary account and filesystem state.
|
|||||||
|
|
||||||
Required cases are:
|
Required cases are:
|
||||||
|
|
||||||
- missing, empty, comment-only, symlinked, wrong-owner, and wrong-mode target keys;
|
- missing, empty, comment-only, malformed, mixed valid/malformed, symlinked,
|
||||||
|
wrong-owner, and wrong-mode target keys;
|
||||||
- safe root-key copy and safe existing target key;
|
- safe root-key copy and safe existing target key;
|
||||||
|
- target-UID copy normalization with a primary group whose name differs from the user;
|
||||||
|
- declined hardening and no installed SSH unit;
|
||||||
|
- pre-existing symlink, directory, and FIFO Panama drop-ins;
|
||||||
- successful initial install and replacement of an existing drop-in;
|
- successful initial install and replacement of an existing drop-in;
|
||||||
- invalid candidate rollback;
|
- invalid syntax and conflicting effective-policy rollback;
|
||||||
- failed reload rollback, including restored validation and reload;
|
- failed reload rollback, including restored validation and reload;
|
||||||
- rollback failure retaining its recovery artifact;
|
- rollback failure retaining its recovery artifact or printing no-prior-file removal;
|
||||||
|
- INT/TERM during candidate and backup preparation as well as after activation;
|
||||||
|
- actual-root rejection of `PANAMA_BOOT_FIXTURE_ROOT` in a user namespace;
|
||||||
- exact command ordering and no install handoff after a transactional failure.
|
- exact command ordering and no install handoff after a transactional failure.
|
||||||
|
|
||||||
## Verified bootstrap and installer inputs
|
## Verified bootstrap and installer inputs
|
||||||
|
|||||||
@@ -271,7 +271,8 @@ hermetic tests/setup/package-lists-contract
|
|||||||
hermetic tests/setup/projects-contract
|
hermetic tests/setup/projects-contract
|
||||||
hermetic tests/setup/readme-contract
|
hermetic tests/setup/readme-contract
|
||||||
hermetic tests/setup/role-contract
|
hermetic tests/setup/role-contract
|
||||||
# Root bootstrap runs entirely against a temporary filesystem and PATH adapters.
|
# Root bootstrap uses a temporary filesystem, PATH adapters, real public-key
|
||||||
|
# parsing, and a user namespace for the actual-root fixture guard.
|
||||||
hermetic tests/setup/root-server-bootstrap-contract
|
hermetic tests/setup/root-server-bootstrap-contract
|
||||||
hermetic tests/setup/skills-contract
|
hermetic tests/setup/skills-contract
|
||||||
hermetic tests/setup/test-runner-contract
|
hermetic tests/setup/test-runner-contract
|
||||||
|
|||||||
@@ -57,18 +57,28 @@ assert_bootstrap_probe_rejected() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target_key_requirement='target user owns[^.]*\.ssh[^.]*mode[^.]*([^0-9]|^)0700([^0-9]|$)[^.]*authorized_keys[^.]*mode[^.]*([^0-9]|^)0600([^0-9]|$)'
|
target_key_requirement='target user owns[^.]*\.ssh[^.]*mode[^.]*([^0-9]|^)0700([^0-9]|$)[^.]*authorized_keys[^.]*mode[^.]*([^0-9]|^)0600([^0-9]|$)'
|
||||||
|
parseable_key_requirement='(every|each)[^.]*non-?comment[^.]*authorized_keys[^.]*(OpenSSH|ssh-keygen)[^.]*(parse|valid)|(OpenSSH|ssh-keygen)[^-]*parse[^.]*every[^.]*non-?comment'
|
||||||
hardening_continues_requirement='hardening[[:space:]]+is[[:space:]]+unavailable[^.]*without[^.]*verified[^.]*key[^.]*install[[:space:]]+continues[^.]*without[[:space:]]+(it|SSH[[:space:]]+hardening)'
|
hardening_continues_requirement='hardening[[:space:]]+is[[:space:]]+unavailable[^.]*without[^.]*verified[^.]*key[^.]*install[[:space:]]+continues[^.]*without[[:space:]]+(it|SSH[[:space:]]+hardening)'
|
||||||
atomic_dropin_requirement='atomic[[:space:]]+same-directory[[:space:]]+drop-in'
|
atomic_dropin_requirement='atomic[[:space:]]+same-directory[[:space:]]+drop-in'
|
||||||
rollback_requirement='validation[^.]*reload[^.]*fail[^.]*(restor|rollback)[^.]*previous[[:space:]]+drop-in'
|
rollback_requirement='validation[^.]*reload[^.]*fail[^.]*(restor|rollback)[^.]*previous[[:space:]]+drop-in'
|
||||||
|
effective_policy_requirement='sshd -T[^.]*root[^.]*target|sshd -T[^.]*target[^.]*root'
|
||||||
|
|
||||||
require_bootstrap_doc "$target_key_requirement" \
|
require_bootstrap_doc "$target_key_requirement" \
|
||||||
'the root bootstrap docs do not require target-user ownership with exact 0700/0600 SSH modes'
|
'the root bootstrap docs do not require target-user ownership with exact 0700/0600 SSH modes'
|
||||||
|
require_bootstrap_doc "$parseable_key_requirement" \
|
||||||
|
'the root bootstrap docs do not require OpenSSH to parse every non-comment key entry'
|
||||||
require_bootstrap_doc "$hardening_continues_requirement" \
|
require_bootstrap_doc "$hardening_continues_requirement" \
|
||||||
'the root bootstrap docs do not say bootstrap continues without unavailable SSH hardening'
|
'the root bootstrap docs do not say bootstrap continues without unavailable SSH hardening'
|
||||||
require_bootstrap_doc 'sshd -t' \
|
require_bootstrap_doc 'sshd -t' \
|
||||||
'the root bootstrap docs do not name sshd -t validation'
|
'the root bootstrap docs do not name sshd -t validation'
|
||||||
|
require_bootstrap_doc "$effective_policy_requirement" \
|
||||||
|
'the root bootstrap docs do not name sshd -T checks for root and target contexts'
|
||||||
require_bootstrap_doc "$atomic_dropin_requirement" \
|
require_bootstrap_doc "$atomic_dropin_requirement" \
|
||||||
'the root bootstrap docs do not describe the atomic same-directory drop-in'
|
'the root bootstrap docs do not describe the atomic same-directory drop-in'
|
||||||
|
require_bootstrap_doc '00-panama\.conf' \
|
||||||
|
'the root bootstrap docs do not name the precedence-safe 00-panama.conf drop-in'
|
||||||
|
require_bootstrap_doc 'PermitRootLogin[^.]*no[^.]*PasswordAuthentication[^.]*no[^.]*KbdInteractiveAuthentication[^.]*no' \
|
||||||
|
'the root bootstrap docs do not state all three effective authentication denials'
|
||||||
require_bootstrap_doc 'detected (SSH )?unit.*reload|reload.*detected (SSH )?unit' \
|
require_bootstrap_doc 'detected (SSH )?unit.*reload|reload.*detected (SSH )?unit' \
|
||||||
'the root bootstrap docs do not describe reloading the detected SSH unit'
|
'the root bootstrap docs do not describe reloading the detected SSH unit'
|
||||||
require_bootstrap_doc "$rollback_requirement" \
|
require_bootstrap_doc "$rollback_requirement" \
|
||||||
@@ -85,6 +95,10 @@ assert_bootstrap_probe_rejected 'exact SSH modes' "$target_key_requirement" \
|
|||||||
"${bootstrap_doc//0700/700}"
|
"${bootstrap_doc//0700/700}"
|
||||||
assert_bootstrap_probe_rejected 'target-user ownership' "$target_key_requirement" \
|
assert_bootstrap_probe_rejected 'target-user ownership' "$target_key_requirement" \
|
||||||
"${bootstrap_doc//target user owns/someone owns}"
|
"${bootstrap_doc//target user owns/someone owns}"
|
||||||
|
weakened_key_doc="${bootstrap_doc//OpenSSH/text tooling}"
|
||||||
|
weakened_key_doc="${weakened_key_doc//ssh-keygen/text parser}"
|
||||||
|
assert_bootstrap_probe_rejected 'OpenSSH key parsing' "$parseable_key_requirement" \
|
||||||
|
"$weakened_key_doc"
|
||||||
assert_bootstrap_probe_rejected 'hardening availability' "$hardening_continues_requirement" \
|
assert_bootstrap_probe_rejected 'hardening availability' "$hardening_continues_requirement" \
|
||||||
"${bootstrap_doc//unavailable/available}"
|
"${bootstrap_doc//unavailable/available}"
|
||||||
assert_bootstrap_probe_rejected 'hardening continuation' "$hardening_continues_requirement" \
|
assert_bootstrap_probe_rejected 'hardening continuation' "$hardening_continues_requirement" \
|
||||||
@@ -95,6 +109,8 @@ assert_bootstrap_probe_rejected 'rollback after failure' "$rollback_requirement"
|
|||||||
"${bootstrap_doc//restores /keeps }"
|
"${bootstrap_doc//restores /keeps }"
|
||||||
assert_bootstrap_probe_rejected 'rollback trigger' "$rollback_requirement" \
|
assert_bootstrap_probe_rejected 'rollback trigger' "$rollback_requirement" \
|
||||||
"${bootstrap_doc//fails/works}"
|
"${bootstrap_doc//fails/works}"
|
||||||
|
assert_bootstrap_probe_rejected 'effective target policy' "$effective_policy_requirement" \
|
||||||
|
"${bootstrap_doc//target/root}"
|
||||||
|
|
||||||
if grep -qiE 'merely writes? (the )?(SSH )?(drop-in|file)|reload failure.*ignored|ignores? .*reload failure' <<<"$bootstrap_doc"; then
|
if grep -qiE 'merely writes? (the )?(SSH )?(drop-in|file)|reload failure.*ignored|ignores? .*reload failure' <<<"$bootstrap_doc"; then
|
||||||
note 'the root bootstrap docs weaken the transaction by treating the write or reload failure as harmless'
|
note 'the root bootstrap docs weaken the transaction by treating the write or reload failure as harmless'
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import fcntl
|
|||||||
import os
|
import os
|
||||||
import pty
|
import pty
|
||||||
import re
|
import re
|
||||||
|
import select
|
||||||
import signal
|
import signal
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat as stat_module
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -45,6 +47,22 @@ def write_executable(path: Path, contents: str) -> None:
|
|||||||
path.chmod(0o755)
|
path.chmod(0o755)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_public_key(label: str) -> str:
|
||||||
|
key_path = work / label
|
||||||
|
subprocess.run(
|
||||||
|
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", label, "-f", key_path],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
public_key = key_path.with_suffix(".pub").read_text()
|
||||||
|
key_path.unlink()
|
||||||
|
key_path.with_suffix(".pub").unlink()
|
||||||
|
return public_key
|
||||||
|
|
||||||
|
|
||||||
|
TARGET_PUBLIC_KEY = generate_public_key("panama-target-fixture")
|
||||||
|
ROOT_PUBLIC_KEY = generate_public_key("panama-root-fixture")
|
||||||
|
|
||||||
|
|
||||||
def make_stubs(stub_dir: Path, fixture_root: Path, calls: Path) -> None:
|
def make_stubs(stub_dir: Path, fixture_root: Path, calls: Path) -> None:
|
||||||
common = f'''#!/usr/bin/env bash
|
common = f'''#!/usr/bin/env bash
|
||||||
set -u
|
set -u
|
||||||
@@ -77,7 +95,7 @@ case "${1:-}" in
|
|||||||
*) exit 97 ;;
|
*) exit 97 ;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
-nG) [[ "${2:-}" == gib ]] || exit 97; printf 'gib wheel\n' ;;
|
-nG) [[ "${2:-}" == gib ]] || exit 97; printf 'operators wheel\n' ;;
|
||||||
*) exit 97 ;;
|
*) exit 97 ;;
|
||||||
esac
|
esac
|
||||||
''')
|
''')
|
||||||
@@ -90,7 +108,7 @@ printf 'gib PS\n'
|
|||||||
log getent "$@"
|
log getent "$@"
|
||||||
[[ "${1:-}" == passwd && "${2:-}" == gib ]] || exit 97
|
[[ "${1:-}" == passwd && "${2:-}" == gib ]] || exit 97
|
||||||
home="$(<"$PANAMA_BOOT_FIXTURE_ROOT/state/home")"
|
home="$(<"$PANAMA_BOOT_FIXTURE_ROOT/state/home")"
|
||||||
printf 'gib:x:1000:1000::%s:/bin/bash\n' "$home"
|
printf 'gib:x:1000:2000::%s:/bin/bash\n' "$home"
|
||||||
''')
|
''')
|
||||||
write_executable(stub_dir / "stat", common + r'''
|
write_executable(stub_dir / "stat", common + r'''
|
||||||
log stat "$@"
|
log stat "$@"
|
||||||
@@ -106,7 +124,23 @@ esac
|
|||||||
log runuser "$@"
|
log runuser "$@"
|
||||||
[[ "${1:-}" == -u && "${2:-}" == gib && "${3:-}" == -- ]] || exit 97
|
[[ "${1:-}" == -u && "${2:-}" == gib && "${3:-}" == -- ]] || exit 97
|
||||||
shift 3
|
shift 3
|
||||||
|
if [[ "${1:-}" == install && "${2:-}" == -d && "${3:-}" == -m && "${4:-}" == 0700 && "${5:-}" == -- ]]; then
|
||||||
|
/usr/bin/install "${@:2}"
|
||||||
|
printf '%s:700\n' "$(<"$PANAMA_BOOT_FIXTURE_ROOT/state/target-uid")" \
|
||||||
|
>"$PANAMA_BOOT_FIXTURE_ROOT/state/target-dir-meta"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "${1:-}" == install && "${2:-}" == -m && "${3:-}" == 0600 && "${4:-}" == -- ]]; then
|
||||||
|
/usr/bin/install "${@:2}"
|
||||||
|
printf '%s:600\n' "$(<"$PANAMA_BOOT_FIXTURE_ROOT/state/target-uid")" \
|
||||||
|
>"$PANAMA_BOOT_FIXTURE_ROOT/state/target-key-meta"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
"$@"
|
"$@"
|
||||||
|
''')
|
||||||
|
write_executable(stub_dir / "ssh-keygen", common + r'''
|
||||||
|
log ssh-keygen "$@"
|
||||||
|
exec /usr/bin/ssh-keygen "$@"
|
||||||
''')
|
''')
|
||||||
write_executable(stub_dir / "git", common + r'''
|
write_executable(stub_dir / "git", common + r'''
|
||||||
log git "$@"
|
log git "$@"
|
||||||
@@ -126,12 +160,29 @@ log dnf "$@"
|
|||||||
''')
|
''')
|
||||||
write_executable(stub_dir / "sshd", common + r'''
|
write_executable(stub_dir / "sshd", common + r'''
|
||||||
log sshd "$@"
|
log sshd "$@"
|
||||||
[[ "$#" -eq 1 && "$1" == -t ]] || exit 97
|
case "${1:-}" in
|
||||||
for artifact in "$PANAMA_BOOT_FIXTURE_ROOT/etc/ssh/sshd_config.d"/.90-panama.*; do
|
-t)
|
||||||
[[ -e "$artifact" ]] || continue
|
[[ "$#" -eq 1 ]] || exit 97
|
||||||
log ssh-artifact "$artifact" "$(/usr/bin/stat -c %a -- "$artifact")"
|
for artifact in "$PANAMA_BOOT_FIXTURE_ROOT/etc/ssh/sshd_config.d"/.00-panama.*; do
|
||||||
done
|
[[ -e "$artifact" ]] || continue
|
||||||
consume_result SSHD_RESULTS
|
log ssh-artifact "$artifact" "$(/usr/bin/stat -c %a -- "$artifact")"
|
||||||
|
done
|
||||||
|
consume_result SSHD_RESULTS
|
||||||
|
;;
|
||||||
|
-T)
|
||||||
|
[[ "$#" -eq 3 && "$2" == -C ]] || exit 97
|
||||||
|
case "$3" in
|
||||||
|
user=root,host=localhost,addr=127.0.0.1)
|
||||||
|
cat "$PANAMA_BOOT_FIXTURE_ROOT/state/ROOT_POLICY"
|
||||||
|
;;
|
||||||
|
user=gib,host=localhost,addr=127.0.0.1)
|
||||||
|
cat "$PANAMA_BOOT_FIXTURE_ROOT/state/TARGET_POLICY"
|
||||||
|
;;
|
||||||
|
*) exit 97 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*) exit 97 ;;
|
||||||
|
esac
|
||||||
''')
|
''')
|
||||||
write_executable(stub_dir / "systemctl", common + r'''
|
write_executable(stub_dir / "systemctl", common + r'''
|
||||||
log systemctl "$@"
|
log systemctl "$@"
|
||||||
@@ -141,6 +192,40 @@ case "${1:-}:${2:-}" in
|
|||||||
reload:sshd.service|reload:ssh.service) consume_result RELOAD_RESULTS ;;
|
reload:sshd.service|reload:ssh.service) consume_result RELOAD_RESULTS ;;
|
||||||
*) exit 97 ;;
|
*) exit 97 ;;
|
||||||
esac
|
esac
|
||||||
|
''')
|
||||||
|
write_executable(stub_dir / "mktemp", common + r'''
|
||||||
|
log mktemp "$@"
|
||||||
|
artifact="$(/usr/bin/mktemp "$@")" || exit
|
||||||
|
case "$artifact" in
|
||||||
|
*.tmp)
|
||||||
|
if [[ -e "$PANAMA_BOOT_FIXTURE_ROOT/state/HOLD_CANDIDATE_PREPARATION" ]]; then
|
||||||
|
: >"$PANAMA_BOOT_FIXTURE_ROOT/state/CANDIDATE_PREPARING"
|
||||||
|
while [[ ! -e "$PANAMA_BOOT_FIXTURE_ROOT/state/RELEASE_CANDIDATE_PREPARATION" ]]; do
|
||||||
|
/usr/bin/sleep 0.01
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*.backup)
|
||||||
|
if [[ -e "$PANAMA_BOOT_FIXTURE_ROOT/state/HOLD_BACKUP_MKTEMP" ]]; then
|
||||||
|
: >"$PANAMA_BOOT_FIXTURE_ROOT/state/BACKUP_MKTEMP_RUNNING"
|
||||||
|
while [[ ! -e "$PANAMA_BOOT_FIXTURE_ROOT/state/RELEASE_BACKUP_MKTEMP" ]]; do
|
||||||
|
/usr/bin/sleep 0.01
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf '%s\n' "$artifact"
|
||||||
|
''')
|
||||||
|
write_executable(stub_dir / "cp", common + r'''
|
||||||
|
log cp "$@"
|
||||||
|
destination="${@: -1}"
|
||||||
|
if [[ "$destination" == *.backup && -e "$PANAMA_BOOT_FIXTURE_ROOT/state/HOLD_BACKUP_PREPARATION" ]]; then
|
||||||
|
: >"$PANAMA_BOOT_FIXTURE_ROOT/state/BACKUP_PREPARING"
|
||||||
|
while [[ ! -e "$PANAMA_BOOT_FIXTURE_ROOT/state/RELEASE_BACKUP_PREPARATION" ]]; do
|
||||||
|
/usr/bin/sleep 0.01
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
exec /usr/bin/cp "$@"
|
||||||
''')
|
''')
|
||||||
write_executable(stub_dir / "mv", common + r'''
|
write_executable(stub_dir / "mv", common + r'''
|
||||||
log mv "$@" "source-mode=$(/usr/bin/stat -c %a -- "${3:-}")"
|
log mv "$@" "source-mode=$(/usr/bin/stat -c %a -- "${3:-}")"
|
||||||
@@ -176,9 +261,14 @@ if [[ "${1:-}" == -f && "${2:-}" == -- && "${3:-}" == *.tmp ]]; then
|
|||||||
result=$?
|
result=$?
|
||||||
(( result == 0 )) || exit "$result"
|
(( result == 0 )) || exit "$result"
|
||||||
fi
|
fi
|
||||||
|
if [[ "${1:-}" == -f && "${2:-}" == -- && "${3:-}" == */00-panama.conf ]]; then
|
||||||
|
consume_result RM_DROPIN_RESULTS
|
||||||
|
result=$?
|
||||||
|
(( result == 0 )) || exit "$result"
|
||||||
|
fi
|
||||||
exec /usr/bin/rm "$@"
|
exec /usr/bin/rm "$@"
|
||||||
''')
|
''')
|
||||||
for command in ("useradd", "usermod"):
|
for command in ("chown", "useradd", "usermod"):
|
||||||
write_executable(stub_dir / command, common + f'''\nlog {command} "$@"\nexit 97\n''')
|
write_executable(stub_dir / command, common + f'''\nlog {command} "$@"\nexit 97\n''')
|
||||||
|
|
||||||
|
|
||||||
@@ -190,11 +280,24 @@ def configure_case(
|
|||||||
mv_activation_results: tuple[int, ...] = (),
|
mv_activation_results: tuple[int, ...] = (),
|
||||||
rm_backup_results: tuple[int, ...] = (),
|
rm_backup_results: tuple[int, ...] = (),
|
||||||
rm_candidate_results: tuple[int, ...] = (),
|
rm_candidate_results: tuple[int, ...] = (),
|
||||||
|
rm_dropin_results: tuple[int, ...] = (),
|
||||||
prior_dropin: bytes | None = None,
|
prior_dropin: bytes | None = None,
|
||||||
|
prior_dropin_kind: str = "regular",
|
||||||
|
root_policy: str = (
|
||||||
|
"permitrootlogin no\n"
|
||||||
|
"passwordauthentication no\n"
|
||||||
|
"kbdinteractiveauthentication no\n"
|
||||||
|
),
|
||||||
|
target_policy: str = (
|
||||||
|
"passwordauthentication no\n"
|
||||||
|
"kbdinteractiveauthentication no\n"
|
||||||
|
),
|
||||||
sshd_unit: bool = True,
|
sshd_unit: bool = True,
|
||||||
ssh_unit: bool = True,
|
ssh_unit: bool = True,
|
||||||
hold_activation: bool = False,
|
hold_activation: bool = False,
|
||||||
hold_before_activation: bool = False,
|
hold_before_activation: bool = False,
|
||||||
|
hold_candidate_preparation: bool = False,
|
||||||
|
hold_backup_preparation: bool = False,
|
||||||
) -> tuple[Path, Path]:
|
) -> tuple[Path, Path]:
|
||||||
fixture_root = work / name / "root"
|
fixture_root = work / name / "root"
|
||||||
stub_dir = work / name / "bin"
|
stub_dir = work / name / "bin"
|
||||||
@@ -224,22 +327,45 @@ def configure_case(
|
|||||||
(state / "RM_CANDIDATE_RESULTS").write_text(
|
(state / "RM_CANDIDATE_RESULTS").write_text(
|
||||||
"".join(f"{result}\n" for result in rm_candidate_results)
|
"".join(f"{result}\n" for result in rm_candidate_results)
|
||||||
)
|
)
|
||||||
|
(state / "RM_DROPIN_RESULTS").write_text(
|
||||||
|
"".join(f"{result}\n" for result in rm_dropin_results)
|
||||||
|
)
|
||||||
|
(state / "ROOT_POLICY").write_text(root_policy)
|
||||||
|
(state / "TARGET_POLICY").write_text(target_policy)
|
||||||
(state / "SSHD_UNIT").write_text("present\n" if sshd_unit else "absent\n")
|
(state / "SSHD_UNIT").write_text("present\n" if sshd_unit else "absent\n")
|
||||||
(state / "SSH_UNIT").write_text("present\n" if ssh_unit else "absent\n")
|
(state / "SSH_UNIT").write_text("present\n" if ssh_unit else "absent\n")
|
||||||
if hold_activation:
|
if hold_activation:
|
||||||
(state / "HOLD_ACTIVATION").touch()
|
(state / "HOLD_ACTIVATION").touch()
|
||||||
if hold_before_activation:
|
if hold_before_activation:
|
||||||
(state / "HOLD_BEFORE_ACTIVATION").touch()
|
(state / "HOLD_BEFORE_ACTIVATION").touch()
|
||||||
|
if hold_candidate_preparation:
|
||||||
|
(state / "HOLD_CANDIDATE_PREPARATION").touch()
|
||||||
|
if hold_backup_preparation:
|
||||||
|
(state / "HOLD_BACKUP_PREPARATION").touch()
|
||||||
(fixture_root / "stub-install").write_text(
|
(fixture_root / "stub-install").write_text(
|
||||||
"#!/usr/bin/env bash\nprintf 'install-handoff %s\\n' \"${PANAMA_PATH:-unset}\" >> \"$PANAMA_BOOT_FIXTURE_ROOT/calls\"\n"
|
"#!/usr/bin/env bash\nprintf 'install-handoff %s\\n' \"${PANAMA_PATH:-unset}\" >> \"$PANAMA_BOOT_FIXTURE_ROOT/calls\"\n"
|
||||||
)
|
)
|
||||||
(fixture_root / "stub-install").chmod(0o755)
|
(fixture_root / "stub-install").chmod(0o755)
|
||||||
make_stubs(stub_dir, fixture_root, calls)
|
make_stubs(stub_dir, fixture_root, calls)
|
||||||
|
|
||||||
if prior_dropin is not None:
|
dropin = fixture_root / "etc/ssh/sshd_config.d/00-panama.conf"
|
||||||
dropin = fixture_root / "etc/ssh/sshd_config.d/90-panama.conf"
|
if prior_dropin_kind != "regular":
|
||||||
|
if prior_dropin_kind == "symlink":
|
||||||
|
symlink_target = fixture_root / "unsupported-panama-dropin"
|
||||||
|
symlink_target.write_bytes(prior_dropin or b"unsupported symlink target\n")
|
||||||
|
dropin.symlink_to(symlink_target)
|
||||||
|
elif prior_dropin_kind == "directory":
|
||||||
|
dropin.mkdir()
|
||||||
|
elif prior_dropin_kind == "fifo":
|
||||||
|
os.mkfifo(dropin)
|
||||||
|
else:
|
||||||
|
raise ValueError(prior_dropin_kind)
|
||||||
|
elif prior_dropin is not None:
|
||||||
dropin.write_bytes(prior_dropin)
|
dropin.write_bytes(prior_dropin)
|
||||||
dropin.chmod(0o600)
|
dropin.chmod(0o640)
|
||||||
|
os.utime(dropin, ns=(1_700_000_000_123_456_789, 1_700_000_000_123_456_789))
|
||||||
|
os.setxattr(dropin, b"user.panama-contract", b"preserve-me")
|
||||||
|
subprocess.run(["setfacl", "-m", "u:65534:r--", dropin], check=True)
|
||||||
|
|
||||||
target_keys = ssh_dir / "authorized_keys"
|
target_keys = ssh_dir / "authorized_keys"
|
||||||
root_keys = root_ssh_dir / "authorized_keys"
|
root_keys = root_ssh_dir / "authorized_keys"
|
||||||
@@ -256,31 +382,42 @@ def configure_case(
|
|||||||
(fixture_root / "home/gib/.ssh").symlink_to(alternate)
|
(fixture_root / "home/gib/.ssh").symlink_to(alternate)
|
||||||
elif name == "authorized-keys-symlink":
|
elif name == "authorized-keys-symlink":
|
||||||
alternate = fixture_root / "unsafe-authorized-keys"
|
alternate = fixture_root / "unsafe-authorized-keys"
|
||||||
alternate.write_text("ssh-ed25519 unsafe\n")
|
alternate.write_text(TARGET_PUBLIC_KEY)
|
||||||
target_keys.symlink_to(alternate)
|
target_keys.symlink_to(alternate)
|
||||||
|
elif name == "malformed-key":
|
||||||
|
target_keys.write_text("this is not OpenSSH key material\n")
|
||||||
|
elif name == "mixed-valid-and-malformed-key":
|
||||||
|
target_keys.write_text(TARGET_PUBLIC_KEY + "this is not OpenSSH key material\n")
|
||||||
|
elif name == "malformed-root-key":
|
||||||
|
root_keys.write_text("this is not OpenSSH key material\n")
|
||||||
elif name == "directory-wrong-mode":
|
elif name == "directory-wrong-mode":
|
||||||
target_keys.write_text("ssh-ed25519 target\n")
|
target_keys.write_text(TARGET_PUBLIC_KEY)
|
||||||
(state / "target-dir-meta").write_text("1000:755\n")
|
(state / "target-dir-meta").write_text("1000:755\n")
|
||||||
elif name == "root-copy-directory-wrong-mode":
|
elif name == "root-copy-directory-wrong-mode":
|
||||||
root_keys.write_text("ssh-ed25519 root\n")
|
root_keys.write_text(ROOT_PUBLIC_KEY)
|
||||||
(state / "target-dir-meta").write_text("1000:755\n")
|
(state / "target-dir-meta").write_text("1000:755\n")
|
||||||
elif name == "file-wrong-mode":
|
elif name == "file-wrong-mode":
|
||||||
target_keys.write_text("ssh-ed25519 target\n")
|
target_keys.write_text(TARGET_PUBLIC_KEY)
|
||||||
(state / "target-key-meta").write_text("1000:644\n")
|
(state / "target-key-meta").write_text("1000:644\n")
|
||||||
elif name == "directory-wrong-owner":
|
elif name == "directory-wrong-owner":
|
||||||
target_keys.write_text("ssh-ed25519 target\n")
|
target_keys.write_text(TARGET_PUBLIC_KEY)
|
||||||
(state / "target-dir-meta").write_text("0:700\n")
|
(state / "target-dir-meta").write_text("0:700\n")
|
||||||
elif name == "file-wrong-owner":
|
elif name == "file-wrong-owner":
|
||||||
target_keys.write_text("ssh-ed25519 target\n")
|
target_keys.write_text(TARGET_PUBLIC_KEY)
|
||||||
(state / "target-key-meta").write_text("0:600\n")
|
(state / "target-key-meta").write_text("0:600\n")
|
||||||
elif name == "root-target-account":
|
elif name == "root-target-account":
|
||||||
target_keys.write_text("ssh-ed25519 target\n")
|
target_keys.write_text(TARGET_PUBLIC_KEY)
|
||||||
(state / "target-uid").write_text("0\n")
|
(state / "target-uid").write_text("0\n")
|
||||||
elif name == "relative-home":
|
elif name == "relative-home":
|
||||||
target_keys.write_text("ssh-ed25519 target\n")
|
target_keys.write_text(TARGET_PUBLIC_KEY)
|
||||||
(state / "home").write_text("home/gib\n")
|
(state / "home").write_text("home/gib\n")
|
||||||
elif name in (
|
elif name in (
|
||||||
"safe-existing-key",
|
"safe-existing-key",
|
||||||
|
"declines-hardening",
|
||||||
|
"missing-ssh-unit",
|
||||||
|
"preexisting-dropin-symlink",
|
||||||
|
"preexisting-dropin-directory",
|
||||||
|
"preexisting-dropin-fifo",
|
||||||
"success-without-prior-dropin",
|
"success-without-prior-dropin",
|
||||||
"success-replaces-prior-dropin",
|
"success-replaces-prior-dropin",
|
||||||
"candidate-invalid",
|
"candidate-invalid",
|
||||||
@@ -289,6 +426,9 @@ def configure_case(
|
|||||||
"candidate-reload-fails-without-prior",
|
"candidate-reload-fails-without-prior",
|
||||||
"rollback-validation-fails",
|
"rollback-validation-fails",
|
||||||
"rollback-reload-fails",
|
"rollback-reload-fails",
|
||||||
|
"rollback-removal-fails-without-prior",
|
||||||
|
"effective-root-policy-conflict",
|
||||||
|
"effective-target-policy-conflict",
|
||||||
"success-backup-cleanup-fails",
|
"success-backup-cleanup-fails",
|
||||||
"rollback-backup-cleanup-fails",
|
"rollback-backup-cleanup-fails",
|
||||||
"signal-int-restores-prior",
|
"signal-int-restores-prior",
|
||||||
@@ -297,10 +437,15 @@ def configure_case(
|
|||||||
"signal-int-before-activation-prior",
|
"signal-int-before-activation-prior",
|
||||||
"signal-term-before-activation-no-prior",
|
"signal-term-before-activation-no-prior",
|
||||||
"signal-int-before-activation-cleanup-fails",
|
"signal-int-before-activation-cleanup-fails",
|
||||||
|
"signal-int-during-candidate-preparation",
|
||||||
|
"signal-term-during-backup-preparation",
|
||||||
):
|
):
|
||||||
target_keys.write_text("ssh-ed25519 target\n")
|
target_keys.write_text(TARGET_PUBLIC_KEY)
|
||||||
elif name == "safe-root-key-copy":
|
elif name == "safe-root-key-copy":
|
||||||
root_keys.write_text("ssh-ed25519 root\n")
|
shutil.rmtree(ssh_dir)
|
||||||
|
(state / "target-dir-meta").write_text("missing\n")
|
||||||
|
(state / "target-key-meta").write_text("missing\n")
|
||||||
|
root_keys.write_text(ROOT_PUBLIC_KEY)
|
||||||
else:
|
else:
|
||||||
raise ValueError(name)
|
raise ValueError(name)
|
||||||
return fixture_root, stub_dir
|
return fixture_root, stub_dir
|
||||||
@@ -311,6 +456,9 @@ def run_case(
|
|||||||
*,
|
*,
|
||||||
signal_after_activation: int | None = None,
|
signal_after_activation: int | None = None,
|
||||||
signal_before_activation: int | None = None,
|
signal_before_activation: int | None = None,
|
||||||
|
signal_during_candidate_preparation: int | None = None,
|
||||||
|
signal_during_backup_preparation: int | None = None,
|
||||||
|
harden_answer: str = "Y",
|
||||||
prior_traps: bool = False,
|
prior_traps: bool = False,
|
||||||
**configuration: object,
|
**configuration: object,
|
||||||
) -> tuple[int, str, str, Path, int]:
|
) -> tuple[int, str, str, Path, int]:
|
||||||
@@ -318,6 +466,8 @@ def run_case(
|
|||||||
name,
|
name,
|
||||||
hold_activation=signal_after_activation is not None,
|
hold_activation=signal_after_activation is not None,
|
||||||
hold_before_activation=signal_before_activation is not None,
|
hold_before_activation=signal_before_activation is not None,
|
||||||
|
hold_candidate_preparation=signal_during_candidate_preparation is not None,
|
||||||
|
hold_backup_preparation=signal_during_backup_preparation is not None,
|
||||||
**configuration,
|
**configuration,
|
||||||
)
|
)
|
||||||
master, slave = pty.openpty()
|
master, slave = pty.openpty()
|
||||||
@@ -358,8 +508,28 @@ fi
|
|||||||
preexec_fn=attach_terminal,
|
preexec_fn=attach_terminal,
|
||||||
)
|
)
|
||||||
os.close(slave)
|
os.close(slave)
|
||||||
os.write(master, b"gib\nY\n")
|
os.write(master, f"gib\n{harden_answer}\n".encode())
|
||||||
if signal_before_activation is not None:
|
if signal_during_candidate_preparation is not None:
|
||||||
|
marker = fixture_root / "state/CANDIDATE_PREPARING"
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while not marker.exists() and process.poll() is None and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.01)
|
||||||
|
if not marker.exists():
|
||||||
|
note(f"{name}: fixture did not observe candidate preparation before signaling")
|
||||||
|
else:
|
||||||
|
os.kill(process.pid, signal_during_candidate_preparation)
|
||||||
|
(fixture_root / "state/RELEASE_CANDIDATE_PREPARATION").touch()
|
||||||
|
elif signal_during_backup_preparation is not None:
|
||||||
|
marker = fixture_root / "state/BACKUP_PREPARING"
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while not marker.exists() and process.poll() is None and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.01)
|
||||||
|
if not marker.exists():
|
||||||
|
note(f"{name}: fixture did not observe backup preparation before signaling")
|
||||||
|
else:
|
||||||
|
os.kill(process.pid, signal_during_backup_preparation)
|
||||||
|
(fixture_root / "state/RELEASE_BACKUP_PREPARATION").touch()
|
||||||
|
elif signal_before_activation is not None:
|
||||||
armed = fixture_root / "state/TRANSACTION_ARMED"
|
armed = fixture_root / "state/TRANSACTION_ARMED"
|
||||||
deadline = time.monotonic() + 5
|
deadline = time.monotonic() + 5
|
||||||
while not armed.exists() and process.poll() is None and time.monotonic() < deadline:
|
while not armed.exists() and process.poll() is None and time.monotonic() < deadline:
|
||||||
@@ -380,7 +550,19 @@ fi
|
|||||||
os.kill(process.pid, signal_after_activation)
|
os.kill(process.pid, signal_after_activation)
|
||||||
(fixture_root / "state/RELEASE_ACTIVATION").touch()
|
(fixture_root / "state/RELEASE_ACTIVATION").touch()
|
||||||
chunks: list[bytes] = []
|
chunks: list[bytes] = []
|
||||||
|
deadline = time.monotonic() + 8
|
||||||
|
timed_out = False
|
||||||
while True:
|
while True:
|
||||||
|
readable, _, _ = select.select([master], [], [], 0.1)
|
||||||
|
if not readable:
|
||||||
|
if process.poll() is not None:
|
||||||
|
break
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
timed_out = True
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
process.wait()
|
||||||
|
continue
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
chunk = os.read(master, 4096)
|
chunk = os.read(master, 4096)
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
@@ -392,15 +574,53 @@ fi
|
|||||||
chunks.append(chunk)
|
chunks.append(chunk)
|
||||||
os.close(master)
|
os.close(master)
|
||||||
status = process.wait()
|
status = process.wait()
|
||||||
|
if timed_out:
|
||||||
|
note(f"{name}: bootstrap timed out, likely while reading an unsupported object")
|
||||||
calls = (fixture_root / "calls").read_text()
|
calls = (fixture_root / "calls").read_text()
|
||||||
output = b"".join(chunks).decode(errors="replace")
|
output = b"".join(chunks).decode(errors="replace")
|
||||||
return status, output, calls, fixture_root, process.pid
|
return status, output, calls, fixture_root, process.pid
|
||||||
|
|
||||||
|
|
||||||
|
guard_root = work / "actual-root-fixture-guard"
|
||||||
|
guard_root.mkdir()
|
||||||
|
guard_env = {
|
||||||
|
**os.environ,
|
||||||
|
"PANAMA_BOOT_FIXTURE_ROOT": str(guard_root),
|
||||||
|
"HOME": str(guard_root),
|
||||||
|
}
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
guard_command = ["bash", boot, "--server"]
|
||||||
|
else:
|
||||||
|
guard_command = ["unshare", "--user", "--map-root-user", "--", "bash", boot, "--server"]
|
||||||
|
try:
|
||||||
|
guard_result = subprocess.run(
|
||||||
|
guard_command,
|
||||||
|
env=guard_env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired) as error:
|
||||||
|
note(f"actual-root-fixture-guard: could not create a hermetic root process: {error}")
|
||||||
|
else:
|
||||||
|
if guard_result.returncode != 1:
|
||||||
|
note(
|
||||||
|
"actual-root-fixture-guard: actual root did not reject "
|
||||||
|
f"PANAMA_BOOT_FIXTURE_ROOT with status 1: {guard_result.returncode}"
|
||||||
|
)
|
||||||
|
if "PANAMA_BOOT_FIXTURE_ROOT is test-only" not in guard_result.stderr:
|
||||||
|
note("actual-root-fixture-guard: rejection diagnostic was missing")
|
||||||
|
if any(guard_root.iterdir()):
|
||||||
|
note("actual-root-fixture-guard: boot mutated its rejected fixture root")
|
||||||
|
|
||||||
|
|
||||||
unsafe_cases = (
|
unsafe_cases = (
|
||||||
"missing",
|
"missing",
|
||||||
"empty",
|
"empty",
|
||||||
"comment-only",
|
"comment-only",
|
||||||
|
"malformed-key",
|
||||||
|
"mixed-valid-and-malformed-key",
|
||||||
|
"malformed-root-key",
|
||||||
"ssh-directory-symlink",
|
"ssh-directory-symlink",
|
||||||
"authorized-keys-symlink",
|
"authorized-keys-symlink",
|
||||||
"directory-wrong-mode",
|
"directory-wrong-mode",
|
||||||
@@ -421,7 +641,7 @@ for case in unsafe_cases:
|
|||||||
note(f"{case}: unsafe login path validated sshd")
|
note(f"{case}: unsafe login path validated sshd")
|
||||||
if "systemctl reload" in calls:
|
if "systemctl reload" in calls:
|
||||||
note(f"{case}: unsafe login path reloaded SSH")
|
note(f"{case}: unsafe login path reloaded SSH")
|
||||||
if (fixture_root / "etc/ssh/sshd_config.d/90-panama.conf").exists():
|
if (fixture_root / "etc/ssh/sshd_config.d/00-panama.conf").exists():
|
||||||
note(f"{case}: unsafe login path changed the SSH drop-in")
|
note(f"{case}: unsafe login path changed the SSH drop-in")
|
||||||
if case == "root-copy-directory-wrong-mode" and (
|
if case == "root-copy-directory-wrong-mode" and (
|
||||||
fixture_root / "home/gib/.ssh/authorized_keys"
|
fixture_root / "home/gib/.ssh/authorized_keys"
|
||||||
@@ -430,26 +650,132 @@ for case in unsafe_cases:
|
|||||||
if "install-handoff " not in calls:
|
if "install-handoff " not in calls:
|
||||||
note(f"{case}: unsafe login path did not hand off to install")
|
note(f"{case}: unsafe login path did not hand off to install")
|
||||||
|
|
||||||
|
desired_dropin = (
|
||||||
|
b"PermitRootLogin no\n"
|
||||||
|
b"PasswordAuthentication no\n"
|
||||||
|
b"KbdInteractiveAuthentication no\n"
|
||||||
|
)
|
||||||
|
prior_dropin = b"# prior Panama settings\nPasswordAuthentication yes\n"
|
||||||
|
|
||||||
for case in ("safe-existing-key", "safe-root-key-copy"):
|
for case in ("safe-existing-key", "safe-root-key-copy"):
|
||||||
status, output, calls, fixture_root, _ = run_case(case)
|
status, output, calls, fixture_root, _ = run_case(case)
|
||||||
if status != 0:
|
if status != 0:
|
||||||
note(f"{case}: safe login path stopped with status {status}: {output.strip()}")
|
note(f"{case}: safe login path stopped with status {status}: {output.strip()}")
|
||||||
if "SSH hardening unavailable" in output:
|
if "SSH hardening unavailable" in output:
|
||||||
note(f"{case}: safe login path was rejected")
|
note(f"{case}: safe login path was rejected: {output.strip()} | {calls.strip()}")
|
||||||
if "systemctl reload" not in calls:
|
if "systemctl reload" not in calls:
|
||||||
note(f"{case}: safe login path did not reach SSH hardening")
|
note(f"{case}: safe login path did not reach SSH hardening")
|
||||||
if "install-handoff " not in calls:
|
if "install-handoff " not in calls:
|
||||||
note(f"{case}: safe login path did not hand off to install")
|
note(f"{case}: safe login path did not hand off to install")
|
||||||
dropin = fixture_root / "etc/ssh/sshd_config.d/90-panama.conf"
|
dropin = fixture_root / "etc/ssh/sshd_config.d/00-panama.conf"
|
||||||
if (dropin.read_text() if dropin.exists() else "") != "PermitRootLogin no\nPasswordAuthentication no\n":
|
if (dropin.read_bytes() if dropin.exists() else None) != desired_dropin:
|
||||||
note(f"{case}: safe login path did not write the expected SSH drop-in")
|
note(f"{case}: safe login path did not write the expected SSH drop-in")
|
||||||
if case == "safe-root-key-copy":
|
if case == "safe-root-key-copy":
|
||||||
keys = fixture_root / "home/gib/.ssh/authorized_keys"
|
keys = fixture_root / "home/gib/.ssh/authorized_keys"
|
||||||
if not keys.exists() or keys.read_text() != "ssh-ed25519 root\n":
|
ssh_dir = keys.parent
|
||||||
|
if not keys.exists() or keys.read_text() != ROOT_PUBLIC_KEY:
|
||||||
note("safe-root-key-copy: root key was not copied to the target account")
|
note("safe-root-key-copy: root key was not copied to the target account")
|
||||||
|
if keys.exists() and (
|
||||||
|
ssh_dir.stat().st_mode & 0o777 != 0o700
|
||||||
|
or keys.stat().st_mode & 0o777 != 0o600
|
||||||
|
):
|
||||||
|
note("safe-root-key-copy: destination modes were not normalized to 0700/0600")
|
||||||
|
call_lines = calls.splitlines()
|
||||||
|
install_dir = (
|
||||||
|
f"runuser -u gib -- install -d -m 0700 -- {ssh_dir} "
|
||||||
|
)
|
||||||
|
install_key = (
|
||||||
|
"runuser -u gib -- install -m 0600 -- "
|
||||||
|
f"/dev/stdin {keys} "
|
||||||
|
)
|
||||||
|
if install_dir not in call_lines or install_key not in call_lines:
|
||||||
|
note("safe-root-key-copy: destination creation and writing did not run as the target user")
|
||||||
|
else:
|
||||||
|
validation_indices = [
|
||||||
|
index
|
||||||
|
for index, line in enumerate(call_lines)
|
||||||
|
if line.startswith("ssh-keygen -l -f ")
|
||||||
|
]
|
||||||
|
if not validation_indices or max(validation_indices) < call_lines.index(install_key):
|
||||||
|
note("safe-root-key-copy: copied key validity was not rechecked after installation")
|
||||||
|
if any(line.startswith("chown ") for line in call_lines):
|
||||||
|
note("safe-root-key-copy: bootstrap still assumes the primary group matches the username")
|
||||||
|
|
||||||
|
status, output, calls, fixture_root, _ = run_case(
|
||||||
|
"declines-hardening",
|
||||||
|
harden_answer="n",
|
||||||
|
)
|
||||||
|
declined_dropin = fixture_root / "etc/ssh/sshd_config.d/00-panama.conf"
|
||||||
|
if status != 0 or "install-handoff " not in calls:
|
||||||
|
note("declines-hardening: declining did not continue to install")
|
||||||
|
if declined_dropin.exists() or "sshd " in calls or "systemctl reload " in calls:
|
||||||
|
note("declines-hardening: declining changed or validated SSH state")
|
||||||
|
|
||||||
|
status, output, calls, fixture_root, _ = run_case(
|
||||||
|
"missing-ssh-unit",
|
||||||
|
sshd_unit=False,
|
||||||
|
ssh_unit=False,
|
||||||
|
)
|
||||||
|
missing_unit_dropin = fixture_root / "etc/ssh/sshd_config.d/00-panama.conf"
|
||||||
|
if status != 0 or "install-handoff " not in calls:
|
||||||
|
note("missing-ssh-unit: unavailable hardening did not continue to install")
|
||||||
|
if "SSH hardening unavailable" not in output:
|
||||||
|
note("missing-ssh-unit: missing units did not explain that hardening was unavailable")
|
||||||
|
if missing_unit_dropin.exists() or "sshd " in calls or "systemctl reload " in calls:
|
||||||
|
note("missing-ssh-unit: unavailable hardening changed or validated SSH state")
|
||||||
|
|
||||||
|
unsupported_dropins = {
|
||||||
|
"preexisting-dropin-symlink": "symlink",
|
||||||
|
"preexisting-dropin-directory": "directory",
|
||||||
|
"preexisting-dropin-fifo": "fifo",
|
||||||
|
}
|
||||||
|
for case, kind in unsupported_dropins.items():
|
||||||
|
status, output, calls, fixture_root, _ = run_case(
|
||||||
|
case,
|
||||||
|
prior_dropin=prior_dropin,
|
||||||
|
prior_dropin_kind=kind,
|
||||||
|
)
|
||||||
|
dropin = fixture_root / "etc/ssh/sshd_config.d/00-panama.conf"
|
||||||
|
if status != 0 or "install-handoff " not in calls:
|
||||||
|
note(f"{case}: unsupported object did not continue to install")
|
||||||
|
if "SSH hardening unavailable" not in output:
|
||||||
|
note(f"{case}: unsupported object did not explain that hardening was unavailable")
|
||||||
|
if "sshd " in calls or "systemctl reload " in calls:
|
||||||
|
note(f"{case}: unsupported object reached SSH validation or reload")
|
||||||
|
if kind == "symlink" and not dropin.is_symlink():
|
||||||
|
note(f"{case}: pre-existing symlink was changed")
|
||||||
|
if kind == "directory" and not dropin.is_dir():
|
||||||
|
note(f"{case}: pre-existing directory was changed")
|
||||||
|
if kind == "fifo" and not stat_module.S_ISFIFO(dropin.lstat().st_mode):
|
||||||
|
note(f"{case}: pre-existing FIFO was changed")
|
||||||
|
|
||||||
|
|
||||||
|
def regular_metadata(path: Path) -> tuple[object, ...]:
|
||||||
|
metadata = path.stat()
|
||||||
|
xattrs = tuple((name, os.getxattr(path, name)) for name in sorted(os.listxattr(path)))
|
||||||
|
acl = subprocess.check_output(["getfacl", "-cp", path])
|
||||||
|
return (
|
||||||
|
stat_module.S_IMODE(metadata.st_mode),
|
||||||
|
metadata.st_uid,
|
||||||
|
metadata.st_gid,
|
||||||
|
metadata.st_mtime_ns,
|
||||||
|
xattrs,
|
||||||
|
acl,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
metadata_reference = work / "prior-dropin-metadata-reference"
|
||||||
|
metadata_reference.write_bytes(prior_dropin)
|
||||||
|
metadata_reference.chmod(0o640)
|
||||||
|
os.utime(
|
||||||
|
metadata_reference,
|
||||||
|
ns=(1_700_000_000_123_456_789, 1_700_000_000_123_456_789),
|
||||||
|
)
|
||||||
|
os.setxattr(metadata_reference, b"user.panama-contract", b"preserve-me")
|
||||||
|
subprocess.run(["setfacl", "-m", "u:65534:r--", metadata_reference], check=True)
|
||||||
|
expected_prior_metadata = regular_metadata(metadata_reference)
|
||||||
|
|
||||||
|
|
||||||
desired_dropin = b"PermitRootLogin no\nPasswordAuthentication no\n"
|
|
||||||
prior_dropin = b"# prior Panama settings\nPasswordAuthentication yes\n"
|
|
||||||
transaction_cases = {
|
transaction_cases = {
|
||||||
"success-without-prior-dropin": {
|
"success-without-prior-dropin": {
|
||||||
"sshd_results": (0,),
|
"sshd_results": (0,),
|
||||||
@@ -517,17 +843,53 @@ transaction_cases = {
|
|||||||
"succeeds": False,
|
"succeeds": False,
|
||||||
"rollback_fails": True,
|
"rollback_fails": True,
|
||||||
},
|
},
|
||||||
|
"rollback-removal-fails-without-prior": {
|
||||||
|
"sshd_results": (1, 1),
|
||||||
|
"reload_results": (),
|
||||||
|
"rm_dropin_results": (1,),
|
||||||
|
"prior_dropin": None,
|
||||||
|
"sshd_unit": True,
|
||||||
|
"ssh_unit": True,
|
||||||
|
"succeeds": False,
|
||||||
|
"rollback_fails": True,
|
||||||
|
"settled_dropin": desired_dropin,
|
||||||
|
},
|
||||||
|
"effective-root-policy-conflict": {
|
||||||
|
"sshd_results": (0, 0),
|
||||||
|
"reload_results": (),
|
||||||
|
"prior_dropin": prior_dropin,
|
||||||
|
"root_policy": (
|
||||||
|
"permitrootlogin yes\n"
|
||||||
|
"passwordauthentication no\n"
|
||||||
|
"kbdinteractiveauthentication no\n"
|
||||||
|
),
|
||||||
|
"sshd_unit": True,
|
||||||
|
"ssh_unit": True,
|
||||||
|
"succeeds": False,
|
||||||
|
},
|
||||||
|
"effective-target-policy-conflict": {
|
||||||
|
"sshd_results": (0, 0),
|
||||||
|
"reload_results": (),
|
||||||
|
"prior_dropin": prior_dropin,
|
||||||
|
"target_policy": (
|
||||||
|
"passwordauthentication no\n"
|
||||||
|
"kbdinteractiveauthentication yes\n"
|
||||||
|
),
|
||||||
|
"sshd_unit": True,
|
||||||
|
"ssh_unit": True,
|
||||||
|
"succeeds": False,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for case, expected in transaction_cases.items():
|
for case, expected in transaction_cases.items():
|
||||||
configuration = {
|
configuration = {
|
||||||
key: value
|
key: value
|
||||||
for key, value in expected.items()
|
for key, value in expected.items()
|
||||||
if key not in {"succeeds", "rollback_fails"}
|
if key not in {"succeeds", "rollback_fails", "settled_dropin"}
|
||||||
}
|
}
|
||||||
status, output, calls, fixture_root, _ = run_case(case, **configuration)
|
status, output, calls, fixture_root, _ = run_case(case, **configuration)
|
||||||
call_lines = calls.splitlines()
|
call_lines = calls.splitlines()
|
||||||
dropin = fixture_root / "etc/ssh/sshd_config.d/90-panama.conf"
|
dropin = fixture_root / "etc/ssh/sshd_config.d/00-panama.conf"
|
||||||
sshd_dir = dropin.parent
|
sshd_dir = dropin.parent
|
||||||
validations = [index for index, line in enumerate(call_lines) if line.startswith("sshd -t ")]
|
validations = [index for index, line in enumerate(call_lines) if line.startswith("sshd -t ")]
|
||||||
reloads = [
|
reloads = [
|
||||||
@@ -539,7 +901,7 @@ for case, expected in transaction_cases.items():
|
|||||||
(index, line)
|
(index, line)
|
||||||
for index, line in enumerate(call_lines)
|
for index, line in enumerate(call_lines)
|
||||||
if re.fullmatch(
|
if re.fullmatch(
|
||||||
rf"mv -f -- {re.escape(str(sshd_dir))}/\.90-panama\.[A-Za-z0-9]+\.tmp "
|
rf"mv -f -- {re.escape(str(sshd_dir))}/\.00-panama\.[A-Za-z0-9]+\.tmp "
|
||||||
rf"{re.escape(str(dropin))} source-mode=600 ",
|
rf"{re.escape(str(dropin))} source-mode=600 ",
|
||||||
line,
|
line,
|
||||||
)
|
)
|
||||||
@@ -548,8 +910,8 @@ for case, expected in transaction_cases.items():
|
|||||||
index
|
index
|
||||||
for index, line in enumerate(call_lines)
|
for index, line in enumerate(call_lines)
|
||||||
if re.fullmatch(
|
if re.fullmatch(
|
||||||
rf"mv -f -- {re.escape(str(sshd_dir))}/\.90-panama\.[A-Za-z0-9]+\.restore "
|
rf"mv -f -- {re.escape(str(sshd_dir))}/\.00-panama\.[A-Za-z0-9]+\.restore "
|
||||||
rf"{re.escape(str(dropin))} source-mode=600 ",
|
rf"{re.escape(str(dropin))} source-mode=640 ",
|
||||||
line,
|
line,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -570,31 +932,86 @@ for case, expected in transaction_cases.items():
|
|||||||
if not succeeds and "install-handoff " in calls:
|
if not succeeds and "install-handoff " in calls:
|
||||||
note(f"{case}: failed transaction handed off to install")
|
note(f"{case}: failed transaction handed off to install")
|
||||||
|
|
||||||
wanted_contents = desired_dropin if succeeds else expected["prior_dropin"]
|
wanted_contents = (
|
||||||
|
desired_dropin
|
||||||
|
if succeeds
|
||||||
|
else expected.get("settled_dropin", expected["prior_dropin"])
|
||||||
|
)
|
||||||
actual_contents = dropin.read_bytes() if dropin.exists() else None
|
actual_contents = dropin.read_bytes() if dropin.exists() else None
|
||||||
if actual_contents != wanted_contents:
|
if actual_contents != wanted_contents:
|
||||||
note(f"{case}: SSH drop-in contents were not {'activated' if succeeds else 'restored'}")
|
note(f"{case}: SSH drop-in contents were not {'activated' if succeeds else 'restored'}")
|
||||||
|
|
||||||
if len(activation_lines) != 1:
|
if len(activation_lines) != 1:
|
||||||
note(f"{case}: candidate was not activated once through a restrictive same-directory rename")
|
note(f"{case}: candidate was not activated once through a restrictive same-directory rename")
|
||||||
|
root_policy_lines = [
|
||||||
|
index
|
||||||
|
for index, line in enumerate(call_lines)
|
||||||
|
if line == r"sshd -T -C user=root\,host=localhost\,addr=127.0.0.1 "
|
||||||
|
]
|
||||||
|
target_policy_lines = [
|
||||||
|
index
|
||||||
|
for index, line in enumerate(call_lines)
|
||||||
|
if line == r"sshd -T -C user=gib\,host=localhost\,addr=127.0.0.1 "
|
||||||
|
]
|
||||||
|
if case in {
|
||||||
|
"candidate-invalid",
|
||||||
|
"candidate-invalid-without-prior",
|
||||||
|
"rollback-removal-fails-without-prior",
|
||||||
|
}:
|
||||||
|
expected_policy_users: tuple[str, ...] = ()
|
||||||
|
elif case == "effective-root-policy-conflict":
|
||||||
|
expected_policy_users = ("root",)
|
||||||
|
else:
|
||||||
|
expected_policy_users = ("root", "gib")
|
||||||
|
if len(root_policy_lines) != (1 if "root" in expected_policy_users else 0):
|
||||||
|
note(f"{case}: effective root policy validation count was wrong")
|
||||||
|
if len(target_policy_lines) != (1 if "gib" in expected_policy_users else 0):
|
||||||
|
note(f"{case}: effective target policy validation count was wrong")
|
||||||
|
|
||||||
if succeeds:
|
if succeeds:
|
||||||
if len(validations) != 1 or len(reloads) != 1:
|
if len(validations) != 1 or len(reloads) != 1:
|
||||||
note(f"{case}: success did not validate once and reload once")
|
note(f"{case}: success did not validate once and reload once")
|
||||||
elif activation_lines and not activation_lines[0][0] < validations[0] < reloads[0]:
|
elif root_policy_lines and target_policy_lines and activation_lines and not (
|
||||||
note(f"{case}: success did not activate, validate, then reload")
|
activation_lines[0][0]
|
||||||
elif case in {"candidate-invalid", "candidate-invalid-without-prior"}:
|
< validations[0]
|
||||||
if len(validations) != 2 or reloads:
|
< root_policy_lines[0]
|
||||||
note(f"{case}: invalid candidate did not validate candidate and restoration without reload")
|
< target_policy_lines[0]
|
||||||
elif activation_lines and rollback_lines and not (
|
< reloads[0]
|
||||||
activation_lines[0][0] < validations[0] < rollback_lines[0] < validations[1]
|
|
||||||
):
|
):
|
||||||
note(f"{case}: rollback command order was wrong")
|
note(f"{case}: success did not activate, validate syntax and effective policy, then reload")
|
||||||
|
elif case in {
|
||||||
|
"candidate-invalid",
|
||||||
|
"candidate-invalid-without-prior",
|
||||||
|
"rollback-removal-fails-without-prior",
|
||||||
|
"effective-root-policy-conflict",
|
||||||
|
"effective-target-policy-conflict",
|
||||||
|
}:
|
||||||
|
if len(validations) != 2 or reloads:
|
||||||
|
note(f"{case}: rejected candidate did not validate candidate and restoration without reload")
|
||||||
|
elif activation_lines and rollback_lines:
|
||||||
|
policy_order = [
|
||||||
|
*root_policy_lines,
|
||||||
|
*target_policy_lines,
|
||||||
|
]
|
||||||
|
if not (
|
||||||
|
activation_lines[0][0]
|
||||||
|
< validations[0]
|
||||||
|
< (policy_order[0] if policy_order else rollback_lines[0])
|
||||||
|
and all(
|
||||||
|
left < right
|
||||||
|
for left, right in zip(policy_order, [*policy_order[1:], rollback_lines[0]])
|
||||||
|
)
|
||||||
|
and rollback_lines[0] < validations[1]
|
||||||
|
):
|
||||||
|
note(f"{case}: rollback command order was wrong")
|
||||||
else:
|
else:
|
||||||
if len(validations) != 2 or len(reloads) != 2:
|
if len(validations) != 2 or len(reloads) != 2:
|
||||||
note(f"{case}: reload failure did not validate and reload the restored configuration")
|
note(f"{case}: reload failure did not validate and reload the restored configuration")
|
||||||
elif activation_lines and rollback_lines and not (
|
elif activation_lines and rollback_lines and root_policy_lines and target_policy_lines and not (
|
||||||
activation_lines[0][0]
|
activation_lines[0][0]
|
||||||
< validations[0]
|
< validations[0]
|
||||||
|
< root_policy_lines[0]
|
||||||
|
< target_policy_lines[0]
|
||||||
< reloads[0]
|
< reloads[0]
|
||||||
< rollback_lines[0]
|
< rollback_lines[0]
|
||||||
< validations[1]
|
< validations[1]
|
||||||
@@ -607,6 +1024,9 @@ for case, expected in transaction_cases.items():
|
|||||||
if not succeeds:
|
if not succeeds:
|
||||||
if len(rollback_lines) != 1:
|
if len(rollback_lines) != 1:
|
||||||
note(f"{case}: pre-transaction SSH state was not restored exactly once")
|
note(f"{case}: pre-transaction SSH state was not restored exactly once")
|
||||||
|
if expected["prior_dropin"] is not None and dropin.exists():
|
||||||
|
if regular_metadata(dropin) != expected_prior_metadata:
|
||||||
|
note(f"{case}: rollback did not restore complete regular-file metadata")
|
||||||
|
|
||||||
detected_unit = "ssh.service" if case == "success-replaces-prior-dropin" else "sshd.service"
|
detected_unit = "ssh.service" if case == "success-replaces-prior-dropin" else "sshd.service"
|
||||||
other_unit = "sshd.service" if detected_unit == "ssh.service" else "ssh.service"
|
other_unit = "sshd.service" if detected_unit == "ssh.service" else "ssh.service"
|
||||||
@@ -628,20 +1048,26 @@ for case, expected in transaction_cases.items():
|
|||||||
):
|
):
|
||||||
note(f"{case}: did not fall back from absent sshd.service to ssh.service")
|
note(f"{case}: did not fall back from absent sshd.service to ssh.service")
|
||||||
|
|
||||||
artifacts = list(sshd_dir.glob(".90-panama.*"))
|
artifacts = list(sshd_dir.glob(".00-panama.*"))
|
||||||
rollback_fails = bool(expected.get("rollback_fails", False))
|
rollback_fails = bool(expected.get("rollback_fails", False))
|
||||||
if not rollback_fails and artifacts:
|
if not rollback_fails and artifacts:
|
||||||
note(f"{case}: successful or cleanly rolled-back transaction left temporary artifacts")
|
note(f"{case}: successful or cleanly rolled-back transaction left temporary artifacts")
|
||||||
if rollback_fails:
|
if rollback_fails:
|
||||||
backups = [artifact for artifact in artifacts if artifact.name.endswith(".backup")]
|
backups = [artifact for artifact in artifacts if artifact.name.endswith(".backup")]
|
||||||
if len(backups) != 1:
|
if expected["prior_dropin"] is None:
|
||||||
|
if backups:
|
||||||
|
note(f"{case}: no-prior-file recovery retained a nonexistent backup")
|
||||||
|
expected_remove = f"rm -f -- {dropin.resolve()}"
|
||||||
|
if expected_remove not in output or "cp -a --" in output:
|
||||||
|
note(f"{case}: no-prior-file recovery did not instruct removal of the installed drop-in")
|
||||||
|
elif len(backups) != 1:
|
||||||
note(f"{case}: rollback failure did not retain exactly one backup")
|
note(f"{case}: rollback failure did not retain exactly one backup")
|
||||||
else:
|
else:
|
||||||
backup = backups[0]
|
backup = backups[0]
|
||||||
if backup.parent != sshd_dir or backup.read_bytes() != prior_dropin:
|
if backup.parent != sshd_dir or backup.read_bytes() != prior_dropin:
|
||||||
note(f"{case}: retained backup was not a same-directory byte copy")
|
note(f"{case}: retained backup was not a same-directory copy")
|
||||||
if backup.stat().st_mode & 0o777 != 0o600:
|
if regular_metadata(backup) != expected_prior_metadata:
|
||||||
note(f"{case}: retained backup permissions were not restrictive")
|
note(f"{case}: retained backup did not preserve complete regular-file metadata")
|
||||||
if str(backup.resolve()) not in output:
|
if str(backup.resolve()) not in output:
|
||||||
note(f"{case}: recovery output omitted the absolute backup path")
|
note(f"{case}: recovery output omitted the absolute backup path")
|
||||||
if "sshd -t" not in output or f"systemctl reload {detected_unit}" not in output:
|
if "sshd -t" not in output or f"systemctl reload {detected_unit}" not in output:
|
||||||
@@ -650,7 +1076,7 @@ for case, expected in transaction_cases.items():
|
|||||||
artifact_logs = [line for line in call_lines if line.startswith("ssh-artifact ")]
|
artifact_logs = [line for line in call_lines if line.startswith("ssh-artifact ")]
|
||||||
if expected["prior_dropin"] is not None and not any(
|
if expected["prior_dropin"] is not None and not any(
|
||||||
re.fullmatch(
|
re.fullmatch(
|
||||||
rf"ssh-artifact {re.escape(str(sshd_dir))}/\.90-panama\.[A-Za-z0-9]+\.backup 600 ",
|
rf"ssh-artifact {re.escape(str(sshd_dir))}/\.00-panama\.[A-Za-z0-9]+\.backup 640 ",
|
||||||
line,
|
line,
|
||||||
)
|
)
|
||||||
for line in artifact_logs
|
for line in artifact_logs
|
||||||
@@ -684,8 +1110,8 @@ for case, expected in cleanup_failure_cases.items():
|
|||||||
rm_backup_results=expected["rm_backup_results"],
|
rm_backup_results=expected["rm_backup_results"],
|
||||||
prior_dropin=prior_dropin,
|
prior_dropin=prior_dropin,
|
||||||
)
|
)
|
||||||
dropin = fixture_root / "etc/ssh/sshd_config.d/90-panama.conf"
|
dropin = fixture_root / "etc/ssh/sshd_config.d/00-panama.conf"
|
||||||
backups = list(dropin.parent.glob(".90-panama.*.backup"))
|
backups = list(dropin.parent.glob(".00-panama.*.backup"))
|
||||||
if status == 0:
|
if status == 0:
|
||||||
note(f"{case}: cleanup failure returned success")
|
note(f"{case}: cleanup failure returned success")
|
||||||
if "install-handoff " in calls:
|
if "install-handoff " in calls:
|
||||||
@@ -696,20 +1122,27 @@ for case, expected in cleanup_failure_cases.items():
|
|||||||
note(f"{case}: cleanup failure validation count was wrong")
|
note(f"{case}: cleanup failure validation count was wrong")
|
||||||
if calls.count("systemctl reload sshd.service \n") != expected["expected_reloads"]:
|
if calls.count("systemctl reload sshd.service \n") != expected["expected_reloads"]:
|
||||||
note(f"{case}: cleanup failure reload count was wrong")
|
note(f"{case}: cleanup failure reload count was wrong")
|
||||||
|
expected_policy_checks = 1 if case == "success-backup-cleanup-fails" else 0
|
||||||
|
if calls.count("sshd -T -C user=root\\,host=localhost\\,addr=127.0.0.1 \n") != expected_policy_checks:
|
||||||
|
note(f"{case}: cleanup failure root policy validation count was wrong")
|
||||||
|
if calls.count("sshd -T -C user=gib\\,host=localhost\\,addr=127.0.0.1 \n") != expected_policy_checks:
|
||||||
|
note(f"{case}: cleanup failure target policy validation count was wrong")
|
||||||
if len(backups) != 1:
|
if len(backups) != 1:
|
||||||
note(f"{case}: failed cleanup did not retain exactly one backup")
|
note(f"{case}: failed cleanup did not retain exactly one backup")
|
||||||
else:
|
else:
|
||||||
backup = backups[0]
|
backup = backups[0]
|
||||||
if str(backup.resolve()) not in output or "rm -f --" not in output:
|
if str(backup.resolve()) not in output or "rm -f --" not in output:
|
||||||
note(f"{case}: retained backup was not reported with an actionable cleanup command")
|
note(f"{case}: retained backup was not reported with an actionable cleanup command")
|
||||||
|
if regular_metadata(backup) != expected_prior_metadata:
|
||||||
|
note(f"{case}: cleanup failure backup lost regular-file metadata")
|
||||||
|
|
||||||
status, output, calls, fixture_root, _ = run_case(
|
status, output, calls, fixture_root, _ = run_case(
|
||||||
"candidate-cleanup-fails",
|
"candidate-cleanup-fails",
|
||||||
mv_activation_results=(1,),
|
mv_activation_results=(1,),
|
||||||
rm_candidate_results=(1,),
|
rm_candidate_results=(1,),
|
||||||
)
|
)
|
||||||
dropin = fixture_root / "etc/ssh/sshd_config.d/90-panama.conf"
|
dropin = fixture_root / "etc/ssh/sshd_config.d/00-panama.conf"
|
||||||
candidates = list(dropin.parent.glob(".90-panama.*.tmp"))
|
candidates = list(dropin.parent.glob(".00-panama.*.tmp"))
|
||||||
if status == 0:
|
if status == 0:
|
||||||
note("candidate-cleanup-fails: activation cleanup failure returned success")
|
note("candidate-cleanup-fails: activation cleanup failure returned success")
|
||||||
if dropin.exists():
|
if dropin.exists():
|
||||||
@@ -745,13 +1178,13 @@ for case, expected in signal_cases.items():
|
|||||||
reload_results=(0,),
|
reload_results=(0,),
|
||||||
prior_dropin=expected["prior_dropin"],
|
prior_dropin=expected["prior_dropin"],
|
||||||
)
|
)
|
||||||
dropin = fixture_root / "etc/ssh/sshd_config.d/90-panama.conf"
|
dropin = fixture_root / "etc/ssh/sshd_config.d/00-panama.conf"
|
||||||
actual_dropin = dropin.read_bytes() if dropin.exists() else None
|
actual_dropin = dropin.read_bytes() if dropin.exists() else None
|
||||||
if status != expected["status"]:
|
if status != expected["status"]:
|
||||||
note(f"{case}: signal returned status {status}, expected {expected['status']}")
|
note(f"{case}: signal returned status {status}, expected {expected['status']}")
|
||||||
if actual_dropin != expected["prior_dropin"]:
|
if actual_dropin != expected["prior_dropin"]:
|
||||||
note(f"{case}: signal did not restore the pre-transaction SSH state")
|
note(f"{case}: signal did not restore the pre-transaction SSH state")
|
||||||
if list(dropin.parent.glob(".90-panama.*")):
|
if list(dropin.parent.glob(".00-panama.*")):
|
||||||
note(f"{case}: signal left transaction residue")
|
note(f"{case}: signal left transaction residue")
|
||||||
if "install-handoff " in calls:
|
if "install-handoff " in calls:
|
||||||
note(f"{case}: signal reached install handoff")
|
note(f"{case}: signal reached install handoff")
|
||||||
@@ -768,8 +1201,8 @@ for case, expected in signal_cases.items():
|
|||||||
if (
|
if (
|
||||||
expected["prior_dropin"] is not None
|
expected["prior_dropin"] is not None
|
||||||
and re.fullmatch(
|
and re.fullmatch(
|
||||||
rf"mv -f -- {re.escape(str(dropin.parent))}/\.90-panama\.[A-Za-z0-9]+\.restore "
|
rf"mv -f -- {re.escape(str(dropin.parent))}/\.00-panama\.[A-Za-z0-9]+\.restore "
|
||||||
rf"{re.escape(str(dropin))} source-mode=600 ",
|
rf"{re.escape(str(dropin))} source-mode=640 ",
|
||||||
line,
|
line,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -788,6 +1221,8 @@ for case, expected in signal_cases.items():
|
|||||||
and rollback_indices[0] < validation_indices[0] < reload_indices[0]
|
and rollback_indices[0] < validation_indices[0] < reload_indices[0]
|
||||||
):
|
):
|
||||||
note(f"{case}: signal did not restore, validate, then reload in order")
|
note(f"{case}: signal did not restore, validate, then reload in order")
|
||||||
|
if expected["prior_dropin"] is not None and regular_metadata(dropin) != expected_prior_metadata:
|
||||||
|
note(f"{case}: signal rollback did not restore complete regular-file metadata")
|
||||||
|
|
||||||
pre_activation_signal_cases = {
|
pre_activation_signal_cases = {
|
||||||
"signal-int-before-activation-prior": {
|
"signal-int-before-activation-prior": {
|
||||||
@@ -819,10 +1254,10 @@ for case, expected in pre_activation_signal_cases.items():
|
|||||||
prior_dropin=expected["prior_dropin"],
|
prior_dropin=expected["prior_dropin"],
|
||||||
rm_candidate_results=rm_candidate_results,
|
rm_candidate_results=rm_candidate_results,
|
||||||
)
|
)
|
||||||
dropin = fixture_root / "etc/ssh/sshd_config.d/90-panama.conf"
|
dropin = fixture_root / "etc/ssh/sshd_config.d/00-panama.conf"
|
||||||
actual_dropin = dropin.read_bytes() if dropin.exists() else None
|
actual_dropin = dropin.read_bytes() if dropin.exists() else None
|
||||||
candidates = list(dropin.parent.glob(".90-panama.*.tmp"))
|
candidates = list(dropin.parent.glob(".00-panama.*.tmp"))
|
||||||
backups = list(dropin.parent.glob(".90-panama.*.backup"))
|
backups = list(dropin.parent.glob(".00-panama.*.backup"))
|
||||||
if status != expected["status"]:
|
if status != expected["status"]:
|
||||||
note(f"{case}: signal returned status {status}, expected {expected['status']}")
|
note(f"{case}: signal returned status {status}, expected {expected['status']}")
|
||||||
if actual_dropin != expected["prior_dropin"]:
|
if actual_dropin != expected["prior_dropin"]:
|
||||||
@@ -848,6 +1283,50 @@ for case, expected in pre_activation_signal_cases.items():
|
|||||||
if ".restore " in calls or f"rm -f -- {dropin} " in calls:
|
if ".restore " in calls or f"rm -f -- {dropin} " in calls:
|
||||||
note(f"{case}: pre-activation signal rewrote the unchanged final drop-in")
|
note(f"{case}: pre-activation signal rewrote the unchanged final drop-in")
|
||||||
|
|
||||||
|
preparation_signal_cases = {
|
||||||
|
"signal-int-during-candidate-preparation": {
|
||||||
|
"signal": signal.SIGINT,
|
||||||
|
"status": 130,
|
||||||
|
"prior_dropin": None,
|
||||||
|
"phase": "candidate",
|
||||||
|
},
|
||||||
|
"signal-term-during-backup-preparation": {
|
||||||
|
"signal": signal.SIGTERM,
|
||||||
|
"status": 143,
|
||||||
|
"prior_dropin": prior_dropin,
|
||||||
|
"phase": "backup",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for case, expected in preparation_signal_cases.items():
|
||||||
|
signal_arguments = (
|
||||||
|
{"signal_during_candidate_preparation": expected["signal"]}
|
||||||
|
if expected["phase"] == "candidate"
|
||||||
|
else {"signal_during_backup_preparation": expected["signal"]}
|
||||||
|
)
|
||||||
|
status, output, calls, fixture_root, boot_pid = run_case(
|
||||||
|
case,
|
||||||
|
prior_traps=True,
|
||||||
|
prior_dropin=expected["prior_dropin"],
|
||||||
|
**signal_arguments,
|
||||||
|
)
|
||||||
|
dropin = fixture_root / "etc/ssh/sshd_config.d/00-panama.conf"
|
||||||
|
actual_dropin = dropin.read_bytes() if dropin.exists() else None
|
||||||
|
if status != expected["status"]:
|
||||||
|
note(f"{case}: signal returned status {status}, expected {expected['status']}")
|
||||||
|
if actual_dropin != expected["prior_dropin"]:
|
||||||
|
note(f"{case}: preparation signal changed the final drop-in")
|
||||||
|
if list(dropin.parent.glob(".00-panama.*")):
|
||||||
|
note(f"{case}: preparation signal left candidate or backup residue")
|
||||||
|
if "install-handoff " in calls:
|
||||||
|
note(f"{case}: preparation signal reached install handoff")
|
||||||
|
if f"prior-exit {boot_pid}\n" not in calls:
|
||||||
|
note(f"{case}: preparation signal suppressed the saved EXIT trap")
|
||||||
|
if "sshd " in calls or "systemctl reload " in calls:
|
||||||
|
note(f"{case}: preparation signal validated or reloaded unchanged SSH state")
|
||||||
|
if expected["prior_dropin"] is not None and regular_metadata(dropin) != expected_prior_metadata:
|
||||||
|
note(f"{case}: preparation signal changed prior regular-file metadata")
|
||||||
|
|
||||||
if findings:
|
if findings:
|
||||||
print(f"root server bootstrap: {len(findings)} finding(s)", file=sys.stderr)
|
print(f"root server bootstrap: {len(findings)} finding(s)", file=sys.stderr)
|
||||||
for finding in findings:
|
for finding in findings:
|
||||||
|
|||||||
Reference in New Issue
Block a user