#!/usr/bin/env bash
# =============================================================================
#  aartool: one front door for the CyberAar hardening toolkit
#
#  DO NOT EDIT THIS FILE. It is built from scripts/aartool-src/ by
#  scripts/build-aartool.sh, the same way cyberaar-baseline.sh is built from
#  scripts/src/. Edit the sources and rebuild.
#
#  WHY THIS EXISTS
#  The toolkit had two entry points with two incompatible conventions for one
#  workflow. cyberaar-baseline.sh took --host and --user; run-hardening.sh took
#  -t and -u. Worse, run-hardening.sh used -t for the target and -T for the
#  tags: a shift key away from each other, on a tool that rewrites sshd_config,
#  PAM and firewall rules.
#
#  aartool wraps both. Neither is replaced and neither changes: they stay
#  independently usable, and cyberaar-baseline.sh keeps the single-file
#  portability that lets you curl it onto an air-gapped box.
#
#  TWO DELIBERATE DIFFERENCES FROM WHAT IT WRAPS
#
#  1. The dry run is a command, not a flag. run-hardening.sh applies changes by
#     default and takes -c to preview them, so forgetting one character is the
#     difference between a report and a rewritten SSH config. Here `plan` shows
#     and `apply` does.
#
#  2. --target is required. run-hardening.sh defaults to the group
#     `linux_servers`, which is every machine in the inventory, so a bare
#     invocation hardens the entire estate. aartool refuses to guess.
# =============================================================================
set -euo pipefail

AARTOOL_VERSION="3.3.0"

# ── Output ───────────────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
  RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'
  CYAN=$'\033[0;36m'; BOLD=$'\033[1m';   RESET=$'\033[0m'
else
  RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; RESET=''
fi

info()    { printf '%s[INFO]%s  %s\n'  "$CYAN"   "$RESET" "$*"; }
success() { printf '%s[OK]%s    %s\n'  "$GREEN"  "$RESET" "$*"; }
warn()    { printf '%s[WARN]%s  %s\n'  "$YELLOW" "$RESET" "$*" >&2; }

# die() carries the fix, not just the fault. Every message below the first line
# is indented guidance, and it is not decoration: the commonest way a first-time
# user abandons a tool is an error that is accurate and tells them nothing.
die()     { printf '%s[ERROR]%s %s\n'  "$RED"    "$RESET" "$*" >&2; exit 1; }

# --verbose. Off by default because the normal output is the point; on, it says
# what aartool is about to shell out to, which is the only thing that helps when
# it is ansible or ssh that is failing rather than aartool.
AARTOOL_VERBOSE="${AARTOOL_VERBOSE:-0}"
vlog() { [[ "$AARTOOL_VERBOSE" == "1" ]] && printf '%s[..]%s    %s\n' "$CYAN" "$RESET" "$*" >&2; return 0; }

# Show a command before running it, so a failure can be reproduced by hand.
vrun() {
  vlog "run: $*"
  "$@"
}

# The banner prints on the human-facing paths only: a bare invocation and
# --help. Not on --version, which is parsed by scripts and by our own tests, and
# not on every subcommand, where it would be noise in front of the output you
# actually asked for.
#
# Two versions. The block one reads at any size and is what the published
# screenshots show; the ASCII one is for a terminal that cannot render those
# glyphs, which on the machines this tool is pointed at is not hypothetical: a
# serial console on a hardened box, a rescue shell, a locale set to C. The
# fallback is chosen from the locale rather than attempted and repaired, because
# there is no way to un-print a line of boxes.
banner() {
  printf '%s' "$CYAN"
  case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in
    *UTF-8*|*utf8*|*UTF8*)
      cat <<'ART'
 █████╗  █████╗ ██████╗ ████████╗ ██████╗  ██████╗ ██╗
██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██╔═══██╗██╔═══██╗██║
███████║███████║██████╔╝   ██║   ██║   ██║██║   ██║██║
██╔══██║██╔══██║██╔══██╗   ██║   ██║   ██║██║   ██║██║
██║  ██║██║  ██║██║  ██║   ██║   ╚██████╔╝╚██████╔╝███████╗
╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝   ╚═╝    ╚═════╝  ╚═════╝ ╚══════╝
ART
      ;;
    *)
      cat <<'ART'
                  _              _
  __ _  __ _ _ __| |_ ___   ___ | |
 / _` |/ _` | '__| __/ _ \ / _ \| |
