When a Fedora box is about to face a real network, the defaults get you surprisingly far, but they do not make it hardened. SELinux is enforcing, Secure Boot puts the kernel in lockdown and every package signature is checked, yet a fresh Fedora 45 Cloud VM still scored 75.88 out of 100 on the CIS Level 1 Server benchmark in OpenSCAP. A Fedora 44 VM scored exactly the same. The steps in this guide took them to 89.10 and 86.87.
If you’re working from an older Fedora hardening guide, check four of its steps first. Re-running the previous version of this one, its SSH drop-in was silently overridden, its audit rules logged nothing, chrony preferred the DHCP time server over its NTS servers and its sudo logging killed piped commands. You’ll find each one fixed below with the output that proves it, for workstations and servers alike. This update re-tests every step on Fedora 45 and Fedora 44 in October 2026, on two lab VMs with SELinux enforcing throughout.
Choosing a posture: workstation, server or compliance baseline
What the machine does decides what you harden, so settle that before you touch a config file. A laptop that joins hotel Wi-Fi needs MAC randomization and a sandboxed browser, and it breaks if you mask the services GNOME relies on. A headless server needs none of the desktop work, but you’ll want it measured against a benchmark. If an auditor will read the result, the benchmark profile decides, and your opinions about defaults come second.
| Posture | Use it for | What it rules out |
|---|---|---|
| Workstation | Daily-driver laptop, developer desktop | Masking cups if you print, noexec on /home if you use Flatpak, requiretty in sudo |
| Server | Headless host, reverse proxy, database, container host | MAC randomization, browser sandboxing, a desktop secret service |
| Compliance baseline | Anything that must pass CIS, PCI-DSS or OSPP | Hand-picked deviations without a written exception |
Most small teams end up with a mix, usually server defaults plus a monthly compliance scan, or a workstation that borrows faillock, auditd and AIDE from the server column. Every section below says which posture it’s for. Measurement comes first for everyone, because without a baseline you can’t tell a change that worked from one that silently didn’t, and this guide has four of those.
Measuring the baseline with OpenSCAP and Lynis
Three tools cover the measurement, and each one catches something the others miss:
- OpenSCAP: a score against a CIS, PCI-DSS or OSPP profile
- Lynis: a general hardening index with suggestions
systemd-analyze security: an exposure score per service
The Cloud image ships none of the hardening tooling, and it doesn’t ship firewalld either, so a fresh VM accepts anything that’s listening. Install everything the rest of this guide uses in one transaction:
sudo dnf5 install -y lynis aide audit fail2ban firewalld \
openscap-scanner scap-security-guide \
policycoreutils-python-utils setroubleshoot-server usbguard
The scap-security-guide package carries the Fedora datastream and its profiles. Listing them is worth the few seconds, because the profile ID is what every later command needs:
sudo oscap info /usr/share/xml/scap/ssg/content/ssg-fedora-ds.xml | grep -E "Title|^\s+Id:"
Both releases list the same eight profiles. The four CIS ones are still marked DRAFT upstream, which matters if an auditor asks for a final benchmark rather than a working one.
Title: DRAFT - CIS Fedora Benchmark for Level 2 - Server
Id: xccdf_org.ssgproject.content_profile_cis
Title: DRAFT - CIS Fedora Benchmark for Level 1 - Server
Id: xccdf_org.ssgproject.content_profile_cis_server_l1
Title: DRAFT - CIS Fedora Benchmark for Level 1 - Workstation
Id: xccdf_org.ssgproject.content_profile_cis_workstation_l1
Title: DRAFT - CIS Fedora Benchmark for Level 2 - Workstation
Id: xccdf_org.ssgproject.content_profile_cis_workstation_l2
Title: CUSP - Common User Security Profile for Fedora Workstation
Id: xccdf_org.ssgproject.content_profile_cusp_fedora
Title: OSPP - Protection Profile for General Purpose Operating Systems
Id: xccdf_org.ssgproject.content_profile_ospp
Title: PCI-DSS v3.2.1 Control Baseline for Fedora
Id: xccdf_org.ssgproject.content_profile_pci-dss
Title: Standard System Security Profile for Fedora
Id: xccdf_org.ssgproject.content_profile_standard
For a server, scan with cis_server_l1; for a laptop, use cis_workstation_l1 or cusp_fedora. Write both the HTML report and the XML results. The report is what you read, and the XML is what you’ll grep later.
sudo oscap xccdf eval \
--profile xccdf_org.ssgproject.content_profile_cis_server_l1 \
--report /var/tmp/oscap-baseline.html \
--results /var/tmp/oscap-baseline.xml \
/usr/share/xml/scap/ssg/content/ssg-fedora-ds.xml > /dev/null
The scan took about 20 seconds on a 2 vCPU VM. It exits with status 2 whenever any rule fails, so do not treat that exit code as a crash in a script. Pull the score and the counts out of the XML.
sudo grep -oE "<score [^>]*>[^<]+</score>" /var/tmp/oscap-baseline.xml
sudo grep -oE "<result>(pass|fail|notapplicable)</result>" /var/tmp/oscap-baseline.xml | sort | uniq -c
Our Fedora 45 VM, shown here, and the Fedora 44 VM produced identical numbers: 75.88, with 175 rules passing, 121 failing and 28 not applicable. Lynis disagreed between them, rating Fedora 45 at 71 and Fedora 44 at 68.

