What it checks
Hostveil checks the highest-impact paths that actually get self-hosters breached, then merges everything into one 0–100 score. Each finding has a stable ID of the form domain.rule — use it with explain, fix, and rollback.
Domains at a glance
| Domain | Prefix | Needs |
|---|---|---|
| Docker / Compose | compose. | Docker |
| SSH | ssh. | — |
| Firewall | firewall. | — |
| Auto-updates | updates. | — |
| Exposed services | ports. | ss |
| Accounts | accounts. | root (for /etc/shadow) |
| File permissions | fileperms. | — |
| AI agent runtimes | agent. | OpenClaw or Hermes installed |
| Kernel hardening | sysctl. | — |
| Docker daemon | dockerd. | Docker |
| Service hardening | systemd. | systemd |
| Reverse proxy | proxy. | nginx or Docker |
| Image CVEs (optional) | cve. | Trivy |
Missing Docker or Trivy? Those domains are skipped cleanly and the score is renormalized, so you are never handed a misleadingly perfect result for a scan that did not run.
Across those domains Hostveil can report 175 findings, and 128 of them carry a fix — 75 it will apply unattended, 53 only after you have read the diff. The rest are Manual on purpose, and every one is named in the register in internal/fix/register.go with the reason Hostveil will not act: deleting an account cannot be undone from a checkpoint, removing a Docker socket mount breaks the tool that needs it. Manual by decision, never Manual because nobody looked — a test fails the build on the difference, and the counts in this paragraph are computed from the registry rather than typed here.
Severity & the score
Hostveil grades a finding by how urgent it is, not by how bad it might turn out to be. A scanner reading your config cannot tell you how bad a container running as root is — that depends entirely on what the container does. What it can tell you is how much stands between an attacker and the problem right now, and that is what these three levels measure.
| Level | What it means | Takes |
|---|---|---|
| HIGH | Reachable or usable right now, from off the host, by someone holding nothing. An open service with no authentication, a login with no password, a container that has already been handed host root. Nothing has to be broken first. | 1/2 of what remains |
| MEDIUM | A boundary that gives way to a foothold, a guessed credential, or a local account. The attacker needs something they do not have yet, and this is what makes getting it worthwhile. | 1/8 |
| LOW | Defence in depth. No known path today; it narrows what a future compromise reaches. | 1/16 |
Three levels, not four, and the definitions above are the whole taxonomy. The names are ordinary because a name has one job here — carrying the order — and it has to do that on a chip, in one line of --json, and in a SARIF level, where there is no room for anything else. What each level means is the column beside it, and it is a claim about your host rather than an opinion about the finding.
Hostveil used to have four levels — Critical / High / Medium / Low — inherited from Trivy so a CVE's published rating could pass straight through. Vulnerabilities are rolled up per image now, so what reaches a finding is one level for the whole image rather than a rating per CVE, and the fourth level was left asking a question a config file cannot answer.
HIGH is exactly what Critical and High were together, so scan's exit code and the SARIF export mean what they always did. If you are comparing against Trivy's own output, note that a vulnerability Trivy calls CRITICAL lands here as HIGH: Hostveil has three levels and Trivy has four, and the top of one is not the top of the other by name.
Because each finding takes a share of what is left rather than a fixed amount, an axis never bottoms out after two findings — the tenth issue still costs you something, and a host with thirty problems scores below one with three. Scoring is the whole model: the arithmetic, a worked example from a real scan, and what the number cannot tell you.
A finding Hostveil marks Unavailable — a vulnerability with no upstream patch — costs exactly what its severity says, the same as one you could act on. Not being able to fix something yet does not make it less real, so there is no discount for it; a host that pulls unpatched images will see that reflected in its Vulnerabilities axis.
What each domain is worth
The axes are weighted by how much they matter to a self-hosted server, and the weights sum to 100. When a domain does not run, its weight leaves the total and the rest are renormalized.
| Axis | Weight |
|---|---|
| Container exposure | 13 |
| SSH hardening | 13 |
| Vulnerabilities | 9 |
| Host firewall | 8 |
| Exposed services | 7 |
| AI agent runtimes | 7 |
| Reverse proxy | 6 |
| Account hygiene | 7 |
| Auto-updates | 7 |
| Docker daemon | 7 |
| Service hardening | 6 |
| File permissions | 5 |
| Kernel hardening | 5 |
When a domain can't be fully checked
"I couldn't look" and "there was nothing there" produce the same empty list, and they mean opposite things. Hostveil never lets one pass for the other, so every domain reports how much ground it actually covered:
| State | What it means | Effect on the score |
|---|---|---|
| Done | The domain was checked end to end. | Scored normally. |
| Skipped | A dependency is missing — no Docker, no Trivy, no ufw. | Shown as N/A and excluded; the other axes are renormalized. |
| Partial | Some of the ground was covered and some was not — Trivy could scan three images out of five. | Scored on what was seen, and flagged. The TUI writes 42~, the dashboard writes 42~ in amber. |
| Failed | The checker could not examine its ground at all. | Excluded like Skipped, and the reason is shown. |
Both UIs print the reason above the findings list — "Container partial: cannot inspect containers started outside Compose", "CVEs skipped: Trivy not installed". An excluded axis is never scored 100: a scan that could not see your images is not a scan that found your images clean. This is why running without root produces a lower, not a higher, score than running with it.
hostveil scan exits non-zero when any unfixed finding is HIGH — useful as a CI or cron gate.
SSH ssh.
Parsed natively from sshd_config, following Include directives into sshd_config.d/. That matters on Debian and Ubuntu, which put the Include at the top of the file: sshd keeps the first value it obtains for each keyword, so a drop-in wins over the lines below it. Reading these needs root, which hostveil obtains by auto-elevating with sudo. A fix edits the file the winning directive actually lives in.
Reading stops at the first Match. sshd applies what follows only to connections that match, so a directive there does not describe the host the way a global one does — but not reading it is a gap, not a clean bill, so the domain is reported Degraded with the file named. Match Address 0.0.0.0/0 followed by PasswordAuthentication yes puts passwords back for every connection there is, and a scan that called that host fully audited would be claiming something it never looked at.
| ID | What it flags | Severity | Fix |
|---|---|---|---|
ssh.emptypasswords | Empty passwords are permitted | HIGH | Auto-fix |
ssh.rootlogin | Root login with a password is allowed | HIGH | Review |
ssh.passwordauth | Password authentication is allowed | MEDIUM | Review |
ssh.gatewayports | Remote-forwarded ports are exposed to the network | MEDIUM | Review |
ssh.hostbasedauth | Host-based authentication is enabled | MEDIUM | Review |
ssh.kbdinteractive | Interactive password prompts survive PasswordAuthentication no | MEDIUM | Review |
ssh.permituserenvironment | Users can supply SSH session environment variables | MEDIUM | Review |
ssh.permittunnel | Users can create SSH network tunnels | MEDIUM | Review |
ssh.maxauthtries | Too many auth attempts per connection | LOW | Auto-fix |
ssh.logingracetime | Unauthenticated connections are held open too long | LOW | Auto-fix |
ssh.x11forwarding | X11 forwarding is enabled | LOW | Auto-fix |
ssh.allowtcpforwarding | TCP forwarding is enabled | LOW | Review |
ssh.clientalivecountmax | Dead SSH sessions survive too many keepalive failures | LOW | Auto-fix |
ssh.clientaliveinterval | Idle SSH sessions have no server-side timeout | LOW | Auto-fix |
ssh.fingerprinthash | Host-key fingerprints use a legacy hash | LOW | Auto-fix |
ssh.ignorerhosts | Legacy rhosts trust is not explicitly ignored | LOW | Auto-fix |
ssh.loglevel | SSH authentication logging is not verbose | LOW | Auto-fix |
ssh.maxsessions | One SSH connection may open many sessions | LOW | Review |
ssh.printlastlog | Users are not shown their previous login | LOW | Auto-fix |
ssh.strictmodes | SSH does not enforce ownership and mode checks | LOW | Auto-fix |
ssh.tcpkeepalive | SSH trusts spoofable TCP keepalives | LOW | Auto-fix |
ssh.usedns | SSH performs unnecessary reverse DNS lookups | LOW | Auto-fix |
ssh.allowagentforwarding | SSH agent forwarding is enabled | LOW | Review |
Docker / Compose compose.
A native audit of your Compose files — no external scanner required. Containers started with plain docker run are audited too, read from the daemon rather than a file. Those findings are always Manual: there is no file for a fix to edit, so Hostveil tells you what to change when you recreate the container instead of offering a button that could not work.
What counts as exposed. A published port is treated as reachable from every interface when its host address is 0.0.0.0, ::, or not written at all. That last case is the one that catches people: "6379:6379" names no address, and Docker binds it to every interface, so it is exposed by this rule and by the daemon. Only an explicit loopback address such as "127.0.0.1:6379:6379" is not, and a port with no host side at all is not published and never counts.
The rule is applied to the merged project, which is what docker compose config prints, not to any single file. Compose appends port mappings across files rather than replacing them, so a base file binding a service to loopback and an override republishing it leaves the service exposed. Reading the base alone would call that host clean.
| ID | What it flags | Severity | Fix |
|---|---|---|---|
compose.ds016 | Docker socket mounted into a container | HIGH | Manual |
compose.ds018 | Datastore exposed on all interfaces | HIGH | Auto-fix |
compose.ds001 | Container runs in privileged mode | HIGH | Manual |
compose.ds019 | Admin panel exposed on all interfaces | HIGH | Auto-fix |
compose.dr001 | Container uses host network mode | HIGH | Manual |
compose.ds017 | Sensitive host path mounted read-write | HIGH | Manual |
compose.ds005 | Adds a dangerous Linux capability | HIGH | Manual |
compose.ds020 | Shares the host PID namespace | HIGH | Manual |
compose.dr005 | Hardcoded secret in the environment | HIGH | Manual |
compose.ds006 | Missing no-new-privileges hardening | MEDIUM | Auto-fix |
compose.ds021 | Shares the host IPC namespace | MEDIUM | Manual |
compose.ds009 | Container runs as root | MEDIUM | Manual |
compose.dr002 | Port published on all interfaces | MEDIUM | Auto-fix |
compose.ds008 | No restart policy set | LOW | Auto-fix |
compose.ds022 | Container filesystem is writable | LOW | Manual |
compose.ds023 | Seccomp filtering is disabled | HIGH | Manual |
compose.ds024 | AppArmor confinement is disabled | MEDIUM | Manual |
compose.ds026 | Host user namespace is used | MEDIUM | Manual |
compose.ds010 | No memory limit set | LOW | Review |
compose.ds012 | No healthcheck defined | LOW | Manual |
compose.dr004 | Loads secrets from an env_file | LOW | Manual |
Firewall firewall.
| ID | What it flags | Severity | Fix |
|---|---|---|---|
firewall.inactive | No active host firewall (ufw, firewalld, nftables, or iptables) | HIGH | Review |
firewall.default-allow | A firewall is running but its default inbound policy accepts everything | HIGH | Review |
firewall.docker-bypass | Published container ports are reachable despite an active ufw | HIGH | Manual |
Auto-updates updates.
| ID | What it flags | Severity | Fix |
|---|---|---|---|
updates.disabled | Automatic security updates are not enabled (apt unattended-upgrades or dnf-automatic) | MEDIUM | Review |
updates.pending-security | Security updates are available but not installed | HIGH / MEDIUM | Review |
updates.reboot-required | Installed updates need a reboot to take effect | HIGH | Manual |
updates.process-accounting | Process accounting is not installed | LOW | Review |
updates.sysstat | System activity accounting is not installed | LOW | Review |
updates.auditd | The Linux audit daemon is not installed | MEDIUM | Review |
updates.debsums | Installed package files cannot be verified | LOW | Review |
updates.rkhunter | No rootkit scanner is installed | LOW | Review |
updates.pam-pwquality | PAM has no password-quality module | MEDIUM | Review |
updates.apt-show-versions | Patch inventory tooling is not installed | LOW | Review |
updates.sysstat-disabled | System activity collection is disabled | LOW | Auto-fix |
updates.apt-listchanges | APT changelog review is not installed | LOW | Review |
updates.pam-tmpdir | PAM sessions share global temporary directories | LOW | Review |
updates.fail2ban | Repeated authentication failures are not automatically blocked | MEDIUM | Review |
Exposed services ports.
Reads the host's listening TCP sockets with ss and flags services bound to a non-loopback address — the natively-installed database, admin panel, or app that a Compose-file audit can't see because it isn't a container. Loopback-only binds are ignored; SSH is expected and never flagged here.
| ID | What it flags | Severity | Fix |
|---|---|---|---|
ports.exposed-datastore | A datastore (Postgres, MySQL, Redis, MongoDB, …) is reachable from the network | HIGH | Manual |
ports.exposed-admin | An admin/management UI (e.g. Portainer) is reachable from the network | HIGH | Manual |
ports.exposed | Other services listen on a non-loopback address and no host firewall is active | LOW | Manual |
ports.redis-bind | Redis is configured to listen beyond loopback | MEDIUM | Review |
ports.redis-protected-mode | Redis protected mode is disabled | MEDIUM | Auto-fix |
ports.redis-disable-config | Redis exposes the CONFIG command | MEDIUM | Review |
Accounts accounts.
One question asked three ways: who can become root, and what stands in their way. The first two answers come out of /etc/passwd and /etc/shadow, parsed natively. Reading /etc/shadow needs root, which hostveil obtains by auto-elevating with sudo; without it, the empty-password check is skipped and the UID-0 check still runs.
The third answer does not come from a file. sudo rules are a layered configuration with aliases, group specs that may resolve through LDAP rather than /etc/group, and include ordering — so Hostveil asks sudo itself what each account may actually run, rather than re-deriving it from /etc/sudoers. That needs root too: without it the domain reports the gap instead of reporting that nobody was found.
| ID | What it flags | Severity | Fix |
|---|---|---|---|
accounts.uid0 | A non-root account has root's UID (0) | HIGH | Manual |
accounts.emptypassword | A login account has an empty password | HIGH | Review |
accounts.sudo-nopasswd | A sudo rule lets an account run anything as root without being asked for a password | MEDIUM | Manual |
accounts.duplicate-uid | Multiple accounts share one UID | MEDIUM | Manual |
accounts.weak-password-hash | A login account uses a weak password hash | MEDIUM | Manual |
accounts.password-rounds | Password hashing rounds are not hardened | LOW | Auto-fix |
accounts.default-umask | New files default to broad permissions | LOW | Auto-fix |
accounts.local-banner | The local login warning does not discourage unauthorized access | LOW | Review |
accounts.remote-banner | The remote login warning does not discourage unauthorized access | LOW | Review |
accounts.password-aging | Password aging defaults are too permissive | LOW | Auto-fix |
accounts.core-dumps | Core dumps are not explicitly disabled | LOW | Auto-fix |
Reverse proxy proxy.
The component this audience is most likely to have and least likely to have configured: everything else here sits behind whatever answers on 443. Two surfaces, because there are two ways people run one — nginx from a package, read out of /etc/nginx and following include into conf.d and sites-enabled; and Traefik as a container, read out of the Compose files the container domain already discovers.
Three things are deliberately not checked. A proxy serving plain HTTP, because a vhost whose only job is to redirect to HTTPS and a proxy behind something that already terminates TLS are both correct and neither is distinguishable from the config. Missing security headers, because they are worth having and are not what gets somebody breached. And anything about certificates, because expiry and issuer are facts about the network rather than about a file.
| ID | What it flags | Severity | Fix |
|---|---|---|---|
proxy.traefik-api-insecure | Traefik's API and dashboard are served with no authentication, listing every router and backend address behind the proxy | HIGH | Manual |
proxy.tls-deprecated-protocols | ssl_protocols still offers TLS 1.0 or 1.1, which RFC 8996 deprecated and every current browser refuses | MEDIUM | Manual |
proxy.directory-listing | autoindex on makes every directory with no index file browsable | MEDIUM | Manual |
proxy.no-scan-jail | fail2ban is installed but its nginx-botsearch jail, built to catch repeated requests for nonexistent or known-vulnerable paths, is not enabled | MEDIUM | Review |
File permissions fileperms.
Stats a curated set of security-critical files and flags any whose permission bits are looser than they should ever be.
| ID | What it flags | Severity | Fix |
|---|---|---|---|
fileperms.shadow | /etc/shadow is more permissive than 0640 | HIGH | Auto-fix |
fileperms.passwd | /etc/passwd is writable by non-root users | HIGH | Auto-fix |
fileperms.group | /etc/group is writable by non-root users | HIGH | Auto-fix |
fileperms.hostkey | An SSH host private key is readable beyond root | HIGH | Auto-fix |
fileperms.gshadow | /etc/gshadow is too permissive | HIGH | Auto-fix |
fileperms.sudoers | /etc/sudoers is too permissive | HIGH | Auto-fix |
fileperms.sudoers-dropins | A sudoers drop-in is too permissive | HIGH | Auto-fix |
fileperms.cron | A system cron definition is writable by non-root | HIGH | Auto-fix |
fileperms.systemd-units | A system service unit is writable by non-root | HIGH | Auto-fix |
fileperms.docker-config | Root's Docker credentials are too permissive | HIGH | Auto-fix |
fileperms.grub-config | GRUB configuration permissions are too broad | HIGH | Auto-fix |
fileperms.grub2-config | GRUB2 configuration permissions are too broad | HIGH | Auto-fix |
fileperms.at-allow | The at allowlist is too permissive | MEDIUM | Auto-fix |
fileperms.at-deny | The at denylist is too permissive | MEDIUM | Auto-fix |
fileperms.cron-allow | The cron allowlist is too permissive | MEDIUM | Auto-fix |
fileperms.cron-deny | The cron denylist is too permissive | MEDIUM | Auto-fix |
fileperms.crontab | The system crontab is too permissive | HIGH | Auto-fix |
fileperms.passwd-backup | The passwd backup is writable by non-root | HIGH | Auto-fix |
fileperms.owner | One of the files above is not owned by root — permissions are only half of who can read it | HIGH | Manual |
fileperms.sshd-config | sshd_config is writable by non-root users | MEDIUM | Auto-fix |
fileperms.compiler | The system compiler is available to every local user | LOW | Review |
Kernel hardening sysctl.
Read directly from /proc/sys — no sysctl binary needed. These are the quiet knobs whose safe value is the same on essentially every server: none of them is the hole an attacker comes in through, each is what stops a foothold from becoming root or a spoofed packet from becoming a route. A parameter this kernel does not have (Yama not built in, say) is simply skipped.
Unambiguous settings are Auto-fix: Hostveil writes one reversible drop-in under /etc/sysctl.d and deliberately leaves the running kernel untouched. The change takes effect at the next boot, or when you explicitly run sysctl --system. Rollback removes or restores that file and refuses if it has since been edited.
Router-, relay-, and multi-homing-dependent settings remain Review. For example, strict reverse-path filtering can break asymmetric routing and disabling Proxy ARP breaks a host deliberately acting as a relay. Those retain the persistent and immediate alternatives so the operator makes the topology-dependent choice.
net.ipv4.ip_forward is deliberately not audited: Docker, WireGuard, Tailscale exit nodes, and virtualization hosts all legitimately enable it, and Hostveil cannot tell those apart from an accident.
| ID | What it flags | Severity | Fix |
|---|---|---|---|
sysctl.ptrace-scope | Any process can debug its siblings and read their memory | MEDIUM | Auto-fix |
sysctl.syncookies | TCP SYN-flood protection is off | MEDIUM | Auto-fix |
sysctl.accept-redirects | ICMP redirects can rewrite this host's routing | MEDIUM | Auto-fix |
sysctl.protected-links | Symlink/hardlink race protections are disabled | MEDIUM | Auto-fix |
sysctl.kptr-restrict | Kernel pointer addresses are visible to all users | LOW | Auto-fix |
sysctl.dmesg-restrict | Any user can read the kernel log | LOW | Auto-fix |
sysctl.sysrq | Magic SysRq is fully enabled | LOW | Review |
sysctl.rp-filter | Source-address spoofing filter is off (strict and loose both pass) | LOW | Review |
sysctl.accept-source-route | Source-routed packets are accepted | MEDIUM | Auto-fix |
sysctl.send-redirects | The host sends ICMP redirects | MEDIUM | Review |
sysctl.suid-dumpable | Privileged processes may write core dumps | MEDIUM | Auto-fix |
sysctl.protected-fifos | FIFO protections are weak | MEDIUM | Auto-fix |
sysctl.protected-regular | Regular-file protections are weak | MEDIUM | Auto-fix |
sysctl.unprivileged-bpf | Unprivileged users can load BPF programs | MEDIUM | Auto-fix |
sysctl.perf-events | Performance events are exposed to users | MEDIUM | Auto-fix |
sysctl.icmp-broadcasts | Broadcast pings are accepted | LOW | Auto-fix |
sysctl.bogus-icmp-errors | Bogus ICMP errors are not ignored | LOW | Auto-fix |
sysctl.log-martians | Suspicious source addresses are not logged | LOW | Auto-fix |
sysctl.aslr | Full address-space randomization is disabled | MEDIUM | Auto-fix |
sysctl.core-uses-pid | Core dump names omit the process ID | LOW | Auto-fix |
sysctl.ctrl-alt-del | Ctrl-Alt-Delete immediately reboots the host | LOW | Auto-fix |
sysctl.bpf-jit-harden | BPF JIT hardening is incomplete | MEDIUM | Auto-fix |
sysctl.tty-ldisc-autoload | TTY use can autoload line disciplines | MEDIUM | Auto-fix |
sysctl.ipv6-accept-redirects-all | IPv6 redirects are accepted globally | MEDIUM | Auto-fix |
sysctl.ipv6-accept-redirects-default | New IPv6 interfaces accept redirects | MEDIUM | Auto-fix |
sysctl.ipv6-accept-source-route-all | IPv6 source routing is accepted globally | MEDIUM | Auto-fix |
sysctl.ipv6-accept-source-route-default | New IPv6 interfaces accept source routing | MEDIUM | Auto-fix |
sysctl.ipv6-send-redirects | The host sends IPv6 redirects | LOW | Auto-fix |
sysctl.ipv4-default-accept-redirects | New IPv4 interfaces accept redirects | MEDIUM | Auto-fix |
sysctl.ipv4-default-rp-filter | New IPv4 interfaces lack source validation | LOW | Review |
sysctl.proxy-arp-all | Proxy ARP is enabled globally | MEDIUM | Review |
sysctl.proxy-arp-default | New interfaces enable Proxy ARP | MEDIUM | Review |
sysctl.multicast-forwarding | IPv4 multicast forwarding is enabled | LOW | Review |
sysctl.bootp-relay | BOOTP relay is enabled | LOW | Review |
sysctl.tcp-rfc1337 | TCP TIME-WAIT protection is disabled | LOW | Auto-fix |
sysctl.module-dccp | The optional DCCP protocol is available | LOW | Review |
sysctl.module-sctp | The optional SCTP protocol is available | LOW | Review |
sysctl.module-rds | The optional RDS protocol is available | LOW | Review |
sysctl.module-tipc | The optional TIPC protocol is available | LOW | Review |
sysctl.module-usbstorage | The USB storage driver is available | LOW | Review |
Docker daemon dockerd.
The Compose audit judges what your services declare and the CVE scan judges the images they run. This domain judges the daemon underneath both — the part of the stack that actually holds root.
It is worth being blunt about the worst finding here, because it is the worst finding Hostveil has. A daemon listening on a TCP port without TLS client verification is root on the host for anyone who can reach that port: no password, no vulnerability, one HTTP request that starts a container with your filesystem mounted inside it. Membership in the socket's group is the same authority from a local account, and unlike sudo it asks for nothing and logs nothing.
Three sources, and they disagree. Docker's settings live in /etc/docker/daemon.json, in flags on the service unit, and in the running daemon — and after an edit without a restart those are different answers. So the daemon defaults are read from docker info, which is what the daemon actually loaded rather than what someone wrote down; the socket and TLS settings are read from the file and the unit together, because docker info does not report them at all; and ss supplies the endpoint you can go and verify, without ever deciding whether a finding exists.
Every finding here is Manual, on purpose. The checker reads the running daemon, but a fix would edit a file the daemon does not read again until it restarts — and restarting Docker stops every container on the host. A fix that marked the finding resolved and raised your score while changing nothing an attacker can see would be worse than no fix, so the how-to-fix text carries the exact change and you choose the moment.
| ID | What it means | Severity | Fix |
|---|---|---|---|
dockerd.api-unauthenticated | The Docker API is served over TCP with no TLS client verification — unauthenticated root for anyone who can reach the port | HIGH | Manual |
dockerd.socket-world-writable | Every local account can connect to the Docker socket, and so become root | HIGH | Manual |
dockerd.api-tls-unverified | The API is encrypted but accepts any client — TLS without tlsverify authenticates the server, not the caller | HIGH | Manual |
dockerd.group-members | Accounts other than root hold the socket's group, which is root-equivalent with no password prompt and no audit record | MEDIUM / HIGH | Manual |
dockerd.no-new-privileges | Containers can still gain privileges through setuid binaries | MEDIUM | Manual |
dockerd.userns-remap | User namespaces are not remapped, so container root is host root | LOW | Manual |
dockerd.live-restore | Restarting the daemon stops every container, which is why daemon updates get deferred | LOW | Manual |
dockerd.group-members is MEDIUM when the accounts holding the group are people, and HIGH when any of them is a service account — one with a system UID or no interactive login. Nobody deliberately grants a CI runner or a monitoring agent the ability to become root, and a credential that never logs in is one nobody is watching.
A loopback-only TCP socket is not flagged: it reaches nothing the unix socket did not, and it is the documented way to put a proxy in front of the daemon. A daemon running rootless has userns-remap and group-members suppressed. Its API finding stays HIGH: rootless bounds the damage to that user's containers rather than the host, which is a difference the description reports, but it does not make the port any harder to reach. live-restore is suppressed on a swarm node, where Docker does not support it.
Service hardening systemd.
Roughly half of a self-hosted server is not in a container. The Compose domain reads what your containers declare — privileged, running as root, no-new-privileges off — and a service started by a unit file has exactly the same decisions made about it. Until this domain existed, nothing looked at them.
systemd already knows the answers. systemctl show reports the effective value of each property after the unit file, every drop-in, and every default have been merged — so what Hostveil reads is what the service will actually run with, not what some file says.
Only your own units are audited. A unit counts if its file lives under /etc/systemd/system or /usr/local/lib/systemd/system — the ones you wrote or installed by hand. Your distribution hardens its own units on its own schedule, and second-guessing them means taking on maintenance of somebody else’s service. Reporting on them would also bury your services under dozens of findings about software you did not choose, which is how a domain stops being read.
| ID | What it means | Severity | Fix |
|---|---|---|---|
systemd.no-new-privileges | The service can gain privileges through a setuid binary — the usual way a foothold inside a service becomes a foothold outside it | LOW / MEDIUM | Review |
systemd.protect-system | The service can write to /usr, /boot and /etc; one that can edit /etc owns every login on the host | MEDIUM / LOW | Manual |
systemd.protect-home | The service can read /home, /root and /run/user — everyone’s SSH keys, cloud credentials and password databases | MEDIUM / LOW | Manual |
systemd.private-tmp | The service shares /tmp with every other process, which is the ground symlink races live on | LOW | Manual |
systemd.private-devices | The service can access host devices | MEDIUM | Manual |
systemd.protect-kernel-tunables | The service can change kernel tunables | MEDIUM | Manual |
systemd.protect-kernel-modules | The service can alter kernel modules | MEDIUM | Review |
systemd.protect-control-groups | The service can alter control groups | MEDIUM | Manual |
systemd.protect-kernel-logs | The service can access kernel logs | LOW | Review |
systemd.protect-clock | The service can change system clocks | LOW | Review |
systemd.restrict-suid-sgid | The service can create setuid files | MEDIUM | Review |
systemd.restrict-namespaces | The service can create namespaces | MEDIUM | Manual |
systemd.lock-personality | The service can change execution personality | LOW | Review |
systemd.memory-deny-write-execute | The service can create writable executable memory | MEDIUM | Manual |
Two severities, and which one applies depends on the account. no-new-privileges is MEDIUM on a service running as somebody other than root and LOW on one running as root — a root service can already do anything, so the setuid path it closes buys little there. The two filesystem protections go the other way for the same reason: they are worth most exactly where no-new-privileges is worth least.
Every finding here is Manual, on purpose. The drop-in is two lines and Hostveil could write it — but writing it is not the same as knowing it is safe. ProtectSystem=full breaks a service that writes under /usr; PrivateTmp=yes breaks two services that hand each other files through /tmp. Neither failure shows up until the next restart, which on a self-hosted box is the next reboot — and the service that does not come back is the one holding your data. So the how-to-fix carries the exact path and the exact two lines, and you pick the moment to restart and watch.
A property this systemd does not implement is reported as nothing at all, and nothing is not evidence that a protection is off — those are left alone rather than turned into a finding.
AI agent runtimes agent.
Self-hosted AI agents — OpenClaw and Hermes Agent — run a network gateway and keep API keys on disk in your home directory. Misconfigured, the worst case is someone reaching that gateway and driving an agent that can read your files and run commands as you.
This domain is skipped entirely unless one of those runtimes is actually installed, so a host that has never run an agent is not scored on it. Hostveil looks up home directories in /etc/passwd and stats only the paths listed below — it never walks your home.
These projects ship their own config auditors (openclaw security audit), which cover their settings far more thoroughly than Hostveil tries to. What Hostveil adds is the part those tools cannot see: whether the gateway is actually listening on a reachable address, whether a firewall stands behind it, and whether your credential files are readable by other accounts on the box.
| ID | What it flags | Severity | Fix |
|---|---|---|---|
agent.auth-disabled | An exposed gateway that accepts requests with no authentication | HIGH | Manual |
agent.gateway-exposed | The gateway is bound to a network-reachable address | HIGH | Manual |
agent.secret-exposed | A credentials file or directory is readable beyond its owner | HIGH | Auto-fix |
agent.exec-unrestricted | The agent may run shell commands with no approval step | HIGH | Review |
agent.elevated-enabled | Elevated (host-level) command execution is enabled | HIGH | Auto-fix |
agent.sandbox-off | Agent tools run unsandboxed, directly on the host | HIGH | Manual |
agent.control-ui-insecure | The control UI has its auth or device-identity checks disabled | HIGH | Auto-fix |
agent.config-perms | A config or state path is more permissive than upstream ships it | MEDIUM | Auto-fix |
agent.ssrf-private-network | The agent browser may reach private-network addresses | MEDIUM | Auto-fix |
Most of the config findings can now be fixed. OpenClaw's config is JSON5 and carries your own comments, so Hostveil edits it by replacing exactly the bytes of the one value and leaving the rest of the file alone — then re-reads what it wrote and refuses to save it unless nothing else changed. A one-key change stays a one-line diff, and your comments stay where you put them.
Three are still Manual, each for its own reason. For agent.sandbox-off Hostveil knows off is wrong and does not know which mode turns the sandbox on, and a guessed value is not a fix. For agent.auth-disabled the safe posture is no key at all — OpenClaw fails closed when it is absent — and Hostveil replaces values rather than deleting them. And rebinding an exposed gateway can cut you off from the agent you administer remotely. Hermes settings are left alone throughout: they may come from the config, from .env, from a systemd unit, or from a docker -e flag, and a finding cannot tell which is in force.
Only the two permission findings are auto-fixable, and both are a chmod that only ever removes access, checkpointed so hostveil rollback restores the previous mode exactly.
Image CVEs cve. (optional)
When Trivy is installed, Hostveil scans the images your Compose services run for known Critical, High, and Medium vulnerabilities. Findings are reported per image, not per vulnerability — a single image routinely ships hundreds of CVEs, and they all share one remediation.
Each scanned image produces up to two findings, split by what you can actually do about them:
| ID | What it flags | Severity | Fix |
|---|---|---|---|
cve.outdated-image | Vulnerabilities that are already fixed upstream | Worst in that group | Review |
cve.unpatched-image | Vulnerabilities nobody has published a fix for | Worst in that group | Unavailable |
Each finding names the severity counts and its worst few CVEs. The complete list of vulnerability IDs is in the finding's evidence — use hostveil scan --json to read it.
Trivy tells you a package must reach version 3.0.11-1~deb12u2. That is an OS package version inside the image, not an image tag, and there is no mapping from one to the other — so a per-CVE fix would have to invent one. There is nothing you can do about a single CVE that you would not do about all of them at once, so the image is the honest unit. Re-pulling the tag you already chose needs no version mapping, and it promises only that it re-resolves the tag, not that any particular CVE is gone. Images pinned by digest get no fix, because pulling a digest cannot change anything.
Vulnerabilities with no available patch get their own finding rather than being folded into the fixable ones or dropped. Aggregating them away would let an image whose vulnerabilities are all unfixed disappear from the report entirely, and Hostveil would be claiming a host is clean when it is only stuck. The state is modelled as a first-class Unavailable instead of pretending it can be fixed.
What each fix actually does
Every finding above with an Auto-fix or Review in its Fix column has a fix registered for it in internal/fix, and this is exactly what that fix does — generated from the current build, so it can never say something the code doesn't do. A Review finding with more than one entry lists every alternative it offers; the first is the one preselected everywhere, including fix --all --review.
Container
compose.dr002Auto-fix-
Bind published port to 127.0.0.1
✓ Once recreated, the service stops being reachable from the network at all — only processes on this host can reach it, closing off whatever guessing or scanning the open port currently invites.
⚠ Once app is recreated, it is reachable only from this host. If you access it from another machine, use an SSH tunnel, VPN, or reverse proxy. This edits the file, not the running container:
docker compose up -d appis what puts the change into force. compose.ds006Auto-fix-
Add security_opt no-new-privileges:true
✓ A process that breaks into this container can no longer gain more privilege than it started with via a setuid binary — one of the more common container-escape stepping stones, closed.
⚠ This edits the file, not the running container:
docker compose up -d appis what puts the change into force. compose.ds008Auto-fix-
Set restart: unless-stopped
✓ The service comes back on its own after a crash, an OOM kill, or a host reboot instead of silently staying down until someone notices.
⚠ This edits the file, not the running container:
docker compose up -d appis what puts the change into force. compose.ds010Review-
-
Limit app to 1g — typical application container (recommended)
✓ Gives the container a memory ceiling, so a leak or a runaway process inside it gets OOM-killed and restarted instead of exhausting the host's memory and taking every other service down with it.
⚠ Too low a limit gets the container OOM-killed under load. Start generous, watch
docker stats, and tighten later. This is a file edit, so it is fully reversible. This edits the file, not the running container:docker compose up -d appis what puts the change into force. -
Limit app to 512m — small service (proxy, exporter, static site)
✓ Gives the container a memory ceiling, so a leak or a runaway process inside it gets OOM-killed and restarted instead of exhausting the host's memory and taking every other service down with it.
⚠ Too low a limit gets the container OOM-killed under load. Start generous, watch
docker stats, and tighten later. This is a file edit, so it is fully reversible. This edits the file, not the running container:docker compose up -d appis what puts the change into force. -
Limit app to 2g — database or JVM service
✓ Gives the container a memory ceiling, so a leak or a runaway process inside it gets OOM-killed and restarted instead of exhausting the host's memory and taking every other service down with it.
⚠ Too low a limit gets the container OOM-killed under load. Start generous, watch
docker stats, and tighten later. This is a file edit, so it is fully reversible. This edits the file, not the running container:docker compose up -d appis what puts the change into force.
-
compose.ds018Auto-fix-
Bind published port to 127.0.0.1
✓ Once recreated, the service stops being reachable from the network at all — only processes on this host can reach it, closing off whatever guessing or scanning the open port currently invites.
⚠ Once app is recreated, it is reachable only from this host. If you access it from another machine, use an SSH tunnel, VPN, or reverse proxy. This edits the file, not the running container:
docker compose up -d appis what puts the change into force. compose.ds019Auto-fix-
Bind published port to 127.0.0.1
✓ Once recreated, the service stops being reachable from the network at all — only processes on this host can reach it, closing off whatever guessing or scanning the open port currently invites.
⚠ Once app is recreated, it is reachable only from this host. If you access it from another machine, use an SSH tunnel, VPN, or reverse proxy. This edits the file, not the running container:
docker compose up -d appis what puts the change into force.
SSH
ssh.allowagentforwardingAuto-fix-
Disable SSH agent forwarding
✓ Stops a compromised host from riding a forwarded SSH agent to authenticate elsewhere as the user, using credentials that were never actually stored here.
⚠ Existing workflows that hop through this host using a forwarded agent will stop working.
ssh.allowtcpforwardingAuto-fix-
Disable SSH TCP forwarding
✓ Closes SSH's local, remote, and dynamic port forwarding — a common pivot and tunnel-out vector for anyone who gets a session on this host.
⚠ Existing local, remote, and dynamic SSH tunnels will stop working.
ssh.clientalivecountmaxAuto-fix-
Limit unanswered SSH keepalives
✓ Bounds how long a dead or hung session is held open, freeing the session slot instead of leaving it reserved indefinitely.
ssh.clientaliveintervalAuto-fix-
Probe idle SSH clients every five minutes
✓ Detects and disconnects idle or dead clients on a schedule, instead of leaving a session open for as long as the network happens to keep the TCP connection alive.
⚠ Long-running idle sessions may be disconnected when their clients stop responding.
ssh.emptypasswordsAuto-fix-
Disable empty passwords
✓ Closes SSH login with a blank password — the one credential check an attacker never even has to guess.
ssh.fingerprinthashAuto-fix-
Use SHA-256 host-key fingerprints
✓ Uses a stronger hash for the host-key fingerprint shown to users and tooling verifying this server's identity.
ssh.gatewayportsAuto-fix-
Bind remote-forwarded ports to loopback only
✓ Stops an
ssh -Rremote-forwarded port becoming reachable from other machines, closing a route around whatever this host's own firewall is doing.⚠ If you rely on
ssh -Rtunnels being reachable from other machines, this closes them to loopback. ssh.hostbasedauthAuto-fix-
Disable host-based authentication
✓ Removes a trust-based login path that bypasses per-user keys entirely.
⚠ If any user logs in via host-based trust rather than their own key, this removes that path.
ssh.ignorerhostsAuto-fix-
Ignore legacy rhosts trust files
✓ Ignores legacy .rhosts trust files, closing a decades-old authentication bypass that has no place on a hardened host.
ssh.kbdinteractiveAuto-fix-
Disable keyboard-interactive authentication
✓ Closes the same password-equivalent prompt path PasswordAuthentication closes, for the keyboard-interactive method some clients use instead.
⚠ PAM-based one-time codes (2FA prompts) also use this mechanism — keep it enabled if your logins go through one.
ssh.logingracetimeAuto-fix-
Lower LoginGraceTime to 60 seconds
✓ Shortens the window an unauthenticated connection can hold a login slot open, reducing exposure to connection-slot exhaustion.
ssh.loglevelAuto-fix-
Log SSH key fingerprints
✓ Logs the key fingerprint used on every login, giving the operator forensic evidence of exactly which key authenticated — not just that a login succeeded.
ssh.maxauthtriesAuto-fix-
Lower MaxAuthTries to 3
✓ Throttles how many credential guesses a single SSH connection gets before it is dropped, slowing a brute-force attempt against this host specifically.
ssh.maxsessionsAuto-fix-
Limit multiplexed SSH sessions
✓ Caps multiplexed sessions per connection, limiting how much one compromised or stolen connection can do at once.
⚠ Clients opening more than two sessions over one SSH connection will be refused.
ssh.passwordauthAuto-fix-
Disable password authentication
✓ Removes password-guessing as an SSH attack surface entirely — only a held key gets in from here on.
⚠ Make sure key-based login works BEFORE applying this, or you may lock yourself out of SSH.
ssh.permittunnelAuto-fix-
Disable SSH tun/tap tunnels
✓ Disables SSH's own VPN-like tun/tap tunneling, removing a route around whatever network controls this host is behind.
⚠ Existing SSH VPN or tun/tap workflows will stop working.
ssh.permituserenvironmentAuto-fix-
Disable user-supplied SSH environments
✓ Stops a user's ~/.ssh/environment overriding the server process's own environment — a known way to hijack LD_PRELOAD or similar into a login session.
⚠ Login automation that relies on ~/.ssh/environment will stop receiving those variables.
ssh.printlastlogAuto-fix-
Show the previous login
✓ Shows the previous login on every connect, so a user has a chance to notice a login they did not make.
ssh.rootloginReview-
-
Allow root only with an SSH key (prohibit-password) (recommended)
✓ Keeps key-based root access working while removing the one thing root logins are actually dangerous for: a guessable or stolen password.
⚠ Keep a working key for root, or use a sudo user instead.
-
Disable root login entirely (no)
✓ Removes direct root login over SSH entirely, so compromising this host's authentication means compromising a named, sudo-capable user instead of the one account every attacker already knows the name of.
⚠ Make sure another user can log in and use sudo before applying this.
-
ssh.strictmodesAuto-fix-
Enforce SSH login-file ownership
✓ Refuses to honor login files (keys, config) with loose ownership or permissions, closing a local tampering vector.
ssh.tcpkeepaliveAuto-fix-
Disable TCP keepalives for SSH
✓ Stops relying on spoofable TCP keepalives for liveness, leaving ClientAliveInterval's encrypted check as the one source of truth for whether a session is still alive.
⚠ Use ClientAliveInterval for encrypted liveness checks; dead connections may otherwise take longer to disappear from intermediate network devices.
ssh.usednsAuto-fix-
Disable SSH reverse-DNS lookups
✓ Removes a DNS lookup from the login path — both a minor timing/DoS surface and a spoofing vector, for a lookup the login never actually needed.
ssh.x11forwardingAuto-fix-
Disable X11 forwarding
✓ Removes an X11 forwarding channel that malware or an attacker with a foothold could otherwise ride out of the session.
Firewall
firewall.default-allowReview-
Allow SSH on 22/tcp, then set ufw's default inbound policy to deny
✓ Flips a firewall that is already running from allow-by-default to deny-by-default, closing every port nothing has explicitly opened on a host where a firewall was already assumed to be doing that job.
⚠ Every inbound port except 22/tcp stops being reachable the moment this runs, including anything a container publishes. There is no rollback checkpoint — exec fixes are not file-backed — so undoing it means
ufw default allow incomingby hand. firewall.inactiveReview-
Allow SSH on 22/tcp, then enable ufw with a default-deny inbound policy
✓ Turns on default-deny, so only the ports hostveil confirmed sshd is actually listening on stay reachable — everything else stops accepting connections from off this host.
⚠ Every inbound port except 22/tcp stops being reachable the moment this runs, including anything a container publishes. There is no rollback checkpoint — exec fixes are not file-backed — so undoing it means
ufw disableby hand.
Updates
updates.apt-listchangesReview-
Install and enable auditd
✓ Installs a kernel-level audit trail of security-relevant actions — file access, privilege changes — that survives a process exiting or an application log being cleared.
⚠ Installs auditd and enables its service. There is no rollback checkpoint; undoing this means removing the package by hand.
updates.apt-show-versionsReview-
Install and enable auditd
✓ Installs a kernel-level audit trail of security-relevant actions — file access, privilege changes — that survives a process exiting or an application log being cleared.
⚠ Installs auditd and enables its service. There is no rollback checkpoint; undoing this means removing the package by hand.
updates.auditdReview-
Install and enable auditd
✓ Installs a kernel-level audit trail of security-relevant actions — file access, privilege changes — that survives a process exiting or an application log being cleared.
⚠ Installs auditd and enables its service. There is no rollback checkpoint; undoing this means removing the package by hand.
updates.debsumsReview-
Install and enable auditd
✓ Installs a kernel-level audit trail of security-relevant actions — file access, privilege changes — that survives a process exiting or an application log being cleared.
⚠ Installs auditd and enables its service. There is no rollback checkpoint; undoing this means removing the package by hand.
updates.disabledReview-
Install and enable dnf-automatic
✓ Once enabled, security patches for every installed package arrive on their own instead of depending on someone remembering to run apt/dnf.
⚠ Installs dnf-automatic and enables its timer. There is no rollback checkpoint; undoing this means removing the package by hand.
updates.fail2banReview-
Install and enable auditd
✓ Installs a kernel-level audit trail of security-relevant actions — file access, privilege changes — that survives a process exiting or an application log being cleared.
⚠ Installs auditd and enables its service. There is no rollback checkpoint; undoing this means removing the package by hand.
updates.pam-pwqualityReview-
Install and enable auditd
✓ Installs a kernel-level audit trail of security-relevant actions — file access, privilege changes — that survives a process exiting or an application log being cleared.
⚠ Installs auditd and enables its service. There is no rollback checkpoint; undoing this means removing the package by hand.
updates.pam-tmpdirReview-
Install and enable auditd
✓ Installs a kernel-level audit trail of security-relevant actions — file access, privilege changes — that survives a process exiting or an application log being cleared.
⚠ Installs auditd and enables its service. There is no rollback checkpoint; undoing this means removing the package by hand.
updates.pending-securityReview-
Refresh apt metadata and fully upgrade installed packages
✓ Applies whatever security patches are already sitting unapplied right now — closes today's already-known gaps rather than waiting for the next automatic run.
⚠ Upgrading packages can restart the services they belong to, including sshd and the Docker daemon — and a container whose compose file has no restart policy in force stays down until
docker compose up -dbrings it back. A kernel or libc update may also need a reboot to actually take effect. There is no rollback checkpoint; undoing this means downgrading the affected packages by hand. updates.process-accountingReview-
Install and enable auditd
✓ Installs a kernel-level audit trail of security-relevant actions — file access, privilege changes — that survives a process exiting or an application log being cleared.
⚠ Installs auditd and enables its service. There is no rollback checkpoint; undoing this means removing the package by hand.
updates.rkhunterReview-
Install and enable auditd
✓ Installs a kernel-level audit trail of security-relevant actions — file access, privilege changes — that survives a process exiting or an application log being cleared.
⚠ Installs auditd and enables its service. There is no rollback checkpoint; undoing this means removing the package by hand.
updates.sysstatReview-
Install and enable auditd
✓ Installs a kernel-level audit trail of security-relevant actions — file access, privilege changes — that survives a process exiting or an application log being cleared.
⚠ Installs auditd and enables its service. There is no rollback checkpoint; undoing this means removing the package by hand.
updates.sysstat-disabledAuto-fix-
Set ENABLED to true
✓ Turns on the data collection the sysstat package already has installed, so historical resource data actually starts accumulating instead of the package sitting present but silent.
CVEs
cve.outdated-imageReview-
-
Pull the new image and recreate app now (recommended)
✓ Re-resolves the tag to whatever it currently points at, which is how the vendor ships fixes for a floating-tag image — it may include a patch for the CVE that triggered this finding, though hostveil cannot guarantee it does, since the same tag can also move for unrelated reasons. On a host where staying current matters more than staying unchanged, this is the fastest way to find out.
⚠ This recreates the container: the service goes down briefly and comes back on a different image. There is no rollback checkpoint: exec fixes are not file-backed, so Hostveil cannot undo this. Note the current image ID (
docker compose -f /opt/example/docker-compose.yml images) before applying, so you can pin it back if the new one misbehaves. The new image may also carry unrelated upstream changes since you last pulled — on a host you are not trying to disturb, that is a real cost to weigh against a fix that is not guaranteed to land anyway. -
Download the new image only; recreate app on your own schedule
✓ Downloads the new image without touching the running container, so there is a chance to inspect it — its digest, its changed packages, its release notes — before deciding whether pulling it in is worth the risk to a service that is stable today.
⚠ This changes nothing that is running: the image is downloaded but the container keeps using the old one until you recreate it, and the finding will still be reported until then. There is no rollback checkpoint: exec fixes are not file-backed, so Hostveil cannot undo this.
-
Ports
ports.redis-bindAuto-fix-
Bind Redis to loopback
✓ Redis ships with no authentication by default; binding it to loopback means the only way to reach it is already being on this host — closes it to the network entirely.
⚠ Restarting Redis can interrupt clients; validate this setting first.
ports.redis-disable-configAuto-fix-
Disable the Redis CONFIG command
✓ Removes remote CONFIG SET/GET, closing the well-known Redis-to-remote-code-execution chain that writes a webshell or SSH key to disk via CONFIG SET dir/dbfilename.
⚠ Restarting Redis can interrupt clients; validate this setting first.
ports.redis-protected-modeAuto-fix-
Enable Redis protected mode
✓ Turns on Redis's own built-in refusal to serve remote clients when no password is set — a second, independent barrier behind the bind address, so a firewall or compose slip elsewhere doesn't leave Redis exposed anyway.
⚠ Restarting Redis can interrupt clients; validate this setting first.
Accounts
accounts.core-dumpsAuto-fix-
Set a hard core-size limit of zero
✓ Stops a crashed privileged process writing a core file to disk — the easiest place a live secret in memory (a password, a key) ends up sitting in plaintext after a crash.
accounts.default-umaskAuto-fix-
Set the default umask to 027
✓ New files a user creates default to unreadable by other accounts on the box, closing the easiest way a stray world-readable file leaks something it shouldn't.
accounts.emptypasswordReview-
Lock alice with
passwd -l✓ Closes the one account whose password prompt currently succeeds for anyone who tries it, including a stranger who has never touched this host before.
⚠ If alice is your only way to reach this machine locally (console, su) and it has no other credential, locking it removes that access. Confirm you have another route in — an SSH key, a different sudo-capable account — before applying. The remote SSH path is already closed by ssh.emptypasswords; this only closes the local one.
-
Append the standard access warning
✓ Puts a legal notice in front of anyone who logs into this host, local or remote, which several jurisdictions require before monitoring or logging a session can be used as evidence against an intruder.
⚠ Have counsel or the system owner approve login-banner wording for this organization.
accounts.password-agingAuto-fix-
Harden password aging defaults
✓ Forces credentials to actually expire and blocks changing a password back to itself immediately, so a leaked password has a shelf life instead of being valid forever.
accounts.password-roundsAuto-fix-
Harden password hashing rounds
✓ Raises the SHA-512 rounds used to hash new or changed passwords, slowing an offline dictionary or brute-force attempt against a stolen /etc/shadow by roughly the same factor.
-
Append the standard access warning
✓ Puts a legal notice in front of anyone who logs into this host, local or remote, which several jurisdictions require before monitoring or logging a session can be used as evidence against an intruder.
⚠ Have counsel or the system owner approve login-banner wording for this organization.
File perms
fileperms.at-allowAuto-fix-
Tighten /etc/shadow to 0640
✓ Keeps control of who is allowed to schedule future one-off commands to root alone.
fileperms.at-denyAuto-fix-
Tighten /etc/shadow to 0640
✓ Keeps control of who is blocked from scheduling future one-off commands to root alone.
fileperms.compilerAuto-fix-
Tighten /etc/shadow to 0640
✓ Removes the system compiler from every local account's reach by default, taking away a convenient way to build exploit code from a local foothold.
fileperms.cronAuto-fix-
Tighten /etc/shadow to 0640
✓ Stops a non-root account scheduling a command to run as root through a system cron definition.
fileperms.cron-allowAuto-fix-
Tighten /etc/shadow to 0640
✓ Keeps control of who is allowed to schedule recurring cron jobs to root alone.
fileperms.cron-denyAuto-fix-
Tighten /etc/shadow to 0640
✓ Keeps control of who is blocked from scheduling recurring cron jobs to root alone.
fileperms.crontabAuto-fix-
Tighten /etc/shadow to 0640
✓ Closes an avoidable persistence path: the system crontab can run commands as root, and this keeps it restricted to what the scheduler itself needs.
fileperms.docker-configAuto-fix-
Tighten /etc/shadow to 0640
✓ Stops other accounts on the host reading root's Docker registry credentials, which could otherwise pull private images or publish under this host's identity.
fileperms.groupAuto-fix-
Tighten /etc/shadow to 0640
✓ Stops a non-root account adding itself to a privileged group — sudo, docker — by editing the group database directly.
fileperms.grub-configAuto-fix-
Tighten /etc/shadow to 0640
✓ Stops a non-root account reading or rewriting the bootloader configuration, which controls what runs before any of the host's normal access controls are active.
fileperms.grub2-configAuto-fix-
Tighten /etc/shadow to 0640
✓ Same protection as the GRUB config, for the GRUB2 variant.
fileperms.gshadowAuto-fix-
Tighten /etc/shadow to 0640
✓ Stops a non-root account reading group passwords and membership data that /etc/gshadow is supposed to keep to root and the shadow group alone.
fileperms.hostkeyAuto-fix-
Tighten /etc/shadow to 0640
✓ Stops a non-root account reading this server's SSH private host key — the one thing that would let them impersonate this host to anyone connecting.
fileperms.passwdAuto-fix-
Tighten /etc/shadow to 0640
✓ Stops a non-root account editing the account database directly, closing a path to adding an account or handing itself UID 0.
fileperms.passwd-backupAuto-fix-
Tighten /etc/shadow to 0640
✓ Stops a non-root account planting a change in the passwd backup that becomes a live credential the next time it is restored.
fileperms.shadowAuto-fix-
Tighten /etc/shadow to 0640
✓ Stops any account other than root (and the shadow group) reading password hashes off disk, closing the offline-cracking path entirely.
fileperms.sshd-configAuto-fix-
Tighten /etc/shadow to 0640
✓ Stops a non-root account weakening the SSH server's own config (re-enabling root login or password auth) to open a way back in.
fileperms.sudoersAuto-fix-
Tighten /etc/shadow to 0640
✓ Stops a non-root account granting itself root by editing sudo's own policy file.
fileperms.sudoers-dropinsAuto-fix-
Tighten /etc/shadow to 0640
✓ Same protection as the main sudoers file, for a drop-in carrying the exact same authority.
fileperms.systemd-unitsAuto-fix-
Tighten /etc/shadow to 0640
✓ Stops a non-root account replacing the command a systemd unit runs the next time it starts as root.
AI agents
agent.config-permsAuto-fix-
Tighten /etc/shadow to 0640
✓ Stops other local accounts on this host reading the agent runtime's own configuration, which can carry API keys and tool permissions.
agent.control-ui-insecureAuto-fix-
Set kernel.kptr_restrict to 1
✓ Closes the agent's control UI to unauthenticated access, so reaching it requires being someone the operator actually let in.
agent.elevated-enabledAuto-fix-
Set kernel.kptr_restrict to 1
✓ Stops the agent running with elevated or administrative privileges by default, so a prompt-injected or misused tool call can't act with more authority than the task ever needed.
agent.exec-unrestrictedAuto-fix-
Set kernel.kptr_restrict to 1
✓ Stops the agent executing shell commands at all without an explicit human decision each time — closes the most direct path from "the agent was tricked by its input" to "a shell command ran on this host."
agent.secret-exposedAuto-fix-
Tighten /etc/shadow to 0640
✓ Stops other local accounts reading a secret file the agent runtime keeps in its state directory.
agent.ssrf-private-networkAuto-fix-
Set kernel.kptr_restrict to 1
✓ Stops the agent's own tool calls reaching internal or private network addresses on the operator's behalf — closes the classic SSRF pivot from "the agent fetched a URL" to "the agent probed the internal network."
Kernel
sysctl.accept-redirectsAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-accept-redirects.conf
✓ Stops the kernel trusting ICMP redirects, closing an easy way for something on the local network to quietly re-route this host's outbound traffic through itself.
sysctl.accept-source-routeAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-accept-source-route.conf
✓ Stops the kernel honoring source-routed packets, closing a way to make traffic take a path chosen by whoever sent it rather than by this network's own routing.
sysctl.aslrAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-aslr.conf
✓ Turns on full address space layout randomization, making a memory-corruption bug substantially harder to turn into a reliable exploit.
sysctl.bogus-icmp-errorsAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-bogus-icmp-errors.conf
✓ Stops the kernel logging bogus ICMP error responses as if real, reducing log noise and a minor spoofing-detection blind spot.
sysctl.bootp-relayReview-
-
Persist it: write /etc/sysctl.d/60-hostveil-bootp-relay.conf (recommended)
✓ Stops this host forwarding BOOTP/DHCP broadcasts between networks it has no business relaying for.
-
Apply it now: kernel.kptr_restrict
✓ Stops this host forwarding BOOTP/DHCP broadcasts between networks it has no business relaying for.
⚠ Changes the running kernel immediately and has no rollback checkpoint.
-
sysctl.bpf-jit-hardenAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-bpf-jit-harden.conf
✓ Hardens the BPF JIT compiler's output against the spraying techniques used to turn a BPF bug into kernel code execution.
sysctl.core-uses-pidAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-core-uses-pid.conf
✓ Includes the process ID in a core dump's filename, so a crash does not silently overwrite an earlier one a security investigation might still need.
sysctl.ctrl-alt-delAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-ctrl-alt-del.conf
✓ Stops Ctrl-Alt-Del triggering an instant reboot, closing a trivial denial-of-service available to anyone with physical or console access.
sysctl.dmesg-restrictAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-dmesg-restrict.conf
✓ Stops unprivileged users reading the kernel log, which can leak addresses and details useful for a kernel-level exploit.
sysctl.icmp-broadcastsAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-icmp-broadcasts.conf
✓ Stops this host answering ICMP echo requests sent to a broadcast address, closing its use as an amplifier in a Smurf-style denial-of-service attack.
sysctl.ipv4-default-accept-redirectsAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-ipv4-default-accept-redirects.conf
✓ Same protection as accept-redirects, as the IPv4 default template new interfaces inherit.
sysctl.ipv4-default-rp-filterReview-
-
Persist it: write /etc/sysctl.d/60-hostveil-ipv4-default-rp-filter.conf (recommended)
✓ Same protection as rp-filter, as the default template.
-
Apply it now: kernel.kptr_restrict
✓ Same protection as rp-filter, as the default template.
⚠ Changes the running kernel immediately and has no rollback checkpoint.
-
sysctl.ipv6-accept-redirects-allAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-ipv6-accept-redirects-all.conf
✓ Same protection as accept-redirects, for IPv6, applied to every interface.
sysctl.ipv6-accept-redirects-defaultAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-ipv6-accept-redirects-default.conf
✓ Same protection as accept-redirects, for IPv6, as the default template new interfaces inherit.
sysctl.ipv6-accept-source-route-allAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-ipv6-accept-source-route-all.conf
✓ Same protection as accept-source-route, for IPv6, every interface.
sysctl.ipv6-accept-source-route-defaultAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-ipv6-accept-source-route-default.conf
✓ Same protection as accept-source-route, for IPv6, as the default template.
sysctl.ipv6-send-redirectsAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-ipv6-send-redirects.conf
✓ Same protection as send-redirects, for IPv6.
sysctl.kptr-restrictAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-kptr-restrict.conf
✓ Hides kernel pointer addresses from unprivileged reads of /proc, taking away information an attacker would otherwise use to build a kernel exploit.
sysctl.log-martiansAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-log-martians.conf
✓ Logs packets carrying an impossible source or destination address, giving visibility into spoofing or misconfiguration attempts that would otherwise pass silently.
sysctl.module-dccpAuto-fix-
Persist a modprobe install block
✓ Removes the kernel's ability to load the DCCP module at all — a rarely-used protocol on an ordinary self-hosted server, and one that has shown up repeatedly as a local attack vector when left loadable.
⚠ Confirm this host does not use dccp before applying.
sysctl.module-rdsAuto-fix-
Persist a modprobe install block
✓ Removes the kernel's ability to load the RDS module at all — the same reasoning as DCCP: rarely used here, and a repeat source of local kernel bugs when loadable.
⚠ Confirm this host does not use dccp before applying.
sysctl.module-sctpAuto-fix-
Persist a modprobe install block
✓ Removes the kernel's ability to load the SCTP module at all — the same reasoning as DCCP: rarely used here, and a repeat source of local kernel bugs when loadable.
⚠ Confirm this host does not use dccp before applying.
sysctl.module-tipcAuto-fix-
Persist a modprobe install block
✓ Removes the kernel's ability to load the TIPC module at all — the same reasoning as DCCP: rarely used here, and a repeat source of local kernel bugs when loadable.
⚠ Confirm this host does not use dccp before applying.
sysctl.module-usbstorageAuto-fix-
Persist a modprobe install block
✓ Removes the kernel's ability to load the USB mass-storage driver, closing a common way to pull data off or introduce malware onto a physically-accessible server via a USB drive.
⚠ Confirm this host does not use dccp before applying.
sysctl.multicast-forwardingReview-
-
Persist it: write /etc/sysctl.d/60-hostveil-multicast-forwarding.conf (recommended)
✓ Stops this host relaying multicast traffic between interfaces it has no business routing for.
-
Apply it now: kernel.kptr_restrict
✓ Stops this host relaying multicast traffic between interfaces it has no business routing for.
⚠ Changes the running kernel immediately and has no rollback checkpoint.
-
sysctl.perf-eventsAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-perf-events.conf
✓ Restricts performance-counter access so an unprivileged user can't use it to leak kernel addresses or run a side-channel attack against other processes.
sysctl.protected-fifosAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-protected-fifos.conf
✓ Stops a process writing into a FIFO it does not own in a world-writable sticky directory — the same class protected-links closes for symlinks.
sysctl.protected-linksAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-protected-links.conf
✓ Stops a process following a symlink it does not own in a world-writable sticky directory, closing a classic local privilege-escalation and file-clobbering trick.
sysctl.protected-regularAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-protected-regular.conf
✓ Stops a process writing into a regular file it does not own in a world-writable sticky directory, for the same reason.
sysctl.proxy-arp-allReview-
-
Persist it: write /etc/sysctl.d/60-hostveil-proxy-arp-all.conf (recommended)
✓ Stops the kernel answering ARP on behalf of other hosts across interfaces, closing an easy way to bridge or spoof between network segments this host sits on.
-
Apply it now: kernel.kptr_restrict
✓ Stops the kernel answering ARP on behalf of other hosts across interfaces, closing an easy way to bridge or spoof between network segments this host sits on.
⚠ Changes the running kernel immediately and has no rollback checkpoint.
-
sysctl.proxy-arp-defaultReview-
-
Persist it: write /etc/sysctl.d/60-hostveil-proxy-arp-default.conf (recommended)
✓ Same protection as proxy-arp-all, as the default template.
-
Apply it now: kernel.kptr_restrict
✓ Same protection as proxy-arp-all, as the default template.
⚠ Changes the running kernel immediately and has no rollback checkpoint.
-
sysctl.ptrace-scopeAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-ptrace-scope.conf
✓ Restricts ptrace to a process's own children, closing a well-known technique for one compromised process to read another's memory or inject code into it.
sysctl.rp-filterReview-
-
Persist it: write /etc/sysctl.d/60-hostveil-rp-filter.conf (recommended)
✓ Drops packets whose source address could not have arrived on the interface they came in on, closing a common IP-spoofing technique.
-
Apply it now: kernel.kptr_restrict
✓ Drops packets whose source address could not have arrived on the interface they came in on, closing a common IP-spoofing technique.
⚠ Changes the running kernel immediately and has no rollback checkpoint.
-
sysctl.send-redirectsReview-
-
Persist it: write /etc/sysctl.d/60-hostveil-send-redirects.conf (recommended)
✓ Stops this host sending ICMP redirects itself, which it should not be doing unless it is actually a router — removes a route-injection tool a compromised host could use against its neighbors.
-
Apply it now: kernel.kptr_restrict
✓ Stops this host sending ICMP redirects itself, which it should not be doing unless it is actually a router — removes a route-injection tool a compromised host could use against its neighbors.
⚠ Changes the running kernel immediately and has no rollback checkpoint.
-
sysctl.suid-dumpableAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-suid-dumpable.conf
✓ Stops a crashed setuid process writing a core dump that can contain whatever secrets were in its memory at the moment it crashed.
-
Persist it: write /etc/sysctl.d/60-hostveil-syncookies.conf
✓ Enables SYN cookies, so a SYN-flood cannot exhaust the connection queue and deny the host's legitimate services.
sysctl.sysrqReview-
-
Persist it: write /etc/sysctl.d/60-hostveil-sysrq.conf (recommended)
✓ Restricts the SysRq magic-key functions to the safe subset, closing the ones that can dump memory or force a reboot from an unprivileged local session.
-
Apply it now: kernel.kptr_restrict
✓ Restricts the SysRq magic-key functions to the safe subset, closing the ones that can dump memory or force a reboot from an unprivileged local session.
⚠ Changes the running kernel immediately and has no rollback checkpoint.
-
sysctl.tcp-rfc1337Auto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-tcp-rfc1337.conf
✓ Protects against TIME-WAIT assassination, so an off-path attacker cannot reset a connection sitting in TIME_WAIT by spoofing packets at it.
sysctl.tty-ldisc-autoloadAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-tty-ldisc-autoload.conf
✓ Stops the kernel auto-loading obscure TTY line disciplines on request, closing a local privilege-escalation path specific line-discipline modules have shipped in the past.
sysctl.unprivileged-bpfAuto-fix-
Persist it: write /etc/sysctl.d/60-hostveil-unprivileged-bpf.conf
✓ Removes unprivileged users' ability to load BPF programs into the kernel, closing a well-documented local privilege-escalation and kernel-exploit vector.
Services
systemd.lock-personalityAuto-fix-
Set LockPersonality=yes for app
✓ Stops the unit switching to an alternate execution personality, closing an old technique for bypassing ASLR.
⚠ Needing an alternate execution personality is rare outside emulation and compatibility layers. An ordinary service is unaffected; one that needs one fails at the next restart.
systemd.no-new-privilegesAuto-fix-
Set NoNewPrivileges=yes for app
✓ Closes the setuid escalation path out of this unit — a compromised process cannot gain more privilege than the service already had, the same protection compose.ds006 gives a container.
⚠ Closes the setuid path out of this service. A service that deliberately escalates — anything calling a setuid helper — stops working, and it stops at the next restart rather than now.
systemd.protect-clockAuto-fix-
Set ProtectClock=yes for app
✓ Stops the unit changing the system or hardware clock, closing off a way a compromised service could hide its tracks by tampering with timestamps or break time-based authentication.
⚠ Only time-sync daemons (chronyd, ntpd, systemd-timesyncd) legitimately need to change the system or hardware clock. A service that is not one of those stops being able to, at the next restart.
systemd.protect-kernel-logsAuto-fix-
Set ProtectKernelLogs=yes for app
✓ Stops the unit reading /dev/kmsg directly, closing an information-leak path that can hand an attacker kernel addresses useful for a further exploit.
⚠ Only a service that reads kernel logs directly — a diagnostics tool, an agent reading /dev/kmsg — needs this off. Most services never touch it.
systemd.protect-kernel-modulesAuto-fix-
Set ProtectKernelModules=yes for app
✓ Stops the unit loading or removing kernel modules itself, closing a direct path to running arbitrary code in kernel space from what should be an ordinary service.
⚠ Only a service that loads or removes kernel modules itself at runtime — rather than modules already loaded at boot — needs this off.
systemd.restrict-suid-sgidAuto-fix-
Set RestrictSUIDSGID=yes for app
✓ Stops the unit creating new setuid or setgid files, closing a persistence and privilege-escalation path a compromised service could otherwise leave behind for later.
⚠ Only a service that itself creates setuid or setgid files — a package manager, an installer — needs this off. An ordinary network daemon does not create such files and is unaffected.
Proxy
proxy.no-scan-jailReview-
-
Enable it (fail2ban's own default: 10 minute ban) (recommended)
✓ Once fail2ban reloads, an IP that repeatedly requests nonexistent or known-vulnerable paths gets banned automatically — no more finding it and banning it yourself.
⚠ Takes effect on the next
fail2ban-client reload, not immediately. A shared or NAT'd IP address could ban more than the one visitor responsible for it. This only ever affects HTTP(S) traffic — never SSH, and never the operator's own access to the host. -
Enable it with a longer ban (1 week)
✓ Once fail2ban reloads, an IP that repeatedly requests nonexistent or known-vulnerable paths gets banned automatically — no more finding it and banning it yourself.
⚠ Takes effect on the next
fail2ban-client reload, not immediately. A shared or NAT'd IP address could ban more than the one visitor responsible for it. This only ever affects HTTP(S) traffic — never SSH, and never the operator's own access to the host.
-