[1.19.0] — hardening-audit learns about mandatory access control
Three versions since 1.16.0, and they are one piece of work. hardening-audit
answered six questions and said nothing about the mechanism that is supposed to contain a
compromised service. It now answers nine, including AppArmor on Debian and Ubuntu and
SELinux on the RHEL family.
| version | what changed |
|---|---|
| 1.17.0 | -H elevates, because the policy is root-only. --user opts out. |
| 1.18.0 | AppArmor axis. |
| 1.19.0 | SELinux axis. |
The two systems are not in the same state in the field
This is the part I did not expect, and it is why the two axes ask different questions.
Measured on five untuned installs, none of them tuned by hand:
| host | mandatory access control | processes actually confined |
|---|---|---|
| Debian 13 | AppArmor, 106 profiles loaded | 0 of 19 |
| Ubuntu 26.04 | AppArmor, 173 profiles loaded | 2 of 20 |
| AlmaLinux 9.8 | SELinux, enforcing | 141 of 147 |
| Rocky Linux 9.8 | SELinux, enforcing | 140 of 146 |
| Fedora 44 | SELinux, enforcing | 150 of 154 |
A stock Debian 13 loads 106 AppArmor profiles and confines nothing that is running.
sshd, systemd-resolved, systemd-networkd, ModemManager and udisksd are all
unconfined. A stock AlmaLinux 9 confines everything except the login session.
So the AppArmor axis reports whether anything is confined, and the SELinux axis reports
what mode the machine is in and whether it agrees with itself.
AppArmor: enabled is not the same as confining
The three profile modes are never added together. enforce applies a policy, complain
logs and applies nothing, and unconfined applies nothing either. That last group is
between 74 and 79 profiles per host and is not a defect: distros ship them so applications
keep working after Ubuntu set kernel.apparmor_restrict_unprivileged_userns=1.
/etc/apparmor.d/brave says so itself, and Ubuntu's documentation calls such a profile a
trivial bypass rather than a restriction.
One finding, at Low: AppArmor is enabled and no running process is confined by it, naming
apparmor-profiles and apparmor-profiles-extra when they are absent. It fires on a
stock Debian 13, deliberately, because that is the real state and there is something
specific to do about it.
SELinux: what mode, and does the machine agree with itself
"Enabled or disabled" has five answers on RHEL 9. The one worth building for is that Red
Hat deprecated SELINUX=disabled in /etc/selinux/config: the system then starts with
SELinux enabled and no policy loaded, and selinux=0 on the kernel command line is
the documented way to actually disable it. An admin who edited that file believes SELinux
is off. It is on, with no policy, and both the file and getenforce agree with the
mistaken belief.
Four findings, each with a measured floor of zero, covering not-enforcing, runtime
disagreeing with the configured mode, that RHEL 9 config trap, and booleans that differ
from the policy default.
Four rules were dropped because the numbers said so
- AppArmor profile files on disk that are not loaded. Filenames and profile names are
different namespaces. 151 files produce 168 profiles, 13 of them children like
zgrep//sed, and only 106 filenames match a profile name. The naive difference reports
45 non-problems. - AppArmor complain mode. The floor swings from 2 to 23 across three ordinary hosts, 16
of Debian's 23 come fromsbuildalone, and theapparmorpackage owns every one of
those files. - A curated list of dangerous SELinux booleans. Four high-risk-sounding booleans are
on by default on every host tested (selinuxuser_execstack,unconfined_login,
httpd_enable_cgi,nfs_export_all_rw), and two more on Fedora. - Flagging SELinux booleans that are off.
httpd_can_network_connectand
samba_export_all_rware off everywhere and are exactly what you enable to run a
reverse proxy or a Samba share, so flagging them reports what the machine needs in order
to work.
What survives measurement is narrow and quiet. An AppArmor process running unconfined
while a profile exists for that exact executable measured 0 on all three hosts, and
SELinux booleans differing from the policy default measured 0 on all three.
-H needs root now
The AppArmor policy is root-only. Unprivileged, aa-status prints "You do not have enough
privilege to read the profile set" and exits 0, and
/sys/kernel/security/apparmor/profiles is mode 0444 and still returns EACCES because
securityfs enforces past the mode bits. So the unprivileged view is the reassuring half of
the answer.
--user keeps the old behaviour, applies to read-only audits only, and refuses when
combined with an action that changes the system. Verified on Debian 13: identical findings
either way, 28 s as root against 27 s with --user.
SELinux is the opposite and needs almost no privilege, so that axis gives a full answer
under --user apart from the boolean comparison, which needs semanage from
policycoreutils-python-utils, a package stock AlmaLinux 9 does not install.
Verified before release
Nine live runs across five guests, plus a full lab matrix of 13 actions across 6 guests
reverting between runs: 71 pass, 7 issue, 0 FAIL, 0 skip. Every issue was examined and
none is a defect. Five are aur-audit correctly declining on non-Arch targets, and two are
Ubuntu hitting the apt lists lock held by apt-daily, which fettle reported truthfully and
which pass on a re-run.
One path is not verified on a machine, and this says so rather than implying otherwise:
the RHEL 9 SELINUX=disabled state. Creating it needs a config edit and a reboot, and so
does recovering, so it rests on unit tests and Red Hat's documentation.
SELinux is in far better shape than AppArmor, so this axis asks a different question.
Measured on three untuned guests:
| host | mode | confined processes |
|---|---|---|
| AlmaLinux 9.8 | enforcing | 141 of 147 |
| Rocky Linux 9.8 | enforcing | 140 of 146 |
| Fedora 44 | enforcing | 150 of 154 |
No daemon runs unconfined on a stock EL9 install. The unconfined processes are the login
session and nothing else. Coverage is not the question here; mode and configuration drift
are.
Enabled or disabled has five answers on RHEL 9
| 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 |
The fourth is why this axis is worth having. Red Hat deprecated disabling SELinux through
SELINUX=disabled: on RHEL 9 the system then starts with SELinux enabled and no policy
loaded, and selinux=0 on the kernel command line is the documented way to actually
disable it. An admin who edited that file believes SELinux is off. It is on, with no
policy, and both the config file and getenforce agree with the mistaken belief.
Four findings, each with a measured floor of zero
selinux-not-enforcing at Medium covers permissive, disabled at boot and no-policy, with
the wording naming which. selinux-mode-mismatch at Low catches a setenforce that will
not survive a reboot. selinux-config-disabled-el9 at Low catches the state above.
selinux-boolean-changed at Low lists booleans that differ from the policy default, which
measured 0 differences on all three hosts.
The boolean rules that measurement rejected
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. A curated "dangerous booleans" list would fire four times on a stock
EL9 host.
The ones that are off are no better. httpd_can_network_connect and samba_export_all_rw
are legitimately switched on by anyone running a reverse proxy or a Samba share, and
flagging those reports what the machine needs in order to work. The kernel-sysctl axis
already refuses that on principle. Booleans are counted, never judged individually.
semanage boolean -l is the exception and it is not always available: it needs root and
policycoreutils-python-utils, which a stock AlmaLinux 9 does not install. Its absence is
reported as blindness, never as "no booleans were changed".
Verified on five hosts
| host | result |
|---|---|
| AlmaLinux 9.8, permissive (temporarily) | 1 Medium, 1 Low — permissive, and running mode disagreeing with the config |
| AlmaLinux 9.8, enforcing | nothing to report, plus semanage blindness |
| Rocky 9.8 | nothing to report, semanage present so no blindness |
| Fedora 44 | nothing to report, 150 of 154 confined, policy version 35 |
| Debian 13 | not applicable, covered by the apparmor axis |
The permissive run was done by temporarily setting setenforce 0 on the AlmaLinux guest
and reverting in the same operation. The RHEL 9 config-file state was not created on a
machine, because recovering from it needs a config edit and a reboot; that finding is
covered by unit tests against synthetic inputs and by Red Hat's documentation.
The AppArmor axis's not-applicable wording now points at this axis instead of saying
SELinux "is not used here".
What to download
| file | for |
|---|---|
fettle_<version>_all.deb |
Debian, Ubuntu, and derivatives |
fettle-<version>-1.noarch.rpm |
RHEL, Rocky, AlmaLinux, Fedora |
fettle-<version>-1-any.pkg.tar.zst |
Arch, Manjaro, EndeavourOS |
fettle-<version>-zipapp.tar.gz / .zip |
anything else — runs on any Linux with python 3.11+ |
fettle.pyz |
the zipapp on its own, if you do not want the archive |
fettle is pure standard library: it needs python 3.11 or newer and nothing else.
On RHEL/Rocky/Alma 9 and Ubuntu 22.04 the system python3 is older than that, so the
packages pull in a suitable interpreter and the launcher finds it for you.
Verifying what you downloaded
sha256sum -c SHA256SUMS --ignore-missing