Skip to content

System hardening audit

Paul Asadoorian edited this page Aug 27, 2026 · 4 revisions

System hardening audit — -H / hardening-audit

-H asks "is this system hardened?" along several independent axes. Each answers one question and reports on its own, so a tool you don't have costs you that axis and nothing else:

axis question needs
binary were the installed binaries built with the distro's hardening flags? checksec
filesystem can a local user tamper with shared directories? nothing
services how much of the system can each running service reach? systemd
kernel are the kernel's runtime protections switched on? nothing
ssh is the effective sshd configuration weak anywhere? sshd (needs root)
firewall is a host firewall active, and does it actually have rules? nft or iptables (needs root to read rules)
certs are any TLS certificates expired or about to be? openssl
apparmor is mandatory access control confining anything, or only switched on?
selinux what mode is SELinux in, and does the machine agree with itself?

AppArmor: enabled is not the same as confining

"AppArmor is enabled" is the figure usually quoted, and on its own it says very little. Measured on three untuned installs:

host loaded enforce complain name-only confined processes
Manjaro desktop 168 84 5 79 7 of 92
Debian 13 server 106 7 23 76 0 of 19
Ubuntu 26.04 server 173 97 2 74 2 of 20

A stock Debian 13 loads 106 profiles and confines nothing that is running. sshd, systemd-resolved, systemd-networkd, ModemManager and udisksd all run unconfined.

The three modes are never added together. enforce applies a policy, complain logs and applies nothing, and unconfined applies nothing either. That last group is not a defect: distros ship those profiles so applications keep working after Ubuntu set kernel.apparmor_restrict_unprivileged_userns=1, and the files say so themselves. From /etc/apparmor.d/brave:

"This profile allows everything and only exists to give the application a name instead of having the label 'unconfined'"

fettle reports them as exemptions, counted apart from the enforcing profiles.

One finding, at Low: AppArmor is enabled and no running process is confined by it. It names apparmor-profiles and apparmor-profiles-extra when they are missing, which is the usual reason a default install loads profiles covering nothing that runs. It fires on a stock Debian, because that is the real state of the machine.

Complain mode is counted and never flagged: the floor swings from 2 to 23 across three ordinary hosts, 16 of Debian's 23 come from sbuild alone, and the apparmor package owns every one of those files. Nobody chose complain mode there.

SELinux: what mode, and does the machine agree with itself?

SELinux is in much better shape than AppArmor, so this axis asks a different question. Measured on three untuned guests, all enforcing: AlmaLinux 9.8 confines 141 of 147 running processes, Rocky 9.8 confines 140 of 146, Fedora 44 confines 150 of 154. No daemon runs unconfined on a stock EL9 install, so coverage is not the problem. Mode and configuration drift are.

"Enabled or disabled" has five answers on RHEL 9, and two of them get confused:

state how it is told apart
enforcing /sys/fs/selinux/enforce is 1
permissive enforce is 0, policy loaded
disabled at boot selinux=0 on the kernel command line
SELINUX=disabled on RHEL 9+ the config file, plus the release
runtime and config disagree getenforce against SELINUX= in the config

That fourth row is the reason this axis exists. Red Hat deprecated disabling SELinux through SELINUX=disabled: on RHEL 9 the system starts with SELinux enabled and no policy loaded, and selinux=0 on the kernel command line is the documented way to actually turn it off. Edit the config file and you believe SELinux is off; it is on, with no policy, and both the file and getenforce agree with you.

The fifth is the ordinary setenforce 0 left behind after debugging. It reverts at the next reboot, so the machine changes behaviour when it restarts.

Booleans are counted, never judged one by one. Of 17 candidate high-risk booleans, four are on by default on every host tested (selinuxuser_execstack, unconfined_login, httpd_enable_cgi, nfs_export_all_rw) and two more on Fedora, so a "dangerous booleans" list would fire four times on a stock EL9 machine. The ones that are off are no better, because httpd_can_network_connect and samba_export_all_rw are exactly what you switch on to run a reverse proxy or a Samba share.

What fettle does report is booleans that differ from the policy default, which measured 0 on all three hosts. That needs semanage, from policycoreutils-python-utils, which a stock AlmaLinux 9 does not install. When it is missing you get "booleans were NOT compared against the policy defaults" rather than silence.

RHEL reports this axis as not applicable, since it uses SELinux, and the selinux axis answers for it. Unprivileged, the axis reports that it could not read the policy rather than that the policy is empty: aa-status exits 0 while printing "You do not have enough privilege to read the profile set".

Every axis is on by default; turn one off with [hardening] disable_axes = ["binary"]. An axis that can't look says so in its own words — it never renders as a pass.

Findings print as one table across every axis, ranked worst first on a single Critical / High / Medium / Low scale shared with the binary axis, with a GROUP column naming the axis each row came from. Above it sits one coverage line per axis — that is where the (15 checked) number lives, which a findings table cannot carry.

The table deliberately drops the why and the fix; both are kept in full in the saved report under ~/.fettle/reports/, the same split the binary axis already makes between its on-screen ranking and its full matrix. It also truncates an over-long subject in the middle, keeping both ends, so two units that differ only in a UUID stay distinguishable — the untruncated name is in the report. Width follows the terminal up to 120 columns, and is fixed at 80 when output is piped so a saved log does not change shape with the window that produced it. The HTML dashboard shows every axis finding with its fix, and counts them toward the host's verdict.

Binary axis — were these built hardened?

In plain terms: when a program is compiled it can be given built-in safety features — protections that don't change what it does, but that make a bug much harder for an attacker to turn into a break-in. Your distro publishes a "building code" of features every program it ships should have. fettle -H is the building inspector: it walks every installed program and lists the ones built without the safety features their neighbours all have — and which package they came from. Most findings are harmless; the ones that matter are high-privilege or network-facing programs missing a protection. It's a "why is this one different?" signal, not a "you've been hacked" alarm.

The rest of this section is the technical detail behind that.

fettle -H asks a supply-chain question the other checks don't: were the installed binaries actually built with the hardening the distro says it uses? It runs checksec over your executables and compares each against a baseline derived from the distro's own build policy — not a generic wishlist. On Arch that baseline is makepkg.conf plus GCC's compiled-in defaults (--enable-default-pie/--enable-default-ssp supply PIE and the stack canary, which makepkg.conf's CFLAGS never mention); on Debian/Ubuntu it's dpkg-buildflags. A deviation therefore means a package escaped the distro's build policy — an upstream Makefile clobbering CFLAGS, a vendored prebuilt binary, or a sloppy AUR build. Findings are rolled up per package and saved to ~/.fettle/reports/.