| (_| | (_| | |  | || (_) | (_) | |
 \__,_|\__,_|_|   \__\___/ \___/|_|
ART
      ;;
  esac
  printf '%s' "$RESET"
  # ASCII separator: this line prints on the fallback path too, where a
  # middle dot would be exactly the glyph the fallback exists to avoid.
  printf ' %sv%s%s  |  audit, plan, apply, prove\n\n' "$BOLD" "$AARTOOL_VERSION" "$RESET"
}

usage() {
  banner
  cat <<'EOF'
Usage:
  aartool <command> [options]

Commands:
  inspect     Audit a machine and write HTML + JSON reports. Changes nothing.
  plan        Show what hardening would change on a target. Changes nothing.
  apply       Apply hardening to a target.
  surface     Kernel attack surface: what a local privilege escalation
              would still reach on this machine, and how to close it.
  advise      Turn an audit into an ordered plan: what to fix first, what it
              costs, and the command for each wave.
  explain     What a finding means, what an attacker does with it, and what
              closing it breaks.
  doctor      Check everything plan and apply depend on.
  report      Visualise audit reports, or bake them into one shareable file.
  diff        What changed between two audits. Exits non-zero on a regression.
  install     Put aartool on your PATH.

Global options:
  -h, --help        Show this help, or help for a command
  -v, --verbose     Say what is being run, and where. Use this when something
                    fails and the message is not enough.
  -V, --version     Print the version

Examples:
  # Audit the machine you are on
  sudo aartool inspect

  # Audit one remote host
  aartool inspect --host 10.0.1.10 --user admin

  # See what hardening would do. Nothing is changed.
  aartool plan --target web-01 --user ubuntu

  # SSH hardening only, still a preview
  aartool plan --target web-01 --user ubuntu --only ssh

  # Apply it. Asks you to confirm the target by name.
  aartool apply --target web-01 --user ubuntu

  # What would the next kernel LPE still reach here?
  aartool surface

  # What should I fix first, and what does each fix cost me?
  aartool advise --target web-01 --user ubuntu

  # Why does this finding matter, and what breaks if I close it?
  aartool explain KRN-01

Getting started, on a machine you are sitting at:
  sudo aartool inspect -o ./reports   # 109 checks, changes nothing
  aartool advise                      # the ordered plan, newest report
  aartool explain <ID>                # anything in it you do not know

Run 'aartool <command> --help' for the options of a single command.
Add -v to any command to see what it is running.
EOF
}

# ── Locating the toolkit ─────────────────────────────────────────────────────
# aartool may be run from the repo, from a copy in PATH, or from a checkout
# several directories below where it was invoked. It finds the pieces it wraps
# by walking up from its own location, which is the same approach
# run-hardening.sh takes.
#
# It resolves symlinks first. Installing to /usr/local/bin/aartool as a symlink
# is the obvious thing for a user to do, and without this the walk would start
# in /usr/local/bin and find nothing.

_self_dir() {
  local src="${BASH_SOURCE[0]}" dir
  while [[ -L "$src" ]]; do
    dir="$(cd -P "$(dirname "$src")" && pwd)"
    src="$(readlink "$src")"
    [[ "$src" != /* ]] && src="$dir/$src"
  done
  cd -P "$(dirname "$src")" && pwd
}

# Walk up looking for a directory that contains the marker.
_find_up() {
  local marker="$1" search="$2"
  for _ in 1 2 3 4 5 6; do
    [[ -e "$search/$marker" ]] && { printf '%s' "$search"; return 0; }
    search="$(dirname "$search")"
    [[ "$search" == "/" ]] && break
  done
  return 1
}

resolve_paths() {
  local here root

  # An explicit home wins over the search. Needed by anyone who COPIES aartool
  # somewhere rather than symlinking it, and by container images that mount the
  # toolkit at a fixed path.
  if [[ -n "${AARTOOL_HOME:-}" ]]; then
    [[ -d "$AARTOOL_HOME/ansible-hardening" ]] \
      || die "AARTOOL_HOME is set to '$AARTOOL_HOME' but there is no ansible-hardening/ there."
    root="$AARTOOL_HOME"
  else
    here="$(_self_dir)"
    root="$(_find_up "ansible-hardening" "$here")" \
      || die "Cannot find the toolkit. Looked for ansible-hardening/ in $here and six directories above it.
        If you copied aartool out of the repository rather than symlinking it, point it back:
          export AARTOOL_HOME=/path/to/aartool
        'aartool install' creates a symlink precisely so this does not happen."
  fi

  ANSIBLE_BASE="$root/ansible-hardening"
  # Overridable so a run can point at another estate's inventory, and so the
  # tests can work against a fixture instead of whatever is on the machine.
  #
  # A packaged install keeps its inventory in /etc instead. Everything under
  # /usr/share belongs to the package manager and is replaced wholesale on
  # upgrade, so an inventory written there would be silently destroyed by the
  # next `apt upgrade`. The marker file ships only in the .deb and the .rpm, so
  # a git checkout behaves exactly as it always has.
  if [[ -f "$root/.packaged" ]]; then
    INVENTORY="${AARTOOL_INVENTORY:-/etc/aartool/inventory}"
  else
    INVENTORY="${AARTOOL_INVENTORY:-$ANSIBLE_BASE/inventory/hosts}"
  fi
  INVENTORY_EXAMPLE="$ANSIBLE_BASE/inventory/hosts.example"
  TOOLKIT_DASHBOARD="$root/dashboard/index.html"
  BASELINE="$root/scripts/cyberaar-baseline.sh"
  HARDEN="$root/scripts/run-hardening.sh"

  # The inventory is NOT checked here. inventory/hosts is gitignored, because it
  # names real machines, so a fresh clone does not have one; and `inspect` on the
  # local machine has no use for it. Requiring it up front made every command
  # fail on a clean checkout, including the one command that needs nothing.
  # plan and apply check it themselves, where it actually matters.
  [[ -f "$BASELINE" ]] || die "cyberaar-baseline.sh not found: $BASELINE"
  [[ -f "$HARDEN"   ]] || die "run-hardening.sh not found: $HARDEN"
}

# Called by plan and apply, which cannot work without an inventory.
require_inventory() {
  [[ -f "$INVENTORY" ]] && return 0
  if [[ -f "$INVENTORY_EXAMPLE" ]]; then
    die "No inventory at $INVENTORY.
        It is gitignored, because it names real machines, so a fresh clone has none.
        Start from the template:
          cp $INVENTORY_EXAMPLE $INVENTORY"
  fi
  die "No inventory at $INVENTORY. Create it, listing your hosts and groups in INI format."
}

# Confirm a target exists in the inventory before handing it to Ansible.
# The INI inventory lists hosts one per line and groups as [name]; a target may
# legitimately be either, so both forms are accepted.
target_in_inventory() {
  local target="$1"
  grep -qE "^\s*${target}(\s|$)" "$INVENTORY" && return 0
  grep -qE "^\s*\[${target}(:children)?\]\s*$" "$INVENTORY" && return 0
  return 1
}

# ── Kernel attack surface catalogue ──────────────────────────────────────────
# One row per doorway. The KRN-xx family in the baseline audits these; this
# catalogue is what makes them actionable, and it carries the two things an
# audit result cannot: what the mitigation costs, and whether it is safe to
# apply without knowing the workload.
#
# Fields, tab separated:
#   id | sysctl key | desired value | tier | what it closes | what it costs
#
# tier is 'safe' or 'strict'. safe means no mainstream workload is known to
# depend on it, so it can be applied on a server without a conversation.
# strict means it will break something real for somebody, and the cost column
# says what. Defaulting to safe is the difference between a tool people run and
# a tool people uninstall after it takes down their containers.
#
# scripts/tests/test_aartool.sh asserts every id here has a matching KRN check,
# so the catalogue and the audit cannot drift apart.

surface_catalogue() {
  cat <<'ROWS'
KRN-02	kernel.unprivileged_bpf_disabled	1	safe	Unprivileged eBPF, a recurring source of kernel exploits	Nothing on a server. Some eBPF observability agents run privileged anyway.
KRN-04	vm.unprivileged_userfaultfd	0	safe	userfaultfd, used to turn kernel races into reliable exploits	Nothing outside CRIU and some live-migration tooling.
KRN-06	kernel.kexec_load_disabled	1	safe	Booting an arbitrary kernel without firmware, bypassing Secure Boot	Nothing, unless you use kdump crash capture.
KRN-07	dev.tty.ldisc_autoload	0	safe	Autoloading old, lightly audited TTY line-discipline modules	Nothing on a server. Affects some serial and ham radio setups.
KRN-09	net.core.bpf_jit_harden	2	safe	JIT-sprayed code in kernel memory	A small BPF throughput cost. Irrelevant unless you run XDP at line rate.
KRN-10	kernel.sysrq	4	safe	Kernel operations from the physical console, including a memory dump	Loses SysRq except the keyboard reset combination.
KRN-01	kernel.unprivileged_userns_clone	0	strict	The doorway most published Linux LPEs walk through	Breaks rootless Docker and Podman, Chrome's sandbox, and most CI runners.
KRN-03	kernel.io_uring_disabled	2	strict	io_uring, young and disproportionately represented in recent LPEs	Breaks workloads that use it: some databases, proxies and modern async runtimes.
KRN-05	kernel.modules_disabled	1	strict	Loading a rootkit as a kernel module	Irreversible until reboot. Blocks every later modprobe, including yours.
ROWS
}

# Read a sysctl, printing '?' when the key does not exist on this kernel.
surface_read() { sysctl -n "$1" 2>/dev/null || printf '?'; }

# A key is satisfied if it already meets or exceeds the desired value. Several
# of these are "higher is stricter", so an exact match would report a machine
# that is MORE locked down than we ask as non-compliant.
surface_ok() {
  local key="$1" want="$2" have="$3"
  [[ "$have" == "?" ]] && return 2          # not present on this kernel
  case "$key" in
    kernel.unprivileged_bpf_disabled|net.core.bpf_jit_harden|kernel.io_uring_disabled)
      [[ "$have" =~ ^[0-9]+$ ]] && [[ "$have" -ge "$want" ]] ;;
    kernel.sysrq)
      # 0 is stricter than 4; anything else is permissive.
      [[ "$have" == "0" || "$have" == "4" ]] ;;
    user.max_user_namespaces|kernel.unprivileged_userns_clone|vm.unprivileged_userfaultfd)
      [[ "$have" == "0" ]] ;;
    *)
      [[ "$have" == "$want" ]] ;;
  esac
}

# Debian and Ubuntu expose the user namespace switch under a different key from
# the RHEL family. Resolve it per machine rather than guessing from /etc/os-release,
# which is wrong on derivatives.
surface_userns_key() {
  if [[ -e /proc/sys/kernel/unprivileged_userns_clone ]]; then
    printf 'kernel.unprivileged_userns_clone'
  else
    printf 'user.max_user_namespaces'
  fi
}

# ── Knowledge base ───────────────────────────────────────────────────────────
# An audit result tells you a setting is wrong. It does not tell you what an
# attacker does with it, what you lose by changing it, or whether it is the
# thing to fix first. Operators fill that in from memory, or they don't, and
# a report with forty findings and no ordering gets read once and filed.
#
# These entries carry the three things the check line cannot fit:
#   - the concrete path from the finding to a compromised machine
#   - the honest cost of closing it, including what it breaks
#   - where it sits relative to the other findings
#
# Not every ID has an entry. `explain` falls back to the remediation map, which
# covers 99 of 109, so the command always answers. Entries are written where
# knowing the mechanism changes what a reasonable person decides to do.
#
# scripts/tests/test_aartool.sh asserts every ID named here exists as a real
# check, so this file cannot drift into documenting things that do not exist.

kb_ids() {
  cat <<'IDS'
KRN-01 KRN-02 KRN-03 KRN-04 KRN-05 KRN-06 KRN-07 KRN-08 KRN-09 KRN-10 KRN-11 KRN-12
SSH-01 SSH-02 SSH-03 SSH-09 SSH-10 SSH-11 SSH-13
AUTH-04 AUTH-09 AUTH-11 AUTH-15
SYS-03 SYS-04 SYS-05 SYS-11
NET-01 NET-02 NET-05
LOG-01 LOG-04 LOG-08
FS-01 FS-05 FS-06 FS-07
IDS
}

kb_has() { kb_ids | tr ' ' '\n' | grep -qx "$1"; }

# One entry. Sections are fixed so the output is skimmable and greppable:
# WHAT / WHY / COST / BY HAND / WITH AARTOOL / MORE.
kb_entry() {
  case "$1" in

  KRN-01) cat <<'E'
WHAT
  Whether an unprivileged user can create a user namespace, which hands them
  CAP_SYS_ADMIN inside it. Debian and Ubuntu expose this as
  kernel.unprivileged_userns_clone; the RHEL family uses user.max_user_namespaces.

WHY
  This is the doorway most published Linux privilege escalations walk through.
  The exploit rarely attacks the kernel directly from an unprivileged context.
  It creates a user namespace, becomes root inside it, and from there reaches
  subsystems that check for CAP_SYS_ADMIN and nothing else: nftables, OverlayFS,
  io_uring, netfilter. CVE-2022-0185, CVE-2023-0386 and CVE-2023-32233 all take
  this shape. Closing it does not patch those bugs, it removes the step that
  makes them reachable by a normal user.

COST
  Real, and you must decide. This breaks rootless Docker and Podman, Chrome's
  and Firefox's sandboxes, most CI runners, snap confinement, and bubblewrap.
  On a database or an appliance server, nothing notices. On a build host, it
  takes the build host down.

BY HAND
  # Debian / Ubuntu
  echo 'kernel.unprivileged_userns_clone = 0' > /etc/sysctl.d/99-userns.conf
  # RHEL 9 family
  echo 'user.max_user_namespaces = 0' > /etc/sysctl.d/99-userns.conf
  sysctl --system

WITH AARTOOL
  Off by default, deliberately: a role that silently breaks rootless containers
  gets disabled wholesale, and then none of the safe settings apply either.
  Opt in per host or group:
    linux_kernel_restrict_userns: true
  then:  aartool apply --target HOST --user USER --only kernel

MORE
  If you cannot close it, the mitigations that still help are a current kernel,
  seccomp on anything that accepts untrusted input, and Lockdown mode. Check
  first whether anything is actually using it:
    find /proc/*/ns/user -newer /proc/1/ns/user 2>/dev/null | head
E
  ;;

  KRN-02) cat <<'E'
WHAT
  kernel.unprivileged_bpf_disabled. Whether a user without CAP_BPF can load an
  eBPF program.

WHY
  eBPF is a verifier standing between user-supplied bytecode and the kernel's
  own address space. Verifier bugs are frequent and each one is a direct
  arbitrary-read or arbitrary-write in kernel memory: CVE-2021-3490,
  CVE-2021-33200, CVE-2022-23222. Unprivileged eBPF gives every local account a
  path to that verifier.

COST
  On a server, nothing. Observability agents that use eBPF (Falco, Cilium,
  Pixie, bpftrace) already run privileged and are unaffected. Value 2 also
  disables the JIT for unprivileged programs and cannot be lowered again
  without a reboot.

BY HAND
  echo 'kernel.unprivileged_bpf_disabled = 1' > /etc/sysctl.d/99-bpf.conf
  sysctl --system

WITH AARTOOL
  Applied by default (safe tier):
    aartool apply --target HOST --user USER --only kernel

MORE
  Ubuntu has shipped this on by default since 20.10. Finding it at 0 usually
  means something set it back, which is worth understanding before you change it.
E
  ;;

  KRN-03) cat <<'E'
WHAT
  kernel.io_uring_disabled. Whether io_uring rings can be created, and by whom.
  0 = anyone, 1 = only with CAP_SYS_ADMIN, 2 = nobody.

WHY
  io_uring is the youngest large syscall surface in the kernel and is
  disproportionately represented in recent local privilege escalations. Google
  reported it in roughly 60 percent of the kernel exploits submitted to its own
  bug bounty in one period, and disabled it across ChromeOS, Android and its
  production fleet. It also bypasses most seccomp filters, because the work is
  submitted through a ring rather than through the syscalls a filter watches:
  a sandbox that blocks openat does not block an io_uring open.

COST
  Real. It breaks anything that uses it, and adoption is growing: recent
  PostgreSQL and MySQL builds, some proxies, Rust and Go async runtimes,
  and modern container runtimes. Test before you set 2.

BY HAND
  echo 'kernel.io_uring_disabled = 2' > /etc/sysctl.d/99-iouring.conf
  sysctl --system
  # Requires a kernel with the knob: 6.6+, or a distro backport.

WITH AARTOOL
  Off by default. Opt in:
    linux_kernel_restrict_io_uring: true
  then:  aartool apply --target HOST --user USER --only kernel

MORE
  Check whether anything is using it before deciding:
    grep -l io_uring /proc/*/maps 2>/dev/null | head
  Value 1 is the middle ground: privileged callers keep it, everyone else
  loses it, and most of the exploit value goes with them.
E
  ;;

  KRN-04) cat <<'E'
WHAT
  vm.unprivileged_userfaultfd. Whether an unprivileged process can handle its
  own page faults in userspace.

WHY
  userfaultfd rarely is the bug. It is what makes the bug reliable. A kernel
  use-after-free or double-free usually has a race window measured in
  microseconds; userfaultfd lets an attacker stop the kernel mid-operation, at
  a page fault of their choosing, and hold it there for as long as they need to
  groom the heap. It turns a flaky proof of concept into a dependable exploit.
  It shows up in the write-ups for CVE-2022-2588, CVE-2023-3269 and many others.

COST
  Effectively none on a server. CRIU checkpoint/restore uses it, as do some
  live-migration and userspace-paging systems. If you do not run those, nothing
  changes.

BY HAND
  echo 'vm.unprivileged_userfaultfd = 0' > /etc/sysctl.d/99-uffd.conf
  sysctl --system

WITH AARTOOL
  Applied by default (safe tier):
    aartool apply --target HOST --user USER --only kernel

MORE
  This is the highest ratio of exploit reliability removed to workload broken
  in the whole KRN family. If you only change one thing here, change this one.
E
  ;;

  KRN-05) cat <<'E'
WHAT
  kernel.modules_disabled. Once set to 1, no further kernel module can be
  loaded until reboot.

WHY
  Loadable modules are how a kernel rootkit installs itself. An attacker who
  reaches root and wants to stay wants a module: it survives your process
  hunting, hides its own files and connections, and outlives anything you do in
  userspace. Setting this closes that door for the rest of the uptime.

COST
  Irreversible until reboot, and it blocks every later modprobe, including
  yours. Anything that loads modules on demand fails after this point: mounting
  an unusual filesystem, plugging in hardware, starting a VPN whose module is
  not already loaded, some container network drivers.

BY HAND
  # Load everything you need FIRST, then:
  sysctl -w kernel.modules_disabled=1
  # As a boot-time setting, it must run last:
  echo 'kernel.modules_disabled = 1' > /etc/sysctl.d/99-zz-modules.conf

WITH AARTOOL
  Off by default. Opt in only on appliances with a fixed hardware and network
  profile:
    linux_kernel_lock_modules: true
  then:  aartool apply --target HOST --user USER --only kernel

MORE
  A softer alternative that keeps modprobe working is module signature
  enforcement (module.sig_enforce=1 on the kernel command line) plus Secure
  Boot, so only modules signed by a key in the MOK database load. See SYS-08.
E
  ;;

  KRN-06) cat <<'E'
WHAT
  kernel.kexec_load_disabled. Whether kexec can load a replacement kernel.

WHY
  kexec boots a kernel image directly, skipping firmware. That skips Secure
  Boot verification with it. An attacker at root can kexec into a kernel they
  control, and every measurement your firmware made becomes meaningless. It is
  also a clean way to destroy volatile evidence: memory is gone and the machine
  looks like it rebooted normally.

COST
  Nothing, unless you use kdump crash capture, which is built on kexec. If
  kdump is enabled, decide which you want.

BY HAND
  echo 'kernel.kexec_load_disabled = 1' > /etc/sysctl.d/99-kexec.conf
  sysctl --system
  # One-way until reboot, like modules_disabled.

WITH AARTOOL
  Applied by default (safe tier):
    aartool apply --target HOST --user USER --only kernel

MORE
  systemctl is-active kdump  # check before applying on RHEL
E
  ;;

  KRN-07) cat <<'E'
WHAT
  dev.tty.ldisc_autoload. Whether an unprivileged ioctl can make the kernel
  load a TTY line discipline module on demand.

WHY
  There are around thirty line-discipline drivers in the tree. Several date to
  the 1990s, are maintained by nobody, and have never been fuzzed seriously.
  With autoload on, any user can reach all of them with one ioctl. This is how
  CVE-2017-2636 (n_hdlc) and CVE-2020-14386 were reached. Turning autoload off
  does not remove the code, it removes the unprivileged path to it.

COST
  Nothing on a server. Serial console setups, SLIP/PPP and ham radio (AX.25)
  configurations may need a specific discipline, which you can load explicitly
  at boot instead.

BY HAND
  echo 'dev.tty.ldisc_autoload = 0' > /etc/sysctl.d/99-ldisc.conf
  sysctl --system

WITH AARTOOL
  Applied by default (safe tier):
    aartool apply --target HOST --user USER --only kernel

MORE
  Cheapest item in the family: a large, ancient attack surface removed at
  essentially zero operational cost.
E
  ;;

  KRN-08) cat <<'E'
WHAT
  Kernel lockdown mode, read from /sys/kernel/security/lockdown. States are
  none, integrity and confidentiality.

WHY
  Lockdown draws a line that UID 0 alone does not: it stops root from modifying
  the running kernel. integrity blocks writes to /dev/mem, unsigned module
  loading, and raw PCI and MSR access. confidentiality additionally blocks
  reading kernel memory, so /proc/kcore and kprobes go too. Without it, root
  and ring 0 are the same privilege, and a compromised root account can install
  something your userspace tooling will never see.

COST
  integrity breaks unsigned out-of-tree modules, which in practice means NVIDIA,
  VirtualBox and ZFS builds unless they are signed for your MOK.
  confidentiality additionally breaks most kernel debugging and some profilers.

BY HAND
  Lockdown is not a sysctl. It is enabled at boot, and only meaningfully with
  Secure Boot on:
    # add to GRUB_CMDLINE_LINUX in /etc/default/grub
    lockdown=integrity
    grub2-mkconfig -o /boot/grub2/grub.cfg   # RHEL
    update-grub                              # Debian / Ubuntu

WITH AARTOOL
  No role applies this. It is a boot parameter with a firmware dependency, and
  a playbook that edits the kernel command line on a machine it cannot reach
  the console of is a way to lose the machine. aartool reports it and leaves
  the change to you.

MORE
  Under Secure Boot, most distributions enable lockdown=integrity automatically.
  Finding it at none on a Secure Boot machine means something turned it off.
E
  ;;

  KRN-09) cat <<'E'
WHAT
  net.core.bpf_jit_harden. Whether the eBPF JIT blinds constants and randomises
  its output. 0 = off, 1 = for unprivileged, 2 = for everyone.

WHY
  The JIT writes attacker-influenced constants into executable kernel memory.
  Without blinding, an attacker encodes a short instruction sequence as a
  constant in a BPF program, and the JIT faithfully emits it into a page the
  kernel will execute: JIT spraying. Blinding splits each constant across
  instructions so the encoding cannot survive.

COST
  A measurable but small BPF throughput cost. Irrelevant unless you are running
  XDP at line rate, in which case measure it. Value 2 covers privileged
  programs too, which matters because a compromised privileged agent is exactly
  the case you are defending against.

BY HAND
  echo 'net.core.bpf_jit_harden = 2' > /etc/sysctl.d/99-bpf.conf
  sysctl --system

WITH AARTOOL
  Applied by default (safe tier):
    aartool apply --target HOST --user USER --only kernel

MORE
  Redundant if KRN-02 is set to 2, which disables the JIT for unprivileged
  callers outright. Set both: they cover different callers.
E
  ;;

  KRN-10) cat <<'E'
WHAT
  kernel.sysrq. Which magic SysRq key operations the kernel will honour.

WHY
  SysRq is a debugging interface that runs in kernel context and ignores
  permissions. The dangerous ones are not the reboot: it can dump memory, kill
  every process, remount everything read-only, and drop to a kernel debugger.
  It is reachable from the physical console and, on many systems, by writing to
  /proc/sysrq-trigger. On a hosted or colocated machine, "physical console"
  means anyone with the out-of-band management credentials.

COST
  Small. Value 4 keeps the keyboard-only subset, which is the one that gets a
  hung machine down cleanly. 0 disables everything, including that.

BY HAND
  echo 'kernel.sysrq = 4' > /etc/sysctl.d/99-sysrq.conf
  sysctl --system

WITH AARTOOL
  Applied by default (safe tier), value 4:
    aartool apply --target HOST --user USER --only kernel

MORE
  aartool accepts either 0 or 4 as compliant. 0 is stricter; 4 is the value
  most operations teams can actually live with.
E
  ;;

  KRN-11) cat <<'E'
WHAT
  Whether rarely used filesystem and network protocol modules are blacklisted:
  cramfs, freevxfs, jffs2, hfs, hfsplus, squashfs, udf, dccp, sctp, rds, tipc.

WHY
  These are drivers almost nobody uses and almost nobody audits, and they are
  reachable without privileges. A filesystem driver parses attacker-controlled
  bytes the moment a user mounts an image or plugs in a device; a protocol
  driver parses attacker-controlled packets. CVE-2021-27365 (iSCSI),
  CVE-2022-2588 (route4) and the long line of DCCP and SCTP bugs are all in
  this category. If you do not use them, having them loadable is pure downside.

COST
  Nothing, provided you actually do not use them. squashfs matters if you use
  snap packages or container images that mount squashfs layers. Check before
  blacklisting that one.

BY HAND
  cat > /etc/modprobe.d/99-cis-blacklist.conf <<'EOF'
  install cramfs /bin/false
  install freevxfs /bin/false
  install jffs2 /bin/false
  install hfs /bin/false
  install hfsplus /bin/false
  install udf /bin/false
  install dccp /bin/false
  install sctp /bin/false
  install rds /bin/false
  install tipc /bin/false
  EOF

WITH AARTOOL
  Applied by default:
    aartool apply --target HOST --user USER --only kernel,sysctl

MORE
  blacklist alone is not enough: it only stops autoload by alias, not an
  explicit modprobe. install ... /bin/false is what actually blocks it.
E
  ;;

  KRN-12) cat <<'E'
WHAT
  A summary, not a separate setting. It counts how many of KRN-01 to KRN-11 are
  closed and reports the attack surface as a whole.

WHY
  The individual findings are each defensible to ignore. The aggregate is the
  thing worth looking at: a machine with every doorway open is one kernel CVE
  away from local root, and the next one is always weeks away. This line is
  what you put in front of someone who has to approve the change.

COST
  None. It changes nothing.

BY HAND
  aartool surface

WITH AARTOOL
  aartool surface            # what is open, what it costs to close
  aartool surface --strict   # include the three that break things

MORE
  KRN-12 counts recorded verdicts rather than re-reading the sysctls, so it
  cannot disagree with the checks above it. That was a real bug once.
E
  ;;

  SSH-01) cat <<'E'
WHAT
  PermitRootLogin in sshd_config.

WHY
  Root is the one username an attacker never has to guess. Leaving it able to
  log in halves the work of every credential attack against the host, and it
  destroys attribution: three admins sharing root produce a log that says root
  did it, which is useless during an incident and fails every audit that asks
  who made a change.

COST
  You need a working non-root account with sudo BEFORE you apply this, and you
  need to have tested it. This is the single most common way to lock yourself
  out of a machine.

BY HAND
  # verify first, in another session that stays open:
  ssh admin@host sudo -n true
  # then:
  sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
  sshd -t && systemctl reload sshd

WITH AARTOOL
  aartool plan  --target HOST --user USER --only ssh    # preview
  aartool apply --target HOST --user USER --only ssh

MORE
  prohibit-password is also accepted: root keys work, root passwords do not.
  Use it where automation still needs root, then remove it.
E
  ;;

  SSH-02) cat <<'E'
WHAT
  PasswordAuthentication in sshd_config.

WHY
  With passwords on, your exposure is the weakest password on the machine and
  the internet gets unlimited attempts at it. Every honeypot dataset says the
  same thing: an SSH port reachable from the internet sees thousands of
  credential attempts a day within hours of opening. Key-only authentication
  removes the entire class.

COST
  Every account that needs to log in must have a key installed first. Miss one
  and that user is locked out. Check who would lose access:
    for u in $(awk -F: '$3>=1000 && $7!~/nologin|false/{print $1}' /etc/passwd); do
      [ -s "/home/$u/.ssh/authorized_keys" ] || echo "NO KEY: $u"
    done

BY HAND
  sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
  sshd -t && systemctl reload sshd

WITH AARTOOL
  aartool apply --target HOST --user USER --only ssh

MORE
  Also set KbdInteractiveAuthentication no. On many builds it is a second path
  to the same password prompt, and turning off only the first one leaves the
  door open. aartool's ssh role sets both.
E
  ;;

  SSH-03) cat <<'E'
WHAT
  MaxAuthTries: how many authentication attempts one TCP connection gets.

WHY
  The default of 6 lets an attacker try six passwords per connection, which
  multiplies whatever connection rate they can sustain. Lowering it to 3 or 4
  is a rate limit that costs nothing.

COST
  One that catches people out: ssh offers every key in your agent, and each
  offer counts as an attempt. An operator with eight keys loaded fails to
  authenticate before reaching the right one, and fail2ban bans them. Use
  IdentitiesOnly=yes with an explicit -i.

BY HAND
  sed -i 's/^#*MaxAuthTries.*/MaxAuthTries 3/' /etc/ssh/sshd_config
  sshd -t && systemctl reload sshd

WITH AARTOOL
  aartool apply --target HOST --user USER --only ssh

MORE
  Pair it with fail2ban, which turns repeated failures into a block rather
  than an unlimited retry budget.
E
  ;;

  SSH-13) cat <<'E'
WHAT
  The key exchange, cipher and MAC algorithms sshd will negotiate.

WHY
  OpenSSH still offers legacy algorithms for compatibility. Some are broken
  outright (CBC ciphers with the standard MAC construction, hmac-md5,
  hmac-sha1-96, diffie-hellman-group1-sha1 at 1024 bits). An attacker who can
  see or modify the traffic downgrades the negotiation to whichever weak
  algorithm both ends still accept, so offering them at all is the exposure.

COST
  Clients older than roughly 2014 stop connecting. In practice this means
  legacy network appliances, some Java SSH libraries, and old jump boxes. Check
  what is actually connecting before you cut:
    grep 'Accepted' /var/log/auth.log | awk '{print $NF}' | sort -u

BY HAND
  cat >> /etc/ssh/sshd_config <<'EOF'
  KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512
  Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes256-ctr
  MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
  EOF
  sshd -t && systemctl reload sshd

WITH AARTOOL
  aartool apply --target HOST --user USER --only ssh

MORE
  On RHEL 9 the system-wide crypto policy may override sshd_config. See
  SYS/crypto checks and update-crypto-policies --set DEFAULT:NO-SHA1.
E
  ;;

  SSH-10) cat <<'E'
