← 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"
# The trap: IPV6=no means ufw ignores ip6tables entirely.
if grep -q '^IPV6=no' /etc/default/ufw 2>/dev/null; then
if [ "$(sysctl -n net.ipv6.conf.all.disable_ipv6 2>/dev/null)" = "1" ]; then
chk FW02 high "ufw IPV6=no matches disabled IPv6" pass ""
else
chk FW02 high "IPv6 is UP but ufw ignores it" fail "NO FIREWALL ON IPv6"
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