Scope: every ELF executable in the standard bin dirs plus every setuid/setgid binary (paths are realpath-deduped so a merged-/usr layout isn't scanned twice). It needs no root. It's opt-in (not in the default -a set) because the list is long and mostly informational — the signal is the outlier (a setuid or network-facing binary missing RELRO/canary), not the bulk.

What it can and can't see. checksec infers hardening from ELF structure, so: detectable = PIE, NX, RELRO (full/partial), stack canary, _FORTIFY_SOURCE presence, CET/IBT, RPATH/RUNPATH. Not detectable = -fstack-clash-protection, the FORTIFY level (2 vs 3), -Werror=format-security. Four accuracy corrections are always applied (they fix wrong data, and are not user-tunable): non-ELF files are skipped (checksec otherwise "fails" every check on a shell/Perl script); static Go/Rust binaries are skipped (symbol-based checks are meaningless there); _FORTIFY_SOURCE=No is ignored when nothing was fortifiable; and stack_clash is never treated as pass/fail (its "No Probes" just means the binary needed none).

Reading the output. Results are scored and ranked, worst first. The on-screen table shows only the Critical and High packages (the ones worth acting on); Medium/Low collapse into a one-line tally and the full per-criterion matrix is written to ~/.fettle/reports/.

BAND      SCORE  P  PACKAGE           BINS  MISSING (worst-weighted first)
Critical     18  !  xorg-server          2  canary=2, relro=2
High         10     containerd           3  canary=2, relro=3, fortify_source=3, pie=3
High          9  !  xf86-video-intel     2  relro=2
… plus 131 Medium, 95 Low package(s) — full list in the saved matrix
✓ 1 Critical, 6 High, 131 Medium, 95 Low  (813 deviations across 233 packages)

Each row is a package; a package not listed conforms fully. BINS is how many of its binaries deviate, MISSING names the absent protections (heaviest-weighted first, with counts), and P = ! marks a privilege boundary — a setuid/setgid binary or one in your sensitive_packages list.

The score is Σ weight(missing protection) × privilege-multiplier, computed per binary; a package takes its worst binary's score. Defaults: canary 3, relro 3, pie 2, fortify 2, cfi 1, rpath 1, runpath 0.5; ×3 when privileged. Bands: Critical ≥ 14 · High ≥ 8 · Medium ≥ 3 · Low < 3. Because the score already folds in how bad the missing protection is and whether the binary is privileged, the ranking does your triage for you — the Critical/High rows are the outliers that matter, not the bulk. What each protection defends, heaviest first:

Criterion Good value Protects against Missing means
canary Canary Found stack buffer overflows no tripwire before the return address — a classic stack smash is easier
relro Full RELRO GOT-overwrite attacks function-pointer tables stay writable (a common exploit primitive)
pie PIE Enabled predictable code addresses loads at a fixed address, weakening ASLR (ROP is easier)
fortify_source Yes unsafe libc calls (strcpy…) no compile-time bounds checks on those wrappers
cfi SHSTK & IBT ROP/JOP hijacking no hardware shadow-stack / indirect-branch tracking
nx NX enabled code injection a writable memory page could also be executable
rpath / runpath No RPATH malicious library loading a baked-in library search path an attacker could plant a .so in

Tuning. Everything below ships with sensible defaults; add a [hardening] block to your config to adjust. Exclude lists (globs) prune the report; the scoring keys re-weight it. sensitive_packages is how you tell fettle a network daemon is a privilege boundary (setuid/setgid is detected automatically):

[hardening]
# prune — fettle reports how many findings your excludes hid
exclude_checks     = ["runpath", "cfi"]                  # criteria you don't care about
exclude_packages   = ["mingw-w64-*", "*-linux-gnu-gcc"]  # e.g. cross-compilers
exclude_paths      = ["/usr/lib/electron*/*"]
# score — all optional
sensitive_packages = ["openssh", "nginx", "cups", "avahi"]  # treat as privilege boundaries
priv_multiplier    = 3
weights            = { canary = 3, relro = 3, pie = 2, fortify_source = 2 }

A deviation means the binary was built differently from the distro norm — the score tells you where to look, not that anything is exploitable.

Filesystem axis — can a local user tamper with shared directories?

In plain terms: /tmp is a shared room everyone can write in. A single mode bit (the sticky bit) is what stops one person deleting another person's things there. This axis checks that bit is set, and checks whether the filesystems that hold shared directories are mounted with the restrictions that keep a dropped file from becoming a running program.

Two questions, both answered from stat and /proc/mounts, with no directory walk:

  1. Is a world-writable directory missing its sticky bit? Without it any local user can delete or rename anyone's files there, regardless of ownership. This is the check that finds real bugs on real machines.
  2. Does a separate filesystem lack nosuid / noexec / nodev? Only asked where the path is its own mount — you can't set mount options on a directory that lives inside /, so on a single-filesystem host the honest answer is "not applicable", and fettle says that in one line rather than reporting four phantom defects.

Checked by default: /, /tmp, /var/tmp, /dev/shm, /home, /var, /boot. Absent paths aren't findings — most containers have no /boot.

The severities are deliberately not flat: a missing nosuid (a setuid binary would keep its privileges) and a missing noexec (a payload can be run where it landed) are Medium; a missing nodev is Low, because planting a device node already needs the root you'd have to have to mount the filesystem in the first place.

[hardening]
filesystem_paths = ["/srv", "/data"]   # ADDED to the built-in list, not a replacement
exclude_paths    = ["/var"]            # …and this is how you drop one

filesystem_paths is additive on purpose: asking fettle to also watch /srv should never silently stop it watching /tmp.

This axis deliberately does not walk the filesystem looking for world-writable files. Lynis's equivalent test does, and on the machine this was written against it took 19 seconds and earned its own "long execution" warning inside its own findings list. Everything here runs in well under a second.

Services axis — how much can each running service reach?

In plain terms: systemd can put a fence around a service — no access to your home directory, no raw network, not running as root. systemd-analyze security scores how low that fence is, 0–10. This axis reads those scores, but it is careful about what it calls a problem.

A high score is not a defect, and this is the whole design. sshd scores 9.6 "UNSAFE" on a perfectly healthy machine — it runs as root and opens a listening socket because that is its job. So do docker, libvirtd, gdm. A check that reports eighteen unsafe services on a working desktop has told you nothing you can act on. So fettle reports two different things:

  • Findings — a service at high exposure whose unit file is owned by no package. That is a fact rather than an opinion: something outside your package manager installed a service, no packaging review ever looked at it, and it runs with wide access. Each finding names the specific directives it leaves unset ("has access to the host's network; runs as root user") rather than just quoting a number.
  • Review material — the worst running-or-enabled units with their owning package, written to the saved report, not the screen.

Only running or enabled units are considered — a unit that exists but never starts is not exposure. On the reference machine that is 37 units rather than the 67 Lynis printed, and 18 unsafe rather than 42.

On a host with no systemd this axis reports not applicable (it isn't blindness — there are no unit files to be exposed). Where systemd is installed but isn't the running init, as in most containers, it reports that it could not look, and says why.

Kernel axis — are the runtime protections switched on?

In plain terms: the kernel has a set of switches that make a bug harder to exploit — randomised memory layout, hiding kernel addresses from ordinary users, refusing symlink tricks in shared directories. This reads them straight out of /proc/sys (no sysctl binary, so it works inside the remote zipapp on a host with nothing installed).

The list is deliberately short, and that is the feature. Lynis compares 38 sysctls against a fixed profile. On the reference machine two of its "deviations" were requirements: net.ipv4.conf.all.forwarding must be 1 on a host running libvirt and Docker, and kernel.modules_disabled=1 would leave a workstation unable to load a module for newly attached hardware. fettle judges only keys whose right value doesn't depend on what the machine is for — and the saved report names the ones it is declining to judge, with the reason, so the scope is explicit rather than hidden.

It also allows more than one right answer where there is one. fs.suid_dumpable is safe at 0 (no dumps from setuid processes) and at 2 (dumps readable only by root); only 1 exposes them. Lynis wants 0 and reports 2 as a deviation, which is a preference reported as a defect.

ICMP redirects are computed, not read. Accepting one lets anything that can reach the host rewrite its route to a destination — a local man-in-the-middle. Whether one is actually accepted is not the conf/all value everyone reads, and the two address families don't even follow the same rule (from the kernel's own ip-sysctl docs):

  • IPv4 — accepted if both conf/all and conf/<iface> are set when that interface forwards, or if either is set when it doesn't.
  • IPv6 — accepted if local forwarding is disabled, ignored if it's enabled.

So fettle walks the interfaces and applies the real rule. On the reference machine that changed the answer in both directions: no IPv4 interface was accepting redirects (Lynis flagged conf.default, which only templates interfaces created later), while ten IPv6 interfaces were — a live exposure it reported only as one generic "differs from profile" line.

Settings the kernel doesn't have (yama.ptrace_scope without the Yama LSM, or a container's masked /proc/sys) collapse into a single line rather than a dozen findings about knobs that were never offered.