WHAT
  Banner in sshd_config, and the contents of /etc/issue.net.

WHY
  This one is legal, not technical, and it is the reason it is in every
  benchmark. In several jurisdictions a prosecution for unauthorised access is
  materially harder if the system never stated that access was restricted. It
  is also the control an ISO 27001 or PCI auditor checks in about four seconds,
  because it is trivially verifiable.

COST
  None, with one caveat: the banner must not name the operating system,
  version, hostname or organisation contact details. A banner that helpfully
  says "Ubuntu 22.04 LTS - contact ops@example.com" is reconnaissance you
  published yourself.

BY HAND
  cat > /etc/issue.net <<'EOF'
  Authorised access only. All activity is monitored and recorded.
  Disconnect immediately if you are not an authorised user.
  EOF
  sed -i 's|^#*Banner.*|Banner /etc/issue.net|' /etc/ssh/sshd_config
  sshd -t && systemctl reload sshd

WITH AARTOOL
  aartool apply --target HOST --user USER --only ssh,banner

MORE
  Have your legal or compliance owner approve the wording once, then apply the
  same text estate-wide. Set DebianBanner no as well, so the version string is
  not leaked in the protocol handshake before the banner is ever shown.
E
  ;;

  SSH-11) cat <<'E'
WHAT
  ClientAliveInterval and ClientAliveCountMax: when sshd drops an idle session.

WHY
  The threat is an unattended authenticated session, not a network problem. An
  admin who walks away from an unlocked laptop with an open root session has
  handed over the machine to anyone who sits down. It also bounds how long a
  hijacked session stays useful after the operator's connection is gone.

COST
  Long-running interactive jobs die when the session is dropped. The answer is
  tmux or screen, not a longer timeout, and telling people that is part of
  applying this.

BY HAND
  cat >> /etc/ssh/sshd_config <<'EOF'
  ClientAliveInterval 300
  ClientAliveCountMax 0
  EOF
  sshd -t && systemctl reload sshd

WITH AARTOOL
  aartool apply --target HOST --user USER --only ssh

MORE
  ClientAliveCountMax 0 means one missed interval ends the session, so the
  interval is the timeout. This is server-side and cannot be overridden by the
  client, unlike a shell TMOUT (see AUTH-10).
E
  ;;

  AUTH-04) cat <<'E'
WHAT
  The minimum password length pam_pwquality enforces: minlen in
  /etc/security/pwquality.conf. The check wants 12 or more.

WHY
  Length is the setting that actually helps, and NIST SP 800-63B now recommends
  it over forced character classes precisely because it survives contact with
  users. A 12-character passphrase resists offline cracking of a stolen shadow
  file for orders of magnitude longer than an 8-character one with a digit and
  a symbol bolted on.

  Two neighbours matter as much and are separate checks. First, the hashing
  algorithm: if /etc/shadow still holds md5 or a low
  round count, a stolen shadow file is cracked in hours rather than years.
  Second, remember=N, which stops the rotation theatre where a forced change
  becomes Password1 to Password2 and back.

COST
  Rules that are too aggressive produce passwords on sticky notes. NIST
  SP 800-63B now recommends length and a breached-password check over forced
  character classes. Length is the setting that actually helps.

BY HAND
  # /etc/security/pwquality.conf
  minlen = 14
  dcredit = -1
  ucredit = -1
  ocredit = -1
  lcredit = -1
  # /etc/pam.d/common-password (Debian) or via authselect (RHEL)
  password requisite pam_pwquality.so retry=3
  password required  pam_pwhistory.so remember=5
  # verify the hash in use:
  awk -F: '$2 ~ /^\$/ {print $1, substr($2,1,3)}' /etc/shadow   # want $6 or $y

WITH AARTOOL
  aartool apply --target HOST --user USER --only auth,pam

MORE
  Editing PAM by hand is the classic way to lock everyone out of a machine.
  Keep a root session open in another terminal until you have tested a login.
  On RHEL 9, edit through authselect rather than the files directly.
E
  ;;

  AUTH-09) cat <<'E'
WHAT
  pam_faillock: whether repeated failed logins lock the account.

WHY
  Without it, local and console authentication has no rate limit at all.
  fail2ban watches SSH; it does not watch su, sudo, the console, or a display
  manager. Faillock is the control that covers those.

COST
  It creates a denial of service you did the work for: an attacker who knows a
  username can lock it out on purpose. Never apply an unlock_time of 0
  (permanent) to accounts you rely on, and always exempt root
  (even_deny_root off) unless you have out-of-band console access.

BY HAND
  # /etc/security/faillock.conf
  deny = 5
  unlock_time = 900
  fail_interval = 900
  # inspect and clear:
  faillock --user alice
  faillock --user alice --reset

WITH AARTOOL
  aartool apply --target HOST --user USER --only auth,pam

MORE
  15 minutes stops password guessing dead while keeping a locked-out colleague
  productive after a coffee. Permanent lockouts generate helpdesk tickets, not
  security.
E
  ;;

  AUTH-11) cat <<'E'
WHAT
  Every account in /etc/passwd whose UID is 0.

WHY
  There should be exactly one, and it should be root. A second UID 0 account is
  a textbook persistence mechanism: it is root, it does not look like root in
  the logs, and it survives a password change on the real root account. It is
  also easy to create accidentally with a mistyped useradd -u.

COST
  None to check. Before deleting one, find out what it is. Some appliance
  vendors legitimately ship a second UID 0 account, and removing it breaks
  their support tooling.

BY HAND
  awk -F: '$3==0 {print $1}' /etc/passwd      # expect exactly: root
  # if there is another, find out who made it and when:
  grep -E 'useradd|usermod' /var/log/auth.log /var/log/secure 2>/dev/null
  # then, once you are sure:
  userdel -r suspicious_account

WITH AARTOOL
  aartool inspect          # reports it
  The roles do not delete accounts. Deleting a UID 0 account on the strength of
  a scan, without knowing what it is, is how a playbook takes down an estate.

MORE
  Extend the same check to sudoers: a NOPASSWD:ALL entry is UID 0 by another
  route. grep -r NOPASSWD /etc/sudoers /etc/sudoers.d/
E
  ;;

  AUTH-15) cat <<'E'
WHAT
  Defaults use_pty in the sudoers file.

WHY
  Without a pty, a program run under sudo shares the terminal of the calling
  user. A compromised unprivileged process can then inject characters into that
  terminal with the TIOCSTI ioctl and have them executed as root after sudo
  returns. use_pty gives the privileged command its own pty, which severs that
  channel. It is also a precondition for usable sudo session logging.

COST
  Almost none. A small number of programs that expect to inherit the exact
  terminal misbehave, mostly interactive full-screen tools invoked in unusual
  ways.

BY HAND
  visudo    # never edit /etc/sudoers with a plain editor
  # add:
  Defaults use_pty
  Defaults logfile="/var/log/sudo.log"

WITH AARTOOL
  aartool apply --target HOST --user USER --only sudo

MORE
  Modern kernels also offer dev.tty.legacy_tiocsti=0, which removes the ioctl
  entirely. Set both: use_pty covers the sudo path, the sysctl covers the rest.
E
  ;;

  SYS-03) cat <<'E'
WHAT
  Whether security updates install automatically: dnf-automatic on RHEL,
  unattended-upgrades on Debian and Ubuntu.

WHY
  The window between a public exploit and a compromise is now measured in
  hours, and nobody patches a fleet by hand at that pace. Every large breach
  post-mortem that names a patch names one that was available and not applied.
  Automatic security updates are the single highest-value item in this entire
  report, and the one most often argued away.

COST
  The real objection is an unattended change breaking a service, so configure
  it honestly: security repository only, not everything; a fixed maintenance
  window; and mail on failure so a silent breakage does not go unnoticed for
  months.

BY HAND
  # RHEL
  dnf install -y dnf-automatic
  sed -i 's/^upgrade_type.*/upgrade_type = security/'   /etc/dnf/automatic.conf
  sed -i 's/^apply_updates.*/apply_updates = yes/'      /etc/dnf/automatic.conf
  systemctl enable --now dnf-automatic.timer
  # Debian / Ubuntu
  apt install -y unattended-upgrades
  dpkg-reconfigure -plow unattended-upgrades

WITH AARTOOL
  aartool apply --target HOST --user USER --only updates,patching

MORE
  Installing the update is not the same as running it. A kernel update sits in
  /boot doing nothing until reboot: that is SYS-11, and it is a separate
  problem with a separate answer.
E
  ;;

  SYS-04) cat <<'E'
WHAT
  SELinux on RHEL, AppArmor on Ubuntu and Debian: whether mandatory access
  control is enforcing.

WHY
  Discretionary permissions ask who owns the file. Mandatory access control
  asks what this program is allowed to do at all, and that is the difference
  between a web server bug and a compromised machine. When your httpd is
  exploited, SELinux is the reason the shell it spawns cannot read /etc/shadow
  or open an outbound connection. It is the highest-value control on the list
  after patching, and the one most often set to permissive during an
  installation and never set back.

COST
  Denials, which is the point. Run in permissive first, collect what would have
  been blocked, fix the labels, then enforce. Do not enable it blind on a
  production machine.

BY HAND
  # RHEL
  getenforce
  ausearch -m AVC -ts recent      # what would break
  setenforce 1                    # this boot
  sed -i 's/^SELINUX=.*/SELINUX=enforcing/' /etc/selinux/config   # persistent
  # Ubuntu / Debian
  aa-status
  aa-enforce /etc/apparmor.d/*

WITH AARTOOL
  aartool apply --target HOST --user USER --only mac

MORE
  A relabel may be required after switching from disabled: touch /.autorelabel
  and reboot. It takes a while and the machine is unavailable during it, so
  schedule it.
E
  ;;

  SYS-05) cat <<'E'
WHAT
  Whether processes can write core dumps: fs.suid_dumpable, the hard core limit,
  and the systemd-coredump configuration.

WHY
  A core dump is the process's memory written to disk, which means private keys,
  session tokens, decrypted secrets and passwords in cleartext, in a file that
  frequently ends up world-readable or shipped to a crash reporting service. It
  is one of the most reliable ways to get credentials off a machine without
  exploiting anything at all.

COST
  You lose crash forensics. On a server running packaged software that is
  usually fine. If you are debugging your own binaries, keep dumps but restrict
  the storage path to root and make sure it is not on a shared or backed-up
  volume.

BY HAND
  echo 'fs.suid_dumpable = 0' > /etc/sysctl.d/99-coredump.conf
  echo '* hard core 0'        > /etc/security/limits.d/99-coredump.conf
  # systemd
  mkdir -p /etc/systemd/coredump.conf.d
  printf '[Coredump]\nStorage=none\nProcessSizeMax=0\n' \
    > /etc/systemd/coredump.conf.d/99-disable.conf
  sysctl --system && systemctl daemon-reload

WITH AARTOOL
  aartool apply --target HOST --user USER --only kernel,coredump

MORE
  Check for dumps already on disk before you consider this closed:
    coredumpctl list 2>/dev/null; ls -l /var/lib/systemd/coredump/ 2>/dev/null
E
  ;;

  SYS-11) cat <<'E'
WHAT
  Whether the running kernel is the newest one installed. A pending reboot.

WHY
  This is the finding people dismiss and should not. The update is applied, the
  package manager reports success, the scanner that reads package versions says
  patched, and the machine is running the vulnerable kernel it booted weeks ago
  and will keep running it until someone reboots. Every kernel CVE you patched
  since the last boot is still live.

COST
  A reboot, which on a clustered service is an orchestration problem rather
  than a configuration one: quorum, leader election, shard allocation, VRRP
  ownership. That is precisely why it gets deferred indefinitely.

BY HAND
  uname -r                                     # running
  rpm -q kernel --last | head -1               # RHEL: newest installed
  ls -t /boot/vmlinuz-* | head -1              # Debian / Ubuntu
  # and check whether anything else is waiting:
  needs-restarting -r 2>/dev/null || cat /var/run/reboot-required 2>/dev/null

WITH AARTOOL
  No role fixes this, and the remediation map says so explicitly. The roles
  named against SYS-11 only decide who owns future reboots; they do not
  activate the kernel already sitting in /boot.

MORE
  Reboot one node at a time, verify the cluster is healthy between each, and
  make the verification a gate rather than a glance. If your check queries the
  node you just rebooted, it is not a check.
E
  ;;

  NET-01) cat <<'E'
WHAT
  Whether a host firewall is present and default-deny on inbound: firewalld on
  RHEL, ufw or nftables on Debian and Ubuntu.

WHY
  A cloud or perimeter firewall filters one interface. It does not filter the
  private network, the VPN, the container bridge, or anything else that reaches
  the host by another path, and it does not exist at all once an attacker is
  inside the same segment. The host firewall is the only one that sees every
  packet that arrives.

COST
  The obvious one: lock yourself out by enabling default-deny before allowing
  SSH. Always add the SSH rule first, in the same command sequence, and keep a
  second session open.

BY HAND
  # RHEL
  systemctl enable --now firewalld
  firewall-cmd --set-default-zone=drop
  firewall-cmd --permanent --add-service=ssh && firewall-cmd --reload
  # Ubuntu / Debian
  ufw allow OpenSSH        # FIRST
  ufw default deny incoming
  ufw enable

WITH AARTOOL
  aartool plan  --target HOST --user USER --only firewall   # read this one
  aartool apply --target HOST --user USER --only firewall

MORE
  Docker writes its own rules and bypasses ufw entirely: a published container
  port is reachable even when ufw says deny. Filter Docker traffic in the
  DOCKER-USER chain, not in ufw.
E
  ;;

  NET-02) cat <<'E'
WHAT
  IP forwarding and the redirect and source-route sysctls: whether this host
  will route packets that are not addressed to it.

WHY
  A host that forwards is a bridge between segments, and an attacker who lands
  on it inherits that bridge. Accepted ICMP redirects let anyone on the local
  network rewrite your routing table; accepted source routing lets a remote
  attacker choose the return path and defeat filters that assume packets come
  back the way they left.

COST
  This is the check that breaks things silently, so read before applying:
  ip_forward=0 stops Docker container networking, every NAT gateway, every
  VPN concentrator and every Kubernetes node from working. If the host is any
  of those, forwarding must stay on and the finding is expected.

BY HAND
  cat > /etc/sysctl.d/99-net.conf <<'EOF'
  net.ipv4.ip_forward = 0
  net.ipv4.conf.all.accept_redirects = 0
  net.ipv4.conf.all.secure_redirects = 0
  net.ipv4.conf.all.send_redirects = 0
  net.ipv4.conf.all.accept_source_route = 0
  net.ipv4.conf.all.rp_filter = 1
  net.ipv4.conf.all.log_martians = 1
  EOF
  sysctl --system

WITH AARTOOL
  The role has a variable for exactly this case. On a router, gateway or
  container host set:
    linux_ip_forwarding_enabled: true
  so the redirect and source-route settings are still applied and only
  ip_forward is left alone.
    aartool apply --target HOST --user USER --only network

MORE
  Turning ip_forward off on a Docker host takes container networking down
  immediately and the cause is not obvious from the symptom. Check first:
    docker info >/dev/null 2>&1 && echo "container host: leave ip_forward on"
E
  ;;

  LOG-01) cat <<'E'
WHAT
  Whether auditd is installed, running, and has rules loaded.

WHY
  Nothing else on a Linux host records who ran what. syslog records what
  services chose to say about themselves, which is not the same thing and is
  not what an incident responder needs. Without auditd there is no answer to
  "what did the attacker do after they got in", and reconstructing an incident
  becomes guesswork. This is also the control every certification asks for by
  name.

COST
  Disk and I/O. A busy machine with aggressive rules generates gigabytes a day,
  and the default max_log_file_action can fill a partition. Size it, put
  /var/log/audit on its own volume, and ship logs off the host.

BY HAND
  systemctl enable --now auditd
  auditctl -l | wc -l          # 0 means it is running and watching nothing
  augenrules --load
  # minimum useful rules:
  -w /etc/passwd -p wa -k identity
  -w /etc/shadow -p wa -k identity
  -w /etc/sudoers -p wa -k scope
  -w /var/log/sudo.log -p wa -k actions
  -a always,exit -F arch=b64 -S execve -F euid=0 -k rootcmd

WITH AARTOOL
  aartool apply --target HOST --user USER --only audit

MORE
  A running auditd with no rules is the failure mode to watch for: every
  service check goes green and nothing is recorded. auditctl -l is the check
  that actually tells you.
E
  ;;

  LOG-08) cat <<'E'
WHAT
  Whether logs are shipped off the host, and whether local logs are persistent
  and access-restricted.

WHY
  The first thing a competent intruder does after getting root is edit the
  logs. Local logs are evidence you have handed the attacker write access to.
  Remote logs are the copy they cannot reach. On systemd machines there is a
  second failure: with journald Storage=volatile, which is still the default on
  some minimal images, the entire journal is in memory and a reboot erases it.

COST
  A log destination to run and keep available, plus the bandwidth. If the
  remote endpoint is down, decide in advance whether the host queues or drops,
  and make sure a full queue cannot fill the disk.

BY HAND
  # persist the journal
  mkdir -p /var/log/journal && systemd-tmpfiles --create --prefix /var/log/journal
  sed -i 's/^#*Storage=.*/Storage=persistent/' /etc/systemd/journald.conf
  systemctl restart systemd-journald
  # forward (rsyslog)
  echo '*.* @@logs.example.internal:6514' > /etc/rsyslog.d/99-remote.conf
  systemctl restart rsyslog

