Skip to content

carlok/Ubuntu-Hardening

 
 

Repository files navigation

Hetzner VM Hardening Provisioner

Fork of AndyHS-506/Ubuntu-Hardening.

Why this exists

A freshly created cloud VM is briefly exposed: root SSH on port 22, default config, while the hardening script runs its course — several minutes of package installs and service configuration. That window is small, but it exists, and automated scanners find new IPs fast.

The approach is to separate the work into two phases:

  • Phase 1 (~30s, no apt) — configures only what is already present on a fresh Ubuntu 24.04 image:
    • UFW is enabled early: default-deny-incoming, only the new random SSH port open — OS-level firewall closes the gap within seconds of first connection
    • New unprivileged user, key-only SSH, root locked, random high port
    • sysctl hardening, TCP wrappers, hostname randomised
    • At exit: Hetzner Cloud Firewall updated via API — port 22 closed, random port opened — a second layer on top of UFW
  • Phase 2 (full CIS pipeline) — runs entirely behind both firewalls, as an unprivileged user on the new port: package updates, AppArmor, auditd, AIDE, PAM hardening, fail2ban, rkhunter, msmtp, Podman

An orchestrator drives both phases through the Hetzner Cloud API, so the whole thing — VM creation, hardening, verification — runs as a single command with no manual steps. Nothing is installed on the host; everything runs inside a container.

The result is a CIS Level 1/2 hardened Ubuntu 24.04 VM, fully automated, in under 15 minutes.


Background

This project is based on Hardening-Ubuntu-2024.sh from the upstream repository, a comprehensive CIS Level 1/2 hardening script for Ubuntu 24.04 covering kernel parameters, AppArmor, auditd, PAM policy, SSH hardening, filesystem restrictions, AIDE integrity checking, and more.

We kept the upstream script's structure and section numbering as a reference point, but significantly reworked and extended it:

  • Split into two phases. The upstream ran everything in one pass, leaving the VM exposed on port 22 with root access for the full duration (several minutes). We separated the work into an immediate lockdown phase (~30 seconds, no package installs) and a full CIS phase, so the attack surface is minimised from the very first seconds of the VM's life.
  • Added a Python orchestrator (provision.py) that drives both phases via the Hetzner Cloud API: creates the VM and firewall, runs Phase 1, closes port 22 at the network level, reconnects as the new unprivileged user, then runs Phase 2.
  • SSH hardening split: Phase 1 writes the complete sshd_config (custom port, key-only auth, AllowUsers). Phase 2 adds a drop-in at sshd_config.d/50-cis-hardening.conf for cipher and MAC hardening without overwriting Phase 1's access settings.
  • Replaced postfix with msmtp for lightweight SMTP-relay-based alerting, wired as the system MTA so auditd, AIDE, rkhunter, and logwatch can all send email with no daemon running.
  • Added tooling: fail2ban, needrestart, rkhunter (with nightly cron), logwatch, Podman rootless runtime.
  • Added destroy.py: a companion CLI tool to cleanly tear down a server and all associated Hetzner resources (firewall, orphaned SSH keys).

Provisioning Flow