SSH axis — what the server is actually configured to do

In plain terms: most of an SSH server's settings are never written in its config file — they take an out-of-the-box default. Asking "is PermitRootLogin in the file?" therefore tells you nothing. This asks the running server what it will actually do.

This is the clearest case in the whole comparison. On the reference machine Lynis prints twenty-two lines like:

- OpenSSH option: PermitRootLogin      [ NOT FOUND ]
- OpenSSH option: PermitEmptyPasswords [ NOT FOUND ]

…on a host whose sshd_config is three non-comment lines, so everything is at an OpenSSH default — and modern OpenSSH defaults are fine (PermitRootLogin prohibit-password, PermitEmptyPasswords no, HostbasedAuthentication no). Twenty-two finding-shaped lines that say nothing about actual exposure, because the question was asked of the file rather than of the server.

fettle parses sshd -T — the effective configuration with defaults filled in — and reports only what is genuinely weak. On a stock install that means two low notes and nothing else. The worst case is treated as a combination rather than two separate lines: PermitRootLogin yes with password authentication is one high finding, because the account that matters most becomes reachable by guessing.

The weak-algorithm lists are deliberately not a modern-crypto wishlist. hmac-sha1 and aes256-ctr are shipped OpenSSH defaults, so listing them would fire on every unmodified host; only algorithms that are not defaults — 3des-cbc, arcfour, ssh-dss, diffie-hellman-group1-sha1 — are reported, since their presence means someone deliberately re-enabled them for a legacy peer.