WITH AARTOOL
  aartool apply --target HOST --user USER --only logging,journald

MORE
  Test it properly rather than assuming: logger "aartool test $(date +%s)"
  and then confirm the line arrived at the collector. A forwarding rule that
  silently fails looks identical to one that works.
E
  ;;

  FS-06) cat <<'E'
WHAT
  Mount options on /tmp, /var/tmp and /dev/shm: nodev, nosuid and noexec.

WHY
  These are the directories any user can write to, which makes them where a
  payload lands. noexec means the dropped binary will not run from there;
  nosuid means a setuid binary copied there does not keep its privilege. It
  does not stop a determined attacker, who can copy elsewhere or use an
  interpreter, but it breaks a large fraction of automated tooling that assumes
  /tmp is executable.

COST
  Real and easily overlooked. Package managers and installers extract to /tmp
  and execute from it: some dnf and apt operations, most vendor install
  scripts, Java's temporary native library extraction, and several databases.
  Expect to whitelist something.

BY HAND
  # /etc/fstab
  tmpfs /dev/shm  tmpfs defaults,nodev,nosuid,noexec 0 0
  tmpfs /tmp      tmpfs defaults,nodev,nosuid,noexec,size=2G 0 0
  /tmp  /var/tmp  none  rw,noexec,nosuid,nodev,bind 0 0
  mount -o remount /tmp

WITH AARTOOL
  aartool plan  --target HOST --user USER --only filesystem,mounts   # read it
  aartool apply --target HOST --user USER --only filesystem,mounts

MORE
  If a package operation fails afterwards, point it at a directory you control
  rather than removing noexec:
    export TMPDIR=/var/lib/mytmp
E
  ;;

  FS-07) cat <<'E'
WHAT
  World-writable directories that do not have the sticky bit set.

WHY
  Without the sticky bit, any user who can write to a directory can delete or
  rename anyone else's files in it, not just their own. That is the mechanism
  behind a whole family of symlink and rename races: an attacker swaps a file
  another process is about to open, between the check and the open. /tmp is the
  classic case, which is why /tmp has had the sticky bit since the 1980s.

COST
  Read the list before you act on it. On any host that runs containers, almost
  every hit is inside the image and snapshot store, and those permissions
  reflect the contents of the images rather than anything about this machine.
  Mass-chmodding a snapshot tree corrupts layers and breaks the containers
  built on them. On a real docs server all 3622 findings were under
  /var/lib/containerd and not one of them was worth changing.

BY HAND
  # Look first, and group by where they actually live:
  find / -xdev -type d -perm -0002 ! -perm -1000 2>/dev/null \
    | cut -d/ -f1-4 | sort | uniq -c | sort -rn | head
  # Then fix only what is genuinely yours:
  find /srv /opt /home -xdev -type d -perm -0002 ! -perm -1000 \
    -exec chmod a+t {} +

WITH AARTOOL
  aartool plan --target HOST --user USER --only filesystem,permissions
  Read that plan rather than applying it blind. The role fixes the paths it
  knows about; a container store is not one of them, and it should not be.

MORE
  Exclude the container root from the question instead of from the fix. If your
  storage driver lives under /var/lib/docker or /var/lib/containerd, findings
  there are about your images, and the place to fix them is the Dockerfile.
E
  ;;

  FS-05) cat <<'E'
WHAT
  Unexpected setuid and setgid binaries on the filesystem.

WHY
  A setuid root binary runs as root no matter who starts it, so every one of
  them is a potential privilege escalation and the list should be short and
  known. Two distinct problems hide here: a distribution binary with a known
  CVE (pkexec and CVE-2021-4034 is the canonical example, sudo and
  CVE-2021-3156 the other), and a planted one, which is a persistence
  mechanism that survives password changes and looks entirely ordinary in a
  directory listing.

COST
  None to look. Removing the bit from something the system needs breaks it:
  su, sudo, passwd, mount and ping legitimately carry it.

BY HAND
  find / -xdev \( -perm -4000 -o -perm -2000 \) -type f -exec ls -l {} \; 2>/dev/null
  # compare against what the package manager expects:
  rpm -Va --nofiles --noscripts 2>/dev/null | grep '^.M'     # RHEL
  dpkg --verify 2>/dev/null | grep '^..5'                     # Debian
  # drop the bit where it is not needed:
  chmod u-s /usr/bin/example

WITH AARTOOL
  aartool apply --target HOST --user USER --only filesystem,permissions
  The role fixes permissions on known files. It does not delete unknown setuid
  binaries: that decision needs a human who knows what the machine runs.

MORE
  Take a baseline on a known-good build and diff against it on every host. A
  new setuid binary appearing between two audits is one of the highest-signal
  indicators available on a Linux box, and `aartool diff` will surface it.
E
  ;;

  SSH-09) cat <<'E'
WHAT
  HostbasedAuthentication in sshd_config, and the .rhosts and .shosts files it
  reads.

WHY
  Host-based authentication trusts the client machine rather than the person on
  it. If host A is listed as trusted, sshd accepts A's assertion that the user
  is who A says they are: no password, no key of their own. One compromised
  machine in the trust set therefore becomes every machine in it, and the trust
  is transitive in practice because nobody maps it. It is a survival of the
  rlogin era and there is almost never a reason to have it on.

COST
  None on any modern estate. The only things that use it are old cluster
  schedulers and some HPC batch systems, which will say so loudly.

BY HAND
  sed -i 's/^#*HostbasedAuthentication.*/HostbasedAuthentication no/' /etc/ssh/sshd_config
  sed -i 's/^#*IgnoreRhosts.*/IgnoreRhosts yes/'                     /etc/ssh/sshd_config
  sshd -t && systemctl reload sshd
  # and look for what is already there:
  find /home /root -maxdepth 2 -name '.rhosts' -o -name '.shosts' 2>/dev/null

WITH AARTOOL
  aartool apply --target HOST --user USER --only ssh

MORE
  Set IgnoreRhosts yes alongside it (that is SSH-08). Turning off host-based
  auth while leaving .rhosts readable keeps the files around for whatever reads
  them next.
E
  ;;

  NET-05) cat <<'E'
WHAT
  Whether legacy cleartext network services are installed or listening: telnet,
  rsh, rlogin, rexec, tftp, ypserv, ftp.

WHY
  Every one of these sends credentials in cleartext, and most authenticate the
  peer weakly or not at all. Anyone who can see the traffic has the password,
  which on a shared or cloud network is a larger set of people than you think.
  They are also frequently forgotten rather than chosen: pulled in by a
  dependency, enabled by an appliance image, still listening years later. An
  attacker port-scanning your estate finds them long before you audit for them.

COST
  None, unless something genuinely uses them, in which case the fix is to
  replace it rather than keep it. tftp is the common real exception: PXE boot
  and network device firmware need it. If so, bind it to the provisioning
  network and firewall it there.

BY HAND
  ss -tulpn | grep -E ':(23|21|69|512|513|514|111)\b'
  systemctl list-unit-files | grep -E 'telnet|rsh|rlogin|rexec|tftp|ypserv|vsftpd'
  apt purge -y telnetd rsh-server tftpd ypserv    # Debian / Ubuntu
  dnf remove -y telnet-server rsh-server tftp-server ypserv   # RHEL

WITH AARTOOL
  aartool apply --target HOST --user USER --only services

MORE
  Removing the package beats masking the unit. A masked service comes back the
  next time something re-enables it, and nothing will tell you.
E
  ;;

  FS-01) cat <<'E'
WHAT
  Permissions and ownership on /etc/passwd. It must be root-owned and 644.

WHY
  /etc/passwd has to be world-readable, which is fine: it holds no hashes any
  more. What matters is that it must not be world- or group-WRITABLE. An
  attacker who can write it does not need an exploit at all. They add a line
  with UID 0, or blank the second field so a system account has no password, or
  change root's shell. It is the shortest path from any write primitive to root
  on the machine, and it is silent.

COST
  None. A correct system already looks like this, so this check failing means
  something changed it, and finding out what changed it matters more than
  fixing the mode.

BY HAND
  stat -c '%U %G %a' /etc/passwd      # want: root root 644
  chown root:root /etc/passwd && chmod 644 /etc/passwd
  # find out who did it, before you conclude it was an accident:
  ausearch -f /etc/passwd -ts recent 2>/dev/null
  # and check the neighbours, which are separate checks:
  stat -c '%n %U %G %a' /etc/shadow /etc/group /etc/gshadow /etc/sudoers

WITH AARTOOL
  aartool apply --target HOST --user USER --only filesystem,permissions

MORE
  /etc/shadow (FS-02) is the one that must not be world-READABLE. The two files
  have opposite requirements and are easy to reason about backwards.
E
  ;;

  LOG-04) cat <<'E'
WHAT
  Whether auditd has rules loaded. Not whether it is running: whether it is
  watching anything.

WHY
  This is the check that catches the most convincing false sense of security in
  the whole report. auditd starts, systemd reports active, every service check
  goes green, and with an empty ruleset it records essentially nothing. When you
  need it during an incident, six months of "audit was enabled" turns out to
  mean six months of nothing. LOG-01 asks whether the daemon runs. This asks
  whether it does anything.

COST
  Disk and I/O, in proportion to how aggressive the rules are. Rules on execve
  for every user on a busy machine generate gigabytes a day. Start with the
  identity and privilege rules, which are cheap and high-signal, and add
  syscall rules deliberately.

BY HAND
  auditctl -l | wc -l          # 0 means running and watching nothing
  cat > /etc/audit/rules.d/50-base.rules <<'EOF'
  -w /etc/passwd -p wa -k identity
  -w /etc/shadow -p wa -k identity
  -w /etc/sudoers -p wa -k scope
  -w /etc/sudoers.d/ -p wa -k scope
  -w /var/log/sudo.log -p wa -k actions
  -a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=-1 -k rootcmd
  EOF
  augenrules --load && auditctl -l | wc -l

WITH AARTOOL
  aartool apply --target HOST --user USER --only audit

MORE
  Make the rules immutable once you are happy with them: a final "-e 2" line
  means they cannot be changed until reboot, so an intruder cannot quietly
  unload the rule that would have recorded them.
E
  ;;

  *) return 1 ;;
  esac
}

# ── inspect ──────────────────────────────────────────────────────────────────
# Wraps cyberaar-baseline.sh. Its flags are already clear and already the ones
# in the published docs, so they are passed through rather than renamed: the
# gain from a second spelling would not repay teaching people two.

cmd_inspect_usage() {
  cat <<'EOF'
aartool inspect: audit a machine. Changes nothing.

Usage:
  aartool inspect [options]

Options:
  --host HOST           Audit one remote host over SSH
  --host-file FILE      Audit every host listed in FILE, one per line
  --inventory FILE      Audit every host in an Ansible inventory
  --user USER           SSH user for a remote audit
  --ssh-key FILE        SSH private key for a remote audit
  --jump USER@HOST[:PORT]
                        Reach the target through this bastion, which is how
                        most estates are shaped. Use this rather than
                        --ssh-opt '-J ...': ssh does not pass --ssh-key or the
                        connection options to the jump hop, so -J fails on hop
                        one with a host key error that never names the bastion.
  --ssh-opt OPT         Extra ssh option, repeatable
  -o, --out DIR         Write reports to DIR. Default: ./reports
  --no-save             Print to the terminal and write nothing
  -h, --help            Show this help

With no --host, --host-file or --inventory, aartool audits the machine it is
running on, which needs root:

  sudo aartool inspect

Reports are written to ./reports unless -o says otherwise. HTML and JSON: the
HTML opens offline with no external requests, which is the point on an isolated
network, and the JSON is what advise, diff and report read.

Under sudo the reports are handed back to the user who invoked it, so the very
next command does not need root as well.

  sudo aartool inspect       # audit, reports land in ./reports
  aartool advise             # the ordered plan, from what inspect just wrote
EOF
}

cmd_inspect() {
  local -a passthru=()
  # Reports are written by default. The previous behaviour was to write nothing
  # unless -o was given, which meant the three-command loop taught in the README
  # (inspect, then advise) failed on the second command for every first-time
  # user, with an error about a missing report rather than about the flag they
  # were never told to pass. A tool whose documented first run does not work is
  # the one thing worse than a tool with no documentation.
  local out_dir="./reports" save=true

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --host|--host-file|--inventory|--user|--ssh-key|--ssh-opt|--jump)
        [[ $# -ge 2 ]] || die "$1 needs a value."
        passthru+=("$1" "$2"); shift 2 ;;
      -o|--out)
        [[ $# -ge 2 ]] || die "$1 needs a value."
        out_dir="$2"; save=true; shift 2 ;;
      --no-save) save=false; shift ;;
      -h|--help) cmd_inspect_usage; return 0 ;;
      --) shift; break ;;
      -*) die "Unknown option for inspect: $1. Try 'aartool inspect --help'." ;;
      *)  die "inspect takes no positional arguments. Did you mean --host $1 ?" ;;
    esac
  done

  resolve_paths

  # A local audit reads files under /etc and /proc that are root-only. Saying so
  # here beats letting the script produce a report full of unknowns.
  local remote=false
  local a
  for a in ${passthru[@]+"${passthru[@]}"}; do
    case "$a" in --host|--host-file|--inventory) remote=true ;; esac
  done
  if [[ "$remote" == false && "${EUID:-$(id -u)}" -ne 0 ]]; then
    die "A local audit needs root: it reads sshd_config, /etc/shadow, audit
        rules and sysctls that are not readable otherwise.
          sudo aartool inspect
        To audit another machine instead, and not need root here:
          aartool inspect --host HOST --user USER"
  fi

  if [[ "$save" == true ]]; then
    mkdir -p "$out_dir" || die "Cannot create the report directory: $out_dir
        Pass somewhere writable with -o DIR, or --no-save to write nothing."
    passthru+=(--output-dir "$out_dir")
  fi


  info "Auditing with $(basename "$BASELINE")"
  local rc=0
  bash "$BASELINE" ${passthru[@]+"${passthru[@]}"} || rc=$?

  local wrote=0
  if [[ "$save" == true && -d "$out_dir" ]]; then
    wrote=$(find "$out_dir" -maxdepth 1 -name 'cyberaar-*.json' -newermt '-10 minutes' 2>/dev/null | wc -l)
    # Leave no empty directory behind from a run that produced nothing.
    [[ "$wrote" -eq 0 ]] && rmdir "$out_dir" 2>/dev/null || true
  fi
  if [[ "$wrote" -gt 0 ]]; then
    # sudo aartool inspect writes root-owned, mode 600 reports into the caller's
    # own directory, and the next command they were told to run cannot read
    # them. Hand the files back to whoever invoked sudo.
    if [[ -n "${SUDO_UID:-}" && -n "${SUDO_GID:-}" ]]; then
      chown -R "${SUDO_UID}:${SUDO_GID}" "$out_dir" 2>/dev/null || true
      vlog "reports handed back to uid ${SUDO_UID}"
    fi
    printf '\n  Reports:  %s  (%s)\n' "$out_dir" \
      "$( [[ "$wrote" -eq 1 ]] && printf '1 host' || printf '%s hosts' "$wrote" )"
    printf '  Next:     %saartool advise%s   what to fix first, and what each fix costs\n\n' \
      "$CYAN" "$RESET"
  fi
  return "$rc"
}

# ── plan and apply ───────────────────────────────────────────────────────────
# Both wrap run-hardening.sh. They differ in exactly one thing, which is whether
# --check is passed, and that difference is worth a whole command rather than a
# flag: the failure mode of forgetting -c is a rewritten sshd_config on a live
# machine.

cmd_harden_usage() {
  local verb="$1"
  cat <<EOF
aartool ${verb}: $( [[ "$verb" == plan ]] \
  && echo "show what hardening would change. Changes nothing." \
  || echo "apply hardening to a target." )

Usage:
  aartool ${verb} --target HOST|GROUP [options]

Options:
  -t, --target HOST|GROUP   Host or inventory group. Required.
  -u, --user USER           SSH user (default: ansible)
      --ssh-key FILE        SSH private key. inspect has always taken one;
                            plan and apply did not, so on an estate with a
                            dedicated key they failed with a permission error
                            that pointed at the target rather than at the
                            missing flag.
      --only TAGS           Limit to categories, comma separated.
                            e.g. ssh, firewall, audit, kernel, users
      --full                Run the three-step pipeline: audit, harden, audit.
                            Default is the hardening step alone.
  -K, --ask-become-pass     Prompt for the sudo password on the target
$( [[ "$verb" == apply ]] && printf '  -y, --yes                 Skip the confirmation prompt\n' )
  -h, --help                Show this help

--target is required and has no default. run-hardening.sh defaults to the group
'linux_servers', which is every machine in the inventory, so a bare invocation
hardens the whole estate. That is not a default worth having.
EOF
}

cmd_harden() {
  local mode="$1"; shift          # plan | apply
  local target="" user="" sshkey="" tags="" full=false become=false assume_yes=false

  while [[ $# -gt 0 ]]; do
    case "$1" in
      -t|--target) [[ $# -ge 2 ]] || die "$1 needs a value."; target="$2"; shift 2 ;;
      -u|--user)   [[ $# -ge 2 ]] || die "$1 needs a value."; user="$2";   shift 2 ;;
      --ssh-key)   [[ $# -ge 2 ]] || die "$1 needs a value."
                   [[ -r "$2" ]] || die "SSH key not readable: $2"
                   sshkey="$2"; shift 2 ;;
      --only)      [[ $# -ge 2 ]] || die "$1 needs a value."; tags="$2";   shift 2 ;;
      --full)      full=true;       shift ;;
      -K|--ask-become-pass) become=true; shift ;;
      -y|--yes)
        [[ "$mode" == apply ]] || die "--yes only applies to 'apply'. 'plan' changes nothing, so there is nothing to confirm."
        assume_yes=true; shift ;;
      -h|--help)   cmd_harden_usage "$mode"; return 0 ;;
      --) shift; break ;;
      -*) die "Unknown option for ${mode}: $1. Try 'aartool ${mode} --help'." ;;
      *)  die "Unexpected argument: $1. The target goes after --target." ;;
    esac
  done

  [[ -n "$target" ]] || die "--target is required. Name a host or an inventory group, e.g. 'aartool ${mode} --target web-01'."

  resolve_paths
  require_inventory

  if ! target_in_inventory "$target"; then
    die "'$target' is not a host or group in $INVENTORY. Add it there first, or check the spelling."
  fi

  local -a args=(-t "$target" -s "$( [[ "$full" == true ]] && echo all || echo 2 )")
  [[ -n "$user" ]]      && args+=(-u "$user")
  [[ -n "$sshkey" ]]    && args+=(-i "$sshkey")
  [[ -n "$tags" ]]      && args+=(-T "$tags")
  [[ "$become" == true ]] && args+=(-K)
  [[ "$mode" == plan ]] && args+=(-c)

  echo
  printf '%sTarget%s   %s\n'  "$BOLD" "$RESET" "$target"
  printf '%sScope%s    %s\n'  "$BOLD" "$RESET" "${tags:-all hardening categories}"
  printf '%sSteps%s    %s\n'  "$BOLD" "$RESET" "$( [[ "$full" == true ]] && echo "audit, harden, audit" || echo "harden" )"
  if [[ "$mode" == plan ]]; then
    printf '%sMode%s     %spreview: nothing will be changed%s\n' "$BOLD" "$RESET" "$GREEN" "$RESET"
  else
    printf '%sMode%s     %sAPPLY: this will change the target%s\n' "$BOLD" "$RESET" "$RED" "$RESET"
  fi
  echo

  if [[ "$mode" == apply && "$assume_yes" == false ]]; then
    if [[ ! -t 0 ]]; then
      die "apply needs confirmation and there is no terminal to ask on. Pass --yes if you meant it, or use 'aartool plan' first."
    fi
    # Typing the name, rather than y/N, because the dangerous mistake here is
    # applying to the right kind of thing with the wrong name: a group instead
    # of the one host you meant.
    local answer=""
    printf 'Type the target name to confirm: '
    read -r answer
    [[ "$answer" == "$target" ]] || die "Confirmation did not match. Nothing was changed."
    echo
  fi

  info "Running $(basename "$HARDEN")"
  bash "$HARDEN" "${args[@]}"
}

# ── surface ──────────────────────────────────────────────────────────────────
# The command this toolkit exists to offer that a distribution vendor does not.
#
# Red Hat and Debian ship the patch. Nothing helps you in the window before the
# patch exists, or on the machine you cannot reboot until the change window in
# three weeks. These settings close classes of local privilege escalation rather
# than individual CVEs, take effect immediately, and survive a kernel that is
# still vulnerable.

cmd_surface_usage() {
  cat <<'EOF'
aartool surface: kernel attack surface. Assess by default; changes nothing.

Usage:
  aartool surface [options]

Options:
      --strict          Include mitigations that break real workloads.
                        Read the cost column first.
      --fix             Print the sysctl drop-in that would close the gaps.
                        Writes nothing.
      --write FILE      Write that drop-in to FILE instead of printing it.
      --apply           Write to /etc/sysctl.d/60-aartool-surface.conf and load
                        it. Needs root. Asks for confirmation.
  -y, --yes             Skip the confirmation on --apply.
  -h, --help            Show this help

Two tiers, because a mitigation that breaks the workload gets reverted and
teaches people to ignore the tool:

  safe      no mainstream workload is known to depend on it
  strict    will break something real for somebody, and the cost is printed

Without --strict, only the safe tier is considered.

Every setting here is a sysctl. Nothing is compiled, nothing is rebooted, and
anything applied can be undone by deleting the drop-in file and rebooting, or by
setting the value back.
EOF
}

cmd_surface() {
  local strict=false emit="" out_file="" do_apply=false assume_yes=false

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --strict) strict=true; shift ;;
      --fix)    emit="print"; shift ;;
      --write)  [[ $# -ge 2 ]] || die "--write needs a file path."; emit="file"; out_file="$2"; shift 2 ;;
      --apply)  do_apply=true; shift ;;
      -y|--yes) assume_yes=true; shift ;;
      -h|--help) cmd_surface_usage; return 0 ;;
      --) shift; break ;;
      -*) die "Unknown option for surface: $1. Try 'aartool surface --help'." ;;
      *)  die "surface takes no positional arguments." ;;
    esac
  done

  local tiers="safe"
  [[ "$strict" == true ]] && tiers="safe strict"

  local -a gap_key=() gap_val=() gap_id=() gap_cost=()
  local n_ok=0 n_na=0

  printf '\n%sKernel attack surface%s   %s\n' "$BOLD" "$RESET" "$(uname -r)"
  printf '%s\n' "────────────────────────────────────────────────────────────────────"

  local id key want tier closes cost have status
  while IFS=$'\t' read -r id key want tier closes cost; do
    [[ -n "$id" ]] || continue
    case " $tiers " in *" $tier "*) ;; *) continue ;; esac

    # The user namespace switch lives under a different key per distribution.
    [[ "$id" == "KRN-01" ]] && key="$(surface_userns_key)"

    have="$(surface_read "$key")"
    if surface_ok "$key" "$want" "$have"; then
      status="closed";  n_ok=$((n_ok+1))
    elif [[ "$have" == "?" ]]; then
      status="absent";  n_na=$((n_na+1))
    else
      status="OPEN"
      gap_key+=("$key"); gap_val+=("$want"); gap_id+=("$id"); gap_cost+=("$cost")
    fi

    # A closed door is one line; an open one earns three, because the operator
    # has a decision to make and needs the cost in front of them to make it.
    case "$status" in
      closed) printf '  %s✔%s  %-7s %s\n' "$GREEN" "$RESET" "$id" "$closes" ;;
      absent) printf '  %s·%s  %-7s %s %s(not present on this kernel)%s\n' "$CYAN" "$RESET" "$id" "$closes" "$CYAN" "$RESET" ;;
      OPEN)
        printf '\n  %s✗%s  %-7s %s%s = %s%s\n' "$RED" "$RESET" "$id" "$BOLD" "$key" "$have" "$RESET"
        printf '     %scloses%s  %s\n' "$CYAN" "$RESET" "$closes"
        printf '     %scost%s    %s\n' "$CYAN" "$RESET" "$cost"
        printf '     %sfix%s     %s = %s\n\n' "$CYAN" "$RESET" "$key" "$want" ;;
    esac
  done < <(surface_catalogue)

  local n_gap="${#gap_key[@]}"
  printf '%s\n' "────────────────────────────────────────────────────────────────────"
  printf '  %d closed, %d open, %d not applicable' "$n_ok" "$n_gap" "$n_na"
  [[ "$strict" == false ]] && printf '   %s(safe tier only; --strict for the rest)%s' "$CYAN" "$RESET"
  printf '\n\n'

  if [[ "$n_gap" -eq 0 ]]; then
    success "Nothing to close in this tier."
    return 0
  fi

  # Nothing below this point runs unless the operator asked for it.
  [[ -z "$emit" && "$do_apply" == false ]] && {
    info "Run 'aartool surface --fix' to see the drop-in that closes these, or --apply to apply it."
    return 0
  }

  local dropin
  dropin="$(surface_render_dropin gap_key gap_val gap_id)"

  if [[ "$emit" == "print" ]]; then
    printf '%s\n' "$dropin"
    return 0
  fi
  if [[ "$emit" == "file" ]]; then
    printf '%s\n' "$dropin" > "$out_file" || die "Cannot write $out_file"
    success "Written: $out_file"
    info "Apply with: sudo sysctl --system"
    return 0
  fi

  # ── apply ──────────────────────────────────────────────────────────────────
  [[ "${EUID:-$(id -u)}" -eq 0 ]] || die "--apply needs root. Re-run with sudo, or use --write and apply it yourself."

  local target="/etc/sysctl.d/60-aartool-surface.conf"
  printf '%sWill write%s %s and run sysctl --system\n' "$BOLD" "$RESET" "$target"
  printf '%sClosing%s   %d setting(s)\n\n' "$BOLD" "$RESET" "$n_gap"

  if [[ "$assume_yes" == false ]]; then
    [[ -t 0 ]] || die "--apply needs confirmation and there is no terminal. Pass --yes if you meant it."
    local answer=""
    printf 'Apply these to this machine? Type yes to confirm: '
    read -r answer
    [[ "$answer" == "yes" ]] || die "Not confirmed. Nothing was changed."
    echo
  fi

  printf '%s\n' "$dropin" > "$target" || die "Cannot write $target"
  success "Wrote $target"
  if sysctl --system >/dev/null 2>&1; then
    success "Loaded. Verify with: aartool surface$( [[ "$strict" == true ]] && printf ' --strict' )"
  else
    warn "Wrote the file but 'sysctl --system' reported an error. Check: sysctl --system"
  fi
  info "To undo: rm $target && reboot (or set the values back by hand)."
}

# Rendered as a drop-in rather than applied with `sysctl -w`, so the change
# survives a reboot and is visible in one file that can be deleted to revert.
surface_render_dropin() {
  local -n _k="$1" _v="$2" _i="$3"
  printf '# Written by aartool %s on %s\n' "$AARTOOL_VERSION" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  printf '#\n'
  printf '# Kernel attack surface reduction. Each line closes a class of local\n'
  printf '# privilege escalation rather than a single CVE, and takes effect without\n'
  printf '# a reboot once loaded with: sysctl --system\n'
  printf '#\n'
  printf '# To revert: delete this file and reboot, or set the values back by hand.\n'
  printf '\n'
  local n="${#_k[@]}" idx
  for (( idx=0; idx<n; idx++ )); do
    printf '# %s\n%s = %s\n' "${_i[$idx]}" "${_k[$idx]}" "${_v[$idx]}"
  done
}

# ── advise ───────────────────────────────────────────────────────────────────
# inspect answers "what is wrong". advise answers the question an operator
# actually has, which is "what do I do on Monday morning".
#
# Forty findings in report order is not a plan. It has no ordering, so the
# cheapest item and the one an attacker is using right now look the same; and
# it does not separate the changes you can apply without a conversation from
# the ones that will break a workload. Both omissions have the same result: the
# report gets read once and nothing is applied.
#
# The ordering is by reachability, not by CVSS-style severity, because that is
# what determines what an attacker reaches first:
#
#   1  from the network      no account needed
#   2  account to root       what the next kernel LPE or a stolen key gets
#   3  you would not know    detection and forensics
#   4  hygiene and evidence  everything else
#
# Within a wave: FAIL before WARN, and inside that, changes that are safe to
# apply blind before changes that need a decision. The decision list is printed
# separately, because the fastest way to make someone stop using a hardening
# tool is to have it break their containers on the first run.