That three-point Lynis lead is not real. On Fedora 45, sshd -T prints its keys in CamelCase (MaxAuthTries 6), while Lynis looks for the lowercase form Fedora 44 still prints (maxauthtries 6). So its SSH test finds nothing to grade on Fedora 45, drops all nine SSH suggestions and inflates the index. You can see it in the Lynis log.
sudo grep -A4 "Performing test ID SSH-7408" /var/log/lynis.log
Every SSH option comes back as “not found” on Fedora 45, even the ones sshd is clearly using:
Performing test ID SSH-7408 (Check SSH specific defined options)
Test: Checking specific defined options in /tmp/lynis.wcAb0Z2uD0
Test: Checking AllowTcpForwarding in /tmp/lynis.wcAb0Z2uD0
Result: Option AllowTcpForwarding not found in output
Test: Checking ClientAliveCountMax in /tmp/lynis.wcAb0Z2uD0
Until Lynis learns the new format, judge Fedora 45 SSH settings with sshd -T and OpenSCAP, and treat its Lynis index as a couple of points generous. For per-service exposure, run systemd-analyze security --no-pager. On the stock image sshd, auditd and the console units sit between 9.4 and 9.6 (UNSAFE), and only chronyd scores OK, so there’s plenty of room to improve the services you keep.
Knowing what Fedora 45 changed for hardening
Most commands in this guide are identical on both releases, but a handful of defaults moved in Fedora 45, and several of them change what a hardening step has to do. We ran the two VMs side by side, and these are the differences that showed up:
| Area | Fedora 45 | Fedora 44 |
|---|---|---|
kernel.yama.ptrace_scope default | 1, from the kernel build; no sysctl file sets it | 0, set by /usr/lib/sysctl.d/10-default-yama-scope.conf |
| Package that resets ptrace to 0 | yama-ptrace-enable, a weak dependency of strace and similar tools, removable on its own | elfutils-default-yama-scope, required by elfutils-libs; removing it takes 36 packages with it |
| Repo files and keys | /usr/share/dnf5/repos.d/ and /usr/share/pki/rpm-gpg/; /etc/yum.repos.d/ is empty | /etc/yum.repos.d/ and /etc/pki/rpm-gpg/ |
| RPM signature policy | %_pkgverify_level all | %_pkgverify_level digest |
| Local console | kmsconvt@tty1 replaces getty@tty1 | getty@tty1 |
| Desktop secret service | oo7-daemon and pam_oo7; gnome-keyring isn’t installed | gnome-keyring and gnome-keyring-pam |
| GDM PAM files | /usr/lib/pam.d/gdm-password | /etc/pam.d/gdm-password |
| AIDE | Ships aide-check.timer and reads /etc/aide.d/*.conf | No timer, no /etc/aide.d include |
| Journal sealing | Seal=yes works | systemd built without forward-secure sealing |
Two rows deserve a warning before you start. The ptrace row means a Fedora 45 box that installs strace or a debugger quietly gains a sysctl file setting ptrace_scope back to 0 at the next boot, so the drop-in further down is the only thing keeping it at 1. The repo row means an old audit script that runs grep gpgcheck /etc/yum.repos.d/* finds no files on Fedora 45 and reports nothing, which reads as success. Point those checks at dnf5 repo list and /usr/share/dnf5/repos.d/ instead, or they’ll keep passing while proving nothing.
Hardening authentication with authselect, faillock and pwquality
Fedora manages the PAM stack with authselect, and a fresh install sits on the local profile with only with-silent-lastlog enabled. You want faillock, password history and the rule that rejects empty passwords. Older advice switches to the sssd profile, but that only makes sense if the box joins FreeIPA or Active Directory, so for local accounts keep local.
sudo authselect select local with-faillock with-pwhistory without-nullok with-silent-lastlog --force
sudo authselect current
The profile and its four features are listed back. Do not hand-edit /etc/pam.d/system-auth after this, since it is a symlink authselect owns and authselect check reports any drift.
Profile ID: local
Enabled features:
- with-faillock
- with-pwhistory
- without-nullok
- with-silent-lastlog
The pam_faillock defaults are three failures within 15 minutes and a 10 minute lockout. That’s reasonable, but CIS wants a longer lockout, and some older guides describe the default as “15 attempts” and then set deny = 5, which actually loosens it. Open the faillock config:
sudo vim /etc/security/faillock.conf
Keep three attempts and stretch the lockout to 15 minutes. silent stops the prompt from revealing whether an account exists, and audit logs the attempted user name when the account doesn’t exist.
deny = 3
unlock_time = 900
fail_interval = 900
silent
audit
We tested it with a throwaway account on both releases. Four wrong passwords through su, then the correct one, which was refused because the account was already locked. sudo faillock --user alice listed three recorded failures, and the lock only clears by itself after 15 minutes, so when a colleague locks themselves out you’ll reset it by hand:
sudo faillock --user alice --reset
Password quality comes next, and this is where we hit the first scanner surprise. Setting the rules in a drop-in under /etc/security/pwquality.conf.d/ works for PAM, but OpenSCAP’s CIS checks only read the main file, so all seven password rules kept failing. Put the settings in the main file and PAM and the scanner agree.
sudo vim /etc/security/pwquality.conf
Append these lines at the end of the file. enforce_for_root matters most, because without it root can set any password it likes.
minlen = 14
minclass = 4
maxrepeat = 3
maxsequence = 3
difok = 8
dictcheck = 1
enforcing = 1
enforce_for_root
pwscore tests a candidate against the same rules without touching any account, so it’s the quickest way to confirm the file took effect:
echo "Summer2026x" | pwscore
A short password is refused with the reason, which is the same message a user sees from passwd.
Password quality check failed:
The password is shorter than 14 characters
Existing passwords are not re-checked, so the policy only bites at each user’s next change. Pair it with history, which with-pwhistory switched on, by telling PAM how many old passwords to remember:
sudo vim /etc/security/pwhistory.conf
Add the two settings below. As with pwquality, root is exempt unless you say otherwise.
remember = 24
enforce_for_root
With faillock, pwquality and history in place, the 14 lockout, password-quality and history rules that failed in the baseline all passed on the next scan. Two password rules still fail afterwards, and both check how authselect wires pam_unix rather than any setting you can change in these files.
Logging sudo sessions without breaking automation
Sudo logs the command line by default but not what the command printed or what was typed into it, and that’s the first thing you’ll want after an incident. Full I/O logging fixes it. Create the drop-in with visudo, so a typo is caught before it can lock every admin out of sudo.
sudo visudo -f /etc/sudoers.d/50-hardening
This is the set we kept after testing on both releases. Each line was tested over SSH, interactively and through pipes.
Defaults log_input, log_output
Defaults iolog_dir=/var/log/sudo-io/%{user}
Defaults logfile=/var/log/sudo.log
Defaults log_exit_status
Defaults use_pty
Defaults timestamp_timeout=5
The previous version of this guide also had log_subcmds, and that line is a trap. With log_subcmds and log_input both set, any sudo command that reads from a pipe over a non-interactive SSH session is killed before it writes anything. We reproduced it on Fedora 45 and Fedora 44: echo test | sudo tee file exits 137 (SIGKILL) or 129 (SIGHUP), and the file never appears. Neither directive breaks it alone, only the pair. That’s how Ansible and most deploy scripts drive sudo, so the failure shows up as a broken pipeline rather than a security event, and it’s easy to blame the wrong thing.

Leave requiretty out for a similar reason. We added it on the Fedora 45 VM and every ssh host 'sudo ...' call failed with “sudo: sorry, you must have a tty to run sudo”. With the drop-in above, every session lands under /var/log/sudo-io/, and sudo sudoreplay -d /var/log/sudo-io/$USER -l lists your sessions by host and command, ready to replay by their TSID with the same -d.
Restricting su and the root account
Fedora ships the pam_wheel line in /etc/pam.d/su commented out, so any user who knows another account’s password can su to it. Uncomment it so only wheel members can use su at all:
sudo sed -i 's/^#\(auth\s\+required\s\+pam_wheel.so use_uid\)/\1/' /etc/pam.d/su
grep -n pam_wheel /etc/pam.d/su
Line 7 should now be active while the trust line stays commented. Leave that one alone, because it would let wheel members switch user without a password.
5:#auth sufficient pam_wheel.so trust use_uid
7:auth required pam_wheel.so use_uid
The root password on both Cloud images is already locked, and sudo passwd -S root prints root L followed by the date. Keep it that way on servers where every admin has a sudo account. CIS will flag ensure_root_password_configured as a result, because the benchmark wants a root password for single-user rescue. On a VM with console access through the hypervisor, the locked password is the better trade, so we left that rule failing on purpose and wrote it into the exception list.
Tuning sysctl and the kernel command line
A single sysctl drop-in closes most of the kernel and network findings. Name it with a 99- prefix so it sorts after the vendor files in /usr/lib/sysctl.d/, because that ordering is what keeps ptrace_scope at 1 after a debugger package drops its reset file in. Create the file:
sudo vim /etc/sysctl.d/99-hardening.conf
This is the exact file we applied on both VMs. IP forwarding is deliberately missing, because Podman and Docker networking need it. The conf.*.rp_filter line is there because systemd’s 50-default.conf sets every existing interface to loose mode (2), and the kernel uses the higher of the all and per-interface values. Without it, our eth0 stayed at 2 while CIS happily passed the all check. Leave it out on a multi-homed router, where strict reverse-path filtering drops legitimate asymmetric traffic.
# Network
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.*.rp_filter = 1
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.default.secure_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_rfc1337 = 1
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv6.conf.default.accept_source_route = 0
# Kernel
kernel.randomize_va_space = 2
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.printk = 3 3 3 3
kernel.unprivileged_bpf_disabled = 1
net.core.bpf_jit_harden = 2
kernel.kexec_load_disabled = 1
kernel.yama.ptrace_scope = 1
kernel.sysrq = 4
kernel.perf_event_paranoid = 3
vm.unprivileged_userfaultfd = 0
# Filesystem
fs.protected_fifos = 2
fs.protected_regular = 2
fs.protected_symlinks = 1
fs.protected_hardlinks = 1
fs.suid_dumpable = 0
Load it, then read back the keys that change behaviour the most. A typo in a key name prints an error here instead of failing silently at boot.
sudo sysctl -p /etc/sysctl.d/99-hardening.conf > /dev/null
sudo sysctl kernel.kptr_restrict kernel.yama.ptrace_scope kernel.unprivileged_bpf_disabled kernel.kexec_load_disabled fs.protected_fifos
Both releases report the new values, and they survived the reboot we did later for the kernel arguments.
kernel.kptr_restrict = 2
kernel.yama.ptrace_scope = 1
kernel.unprivileged_bpf_disabled = 1
kernel.kexec_load_disabled = 1
fs.protected_fifos = 2
Some of these change how the machine behaves day to day, so know the side effects before the file goes on a workstation:
ptrace_scope = 1:strace -pandgdb -pon another session’s process need sudounprivileged_bpf_disabled = 1: already 2 on both releases; 1 can’t be undone until rebootkexec_load_disabled = 1: no kexec, and kdump can’t load its crash kernel, so drop it on hosts that use kdump
We confirmed the ptrace effect with strace against a sleep started in another session. The unprivileged attach failed with the message below, and the same command under sudo attached immediately, which is the behaviour you want on a shared box where one user shouldn’t read another’s process memory:
strace: attach: ptrace(PTRACE_SEIZE, 37063): Operation not permitted
strace: sysctl kernel.yama.ptrace_scope = 1 may be restricting this attach
Fedora 45 also lets you remove the package that tries to undo the setting, since nothing hard-depends on it, and that clears the matching CIS failure too. Do not try the equivalent on Fedora 44. elfutils-default-yama-scope is required by elfutils-libs, and removing it on our VM took 36 packages with it, including dnf’s Python bindings, polkit, setroubleshoot and strace, so we rolled it back with dnf5 history undo last.
sudo dnf5 remove -y yama-ptrace-enable
Debugging tools like strace keep working after the removal, since the package was only a weak dependency. They just need sudo to attach to a process you didn’t start.
Adding kernel command-line protections
Check lockdown before adding anything. With Secure Boot enabled, both Cloud images already booted in integrity lockdown with module signature enforcement on, so the old advice to add lockdown=integrity mostly matters on machines that boot with Secure Boot off.
cat /sys/kernel/security/lockdown
cat /sys/module/module/parameters/sig_enforce
Ours printed the active mode in brackets and a Y for signature enforcement. That is Secure Boot doing the work, and mokutil --sb-state reported SecureBoot enabled on both VMs.
none [integrity] confidentiality
Y
Add the memory-hardening arguments with grubby, and keep the two lockdown arguments in the list so the protection survives someone switching Secure Boot off. Fedora boots from BLS entries, which grubby edits directly, so the grub2-mkconfig step that older guides add here is not needed.
sudo grubby --update-kernel=ALL --args="slab_nomerge init_on_alloc=1 init_on_free=1 randomize_kstack_offset=on vsyscall=none debugfs=off lockdown=integrity module.sig_enforce=1"
sudo grubby --info=DEFAULT | grep ^args
Hold back on module.sig_enforce=1 and lockdown=integrity on a desktop with Secure Boot off that relies on akmod NVIDIA or VirtualBox modules. Unless those modules are signed with an enrolled MOK key, they won’t load, and the first sign is a missing GPU driver. Lockdown also disables hibernation. After a reboot, cat /proc/cmdline on both VMs ended with the new arguments, and every service we’d configured came back active. init_on_free=1 zeroes memory as it’s freed, which costs some performance on allocation-heavy workloads, so benchmark a database host before and after rather than assuming.
Physical access is the other gap. Without a GRUB password, anyone at the console can press e, add rd.break and reset root in a minute. grub2-setpassword prompts twice and needs a real terminal, so run it from an interactive session; over a non-interactive SSH command it fails with stty: 'standard input': Inappropriate ioctl for device.
sudo grub2-setpassword
It writes a PBKDF2 hash to /boot/grub2/user.cfg. Default boot entries still start unattended and only editing an entry asks for the password, which is the right trade for a server that reboots on its own after updates.
Locking down SSH with a drop-in that actually wins
If a Workstation install isn’t running sshd yet, enable the SSH server on Fedora first. Then look at the file order, because it decides which of your settings apply. OpenSSH uses the first value it reads for most keywords, and Fedora already ships three drop-ins in /etc/ssh/sshd_config.d/:
40-redhat-crypto-policies.conf: ciphers, MACs and key exchange from the crypto policy50-cloud-init.conf:PasswordAuthentication noon Cloud images50-redhat.conf:X11Forwarding yesand other distro defaults
A hardening file named 99-, as most guides suggest, is read last. Its cipher lines and its X11Forwarding no are then ignored without a warning, and sshd -t still reports the config as valid, so nothing tells you. We renamed the same file between 99- and 10- and compared sshd -T:

Name the file 10-hardening.conf so it’s read first. OpenSCAP’s own remediation does the same with 00-complianceascode-hardening.conf, for the same reason. Create it:
sudo vim /etc/ssh/sshd_config.d/10-hardening.conf
Keep mlkem768x25519-sha256 first in the key exchange list. It’s the post-quantum hybrid both releases offer by default, and the older list that started at curve25519 quietly dropped it, which is a step backwards for a “hardened” config.
PermitRootLogin no
PasswordAuthentication no
PermitEmptyPasswords no
KbdInteractiveAuthentication no
HostbasedAuthentication no
IgnoreRhosts yes
PermitUserEnvironment no
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
MaxAuthTries 3
MaxSessions 4
MaxStartups 10:30:60
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 1
LogLevel VERBOSE
Banner /etc/issue.net
KexAlgorithms mlkem768x25519-sha256,curve25519-sha256,[email protected]
Ciphers [email protected],[email protected],[email protected]
MACs [email protected],[email protected]
AllowGroups ssh-users
AllowGroups is the lockout risk in that file, together with PermitRootLogin no. Before this step you need a non-root account in wheel with a working SSH key; if you only log in as root (common on a fresh VPS), create that account first, because adding root to ssh-users won’t help. If sshd listens on a port other than 22, also allow it in firewalld below with --add-port. Create the group and add yourself before reloading, and keep your current session open until a fresh login works from a second terminal. If you do get locked out, the hypervisor or cloud console gets you back in, where you can delete the file and reload sshd.
sudo groupadd -f ssh-users
sudo usermod -aG ssh-users "$USER"
Write the banner the config refers to, and copy it to /etc/issue so the local console shows it as well. On Fedora 45 the console is kmscon rather than agetty, and it still prints /etc/issue before the login prompt because its --issue option defaults to on.
echo "WARNING: Authorized users only. All activity is logged." | sudo tee /etc/issue.net /etc/issue
Validate and reload, then confirm the settings that the 99- name used to lose:
sudo sshd -t && sudo systemctl reload sshd
sudo sshd -T | grep -iE "^(x11forwarding|kexalgorithms|maxauthtries|allowgroups) "
To prove AllowGroups works, we gave a second account a valid key but left it out of ssh-users. The login was refused and sshd logged why. That’s the line to search for when someone says their key “stopped working” after hardening:
sshd-session[38140]: User alice from 10.0.1.20 not allowed because none of user's groups are listed in AllowGroups
sshd-session[38140]: Connection closed by invalid user alice 10.0.1.20 port 59732 [preauth]
Notice the second line calls alice an “invalid user” even though the account exists. That’s how sshd labelled the user it refused through AllowGroups, so don’t go hunting for a missing account.
Keeping SELinux enforcing
SELinux does more for a Fedora box than any other single control, and it is the one people switch off first. Do not. Every denial in this guide, including the USBGuard one further down, has a targeted fix, and the SELinux survival guide for Fedora covers the full sealert, semanage and restorecon workflow. Confirm the mode on every box you touch:
sestatus | grep -E "Current mode|Mode from config"
Both lines must read enforcing; if the config line says permissive, the next reboot undoes your work. These booleans are worth knowing on a hardened server, and the defaults below are what both releases shipped:
| Boolean | Default | What it does |
|---|---|---|
deny_ptrace | off | Blocks ptrace for all confined domains, a stronger complement to ptrace_scope |
deny_execmem | off | Blocks writable-and-executable memory for confined processes |
secure_mode_insmod | off | Blocks loading any more kernel modules, so anything that loads one on demand later fails |
selinuxuser_execmod | on | Lets confined users run code that needs text relocation |
httpd_can_network_connect_db | off | Must be on for a web app talking to a database on another host |
Change them with sudo setsebool -P <name> on, and test the blocking ones on a staging box first, since a denial there shows up as an application that just stops working. Without -P the change is gone after a reboot.
Building the firewall with firewalld and fail2ban
The Fedora Cloud image does not ship firewalld at all, so a fresh VM accepts anything that’s listening, while Workstation and Server editions have it running on their own default zone. Either way, write the rules offline first and only then start the daemon. That order means you never sit behind a drop zone that doesn’t allow SSH yet.
sudo firewall-offline-cmd --set-default-zone=drop
sudo firewall-offline-cmd --zone=drop --add-service=ssh
sudo systemctl enable --now firewalld
sudo firewall-cmd --list-all
The drop zone becomes the default with only SSH allowed, and anything else is dropped without a reply:
drop (default, active)
target: DROP
ingress-priority: 0
egress-priority: 0
icmp-block-inversion: no
interfaces: eth0
sources:
services: ssh
ports:
On a box where firewalld is already running, make the same changes with firewall-cmd --permanent; the firewalld guide for Fedora covers zones and rich rules in depth. The next part is where the old guide went wrong. It added an SSH rate limit as a rich rule on top of the ssh service, and we measured what that does: ten connections in a row from one address, all ten accepted. The plain service rule accepts the packet before the rate limit is ever consulted, so the limit is decoration.
The limit only works once the service is removed, and then it applies to you as well. Five connections a minute will stall an Ansible run, so exempt your admin network with its own rule first. Replace 10.0.1.0/24 with the subnet you manage from:
sudo firewall-cmd --permanent --zone=drop --add-rich-rule='rule family="ipv4" source address="10.0.1.0/24" service name="ssh" accept'
sudo firewall-cmd --permanent --zone=drop --add-rich-rule='rule service name="ssh" accept limit value="5/m"'
sudo firewall-cmd --permanent --zone=drop --remove-service=ssh
sudo firewall-cmd --reload
sudo firewall-cmd --zone=drop --list-rich-rules
With the service removed and only the limited rule left, the same ten-connection test got five through. Once the admin rule was added, connections from the admin subnet got ten out of ten again. All three changes are --permanent, so nothing takes effect until the reload; just don’t reload halfway through, or your own new connections are capped at five a minute too.
Banning repeat offenders with fail2ban
fail2ban reads the journal and bans addresses through firewalld rich rules, and Fedora’s package already sets banaction = firewallcmd-rich-rules. Its stock filter also matches the sshd-session process that newer OpenSSH logs from, so it works on both releases without a custom filter. Create a jail:
sudo vim /etc/fail2ban/jail.d/sshd.local
Put your admin subnet in ignoreip so a mistyped key during maintenance doesn’t ban you. With bantime.increment on, each repeat ban for the same address doubles, and bantime.factor = 4 scales the whole series by four, which wears down a persistent scanner without permanent bans you’d have to clean up.
[sshd]
enabled = true
backend = systemd
maxretry = 3
findtime = 10m
bantime = 1h
bantime.increment = true
bantime.factor = 4
ignoreip = 127.0.0.1/8 ::1 10.0.1.0/24
Start it and check the jail. The status output is also where you will look first when someone reports being blocked.
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd
We triggered a real ban by trying the refused account four times from the other lab VM, after narrowing ignoreip for the test. The jail counted the four failures and banned the source, which proves the journal match works with the split sshd-session process:
Status for the jail: sshd
|- Filter
| |- Currently failed: 1
| |- Total failed: 4
| `- Journal matches: _SYSTEMD_UNIT=sshd.service + _COMM=sshd + _COMM=sshd-session
`- Actions
|- Currently banned: 1
|- Total banned: 2
`- Banned IP list: 10.0.1.52
The ban shows up in sudo firewall-cmd --list-rich-rules as a rule that rejects the address on the SSH port. To lift it, run sudo fail2ban-client set sshd unbanip with the address.
Making auditd actually record events
This was the worst finding in the lab. The audit-rules package installs /etc/audit/rules.d/audit.rules with exactly two active lines, and the second one switches syscall auditing off for every process. Its own comment says it’s there for performance, for people who don’t want a working audit system:
-D
-a task,never
Rules files load in name order, so a custom file next to it loads cleanly and shows up in auditctl -l, yet records nothing. That’s what happened with the previous version of this guide. We loaded its rules, read /etc/shadow as a normal user and edited a sudoers file, and got zero events on both releases. The fix is to replace that file with the packaged base config, which keeps auditing on and sets a sane backlog:
sudo rm /etc/audit/rules.d/audit.rules
sudo cp /usr/share/audit-rules/10-base-config.rules /etc/audit/rules.d/
Now add your own rules in a second file, so a future package update to the base config doesn’t touch them:
sudo vim /etc/audit/rules.d/50-hardening.rules
We wrote file watches in the -F path= form, because the audit tools on both releases print “Old style watch rules are slower” once for every -w line.
## Identity and privilege files
-a always,exit -F arch=b64 -F path=/etc/passwd -F perm=wa -k identity
-a always,exit -F arch=b64 -F path=/etc/shadow -F perm=wa -k identity
-a always,exit -F arch=b64 -F path=/etc/group -F perm=wa -k identity
-a always,exit -F arch=b64 -F path=/etc/gshadow -F perm=wa -k identity
-a always,exit -F arch=b64 -F path=/etc/sudoers -F perm=wa -k actions
-a always,exit -F arch=b64 -F dir=/etc/sudoers.d/ -F perm=wa -k actions
-a always,exit -F arch=b64 -F path=/etc/ssh/sshd_config -F perm=wa -k sshd_config
-a always,exit -F arch=b64 -F dir=/etc/ssh/sshd_config.d/ -F perm=wa -k sshd_config
-a always,exit -F arch=b64 -F dir=/etc/selinux/ -F perm=wa -k selinux_config
## User and group management binaries
-a always,exit -F arch=b64 -F path=/usr/sbin/useradd -F perm=x -k user_mgmt
-a always,exit -F arch=b64 -F path=/usr/sbin/userdel -F perm=x -k user_mgmt
-a always,exit -F arch=b64 -F path=/usr/sbin/usermod -F perm=x -k user_mgmt
-a always,exit -F arch=b64 -F path=/usr/bin/passwd -F perm=x -k user_mgmt
## Time changes
-a always,exit -F arch=b64 -S adjtimex,settimeofday,clock_settime -k time_change
## Mounts by real users
-a always,exit -F arch=b64 -S mount,umount2 -F auid>=1000 -F auid!=unset -k mount
## Denied file access by real users
-a always,exit -F arch=b64 -S openat,truncate,ftruncate -F exit=-EACCES -F auid>=1000 -F auid!=unset -k unauthed_access
-a always,exit -F arch=b64 -S openat,truncate,ftruncate -F exit=-EPERM -F auid>=1000 -F auid!=unset -k unauthed_access
## Kernel module load and unload
-a always,exit -F arch=b64 -S init_module,finit_module,delete_module -k module_load
Load the rules and check that auditing is on. Then log out and back in, since processes started while the old rule was loaded may carry no audit context and keep producing nothing.
sudo augenrules --load
sudo auditctl -l | head -1
sudo auditctl -s | grep -E "^enabled|backlog_limit"
From the new session, trigger a denied read and search for it. Pass --input-logs every time you call ausearch from a script or a non-interactive SSH command, because without it the tool waits on standard input and simply hangs.
cat /etc/shadow
sudo ausearch --input-logs -k unauthed_access -ts recent -i | grep type=SYSCALL
The denied openat is now recorded with auid=mary, the login user, which survives su and sudo and is exactly what an investigation needs:
type=SYSCALL msg=audit(10/26/2026 01:41:02.953:5114) : arch=x86_64 syscall=openat success=no exit=EACCES(Permission denied) a0=AT_FDCWD a1=0x7ffd43482d3e a2=O_RDONLY a3=0x0 items=1 ppid=39647 pid=39676 auid=mary uid=mary gid=mary euid=mary suid=mary fsuid=mary egid=mary sgid=mary fsgid=mary tty=(none) ses=159 comm=cat exe=/usr/bin/cat subj=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 key=unauthed_access
For an overview, sudo aureport --input-logs --summary works the same way. If you’d rather start from a compliance set than hand-written rules, /usr/share/audit-rules/ also ships the OSPP, PCI-DSS and STIG rule files, and you copy the ones you want into rules.d the same way as the base config.
Watching files with AIDE
AIDE hashes the files listed in /etc/aide.conf and reports anything that changes. Before you trust it, look at what it watches. Fedora’s config covers /etc/ssh/sshd_config and /etc/ssh/ssh_config but nothing else under /etc/ssh, so when we appended a line to the SSH drop-in from the previous section, AIDE reported no change at all. It also watches /var/log, so the sudo session logs you just enabled show up as changes on every single check.
A few lines of local rules fix both. On Fedora 45, AIDE reads drop-ins from /etc/aide.d/:
sudo mkdir -p /etc/aide.d
sudo vim /etc/aide.d/local.conf
Add the directories this guide changes and exclude the sudo logs. A leading ! tells AIDE to skip a path entirely.
/etc/ssh NORMAL
/etc/fail2ban NORMAL
/etc/usbguard NORMAL
!/var/log/sudo-io
Fedora 44’s aide.conf has no include line, so the drop-in is silently ignored there. Append the same four lines to the end of /etc/aide.conf with sudo vim /etc/aide.conf instead, then make sure AIDE still parses the file:
sudo aide --config-check && echo config-ok
Build the database and move it into place. The first init took 53 seconds on Fedora 45 and 41 seconds on Fedora 44 with 2 vCPUs, and it reads every watched file, so run it outside busy hours on a large server.
sudo aide --init
sudo mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz
sudo aide --check
With the local rules in place, the same test edit to 10-hardening.conf is caught on both releases:
Summary:
Total number of entries: 53559
Added entries: 0
Removed entries: 0
Changed entries: 1
Changed entries:
f > ... ..H.... : /etc/ssh/sshd_config.d/10-hardening.conf
Scheduling differs between the releases. Fedora 45’s aide package ships aide-check.timer, so enabling it is one command. Its service treats exit codes 0 to 7 as success, which means the unit never shows as failed when files change, so you read the result in journalctl -u aide-check instead of waiting for an alert.
sudo systemctl enable --now aide-check.timer
systemctl list-timers aide-check.timer --no-pager
Fedora 44 has no packaged timer, so you create the service yourself:
sudo vim /etc/systemd/system/aide-check.service
Without a SuccessExitStatus line, this unit goes to failed whenever AIDE finds a change. That’s handy if you already alert on failed units.
[Unit]
Description=AIDE file integrity check
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/sbin/aide --check
Nice=15
IOSchedulingClass=idle
Then create the timer that runs it daily. The Persistent=true line catches up on a run the machine missed while it was off.
sudo vim /etc/systemd/system/aide-check.timer
The randomized delay keeps a fleet of servers from hashing every disk in the same minute.
[Unit]
Description=Daily AIDE file integrity check
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=30m
[Install]
WantedBy=timers.target
Enable it with sudo systemctl daemon-reload && sudo systemctl enable --now aide-check.timer. After every planned change, such as a dnf upgrade, refresh the baseline with sudo aide --update and move the new database into place. Skip that and the next real alert gets buried under hundreds of expected ones, which is how AIDE ends up ignored.
Tightening mount options on Btrfs
The Fedora Cloud image uses one Btrfs filesystem with root, home, var and boot subvolumes, and /tmp is a tmpfs managed by systemd. Workstation installs keep /boot on a separate ext4 partition with no var subvolume, and Server defaults to XFS on LVM, so on those editions add the same flags to your own fstab lines by hand instead of using the sed below. The nodev, nosuid and noexec flags apply per mount, so they work on subvolumes too. CIS flagged four things on both releases:
/tmp: missingnoexec/dev/shm: no fstab entry, even though it’s mountednosuid,nodev/home: missingnodev,nosuid/var: missingnodev,nosuid
Back up fstab, add the flags to the three subvolume lines and give /dev/shm its own entry. /boot gets noexec as well, since nothing should ever run from it.
sudo cp /etc/fstab /etc/fstab.bak
sudo sed -i -E 's#(\s/home\s+btrfs\s+)(\S+)#\1\2,nodev,nosuid#; s#(\s/var\s+btrfs\s+)(\S+)#\1\2,nodev,nosuid#; s#(\s/boot\s+btrfs\s+)(\S+)#\1\2,nodev,nosuid,noexec#' /etc/fstab
echo "tmpfs /dev/shm tmpfs defaults,nodev,nosuid,noexec 0 0" | sudo tee -a /etc/fstab
sudo findmnt --verify
findmnt --verify should finish with “Success, no errors or warnings detected”. Leave noexec off /home on a workstation, because Flatpak user installs and anything in ~/.local/bin stop running. For /tmp, override the systemd unit instead of adding an fstab line:
sudo systemctl edit tmp.mount
Copy Fedora’s own line from systemctl cat tmp.mount and add noexec. Keep the size written as 50%%, because a single % starts a specifier in a unit file, which is why the stock unit doubles it.
[Mount]
Options=mode=1777,strictatime,nosuid,nodev,noexec,size=50%%,nr_inodes=1m,x-systemd.graceful-option=usrquota
Apply everything without a reboot, then confirm the flags on each mount:
sudo systemctl daemon-reload
for m in /home /var /boot /dev/shm; do sudo mount -o remount "$m"; done
sudo mount -o remount,noexec /tmp
for m in /home /var /boot /dev/shm /tmp; do findmnt -no TARGET,OPTIONS "$m"; done
Every mount carries the new flags. This is Fedora 44; Fedora 45 showed the same flags, though its /tmp line didn’t print a size= value.
/home rw,nosuid,nodev,relatime,seclabel,compress=zstd:1,ssd,discard=async,space_cache=v2,subvolid=258,subvol=/home
/var rw,nosuid,nodev,relatime,seclabel,compress=zstd:1,ssd,discard=async,space_cache=v2,subvolid=259,subvol=/var
/boot rw,nosuid,nodev,noexec,relatime,seclabel,compress=zstd:1,ssd,discard=async,space_cache=v2,subvolid=257,subvol=/boot
/dev/shm rw,nosuid,nodev,noexec,seclabel,inode64,usrquota
/tmp rw,nosuid,nodev,noexec,seclabel,size=1987048k,nr_inodes=1048576,inode64
A binary copied into /tmp now fails with “Permission denied” (exit 126). Package installs kept working with /tmp on noexec: afterwards we installed nginx, which runs scriptlets, and the full Workstation package set, without a single mount-related error on either release.
Trimming services and sandboxing the rest
Every listening socket is attack surface, so start with what’s actually listening before you mask anything:
sudo ss -tulpn
Apart from sshd and the local resolver stubs, both Cloud images had systemd-resolved answering LLMNR on port 5355 on every interface. LLMNR resolves names on networks without DNS, and spoofed LLMNR replies are a classic credential-capture trick on office LANs. A server does not need it, so it is switched off in the DNS section below.
A Workstation install enables more. After we added the Fedora 45 Workstation package set to the lab VM, these were enabled out of the gate:
avahi-daemon(service and socket): mDNS service discoverycups.socket: the print server, listening on 127.0.0.1:631ModemManager: mobile broadband modemsbluetoothandswitcheroo-control(dual-GPU switching)
Mask only what that machine will never use. Masking is stronger than disabling, because it points the unit at /dev/null and nothing, not even a dependency, can start it. On a desktop that never prints and doesn’t need to find printers or shares on the LAN:
sudo systemctl mask --now avahi-daemon.service avahi-daemon.socket cups.service cups.socket cups.path
Port 631 disappeared from ss straight away. Leave ModemManager and bluetooth alone on a laptop that uses them, and unmask cups with sudo systemctl unmask cups.service cups.socket cups.path the day someone needs to print.
For the services you keep, systemd’s sandboxing options shrink what a compromised daemon can reach. nginx makes a good example, because it scores 9.2 (UNSAFE) with its stock unit on both releases. Create an override:
sudo systemctl edit nginx.service
These options kept nginx serving its default page on both releases. ReadWritePaths lists the only places it may write, and the capability set covers binding port 80 and dropping to the nginx user.
[Service]
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log/nginx /var/lib/nginx /run
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID CAP_CHOWN CAP_DAC_OVERRIDE
Restart it, make sure it still serves, and score it again. If the restart fails, journalctl -u nginx names the path or capability the sandbox took away.
sudo systemctl restart nginx
curl -s -o /dev/null -w "http=%{http_code}\n" http://127.0.0.1/
systemd-analyze security nginx.service --no-pager | tail -1
The page still answers and the exposure drops by more than half:
http=200
→ Overall exposure level for nginx.service: 4.1 OK :-)
Apply the same pattern to each daemon you run, one at a time, and read its logs after each restart. MemoryDenyWriteExecute is the line most likely to break something, because anything with a JIT (Node.js, Java, PHP with JIT enabled) needs memory that’s both writable and executable.
Two smaller CIS items belong here too. Core dumps can hold passwords and keys straight out of process memory, so switch off storing them:
sudo mkdir -p /etc/systemd/coredump.conf.d
sudo vim /etc/systemd/coredump.conf.d/50-hardening.conf
Two settings are enough. You lose crash dumps for debugging, so on a development box you may prefer to leave this off.
[Coredump]
Storage=none
ProcessSizeMax=0
Then block kernel modules for filesystems and protocols a server never uses. An install ... /bin/false line stops even an explicit modprobe, which a plain blacklist line doesn’t, so each module gets both. Create the file:
sudo vim /etc/modprobe.d/50-hardening-blacklist.conf
Leave dccp out even though CIS still lists it. The protocol was removed in Linux 6.16, so there’s nothing left to block, and the rule keeps failing however you configure it.
install cramfs /bin/false
blacklist cramfs
install freevxfs /bin/false
blacklist freevxfs
install hfs /bin/false
blacklist hfs
install hfsplus /bin/false
blacklist hfsplus
install jffs2 /bin/false
blacklist jffs2
install udf /bin/false
blacklist udf
install rds /bin/false
blacklist rds
install tipc /bin/false
blacklist tipc
install sctp /bin/false
blacklist sctp
install atm /bin/false
blacklist atm
install can /bin/false
blacklist can
install firewire-core /bin/false
blacklist firewire-core
sudo modprobe tipc now fails with “Error running install command ‘/bin/false’ for module tipc”. Drop udf from the list on a desktop that reads DVDs. Leave usb-storage alone on any machine where people plug in drives, because USBGuard below gives you finer control than blocking the driver outright.
Securing updates and package sources
Each Fedora repo file sets gpgcheck=1, but /etc/dnf/dnf.conf on both releases is just a [main] header. Nothing stops a third-party repo file from shipping gpgcheck=0, and that’s also why CIS fails ensure_gpgcheck_globally_activated on a fresh install. Set it globally, and require signatures on local RPM files while you’re there:
sudo vim /etc/dnf/dnf.conf
Add both lines under [main]. The second one matters when someone installs a vendor RPM by hand, because without it dnf skips the signature check on local files.
[main]
gpgcheck=1
localpkg_gpgcheck=1
DNF5 accepts the old option name and maps it to its own. dnf5 --dump-main-config showed gpgcheck, pkg_gpgcheck and localpkg_gpgcheck all at 1 on both VMs, and the rule passed on the next scan.
Audit the enabled repos and the imported keys every quarter. On Fedora 45 the stock repo files live in /usr/share/dnf5/repos.d/, so any file you find in /etc/yum.repos.d/ was added by someone and deserves a question. The DNF5 cheatsheet has the rest of the repo commands.
dnf5 repo list --enabled
ls /etc/yum.repos.d/ /usr/share/dnf5/repos.d/
rpm -q gpg-pubkey --qf "%{nvra}\n"
Any key you can’t tie to a repo you still use can go with sudo rpm -e and the key’s package name, which stops packages signed with it from installing.
Applying security updates automatically
Install the DNF5 automatic plugin. The sed commands in older guides that edit /etc/dnf/automatic.conf fail with “No such file or directory”, because the package only reserves that path and keeps its defaults in /usr/share/dnf5/dnf5-plugins/automatic.conf. You create the file yourself:
sudo dnf5 install -y dnf5-plugin-automatic
sudo vim /etc/dnf/automatic.conf
Use these settings. We first put them in /etc/dnf/dnf5-plugins/automatic.conf, which also works, but every run then printed a warning that the location is deprecated.
[commands]
upgrade_type = security
download_updates = yes
apply_updates = yes
reboot = never
[emitters]
emit_via = stdio
Enable the DNF5 timer. The package also ships the old dnf-automatic.timer name, but dnf5-automatic.timer is the one to use.
sudo systemctl enable --now dnf5-automatic.timer
sudo dnf5 automatic
The manual run printed nothing and exited 0 on both VMs. That’s the expected result when no security updates are pending, because emit_no_updates stays at its default of no. reboot = never is deliberate, since kernel and glibc fixes need a reboot you schedule yourself, not one dnf picks at random.
Securing time and DNS
A host whose clock can be pushed around can be fed expired certificates and confused log timelines. NTS (Network Time Security) authenticates NTP, and chrony supports it. Comment out the default pool, then add NTS servers:
sudo sed -i 's|^pool 2.fedora.pool.ntp.org iburst|#&|' /etc/chrony.conf
sudo vim /etc/chrony.conf
Append the servers, plus one more line that the previous version of this guide was missing.
# NTS-authenticated time sources
server time.cloudflare.com iburst nts
server nts.netnod.se iburst nts
server ptbtime1.ptb.de iburst nts
authselectmode require
Without authselectmode require, chrony still uses the NTP server your DHCP lease hands out, through the sourcedir /run/chrony-dhcp line Fedora’s config keeps. In our lab that unauthenticated LAN server won the selection (^*) even with three NTS servers configured, so the NTS setup protected nothing. With the extra line it drops to ^? and an NTS server takes over:

Restart chronyd and check chronyc tracking. The Reference ID should name an NTS server, time.cloudflare.com in our case. Netnod completed the NTS key exchange but never answered time requests from our lab on either release (Reach stayed at 0), which is exactly why the list has three servers and not one. The flip side of require is that the host gets no time at all if outbound TCP 4460 (the NTS key exchange) is blocked, so check that your firewall allows it before rolling this out.
DNS gets the same treatment. Create a resolved drop-in that sends queries over TLS and turns off LLMNR:
sudo mkdir -p /etc/systemd/resolved.conf.d
sudo vim /etc/systemd/resolved.conf.d/50-dot.conf
An empty FallbackDNS= stops resolved from quietly falling back to plain-text servers when the TLS ones are slow. On a laptop, use DNSOverTLS=opportunistic instead of yes: strict mode breaks all name resolution behind hotel captive portals and on networks that filter outbound port 853.
[Resolve]
DNS=1.1.1.1#cloudflare-dns.com 9.9.9.9#dns.quad9.net
DNSOverTLS=yes
DNSSEC=allow-downgrade
FallbackDNS=
LLMNR=no
After a restart, ss -tnp showed the expected TLS session to 1.1.1.1:853, and also a stuck SYN-SENT to the LAN router on port 853. The DNS server NetworkManager learned for the interface was still in use, and with DoT forced on, resolved tried TLS against a router that doesn’t speak it. Clear the per-link servers on the connection. On the Cloud image it’s named cloud-init eth0, and nmcli -t -f NAME,DEVICE connection show --active shows yours. nmcli connection up re-activates the interface your SSH session runs over; ours survived it, but do this from the console if a dropped session would strand you.
sudo nmcli connection modify "cloud-init eth0" ipv4.dns "" ipv4.ignore-auto-dns yes ipv6.ignore-auto-dns yes
sudo nmcli connection up "cloud-init eth0"
sudo systemctl restart systemd-resolved
resolvectl query fedoraproject.org | tail -2
sudo ss -tnp | grep ":853"
Only the TLS session to the configured resolver is left, and answers come back authenticated over an encrypted transport:
-- Data is authenticated: yes; Data was acquired via local or encrypted transport: yes
-- Data from: network
ESTAB 0 0 10.0.1.51:33656 1.1.1.1:853 users:(("systemd-resolve",pid=40706,fd=25))
Cloud-init had written a static dns= line into that profile, which is why ipv4.ignore-auto-dns alone didn’t help; on a plain DHCP desktop connection it’s enough on its own. sudo ss -tulpn | grep 5355 now returns nothing, so LLMNR is gone as well.
Authorizing USB devices with USBGuard
USBGuard blocks any USB device that isn’t on an allow list, which stops a rogue keyboard-emulating stick on a laptop or a kiosk. You generate a policy from what’s connected now and start the daemon. The obvious way to do that, which the earlier version of this guide used, fails on both releases:

The file permissions are fine; the SELinux label is not. mv keeps the user_tmp_t label the file got in /tmp, and the daemon, running as usbguard_t, may only read usbguard_rules_t, which the AVC in the audit log spells out. restorecon fixes an existing file, but it’s simpler to write the policy straight to its final path so it gets the right label from the start:
sudo usbguard generate-policy | sudo install -m 0600 /dev/stdin /etc/usbguard/rules.conf
ls -Z /etc/usbguard/rules.conf
sudo systemctl enable --now usbguard
The label is correct this time, and the daemon starts on the first try:
system_u:object_r:usbguard_rules_t:s0 /etc/usbguard/rules.conf
Plug in a new device and it shows up as blocked in sudo usbguard list-devices. Allow it once with sudo usbguard allow-device and its ID, or add -p before the ID to append a permanent rule to the policy. Generate the initial policy with every device you rely on already connected, especially the keyboard on a desktop, or you’ll be allowing it from an SSH session.
Keeping journald logs persistent and sealed
Both releases already keep the journal on disk in /var/log/journal. Neither has an /etc/systemd/journald.conf anymore, because the defaults moved to /usr/lib/systemd/journald.conf, so your settings belong in a drop-in:
sudo mkdir -p /etc/systemd/journald.conf.d
sudo vim /etc/systemd/journald.conf.d/50-hardening.conf
Cap the size so logs cannot fill /var, and turn on sealing. ForwardToSyslog=no is there for CIS and only matters if rsyslog is installed.
[Journal]
Storage=persistent
Compress=yes
Seal=yes
ForwardToSyslog=no
SystemMaxUse=1G
MaxRetentionSec=1month
Seal=yes does nothing until you generate a sealing key, and on Fedora 44 it does nothing at all. Restart journald and generate the key, then read the first line of the output:
sudo systemctl restart systemd-journald
sudo journalctl --setup-keys
Fedora 44’s systemd answers with one line and stops, so tamper-evident local logs aren’t available there and remote forwarding is your only protection:
Compiled without forward-secure sealing support.
Fedora 45 generates the sealing key and prints a verification key instead. Copy that key off the machine, into a password manager or your log server, since anyone who can read it on the host can forge sealed entries. Verification then works with it:
sudo journalctl --verify --verify-key="<verification-key>"
Current journal files report PASS. Run --setup-keys --force only when you mean to rotate the key, because files sealed under the old key then report FAIL ... Bad message against the new one, which we saw after regenerating it in the lab. One scanner note: CIS still fails journald_disable_forward_to_syslog with this drop-in, since the check only reads the old /etc/systemd/journald.conf path. The setting is in effect, so record it as a known false positive.
Hardening the Fedora desktop
Everything above applies to a workstation too, minus the server-only firewall rules. A laptop adds three concerns of its own: what the browser can reach, what the Wi-Fi radio leaks, and where the login secrets live. We installed the Workstation package set on the Fedora 45 and Fedora 44 lab VMs to check each one, so the results below come from real Workstation packages, not from the Cloud image.
Start with the secret store, because it changed. On Fedora 45 the Workstation set installs oo7-daemon and pam_oo7, and gnome-keyring isn’t installed at all; Fedora 44 still uses gnome-keyring. GDM’s PAM stack on Fedora 45 also moved to /usr/lib/pam.d/gdm-password, and it loads both modules as optional, so whichever one is present unlocks your login keyring:
grep -nE "oo7|keyring" /usr/lib/pam.d/gdm-password
Both modules appear on the auth, password and session lines. The leading dash on each one is the detail to remember.
3:-auth optional pam_gnome_keyring.so
4:-auth optional pam_oo7.so
11:-password optional pam_gnome_keyring.so use_authtok
12:-password optional pam_oo7.so use_authtok
20:-session optional pam_gnome_keyring.so auto_start
21:-session optional pam_oo7.so auto_start
Two things follow from that. A PAM audit or compliance script that checks /etc/pam.d/gdm-password finds no file on Fedora 45 and needs pointing at /usr/lib/pam.d/. And the leading - on each line means a missing module is skipped silently, so if you remove pam_oo7 to “clean up”, the keyring simply stops unlocking at login, with no error to tell you why.
For the browser, Firefox from Flathub runs inside a Flatpak sandbox with portals for file access, while the RPM build runs with your full user rights. The previous version of this guide stripped the Flatpak’s filesystem=home grant, but Flathub’s Firefox no longer asks for it. Our Workstation VMs had no Flathub remote configured, so add it, install the app and check what it does request:
sudo flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
sudo flatpak install -y flathub org.mozilla.firefox
flatpak info --show-permissions org.mozilla.firefox
Only the Downloads folder is shared. The broad grant now is devices=all, which webcams and hardware security keys rely on:
[Context]
shared=ipc;network;
sockets=cups;fallback-x11;pcsc;pulseaudio;wayland;x11;
devices=all;
filesystems=xdg-config/gtk-3.0:ro;xdg-download;/run/.heim_org.h5l.kcm-socket;/run/udev:ro;xdg-run/speech-dispatcher:ro;
Leave devices=all alone if you use a webcam or a YubiKey in the browser, and review the other grants per app with flatpak override --user --show or the Flatseal app. The Flatpak and Flathub guide for Fedora covers remotes and overrides in more detail. Once the Flatpak build works for you, remove the RPM with sudo dnf5 remove firefox so links don’t open in the unsandboxed copy; on our Fedora 45 VM that removed only firefox and firefox-langpacks.
On Wi-Fi, a laptop’s hardware MAC address lets every network it joins recognise it again. NetworkManager can use a random address per connection instead. Create a config file:
sudo vim /etc/NetworkManager/conf.d/50-mac-randomization.conf
One setting covers Wi-Fi. We left wired connections alone, because a changing wired MAC breaks DHCP reservations and, on a remote box, your way back in.
[connection-mac-randomization]
wifi.cloned-mac-address=random
Reload with sudo systemctl reload NetworkManager, then check that sudo NetworkManager --print-config lists the section. Our VMs have no Wi-Fi card, so we confirmed NetworkManager loaded the setting on both releases but couldn’t watch the address change. On a real laptop, compare ip link before and after reconnecting.
Finally, confirm the disk is encrypted. The Workstation installer offers LUKS during setup, and lsblk -f shows crypto_LUKS on the partition if it was enabled. To see what Fedora picks when it creates a volume, we formatted a scratch file with cryptsetup luksFormat on Fedora 45 and dumped the header. The defaults are LUKS2, aes-xts-plain64 with a 512-bit key, and argon2id for key derivation, which are the parameters you want, so a fresh install needs no tuning. On an existing machine, run sudo cryptsetup luksDump against the encrypted partition and compare; an older LUKS1 volume can be converted with cryptsetup convert --type luks2, but only while it’s closed, so a root volume has to be done from live media, and the converted keyslots keep PBKDF2 until you run cryptsetup luksConvertKey --pbkdf argon2id. Take a backup first.
Re-measuring the result
Run the same scans again on the server. The deltas are the evidence that the work landed, and the rules that still fail become your exception list:
sudo oscap xccdf eval \
--profile xccdf_org.ssgproject.content_profile_cis_server_l1 \
--report /var/tmp/oscap-final.html \
--results /var/tmp/oscap-final.xml \
/usr/share/xml/scap/ssg/content/ssg-fedora-ds.xml > /dev/null
sudo grep -oE "<score [^>]*>[^<]+</score>" /var/tmp/oscap-final.xml
sudo lynis audit system --quick | grep "Hardening index"
Fedora 45 finished at 89.10, with 256 rules passing and 40 failing, and a Lynis index of 85.

Fedora 44 ended at 86.87 with 254 passing and 42 failing. One of its two extra failures is sysctl_kernel_yama_ptrace_scope, which fails because the scanner reads the vendor file that sets 0 even though the running value is 1; the other is file_permissions_boot_grub2. These are the numbers across the whole run:
| Measurement | Fedora 45 before | Fedora 45 after | Fedora 44 before | Fedora 44 after |
|---|---|---|---|---|
| CIS L1 Server score | 75.88 | 89.10 | 75.88 | 86.87 |
| Rules passing / failing | 175 / 121 | 256 / 40 | 175 / 121 | 254 / 42 |
| Lynis hardening index | 71 | 85 | 68 | 85 |
| nginx exposure (systemd-analyze) | 9.2 UNSAFE | 4.1 OK | 9.2 UNSAFE | 4.1 OK |
Most of the 40 remaining failures are decisions rather than gaps, and it pays to sort them before anyone asks:
- Container networking: IP forwarding and IPv6 router advertisements
- Stricter than the benchmark:
kptr_restrictis 2, the check accepts only 1 - Vendor files the scanner reads:
fs.suid_dumpable,rp_filter, journald forwarding - Deliberate choices: a locked root password, nginx installed, no cron daemon
- Real work left: umask 027, shell timeouts, an empty
sugroup, crypto policy
Only the last group needs action, and the HTML report lists the exact fix under each rule. OpenSCAP can also write every fix in the profile into one script, which is the safe way to see what automatic remediation would do before you let it near a server:
sudo oscap xccdf generate fix --fix-type bash \
--profile xccdf_org.ssgproject.content_profile_cis_server_l1 \
--output /var/tmp/remediation.sh \
/usr/share/xml/scap/ssg/content/ssg-fedora-ds.xml
Read it before running anything. On our VM the script held 324 fixes in 11,569 lines, and its SSH fixes delete ClientAliveInterval and ClientAliveCountMax from every file in /etc/ssh/sshd_config.d/ before writing its own 00-complianceascode-hardening.conf. Run as-is, it rewrites the drop-in you made above, so cherry-pick the blocks you want rather than piping the whole file to bash.
Troubleshooting hardening lockouts
Every error below came up on our lab VMs while testing this guide, and each one has a fix that doesn’t involve turning a control off.
“FileRuleSet loading: /etc/usbguard/rules.conf: Permission denied”
The rules file carries the user_tmp_t label because it was created in /tmp and moved. Run sudo restorecon -v /etc/usbguard/rules.conf and restart usbguard. The message blames permissions, but ls -l looks fine, because the problem is the SELinux label and not the mode bits.
“not allowed because none of user’s groups are listed in AllowGroups”
The account isn’t in ssh-users. Add it with sudo usermod -aG ssh-users and the user name, and the next login picks the change up without reloading sshd. If it’s your own account and no session is left open, use the hypervisor or cloud console.
Piped sudo commands exit 137 or 129 and write nothing
A sudoers file sets both log_subcmds and log_input. Remove log_subcmds, check with sudo visudo -c, and pipes work again. If automation is already failing, ssh -t gives the session a terminal, so you can still log in and edit the file.
auditctl lists the rules but ausearch finds nothing
If sudo auditctl -l | head -1 prints -a never,task, the stock audit.rules is still loaded ahead of yours. Replace it with 10-base-config.rules as shown above, run sudo augenrules --load, and test from a fresh login.
“Too many authentication failures” on SSH
With MaxAuthTries 3, a client whose SSH agent holds several keys can run out of attempts before it offers the right one. Point the client at the key with IdentityFile and IdentitiesOnly yes in ~/.ssh/config rather than raising the limit on the server.
“Configuration file location … for dnf automatic is deprecated”
Your config is in /etc/dnf/dnf5-plugins/automatic.conf. Move it to /etc/dnf/automatic.conf and run sudo restorecon /etc/dnf/automatic.conf so the file keeps the right label.
Keeping a hardened Fedora box hardened
Hardening decays. A package update adds a sysctl file, a teammate drops a repo into /etc/yum.repos.d/, and a new scap-security-guide release adds rules so your score falls without anything changing on the box. This is the cadence we’d keep on a small team’s servers:
| When | What to run | What you’re looking for |
|---|---|---|
| Daily (timer) | journalctl -u aide-check | Any changed entry you didn’t make |
| Daily | sudo fail2ban-client status sshd | A jump in banned addresses |
| Weekly | sudo ausearch --input-logs -k actions -ts week-ago | Edits to sudoers you can’t explain |
| Weekly | sudo dnf5 advisory list --security | Security advisories not yet applied |
| Monthly | The OpenSCAP scan above | New failures since last month’s exception list |
| After every upgrade | /usr/lib/systemd/systemd-sysctl --cat-config, searched for ptrace_scope | A new vendor file overriding your drop-in |
| Quarterly | dnf5 repo list --enabled and ls /etc/yum.repos.d/ | Third-party repos nobody remembers adding |
The one log to watch is the AIDE journal. If you look at nothing else each morning, look there, and update the baseline after each planned change so the report stays short enough to read. Before a release upgrade, take a snapshot with Snapper on Btrfs so a setting that breaks the new release can be rolled back. When it’s time to move on, the Fedora 44 to 45 upgrade guide covers the jump, and you should re-run the baseline scan right after it, because that’s when vendor defaults change underneath you.