← All four scripts
harden-1-safe.sh
Roughly 90% of the work, and none of it can lock you out. Sysctls, mount flags, patching, permissions, npm supply chain, systemd sandboxing, nginx.
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
# harden-1-safe.sh — everything that CANNOT lock you out of the box.
#
# Safe to run any time, re-runnable, idempotent. Run this FIRST and in full.
# Roughly 90% of the hardening lives here.
#
# sudo ./harden-1-safe.sh apply
# sudo DRY_RUN=1 ./harden-1-safe.sh show what would change
#
# Nothing here touches sshd or the firewall. Those are scripts 2 and 3.
cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1
# shellcheck source=lib.sh
source ./lib.sh
need_root
# The account that owns the apps. Defaults to whoever invoked sudo, which is
# right on almost every box. Override in harden.local.conf or the environment.
APP_USER="${APP_USER:-${SUDO_USER:-$(id -un)}}"
APP_HOME="/home/${APP_USER}"
head1 "1. Kernel hardening"
SYSCTL=/etc/sysctl.d/99-agent-hardening.conf
ensure_kv "$SYSCTL" kernel.dmesg_restrict 1
ensure_kv "$SYSCTL" kernel.kptr_restrict 2
ensure_kv "$SYSCTL" kernel.yama.ptrace_scope 1
ensure_kv "$SYSCTL" kernel.unprivileged_bpf_disabled 2
ensure_kv "$SYSCTL" kernel.unprivileged_userns_clone 1
ensure_kv "$SYSCTL" net.ipv4.tcp_syncookies 1
ensure_kv "$SYSCTL" net.ipv4.conf.all.rp_filter 2
ensure_kv "$SYSCTL" net.ipv4.conf.all.accept_redirects 0
ensure_kv "$SYSCTL" net.ipv4.conf.all.secure_redirects 0
ensure_kv "$SYSCTL" net.ipv4.conf.all.accept_source_route 0
ensure_kv "$SYSCTL" net.ipv4.conf.all.log_martians 1
ensure_kv "$SYSCTL" net.ipv4.conf.all.send_redirects 0
ensure_kv "$SYSCTL" net.ipv4.conf.default.send_redirects 0
ensure_kv "$SYSCTL" fs.protected_hardlinks 1
ensure_kv "$SYSCTL" fs.protected_symlinks 1
ensure_kv "$SYSCTL" fs.protected_fifos 2
ensure_kv "$SYSCTL" fs.protected_regular 2
ensure_kv "$SYSCTL" fs.suid_dumpable 0
ensure_kv "$SYSCTL" kernel.core_pattern "|/bin/false"
run "reload sysctl" sysctl --system >/dev/null
head1 "2. IPv6 decision (see the trap below)"
# Disabling IPv6 at the kernel and setting ufw IPV6=no is only safe as a PAIR:
# IPV6=no means ufw does not manage ip6tables at all, so leaving IPv6 UP with
# IPV6=no means zero firewall on v6 while ufw still reports itself active.
# Most providers now ship IPv4+IPv6, so this has to be an explicit choice.
# Default: disable. Keep it with KEEP_IPV6=1 and let ufw manage v6 instead.
if [ "${KEEP_IPV6:-0}" = "1" ]; then
warn "KEEP_IPV6=1 — leaving IPv6 enabled."
warn "You MUST then set IPV6=yes in /etc/default/ufw (harden-2 checks this)."
else
ensure_kv "$SYSCTL" net.ipv6.conf.all.disable_ipv6 1
ensure_kv "$SYSCTL" net.ipv6.conf.default.disable_ipv6 1
ensure_kv "$SYSCTL" net.ipv6.conf.lo.disable_ipv6 1
run "reload sysctl (ipv6)" sysctl --system >/dev/null
info "IPv6 disabled. Re-run with KEEP_IPV6=1 to keep it (and fix ufw IPV6=yes)."
fi
head1 "3. noexec/nosuid/nodev on temp dirs"
# /tmp and /var/tmp as tmpfs+bind, /dev/shm remounted. Blocks the standard
# "drop a payload in /tmp and execute it" step.
for spec in \
"/tmp:tmpfs:tmpfs:rw,nosuid,nodev,noexec,size=4G,mode=1777" \
"/dev/shm:tmpfs:tmpfs:rw,nosuid,nodev,noexec,size=2G,mode=1777"
do
IFS=: read -r mnt what fstype opts <<<"$spec"
# Persistence and runtime are checked SEPARATELY on purpose. Doing the fstab
# write only when the mount is not yet noexec means a successful remount
# leaves nothing on disk, and the protection silently vanishes on reboot.
# Also key the entry on the MOUNT POINT: every line here has "tmpfs" as its
# first field, so keying on that makes /tmp and /dev/shm overwrite each other.
if grep -qE "^[^#]*[[:space:]]${mnt}[[:space:]]" /etc/fstab; then
ok "$mnt already in fstab"
elif [ "$DRY_RUN" = "1" ]; then
printf '%s[dry-run]%s would add %s to /etc/fstab\n' "$YLW" "$RST" "$mnt"
else
printf '%s\t%s %s %s 0 0\n' "$what" "$mnt" "$fstype" "$opts" >> /etc/fstab
did "added $mnt to /etc/fstab"
fi
if findmnt -no OPTIONS "$mnt" 2>/dev/null | grep -q noexec; then
ok "$mnt already noexec"
else
run "remount $mnt" mount -o "remount,${opts}" "$mnt" || warn "$mnt remount needs a reboot"
fi
done
# /var/tmp bound to /tmp keeps it under the same flags without a new filesystem.
if findmnt -no OPTIONS /var/tmp 2>/dev/null | grep -q noexec; then
ok "/var/tmp already noexec"
elif ! grep -qE "^[^#]*[[:space:]]/var/tmp[[:space:]]" /etc/fstab; then
if [ "$DRY_RUN" = "1" ]; then
printf '%s[dry-run]%s would bind /var/tmp\n' "$YLW" "$RST"
else
printf '/tmp /var/tmp none rw,noexec,nosuid,nodev,bind 0 0\n' >> /etc/fstab
did "added /var/tmp bind to /etc/fstab"
mount -o remount,bind,noexec,nosuid,nodev /tmp /var/tmp 2>/dev/null \
|| warn "/var/tmp bind needs a reboot"
fi
fi
head1 "4. Patching: unattended-upgrades + Ubuntu Pro"
ensure_pkg unattended-upgrades apt-listchanges
write_file /etc/apt/apt.conf.d/52-agent-unattended 0644 <<'EOF'
// Managed by harden-1-safe.sh. Security AND regular updates: a box left on
// security-only misses the bug-fix releases that close half the real issues.
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}";
"${distro_id}:${distro_codename}-security";
"${distro_id}:${distro_codename}-updates";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
};
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
run "enable unattended-upgrades" systemctl enable --now unattended-upgrades >/dev/null
# ESM origins above are inert unless Pro is attached. Free for <=5 machines.
if command -v pro >/dev/null 2>&1; then
if pro status --format json 2>/dev/null | grep -q '"attached": *true'; then
ok "Ubuntu Pro attached"
run "enable livepatch" pro enable livepatch --assume-yes >/dev/null 2>&1 || \
warn "livepatch enable failed (may already be on)"
else
warn "Ubuntu Pro NOT attached — livepatch is not running."
warn " Get a free token (<=5 machines) at https://ubuntu.com/pro/dashboard"
warn " then: sudo pro attach <TOKEN> && sudo pro enable livepatch"
fi
else
warn "'pro' CLI missing; install ubuntu-advantage-tools"
fi
head1 "5. etckeeper — git history for /etc"
# Replaces auditd for the case that actually matters: what config changed, when.
# Near-zero cost, unlike auditd's syscall overhead on an agent-heavy box.
ensure_pkg git etckeeper
if [ -d /etc/.git ]; then
ok "/etc already under etckeeper"
else
run "init etckeeper" etckeeper init >/dev/null
run "first /etc commit" git -C /etc commit -m "etckeeper: initial commit" -q || \
ok "nothing to commit"
fi
# /etc contains shadow, keys, certs. The repo must not be world-readable.
run "lock down /etc/.git" chmod -R go-rwx /etc/.git
head1 "6. npm supply chain"
# The live threat: the poisoned @bitwarden/cli (Apr 2026) shipped a module that
# specifically targeted authenticated AI coding assistants incl. Claude Code.
# postinstall scripts are the primary infection path and they run as $APP_USER,
# who has sudo. Turning scripts off breaks that chain.
NPMRC="${APP_HOME}/.npmrc"
if [ -d "$APP_HOME" ]; then
if grep -qE '^ignore-scripts[[:space:]]*=[[:space:]]*true' "$NPMRC" 2>/dev/null; then
ok "npm ignore-scripts already true"
elif [ "$DRY_RUN" = "1" ]; then
printf '%s[dry-run]%s would set ignore-scripts=true in %s\n' "$YLW" "$RST" "$NPMRC"
else
touch "$NPMRC"
grep -qE '^ignore-scripts' "$NPMRC" \
&& sed -i -E 's|^ignore-scripts.*|ignore-scripts=true|' "$NPMRC" \
|| printf 'ignore-scripts=true\n' >> "$NPMRC"
# save-exact pins what you install, so no surprise minor bumps
grep -qE '^save-exact' "$NPMRC" || printf 'save-exact=true\n' >> "$NPMRC"
chown "$APP_USER:$APP_USER" "$NPMRC"; chmod 600 "$NPMRC"
did "npm ignore-scripts=true + save-exact=true for $APP_USER"
warn "Packages that genuinely need build scripts now require an explicit"
warn " 'npm install --foreground-scripts <pkg>'. That is the point."
fi
else
warn "$APP_HOME missing; skipping npm hardening"
fi
head1 "7. Sensitive file permissions"
# Not theoretical. The usual finding is a 644 .env or a mode-644 SQLite file that
# every other account on the box, including a service account an agent runs as,
# can simply read. One `cat` and the credentials are gone.
if [ -d "$APP_HOME/apps" ]; then
while IFS= read -r f; do
cur=$(stat -c '%a' "$f")
[ "$cur" = "600" ] && continue
run "chmod 600 ${f#"$APP_HOME"/}" chmod 600 "$f"
done < <(find "$APP_HOME/apps" -maxdepth 5 \
\( -name '.env' -o -name '.env.*' -o -name '*.db' \) \
-type f ! -name '*.example' ! -path '*/node_modules/*' \
! -path '*/archive/*' 2>/dev/null)
fi
for d in "$APP_HOME/backups" "$APP_HOME/secrets" "$APP_HOME/.ssh" "$APP_HOME/.openclaw"; do
[ -d "$d" ] || continue
cur=$(stat -c '%a' "$d")
[ "$cur" = "700" ] && { ok "$(basename "$d") already 700"; continue; }
run "chmod 700 $(basename "$d")" chmod 700 "$d"
done
# Stale copies of an env file are worse than the original: nobody remembers they
# exist, so nobody rotates what is in them. chmod 600 above hides them from other
# users but does not make them safe, hence a separate warning.
if [ -d "$APP_HOME/apps" ]; then
while IFS= read -r f; do
[ -n "$f" ] || continue
warn "stale env copy: ${f#"$APP_HOME"/}"
warn " Assume its keys are live. Rotate them, then delete the file."
done < <(find "$APP_HOME/apps" -maxdepth 5 \
\( -name '.env.backup' -o -name '.env.bak' -o -name '.env.old' -o -name '.env.save' \) \
-type f ! -path '*/node_modules/*' 2>/dev/null)
fi
head1 "8. Service accounts must not have shells"
# Turns the permission problem above from a bug into a non-event.
while IFS=: read -r u _ uid _ _ home shell; do
[ "$uid" -ge 1000 ] && [ "$uid" -lt 65534 ] || continue
[ "$u" = "$APP_USER" ] && continue
case "$shell" in */nologin|*/false) ok "$u already nologin"; continue;; esac
if [ "${LOCK_CUSTOMER_SHELLS:-1}" = "1" ]; then
run "nologin for $u" usermod -s /usr/sbin/nologin "$u"
else
warn "$u has shell $shell (LOCK_CUSTOMER_SHELLS=0, skipping)"
fi
[ -d "$home" ] && [ "$(stat -c '%a' "$home")" != "750" ] && \
run "chmod 750 $home" chmod 750 "$home"
done < /etc/passwd
head1 "9. systemd sandboxing for the app services"
# The highest-value free hardening available, and the one that actually
# contains a hijacked agent: it stops a compromised service reading /home,
# writing outside its own dirs, or gaining privileges.
# Your own service unit names, space separated. There is no sensible default:
# guessing them would either miss yours or sandbox something that then breaks.
SANDBOX_SERVICES="${SANDBOX_SERVICES:-}"
if [ -z "$SANDBOX_SERVICES" ]; then
warn "SANDBOX_SERVICES is empty, so nothing is being sandboxed."
warn " Set it to your unit names (no .service suffix), e.g."
warn " SANDBOX_SERVICES='my-api my-worker' sudo ./harden-1-safe.sh"
warn " or put it in harden.local.conf. This is the highest-value item here."
fi
for svc in $SANDBOX_SERVICES; do
systemctl list-unit-files "${svc}.service" --no-legend 2>/dev/null | grep -q . || {
ok "$svc not present, skipping"; continue; }
d="/etc/systemd/system/${svc}.service.d"
write_file "${d}/20-sandbox.conf" 0644 <<EOF
# Managed by harden-1-safe.sh.
# Deliberately NOT ProtectHome=yes: these services read ~/apps. ReadWritePaths
# below re-opens exactly what each needs and nothing else.
[Service]
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictSUIDSGID=yes
RestrictRealtime=yes
RestrictNamespaces=yes
LockPersonality=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @obsolete @mount @swap @reboot
ReadWritePaths=${APP_HOME}/apps /var/log
EOF
done
run "daemon-reload" systemctl daemon-reload
warn "Sandboxing is written but NOT applied to running services."
warn " Restart each one deliberately and watch it: systemctl restart <svc>"
warn " If a service breaks, add its path to ReadWritePaths= in its drop-in."
head1 "10. nginx TLS + security headers"
if [ -d /etc/nginx ]; then
write_file /etc/nginx/snippets/security-headers.conf 0644 <<'EOF'
# Managed by harden-1-safe.sh. include this in EVERY 443 server block.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
EOF
# TLSv1/1.1 are deprecated. server_tokens leaks the nginx version.
if grep -qE '^\s*ssl_protocols.*TLSv1[^.]' /etc/nginx/nginx.conf 2>/dev/null; then
run "drop TLS 1.0/1.1" sed -i -E \
's|^\s*ssl_protocols.*|\tssl_protocols TLSv1.2 TLSv1.3;|' /etc/nginx/nginx.conf
else ok "TLS 1.0/1.1 already disabled"; fi
if grep -qE '^\s*server_tokens\s+off;' /etc/nginx/nginx.conf 2>/dev/null; then
ok "server_tokens already off"
else
run "server_tokens off" sed -i -E \
's|^\s*#?\s*server_tokens.*|\tserver_tokens off;|' /etc/nginx/nginx.conf
fi
# The web surface is the exposed one, and it is usually the one with no jail.
write_file /etc/fail2ban/jail.d/nginx-agent.conf 0644 <<'EOF'
# Managed by harden-1-safe.sh.
[nginx-http-auth]
enabled = true
[nginx-bad-request]
enabled = true
maxretry = 20
findtime = 60
bantime = 1h
[nginx-botsearch]
enabled = true
maxretry = 10
bantime = 6h
EOF
if nginx -t >/dev/null 2>&1; then
run "reload nginx" systemctl reload nginx
run "restart fail2ban" systemctl restart fail2ban
else
err "nginx -t FAILED — not reloading. Fix the config, then reload by hand."
nginx -t 2>&1 | sed 's/^/ /'
fi
warn "security-headers.conf is written but not yet included anywhere."
warn " Add 'include snippets/security-headers.conf;' to each 443 block."
else
ok "nginx not installed, skipping"
fi
head1 "11. Docker daemon defaults"
if command -v docker >/dev/null 2>&1; then
write_file /etc/docker/daemon.json 0644 <<'EOF'
{
"log-driver": "json-file",
"log-opts": { "max-size": "50m", "max-file": "3" },
"live-restore": true,
"no-new-privileges": true,
"userland-proxy": false
}
EOF
warn "Docker config written. Apply with: systemctl restart docker"
warn " That restarts every container. Do it deliberately, not now."
else
ok "docker not installed, skipping"
fi
summary
cat <<EOF
${GRN}harden-1-safe.sh complete.${RST} Nothing here can have locked you out.
Deliberate follow-ups (each restarts something, so do them one at a time):
1. systemctl restart <service> per sandboxed service, watch each
2. systemctl restart docker applies daemon.json, restarts containers
3. add 'include snippets/security-headers.conf;' to each 443 block
4. sudo pro attach <TOKEN> if Ubuntu Pro is not attached
5. reboot to pick up /tmp mount flags cleanly
Then run ./harden-2-firewall.sh (has a rollback timer).
EOF