# Findings whose fix has a real operational cost. Applying these without
# knowing what the machine runs is how an estate goes down.
_advise_costly() {
  case "$1" in
    KRN-01|KRN-03|KRN-05|KRN-08) return 0 ;;  # containers, io_uring users, modprobe, boot
    SSH-02|SSH-13)               return 0 ;;  # locks out keyless users / old clients
    NET-01)                      return 0 ;;  # firewall default-deny locks you out
    NET-02)                      return 0 ;;  # ip_forward=0 kills container networking
    NET-13)                      return 0 ;;  # disabling IPv6 breaks IPv6-only estates
    FS-06|FS-09)                 return 0 ;;  # noexec /tmp breaks installers
    SYS-04)                      return 0 ;;  # MAC enforcing without a permissive pass
    AUTH-04|AUTH-09|AUTH-14)     return 0 ;;  # PAM edits, self-inflicted lockout
    AUTH-05|AUTH-11|FS-05)       return 0 ;;  # needs a human: what IS that account/binary
    # World-writable paths are overwhelmingly inside container storage on any
    # host that runs containers, and mass-chmodding a snapshot tree corrupts
    # image layers. Found on a real docs server where all 3622 hits were under
    # /var/lib/containerd: the finding was true and the remediation was wrong.
    FS-04|FS-07)                 return 0 ;;
    *) return 1 ;;
  esac
}

# Reachability wave. Everything gets one, including IDs added later, so a new
# check family never falls out of the plan silently.
_advise_wave() {
  case "$1" in
    SSH-*|NET-*)               printf 1 ;;
    KRN-*|AUTH-*|SYS-04|SYS-05|SYS-07|SYS-08|SYS-09|SYS-10|FS-01|FS-02|FS-03|FS-05|FS-06|FS-09)
                               printf 2 ;;
    LOG-*|INT-*|AUD-*)         printf 3 ;;
    SYS-02|SYS-03|SYS-11)      printf 1 ;;   # unpatched is reachable from the network
    *)                         printf 4 ;;
  esac
}

_advise_wave_name() {
  case "$1" in
    1) printf 'Reachable from the network: no account needed' ;;
    2) printf 'Account to root: what the next kernel LPE gets' ;;
    3) printf 'You would not know: detection and forensics' ;;
    *) printf 'Hygiene and audit evidence' ;;
  esac
}

_advise_usage() {
  cat <<'EOF'
aartool advise: turn an audit into an ordered plan

Usage:
  aartool advise [REPORT.json] [options]

With no argument it uses the most recent JSON report it can find in the
current directory, ./reports/ and /var/log/cyberaar/.

Options:
  --wave N          Only show wave N (1-4)
  --safe-only       Keep findings whose fix has a real operational cost out of
                    the waves. They are still listed under "Decide before you
                    apply": nothing disappears, it just stops being in the
                    sequence you are about to run.
  --target HOST     Write the commands against this host (default: HOST)
  --user USER       Write the commands with this SSH user (default: USER)
  -h, --help        This help

Examples:
  sudo aartool inspect                       # reports land in ./reports
  aartool advise                             # reads the newest one
  aartool advise --target web-01 --user ubuntu
  aartool advise --wave 1 --safe-only
EOF
}

# Only files inspect actually wrote. An earlier version matched any *.json in
# the working directory, which happily picked up a package-lock and then said
# it did not look like a report. Guessing is fine; guessing from the wrong pool
# is how a tool earns a reputation for being confused.
_advise_find_report() {
  local f
  f=$(find . ./reports /var/log/cyberaar -maxdepth 1 -name 'cyberaar-*.json' -type f -print0 2>/dev/null \
      | xargs -0 -r ls -t 2>/dev/null | head -1) || true
  [[ -n "$f" ]] && printf '%s' "$f"
  return 0
}

