-
Notifications
You must be signed in to change notification settings - Fork 1
System 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 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 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.
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.
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:
- 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.
-
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 onefilesystem_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.
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.
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/allandconf/<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.
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.
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.
-
Which management service is running?
systemctl is-active— rootless, always answerable. -
Are there packet-filter rules in the kernel?
nft list ruleset, falling back toiptables -S. This needs root. On the reference machine nothing here is readable unprivileged — nft, iptables, ufw andfirewall-cmdall 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.
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 = 45Needs 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".