Skip to content

fettle 1.19.0

Latest

Choose a tag to compare

@github-actions github-actions released this 27 Aug 18:19
· 9 commits to main since this release

[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 from sbuild alone, and the apparmor package 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_connect and
    samba_export_all_rw are 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