#!/bin/sh
# Verify that commonly used passwords are rejected (ism-2078) on a Debian 13 host.
#
# Every candidate is padded to the minimum length in force, measured rather than
# read, so a rejection is never for length when this rule is under test. Settings
# this component owns being set elsewhere is reported separately, as a conflict.
#
# Uses pwscore(1). No account is created or modified, so this is safe on a
# production host.

set -u

PASS_COUNT=0
FAIL_COUNT=0
PARTIAL_COUNT=0

report() {
	case "$1" in
		PASS) PASS_COUNT=$((PASS_COUNT + 1)) ;;
		PARTIAL) PARTIAL_COUNT=$((PARTIAL_COUNT + 1)) ;;
		*) FAIL_COUNT=$((FAIL_COUNT + 1)) ;;
	esac
	printf '%-8s %-13s %s\n' "$1" "$2" "$3"
}

accepts() { printf '%s' "$1" | pwscore >/dev/null 2>&1; }

command -v pwscore >/dev/null 2>&1 || { echo "error: pwscore not found; install libpwquality-tools" >&2; exit 1; }

MINE=/etc/security/pwquality.conf.d/50-ism-dictionary.conf
[ -f "$MINE" ] || { echo "error: $MINE is not installed; run the apply script" >&2; exit 1; }

FILLER=vaultottermangoquiltravenfjordzebraplinthwidgetcarbonhelixjunipermarrow
slice() { printf '%s%s' "$FILLER" "$FILLER" | cut -c1-"$1"; }

FLOOR=
n=1
while [ "$n" -le 128 ]; do
	if accepts "$(slice "$n")"; then FLOOR=$n; break; fi
	n=$((n + 1))
done
: "${FLOOR:=6}"

echo "Minimum in force: $FLOOR characters"
echo

# Common passwords, padded to the minimum in force: a shorter one is rejected for
# LENGTH, which proves nothing about the dictionary.
WEAK_TOTAL=0
WEAK_REJECTED=0
for base in password letmein qwerty welcome monkey trustno1 iloveyou admin; do
	candidate=$base
	while [ "${#candidate}" -lt "$FLOOR" ]; do candidate="${candidate}${base}"; done
	WEAK_TOTAL=$((WEAK_TOTAL + 1))
	accepts "$candidate" || WEAK_REJECTED=$((WEAK_REJECTED + 1))
done
if [ "$WEAK_REJECTED" -eq "$WEAK_TOTAL" ]; then
	report PASS "ism-2078" "all $WEAK_TOTAL common passwords rejected at or above the minimum in force"
else
	report PARTIAL "ism-2078" \
		"only $WEAK_REJECTED/$WEAK_TOTAL common passwords rejected at or above the minimum in force; cracklib is not a breach corpus"
fi

# The organisation word list is this component s own, so it should always bite.
# The last term in the list, which is the least likely to be in cracklib already,
# so a rejection is attributable to badwords rather than the dictionary.
ORG=$(awk -F= '/^[[:space:]]*badwords[[:space:]]*=/ {print $NF}' "$MINE" | awk '{print $NF}')
if [ -n "$ORG" ]; then
	candidate=$ORG
	while [ "${#candidate}" -lt "$FLOOR" ]; do candidate="${candidate}${ORG}"; done
	accepts "$candidate" &&
		report FAIL "badwords" "the organisation word \"$ORG\" is accepted; badwords is not in force" ||
		report PASS "badwords" "the organisation word list is in force"
fi

# IA-5(1)(a) is this component s, and is only as good as the dictionary behind it.
[ "$WEAK_REJECTED" -eq "$WEAK_TOTAL" ] &&
	report PASS "ia-5.1_smt.a" "common passwords rejected" ||
	report PARTIAL "ia-5.1_smt.a" "dictionary check in force but incomplete; see ism-2078"

OVERRIDES=''
for f in /etc/security/pwquality.conf /etc/security/pwquality.conf.d/*.conf; do
	[ -f "$f" ] && [ "$f" != "$MINE" ] || continue
	for key in dictcheck badwords; do
		grep -qE "^[[:space:]]*$key([[:space:]]*=|[[:space:]]*\$)" "$f" &&
			OVERRIDES="$OVERRIDES $key($(basename "$f"))"
	done
done
[ -z "$OVERRIDES" ] &&
	report PASS "settings" "the settings this component owns are set here and nowhere else" ||
	report FAIL "settings" "settings this component owns are also set elsewhere:$OVERRIDES"

echo
echo "passed: $PASS_COUNT  partial: $PARTIAL_COUNT  failed: $FAIL_COUNT"
[ "$FAIL_COUNT" -eq 0 ] || exit 1