sequenceDiagram
    participant H as Host (you)
    participant C as Container<br/>(provision.py)
    participant API as Hetzner API
    participant VM as Ubuntu 24.04 VM

    Note over H,C: ./run.sh

    H->>C: Start provisioner container
    C->>C: Generate RSA-4096 keypair
    C->>H: Save private key → ./keys/id_rsa
    C->>API: Upload public key (prov-key-*)
    C->>API: Create firewall (port 22 only)
    C->>API: Create VM (with key + firewall)
    API-->>C: VM ready — IP address

    Note over C,VM: Phase 1 — Immediate Lockdown (~30s, no apt)
    C->>VM: SSH root@IP:22 (key auth)
    C->>VM: Upload public key → /tmp/provisioner_pub_key
    C->>VM: Upload & run harden-phase1.sh
    Note right of VM: Create user svc_‹hex›<br/>Install SSH key for user<br/>Write sshd_config (new port)<br/>Disable ssh.socket<br/>UFW deny-all + allow new port<br/>sysctl hardening<br/>Lock root, randomise hostname
    VM-->>C: Script exits 0
    C->>VM: systemctl restart ssh
    Note right of VM: sshd restarts on<br/>random high port
    C->>VM: ss -tlnp (verify new port)
    C->>C: Close SSH session

    C->>API: Firewall: close port 22, open random port
    C->>API: Delete prov-key-*

    Note over C,VM: Phase 2 — Full CIS Hardening (several minutes)
    C->>VM: SSH user@IP:random_port (key auth)
    C->>VM: Upload & run harden-phase2.sh (sudo)
    Note right of VM: apt full-upgrade<br/>Remove unnecessary services<br/>AppArmor, auditd, AIDE<br/>SSH cipher/MAC hardening<br/>PAM lockout + password policy<br/>unattended-upgrades<br/>fail2ban, needrestart<br/>rkhunter, logwatch<br/>msmtp (email alerts)<br/>Podman rootless
    VM-->>C: Script exits 0

    Note over C,VM: Post-provisioning verification
    C->>VM: Upload & run verify.sh
    Note right of VM: 41 automated checks:<br/>SSH, UFW, sysctl, services,<br/>AIDE, rkhunter, msmtp,<br/>Podman, network ports, disk
    VM-->>C: Exit code = number of failed checks

    C-->>H: Done — print connection details
    Note over H: ssh -i ./keys/id_rsa<br/>-o IdentitiesOnly=yes<br/>-p <random_port><br/>user@IP
Loading

Key flow

flowchart LR
    K["RSA-4096<br/>keypair"] -->|private| F["./keys/id_rsa<br/>(host)"]
    K -->|public| HZ["Hetzner API<br/>prov-key-*"]
    HZ -->|cloud-init| R["/root/.ssh/<br/>authorized_keys"]
    K -->|public via SFTP| U["/home/user/.ssh/<br/>authorized_keys"]
    HZ -.->|deleted after<br/>Phase 1| X((🗑))
Loading

How It Works

Phase 1 — Immediate lockdown (~30 seconds, no package installs)

Runs as root on port 22 immediately after the VM boots. No apt is involved — pure configuration of pre-installed Ubuntu packages.

  • Creates a random unprivileged user (svc_<8hex>) with key-only SSH access
  • Moves SSH to a random high port (10 000 – 60 000); disables root login and password authentication entirely; restricts AllowUsers to the new account
  • Enables UFW with default-deny-incoming; opens only the new SSH port
  • TCP wrappers: /etc/hosts.deny ALL:ALL, allow only sshd
  • sysctl: SYN cookies, reverse-path filtering, IPv6 disabled, ASLR, dmesg restrict, source-routing disabled
  • Randomises the hostname; disables ctrl-alt-del reboot; locks the root account

As soon as Phase 1 finishes, the Hetzner Cloud Firewall is updated: port 22 is closed and only the new random port is open.

Phase 2 — Full CIS hardening (several minutes)

Reconnects as the new user on the new port and runs the full hardening pipeline via sudo.

  • apt full-upgrade (security + kernel patches), autoremove, clean
  • Removes 20+ unnecessary services (avahi, cups, NFS, Samba, SNMP, …)
  • AppArmor (complain mode), auditd with comprehensive ruleset, AIDE file integrity (daily cron), rsyslog, journald (persistent), process accounting
  • Kernel module blacklisting (cramfs, usb-storage, dccp, sctp, …); secure tmpfs mounts for /tmp, /dev/shm, /var/tmp
  • SSH drop-in at /etc/ssh/sshd_config.d/50-cis-hardening.conf: cipher/MAC hardening, verbose logging — does not overwrite Phase 1 settings
  • PAM: faillock (4 attempts, 15 min lock), pwquality (14-char min), SHA-512 hashing, password history (last 5), 30-min session timeout
  • fail2ban (SSH protection), needrestart (auto-restart services after upgrades)
  • rkhunter baseline + nightly scan (03:30 cron)
  • logwatch daily digest (via msmtp if SMTP is configured)
  • msmtp — lightweight SMTP client wired as system MTA (no postfix daemon); lets auditd, AIDE, rkhunter, logwatch send email alerts
  • Podman rootless runtime for the provisioned user

