Interactive fleet hardening from a linux-audit remediation plan. A human approves every change. Dry-run by default.
linux_harden.py consumes the machine-readable plan.json that
linux_audit.py --plan-out produces, re-verifies each finding on the live
host, and applies the fixes you approve, over plain SSH with sudo. Nothing
is installed on the targets. Every modified file is backed up to your machine
first, and --rollback restores it.
Only run this against systems you are explicitly authorised to change.
- What it does
- Safety model
- What it can fix
- Which file gets edited
- Requirements
- Installation
- Quick start
- The approval flow
- The sshd guard
- Backups and rollback
- Outputs
- Command-line options
- Security considerations
- Limitations
- Troubleshooting
- License
- Loads and validates the plan (schema
linux-audit.remediation-plan, major version 1). Plans older than 7 days are refused without--force. - Without
--apply: prints a full preview and exits. This is the default. - With
--apply: connects to every host in the plan and re-reads the current value of every finding. The plan is a snapshot; the host may have changed. - Walks you through the findings one check at a time, fleet-wide: one decision covers all hosts with that finding, with per-host opt-out.
- Applies the approved changes, verifies the result, and writes an Excel
report plus a machine-readable
harden_results.json.
- Dry-run by default. Nothing changes without
--apply. - A human approves every change. There is deliberately no unattended
mode, and
--applyrefuses to run without a TTY. Items the plan marksdisruptiveadditionally require typingAPPLY. - Re-verification before applying. A host whose current value no longer matches what the audit observed is flagged as drifted and confirmed per host. A value that cannot be read is skipped, never guessed.
- Local backups + rollback. Every file is pulled to your machine before
it is modified.
--rollbackrestores the previous state. - The sshd guard. See below. A bad sshd change reverts itself even if you lose the connection.
- Untrusted input treated as untrusted. Plan values originate from
audited hosts. Values that do not match a strict charset are refused, and
report cells are formula-escaped (
xlsx_safe).
Version 1 executes the low-risk, well-defined action types:
| Action type | What happens |
|---|---|
sshd_config |
Edit the directive in the file that actually decides it, sshd -t, reload under the guard |
sysctl |
Set at runtime and persist in /etc/sysctl.d/60-linux-harden.conf |
login_defs |
Set the key in /etc/login.defs |
file_mode |
chmod/chown the file |
Everything else in the plan (package_*, service_enable, reboot,
manual) is listed in the report as needing a human, exactly as the plan
schema requires. The tool never guesses at an action type it does not know.
sshd uses the first value it obtains for a keyword, and every mainstream
distro ships Include /etc/ssh/sshd_config.d/*.conf at the top of
sshd_config. A drop-in therefore beats the main file. Editing
/etc/ssh/sshd_config to say PermitRootLogin no while
sshd_config.d/01-permitrootlogin.conf still says yes changes nothing, and
root can still log in.
So linux-harden does not assume a file. At --apply time it reads
sshd_config and every file its Include lines pull in, reproduces sshd's
parse order, and edits the exact file:line that determines the effective
value. The approval prompt shows where the edit will land before you approve:
[2/5] 5.2.1 SSH: root login disabled
key: ssh.permit_root_login action: sshd_config expected: no DISRUPTIVE
CAUTION: Verify a sudo-capable non-root account can log in first.
web01.example.com observed: yes -> /etc/ssh/sshd_config.d/01-permitrootlogin.conf:2 SHADOWS /etc/ssh/sshd_config:4 (left as-is; --fix-shadowed)
Consequences worth knowing:
- Shadowed duplicates are reported, not silently rewritten. Above, the
main config's
PermitRootLoginis dead once the drop-in saysno— but it returns the moment someone deletes the drop-in, and an auditor readingsshd_configsees the old value.--fix-shadowedrewrites every unconditional copy; all are backed up and all are rolled back. Matchblocks are never edited. A directive inside aMatchis conditional, so it is reported alongside the result rather than changed.- A new directive is inserted before the first
Matchblock, never appended past it, where it would silently become conditional. - Both
Key valueandKey=valueare recognised. - If the Include layout cannot be read, the item fails rather than guessing. Blindly appending to the main config is the failure this whole section exists to prevent.
- The drop-in may well be owned by config management. linux-harden names the file it changed in the report so you can push the same change upstream.
Password SSH login and password-auth directives. If you log in with a password, hardening
PasswordAuthentication,KbdInteractiveAuthenticationorUsePAMtonobreaks the connectivity probe, and the watchdog will correctly revert the change. Use key-based auth for those checks. The tool warns before you approve one.
Controller (the machine you run it from): Python 3.9+, openpyxl, the
system ssh client, and sshpass only if you use password SSH login.
Linux, WSL or macOS. Targets: the same access linux-audit needed: SSH +
sudo, POSIX shell, GNU coreutils/sed.
git clone https://github.com/vikozs/linux-harden.git
cd linux-harden
python3 -m pip install -r requirements.txtOptionally install it as a command:
python3 -m pip install .
linux-harden --help# 1. audit the fleet (produces the plan)
python3 linux_audit.py -H hosts.txt -o audit.xlsx --plan-out plan.json
# 2. preview what linux-harden would offer (changes nothing)
python3 linux_harden.py plan.json
# 3. interactive apply; key-based SSH + passwordless sudo
python3 linux_harden.py plan.json --apply
# password SSH login + sudo with the same password (one prompt)
python3 linux_harden.py plan.json --apply -u local.user --ask-ssh-pass --sudo-pass-same-as-sshAuthentication flags are identical to linux-audit: key/agent/~/.ssh/config
by default, --ask-ssh-pass/--ssh-pass-env for password login,
--ask-sudo-pass/--sudo-pass-same-as-ssh for sudo. Passwords travel via
stdin or environment, never argv.
One prompt per check, fleet-wide:
[3/5] 5.2.4 SSH: MaxAuthTries <= 4
key: ssh.max_auth_tries action: sshd_config expected: 4
web01.example.com observed: 6
app02.example.com observed: 8
db01.example.com already_compliant (current value '4')
Apply to 2 host(s)? [y]es / [N]o / [s]elect hosts / [q]uit:
yapplies to every listed candidate host,sasks per host,qskips the rest of the checks.- The default is always no.
- Disruptive items show their caution and require typing
APPLY. - Hosts that are already compliant are excluded automatically.
- Hosts that drifted since the audit are confirmed individually, even after a fleet-wide yes.
Changing sshd over ssh can lock you out. Every sshd_config change runs
under a three-layer guard:
- The old config is backed up (locally and on the host), the edit is made,
and
sshd -tvalidates it. A syntax failure restores the old config and nothing is reloaded. - A watchdog is armed on the host, then sshd is reloaded (never
restarted; existing sessions survive). If the watchdog is not disarmed
within
--watchdogseconds (default 90), it restores the old config and reloads again, by itself, with no working connection required. - The controller opens a fresh SSH session to the host. Only if that succeeds is the watchdog disarmed. The session that made the change proves nothing about whether new logins still work.
If the probe fails, the tool waits out the watchdog, confirms the host came
back, and reports the item as reverted.
After the reload, the effective value is read back from sshd -T. If a
drop-in in sshd_config.d overrides the directive (first match wins in
sshd), the item is reported as failed with that explanation, because the
edit did not take effect.
Every run that changes anything writes backups/<run-id>/:
- a copy of every file as it was before the change, per host,
- prior modes/owners for
file_modeitems and prior runtime values forsysctlitems, manifest.jsontying it together.
python3 linux_harden.py --list-backups
python3 linux_harden.py --rollback # latest run
python3 linux_harden.py --rollback 20260717-143012Rollback shows what it will restore, asks for confirmation, restores files,
modes and sysctl values, and if sshd_config was involved it runs sshd -t
before reloading and probes connectivity after.
| File | What it is |
|---|---|
harden_report.xlsx |
Prose for humans. About, Summary (per host), Results (per item, worst first, colour-coded), Errors. |
harden_results.json |
Structure for machines. Schema linux-audit.hardening-result 1.0: per-item status (applied, already_compliant, skipped, drift_skipped, unverified_skipped, failed, reverted, manual, unsupported), prior/post values, run id. |
backups/<run-id>/ |
What --rollback restores from. |
Both outputs derive from the same records; neither is parsed from the other.
applied means the post-check read back the expected value, not merely that
a command exited 0. Re-run linux-audit afterwards for an independent view.
| Option | Description |
|---|---|
plan |
plan.json from linux-audit --plan-out |
--apply |
Actually change things (interactive). Default is a dry-run preview |
--force |
Accept a plan older than 7 days |
-o, --output PATH |
Excel report (default harden_report.xlsx) |
--results PATH |
Results JSON (default harden_results.json) |
--backup-dir DIR |
Local backup root (default backups) |
--rollback [RUN_ID] |
Restore from a backup run (default: latest) |
--list-backups |
List backup runs and exit |
--fix-shadowed |
also rewrite sshd directive copies shadowed by the effective one |
--watchdog SECS |
sshd auto-restore timeout (default 90) |
-u, --user USER |
Default SSH user |
-p, --port PORT |
Default SSH port |
-i, --identity FILE |
SSH private key |
--escalate {none,sudo} |
Escalation method (default sudo) |
--ask-sudo-pass / --sudo-pass-same-as-ssh |
sudo password handling |
--ask-ssh-pass / --ssh-pass-env VAR |
password SSH login (needs sshpass) |
--ssh-opt OPT |
Extra ssh -o option, repeatable |
--host-key-checking VAL |
StrictHostKeyChecking (default accept-new) |
--connect-timeout N / --cmd-timeout N |
Timeouts in seconds |
--workers N |
Parallel hosts for verify/apply (default 8) |
- Authorisation. Only harden hosts you are permitted to change, in an agreed window. This tool edits security-relevant configuration.
- Sensitive files. The plan, the results and the backups describe your
infrastructure and its weaknesses. The provided
.gitignoreexcludes all of them. Store and share them accordingly. - Credential handling. Passwords come from a prompt or an env var and are passed via stdin, never on the command line.
- Injection resistance. Plan values are validated against a strict charset before they are interpolated into any remote command, and every report cell is written formula-safe.
Named here so nobody has to discover them:
- Executes only the four action types above. Patching, package changes, service enablement and reboots stay with you.
Includeexpansion is one level deep, which is what the distros ship. A drop-in that itself includes further files is not followed.- Shadowed duplicates of an sshd directive are reported but left alone unless
you pass
--fix-shadowed. login_defschanges affect new accounts only; existing users needchage.- The remote edits assume GNU sed and coreutils, the same baseline linux-audit collects with.
- The sshd watchdog relies on a background process surviving the session.
On systems with
KillUserProcesses=yesin logind, verify before trusting it (default on all common server distros is no). - This is a hardening executor, not an auditor.
appliedis verified once, at apply time. Re-audit for the authoritative picture.
plan is N days old — re-run linux-audit; the fleet has likely changed.
Use --force only if you know it has not.
--apply requires an interactive terminal — approvals are the product.
There is no unattended mode; run it from a real shell.
A host reports drift_skipped — its current value no longer matches what
the audit observed. Re-audit, or approve the drifted host explicitly when
prompted.
An sshd item reports failed saying another file still wins — something
set the directive earlier in the parse order than the file that was edited,
usually a drop-in added between the audit and the run. Re-run linux-audit; the
Detail column names the file that was changed.
An sshd item reports failed with "refusing to guess" — the Include
topology could not be read (unreadable sshd_config, or sudo did not
escalate). Nothing was changed.
applied, but the value still looks wrong to an auditor — check Detail
for a SHADOWS note: a dead duplicate is sitting in another file. Re-run with
--fix-shadowed.
An sshd item reports reverted — the connectivity probe failed and the
watchdog restored the previous config. The host is as it was. Investigate
before retrying.
sorry, you must have a tty to run sudo — the target has
Defaults requiretty. Same limitation as linux-audit.
Released under the MIT License.
Provided "as is", without warranty. You are responsible for authorisation, for change control, and for reviewing every change you approve.
═══════════════════ ✠ ════════════════════
#### #### #### #### ####
# # # # # # # # # #
# # # # # # # # # #
##### ##### ##### ##### #####
# # # # #
# # # # #
#### #### #### #### ####
═══════════════════ ✠ ════════════════════
Five Nines be upon you, and also with you.
Kubernetes · OpenShift · High Availability
HA-llelujah.
A Highly Available Faith. Keep no Pets. Declare thy state. Back up etcd.
The Scriptorium
- ha-llelujah.dev · the book, the music, and the reliquary
- The Music · the sacred discography
- Be Reconciled · take the vow and join the Reconciled
The Parish
- fivenines.church · confession, prayer, and the living parish
- The Liturgical Calendar · the holy days of uptime
- The Tithe · support the Church
The Canon (source)
- church-of-the-eternal-cluster · doctrine, liturgy, and scripture
- eternal-cluster-mcp · the Oracle, an MCP server that diagnoses thy incidents through doctrine
- vK
- Linux Fleet Audit
- Linux Diskspace
- Linux Fleet Harden
- Size OpenShift subscriptions from evidence
- Diagnose stuck PersistentVolumes, safely
An independent parody, built with love for everyone who has been paged at 03:00. Kubernetes is a trademark of the Linux Foundation. OpenShift is a trademark of Red Hat, Inc. Not affiliated with, nor endorsed by, either. They have real SLAs; we only have belief. HA-llelujah.