← 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