CIS Benchmark Coverage

Both phases combined implement the following controls from the CIS Ubuntu Linux 24.04 LTS Benchmark:

# CIS Section Level Phase Status
1.1 Filesystem module blacklisting (cramfs, freevxfs, hfs, usb-storage, …) L1 2 ✅ Implemented
1.2 Package updates (full-upgrade, autoremove, GRUB permissions) L1 2 ✅ Implemented
1.3 AppArmor (complain mode), ASLR, ptrace scope L1 2 ✅ Implemented
1.4 Core dump hardening (limits.conf, suid_dumpable) L1 1+2 ✅ Implemented
1.5 Remove prelink/apport; unattended-upgrades (security-only) L1 2 ✅ Implemented
1.6 Login banner / MOTD hardening L1 1+2 ✅ Implemented
1.7 Remove GUI (GDM3) L1 2 ✅ Implemented
1.8 Secure tmpfs mounts (/tmp, /dev/shm, /var/tmp — noexec) L1 2 ✅ Implemented
2.1 Remove unnecessary services (avahi, cups, NFS, Samba, SNMP, …) L1 2 ✅ Implemented
2.4 NTP via systemd-timesyncd (chrony removed) L1 2 ✅ Implemented
2.5 Cron permissions (root only) L1 2 ✅ Implemented
3.1 Disable IPv6, remove Bluetooth L1 1+2 ✅ Implemented
3.2 Disable unused network protocols (DCCP, TIPC, RDS, SCTP) L2 2 ✅ Implemented
3.3 Network sysctl hardening (rp_filter, SYN cookies, redirects, source routing) L1 1+2 ✅ Implemented
4.1 Host firewall — UFW default-deny, SSH-only L1 1+2 ✅ Implemented
5.1 SSH hardening — key-only, no root, random port, cipher/MAC hardening L1 1+2 ✅ Implemented
5.2 sudo hardening (logging, use_pty, env_reset) L1 2 ✅ Implemented
5.4 Password policy (SHA-512, 180-day max, 14-char min, faillock, pwhistory) L1 2 ✅ Implemented
6.1 auditd with comprehensive ruleset (time, user/group, priv esc, modules, …) L2 2 ✅ Implemented
6.2 rsyslog (auth logging, emergency broadcast) L1 2 ✅ Implemented
6.3 journald (persistent storage), log rotation L1 2 ✅ Implemented
6.4 Process accounting (acct) L2 2 ✅ Implemented
6.5 AIDE file integrity monitoring (daily cron) L1 2 ✅ Implemented
7.1 Critical file permissions (/etc/passwd, /etc/shadow, …) L1 2 ✅ Implemented
7.2 Log file permissions (640/750) L1 2 ✅ Implemented

Beyond CIS — additional hardening not in the benchmark:

# Control Phase
8.1 fail2ban (SSH brute-force protection) 2
8.2 msmtp (lightweight MTA for security alerts) 2
8.3 logwatch (daily security digest) 2
8.4 needrestart (auto-restart services after upgrades) 2
8.5 rkhunter (rootkit detection, nightly scan) 2
8.6 Podman (rootless container runtime) 2
Hetzner Cloud Firewall (network-level port control) 1
TCP wrappers (hosts.deny ALL:ALL) 1
Random hostname (obscures server purpose) 1
ctrl-alt-del reboot disabled 1
Root account locked 1
ssh.socket disabled (prevents port override) 1

Not implemented (not applicable to Hetzner Cloud VMs):

Control Reason
GRUB password No physical/console access — cloud VMs boot unattended
Separate partitions (/var, /var/log, /home) Single-disk cloud instances; not practical without custom images
Wireless/WLAN hardening No wireless interface on cloud VMs
SELinux Ubuntu uses AppArmor as its default MAC framework

