← 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