sshd -T needs root (it loads the host keys). Unprivileged, this axis reports that it could not look, and says why. It deliberately does not fall back to parsing the config file against a built-in table of defaults: that table drifts with every OpenSSH release, and being confidently wrong about PermitRootLogin is worse than saying nothing. No SSH server installed reports not applicable — there is no exposure to have. An installed-but-stopped sshd is still audited, with a note saying it is not live.

Firewall axis — active and actually filtering?

In plain terms: a firewall that is switched on but has no rules loaded blocks nothing at all — and on every dashboard it still shows up as "active". This asks both halves.

  1. Which management service is running? systemctl is-active — rootless, always answerable.
  2. Are there packet-filter rules in the kernel? nft list ruleset, falling back to iptables -S. This needs root. On the reference machine nothing here is readable unprivileged — nft, iptables, ufw and firewall-cmd all refuse.

So an unprivileged -H reports which service is running and says plainly that it could not verify the rules — less than a full answer, more than a lie, and strictly more than [ACTIVE]. Permission-denied is never mistaken for an empty ruleset; those produce the same empty output, and one of the two is a serious finding.

Rules outrank the absence of a service. Docker and libvirt program netfilter directly, and plenty of hosts load rules from a script with no firewalld.service anywhere — so a full ruleset with no managing service is reported as filtering, not as "no firewall". An -P INPUT ACCEPT default policy is counted as the absence of filtering (it is what "allow everything" looks like written down); -P INPUT DROP is counted as filtering.

Certificates axis — expired, or about to be?

In plain terms: if the certificate your web server hands out expires, everything that connects to it starts failing. This checks the certificates this host presents — and deliberately ignores the list of certificate authorities it trusts.

The CA trust store is excluded on purpose, and that's the whole design. /etc/ssl/certs on the reference machine is 121 root CAs symlinked out of the ca-certificates bundle. Some expire; that's normal, no local action fixes it, and update-ca-certificates already handles it. An axis that walked that directory would bury the certificates that matter under dozens of findings nobody can act on. If you add a trust-store path to the config it's refused, and the run says so — never silently, or you'd believe a directory was being watched when it isn't.

Scanned by default: /etc/letsencrypt/live, /etc/nginx, /etc/httpd, /etc/apache2, /etc/pki/tls/{certs,private}, /etc/ssl/private, /etc/dovecot, /etc/postfix, /etc/openvpn — three levels deep, capped, and only files that look like certificates (so openssl never runs on httpd.conf).

Expired is high; expiring within certificate_warn_days (default 30) is medium. A file that isn't a certificate is skipped silently; a file that can't be read — private-key directories need root — is reported as blindness, because "could not open" must never become "fine".

[hardening]
certificate_paths     = ["/srv/certs"]   # additive; trust stores are refused
certificate_warn_days = 45

Needs openssl — X.509 can't be parsed with the standard library, and fettle has no runtime dependencies to reach for. Without it, the axis reports that it could not look. On a workstation with no service certificates it reports not applicable, which is a different statement from "they're all fine".

Clone this wiki locally