#!/bin/ksh # # First-login security audit for OpenBSD. # # This script is intentionally diagnostic: it does not repair, install, or # reconfigure the host. OpenBSD security(8) may rotate its normal comparison # state in /var/backups, just as it does during the daily security run. # This script cannot prove that a host is clean. # A root-level attacker may alter the kernel, commands, logs, or this script's # view of the system. Treat warnings as reasons to investigate and credible # evidence of root compromise as a reason to isolate and reinstall the host. # # Exit status: # 0 No automatic red flags were found (not proof that the host is clean). # 1 One or more findings require manual review. # 2 The audit could not run correctly. PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin export PATH umask 077 WARNINGS=0 ERRORS=0 heading() { printf '\n===== %s =====\n' "$1" } pass() { printf '[PASS] %s\n' "$1" } info() { printf '[INFO] %s\n' "$1" } warn() { printf '[WARN] %s\n' "$1" WARNINGS=$((WARNINGS + 1)) } error() { printf '[ERROR] %s\n' "$1" >&2 ERRORS=$((ERRORS + 1)) } print_file() { if [ -s "$1" ]; then sed 's/^/ /' "$1" fi } if [ "$(id -u)" -ne 0 ]; then printf 'Run this audit as root, for example:\n' >&2 printf ' doas %s\n' "$0" >&2 exit 2 fi if [ "$(uname -s)" != "OpenBSD" ]; then printf 'This script is intended only for OpenBSD.\n' >&2 exit 2 fi AUDIT_TMPDIR=$(mktemp -d /tmp/openbsd-audit.XXXXXXXXXX) || { printf 'Cannot create temporary audit directory.\n' >&2 exit 2 } cleanup() { case "$AUDIT_TMPDIR" in /tmp/openbsd-audit.*) /bin/rm -rf "$AUDIT_TMPDIR" ;; esac } case "$AUDIT_TMPDIR" in /tmp/openbsd-audit.*) trap cleanup EXIT trap 'exit 2' HUP INT TERM ;; *) printf 'Refusing unexpected temporary path: %s\n' "$AUDIT_TMPDIR" >&2 exit 2 ;; esac heading "IMPORTANT LIMITATION" cat <<'EOF' This report can find common integrity and persistence problems, but it cannot certify that the host is unhacked. A sufficiently privileged attacker can make a running system lie. For strong assurance, inspect from trusted boot media or reinstall from verified OpenBSD media and rotate exposed secrets. EOF heading "SYSTEM IDENTITY" printf 'Date (UTC): %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" printf 'Hostname: %s\n' "$(hostname)" printf 'Kernel: %s\n' "$(uname -a)" printf 'Architecture: %s\n' "$(machine 2>/dev/null || uname -m)" printf 'Uptime: %s\n' "$(uptime)" if [ -n "${SSH_CONNECTION:-}" ]; then set -- $SSH_CONNECTION printf 'SSH source: %s:%s\n' "$1" "$2" printf 'SSH destination: %s:%s\n' "$3" "$4" else printf 'SSH connection: not detected\n' fi SECURELEVEL=$(sysctl -n kern.securelevel 2>/dev/null) if [ -z "$SECURELEVEL" ]; then error "Could not read kern.securelevel" elif [ "$SECURELEVEL" -ge 1 ] 2>/dev/null; then pass "kern.securelevel is $SECURELEVEL" else warn "kern.securelevel is $SECURELEVEL; review why it is below 1" fi heading "OPENBSD SECURITY(8)" SECURITY_OUT="$AUDIT_TMPDIR/security.out" /usr/libexec/security >"$SECURITY_OUT" 2>&1 SECURITY_RC=$? if [ "$SECURITY_RC" -ne 0 ]; then warn "security(8) exited with status $SECURITY_RC" print_file "$SECURITY_OUT" elif [ -s "$SECURITY_OUT" ]; then warn "security(8) produced findings; review every line" print_file "$SECURITY_OUT" else pass "security(8) produced no findings" fi heading "BASE SYSTEM PATCHES" printf '%s\n' "Installed syspatches:" syspatch -l 2>&1 | sed 's/^/ /' SYSPATCH_OUT="$AUDIT_TMPDIR/syspatch.out" syspatch -c >"$SYSPATCH_OUT" 2>&1 SYSPATCH_RC=$? if [ "$SYSPATCH_RC" -ne 0 ]; then warn "Could not reliably check for missing syspatches (status $SYSPATCH_RC)" print_file "$SYSPATCH_OUT" elif [ -s "$SYSPATCH_OUT" ]; then warn "Available syspatches are not installed" print_file "$SYSPATCH_OUT" else pass "No missing syspatches were reported" fi heading "INSTALLED PACKAGE INTEGRITY" PKG_OUT="$AUDIT_TMPDIR/pkg-check.out" pkg_check -n -F -x >"$PKG_OUT" 2>&1 PKG_RC=$? if [ "$PKG_RC" -ne 0 ]; then warn "pkg_check reported an error (status $PKG_RC)" print_file "$PKG_OUT" elif [ -s "$PKG_OUT" ]; then warn "pkg_check produced output; review package checksums and extra objects" print_file "$PKG_OUT" else pass "Installed package files and checksums passed pkg_check" fi heading "PRIVILEGED ACCOUNTS" UID0_OUT="$AUDIT_TMPDIR/uid0.out" awk -F: '$3 == 0 { print $1 ":" $7 }' /etc/master.passwd >"$UID0_OUT" printf '%s\n' "Accounts with UID 0:" print_file "$UID0_OUT" UID0_COUNT=$(wc -l <"$UID0_OUT" | tr -d ' ') if [ "$UID0_COUNT" -eq 1 ] && grep -q '^root:' "$UID0_OUT"; then pass "Only root has UID 0" else warn "Expected exactly one UID 0 account named root" fi heading "SSH AUTHORIZED KEYS" KEY_LIST="$AUDIT_TMPDIR/authorized-keys.list" find /root /home -type f -path '*/.ssh/authorized_keys' -print \ 2>/dev/null >"$KEY_LIST" if [ ! -s "$KEY_LIST" ]; then info "No authorized_keys files were found" else while IFS= read -r KEY_FILE; do printf '%s\n' "File: $KEY_FILE" ls -ld "$KEY_FILE" | sed 's/^/ /' ssh-keygen -lf "$KEY_FILE" 2>&1 | sed 's/^/ /' done <"$KEY_LIST" info "Confirm that every SSH key fingerprint belongs to an authorized person" fi heading "RC SERVICES" printf '%s\n' "Enabled services:" rcctl ls on 2>&1 | sed 's/^/ /' printf '%s\n' "Currently started services:" rcctl ls started 2>&1 | sed 's/^/ /' FAILED_RC="$AUDIT_TMPDIR/rc-failed.out" rcctl ls failed >"$FAILED_RC" 2>&1 FAILED_RC_STATUS=$? if [ "$FAILED_RC_STATUS" -ne 0 ]; then warn "Could not query failed rc services" print_file "$FAILED_RC" elif [ -s "$FAILED_RC" ]; then warn "Some enabled services are in a failed state" print_file "$FAILED_RC" else pass "No failed rc services were reported" fi heading "CRON PERSISTENCE LOCATIONS" if [ -d /var/cron/tabs ]; then find /var/cron/tabs -type f -exec ls -ld {} \; 2>/dev/null else warn "/var/cron/tabs does not exist" fi info "Review the content of every expected user crontab manually" heading "RECENT LOGINS" last -20 2>&1 heading "RECENT SSH AUTHENTICATION EVENTS" if [ -r /var/log/authlog ]; then grep -Ei 'accepted|failed|invalid user|authentication' /var/log/authlog \ 2>/dev/null | tail -50 else warn "Cannot read /var/log/authlog" fi heading "RUNNING PROCESSES" ps auxww heading "INTERNET SOCKETS AND CONNECTIONS" FSTAT_NET="$AUDIT_TMPDIR/fstat-network.out" fstat -n 2>/dev/null | grep -E 'internet|internet6' >"$FSTAT_NET" if [ -s "$FSTAT_NET" ]; then cat "$FSTAT_NET" info "Confirm that every listener and remote connection is expected" else info "No internet sockets were reported by fstat" fi heading "PF STATUS AND RULES" pfctl -si 2>&1 printf '\nFilter rules:\n' pfctl -sr 2>&1 printf '\nNAT and redirection rules:\n' pfctl -sn 2>&1 heading "FILESYSTEMS" mount df -h heading "AUDIT RESULT" if [ "$ERRORS" -gt 0 ]; then printf '[ERROR] Audit incomplete: %d error(s), %d warning(s).\n' \ "$ERRORS" "$WARNINGS" printf 'Do not treat this host as verified.\n' exit 2 elif [ "$WARNINGS" -gt 0 ]; then printf '[WARN] Manual review required: %d warning(s).\n' "$WARNINGS" printf 'Warnings are not proof of compromise, but they must be explained.\n' exit 1 else printf '[PASS] No automatic red flags were found.\n' printf 'This increases confidence but does not prove that the host is clean.\n' exit 0 fi