{"id":51771823,"url":"https://github.com/zavetsec/harden-ubuntu","last_synced_at":"2026-07-20T02:02:01.055Z","repository":{"id":369022151,"uuid":"1287983239","full_name":"zavetsec/harden-ubuntu","owner":"zavetsec","description":"Single-file Ubuntu security hardening and compliance audit","archived":false,"fork":false,"pushed_at":"2026-07-03T08:52:22.000Z","size":2412,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-07-03T09:11:08.685Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Shell","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/zavetsec.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-07-03T07:07:04.000Z","updated_at":"2026-07-03T08:52:52.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/zavetsec/harden-ubuntu","commit_stats":null,"previous_names":["zavetsec/harden-ubuntu"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/zavetsec/harden-ubuntu","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zavetsec%2Fharden-ubuntu","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zavetsec%2Fharden-ubuntu/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zavetsec%2Fharden-ubuntu/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zavetsec%2Fharden-ubuntu/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/zavetsec","download_url":"https://codeload.github.com/zavetsec/harden-ubuntu/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zavetsec%2Fharden-ubuntu/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35670233,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-20T02:00:06.612Z","response_time":111,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-07-20T02:01:59.725Z","updated_at":"2026-07-20T02:02:01.034Z","avatar_url":"https://github.com/zavetsec.png","language":"Shell","funding_links":[],"categories":[],"sub_categories":[],"readme":"# ZavetSec-Harden-Ubuntu\n\n**Single-file Ubuntu security hardening and compliance audit — no Ansible, no Python, no dependencies.**\n\n![shell](https://img.shields.io/badge/shell-bash%204%2B-1f2937)\n![ubuntu](https://img.shields.io/badge/Ubuntu-20.04%20%7C%2022.04%20%7C%2024.04-e95420)\n![mode](https://img.shields.io/badge/default-dry--run%20(safe)-00cc6a)\n![deps](https://img.shields.io/badge/dependencies-none-2ea043)\n![license](https://img.shields.io/badge/license-MIT-blue)\n![version](https://img.shields.io/badge/version-1.5-informational)\n\n**Highlights**\n\n- ✅ Dry-run by default — nothing changes until you say so\n- ✅ Automatic, one-command rollback\n- ✅ Live compliance **audit** (checks the running system, not just config files)\n- ✅ TXT **and** styled HTML reports — self-contained, no external requests\n- ✅ Role-aware profiles (server / workstation / container-host)\n- ✅ Anti-lockout guards on SSH and the firewall\n- ✅ Automatic security patching (`unattended-upgrades`) and `fail2ban` out of the box\n- ✅ Ubuntu 20.04 / 22.04 / 24.04 — Server \u0026 Desktop, version-aware (yescrypt, faillock, sshd option names)\n- ✅ Zero external dependencies (just Bash + coreutils)\n\n![Audit report](docs/report.png)\n\nA pragmatic, CIS/STIG-aligned baseline you can actually drop onto a box and trust. It **detects** the host, picks a role-appropriate **profile**, and either **audits** current compliance (read-only, with reports) or **enforces** the baseline with full backup and one-command rollback. Default mode is dry-run, and the two areas that can strand you — SSH and the firewall — have explicit anti-lockout guards.\n\n```\n 11 hardening modules  ·  90+ live audit checks  ·  4 role profiles\n Ubuntu 20.04 / 22.04 / 24.04  ·  one Bash file  ·  zero dependencies\n dry-run by default  ·  automatic rollback  ·  TXT + HTML reports\n```\n\n```\n detect host ──▶ select profile ──▶ audit (read-only, scored report)\n                                        │\n                                        ▼\n                              apply (backup every change)\n                                        │\n                                        ▼\n                              rollback (one command, full revert)\n```\n\nEvery release is exercised on real VMs through the full `apply → reboot → audit → rollback → apply` cycle before it ships. That process has caught (and fixed) things static review never would: sshd drop-in ordering losing to `cloud-init`, ufw silently reverting `sysctl.d` values on start, a `pipefail`+`grep -q` SIGPIPE race producing phantom \"service absent\" results, and apport re-enabling `suid_dumpable` after every boot. The changelog in the script header documents all of it.\n\n---\n\n## Table of contents\n\n- [Why](#why)\n- [Features](#features)\n- [Non-goals](#non-goals)\n- [Safety model](#safety-model)\n- [Requirements](#requirements)\n- [Install](#install)\n- [Quick start](#quick-start)\n- [Modes](#modes)\n- [Audit \u0026amp; reports](#audit--reports)\n- [Profiles](#profiles)\n- [Modules](#modules)\n- [Command-line reference](#command-line-reference)\n- [Tunables](#tunables)\n- [How it works](#how-it-works)\n- [Rollback](#rollback)\n- [Philosophy](#philosophy)\n- [FAQ \u0026amp; caveats](#faq--caveats)\n- [License](#license)\n\n---\n\n## Why\n\nMost Linux hardening scripts try to support every distribution, accumulate years of compatibility branches nobody re-tests, and eventually become hard to trust with root. **ZavetSec-Harden-Ubuntu takes the opposite approach: Ubuntu only, role-aware, audit-first.**\n\n- **Ubuntu only** — uses `apt`, `ufw`, AppArmor, `pam-auth-update`, `ssh.socket`, and apport directly and correctly, instead of untested cross-distro branches. It even knows the release-specific traps: yescrypt exists only from 22.04, `pam_faillock` needs PAM ≥ 1.4 (absent on 20.04), sshd 8.2 on focal rejects `KbdInteractiveAuthentication`, cloud images ship a `50-cloud-init.conf` that overrides carelessly-named drop-ins, and stock ufw actively resets `log_martians` on every start.\n- **Role-aware** — a container host must keep IP forwarding on and its firewall off; a workstation must keep printing, removable media, apport and SLAAC. Profiles encode those trade-offs.\n- **Audit-first** — measure a host's compliance and produce a shareable report *before* you change anything.\n\n**How it relates to the tools you already know** — different jobs, deliberately:\n\n| | ZavetSec-Harden | Lynis | OpenSCAP | Ansible roles |\n|---|:---:|:---:|:---:|:---:|\n| Audit with score | ✅ | ✅ | ✅ | ❌ |\n| Enforce the baseline | ✅ | ❌ (advice only) | partial (remediation profiles) | ✅ |\n| Dry-run preview of every change | ✅ | n/a | ❌ | partial (`--check`) |\n| One-command rollback | ✅ | n/a | ❌ | ❌ |\n| Single file, zero dependencies | ✅ | ❌ | ❌ | ❌ (needs Ansible + inventory) |\n| Certified compliance reporting | ❌ | ✅ | ✅ | ❌ |\n| Cross-distro | ❌ (by design) | ✅ | ✅ | varies |\n\nUse Lynis or OpenSCAP when an auditor needs a certified report; use this when you need a box hardened *and* provably revertible. They pair well — this project's own release testing uses Lynis as the independent second opinion.\n\n---\n\n## Features\n\nThe things you care about first:\n\n- **Dry-run by default** — see every change before it happens, including every firewall and service action\n- **Automatic rollback** — one script reverts the whole run; a pre-existing ufw ruleset is snapshotted and restored, never just disabled\n- **Compliance audit** — PASS/FAIL/WARN/SKIP against the profile baseline, with a score\n- **HTML reports** — self-contained (no external fonts or requests — an audit report must not phone home), styled, with a live filter and a failed-checks panel\n- **Role-aware profiles** — `defaults`, `server`, `workstation`, `container-host`\n- **Patching \u0026amp; brute-force defence** — `unattended-upgrades` enforced (opt-out), `fail2ban` with an update-proof `jail.local`\n- **Zero dependencies** — Bash + coreutils, present on every Ubuntu install\n\nUnder the hood:\n\n- **Detection layer** — Ubuntu version, init, container, firewall backend, AppArmor, GUI/headless, live SSH port(s) (IPv6-safe parsing), and `ssh.socket` activation (relevant on 24.04).\n- **Eleven modules** — kernel/network `sysctl`, SSH, password/lockout policy, firewall, auditd, filesystem, service reduction, extras (patching/fail2ban/accounting), AppArmor, and misc.\n- **Anti-lockout** — SSH crypto negotiated only from what the installed `sshd` supports (`ssh -Q`); config validated with `sshd -t` and the *previous* drop-in restored from backup on failure; password auth never disabled unless keys exist; firewall always permits the detected SSH port + loopback + established before default-deny; sudoers changes validated with `visudo` and auto-removed if rejected; faillock PAM wiring refuses to run where `pam_faillock` doesn't exist.\n- **Idempotent \u0026amp; verified** — safe to run repeatedly; every in-place edit is verified after writing and logs `ERR` instead of a false `OK` if it didn't land. Commented lines (including documentation comments in `login.defs`) are never rewritten.\n\n---\n\n## Non-goals\n\nZavetSec-Harden-Ubuntu intentionally does **not**:\n\n- rewrite `/etc/fstab` automatically (advisory only)\n- force AppArmor profiles into enforce mode\n- disable user namespaces (would break snap, rootless containers, browser sandboxes)\n- disable or remove snap / squashfs\n- flip SELinux — this is an AppArmor/Ubuntu tool\n- harden non-Ubuntu distributions\n- replace certified scanners like Lynis or OpenSCAP\n- set a GRUB password (physical-access threat model — decide that per site)\n- repartition disks (separate `/home`, `/tmp`, `/var` are install-time decisions)\n- configure remote log shipping (environment-specific by nature)\n- install a malware scanner or AIDE (heavyweight; may appear later as explicit opt-ins)\n\nBeing explicit about the boundaries is part of the point: the tool does a well-defined job and gets out of the way. When Lynis suggests one of the above, that's the intended division of labour, not a gap.\n\n---\n\n## Safety model\n\n1. **Dry-run by default.** Nothing changes unless you pass `--apply` — and dry-run shows *everything*, including firewall and service actions.\n2. **Backups before every change.** Originals are copied into `state-dir/backup`, preserving paths. Audit and dry-run create no state directory and no rollback files at all — only `--apply` does.\n3. **Auto-generated rollback.** `sudo bash \u003cstate-dir\u003e/rollback.sh` reverts the run's changes, re-runs `sysctl --system`, and prints exactly what persists until reboot. If ufw was active *before* the run, rollback restores your previous ruleset instead of disabling the firewall.\n4. **Detect before act.** Role, firewall backend, MAC, and the live SSH port drive behaviour.\n5. **Anti-lockout guards** on the modules that can strand you:\n   - **SSH** writes a drop-in named `00-…` so it wins under sshd's first-match-wins semantics (a higher number silently loses to `50-cloud-init.conf` on cloud images); filters ciphers/KEX/MACs through `ssh -Q`; runs `sshd -t` and restores the previous drop-in on failure; reloads (never kills) `sshd`; is aware of `ssh.socket` on 24.04; uses `ChallengeResponseAuthentication` on focal where the modern option name doesn't exist yet.\n   - **Firewall** allows SSH/loopback/established *before* default-deny, and is **off on `container-host`** so it never clobbers Docker's own nftables chains.\n   - **Auth** wires faillock via native `pam-auth-update` with the canonical `preauth / authfail / authsucc` stack (opt-in), and *refuses* to wire it on releases without `pam_faillock` (20.04) instead of writing a broken PAM stack.\n   - **Sudo** hardening is validated with `visudo -c` and removed instantly if rejected — sudo cannot be bricked by this tool.\n6. **Never blind-enforce.** AppArmor is enabled but profiles aren't force-`enforce`d. Destructive options (`noexec /tmp`, IPv6 off, `usb-storage` off, `ptrace_scope=2`, audit immutable `-e 2`, faillock PAM wiring) are opt-in.\n\n\u003e Always keep a second root session open the first time you run `--apply` on a remote host.\n\n---\n\n## Requirements\n\n**The script refuses to run on non-Ubuntu systems unless you pass `--force`.** Derivatives (Mint, Pop!_OS, etc.) are detected and allowed with a warning.\n\n- Ubuntu **20.04 / 22.04 / 24.04 LTS** (Server or Desktop)\n- Bash 4+ and coreutils (already present)\n- **root** for `--apply` and for a *complete* audit (`sshd -T`, `ufw`, `auditctl`, file permissions). Detection and a partial audit work without root — and both the console and the reports display a prominent warning that a non-root score is unreliable.\n\n---\n\n## Install\n\nIt's one file — that's the entire installation.\n\n```bash\n# git\ngit clone https://github.com/zavetsec/ZavetSec-Harden-Ubuntu\ncd ZavetSec-Harden-Ubuntu \u0026\u0026 chmod +x zavetsec-harden-ubuntu.sh\n\n# curl\ncurl -fsSLO https://raw.githubusercontent.com/zavetsec/ZavetSec-Harden-Ubuntu/main/zavetsec-harden-ubuntu.sh\n\n# wget\nwget https://raw.githubusercontent.com/zavetsec/ZavetSec-Harden-Ubuntu/main/zavetsec-harden-ubuntu.sh\n\nchmod +x zavetsec-harden-ubuntu.sh\n```\n\n**Verify integrity before running** — this is a security tool that will run as root, and a file mangled in transit (encoding, line endings, truncation) can fail in non-obvious ways:\n\n```bash\nsha256sum -c SHA256SUMS\nbash -n zavetsec-harden-ubuntu.sh \u0026\u0026 echo OK\n```\n\n---\n\n## Quick start\n\n```bash\n# Audit compliance (read-only) → TXT + HTML reports\nsudo ./zavetsec-harden-ubuntu.sh --audit\n\n# Enforce the baseline (auto profile from detected role)\nsudo ./zavetsec-harden-ubuntu.sh --apply\n```\n\n\u003cdetails\u003e\n\u003csummary\u003eMore commands\u003c/summary\u003e\n\n```bash\n# See what the tool detects about this host\nsudo ./zavetsec-harden-ubuntu.sh --detect-only\n\n# Preview enforcement without changing anything (dry-run, the default)\nsudo ./zavetsec-harden-ubuntu.sh\n\n# Enforce a specific profile / role\nsudo ./zavetsec-harden-ubuntu.sh --apply --profile server\nsudo ./zavetsec-harden-ubuntu.sh --apply --role container-host\n\n# Only / skip specific modules\nsudo ./zavetsec-harden-ubuntu.sh --apply --only ssh,firewall\nsudo ./zavetsec-harden-ubuntu.sh --apply --skip firewall\n\n# Audit into a specific directory, HTML only\nsudo ./zavetsec-harden-ubuntu.sh --audit --report-dir /var/log/zs-audit --format html\n```\n\n\u003c/details\u003e\n\n---\n\n## Modes\n\n| Mode | Flag | Root | Changes system | Output |\n|------|------|:----:|:--------------:|--------|\n| Detect | `--detect-only` | optional | no | detection summary |\n| Audit | `--audit` (alias `--check`) | recommended | no | console + TXT + HTML report |\n| Dry-run | *(default)* | optional | no | per-change \"would do\" log |\n| Enforce | `--apply` | **required** | yes | applied changes + backups + rollback |\n\n---\n\n## Audit \u0026amp; reports\n\nAudit reads the **live** system state — not just config files — and compares it to the selected profile's baseline: kernel/network via `sysctl -n`, effective SSH config via `sshd -T`, `ufw status`, `aa-status`, `auditctl -l`, mount options via `findmnt`, real permissions via `stat`, service state, installed packages.\n\nEach check yields **PASS / FAIL / WARN / SKIP** with a **HIGH / MED / LOW** severity, and a compliance score (`PASS / (PASS + FAIL)`).\n\n```\n  AUDIT SUMMARY\n  Compliance score: 100%  (PASS:92 FAIL:0 WARN:1 SKIP:1)\n[OK  ] TXT report:  ./zavetsec-audit-web01-20260702-121200.txt\n[OK  ] HTML report: ./zavetsec-audit-web01-20260702-121200.html\n```\n\nReports go to the current directory by default (`--report-dir` to change) as `zavetsec-audit-\u003chost\u003e-\u003ctimestamp\u003e.{txt,html}`; pick `--format txt|html|both` (default `both`).\n\n- **TXT** — grouped by category, summary line, explicit failed-check list with the exact remediation command. Good for logs, cron, and diffing over time.\n- **HTML** — a self-contained page (see screenshot above): compliance gauge, PASS/FAIL/WARN/SKIP tallies, a red panel listing every failure with expected-vs-actual values, per-category tables with severity badges, and a live filter/search box. No external fonts, scripts, or requests — an audit artifact must not phone home.\n- **Non-root runs are flagged.** Without root, `sshd -T`/`ufw`/AppArmor checks SKIP, so both report formats display a prominent *\"NON-ROOT AUDIT — SCORE IS UNRELIABLE\"* warning rather than letting a hollow 100% mislead anyone.\n\nBecause enforcement and audit share the same baseline, a FAIL list is exactly what `--apply` with the same profile will fix. Context-dependent values are reported honestly: e.g. on `workstation`, where apport is deliberately kept, `fs.suid_dumpable` shows as `SKIP (apport-managed)` instead of a false FAIL — apport resets it to `2` on every boot by design.\n\n---\n\n## Profiles\n\nThe role is auto-detected (`container` → `workstation` if a GUI is present → otherwise `server`; a `server` running `dockerd`/`kubelet` is promoted to `container-host`) or set with `--role`. `defaults` is the base; each named profile overrides only deltas.\n\n| Setting | `defaults` | `server` | `workstation` | `container-host` |\n|---|:---:|:---:|:---:|:---:|\n| Firewall default-deny | off | **on** | **on** | **off** (runtime owns rules) |\n| IP forwarding | off | off | off | **on** (required) |\n| `ptrace_scope` | 1 | 2 | 1 | 1 |\n| Unprivileged eBPF | off | off | **kept** | **kept** (CNI/Cilium) |\n| `usb-storage` | kept | disabled | kept | disabled |\n| Filesystem module | on | on | on | **off** (overlayfs) |\n| apport | disabled | disabled | **kept** | disabled |\n| cups / avahi | — | disabled | **kept** | disabled |\n| `PermitRootLogin` | prohibit-password | **no** | **no** | **no** |\n| `MaxSessions` | 4 | **2** | 4 | 4 |\n| Auto security upgrades | on | on | on | on |\n\n\u003e On `container-host`, Docker publishes ports via its own nftables rules that **bypass ufw** — the firewall module is intentionally off there, and the tool warns about it.\n\n\u003e `MaxSessions 4` on non-server profiles is deliberate: `2` (the Lynis ideal) breaks SSH `ControlMaster` multiplexing and VS Code Remote.\n\n---\n\n## Modules\n\nRun order and one-liners:\n\n1. **sysctl-kernel** — kernel info-leak, ASLR, ptrace, eBPF, `protected_*`, coredump hardening\n2. **sysctl-network** — anti-spoofing, redirect/source-route rejection, SYN cookies; role-critical forwarding\n3. **ssh** — modern crypto, `00-` drop-in (wins against cloud-init), `sshd -t` validation, anti-lockout, `ssh.socket`-aware\n4. **auth** — password ageing, version-aware hashing (yescrypt + cost / SHA512 + rounds), pwquality, faillock policy\n5. **firewall** — ufw default-deny inbound, SSH-safe, pins the sysctl values ufw itself would clobber\n6. **auditd** — CIS/STIG-inspired audit rule set (b64 **and** b32 arches)\n7. **filesystem** — safe mount options, rare-fs blacklist (keeps squashfs)\n8. **services** — disable unneeded daemons, purge legacy cleartext services, blacklist uncommon net protocols\n9. **extras** — unattended-upgrades, fail2ban (+ update-proof `jail.local`), debsums, needrestart, process accounting, sysstat\n10. **apparmor** — ensure enabled; never blind-enforce\n11. **misc** — coredumps, apport, banners, cron allow-list, Ctrl-Alt-Del, sudo hardening, file permissions\n\n\u003cdetails\u003e\n\u003csummary\u003eFull module reference\u003c/summary\u003e\n\n| # | Module | Details |\n|---|--------|---------|\n| 1 | `sysctl-kernel` | `kptr_restrict`, `dmesg_restrict`, `kexec_load_disabled`, `perf_event_paranoid`, full ASLR, `yama.ptrace_scope`, unprivileged eBPF off + JIT hardening, `protected_{hardlinks,symlinks,fifos,regular}`, `dev.tty.ldisc_autoload=0`, `suid_dumpable=0` (skipped where apport is deliberately kept — apport owns it there). User namespaces are **not** disabled. |\n| 2 | `sysctl-network` | reverse-path filter, ICMP redirect/source-route rejection, martian logging, broadcast-ICMP ignore, SYN cookies, `tcp_rfc1337`, IPv6 redirect/RA hardening. IP forwarding is role-critical (kept on for `container-host`). |\n| 3 | `ssh` | Hardened drop-in `00-zavetsec-harden.conf` — sshd is first-match-wins, so the lowest-numbered file beats `50-cloud-init.conf` and friends. Modern KEX/ciphers/MACs (filtered by `ssh -Q`), `PermitRootLogin`, `MaxAuthTries 3`, `MaxSessions`, `LoginGraceTime`, `TCPKeepAlive no`, disabled X11/agent/TCP forwarding, `LogLevel VERBOSE`, banner. Focal-compatible option names. Validated with `sshd -t`; on failure the *previous* drop-in is restored from backup. `ssh.socket`-aware (24.04). |\n| 4 | `auth` | `login.defs` (ageing, `UMASK`), version-aware hashing: `ENCRYPT_METHOD yescrypt` + `YESCRYPT_COST_FACTOR` on 22.04+, `SHA512` + `SHA_CRYPT_*_ROUNDS` on 20.04. `pwquality.conf`, `faillock.conf`. Optional faillock PAM wiring via native `pam-auth-update` using the canonical `preauth(1025) / authfail(0) / authsucc(Additional)` stack (opt-in; lockout risk); refuses on releases without `pam_faillock`. |\n| 5 | `firewall` | ufw default-deny inbound; SSH/loopback/established allowed first. Fixes the active `log_martians=0` lines noble ships in `/etc/ufw/sysctl.conf` **in place** and re-runs `sysctl --system` after `ufw enable`, so `sysctl.d` wins immediately. Pre-existing ruleset snapshotted for rollback. |\n| 6 | `auditd` | auditd + CIS/STIG-aligned rules for **both** `arch=b64` and `arch=b32` (identity, time, MAC, logins, perm/ownership changes, unauthorized access, mounts, deletes, module loading, privileged commands). Immutable `-e 2` opt-in. |\n| 7 | `filesystem` | `nodev,nosuid,noexec` on `/dev/shm`, advisory checks for `/tmp`/`/var/tmp`, rare-fs blacklist. `squashfs` kept (snap). `noexec /tmp` and `usb-storage` off are opt-in. |\n| 8 | `services` | Disables profile-listed services (avahi, rpcbind, cups, bluetooth, …), purges legacy cleartext daemons (telnet, rsh, talk, tftp, nis), blacklists uncommon network protocols (`dccp sctp rds tipc`). |\n| 9 | `extras` | `unattended-upgrades` + enforced `20auto-upgrades` (security patching beats half the sysctls combined; opt-out via tune). `fail2ban` with a `jail.local` that survives package updates — a pre-existing user-managed `jail.local` is never touched. `debsums`, `apt-listchanges`, `needrestart`, `libpam-tmpdir`, `acct`, `sysstat` (collection enabled). |\n| 10 | `apparmor` | Ensures AppArmor installed and enabled; reports complain-mode profiles. Never blind-`enforce`s. |\n| 11 | `misc` | Disables core dumps, disables apport (servers), warning banners, cron/at allow-list (flagged as a MED risk — non-root users lose cron unless added), masks Ctrl-Alt-Del, sudo hardening (`use_pty`, `logfile`) validated with `visudo` and auto-removed if rejected, fixes permissions (`/etc/shadow` → `640 root:shadow`, `/etc/sudoers.d` → `750`). |\n\n\u003c/details\u003e\n\nRun a subset with `--only` or exclude with `--skip`.\n\n---\n\n## Command-line reference\n\n```\n--apply                  apply changes (otherwise dry-run)\n--dry-run                explicit dry-run (default)\n--audit, --check         read-only compliance audit + TXT/HTML reports\n--profile \u003cname\u003e         defaults | server | workstation | container-host\n--role \u003cname\u003e            override the auto-detected role\n--only \u003ca,b,c\u003e           run only these modules (by key)\n--skip \u003ca,b,c\u003e           skip these modules\n--detect-only            print the detection summary and exit\n--report-dir \u003cdir\u003e       where to write audit reports (default: current dir)\n--format \u003ctxt|html|both\u003e audit report format (default: both)\n--state-dir \u003cdir\u003e        logs/backups/rollback for --apply runs\n                         (default: /var/log/zavetsec-harden/\u003cts\u003e; audit and\n                         dry-run create no state directory at all)\n--force                  run even on non-Ubuntu (not recommended)\n--list                   list modules in run order\n--no-color               disable ANSI color\n--version                print version\n-h, --help               show help\n```\n\n---\n\n## Tunables\n\nProfiles are shell assignments in the `load_profile` function — edit them or add a new `case` branch.\n\n- **Modules:** `ZS_ENABLE_SYSCTL_KERNEL`, `ZS_ENABLE_SYSCTL_NETWORK`, `ZS_ENABLE_SSH`, `ZS_ENABLE_AUTH`, `ZS_ENABLE_FIREWALL`, `ZS_ENABLE_AUDITD`, `ZS_ENABLE_FILESYSTEM`, `ZS_ENABLE_SERVICES`, `ZS_ENABLE_EXTRAS`, `ZS_ENABLE_APPARMOR`, `ZS_ENABLE_MISC`.\n- **Password/login:** `ZS_TUNE_PASS_MAX_DAYS`, `ZS_TUNE_PASS_MIN_DAYS`, `ZS_TUNE_PASS_WARN_AGE`, `ZS_TUNE_UMASK`, `ZS_TUNE_YESCRYPT_COST`, `ZS_TUNE_SHA_ROUNDS`, `ZS_TUNE_PWQ_MINLEN`, `ZS_TUNE_PWQ_MINCLASS`, `ZS_TUNE_FAILLOCK_DENY`, `ZS_TUNE_FAILLOCK_UNLOCK`, `ZS_TUNE_ENABLE_FAILLOCK_PAM`.\n- **SSH:** `ZS_TUNE_SSH_PERMIT_ROOT`, `ZS_TUNE_SSH_DISABLE_PASSWORD`, `ZS_TUNE_SSH_MAXAUTH`, `ZS_TUNE_SSH_MAXSESSIONS`, `ZS_TUNE_SSH_GRACE`, `ZS_TUNE_SSH_ALIVE_INTERVAL`, `ZS_TUNE_SSH_ALIVE_COUNT`, `ZS_TUNE_SSH_ALLOW_TCP_FWD`, `ZS_TUNE_SSH_X11`, `ZS_TUNE_SSH_TCP_KEEPALIVE`.\n- **Kernel/network:** `ZS_TUNE_NET_FORWARDING`, `ZS_TUNE_PTRACE_SCOPE`, `ZS_TUNE_DISABLE_BPF_UNPRIV`, `ZS_TUNE_DISABLE_USB_STORAGE`, `ZS_TUNE_DISABLE_IPV6`, `ZS_TUNE_NET_PROTO_BLACKLIST`.\n- **Extras:** `ZS_TUNE_EXTRA_PKGS`, `ZS_TUNE_AUTO_UPGRADES`, `ZS_TUNE_F2B_BANTIME`, `ZS_TUNE_F2B_FINDTIME`, `ZS_TUNE_F2B_MAXRETRY`.\n- **Other:** `ZS_TUNE_TMP_NOEXEC`, `ZS_TUNE_AUDIT_IMMUTABLE`, `ZS_TUNE_DISABLE_APPORT`, `ZS_TUNE_SUDO_HARDEN`, `ZS_TUNE_FS_BLACKLIST`, `ZS_TUNE_DISABLE_SERVICES`.\n\n---\n\n## How it works\n\nA single Bash file, organized into clearly separated sections:\n\n```\nzavetsec-harden-ubuntu.sh\n├── logging / state          coloured logs, backup, rollback, change tracking\n├── idempotent helpers       set_kv, ensure_line, write_managed, set_sysctl, apt_*, svc_*\n├── audit engine             chk, a_sysctl, a_sshd, a_kv, a_file_mode, a_svc_*, …\n├── detect()                 Ubuntu-focused detection\n├── load_profile()           defaults + per-role deltas\n├── mod_\u003cname\u003e()             enforcement modules  (apply / dry-run)\n├── audit_\u003cname\u003e()           audit modules        (read-only)\n├── report_txt / report_html report generators\n└── main                     arg parsing, mode dispatch, run loop, summary\n```\n\nEnforcement and audit share the same baseline, so an audit's FAIL list is exactly what `--apply` will change. Adding a module means adding a `mod_\u003ckey\u003e` and an `audit_\u003ckey\u003e` function and listing the key in `MODULES`.\n\nTwo implementation details worth knowing, both earned the hard way:\n\n- `set_kv` never rewrites commented lines (they're often documentation, e.g. in `login.defs`); a live line is edited in place, otherwise the value is appended (last value wins in every target format). Every write is verified afterwards — a failed edit logs `ERR`, never a false `OK`.\n- Under `set -o pipefail`, `bigcommand | grep -q` is a race: `grep -q` exits on first match, the producer dies with SIGPIPE, and the pipeline goes false despite the match. The script avoids that pattern everywhere it matters — it once produced intermittent phantom *\"service absent\"* audit results on real hosts.\n\n---\n\n## Rollback\n\nEvery `--apply` run creates a timestamped state directory with backups, a log, a change list, and a rollback script (audit and dry-run runs create nothing — no empty-rollback clutter):\n\n```bash\nsudo bash /var/log/zavetsec-harden/\u003ctimestamp\u003e/rollback.sh\n```\n\nIt restores backed-up files, re-enables disabled/masked services, undoes ufw/sysctl/PAM changes from that run, then re-runs `sysctl --system` and prints an honest summary of what it could and couldn't revert.\n\nKnow the boundaries — the script itself tells you all of this when it finishes:\n\n- **Rollback is per-run.** Each `rollback.sh` reverts exactly the changes *its* apply made. If you applied several times, run each content-bearing `rollback.sh` from newest to oldest.\n- **Runtime state persists until reboot.** Removing a `sysctl.d` file doesn't reset live kernel values, and loaded audit rules stay loaded — reboot to complete the revert.\n- **Packages stay by design.** auditd, unattended-upgrades, sysstat etc. remain installed (removing security tooling behind your back would be worse); the exception is fail2ban, which is removed if — and only if — the run being rolled back installed it.\n- **A pre-existing ufw ruleset is restored, not disabled.** If ufw was active before the run, rollback puts your previous rules back; it will never leave you with a firewall you didn't ask to lose.\n\n---\n\n## Philosophy\n\n\u003e Security hardening should be predictable.\n\u003e\n\u003e Every change should be visible before it happens.\n\u003e\n\u003e Every change should be reversible.\n\u003e\n\u003e Every recommendation should exist because it improves security without unnecessarily breaking Ubuntu.\n\nAnd one earned in testing: **a hardening tool must never lie about what it did.** Every edit is verified after writing; failures are loud `ERR`s, dry-run output shows every action including firewall and service changes, and a non-root audit tells you its score can't be trusted.\n\n---\n\n## FAQ \u0026amp; caveats\n\n**Is this a certified compliance scanner?**\nNo. It's a baseline-conformance audit and enforcer. For formal CIS/STIG reporting, pair it with [Lynis](https://cisofy.com/lynis/) or `oscap`. (On a stock 24.04 desktop, applying the baseline typically moves the Lynis hardening index from ~60 to the mid-80s; the remaining suggestions are the deliberate non-goals above.)\n\n**Will it lock me out over SSH?**\nThe design goes to real lengths to prevent that (drop-in config, `ssh -Q` crypto filtering, `sshd -t` with restore-from-backup, never disabling passwords without keys, always allowing the live SSH port before default-deny). Still — keep a second root session open on the first remote `--apply`.\n\n**Why is the firewall off on container hosts?**\nDocker inserts its own nftables rules that bypass ufw. Manage container-host ingress at the runtime/orchestrator/cloud-SG layer.\n\n**Why does `fs.suid_dumpable` show SKIP on my desktop?**\nThe `workstation` profile deliberately keeps apport, and apport sets `suid_dumpable=2` on every boot (it needs it to intercept suid crashes). Enforcing `0` there would be a lie that lasts until the next reboot, so the tool skips it honestly and says why. On `server` (apport disabled) it's enforced and audited strictly.\n\n**My audit shows a WARN about AppArmor complain-mode profiles — is that bad?**\nOn Desktop images several snap/browser profiles ship in complain mode; the WARN is informational. Review with `aa-status` and `aa-enforce` selectively — the tool won't do it blindly for you.\n\n**I ran rollback but the audit score didn't drop back to the original — is rollback broken?**\nAlmost certainly not — three effects stack up. (1) Rollback restores *files*; live kernel parameters and loaded audit rules keep their values until **reboot**. (2) Packages installed by the run stay installed by design, so their checks keep passing. (3) Rollback is **per-run**: if you applied several times, the latest rollback returns you to the state before the *latest* apply — which may already have been hardened by earlier ones. For a clean revert test: fresh VM (or snapshot) → one apply → rollback → reboot → audit.\n\n**Potentially disruptive options** (all opt-in): `noexec /tmp`, IPv6 off, `usb-storage` off, `ptrace_scope=2`, auditd immutable `-e 2`, faillock PAM wiring.\n\n**Can I run it in CI?**\nYes. `--audit` needs no confirmation and exits cleanly; the TXT report diffs well across runs. `--apply` prompts for confirmation only on an interactive TTY. Non-root audit runs work (with reduced coverage, clearly flagged) and fall back to a `/tmp` state dir automatically.\n\n---\n\n## License\n\nMIT. Provided as-is, without warranty. You are responsible for testing on non-production systems and keeping a rollback path before enforcing on anything you care about.\n\n---\n\n*ZavetSec — practical security engineering.*\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fzavetsec%2Fharden-ubuntu","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fzavetsec%2Fharden-ubuntu","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fzavetsec%2Fharden-ubuntu/lists"}