Usage

1. Configure .env

cp .env.example .env
nano .env          # fill in HCLOUD_TOKEN at minimum

Optional settings: region, server type, a fixed username, and SMTP credentials for email alerts (auditd, AIDE, rkhunter, logwatch all use the same MTA).

2. Build and provision

chmod +x run.sh
./run.sh

The script builds the container image and runs the provisioner. Your private key is saved to ./keys/id_rsa on the host. A full log is saved to ./logs/provision-<timestamp>.log.

3. Connect

PROVISIONING COMPLETE
Server IP  : 1.2.3.4
SSH Port   : 48123
Username   : svc_a1b2c3d4
Private Key: /workspace/id_rsa  (mounted at ./keys/id_rsa on your host)

Connect with:
  ssh -i ./keys/id_rsa -o IdentitiesOnly=yes -p 48123 svc_a1b2c3d4@1.2.3.4

Tip: -o IdentitiesOnly=yes is important if your SSH agent has multiple keys loaded — without it, the agent offers all keys first and MaxAuthTries 3 rejects you before the correct key is tried.

Destroy a VM

To tear down a server and all associated Hetzner resources:

./run.sh destroy hardened-node-a1b2c3          # interactive confirm
./run.sh destroy hardened-node-a1b2c3 --yes    # non-interactive (CI)
./run.sh destroy 1.2.3.4                       # find by IP instead

This deletes: the server (releasing its primary IPv4/IPv6), attached firewalls, and any orphaned prov-key-* SSH keys left from provisioning. Floating IPs are listed as a warning but not auto-deleted.


Testing

Unit tests and code coverage run entirely inside a dedicated container stage — nothing is installed on the host.

./run.sh test

This builds the test stage of the Dockerfile (extends the production image, adds pytest + pytest-cov), runs all tests, and prints a coverage report. The build fails if coverage drops below 60 %.

tests/test_provision.py   — generate_ssh_keypair, generate_random_password,
                            _ColorFormatter, upload_string,
                            execute_remote_script, wait_for_ssh,
                            lockdown_firewall
tests/test_destroy.py     — find_server, find_attached_firewalls,
                            find_orphaned_prov_keys, find_floating_ips

Functions that require live Hetzner API access (main(), destroy()) are integration concerns and are not unit tested here.


Configuration reference

Variable Default Description
HCLOUD_TOKEN Required. Hetzner Cloud API token
SERVER_NAME hardened-node Name prefix — actual name is <prefix>-<6hex>
SERVER_TYPE cx22 Hetzner server type
LOCATION fsn1 Hetzner datacenter location
OS_IMAGE ubuntu-24.04 Base OS image
NEW_USER_NAME (random) Override the provisioned username
SMTP_HOST SMTP relay hostname (enables msmtp + email alerts)
SMTP_PORT 587 SMTP port (STARTTLS)
SMTP_USER SMTP username
SMTP_PASS SMTP password
SMTP_FROM Sender address
ALERT_EMAIL Recipient for security digests

Files

File Purpose
provision.py Orchestrator — Phase 1 → firewall lockdown → Phase 2 → verify
destroy.py Tear down a server and its Hetzner resources
harden-phase1.sh Phase 1 script (immediate lockdown, no apt)
harden-phase2.sh Phase 2 script (full CIS pipeline)
verify.sh Post-provisioning health check — 41 automated checks (SSH, UFW, sysctl, services, AIDE, rkhunter, msmtp, Podman, network ports, disk)
Dockerfile Container image for the provisioner
run.sh Wrapper: ./run.sh to provision, ./run.sh destroy to tear down
logs/ Host-side logs — <mode>-<timestamp>.log for each run
.env.example Configuration template

About

Ubuntu 24.04 CIS Benchmark Hardening Script

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages

  • Python 56.2%
  • Shell 42.4%
  • Dockerfile 1.4%