cmd_advise() {
  resolve_paths

  local report="" only_wave="" safe_only=0 tgt="HOST" usr="USER"
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -h|--help)   _advise_usage; return 0 ;;
      --wave)      only_wave="${2:-}"; [[ "$only_wave" =~ ^[1-4]$ ]] || die "--wave takes 1, 2, 3 or 4."; shift 2 ;;
      --safe-only) safe_only=1; shift ;;
      --target)    tgt="${2:-}"; [[ -n "$tgt" ]] || die "--target needs a value."; shift 2 ;;
      --user)      usr="${2:-}"; [[ -n "$usr" ]] || die "--user needs a value."; shift 2 ;;
      -*)          die "Unknown option for advise: $1. Try 'aartool advise --help'." ;;
      *)           report="$1"; shift ;;
    esac
  done

  if [[ -z "$report" ]]; then
    report="$(_advise_find_report || true)"
    [[ -n "$report" ]] || die "No audit report given, and none found in ., ./reports or /var/log/cyberaar.
        Produce one first:
          sudo aartool inspect                          this machine
          aartool inspect --host HOST --user USER       another machine
        Reports land in ./reports, and advise with no argument reads the newest."
    info "Using the most recent report found: $report"
  fi
  [[ -f "$report" ]] || die "No such report: $report"
  grep -q '"cyberaar_baseline"' "$report" \
    || die "$report does not look like a cyberaar audit report.
        It must be the JSON that 'aartool inspect -o DIR' writes, not the HTML."

  vlog "parsing $report"

  # Extract id/status/check from the results array without requiring jq. The
  # renderer writes one flat object per result with no nested objects, so a
  # record-per-line split on '},{' is exact rather than hopeful.
  # The renderer writes the array on one line but pretty-prints the object
  # around it, so the closing bracket is followed by a newline and four spaces.
  # An earlier version of this pattern required "],\"ansible_remediation\"" with
  # nothing between, matched the hand-built test fixture perfectly, and matched
  # no real report at all. Hence \s* here, and a real report as the fixture.
  local records; records=$(tr -d '\n' < "$report" \
    | grep -oP '"results":\s*\[\K.*?(?=\]\s*,\s*"ansible_remediation")' \
    | sed 's/},{/}\n{/g')
  [[ -n "$records" ]] || die "Could not read any results out of $report."

  local host score
  host=$(grep -oP '"host":\s*"\K[^"]*' "$report" | head -1 || true)
  score=$(grep -oP '"score":\s*\K[0-9]+' "$report" | head -1 || true)

  printf '\n%s%s%s\n' "$BOLD" "Plan for ${host:-this host}" "$RESET"
  printf '  from %s' "$report"
  [[ -n "$score" ]] && printf '  ·  score %s/100' "$score"
  printf '\n'

  # Bucket the actionable findings.
  local -a W1=() W2=() W3=() W4=() DECIDE=()
  local total_open=0
  while IFS= read -r rec; do
    [[ -n "$rec" ]] || continue
    local id st ck
    id=$(grep -oP '"id":"\K[^"]*'     <<<"$rec" || true)
    st=$(grep -oP '"status":"\K[^"]*' <<<"$rec" || true)
    ck=$(grep -oP '"check":"\K[^"]*'  <<<"$rec" || true)
    [[ "$st" == "FAIL" || "$st" == "WARN" ]] || continue
    total_open=$((total_open+1))

    local costly=0
    _advise_costly "$id" && costly=1
    if [[ $costly -eq 1 ]]; then
      DECIDE+=("$st|$id|$ck")
      [[ $safe_only -eq 1 ]] && continue
    fi

    local line="$st|$id|$ck"
    case "$(_advise_wave "$id")" in
      1) W1+=("$line") ;;
      2) W2+=("$line") ;;
      3) W3+=("$line") ;;
      *) W4+=("$line") ;;
    esac
  done <<<"$records"

  if [[ $total_open -eq 0 ]]; then
    success "Nothing open in this report. Every check passed."
    printf '  Keep it that way: %saartool diff%s against this file after the next change.\n\n' "$CYAN" "$RESET"
    return 0
  fi

  local w
  for w in 1 2 3 4; do
    [[ -n "$only_wave" && "$only_wave" != "$w" ]] && continue
    local -n arr="W$w"
    [[ ${#arr[@]} -gt 0 ]] || continue

    printf '\n%s── Wave %s · %s%s\n' "$BOLD" "$w" "$(_advise_wave_name "$w")" "$RESET"

    # FAIL first, then WARN. sort -s keeps report order inside each group.
    local -a tags=()
    local entry
    while IFS= read -r entry; do
      local st id ck
      IFS='|' read -r st id ck <<<"$entry"
      local mark="${YELLOW}WARN${RESET}"; [[ "$st" == "FAIL" ]] && mark="${RED}FAIL${RESET}"
      local note=""
      _advise_costly "$id" && note="  ${YELLOW}[needs a decision]${RESET}"
      kb_has "$id" && note="$note  ${CYAN}explain${RESET}"
      printf '   %s  %-9s %s%s\n' "$mark" "$id" "$ck" "$note"
      local m; m="$(_explain_map_line "$id")"
      [[ -n "$m" ]] && tags+=("$(cut -d'|' -f1 <<<"$m" | cut -d',' -f1)")
    done < <(printf '%s\n' "${arr[@]}" | sort -s -t'|' -k1,1)

    if [[ ${#tags[@]} -gt 0 ]]; then
      local joined; joined=$(printf '%s\n' "${tags[@]}" | sort -u | paste -sd, -)
      printf '\n     %spreview%s  aartool plan  --target %s --user %s --only %s\n' \
        "$CYAN" "$RESET" "$tgt" "$usr" "$joined"
      printf '     %sapply%s    aartool apply --target %s --user %s --only %s\n' \
        "$CYAN" "$RESET" "$tgt" "$usr" "$joined"
    fi
  done

  if [[ ${#DECIDE[@]} -gt 0 && -z "$only_wave" ]]; then
    printf '\n%s── Decide before you apply%s\n' "$BOLD" "$RESET"
    printf '   These break something real for somebody. What they break, and whether\n'
    printf '   this machine is somebody, is in the explanation for each.\n\n'
    local entry
    for entry in "${DECIDE[@]}"; do
      local st id ck; IFS='|' read -r st id ck <<<"$entry"
      printf '   %-9s %s\n' "$id" "$ck"
      printf '             %saartool explain %s%s\n' "$CYAN" "$id" "$RESET"
    done
  fi

  printf '\n%s── Order of operations%s\n' "$BOLD" "$RESET"
  cat <<EOF
   1. Run the wave 1 preview and read the diff. Nothing is changed by a plan.
   2. Apply wave 1 to ONE host, keeping a second SSH session open the whole time.
   3. Re-audit ${tgt} and compare, so you know what actually moved. Audit it
      the same way you did the first time: this plan was built from a report of
      ${host:-that host}, and 'sudo aartool inspect' would audit the machine you
      are standing on instead.
        aartool diff $report ./reports/<the new one>.json
   4. Only then roll the wave to a group, and start again at wave 2.

   The decision list is not a wave. Each item there is a conversation with
   whoever owns the workload, and the answer is often "not on this machine".
EOF
  printf '\n'
}

# ── explain ──────────────────────────────────────────────────────────────────
# A report that says "FAIL SSH-09: weak algorithms" tells an operator that
# something is wrong and nothing about what to do, what it costs, or whether it
# matters more than the thirty-nine other lines. That gap is where hardening
# reports go to die: read once, filed, never acted on.
#
# explain answers for any check ID, from three sources in order of depth:
#   1. a written entry, where knowing the mechanism changes the decision
#   2. the remediation map, which covers 99 of 109 IDs
#   3. the check's own title, read out of the built baseline
#
# It never says "no information". A command that sometimes refuses to answer
# stops being the thing people reach for.

# Pull one field out of the ANSIBLE_MAP entry in the built baseline.
# Format: ["ID"]="tags|role_rhel|role_ubuntu|description"
_explain_map_line() {
  grep -oP '^\s*\["'"$1"'"\]="\K[^"]+' "$BASELINE" 2>/dev/null | head -1 || true
}

# The English title, from the add_result the check emits. Its category, status,
# id and title are on one physical line, so this is a plain grep.
#
# Prefer the PASS branch. Checks name the state they want, so the PASS title is
# the name of the control; the first branch in file order is often a diagnostic
# ("Cannot determine installed kernels") that reads like nonsense as a heading.
_explain_title() {
  local t
  t=$(grep -oP 'add_result\s+"[^"]+"\s+"PASS"\s+"'"$1"'"\s+"\K[^"]+' "$BASELINE" 2>/dev/null | head -1 || true)
  [[ -n "$t" ]] || t=$(grep -oP 'add_result\s+"[^"]+"\s+"[^"]+"\s+"'"$1"'"\s+"\K[^"]+' "$BASELINE" 2>/dev/null | head -1 || true)
  printf '%s' "$t"
}

_explain_category() {
  grep -oP 'add_result\s+"\K[^"]+(?="\s+"[^"]+"\s+"'"$1"'")' "$BASELINE" 2>/dev/null | head -1 || true
}

_explain_usage() {
  cat <<'EOF'
aartool explain: what a finding means, what it costs, and what to do

Usage:
  aartool explain <CHECK-ID>     Explain one check
  aartool explain --list         List every check ID aartool knows
  aartool explain --written      List the IDs with a written entry

Examples:
  aartool explain KRN-01         # the doorway most Linux LPEs walk through
  aartool explain SSH-02
  aartool explain --list | grep KRN

IDs come from an audit. Run 'aartool inspect' first if you do not have one.
EOF
}

# Every ID the baseline can emit, in file order.
_explain_all_ids() {
  grep -oP 'add_result\s+"[^"]+"\s+"[^"]+"\s+"\K[A-Z]+-[0-9]+' "$BASELINE" 2>/dev/null \
    | awk '!seen[$0]++' || true
}

cmd_explain() {
  resolve_paths

  local id=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -h|--help) _explain_usage; return 0 ;;
      --list)
        _explain_all_ids | while read -r i; do
          printf '  %-10s %s\n' "$i" "$(_explain_title "$i")"
        done
        return 0 ;;
      --written) kb_ids | tr ' ' '\n' | grep -v '^$' | sed 's/^/  /'; return 0 ;;
      -*) die "Unknown option for explain: $1. Try 'aartool explain --help'." ;;
      *)  [[ -z "$id" ]] || die "explain takes one check ID at a time. Got '$id' and '$1'."
          id="${1^^}"; shift ;;
    esac
  done

  [[ -n "$id" ]] || { _explain_usage; return 1; }

  local title; title="$(_explain_title "$id")"
  if [[ -z "$title" ]]; then
    # Suggest, rather than just refusing. A wrong ID is nearly always a typo or
    # a family guess, and the family prefix is enough to be useful.
    local family="${id%%-*}" near
    near="$(_explain_all_ids | grep "^${family}-" | head -8 | tr '\n' ' ' || true)"
    if [[ -n "$near" ]]; then
      die "No check called '$id'. Checks in the $family family: $near
        Full list: aartool explain --list"
    fi
    die "No check called '$id'. See the full list: aartool explain --list"
  fi

  printf '\n%s%s%s  %s\n' "$BOLD" "$id" "$RESET" "$title"
  local cat; cat="$(_explain_category "$id")"
  [[ -n "$cat" ]] && printf '%sCategory: %s%s\n' "$CYAN" "$cat" "$RESET"
  printf '\n'

  if kb_has "$id"; then
    kb_entry "$id" | sed -e 's/^/  /' -e 's/[[:space:]]*$//'
  else
    # No written entry. Assemble one from the remediation map, which is
    # generated from the same table the reports use, so it cannot go stale
    # relative to what apply would actually do.
    local map; map="$(_explain_map_line "$id")"
    if [[ -n "$map" ]]; then
      local tags rhel ubu desc
      IFS='|' read -r tags rhel ubu desc <<<"$map"
      cat <<EOF | sed -e 's/^/  /' -e 's/[[:space:]]*$//'
WHAT
  $desc

FIX WITH AARTOOL
  aartool plan  --target HOST --user USER --only ${tags%%,*}
  aartool apply --target HOST --user USER --only ${tags%%,*}

  tags   $tags
  roles  $rhel (RHEL 9 family)
         $ubu (Debian / Ubuntu)

MORE
  No written entry for this check yet. To see exactly what apply would change
  on a real machine, run the plan above: it is a dry run and changes nothing.
  Written entries: aartool explain --written
EOF
    else
      cat <<EOF | sed -e 's/^/  /' -e 's/[[:space:]]*$//'
WHAT
  $title

FIX WITH AARTOOL
  Nothing. This check is deliberately not mapped to a role, which means one of
  two things: it is informational, or its remediation is not a configuration
  change a playbook can make safely (a reboot, a boot parameter, or a decision
  that needs someone who knows what the machine runs).

MORE
  Run 'aartool inspect' to see the evidence line for this check on the machine
  itself. It carries the observed value, which is usually the missing piece.
EOF
    fi
  fi
  printf '\n'
}

# ── doctor ───────────────────────────────────────────────────────────────────
# Everything that has to be true before plan or apply can work, checked in one
# place and reported all at once.
#
# The failure this replaces: ansible-playbook exits with "couldn't resolve module
# ansible.posix.sysctl" partway through a run, which tells the operator nothing
# about ansible-galaxy and nothing about which of the two collections is
# missing. Preflight is cheap; a half-applied hardening run is not.

_doc_ok=0; _doc_bad=0
_doc_pass() { printf '  %s✔%s  %-34s %s\n' "$GREEN" "$RESET" "$1" "${2:-}"; _doc_ok=$((_doc_ok+1)); }
_doc_fail() {
  printf '  %s✗%s  %-34s %s\n' "$RED" "$RESET" "$1" "${2:-}"
  [[ -n "${3:-}" ]] && printf '     %sfix%s  %s\n' "$CYAN" "$RESET" "$3"
  _doc_bad=$((_doc_bad+1))
}
_doc_warn() {
  printf '  %s!%s  %-34s %s\n' "$YELLOW" "$RESET" "$1" "${2:-}"
  [[ -n "${3:-}" ]] && printf '     %snote%s %s\n' "$CYAN" "$RESET" "$3"
}

cmd_doctor_usage() {
  cat <<'EOF'
aartool doctor: check everything plan and apply depend on. Changes nothing.

Usage:
  aartool doctor [--target HOST [--user USER] [--ssh-key FILE]]

Options:
  -t, --target HOST   Also test SSH reachability and sudo on that host
  -u, --user USER     SSH user for that test. Without it, ansible connects as
                      whoever you are locally, which is almost never right on
                      a real estate.
      --ssh-key FILE  SSH private key for that test
  -h, --help          Show this help

Exits non-zero if anything is missing, so it works as a CI gate.
EOF
}

cmd_doctor() {
  local target="" user="" sshkey=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -t|--target) [[ $# -ge 2 ]] || die "$1 needs a value."; target="$2"; shift 2 ;;
      -u|--user)   [[ $# -ge 2 ]] || die "$1 needs a value."; user="$2";   shift 2 ;;
      --ssh-key)   [[ $# -ge 2 ]] || die "$1 needs a value."; sshkey="$2"; shift 2 ;;
      -h|--help)   cmd_doctor_usage; return 0 ;;
      -*) die "Unknown option for doctor: $1." ;;
      *)  die "doctor takes no positional arguments." ;;
    esac
  done

  resolve_paths
  printf '\n%saartool doctor%s\n' "$BOLD" "$RESET"
  printf '%s\n' "────────────────────────────────────────────────────────────────────"

  # ── The toolkit itself ─────────────────────────────────────────────────────
  _doc_pass "toolkit located" "$ANSIBLE_BASE"
  [[ -r "$BASELINE" ]] && _doc_pass "cyberaar-baseline.sh" "readable" \
    || _doc_fail "cyberaar-baseline.sh" "missing or unreadable" "Re-clone, or run: bash scripts/build.sh"
  [[ -r "$HARDEN" ]] && _doc_pass "run-hardening.sh" "readable" \
    || _doc_fail "run-hardening.sh" "missing or unreadable" "Re-clone the repository"

  # ── Ansible ────────────────────────────────────────────────────────────────
  if command -v ansible-playbook >/dev/null 2>&1; then
    local av; av="$(ansible-playbook --version 2>/dev/null | head -1)"
    _doc_pass "ansible-playbook" "${av:-present}"
  else
    _doc_fail "ansible-playbook" "not on PATH" "pip install ansible   (or use the container: see execution-environment/)"
  fi

  # Named individually. "install the collections" is not actionable when one of
  # the two is already there and the other is not.
  local req="$ANSIBLE_BASE/requirements.yml" c
  if command -v ansible-galaxy >/dev/null 2>&1; then
    for c in ansible.posix community.general; do
      if ansible-galaxy collection list 2>/dev/null | grep -q "^${c} "; then
        _doc_pass "collection ${c}" "installed"
      else
        _doc_fail "collection ${c}" "missing" "ansible-galaxy collection install -r ${req}"
      fi
    done
  else
    _doc_fail "ansible-galaxy" "not on PATH" "pip install ansible"
  fi

  # ── Inventory ──────────────────────────────────────────────────────────────
  if [[ -f "$INVENTORY" ]]; then
    local hosts; hosts="$(grep -cE '^[a-zA-Z0-9][a-zA-Z0-9._-]*' "$INVENTORY" 2>/dev/null || echo 0)"
    _doc_pass "inventory" "$INVENTORY ($hosts entries)"
  elif [[ -f "$INVENTORY_EXAMPLE" ]]; then
    _doc_fail "inventory" "not created yet" "cp $INVENTORY_EXAMPLE $INVENTORY"
  else
    _doc_fail "inventory" "not found" "Create $INVENTORY in INI format"
  fi

  # ── The machine this is running on ─────────────────────────────────────────
  local osname="unknown"
  [[ -r /etc/os-release ]] && osname="$(. /etc/os-release 2>/dev/null; printf '%s %s' "${NAME:-?}" "${VERSION_ID:-}")"
  _doc_pass "control node OS" "$osname"
  _doc_pass "kernel" "$(uname -r)"

  # ── Optional: can we actually reach the target ─────────────────────────────
  if [[ -n "$target" ]]; then
    if [[ ! -f "$INVENTORY" ]]; then
      _doc_warn "target $target" "skipped" "No inventory to resolve it against"
    elif ! target_in_inventory "$target"; then
      _doc_fail "target $target" "not in inventory" "Add it to $INVENTORY"
    elif ! command -v ansible >/dev/null 2>&1; then
      _doc_warn "target $target" "skipped" "ansible not on PATH"
    else
      # Without -u, ansible connects as whoever is running this, which on a
      # real estate is never the admin account. doctor reported "unreachable"
      # for a host that was perfectly reachable, and the fix line it printed
      # reproduced its own mistake. plan and apply have always taken --user;
      # the check that exists to catch connection problems did not.
      local -a probe=(-i "$INVENTORY" "$target" -m ping)
      [[ -n "$user"   ]] && probe+=(-u "$user")
      [[ -n "$sshkey" ]] && probe+=(--private-key "$sshkey")
      vlog "probe: ansible ${probe[*]}"
      if ansible "${probe[@]}" >/dev/null 2>&1; then
        _doc_pass "target $target" "reachable$([[ -n "$user" ]] && printf ' as %s' "$user")"
        # Reachable is not the same as able to change anything. plan is a dry
        # run, but apply needs root, and finding that out at apply time means
        # finding out halfway through a hardening run.
        if ansible -i "$INVENTORY" "$target" -m raw -a 'sudo -n true' \
             ${user:+-u "$user"} ${sshkey:+--private-key "$sshkey"} >/dev/null 2>&1; then
          _doc_pass "sudo on $target" "passwordless"
        else
          _doc_warn "sudo on $target" "needs a password" \
            "apply will stall unless you pass -K, or grant NOPASSWD to the automation account"
        fi
      else
        local hint="ansible -i $INVENTORY $target -m ping"
        [[ -n "$user"   ]] && hint+=" -u $user"
        [[ -n "$sshkey" ]] && hint+=" --private-key $sshkey"
        [[ -z "$user"   ]] && hint+="   (no --user given, so it tried as $(id -un))"
        _doc_fail "target $target" "unreachable" "$hint"
      fi
    fi
  fi

  printf '%s\n' "────────────────────────────────────────────────────────────────────"
  if [[ "$_doc_bad" -eq 0 ]]; then
    printf '  %s%d checks passed. Ready.%s\n\n' "$GREEN" "$_doc_ok" "$RESET"
    return 0
  fi
  printf '  %s%d problem(s)%s, %d fine. Fix the above and run doctor again.\n\n' "$RED" "$_doc_bad" "$RESET" "$_doc_ok"
  return 1
}

# ── report ───────────────────────────────────────────────────────────────────
# The toolkit already ships a dashboard: one HTML file, no server, no internet,
# drag your JSON reports onto it. The friction is everything around that. You run
# an audit, find the JSON, find the dashboard, open a browser, drag files in.
#
# This collapses those into one command, and adds the thing a consultancy
# actually needs: a single self-contained file with the results already inside,
# which can be attached to an email and opened offline by someone who has never
# heard of this toolkit.
#
# The dashboard itself is never modified. A copy is made and a small bootstrap
# appended that feeds its own DB structure, so the two cannot fall out of step
# through anything except a deliberate change to the dashboard's data model.

cmd_report_usage() {
  cat <<'EOF'
aartool report: visualise baseline JSON reports.

Usage:
  aartool report [REPORT.json ...] [options]

Options:
  -o, --out FILE    Write a self-contained HTML file with the reports embedded.
                    Opens offline, anywhere, with no other files. Send it.
      --serve PORT  Serve the dashboard on 127.0.0.1:PORT. For a headless
                    server: run it there, then tunnel with
                      ssh -L PORT:127.0.0.1:PORT user@host
      --open        Try to open the result in a browser
      --anonymise   Replace every hostname with server-01, server-02, ... and
                    every IP address with ip-01, ip-02, ... consistently across
                    all reports, so the file can be shared outside the estate
                    it came from. Prints the mapping and what it changed.
      --redact PAT  Also replace this literal string everywhere. Repeatable.
                    For the things only you know are identifying: a client name,
                    a project codename, an admin account.
  -h, --help        Show this help

With no arguments it opens the empty dashboard, where you can drag reports in
yourself.

  sudo aartool inspect --out /tmp/audit
  aartool report /tmp/audit/*.json --out fleet.html

  # A version you can hand to someone outside the estate
  aartool report /tmp/audit/*.json --anonymise --redact acme-corp --out share.html
EOF
}

# Embedding JSON inside <script> is a breakout waiting to happen: a hostname
# containing </script> would end the block and everything after it becomes
# markup. < and > only ever appear inside strings in JSON, so escaping them to
# their \u form keeps the document valid and closes the hole.
_report_js_safe() { sed -e 's|<|\\u003c|g' -e 's|>|\\u003e|g'; }

# ── Anonymising ───────────────────────────────────────────────────────────────
# An audit report is a list of a machine's weaknesses with its name attached.
# There are good reasons to show one to somebody: a client, a conference talk, a
# post explaining the tool. There is no good reason to hand over the hostnames
# while doing it.
#
# The substitution is literal and global, not a field rewrite. A hostname does
# not only live in the "host" key; it turns up in evidence strings, in
# remediation hints, in whatever a check happened to capture. Replacing the key
# alone produces a document that looks anonymised and is not, which is worse
# than not trying.
#
# It builds one mapping across every input file, so the same machine is the same
# server-NN in all of them and a before/after pair still lines up.
_report_anon_sed=""
_report_anon_map=""

_report_build_anon() {
  local -n _files_ref="$1"; shift
  local -a extra=("$@")
  local f name ip n=0 script="" map=""

  # Longest first. Replacing "web-01" before "web-01.example.com" would leave
  # "server-04.example.com" behind, which still names the domain.
  local hosts
  hosts=$(grep -ho '"host":[[:space:]]*"[^"]*"' "${_files_ref[@]}" 2>/dev/null \
          | sed 's/.*"host":[[:space:]]*"//; s/"$//' | sort -u \
          | awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2- || true)
  while IFS= read -r name; do
    [[ -z "$name" ]] && continue
    n=$((n+1))
    local alias; alias=$(printf 'server-%02d' "$n")
    script+="s|$(_report_sed_escape "$name")|${alias}|g;"
    map+="  ${name}  ->  ${alias}"$'\n'
  done <<<"$hosts"

  # Addresses, in the order they first appear so the numbering is stable.
  local ips
  ips=$(grep -hoE '\b([0-9]{1,3}\.){3}[0-9]{1,3}\b' "${_files_ref[@]}" 2>/dev/null | sort -u || true)
  local m=0
  while IFS= read -r ip; do
    [[ -z "$ip" ]] && continue
    m=$((m+1))
    script+="s|$(_report_sed_escape "$ip")|$(printf 'ip-%02d' "$m")|g;"
  done <<<"$ips"
  [[ "$m" -gt 0 ]] && map+="  ${m} IP address(es)  ->  ip-01 .. $(printf 'ip-%02d' "$m")"$'\n'

  # --redact is a literal global substitution, which is what makes it useful and
  # also what makes it dangerous: the report's own structural keys are just
  # strings in the same document. `--redact cyberaar` rewrote every
  # "cyberaar_baseline" key to "REDACTED_baseline", the dashboard's bootstrap
  # found no reports to load, and the output was a perfectly valid HTML file
  # showing an empty page. Refuse instead, and say why.
  local schema="cyberaar_baseline host os date score summary results id category status check detail remediation ansible_remediation remediation_tags version fail_ids warn_ids playbook inventory pass warn fail total"
  local e k
  for e in ${extra[@]+"${extra[@]}"}; do
    for k in $schema; do
      if [[ "$k" == *"$e"* ]]; then
        die "--redact '$e' would also rewrite the report's own '$k' field, and the
        output would render an empty dashboard. Pick a more specific string, or
        drop it: hostnames and addresses are already handled by --anonymise."
      fi
    done
    script+="s|$(_report_sed_escape "$e")|REDACTED|g;"
    map+="  ${e}  ->  REDACTED"$'\n'
  done

  _report_anon_sed="$script"
  _report_anon_map="$map"
}

# A hostname can legitimately contain characters sed treats as syntax, and the
# pipe has to be escaped too because it is also the s||| delimiter here. The
# first version of this used a sed bracket expression containing a pipe, which
# sed read as the end of the pattern, so it silently produced a broken script
# and the whole command exited 1 with no message. Parameter expansion has no
# delimiter to collide with.
_report_sed_escape() {
  local t="$1"
  t="${t//\\/\\\\}"          # backslash first, or it doubles the others
  t="${t//|/\\|}"
  t="${t//./\\.}"
  t="${t//\*/\\*}"
  t="${t//\[/\\[}"
  t="${t//\]/\\]}"
  t="${t//^/\\^}"
  t="${t//\$/\\$}"
  t="${t//&/\\&}"
  t="${t//\//\\/}"
  printf '%s' "$t"
}

# What a reader would still be able to identify. Printed after the fact rather
# than silently trusted, because the operator knows things this cannot: a client
# name, an internal codename, a person.
_report_anon_warn() {
  local file="$1" leaks="" data
  # Only the injected data. The dashboard above it contains example commands
  # with an example address in them, so scanning the whole file warned on every
  # single run, and a warning that always fires is one people stop reading.
  data=$(sed -n '/Injected by aartool/,$p' "$file" 2>/dev/null || true)
  [[ -n "$data" ]] || return 0
  grep -qE '\b([0-9]{1,3}\.){3}[0-9]{1,3}\b' <<<"$data" && leaks+=" an IP address,"
  # Any host value that is not one of ours. Written as a positive match on what
  # a real hostname looks like, because grep -E has no negative lookahead and
  # the version that pretended otherwise checked nothing at all.
  grep -o '"host":[[:space:]]*"[^"]*"' <<<"$data" 2>/dev/null \
    | grep -qv '"server-[0-9]' && leaks+=" a hostname,"
  if [[ -n "$leaks" ]]; then
    warn "After anonymising, the output still contains:${leaks%,}"
    warn "Read it before you publish it."
  fi
}

cmd_report() {
  local out="" serve="" do_open=false anon=false
  local -a files=() redact=()

  while [[ $# -gt 0 ]]; do
    case "$1" in
      -o|--out)  [[ $# -ge 2 ]] || die "$1 needs a file path."; out="$2"; shift 2 ;;
      --serve)   [[ $# -ge 2 ]] || die "--serve needs a port."; serve="$2"; shift 2 ;;
      --open)    do_open=true; shift ;;
      --anonymise|--anonymize) anon=true; shift ;;
      --redact)  [[ $# -ge 2 ]] || die "--redact needs a string."; redact+=("$2"); shift 2 ;;
      -h|--help) cmd_report_usage; return 0 ;;
      --) shift; while [[ $# -gt 0 ]]; do files+=("$1"); shift; done ;;
      -*) die "Unknown option for report: $1. Try 'aartool report --help'." ;;
      *)  files+=("$1"); shift ;;
    esac
  done

  resolve_paths
  local dash="$TOOLKIT_DASHBOARD"
  [[ -f "$dash" ]] || die "Dashboard not found: $dash"

  local f
  for f in ${files[@]+"${files[@]}"}; do
    [[ -f "$f" ]] || die "No such report: $f"
  done

  # ── serve ──────────────────────────────────────────────────────────────────
  if [[ -n "$serve" ]]; then
    [[ "$serve" =~ ^[0-9]+$ ]] || die "--serve needs a port number, got '$serve'."
    command -v python3 >/dev/null 2>&1 || die "--serve needs python3. Without it, copy dashboard/index.html to your workstation and open it there."
    local dir; dir="$(dirname "$dash")"
    info "Serving $dir on http://127.0.0.1:${serve}/"
    # Bound to the loopback deliberately. This renders audit results for a whole
    # estate; it has no authentication and must not be reachable from the network.
    info "Bound to 127.0.0.1 only. From your workstation: ssh -L ${serve}:127.0.0.1:${serve} $(id -un)@$(hostname)"
    info "Ctrl-C to stop."
    ( cd "$dir" && exec python3 -m http.server "$serve" --bind 127.0.0.1 )
    return 0
  fi

  # ── no reports: just the dashboard as it ships ─────────────────────────────
  if [[ "${#files[@]}" -eq 0 ]]; then
    if [[ -n "$out" ]]; then
      cp "$dash" "$out" || die "Cannot write $out"
      success "Written: $out (empty dashboard, drag reports onto it)"
    else
      info "Dashboard: $dash"
      info "Open it in a browser and drag your JSON reports onto it, or pass them: aartool report *.json"
      [[ "$do_open" == true ]] && _report_open "$dash"
    fi
    return 0
  fi

  # ── build a preloaded copy ─────────────────────────────────────────────────
  local target="${out:-$(mktemp -t aartool-report-XXXXXX.html)}"
  cp "$dash" "$target" || die "Cannot write $target"

  if [[ "$anon" == true ]]; then
    _report_build_anon files ${redact[@]+"${redact[@]}"}
    info "Anonymised. The mapping is printed once, here, and stored nowhere:"
    printf '%s' "$_report_anon_map"
  fi

  {
    printf '\n<script>\n'
    printf '// Injected by aartool %s. The dashboard is unmodified above this line.\n' "$AARTOOL_VERSION"
    printf '(function () {\n  var PRELOAD = [\n'
    local first=true
    for f in "${files[@]}"; do
      [[ "$first" == true ]] || printf ',\n'
      first=false
      if [[ "$anon" == true ]]; then
        sed "$_report_anon_sed" < "$f" | _report_js_safe
      else
        _report_js_safe < "$f"
      fi
    done
    printf '\n  ];\n'
    cat <<'JS'
  // Feed the dashboard's own store rather than re-implementing its parsing, so
  // a change to its data model breaks loudly here instead of rendering wrongly.
  if (typeof DB === 'undefined' || typeof renderAll !== 'function') {
    console.error('aartool: dashboard data model not found; open the file and drag reports in instead.');
    return;
  }
  PRELOAD.forEach(function (raw) {
    var r = raw && raw.cyberaar_baseline;
    if (!r || !r.host) { console.warn('aartool: skipped a report with no host'); return; }
    if (!DB[r.host]) DB[r.host] = [];
    DB[r.host].push(r);
    DB[r.host].sort(function (a, b) { return String(a.date).localeCompare(String(b.date)); });
  });
  renderAll();
})();
JS
    printf '</script>\n'
  } >> "$target"

  # Verify the artefact rather than trusting the transformation that produced
  # it. Anonymising rewrites the document with a generated sed script; if that
  # script touches something structural the file is still valid HTML and still
  # opens, and shows nothing at all.
  local embedded
  embedded=$(grep -c '"cyberaar_baseline"' "$target" || true)
  if [[ "$embedded" -lt "${#files[@]}" ]]; then
    die "Only ${embedded} of ${#files[@]} reports survived into $target with an intact
        structure, so it would open empty. This is a bug in aartool, not in your
        input; please report it with the flags you used."
  fi

  [[ "$anon" == true ]] && _report_anon_warn "$target"

  local n="${#files[@]}"
  if [[ -n "$out" ]]; then
    success "Written: $out"
    info "$n report(s) embedded. Self-contained: no server, no internet, no other files."
  else
    success "Built: $target ($n report(s) embedded)"
  fi
  [[ "$do_open" == true ]] && _report_open "$target"
  return 0
}

_report_open() {
  local f="$1" opener
  for opener in xdg-open open wslview; do
    if command -v "$opener" >/dev/null 2>&1; then
      "$opener" "$f" >/dev/null 2>&1 &
      return 0
    fi
  done
  warn "No browser opener found (xdg-open, open, wslview). Open it yourself: $f"
}

# ── diff ─────────────────────────────────────────────────────────────────────
# What changed between two audits of the same machine.
#
# This is the command that belongs in cron, and the reason is the exit code. A
# weekly audit that mails you 109 results teaches you to filter the mail. One
# that stays silent unless something REGRESSED is a thing you actually read.
#
#   aartool diff last-week.json today.json || mail -s "drift on $(hostname)" soc@
#
# Regressions and improvements are not symmetric here. A check going PASS to FAIL
# is an alert; going FAIL to PASS is a note. Treating them the same is how a
# report becomes wallpaper.

cmd_diff_usage() {
  cat <<'EOF'
aartool diff: what changed between two audits. Changes nothing.

Usage:
  aartool diff BEFORE.json AFTER.json [options]

Options:
      --quiet     Print only regressions. Nothing at all when there are none,
                  which is what you want in cron.
  -h, --help      Show this help

Exit codes:
  0   nothing regressed
  1   at least one check regressed
  2   the reports could not be compared

Built for cron:

  aartool diff /var/log/cyberaar/last.json /var/log/cyberaar/today.json --quiet \
    || mail -s "config drift on $(hostname)" soc@example.com
EOF
}

cmd_diff() {
  local quiet=false
  local -a pos=()
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --quiet)   quiet=true; shift ;;
      -h|--help) cmd_diff_usage; return 0 ;;
      --) shift; while [[ $# -gt 0 ]]; do pos+=("$1"); shift; done ;;
      -*) die "Unknown option for diff: $1. Try 'aartool diff --help'." ;;
      *)  pos+=("$1"); shift ;;
    esac
  done

  [[ "${#pos[@]}" -eq 2 ]] || die "diff needs exactly two reports: aartool diff BEFORE.json AFTER.json"
  local before="${pos[0]}" after="${pos[1]}"
  [[ -f "$before" ]] || die "No such report: $before"
  [[ -f "$after"  ]] || die "No such report: $after"

  command -v python3 >/dev/null 2>&1 \
    || die "diff needs python3 to parse the reports. It is present on any machine that can run Ansible."

  # Kept in python rather than jq: jq is not installed by default on RHEL,
  # Ubuntu server or Alpine, and requiring it would put a package install between
  # an operator and their own audit results.
  python3 - "$before" "$after" "$quiet" <<'PY'
