Hardline docs
Install Hardline and learn every command: audit, apply, confirm, rollback, watch, JSON and SARIF output, config, exit codes, Ansible and troubleshooting.
Everything you need to run Hardline. Short version: audit, then apply one phase at a time, then confirm from a new SSH session.
Requirements
- Ubuntu 24.04, Ubuntu 22.04, Debian 12 and RHEL 9 / Rocky Linux 9 / AlmaLinux 9. Hardline refuses any other OS rather than guess.
- Root (
sudo). - An SSH key that already works for your user, before you apply phase 1.
- Ideally a provider web console, as a second way in while you learn the tool.
Install
Download the release for your CPU, check the checksum, and install it where only root can write:
curl -LO https://github.com/SudoSecurityOrg/Hardline/releases/latest/download/hardline-x86_64-unknown-linux-gnu.tar.gz
curl -LO https://github.com/SudoSecurityOrg/Hardline/releases/latest/download/hardline-x86_64-unknown-linux-gnu.tar.gz.sha256
sha256sum -c hardline-*.tar.gz.sha256
tar -xzf hardline-*.tar.gz
sudo install -o root -g root -m 755 hardline-*/hardline /usr/local/bin/hardline
On ARM servers, use aarch64 in place of x86_64. The revert timer runs the binary as root, so Hardline refuses to arm it from a path a normal user could change, like ~/.cargo/bin. On RHEL-family systems, sudo doesn't search /usr/local/bin; run sudo /usr/local/bin/hardline.
From source: cargo build --release --locked, then install target/release/hardline the same way.
Your first run
- Audit.
sudo hardline auditprints every check by phase, a link for each finding, and your grade. It changes nothing. - Preview.
sudo hardline apply --phase 1shows the exact lines it would write. - Apply.
sudo hardline apply --phase 1 --commitvalidates withsshd -t, reloads SSH and starts a 120-second countdown. - Confirm from a new terminal. Open a new SSH connection and run
sudo hardline confirm. If you can't log in, do nothing; the change reverts on its own. - Repeat for phases 2–8, then
sudo hardline report --out /root/report.html.
Commands
hardline audit [--phase N] [--json | --sarif]- Read-only scan. Exit 0 when everything passes, 1 when there are findings, 3 when a check couldn't run.
--jsonprints the versioned report;--sarifprints SARIF 2.1.0 for code-scanning dashboards. hardline apply --phase N- Dry run: shows the exact files and lines that would change.
hardline apply --phase N --commit [--timeout SECS]- Applies, validates, and for SSH and the firewall waits for
confirm(30–900 seconds, default 120). hardline apply --phase 2 --firewall nftables- Uses Hardline's own
inet hardlinenftables table instead of ufw. Other tables are never flushed. hardline confirm [--from-console]- Keeps a pending change. Must run from a new SSH session;
--from-consoleis for a provider's web console. hardline rollback --phase N [--force]- Restores the files saved before that phase. Refuses if you edited them since, unless
--force. hardline diff [--json]- Shows drift since your last confirmed apply.
hardline status- Shows a pending change, the countdown, the last automatic revert, and snapshots.
hardline report [--out FILE]- Audits and writes a self-contained HTML report with an A–F grade.
--outcreates a new root-only (0600) file and never overwrites. hardline explain <id> [--json]- Explains one control offline: why it matters, what it checks, what apply changes, how to undo it. No root needed.
hardline watch enable [--schedule hourly|daily|weekly]- Installs a systemd timer that re-audits (default daily).
watch run,watch status,watch disable. hardline controls [--json]- Lists every control for the current profile.
hardline apply --phase 4 --allow-irreversible- Also makes audit rules immutable (
stigonly; undoing needs a reboot).
Global option: --profile baseline|stig (default baseline).
Phases
| # | Area | What changes | Lockout guard |
|---|---|---|---|
| 1 | SSH | Key-only login, no root login, no X11, idle timeout | Guarded |
| 2 | Firewall | ufw, nftables or firewalld on; SSH and listening ports kept open | Guarded |
| 3 | Access control | AppArmor or SELinux enforcing (report only) | No |
| 4 | Auditing | auditd on; logs account, sudo and sudoers changes | No |
| 5 | TLS | Protocols and certificate expiry (report only) | No |
| 6 | Containers | Docker daemon defaults; flags ports the firewall can't see | No |
| 7 | Kernel | sysctl hardening; blocks USB storage and rare protocols | No |
| 8 | Accounts | No empty passwords, password quality; stig: ages and inactivity | No |
Profiles
baseline (default) is what SudoSecurity runs on its own servers: modern, practical settings that keep OpenSSH's strong default ciphers. stig (Ubuntu 24.04 only) uses the exact values from the DISA Ubuntu 24.04 STIG, including FIPS-only SSH algorithms, and every control lists its STIG and CCI IDs.
Watch for drift
sudo hardline watch enable installs a systemd timer that re-audits at low CPU and I/O priority. A run fails, so it shows in systemctl --failed, when the grade drops, a passing check starts failing, or someone edits a file Hardline manages. Attach an alert with OnFailure= on hardline-watch.service; each run also logs one line to the journal (journalctl -u hardline-watch.service).
Configuration
Hardline needs no config. To skip a control on purpose, create /etc/hardline/config.toml:
[skip]
ssh-x11-forwarding = "jump host needs X11 for legacy tooling"
The reason is required and is printed in every report, so a skip never hides silently. Config can only skip controls; it can't change what one checks.
JSON and exit codes
sudo hardline audit --json prints a stable, versioned report for CI (schema_version, grade.letter, grade.percent, one result per control).
| Exit | Meaning |
|---|---|
| 0 | Clean, or success |
| 1 | Findings (or watch run found a regression) |
| 2 | Bad arguments |
| 3 | Probe or environment error |
| 4 | Not root |
| 5 | Preflight refused the change |
| 6 | Validation failed; change reverted |
The JSON report, field by field
sudo hardline audit --json (and --phase N to narrow it). Adding fields is a compatible change; renaming or removing one bumps schema_version, so pin on it in scripts.
{
"schema_version": 1,
"tool_version": "0.4.1",
"os": "ubuntu 24.04",
"profile": "baseline",
"results": [
{
"id": "ssh-x11-forwarding",
"phase": 1,
"title": "Disable SSH X11 forwarding",
"severity": "high",
"status": "fail",
"observed": "yes",
"expected": "no",
"refs": { "stig": ["UBTU-24-300022"], "cci": ["CCI-000366"] },
"article": "https://sudosecurity.org/hardline/controls/#ssh-x11"
}
],
"score": { "passed": 30, "applicable": 36 },
"grade": { "letter": "B", "percent": 82 }
}
| Field | Meaning |
|---|---|
schema_version | Report format version. Currently 1. |
tool_version | Hardline version that produced it. |
os | Detected distro and version, e.g. ubuntu 24.04, rocky 9.4. |
profile | baseline or stig. |
results[].status | pass, fail, warn, not_applicable or error. |
results[].observed / expected | What the probe saw and what the control wants. Either can be null. |
results[].refs | STIG rule IDs and DISA CCIs (empty for baseline-only controls). |
results[].article | Link to the control's entry on /hardline/controls/. |
score | passed out of applicable (pass + fail + warn). |
grade | Severity-weighted (high 4, medium 2, low 1). A 90+, B 80+, C 70+, D 60+, else F. A failing high-severity control caps an A at B. |
Useful one-liners with jq:
# grade only
sudo hardline audit --json | jq -r '.grade.letter'
# failing high-severity control IDs
sudo hardline audit --json | jq -r '.results[] | select(.status=="fail" and .severity=="high") | .id'
# STIG IDs still open
sudo hardline --profile stig audit --json | jq -r '.results[] | select(.status=="fail") | .refs.stig[]'
SARIF and code scanning
sudo hardline audit --sarif prints a SARIF 2.1.0 log, the format GitHub code scanning, GitLab, Azure DevOps and most security dashboards import. Each failing or warning control becomes a result:
- Level: high →
error, medium →warning, low →note; awarnstatus is alwayswarning. - Ranking: each rule carries
security-severity(8.0 / 5.0 / 2.0) and tags for phase, profile, STIG ID and CCI. - Location: a server has no source file, so each finding is pinned to a stable virtual path,
hardline/<hostname>/<control-id>, with a matchingpartialFingerprintsentry. Alerts stay de-duplicated across runs and one SARIF per host never collides. - Errors: a check that couldn't run is a tool notification and sets
executionSuccessful: false, so a broken probe can't pass silently. - Grade and score are in
runs[0].properties.
{
"ruleId": "ssh-x11-forwarding",
"level": "error",
"message": { "text": "Disable SSH X11 forwarding: found yes; want no. Fix with `sudo hardline apply --phase 1`." },
"locations": [{ "physicalLocation": {
"artifactLocation": { "uri": "hardline/web-1/ssh-x11-forwarding" },
"region": { "startLine": 1 } } }],
"partialFingerprints": { "hardlineControl/v1": "web-1:ssh-x11-forwarding" }
}
Upload from a self-hosted runner on the server (GitHub code scanning needs a public repository or GitHub Advanced Security):
# .github/workflows/hardline.yml
on:
schedule: [{ cron: "17 4 * * *" }]
workflow_dispatch:
permissions:
security-events: write
jobs:
audit:
runs-on: [self-hosted, web-1]
steps:
- run: sudo /usr/local/bin/hardline audit --sarif > hardline.sarif || [ $? -eq 1 ]
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: hardline.sarif
category: hardline-web-1
Get alerted when watch finds a regression
hardline-watch.service exits 1 on a regression, so any systemd failure hook works. A webhook example; put the URL in a root-only file, not in the unit:
# /etc/systemd/system/hardline-watch.service.d/alert.conf
[Unit]
OnFailure=hardline-alert.service
# /etc/systemd/system/hardline-alert.service
[Unit]
Description=Tell someone Hardline found a regression
[Service]
Type=oneshot
# WEBHOOK_URL=https://... in a root-owned 0600 file
EnvironmentFile=/etc/hardline/alert.env
ExecStart=/usr/bin/curl -fsS --max-time 10 -H 'Content-Type: application/json' \
-d '{"text":"Hardline: %H got worse. Run: journalctl -u hardline-watch.service"}' ${WEBHOOK_URL}
# then: sudo systemctl daemon-reload
Fleets: Ansible and cloud-init
Audit is read-only and safe to run everywhere. Committed changes to SSH and the firewall need a new SSH connection to confirm, which is exactly what a second Ansible task gives you, since Ansible opens a fresh connection when meta: reset_connection runs.
- name: Audit (read-only)
ansible.builtin.command: /usr/local/bin/hardline audit --json
register: audit
changed_when: false
failed_when: audit.rc not in [0, 1]
- name: Apply phase 1 (starts a 120 s revert countdown)
ansible.builtin.command: /usr/local/bin/hardline apply --phase 1 --commit
when: (audit.stdout | from_json).results | selectattr('phase', 'eq', 1) | selectattr('status', 'eq', 'fail') | list | length > 0
- name: Open a fresh SSH connection
ansible.builtin.meta: reset_connection
- name: Confirm from the new connection (proves you can still log in)
ansible.builtin.command: /usr/local/bin/hardline confirm
cloud-init can install Hardline, record a first audit in the boot log and turn on daily monitoring. Leave committed changes to a person or to the Ansible flow above, so every SSH and firewall change is proven by a real login:
#cloud-config
runcmd:
- [sh, -c, "cd /tmp && curl -LO https://github.com/SudoSecurityOrg/Hardline/releases/latest/download/hardline-x86_64-unknown-linux-gnu.tar.gz && curl -LO https://github.com/SudoSecurityOrg/Hardline/releases/latest/download/hardline-x86_64-unknown-linux-gnu.tar.gz.sha256 && sha256sum -c hardline-x86_64-unknown-linux-gnu.tar.gz.sha256 && tar xzf hardline-x86_64-unknown-linux-gnu.tar.gz && install -o root -g root -m 755 hardline-*/hardline /usr/local/bin/hardline"]
- [/usr/local/bin/hardline, audit]
- [/usr/local/bin/hardline, watch, enable]
Gate a deploy on the grade
grade=$(ssh web-1 sudo /usr/local/bin/hardline audit --json | jq -r .grade.letter)
case "$grade" in A|B) echo "grade $grade, deploying" ;; *) echo "grade $grade, refusing" >&2; exit 1 ;; esac
Files Hardline touches
Hardline never edits /etc/ssh/sshd_config; it writes a 10-hardline.conf drop-in that beats cloud images' 50-cloud-init.conf. Other files: ufw rules or /etc/hardline/firewall.nft or the firewalld default zone (phase 2), /etc/audit/rules.d/60-hardline.rules (4), /etc/docker/daemon.json, merged and never replaced (6), /etc/sysctl.d/99-zz-hardline.conf and /etc/modprobe.d/hardline.conf (7), and only the managed lines of pwquality.conf, login.defs and /etc/default/useradd (8). State and snapshots live in /var/lib/hardline/.
Troubleshooting
My new SSH session can't log in
Do nothing. Keep the old session open if you still have it; the change reverts when the countdown ends. If the server reboots first, a boot guard reverts it before SSH starts. sudo hardline status shows the countdown and the last revert.
"No SSH session detected"
You're on a provider console or a local terminal. Confirm with sudo hardline confirm --from-console.
"must be owned by root and not group- or world-writable"
Reinstall the binary with sudo install -o root -g root -m 755 into /usr/local/bin. The revert timer runs it as root, so a user-writable copy would be a privilege-escalation path.
Phase 1 skips ssh-permit-root-login
You're logged in as root. Log in as a normal user with an SSH key and run sudo hardline, so turning off root login can't cut off the session you're using.
Rollback refuses because a file changed
Someone edited a file after Hardline wrote it. Check sudo hardline diff, then use rollback --phase N --force if you still want Hardline's saved copy back.
Uninstall
Run sudo hardline watch disable and sudo hardline rollback --phase N for each phase you want undone, then remove /usr/local/bin/hardline and /var/lib/hardline/. Packages Hardline installed (such as ufw or auditd) stay installed.