Skip to content

Repository files navigation

linux-harden

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.


Contents

What it does

  1. Loads and validates the plan (schema linux-audit.remediation-plan, major version 1). Plans older than 7 days are refused without --force.
  2. Without --apply: prints a full preview and exits. This is the default.
  3. 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.
  4. 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.
  5. Applies the approved changes, verifies the result, and writes an Excel report plus a machine-readable harden_results.json.

Safety model

  • Dry-run by default. Nothing changes without --apply.
  • A human approves every change. There is deliberately no unattended mode, and --apply refuses to run without a TTY. Items the plan marks disruptive additionally require typing APPLY.
  • 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. --rollback restores 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).

What it can fix

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.

Which file gets edited

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 PermitRootLogin is dead once the drop-in says no — but it returns the moment someone deletes the drop-in, and an auditor reading sshd_config sees the old value. --fix-shadowed rewrites every unconditional copy; all are backed up and all are rolled back.
  • Match blocks are never edited. A directive inside a Match is conditional, so it is reported alongside the result rather than changed.
  • A new directive is inserted before the first Match block, never appended past it, where it would silently become conditional.
  • Both Key value and Key=value are 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.

Requirements

Password SSH login and password-auth directives. If you log in with a password, hardening PasswordAuthentication, KbdInteractiveAuthentication or UsePAM to no breaks 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.

Installation

git clone https://github.com/vikozs/linux-harden.git
cd linux-harden
python3 -m pip install -r requirements.txt

Optionally install it as a command:

python3 -m pip install .
linux-harden --help

Quick start

# 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-ssh

Authentication 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.

The approval flow

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:
  • y applies to every listed candidate host, s asks per host, q skips 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.

The sshd guard

Changing sshd over ssh can lock you out. Every sshd_config change runs under a three-layer guard:

  1. The old config is backed up (locally and on the host), the edit is made, and sshd -t validates it. A syntax failure restores the old config and nothing is reloaded.
  2. A watchdog is armed on the host, then sshd is reloaded (never restarted; existing sessions survive). If the watchdog is not disarmed within --watchdog seconds (default 90), it restores the old config and reloads again, by itself, with no working connection required.
  3. 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.

Backups and rollback

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_mode items and prior runtime values for sysctl items,
  • manifest.json tying it together.
python3 linux_harden.py --list-backups
python3 linux_harden.py --rollback              # latest run
python3 linux_harden.py --rollback 20260717-143012

Rollback 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.

Outputs

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.

Command-line options

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)

Security considerations

  • 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 .gitignore excludes 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.

Limitations

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.
  • Include expansion 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_defs changes affect new accounts only; existing users need chage.
  • 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=yes in logind, verify before trusting it (default on all common server distros is no).
  • This is a hardening executor, not an auditor. applied is verified once, at apply time. Re-audit for the authoritative picture.

Troubleshooting

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.

License

Released under the MIT License.

Disclaimer

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.

✠ The Church of the Eternal Cluster

A Highly Available Faith. Keep no Pets. Declare thy state. Back up etcd.

The Scriptorium

The Parish

The Canon (source)

More from the Rootless One

Let's connect


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.


About

Interactive hardening for Linux fleets from a linux-audit remediation plan. Re-verifies every finding over SSH, a human approves each change per check, disruptive items need typed confirmation. Local backups with rollback, sshd lockout guard, Excel + JSON results. Dry-run by default.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages