A cross-platform security auditing and hardening tool. Load a ruleset, run an audit, apply fixes, and roll back if needed, all from a single binary with no external dependencies.
Go 1.24+ is required to build the binary. The golang-go package in most distro
repos is too old; install from the official source instead:
GO_VERSION=$(curl -s https://go.dev/VERSION?m=text | head -1)
curl -fsSL "https://go.dev/dl/${GO_VERSION}.linux-amd64.tar.gz" -o /tmp/go.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf /tmp/go.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.profile
source ~/.profile
go versionmacOS: use
darwin-arm64ordarwin-amd64instead oflinux-amd64in the URL above, or install viabrew install go.
1. Download the ruleset and the guide
| Resource | Link |
|---|---|
| Ruleset (YAML) | ruleset.yaml |
| Hardening Guide (PDF/MD) | ERNW_Hardening_Linux.md |
2. Build the binary
git clone https://github.com/mev0lent/hardener.git
cd hardener
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o hardener-linux .3. Audit your system
# Interactive suite picker
./hardener-linux audit --ruleset ruleset.yaml
# Run all suites non-interactively
./hardener-linux audit --ruleset ruleset.yaml --all
# Include high-security checks (default: baseline only)
./hardener-linux audit --ruleset ruleset.yaml --all --security-level high4. Apply fixes
./hardener-linux fix --ruleset ruleset.yaml --all5. Roll back
# Undo the last fix run
./hardener-linux rollback --latest
# Undo a specific run
./hardener-linux rollback --timestamp 2026-05-18T14:32:00Z
# Roll back specific files only
./hardener-linux rollback --files /etc/sysctl.conf,/etc/hosts.denyA fix only counts as fixed when its check passes when re-run straight
after the fix. A fix whose command succeeds while the check still fails is
reported as not effective, often because its post_action (a reboot or a
service restart) is still due.
Before each fix, its affected_file and any live kernel values the check
reads (/proc/sys/..., sysctl -n/-w <key>) are written to runs.json. If
that backup fails, the fix is skipped. Rollback removes files a fix created,
recreates files it deleted, and refuses to overwrite a file that changed
after the fix. A link the fix created, such as the /dev/null link of
systemctl mask, is removed. Not covered: package installs and removals,
directories, files named only in prose or by glob in affected_file, and
~/ paths. The fix still runs for these.
fix --dry-run runs the audit and lists the fix each failing check would get,
without applying any.
1. Download the ruleset and the guide
| Resource | Link |
|---|---|
| Ruleset (YAML) | ruleset.yaml |
| Hardening Guide (PDF/MD) | Hardening_Guide-macOS_26_Tahoe_1.0.md |
2. Build the binary
git clone https://github.com/mev0lent/hardener.git
cd hardener
# Apple Silicon
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o hardener-macos .
# Intel
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -o hardener-macos .3. Audit your system
# Interactive suite picker
./hardener-macos audit --ruleset ruleset.yaml
# Run all suites non-interactively
./hardener-macos audit --ruleset ruleset.yaml --all
# Include high-security checks (default: baseline only)
./hardener-macos audit --ruleset ruleset.yaml --all --security-level high4. Apply fixes
./hardener-macos fix --ruleset ruleset.yaml --all5. Roll back
./hardener-macos rollback --latestEvery tagged release publishes a SHA-256 checksum alongside the built binary. Before running Hardener under elevated privileges, verify that your local binary matches the released one:
Linux / macOS:
sha256sum -c hardener.sha256
# expected output: hardener: OKWindows (PowerShell):
(Get-FileHash .\hardener -Algorithm SHA256).Hash.ToLower() `
-eq (Get-Content .\hardener.sha256).Split(' ')[0]
# expected output: TrueIf the check fails, the binary in your working directory does not match the tagged release; do not run it with elevated privileges. Report the discrepancy in an issue.
Release assets (binary and hardener.sha256) are attached to each entry
under the Releases page.
Add --tui to audit or fix for live category scores, progress,
and scrolling check results with skip reasons and error details.
./hardener audit --ruleset ruleset.yaml --all --tui
./hardener fix --ruleset ruleset.yaml --all --tui
# Preview using simulated checks
./hardener tui-demo
# Browse saved audit/fix results without executing commands
./hardener view reports/example.json
# Open the report path prompt
./hardener viewUse your binary's filename, such as hardener-linux or hardener-macos.
Scores are red below 35%, orange at 35–64%, and green at 65% or above. Skipped and manual checks are excluded; successful fixes require another audit to count as verified passes.
Press o after a run finishes to open a saved report. Saved reports
reconstruct results, not the original timed activity stream.
See TUI.md for keyboard controls, scoring, and terminal requirements.
| Command | Flag | Short | Description |
|---|---|---|---|
audit / fix |
--ruleset <file> |
-r |
Load checks from a ruleset YAML file |
audit / fix |
--path <dir> |
-p |
Load checks from a hardening-guide directory (alternative to --ruleset) |
audit / fix |
--all |
-A |
Skip interactive suite picker, run everything |
audit / fix |
--security-level <level> |
-s |
baseline (default), medium, high, or optional — runs checks at or below this level |
audit / fix |
--profile <name> |
-P |
Role profile for distro overrides (e.g. server, client) |
audit / fix |
--label <l1,l2> |
-l |
Run only suites whose labels: list contains at least one match |
fix |
--dry-run |
-d |
Audit and list the fix each failing check would get; nothing is applied |
rollback |
--latest |
Roll back to the most recent fix run | |
rollback |
--timestamp <ts> |
-t |
Roll back to a specific run, by its UTC timestamp in runs.json (e.g. 2026-05-18T14:32:00Z) |
rollback |
--files <f1,f2> |
Roll back specific files only | |
audit / fix |
--tui |
Show the live terminal dashboard | |
tui-demo |
Preview the dashboard with simulated checks | ||
view [report.json] |
Browse saved results; omit the path to open a prompt |
--ruleset and --path are mutually exclusive; exactly one must be provided for audit and fix.
Every check has a security_level field: baseline, medium, high, or optional, in that order. Only checks at or below the level requested via --security-level are executed; the rest are reported as skipped. Default is baseline.
optional marks controls a hardening guide calls optional: recommended, but they may disable functionality a system needs. They are never included by high; --security-level optional runs every check, optional ones included.
Levels are case-insensitive. An unknown --security-level is rejected, and checks whose security_level is missing or unknown are listed in a warning before the run, since they never run at any level.
A suite's os: and arch: decide whether it runs on the current system; suites for another OS or architecture are skipped when the ruleset loads. A check can narrow this with its own os: and arch: lists, and is then reported as distro-skipped elsewhere. amd64/x86_64 and arm64/aarch64 are treated as the same architecture.
Checks can carry a distro: map to override command, fix, expected, sudo, or fix_sudo for a specific Linux distribution or distro family. The key is either the lowercase ID= value from /etc/os-release (e.g. ubuntu, rocky, opensuse-leap, arch) or a family name derived from ID_LIKE= (e.g. debian, rhel, suse, arch, fedora):
- id: secure-boot-active
command: mokutil --sb-state | grep -c enabled
distro:
ubuntu:
command: |
if ! command -v mokutil >/dev/null 2>&1; then echo 1
else mokutil --sb-state | grep -c enabled; fi
debian: # applies to any debian-family distro (Ubuntu, Kali, Mint, ...) as fallback
command: |
command -v mokutil >/dev/null 2>&1 && mokutil --sb-state | grep -c enabled || echo 1Lookup order (most specific → least):
<distro>-<profile>(e.g.ubuntu-server)<family>-<profile>for each family in the chain (e.g.debian-server,rhel-server)<distro>(e.g.ubuntu)<family>for each family in the chain (e.g.debian,rhel,suse,arch)
Family chains are derived from ID + ID_LIKE in /etc/os-release, with a small hand-maintained fallback for distros that ship without ID_LIKE:
| Distro | Chain |
|---|---|
| Ubuntu, Kali, Mint, Pop!_OS | <id>, debian |
| Rocky, AlmaLinux, CentOS, Oracle | <id>, rhel, fedora |
| RHEL | rhel, fedora |
| Fedora | fedora, rhel |
| openSUSE Leap/Tumbleweed, SLES | <id>, suse |
| Manjaro, EndeavourOS, Artix | <id>, arch |
If a check has a distro: map but no key in the chain matches, the check is skipped with a skipped_distro state — it does not count as a failure.
For role-based differences (server vs. client), pass a profile at runtime:
./hardener-linux audit --ruleset ruleset.yaml --profile serverThe engine tries the combined key distro-profile (e.g. debian-server) first, then falls back to distro alone, then runs the universal check. This lets you express distro+role overrides without duplicating entries:
distro:
debian-server:
command: systemctl is-active sshd | grep -c active
debian:
command: command -v sshd >/dev/null 2>&1 && echo 0 || echo 0Each suite in a ruleset has a labels: list. Use --label to run only matching suites:
# Run only kernel and network suites
./hardener-linux audit --ruleset ruleset.yaml --label kernel,networkAvailable labels in the Linux ruleset: auth, network, kernel, filesystem, boot, services, audit, logging, mac.
The bundled Linux ruleset ships 48 suites / ~131 checks covering: administrative accounts and PAM stack, password policy, faillock and account aging, sudo hardening, screen/session lock, service hardening (Avahi, Bluetooth, CUPS, Samba), firewall, SSH, kernel and network sysctls, IPv6, DNS resolver, /etc/hosts sanity, chrony, boot (GRUB/Secure Boot/BIOS), UEFI mode, kernel command line, package integrity (rpm/deb/pacman), single-user mode, USBGuard, PATH and umask, mount options, /proc hardening, SUID/SGID/orphan audits, polyinstantiated tmp, auditd rules, journald hardening, syslog, cron/at whitelist, systemd service sandboxing, and MAC (SELinux + AppArmor) enforcement.
A check with requires_command: <binary> is silently skipped with a missing-command state when that binary is not in the system's PATH. This keeps checks that depend on optional tools from showing as errors on systems where the tool isn't installed:
- id: secure-boot-active
requires_command: mokutil
command: mokutil --sb-state | grep -c enabled
expected: 1Missing-command skips appear in suite summaries and are tracked separately from failures.
A check with requires_file: <path> is skipped with a missing-command state when the file does not exist. Useful for checks against distro-specific files that some distros ship without (e.g. /etc/hosts.allow, /etc/login.defs, /etc/pam.d/common-password):
- id: hosts-deny-permissions
requires_file: /etc/hosts.deny
command: stat -c '%a' /etc/hosts.deny
expected: 644A permission error on stat is treated as "file exists but not readable" and does not skip the check — only a genuine "no such file" does.
The runner prepends /usr/local/sbin, /usr/sbin, and /sbin to PATH before executing checks and fixes. This makes sysctl, firewall-cmd, iptables, auditctl, and similar tools discoverable even on distros (openSUSE, some minimal images) that do not include sbin dirs in a regular user's login PATH.
By default the check compares command output against expected as a string. Set expected_op to use a numeric comparison instead:
expected: 1
expected_op: '>='Supported operators: >=, >, <=, <.
Kernel sysctl checks in the bundled ruleset use cat /proc/sys/<key> rather than sysctl -n <key>. This has two upsides: (a) it works without sudo because /proc/sys entries are world-readable, and (b) it sidesteps distros (notably openSUSE Leap) whose sudoers secure_path omits /usr/sbin and would otherwise return exit code 127. If you author a new sysctl-style check, prefer the /proc/sys path for the same reasons.
- Prefer runtime file detection over a
distro:map when the only thing that changes across distros is a filepath. Example — instead of duplicating the check three times for/etc/pam.d/common-password(Debian) vs/etc/pam.d/system-auth(RHEL/Arch), write:command: | f=/etc/pam.d/system-auth; [ -f "$f" ] || f=/etc/pam.d/common-password grep -cE '^\s*password\s+required\s+pam_pwquality.so' "$f"
- Use
requires_commandfor optional binaries (gsettings,auditctl,mokutil) andrequires_filefor optional files (/etc/hosts.allow,/etc/pam.d/common-auth). Both produce a neutral "missing-command" state, not a failure. - For "should not be enabled" service checks, pass unless
systemctl is-enabledreturns exactlyenabled— this treatsdisabled,masked,static,indirect, and missing units all as compliant. - A command that exits non-zero is reported as an error, not a failure. The exception is a counting grep (
grep -c,-ic,-Eci,-v -c,--count, alsoegrep/fgrep) that prints0: grep exits 1 when nothing matches, and the0is compared againstexpectedlike any other output. For other tests, end the command with&& echo 1 || echo 0rather than relying on the exit code.
Automated VM-based testing across Linux distributions. Each distro gets a fresh KVM VM, runs the full audit → fix → rollback cycle, and produces structured results.
Linux bare-metal host required. KVM/libvirt needs hardware virtualisation (VT-x or AMD-V) available directly on the CPU. This test harness does not work on macOS, Windows, or inside a VM (VMware/VirtualBox/WSL2).
1. Install KVM and libvirt
sudo apt install qemu-kvm libvirt-daemon-system libvirt-clients virt-manager
sudo systemctl enable --now libvirtd
sudo usermod -aG libvirt,kvm $USER
# log out and back in for group membership to take effectVerify:
virt-host-validateIOMMU and secure-guest WARN lines are fine. A FAIL on the KVM line means
hardware virtualisation is not available; on bare metal, enable VT-x/AMD-V in
BIOS/UEFI; inside a VM this setup cannot be used, use Docker instead.
2. Install Vagrant
The key at HashiCorp's /gpg URL does not always match the key currently signing
the repo. Fetch it by fingerprint from a keyserver instead, which is always authoritative:
sudo gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys AA16FCBCA621E701
sudo gpg --export AA16FCBCA621E701 \
| sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" \
| sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install vagrant
vagrant --version # should print 2.4+Kali Linux:
lsb_release -csreturnskali-rollingwhich has no entry in the HashiCorp repo. Replace the codename part of theecholine with a hardcodedbookworm(Kali's Debian base):echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \ https://apt.releases.hashicorp.com bookworm main" \ | sudo tee /etc/apt/sources.list.d/hashicorp.list
3. Install the vagrant-libvirt plugin
sudo apt install libvirt-dev ruby-dev build-essential
vagrant plugin install vagrant-libvirt
vagrant plugin list # should list vagrant-libvirt4. Run the tests
Build the binary from the repo root (Go 1.24+ required, see Prerequisites above):
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o hardener-linux .Then from the testing/ directory:
cd testing
chmod +x run_tests.shWith a markdown guide directory:
./run_tests.sh \
--guide path/to/linux-guide/sections \
--binary ../hardener-linux \
--distros "ubuntu debian rocky opensuse archlinux"With a standalone ruleset YAML (--binary is required in this mode):
./run_tests.sh \
--ruleset ../ruleset.yaml \
--binary ../hardener-linux \
--distros "ubuntu"| Flag | Default | Description |
|---|---|---|
--guide |
Path to a hardening-guide directory of .md files |
|
--ruleset |
Path to a standalone ruleset.yaml (alternative to --guide) |
|
--binary |
(required with --ruleset) |
Path to the pre-built hardener binary |
--level |
baseline |
Security level: baseline, medium, high, or optional |
--distros |
all | Space-separated list of distros to run |
--profile |
Passed as --profile to hardener (e.g. server, client) |
|
--label |
Passed as --label to hardener (e.g. kernel,network) |
--guide and --ruleset are mutually exclusive; one must be provided.
For each distro:
- Boots a fresh KVM VM via Vagrant
- Installs distro-specific prerequisites inside the VM
- Rsyncs the guide and binary into the VM at
/opt/hardener/ - Runs the 5-step test cycle: initial audit → fix → post-fix audit → rollback → post-rollback audit
- Pulls structured results out of the VM over SSH
- Destroys the VM
Every run creates a timestamped directory under testing/results/:
results/
└── <TIMESTAMP>/
├── summary.txt # cross-distro table
├── ubuntu.json # per-suite results for all 5 steps
├── ubuntu.log # verbose hardener output
├── ubuntu-vagrant.log # raw vagrant up / provisioning output
└── ...
Summary table columns:
| Column | Meaning |
|---|---|
STATE |
OK = results extracted · SKIP = VM failed or no results |
A1_P / A1_F / A1_E |
Initial audit: passed / failed / errors |
A1_S |
Skipped: check above the requested security level (neutral) |
A1_DS |
Distro-skipped: check has a distro: map but current distro not listed (neutral) |
A1_MC |
Missing-command: requires_command binary not found on this distro (neutral) |
A1_T |
Total checks in suite |
FX_OK / FX_F |
Fixes verified by re-check / still failing after the fix run, including fixes that ran but were not effective |
A3_* |
Post-rollback audit: same columns as A1 |
A healthy run has low A1_F and A1_E. A1_S, A1_DS, and A1_MC are all neutral — they are expected to vary by distro and security level and do not indicate broken checks.
After the table, the runner prints a missing commands per distro section listing the exact binary names that triggered A1_MC on each distro. Use this to decide whether to add a package to the Vagrantfile's pkg_cmd or to add a requires_command: field to the relevant checks.
| Distro | Vagrant box | Firewall |
|---|---|---|
| ubuntu | bento/ubuntu-24.04 |
ufw |
| debian | generic/debian12 |
ufw |
| rocky | bento/rockylinux-9 |
firewalld |
| opensuse | opensuse/Leap-15.6.x86_64 |
firewalld |
| archlinux | generic/arch |
ufw |
| fedora | bento/fedora-latest |
firewalld |
| rhel | generic/rhel9 (requires subscription) |
firewalld |
Distro shows SKIP: open <distro>-vagrant.log. Common causes: box has no libvirt provider variant; package install failed during provisioning.
All results are zero: the binary failed system validation. Open <distro>.log and look for [> ERROR] lines. Usually a required tool from preconditions is missing on that distro.
High A1_MC: many checks are skipped due to missing binaries. Check the "Missing commands per distro" section of summary.txt for the exact binary names. Either add the package to the Vagrantfile pkg_cmd for that distro, or add requires_command: to the affected checks so the skip is intentional and documented.
vagrant plugin install fails: install dev libraries first:
sudo apt install libvirt-dev ruby-dev build-essential- Add an entry to the
DISTROShash intesting/Vagrantfilewith a libvirt-compatible box and its prereq install command - Run:
./run_tests.sh --guide path/to/sections --binary ../hardener-linux --distros "newdistro" - Check the vagrant log if it shows
SKIP, the hardener log if results are all zero