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

  1. Audit. sudo hardline audit prints every check by phase, a link for each finding, and your grade. It changes nothing.
  2. Preview. sudo hardline apply --phase 1 shows the exact lines it would write.
  3. Apply. sudo hardline apply --phase 1 --commit validates with sshd -t, reloads SSH and starts a 120-second countdown.
  4. 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.
  5. 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. --json prints the versioned report; --sarif prints 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 hardline nftables 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-console is 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. --out creates 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 (stig only; undoing needs a reboot).

Global option: --profile baseline|stig (default baseline).

Phases

#AreaWhat changesLockout guard
1SSHKey-only login, no root login, no X11, idle timeoutGuarded
2Firewallufw, nftables or firewalld on; SSH and listening ports kept openGuarded
3Access controlAppArmor or SELinux enforcing (report only)No
4Auditingauditd on; logs account, sudo and sudoers changesNo
5TLSProtocols and certificate expiry (report only)No
6ContainersDocker daemon defaults; flags ports the firewall can't seeNo
7Kernelsysctl hardening; blocks USB storage and rare protocolsNo
8AccountsNo empty passwords, password quality; stig: ages and inactivityNo

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

ExitMeaning
0Clean, or success
1Findings (or watch run found a regression)
2Bad arguments
3Probe or environment error
4Not root
5Preflight refused the change
6Validation 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 }
}
FieldMeaning
schema_versionReport format version. Currently 1.
tool_versionHardline version that produced it.
osDetected distro and version, e.g. ubuntu 24.04, rocky 9.4.
profilebaseline or stig.
results[].statuspass, fail, warn, not_applicable or error.
results[].observed / expectedWhat the probe saw and what the control wants. Either can be null.
results[].refsSTIG rule IDs and DISA CCIs (empty for baseline-only controls).
results[].articleLink to the control's entry on /hardline/controls/.
scorepassed out of applicable (pass + fail + warn).
gradeSeverity-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; a warn status is always warning.
  • 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 matching partialFingerprints entry. 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.