← All four scripts
audit.sh
Read-only. Reports what is wrong and changes nothing, so it is safe to run first and safe to run on a production box.
Read this, do not pipe it. These scripts run as root. Copy the source into a file on your own machine, read it, and run it yourself. Nobody should curl a root shell script straight off a website, including this one.
#!/usr/bin/env bash
# audit.sh — is this box safe to run autonomous AI agents on?
#
# READ-ONLY. Makes no changes, opens no network connections, writes no files.
# Safe for a stranger to run on a production box.
#
# ./audit.sh redacted report (safe to screenshot/paste)
# ./audit.sh --full include IPs, hostnames, usernames (local eyes only)
# ./audit.sh --json machine-readable
#
# Plenty of guides harden a generic VPS. This one asks the question nobody else
# does: you are running agents that can execute code and hold credentials —
# is the blast radius actually contained?
#
# Checks map to OWASP Top 10 for Agentic Applications (2026) where marked ASIxx.
set -uo pipefail
FULL=0; JSON=0
for a in "$@"; do
case "$a" in
--full) FULL=1 ;;
--json) JSON=1 ;;
-h|--help) sed -n '2,18p' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "unknown flag: $a" >&2; exit 2 ;;
esac
done
if [ -t 1 ] && [ "$JSON" = 0 ]; then
RED=$'\033[31m'; GRN=$'\033[32m'; YLW=$'\033[33m'; BLU=$'\033[34m'; DIM=$'\033[2m'; RST=$'\033[0m'
else RED=; GRN=; YLW=; BLU=; DIM=; RST=; fi
PASS=0; WARN=0; FAIL=0; ROWS=()
# redact <string> — hide identifying detail unless --full
redact() { [ "$FULL" = 1 ] && printf '%s' "$1" || printf '%s' "$2"; }
# chk <id> <severity> <title> <status:pass|warn|fail> <detail>
chk() {
local id="$1" sev="$2" title="$3" st="$4" detail="${5:-}"
case "$st" in
pass) PASS=$((PASS+1)); [ "$JSON" = 0 ] && printf ' %s✓%s %-52s %s%s%s\n' "$GRN" "$RST" "$title" "$DIM" "$detail" "$RST" ;;
warn) WARN=$((WARN+1)); [ "$JSON" = 0 ] && printf ' %s!%s %-52s %s\n' "$YLW" "$RST" "$title" "$detail" ;;
fail) FAIL=$((FAIL+1)); [ "$JSON" = 0 ] && printf ' %s✗%s %-52s %s%s%s\n' "$RED" "$RST" "$title" "$RED" "$detail" "$RST" ;;
esac
ROWS+=("{\"id\":\"$id\",\"severity\":\"$sev\",\"title\":\"${title//\"/}\",\"status\":\"$st\",\"detail\":\"${detail//\"/}\"}")
}
sec() { [ "$JSON" = 0 ] && printf '\n%s── %s%s\n' "$BLU" "$1" "$RST"; return 0; }
have() { command -v "$1" >/dev/null 2>&1; }
# Some checks need root; degrade gracefully rather than reporting a false pass.
priv() { [ "$(id -u)" -eq 0 ]; }
[ "$JSON" = 0 ] && cat <<EOF
${BLU}agent-box audit${RST} $(date -u '+%Y-%m-%d %H:%M UTC')
$([ "$FULL" = 0 ] && printf '%s(redacted — run with --full for local detail)%s' "$DIM" "$RST")
EOF
# ─────────────────────────────────────────────────────────────────────────────
sec "Exposure"
if have ss; then
# 22/80/443 are meant to be reachable; counting them as findings is noise.
unexpected=$(ss -tlnH 2>/dev/null | awk '{print $4}' \
| grep -Ev '^(127\.|\[::1\]|100\.)' \
| grep -Ev ':(22|80|443)$' | tr '\n' ' ')
n=$(printf '%s' "$unexpected" | wc -w)
[ "$n" -eq 0 ] && chk NET01 high "No unexpected public listeners" pass "only 22/80/443" \
|| chk NET01 high "Unexpected services reachable off-localhost" warn "$(redact "$unexpected" "$n socket(s)")"
wild=$(ss -tlnH 2>/dev/null | awk '$4 ~ /^\*:/ {print $4}' | tr '\n' ' ')
[ -z "$wild" ] && chk NET02 med "No all-interface (*) binds" pass "" \
|| chk NET02 med "All-interface binds found" warn "$wild"
fi
if have ufw && priv; then
if ufw status 2>/dev/null | grep -q "Status: active"; then
ufw status verbose 2>/dev/null | grep -q "deny (incoming)" \
&& chk FW01 high "Firewall active, default-deny inbound" pass "" \
|| chk FW01 high "Firewall active but NOT default-deny" fail "check ufw defaults"
# FW02. The exposure question is whether inbound IPv6 actually defaults to DROP,
# so read the policy itself rather than something that stands in for it.
#
# This replaces a check that got the right answer for the wrong reason. It keyed
# off net.ipv6.conf.all.disable_ipv6, and its comment claimed "IPV6=no means ufw
# ignores ip6tables entirely". Both are wrong:
#
# - `all` is not the operative sysctl. Interfaces carry their own, and on this
# box `all` reads 1 while eth0 reads 0 and holds a global v6 address. The old
# check therefore reported "IPv6 disabled" about a live v6 interface.
# - ufw does not ignore v6 when IPV6=no. /lib/ufw/ufw-init-functions takes the
# else branch and ip6tables-restores INPUT/FORWARD/OUTPUT policy DROP plus
# loopback ACCEPT. That is why v6 egress times out here. infra/harden/after.init
# reached the same finding on 2026-08-27; it never made it back to this file.
#
# It also used to run only when IPV6=no, so flipping ufw to IPV6=yes silently
# skipped the IPv6 check altogether. This runs either way.
if ! ip6tables -L INPUT -n >/dev/null 2>&1; then
chk FW02 high "No IPv6 stack to firewall" pass "ip6tables unavailable"
else
v6_in=$(ip6tables -S 2>/dev/null | awk '$1=="-P" && $2=="INPUT" {print $3; exit}')
v6_out=$(ip6tables -S 2>/dev/null | awk '$1=="-P" && $2=="OUTPUT" {print $3; exit}')
v6_n=$(ip -6 addr show scope global 2>/dev/null | grep -c inet6)
[ "${v6_n:-0}" -gt 0 ] && v6_where="v6 live on ${v6_n} iface(s)" \
|| v6_where="no global v6 address"
if [ "${v6_in:-}" = "DROP" ]; then
chk FW02 high "Inbound IPv6 default-deny" pass \
"$v6_where, OUTPUT policy ${v6_out:-unknown}"
else
chk FW02 high "Inbound IPv6 NOT default-deny" fail \
"INPUT policy ${v6_in:-unknown}, $v6_where"
fi
fi
else chk FW01 high "Firewall inactive" fail "ufw not enabled"; fi
elif have ufw; then chk FW01 high "Firewall state" warn "needs root to read"; fi
# ─────────────────────────────────────────────────────────────────────────────
sec "SSH"
if priv && have sshd; then
t=$(sshd -T 2>/dev/null)
g() { printf '%s' "$t" | grep -m1 "^$1 " | awk '{print $2}'; }
[ "$(g permitrootlogin)" = "no" ] && chk SSH01 high "Root login disabled" pass "" || chk SSH01 high "Root login permitted" fail "$(g permitrootlogin)"
[ "$(g passwordauthentication)" = "no" ] && chk SSH02 high "Password auth disabled" pass "" || chk SSH02 high "Password auth enabled" fail "brute-forceable"
[ -n "$(g allowusers)" ] && chk SSH03 med "AllowUsers restricts logins" pass "$(redact "$(g allowusers)" 'set')" || chk SSH03 med "No AllowUsers restriction" warn ""
[ "$(g allowtcpforwarding)" = "no" ] && chk SSH04 low "TCP forwarding disabled" pass "" || chk SSH04 low "TCP forwarding allowed" warn "pivot path"
[ "$(g clientaliveinterval)" != "0" ] && chk SSH05 low "Idle sessions time out" pass "" || chk SSH05 low "No idle timeout" warn ""
else chk SSH01 high "SSH config" warn "needs root"; fi
# ─────────────────────────────────────────────────────────────────────────────
sec "Agent blast radius ${DIM}(the part other guides skip)${RST}"
# ASI04 — supply chain. The 2026 npm worms specifically targeted AI CLI tools.
# Run npm as the invoking user: under sudo, npm would read root's prefix/npmrc
# and silently miss the admin user's global packages (found in testing).
NPM_AS="${SUDO_USER:-$(id -un)}"
npm_u() { if [ "$(id -un)" = "$NPM_AS" ]; then npm "$@"; else runuser -u "$NPM_AS" -- npm "$@"; fi; }
if have npm; then
ig=$(npm_u config get ignore-scripts 2>/dev/null)
[ "$ig" = "true" ] && chk ASI04a crit "npm install scripts disabled" pass "" \
|| chk ASI04a crit "npm postinstall scripts RUN" fail "primary supply-chain vector"
gc=$(npm_u ls -g --depth=0 2>/dev/null | tail -n +2 | grep -c "─" || true)
agents=$(npm_u ls -g --depth=0 2>/dev/null | grep -ciE 'openclaw|clawdbot|claude|opencode|codex|aider' || true)
[ "$agents" -gt 0 ] \
&& chk ASI04b high "Agent CLIs installed globally" warn "$agents agent pkg(s), $gc global total" \
|| chk ASI04b high "No global agent CLIs" pass "$gc global pkgs"
fi
# ASI05 — code execution isolation.
if have docker && priv; then
unconf=0; total=0
while read -r c; do
[ -z "$c" ] && continue
total=$((total+1))
m=$(docker inspect "$c" --format '{{.HostConfig.Memory}}' 2>/dev/null || echo 0)
[ "$m" = "0" ] && unconf=$((unconf+1))
iso=$(docker inspect "$c" --format '{{.HostConfig.NetworkMode}}/{{.HostConfig.ReadonlyRootfs}}/{{.HostConfig.CapDrop}}' 2>/dev/null)
case "$iso" in none/true/*ALL*) hardened=$((${hardened:-0}+1)) ;; esac
done < <(docker ps --format '{{.Names}}' 2>/dev/null | grep -iE 'sbx|sandbox|agent' || true)
if [ "$total" -gt 0 ]; then
[ "${hardened:-0}" -eq "$total" ] \
&& chk ASI05b high "Agent sandboxes isolated (no net, ro-rootfs, cap-drop)" pass "$total/$total" \
|| chk ASI05b high "Agent sandboxes not fully isolated" fail "${hardened:-0}/$total"
[ "$unconf" -eq 0 ] && chk ASI05a high "Agent sandboxes have memory limits" pass "$total" \
|| chk ASI05a high "Agent sandboxes UNCAPPED memory" fail "$unconf/$total — one runaway can freeze the host"
fi
fi
# ASI03 — identity and privilege abuse.
if priv; then
blanket=$(grep -rhE '^[^#]*ALL=\(ALL\)\s*NOPASSWD:\s*ALL' /etc/sudoers /etc/sudoers.d/ 2>/dev/null | grep -vc '^%' || echo 0)
[ "$blanket" -eq 0 ] && chk ASI03a high "No blanket passwordless sudo" pass "" \
|| chk ASI03a high "Blanket NOPASSWD:ALL grant" fail "agent RCE becomes instant root"
fi
shells=$(awk -F: '$3>=1000 && $3<65534 && $7 !~ /nologin|false/ {n++} END{print n+0}' /etc/passwd)
[ "$shells" -le 1 ] && chk ASI03b high "Only the admin user has a shell" pass "$shells" \
|| chk ASI03b high "Multiple users have interactive shells" warn "$shells — each is a foothold"
# Secrets reachable by non-admin users. This is the one that bites in practice.
if priv; then
leak=0
while IFS= read -r f; do
[ -n "$f" ] && leak=$((leak+1))
done < <(find /home /srv /opt -maxdepth 5 \( -name '.env' -o -name '.env.*' -o -name '*.db' \) \
-type f -perm -o=r ! -name '*.example' ! -path '*/node_modules/*' 2>/dev/null)
[ "$leak" -eq 0 ] && chk ASI03c crit "No world-readable secrets/databases" pass "" \
|| chk ASI03c crit "World-readable secrets or databases" fail "$leak file(s) — any local user can read them"
fi
# ASI10 — rogue agents: can you see it, can you stop it?
if have auditd || [ -d /etc/.git ] || have etckeeper; then
chk ASI10a med "Config-change history exists" pass "$([ -d /etc/.git ] && echo etckeeper || echo auditd)"
else
chk ASI10a med "No config-change history" warn "cannot answer 'what changed?'"
fi
# ─────────────────────────────────────────────────────────────────────────────
sec "Defence in depth"
if have fail2ban-client && priv; then
jails=$(fail2ban-client status 2>/dev/null | awk -F: '/Jail list/{print $2}' | tr -d ' \t')
jn=$(printf '%s' "$jails" | tr ',' '\n' | grep -c . || true)
case "$jails" in
*nginx*|*http*) chk DEF01 med "fail2ban guards the web surface" pass "$jn jail(s)" ;;
"") chk DEF01 med "fail2ban has no jails" warn "" ;;
*) chk DEF01 med "fail2ban only guards SSH, not nginx" warn "$jn jail(s); web is the exposed surface" ;;
esac
elif have fail2ban-client; then chk DEF01 med "fail2ban present" warn "needs root to read jails"; fi
if have tailscale; then
tailscale status >/dev/null 2>&1 \
&& chk DEF02 med "Private mesh (Tailscale) up" pass "admin ports can be gated" \
|| chk DEF02 med "Tailscale installed but down" warn ""
fi
if have aa-status && priv; then
enf=$(aa-status 2>/dev/null | awk '/profiles are in enforce mode/{print $1}')
[ "${enf:-0}" -gt 0 ] && chk DEF03 med "AppArmor enforcing" pass "${enf} profiles" \
|| chk DEF03 med "AppArmor not enforcing" warn ""
fi
# The kernel hardening sysctls, scored as a group rather than ignored.
set_count=0; want=0
for kv in kernel.dmesg_restrict:1 kernel.kptr_restrict:1 kernel.yama.ptrace_scope:1 \
kernel.unprivileged_bpf_disabled:2 net.ipv4.tcp_syncookies:1 \
net.ipv4.conf.all.accept_redirects:0 net.ipv4.conf.all.accept_source_route:0 \
fs.protected_hardlinks:1 fs.protected_symlinks:1; do
want=$((want+1))
k=${kv%:*}; v=${kv#*:}
cur=$(sysctl -n "$k" 2>/dev/null || echo "")
[ -n "$cur" ] && [ "$cur" -ge "$v" ] 2>/dev/null && set_count=$((set_count+1))
done
[ "$set_count" -ge $((want-1)) ] && chk DEF04 med "Kernel hardening sysctls set" pass "$set_count/$want" \
|| chk DEF04 med "Kernel hardening incomplete" warn "$set_count/$want"
# Agent messaging surface: who is allowed to talk to the agent at all (ASI09).
for cfg in "$HOME/.openclaw/openclaw.json" "/home/${SUDO_USER:-root}/.openclaw/openclaw.json"; do
[ -f "$cfg" ] || continue
weak=$(grep -o '"dmPolicy"[[:space:]]*:[[:space:]]*"[^"]*"' "$cfg" 2>/dev/null | grep -vc allowlist || true)
[ "${weak:-0}" -eq 0 ] && chk ASI09a high "Agent DMs restricted to an allowlist" pass "" \
|| chk ASI09a high "Agent accepts DMs beyond an allowlist" warn "$weak channel(s) not on allowlist"
break
done
# ─────────────────────────────────────────────────────────────────────────────
sec "Service confinement"
if have systemctl; then
hard=0; soft=0
while read -r u; do
[ -z "$u" ] && continue
ps=$(systemctl show "$u" -p ProtectSystem --value 2>/dev/null)
case "$ps" in strict|full) hard=$((hard+1)) ;; *) soft=$((soft+1)) ;; esac
done < <(systemctl list-units --type=service --state=running --no-legend 2>/dev/null \
| awk '{print $1}' | grep -vE '^(systemd|dbus|user@|getty|serial-getty)' | head -40)
tot=$((hard+soft))
if [ "$tot" -gt 0 ]; then
[ "$hard" -ge $((tot/2)) ] && chk SVC01 high "Most services are filesystem-confined" pass "$hard/$tot" \
|| chk SVC01 high "Services run unconfined" warn "$hard/$tot have ProtectSystem"
fi
fi
# ─────────────────────────────────────────────────────────────────────────────
sec "Patching & platform"
if have pro; then
pro status --format json 2>/dev/null | grep -q '"attached": *true' \
&& chk PAT01 med "Ubuntu Pro attached (livepatch available)" pass "" \
|| chk PAT01 med "Ubuntu Pro not attached" warn "no livepatch; free for <=5 machines"
fi
if have apt; then
sec_pending=$(apt list --upgradable 2>/dev/null | grep -ci security || true)
[ "$sec_pending" -eq 0 ] && chk PAT02 high "No pending security updates" pass "" \
|| chk PAT02 high "Pending security updates" fail "$sec_pending"
fi
for m in /tmp /dev/shm; do
if findmnt -no OPTIONS "$m" 2>/dev/null | grep -q noexec; then
chk PAT03 med "$m is noexec" pass ""
else
chk PAT03 med "$m allows execution" warn "standard payload-staging path"
fi
done
# ─────────────────────────────────────────────────────────────────────────────
sec "Backups"
if have restic || have borg || have borgmatic; then
chk BAK01 crit "Backup tool present" pass "$(have restic && echo restic || echo borg)"
else
chk BAK01 crit "No restic/borg found" warn "verify backups exist another way"
fi
# ─────────────────────────────────────────────────────────────────────────────
if [ "$JSON" = 1 ]; then
printf '{"pass":%d,"warn":%d,"fail":%d,"findings":[%s]}\n' \
"$PASS" "$WARN" "$FAIL" "$(IFS=,; echo "${ROWS[*]}")"
else
TOT=$((PASS+WARN+FAIL))
printf '\n%s─────────────────────────────%s\n' "$DIM" "$RST"
printf '%s%d passed%s %s%d warnings%s %s%d failed%s (%d checks)\n' \
"$GRN" "$PASS" "$RST" "$YLW" "$WARN" "$RST" "$RED" "$FAIL" "$RST" "$TOT"
priv || printf '\n%sRun with sudo for the full picture (several checks need root).%s\n' "$DIM" "$RST"
[ "$FAIL" -gt 0 ] && printf '\n%sFailures are exploitable today, not theoretical. Fix those first.%s\n' "$RED" "$RST"
fi
[ "$FAIL" -gt 0 ] && exit 1
[ "$WARN" -gt 0 ] && exit 0
exit 0