import json, sys

before_path, after_path, quiet = sys.argv[1], sys.argv[2], sys.argv[3] == "true"

def load(p):
    try:
        with open(p, encoding="utf-8") as fh:
            doc = json.load(fh)
    except (OSError, json.JSONDecodeError) as e:
        sys.stderr.write(f"[ERROR] Cannot read {p}: {e}\n"); sys.exit(2)
    b = doc.get("cyberaar_baseline")
    if not b:
        sys.stderr.write(f"[ERROR] {p} is not a cyberaar-baseline report.\n"); sys.exit(2)
    return b

a, b = load(before_path), load(after_path)

# Comparing two different machines silently would produce a diff that looks
# real and means nothing.
if a.get("host") and b.get("host") and a["host"] != b["host"]:
    sys.stderr.write(
        f"[ERROR] These are different hosts: '{a['host']}' and '{b['host']}'.\n"
        f"        Comparing them would produce a difference that is not drift.\n")
    sys.exit(2)

RANK = {"PASS": 0, "WARN": 1, "FAIL": 2}
def index(rep):
    return {r["id"]: r for r in rep.get("results", []) if isinstance(r, dict) and "id" in r}

ai, bi = index(a), index(b)

regressed, improved, appeared, vanished = [], [], [], []
for cid, br in bi.items():
    ar = ai.get(cid)
    if ar is None:
        # A new check counts as drift only if it is already failing; a new
        # passing check is just this tool having learned something.
        if RANK.get(br.get("status"), 1) > 0:
            appeared.append((cid, br))
        continue
    was, now = ar.get("status"), br.get("status")
    if RANK.get(now, 1) > RANK.get(was, 1):
        regressed.append((cid, was, now, br))
    elif RANK.get(now, 1) < RANK.get(was, 1):
        improved.append((cid, was, now, br))
for cid, ar in ai.items():
    if cid not in bi:
        vanished.append((cid, ar))

C = sys.stdout.isatty()
RED    = "\033[0;31m" if C else ""
GREEN  = "\033[0;32m" if C else ""
YELLOW = "\033[1;33m" if C else ""
CYAN   = "\033[0;36m" if C else ""
BOLD   = "\033[1m"    if C else ""
RST    = "\033[0m"    if C else ""

def line(sym, colour, cid, text, extra=""):
    print(f"  {colour}{sym}{RST}  {cid:<9} {text}")
    if extra:
        print(f"       {CYAN}{extra}{RST}")

if quiet and not regressed:
    sys.exit(0)

if not quiet:
    print()
    print(f"{BOLD}{a.get('host','?')}{RST}   {a.get('date','?')}  →  {b.get('date','?')}")
    sa, sb = a.get("score"), b.get("score")
    if isinstance(sa, (int, float)) and isinstance(sb, (int, float)):
        delta = sb - sa
        col = GREEN if delta > 0 else (RED if delta < 0 else "")
        print(f"{BOLD}score{RST}    {sa} → {sb}   {col}{delta:+d}{RST}")
    print("─" * 68)

if regressed:
    print(f"\n  {RED}{BOLD}Regressed{RST}  ({len(regressed)})")
    for cid, was, now, r in sorted(regressed, key=lambda x: -RANK.get(x[2], 0)):
        line("✗", RED, cid, f"{was} → {now}   {r.get('check','')}", r.get("detail", ""))
        if r.get("remediation"):
            print(f"       {CYAN}fix{RST}  {r['remediation']}")

if not quiet:
    if appeared:
        print(f"\n  {YELLOW}New and not passing{RST}  ({len(appeared)})")
        for cid, r in appeared:
            line("+", YELLOW, cid, f"{r.get('status')}   {r.get('check','')}", r.get("detail", ""))
    if improved:
        print(f"\n  {GREEN}Improved{RST}  ({len(improved)})")
        for cid, was, now, r in improved:
            line("✔", GREEN, cid, f"{was} → {now}   {r.get('check','')}")
    if vanished:
        print(f"\n  {CYAN}No longer reported{RST}  ({len(vanished)})")
        for cid, r in vanished:
            line("·", CYAN, cid, r.get("check", ""))
    print("\n" + "─" * 68)
    if regressed:
        print(f"  {RED}{len(regressed)} regression(s){RST}, {len(improved)} improvement(s)\n")
    else:
        print(f"  {GREEN}Nothing regressed{RST}, {len(improved)} improvement(s)\n")

sys.exit(1 if regressed else 0)
PY
}

# ── install ──────────────────────────────────────────────────────────────────
# A SYMLINK, not a copy, and that is not a detail.
#
# aartool locates everything it wraps by walking up from its own file until it
# finds ansible-hardening/. Copied to /usr/local/bin there is nothing above it
# but /usr and /, so a copied aartool finds no playbooks, no baseline script and
# no dashboard, and every command fails with a message about six directories.
#
# A symlink is resolved back to the repository first, so the walk starts where
# the toolkit actually is. Anyone who still wants a copy sets AARTOOL_HOME, and
# the error message says so rather than leaving them to work it out.

cmd_install_usage() {
  cat <<'EOF'
aartool install: put aartool on your PATH.

Usage:
  sudo aartool install [options]
  sudo aartool install --uninstall

Options:
      --prefix DIR   Install into DIR/bin instead of /usr/local/bin.
                     Use ~/.local for a per-user install with no root.
      --uninstall    Remove the symlink
  -h, --help         Show this help

It installs a SYMLINK to this file, not a copy. aartool finds the playbooks, the
baseline script and the dashboard by walking up from its own location, so a copy
sitting alone in /usr/local/bin would find none of them. Keep the repository
where it is, or move it and re-run install.

Per-user, no root:

  aartool install --prefix ~/.local
  # then ensure ~/.local/bin is on your PATH
EOF
}

cmd_install() {
  local prefix="/usr/local" uninstall=false
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --prefix)    [[ $# -ge 2 ]] || die "--prefix needs a directory."; prefix="${2%/}"; shift 2 ;;
      --uninstall) uninstall=true; shift ;;
      -h|--help)   cmd_install_usage; return 0 ;;
      -*) die "Unknown option for install: $1." ;;
      *)  die "install takes no positional arguments." ;;
    esac
  done

  local bindir="$prefix/bin" link="$prefix/bin/aartool"

  if [[ "$uninstall" == true ]]; then
    if [[ -L "$link" || -e "$link" ]]; then
      rm -f "$link" || die "Cannot remove $link. Try with sudo."
      success "Removed $link"
    else
      info "Nothing installed at $link"
    fi
    return 0
  fi

  # Resolve the real path of this script, following the symlink chain, so
  # re-running install after a move repoints rather than creating a loop.
  local self; self="$(_self_dir)/$(basename "${BASH_SOURCE[0]}")"
  [[ -f "$self" ]] || self="$(_self_dir)/aartool"
  [[ -f "$self" ]] || die "Cannot determine where aartool lives on disk."

  resolve_paths   # fail here, before installing, if the toolkit is incomplete

  mkdir -p "$bindir" 2>/dev/null || die "Cannot create $bindir. Try sudo, or --prefix ~/.local for a per-user install."
  [[ -w "$bindir" ]] || die "$bindir is not writable. Try sudo, or --prefix ~/.local for a per-user install."

  if [[ -e "$link" && ! -L "$link" ]]; then
    die "$link exists and is not a symlink. Remove it yourself if you are sure; refusing to overwrite a real file."
  fi

  ln -sfn "$self" "$link" || die "Cannot link $link"
  success "Installed: $link -> $self"

  case ":${PATH}:" in
    *":$bindir:"*) info "Run: aartool doctor" ;;
    *) warn "$bindir is not on your PATH."
       info "Add it: echo 'export PATH=\"$bindir:\$PATH\"' >> ~/.bashrc && . ~/.bashrc" ;;
  esac
}

# ── Dispatch ─────────────────────────────────────────────────────────────────
main() {
  [[ $# -gt 0 ]] || { usage; exit 1; }

  # -v is global, so it works before or after the command name. Anything else
  # is left for the command to parse.
  local -a args=()
  local a
  for a in "$@"; do
    case "$a" in
      -v|--verbose) AARTOOL_VERBOSE=1 ;;
      *) args+=("$a") ;;
    esac
  done
  [[ ${#args[@]} -gt 0 ]] || { usage; exit 1; }
  set -- "${args[@]}"
  vlog "aartool $AARTOOL_VERSION, verbose on"

  case "$1" in
    inspect)        shift; cmd_inspect "$@" ;;
    plan)           shift; cmd_harden plan  "$@" ;;
    apply)          shift; cmd_harden apply "$@" ;;
    surface)        shift; cmd_surface "$@" ;;
    advise)         shift; cmd_advise "$@" ;;
    explain|why)    shift; cmd_explain "$@" ;;
    doctor)         shift; cmd_doctor "$@" ;;
    report)         shift; cmd_report "$@" ;;
    diff)           shift; cmd_diff "$@" ;;
    install)        shift; cmd_install "$@" ;;
    -h|--help|help) usage ;;
    -V|--version|version) printf 'aartool %s\n' "$AARTOOL_VERSION" ;;
    # Named so the error can be specific rather than "unknown command".
    audit|scan)     die "There is no '$1' command. Auditing is 'aartool inspect'." ;;
    harden)         die "There is no 'harden' command. Preview with 'aartool plan', apply with 'aartool apply'." ;;
    fix|remediate)  die "There is no '$1' command. See the plan with 'aartool advise', apply it with 'aartool apply'." ;;
    -*)             die "Unknown option: $1. Try 'aartool --help'." ;;
    *)              die "Unknown command: $1. Try 'aartool --help'." ;;
  esac
}

main "$@"
