#!/bin/bash
# Solvbeat Agent — Linux
#
# WHAT THIS DOES: runs a fixed set of READ-ONLY local checks (patch status,
# SSH hardening, firewall, file permissions, etc.) and reports the results
# to your Solvbeat dashboard. By default it never writes to your system
# beyond its own state directory, and never executes anything sent back
# from the server.
#
# OPT-IN ACTIONS (--enable-actions): if you pass this flag, the agent will
# also check for actions a Solvbeat analyst has reviewed and approved on a
# critical alert, and carry them out using one of exactly five fixed,
# reversible templates -- never a free-text command. See execute_action()
# below for the full, exact logic; there is nothing beyond what's written
# there. Off by default; nothing changes unless you add this flag yourself.
#
# OPT-IN LIVE PROCESS MONITORING (--enable-live-monitoring): by default the
# agent takes a point-in-time snapshot each scheduled run and can't see a
# process that started and exited in between. With this flag it installs
# osquery (a widely-used open-source tool) and runs it as a lightweight
# background service that records every process execution via the Linux
# kernel audit subsystem in real time -- so a short-lived malicious process
# is caught even if it's gone before the next scheduled run. This DOES
# install a package and run a daemon, unlike the read-only default, which
# is exactly why it's opt-in. Enabling it once is remembered (a marker in
# the state dir), so scheduled runs keep monitoring without re-passing the
# flag; turn it back off with --disable-live-monitoring. See
# ensure_live_monitoring() and run_osquery_process_rules() below for the
# full, exact logic.
#
# Read this script before running it with sudo; that's the whole point of
# it being plain, readable bash.
#
# Usage:
#   First run (enroll):  ./agent-linux.sh --enroll --token=<token from dashboard>
#   Scheduled runs:       ./agent-linux.sh                 (uses stored API key)
#   Preview without network: ./agent-linux.sh --dry-run
#
# SELF-UPDATING: on every scheduled run this script checks the published
# version and, if newer, overwrites itself and re-execs before doing
# anything else — so new detection rules reach you automatically without
# reinstalling. Skipped for --dry-run (stays true to "preview without
# network") and --enroll (you just fetched it fresh). Disable with
# --no-self-update if you need to pin a specific version.
#
set -uo pipefail

API_BASE="https://solvbeat.co.uk/api"
SCRIPT_URL="https://solvbeat.co.uk/agent/agent-linux.sh"
STATE_DIR="/opt/solvbeat-agent"
KEY_FILE="$STATE_DIR/api_key"
LIVE_MON_MARKER="$STATE_DIR/live_monitoring_enabled"
VERSION="1.13.0"

MODE="report"
TOKEN=""
SELF_UPDATE=1
ENABLE_ACTIONS=0
ENABLE_AUTOFIX=0
LIVE_MON_FLAG=0
DISABLE_LIVE_MON=0
for arg in "$@"; do
  case $arg in
    --enroll) MODE="enroll" ;;
    --dry-run) MODE="dry-run" ;;
    --token=*) TOKEN="${arg#*=}" ;;
    --no-self-update) SELF_UPDATE=0 ;;
    --enable-actions) ENABLE_ACTIONS=1 ;;
    --enable-auto-fix) ENABLE_ACTIONS=1; ENABLE_AUTOFIX=1 ;;
    --enable-live-monitoring) LIVE_MON_FLAG=1 ;;
    --disable-live-monitoring) DISABLE_LIVE_MON=1 ;;
  esac
done

if [ "$MODE" = "report" ] && [ "$SELF_UPDATE" -eq 1 ] && [ -f "$0" ] && [ -w "$0" ]; then
  LATEST=$(curl -sS --max-time 15 "$SCRIPT_URL" 2>/dev/null || true)
  if [ -n "$LATEST" ]; then
    LATEST_VERSION=$(printf '%s\n' "$LATEST" | grep -m1 '^VERSION=' | sed 's/VERSION="//; s/"//')
    if [ -n "$LATEST_VERSION" ] && [ "$LATEST_VERSION" != "$VERSION" ]; then
      TMP_SELF=$(mktemp)
      printf '%s\n' "$LATEST" > "$TMP_SELF"
      if bash -n "$TMP_SELF" 2>/dev/null; then
        cat "$TMP_SELF" > "$0"
        rm -f "$TMP_SELF"
        exec bash "$0" "$@"
      fi
      rm -f "$TMP_SELF"
    fi
  fi
fi

FINDINGS_RAW=()
add_finding() {
  # $1=severity(critical|medium|low) $2=title $3=detail
  # $4=ruleId $5=technique $6=tactic $7=playbook — all optional, pass ""
  # for checks that are static config (not a MITRE-mapped behavioural check).
  FINDINGS_RAW+=("$1"$'\t'"$2"$'\t'"$3"$'\t'"${4:-}"$'\t'"${5:-}"$'\t'"${6:-}"$'\t'"${7:-}")
}

CHECKS_RAW=()
add_check_result() {
  # $1=name (matches AGENT_CHECK_CATALOG label on the backend)
  # $2=status(pass|fail) $3=severity(critical|medium|low|"") $4=detail
  # $5=playbook (only meaningful when status=fail, pass "" otherwise)
  # Called once per check, every run, whether it passed or failed — this is
  # what lets the checklist say exactly what was found either way, not just
  # flag problems.
  CHECKS_RAW+=("$1"$'\t'"$2"$'\t'"${3:-}"$'\t'"$4"$'\t'"${5:-}")
}

ALERTS_RAW=()
add_alert() {
  # $1=ruleName $2=severity(info|low|medium|critical) $3=summary $4=context
  # (free text — wrapped into {"detail": "..."} for the backend's jsonb
  # column, kept as plain text here rather than real nested JSON so bash
  # never has to hand-build JSON and risk breaking on a stray quote).
  ALERTS_RAW+=("$1"$'\t'"$2"$'\t'"$3"$'\t'"${4:-}")
}

# ---------------------------------------------------------------
# Checks — every check here is read-only. Nothing here modifies the
# system, installs anything, or changes configuration.
# ---------------------------------------------------------------
run_checks() {
  # 1. Pending security updates — name the actual packages/advisories, not
  # just a count, so the finding says what to update and points at where
  # to find which CVE each one fixes. On pass, still report the real
  # package-manager state (including any non-security updates available)
  # instead of a bare "OK".
  if command -v apt >/dev/null 2>&1; then
    local upgradable_list sec_pkgs total_upgradable
    upgradable_list=$(apt list --upgradable 2>/dev/null | grep -v '^Listing')
    sec_pkgs=$(printf '%s\n' "$upgradable_list" | grep -i security | cut -d'/' -f1 | sort -u | paste -sd',' -)
    total_upgradable=$(printf '%s\n' "$upgradable_list" | grep -c . || true)
    if [ -n "$sec_pkgs" ]; then
      add_finding "critical" "Pending security updates" "Packages with pending security patches: $sec_pkgs — run 'apt upgrade' to apply them." "" "" "" "Run 'sudo apt update && sudo apt upgrade' to install these. To see exactly which CVE each package fixes before updating, run 'apt-get changelog <package-name>' (top entries list the fixes) or look the package up at https://ubuntu.com/security/notices. Reboot afterwards if the kernel or a running service was patched."
      add_check_result "Pending security updates" "fail" "critical" "Packages with pending security patches: $sec_pkgs (checked via 'apt list --upgradable')." "Run 'sudo apt update && sudo apt upgrade' to install these. To see exactly which CVE each package fixes, run 'apt-get changelog <package-name>' or check https://ubuntu.com/security/notices. Reboot afterwards if the kernel or a running service was patched."
    elif [ "${total_upgradable:-0}" -gt 0 ]; then
      add_check_result "Pending security updates" "pass" "" "No security-flagged updates pending. $total_upgradable non-security package update(s) are available (apt list --upgradable) — not urgent, but worth applying with 'sudo apt update && sudo apt upgrade' during routine maintenance." ""
    else
      add_check_result "Pending security updates" "pass" "" "No pending package updates of any kind — apt reports this host fully up to date as of this check." ""
    fi
  elif command -v yum >/dev/null 2>&1; then
    local sec_advisories
    sec_advisories=$(yum updateinfo list security 2>/dev/null | grep -E '^[A-Za-z]+-[0-9]' | awk '{print $1, $3}' | paste -sd';' -)
    if [ -n "$sec_advisories" ]; then
      add_finding "critical" "Pending security updates" "Pending security advisories: $sec_advisories — run 'yum update --security' to apply them." "" "" "" "Run 'sudo yum update --security' (or 'sudo dnf update --security'). For the full details on any advisory, including the exact CVE(s) it fixes, run 'yum updateinfo info <ADVISORY-ID>' (e.g. 'yum updateinfo info RHSA-2024:1234'). Reboot afterwards if the kernel or a running service was patched."
      add_check_result "Pending security updates" "fail" "critical" "Pending security advisories: $sec_advisories (checked via 'yum updateinfo list security')." "Run 'sudo yum update --security'. For CVE details on any advisory, run 'yum updateinfo info <ADVISORY-ID>'. Reboot afterwards if the kernel or a running service was patched."
    else
      add_check_result "Pending security updates" "pass" "" "No pending security advisories — checked via 'yum updateinfo list security'." ""
    fi
  else
    add_check_result "Pending security updates" "pass" "" "Neither apt nor yum was found on this host — no supported package manager to scan for pending updates." ""
  fi

  # 2. SSH hardening
  if [ -f /etc/ssh/sshd_config ]; then
    local root_login_line pw_auth_line
    root_login_line=$(grep -iE '^[[:space:]]*PermitRootLogin[[:space:]]' /etc/ssh/sshd_config | tail -1 | sed 's/^[[:space:]]*//')
    if printf '%s' "$root_login_line" | grep -qiE 'PermitRootLogin[[:space:]]+yes'; then
      add_finding "critical" "SSH allows root login" "PermitRootLogin is set to yes in /etc/ssh/sshd_config — disable direct root SSH access."
      add_check_result "SSH root login" "fail" "critical" "PermitRootLogin is set to 'yes' in /etc/ssh/sshd_config — root can log in over SSH directly." "Set 'PermitRootLogin no' in /etc/ssh/sshd_config, then run 'sudo systemctl restart sshd'. Use sudo from a normal account instead of logging in as root directly."
    elif [ -n "$root_login_line" ]; then
      add_check_result "SSH root login" "pass" "" "PermitRootLogin is explicitly set ($root_login_line) in /etc/ssh/sshd_config — direct root SSH login is not permitted." ""
    else
      add_check_result "SSH root login" "pass" "" "PermitRootLogin is not set in /etc/ssh/sshd_config, so OpenSSH's secure default (prohibit-password / no) applies — root cannot log in with a password over SSH." ""
    fi
    pw_auth_line=$(grep -iE '^[[:space:]]*PasswordAuthentication[[:space:]]' /etc/ssh/sshd_config | tail -1 | sed 's/^[[:space:]]*//')
    if printf '%s' "$pw_auth_line" | grep -qiE 'PasswordAuthentication[[:space:]]+yes'; then
      add_finding "medium" "SSH allows password authentication" "PasswordAuthentication is yes — key-only auth is far more resistant to brute force."
      add_check_result "SSH password authentication" "fail" "medium" "PasswordAuthentication is set to 'yes' in /etc/ssh/sshd_config — password-based SSH login is allowed, which is brute-forceable." "Set 'PasswordAuthentication no' in /etc/ssh/sshd_config and restart sshd, after confirming you have a working SSH key set up for every account that needs access."
    elif printf '%s' "$pw_auth_line" | grep -qiE 'PasswordAuthentication[[:space:]]+no'; then
      add_check_result "SSH password authentication" "pass" "" "PasswordAuthentication is explicitly set to 'no' in /etc/ssh/sshd_config — SSH access requires a private key, not a password." ""
    else
      add_check_result "SSH password authentication" "pass" "" "PasswordAuthentication is not explicitly set in /etc/ssh/sshd_config. No password-based login was flagged, but the effective default varies by distro/OpenSSH build — recommend setting 'PasswordAuthentication no' explicitly to remove any ambiguity." ""
    fi
  else
    add_check_result "SSH root login" "pass" "" "No /etc/ssh/sshd_config found — an SSH server does not appear to be installed on this host." ""
    add_check_result "SSH password authentication" "pass" "" "No /etc/ssh/sshd_config found — an SSH server does not appear to be installed on this host." ""
  fi

  # 3. Firewall active
  local firewall_active=0 firewall_name=""
  if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -qi "Status: active"; then
    firewall_active=1
    firewall_name="ufw"
  elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state 2>/dev/null | grep -q running; then
    firewall_active=1
    firewall_name="firewalld"
  elif command -v iptables >/dev/null 2>&1 && iptables -L 2>/dev/null | grep -qvE '^(Chain|target|$)'; then
    firewall_active=1
    firewall_name="iptables (custom rules)"
  fi
  if [ "$firewall_active" -eq 0 ]; then
    add_finding "medium" "No active firewall detected" "Neither ufw, firewalld, nor custom iptables rules were found active on this host."
    add_check_result "Firewall active" "fail" "medium" "No active firewall was detected — checked ufw, firewalld, and custom iptables rules, none are active." "Enable a firewall: 'sudo ufw enable' is the simplest option on Debian/Ubuntu (allow SSH first with 'sudo ufw allow OpenSSH' so you don't lock yourself out). On RHEL/CentOS use 'sudo systemctl enable --now firewalld'."
  else
    add_check_result "Firewall active" "pass" "" "$firewall_name is active on this host." ""
  fi

  # 4. World-writable files in sensitive paths
  local ww_files ww_count
  ww_files=$(find /etc /usr/local/bin /usr/local/sbin -xdev -type f -perm -0002 2>/dev/null | head -20)
  ww_count=$(printf '%s\n' "$ww_files" | grep -c . || true)
  if [ "${ww_count:-0}" -gt 0 ]; then
    add_finding "medium" "World-writable files in system paths" "$ww_count file(s) under /etc or /usr/local are writable by any user: $(printf '%s' "$ww_files" | tr '\n' ',' | cut -c1-400)"
    add_check_result "World-writable files" "fail" "medium" "$ww_count file(s) under /etc or /usr/local are writable by any user: $(printf '%s' "$ww_files" | tr '\n' ',' | cut -c1-400)" "Run 'chmod o-w <file>' on each listed file after confirming it doesn't need to be world-writable (it almost never does for anything under /etc or /usr/local). Check ownership and recent modification time to rule out tampering."
  else
    add_check_result "World-writable files" "pass" "" "No world-writable files found under /etc, /usr/local/bin, or /usr/local/sbin (scanned recursively)." ""
  fi

  # 5. Failed SSH login attempts — 24h window via journalctl when available
  # (a real brute-force burst matters far more than a lifetime total), with
  # a fallback to the old lifetime count on non-systemd hosts.
  # MITRE T1110 (Brute Force). Sigma-equivalent: sigma-rules/brute-force-logon.yml
  local auth_log="" failed
  [ -f /var/log/auth.log ] && auth_log=/var/log/auth.log
  [ -f /var/log/secure ] && auth_log=/var/log/secure
  if command -v journalctl >/dev/null 2>&1; then
    failed=$(journalctl -u ssh -u sshd --since "24 hours ago" 2>/dev/null | grep -c "Failed password" || true)
    if [ "${failed:-0}" -gt 20 ]; then
      add_finding "medium" "High number of failed SSH login attempts (last 24h)" \
        "$failed failed password attempts in the last 24 hours — consider installing fail2ban if not already in place." \
        "brute-force-logon" "T1110" "Credential Access" \
        "Identify the source IP(s) with 'journalctl -u ssh -u sshd --since \"24 hours ago\" | grep \"Failed password\"' and block them at the firewall if external. Confirm fail2ban (or an equivalent) is installed. If any targeted account also shows a successful login in the same window, treat it as a likely compromise and force a password reset."
      add_check_result "Failed SSH login attempts" "fail" "medium" "$failed failed password attempts in the last 24 hours (journalctl -u ssh -u sshd) — above the 20-attempt threshold." "Identify the source IP(s) with 'journalctl -u ssh -u sshd --since \"24 hours ago\" | grep \"Failed password\"' and block them at the firewall if external. Confirm fail2ban is installed. If a targeted account also shows a successful login in the same window, treat it as a likely compromise and force a password reset."
    else
      add_check_result "Failed SSH login attempts" "pass" "" "${failed:-0} failed password attempt(s) in the last 24 hours (journalctl -u ssh -u sshd) — below the 20-attempt alert threshold." ""
    fi
  elif [ -n "$auth_log" ] && [ -r "$auth_log" ]; then
    failed=$(grep -c "Failed password" "$auth_log" 2>/dev/null || true)
    if [ "${failed:-0}" -gt 50 ]; then
      add_finding "medium" "High number of failed SSH login attempts" \
        "$failed failed password attempts logged in $auth_log — consider installing fail2ban if not already in place." \
        "brute-force-logon" "T1110" "Credential Access" \
        "Identify the source IP(s) with 'grep \"Failed password\" $auth_log' and block them at the firewall if external. Confirm fail2ban (or an equivalent) is installed. If any targeted account also shows a successful login in the same window, treat it as a likely compromise and force a password reset."
      add_check_result "Failed SSH login attempts" "fail" "medium" "$failed failed password attempts logged in $auth_log — above the 50-attempt threshold." "Identify the source IP(s) with 'grep \"Failed password\" $auth_log' and block them at the firewall if external. Confirm fail2ban is installed."
    else
      add_check_result "Failed SSH login attempts" "pass" "" "${failed:-0} failed password attempt(s) logged in $auth_log — below the 50-attempt alert threshold." ""
    fi
  else
    add_check_result "Failed SSH login attempts" "pass" "" "No SSH log source found (journalctl, /var/log/auth.log, and /var/log/secure are all unavailable) — cannot measure failed login volume on this host." ""
  fi

  # 5b. New scheduled persistence (cron) created in the last 24h — a
  # common foothold technique after a server is compromised.
  # MITRE T1053.003 (Scheduled Task/Job: Cron). Sigma-equivalent: sigma-rules/new-scheduled-task.yml
  local new_cron new_cron_count
  new_cron=$(find /etc/cron.d /etc/cron.daily /etc/cron.hourly /etc/cron.weekly /etc/cron.monthly /var/spool/cron/crontabs /var/spool/cron -maxdepth 1 -type f -mtime -1 2>/dev/null)
  new_cron_count=$(printf '%s\n' "$new_cron" | grep -c . || true)
  if [ "${new_cron_count:-0}" -gt 0 ]; then
    add_finding "medium" "New scheduled task (cron) created in the last 24 hours" \
      "$(printf '%s' "$new_cron" | tr '\n' ',' | cut -c1-400) — review if this change was expected." \
      "new-scheduled-task" "T1053.003" "Persistence" \
      "Open the listed file(s) and check what command they run and as which user. If it references a script in a temp/download folder, or runs as root unexpectedly, remove the entry and investigate how it was created (check auth logs and shell history for the same window)."
    add_check_result "New scheduled tasks (cron)" "fail" "medium" "New cron file(s) in the last 24h: $(printf '%s' "$new_cron" | tr '\n' ',' | cut -c1-400)" "Open the listed file(s) and check what command they run and as which user. If it references a script in a temp/download folder, or runs as root unexpectedly, remove the entry and investigate how it was created."
  else
    add_check_result "New scheduled tasks (cron)" "pass" "" "No new cron files created in the last 24 hours under /etc/cron.d, /etc/cron.{hourly,daily,weekly,monthly}, or the per-user crontab spool." ""
  fi

  # 6. Automatic security updates
  if command -v apt >/dev/null 2>&1; then
    if ! dpkg -l unattended-upgrades 2>/dev/null | grep -q '^ii'; then
      add_finding "low" "Automatic security updates not confirmed" "The unattended-upgrades package was not detected — security patches may require manual installation."
      add_check_result "Automatic security updates" "fail" "low" "The unattended-upgrades package is not installed — security patches will not be applied automatically." "Install it with 'sudo apt install unattended-upgrades' then 'sudo dpkg-reconfigure -plow unattended-upgrades' to enable it."
    else
      local auto_upgrade_enabled=""
      if [ -f /etc/apt/apt.conf.d/20auto-upgrades ] && grep -q 'Unattended-Upgrade "1"' /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null; then
        auto_upgrade_enabled=" and is enabled in /etc/apt/apt.conf.d/20auto-upgrades"
      fi
      add_check_result "Automatic security updates" "pass" "" "The unattended-upgrades package is installed$auto_upgrade_enabled." ""
    fi
  elif command -v yum >/dev/null 2>&1; then
    if rpm -q dnf-automatic >/dev/null 2>&1 || rpm -q yum-cron >/dev/null 2>&1; then
      add_check_result "Automatic security updates" "pass" "" "An automatic-update package (dnf-automatic or yum-cron) is installed." ""
    else
      add_finding "low" "Automatic security updates not confirmed" "Neither dnf-automatic nor yum-cron was detected — security patches may require manual installation."
      add_check_result "Automatic security updates" "fail" "low" "Neither dnf-automatic nor yum-cron is installed — security patches will not be applied automatically." "Install 'sudo yum install dnf-automatic' (or 'yum-cron' on older RHEL/CentOS) and enable its timer/service."
    fi
  else
    add_check_result "Automatic security updates" "pass" "" "Neither apt nor yum was found on this host — no supported package manager to check for automatic-update tooling." ""
  fi

  # 7. Root-owned SUID binaries outside the standard set (rough check —
  # flags for manual review, doesn't assume any specific one is wrong).
  local suid_count
  suid_count=$(find /usr/bin /usr/local/bin /usr/sbin -xdev -type f -perm -4000 2>/dev/null | wc -l)
  if [ "${suid_count:-0}" -gt 40 ]; then
    add_finding "low" "Unusually high number of SUID binaries" "$suid_count SUID root binaries found in standard bin paths — worth a manual review for anything unexpected."
    add_check_result "SUID binaries" "fail" "low" "$suid_count SUID root binaries found in /usr/bin, /usr/local/bin, and /usr/sbin — above the normal range (>40)." "Run 'find /usr/bin /usr/local/bin /usr/sbin -perm -4000' and review the list for anything that isn't a standard system binary (sudo, passwd, su, mount, ping, etc.). Remove the SUID bit from anything unexpected with 'chmod u-s <file>'."
  else
    add_check_result "SUID binaries" "pass" "" "$suid_count SUID root binaries found in /usr/bin, /usr/local/bin, and /usr/sbin — within the normal range (≤40)." ""
  fi

  # 8. New listening ports since the last run -- connection-level network
  # visibility, not packet inspection. Baseline is rolling: whatever's open
  # this run becomes the baseline for next run, so a port only gets flagged
  # once, on the run where it first appears.
  if command -v ss >/dev/null 2>&1; then
    local ports_baseline="$STATE_DIR/known_listening_ports"
    local current_ports new_ports
    current_ports=$(ss -tlnH 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\1/' | sort -un)
    if [ -f "$ports_baseline" ]; then
      new_ports=$(comm -13 "$ports_baseline" <(printf '%s\n' "$current_ports") 2>/dev/null | grep -v '^$' || true)
      if [ -n "$new_ports" ]; then
        local new_ports_csv
        new_ports_csv=$(printf '%s' "$new_ports" | tr '\n' ',' | sed 's/,$//')
        add_finding "medium" "New listening port(s) since last check" "Port(s) $new_ports_csv are now listening and were not on the previous run."
        add_check_result "New listening ports" "fail" "medium" "New port(s) since last run: $new_ports_csv." "Confirm this is an intended service. Check what's bound to it with 'sudo ss -tlnp | grep :<port>' — if it's not something you recognise, stop the process and investigate how it started."
      else
        add_check_result "New listening ports" "pass" "" "No new listening ports since the last check." ""
      fi
    else
      add_check_result "New listening ports" "pass" "" "First run — recorded the current set of listening ports as the baseline for future comparisons." ""
    fi
    printf '%s\n' "$current_ports" > "$ports_baseline" 2>/dev/null || true
  else
    add_check_result "New listening ports" "pass" "" "'ss' is not available on this host — could not check listening ports." ""
  fi

  # 9. Established connections -- flags a single remote IP holding an
  # unusually high number of connections (possible flood, scan, or C2
  # beaconing), reports total distinct remote IPs either way. Connection
  # metadata only (source/dest, no payload) -- this is not packet capture.
  if command -v ss >/dev/null 2>&1; then
    local conn_summary top_ip top_count distinct_ips
    conn_summary=$(ss -tnH state established 2>/dev/null | awk '{print $5}' | sed -E 's/:[0-9]+$//' | sort | uniq -c | sort -rn)
    top_count=$(printf '%s\n' "$conn_summary" | head -1 | awk '{print $1}')
    top_ip=$(printf '%s\n' "$conn_summary" | head -1 | awk '{print $2}')
    distinct_ips=$(printf '%s\n' "$conn_summary" | grep -c . || true)
    if [ -n "$top_count" ] && [ "$top_count" -gt 100 ] 2>/dev/null; then
      add_finding "medium" "Unusually high connection count from one remote IP" "$top_ip holds $top_count established connections to this host right now -- across $distinct_ips distinct remote IPs total."
      add_check_result "Unusual active connections" "fail" "medium" "$top_ip has $top_count established connections (threshold: 100). $distinct_ips distinct remote IPs connected in total." "Check what's driving this with 'ss -tn state established | grep $top_ip' -- if this isn't an expected load balancer, CDN, or monitoring system, it may be a scan, flood, or automated abuse. Consider rate-limiting or blocking the IP at the firewall if it's not legitimate."
    else
      add_check_result "Unusual active connections" "pass" "" "$distinct_ips distinct remote IP(s) currently connected; no single IP holds more than 100 established connections." ""
    fi
  else
    add_check_result "Unusual active connections" "pass" "" "'ss' is not available on this host — could not check active connections." ""
  fi
}

# ---------------------------------------------------------------
# Correlation rules (SOC module) — these look for a PATTERN across
# events, not a single static config value, which is what separates
# them from the checks above. Still entirely read-only, still fixed
# (no rule ever changes based on anything the server sends back).
# ---------------------------------------------------------------
run_alert_rules() {
  # Rule 1: SSH brute-force immediately followed by a successful login —
  # the single strongest "someone got in" signal a log-only agent can see.
  # Best-effort: groups failed attempts by source IP in the last 10 minutes;
  # if one IP has 3+ failures AND any login succeeded in that same window,
  # flags it. Doesn't prove the same IP/account pair, but that combination
  # in a 10-minute window is worth a human look either way.
  # MITRE T1110 (Brute Force) -> T1078 (Valid Accounts).
  if command -v journalctl >/dev/null 2>&1; then
    local recent_auth top_ip_line top_count top_ip success_line success_user
    recent_auth=$(journalctl -u ssh -u sshd --since "10 minutes ago" 2>/dev/null)
    top_ip_line=$(printf '%s\n' "$recent_auth" | grep "Failed password" | grep -oE 'from [0-9.]+' | awk '{print $2}' | sort | uniq -c | sort -rn | head -1)
    top_count=$(printf '%s' "$top_ip_line" | awk '{print $1}')
    top_ip=$(printf '%s' "$top_ip_line" | awk '{print $2}')
    if [ -n "$top_count" ] && [ "$top_count" -ge 3 ] 2>/dev/null; then
      success_line=$(printf '%s\n' "$recent_auth" | grep -E "Accepted (password|publickey)" | tail -1)
      if [ -n "$success_line" ]; then
        success_user=$(printf '%s' "$success_line" | grep -oE 'for [a-zA-Z0-9_-]+' | awk '{print $2}')
        add_alert "ssh_bruteforce_then_success" "critical" \
          "$top_count failed SSH login attempts from $top_ip in the last 10 minutes, followed by a successful login (user: ${success_user:-unknown})." \
          "source_ip=$top_ip failed_attempts=$top_count user=${success_user:-unknown}"
      fi
    fi
  fi

  # Rule 2: hidden admin account — any user besides 'root' with UID 0 is a
  # classic backdoor (full root privileges under a name that won't stand
  # out in a casual 'who's logged in' check). MITRE T1136 (Create Account).
  local uid0_users
  uid0_users=$(awk -F: '$3 == 0 && $1 != "root" {print $1}' /etc/passwd 2>/dev/null)
  if [ -n "$uid0_users" ]; then
    add_alert "hidden_admin_account" "critical" \
      "Account(s) with root privileges (UID 0) besides 'root': $(printf '%s' "$uid0_users" | tr '\n' ',')." \
      "accounts=$(printf '%s' "$uid0_users" | tr '\n' ',')"
  fi

  # Rule 3: new scheduled task whose CONTENT looks like malware staging
  # (download+execute, base64-decoded payload, or a path under /tmp or
  # /dev/shm) — stricter than the general "a cron changed" finding above,
  # this is specifically the pattern real droppers use.
  # MITRE T1053.003 + T1140 (Deobfuscate/Decode).
  local new_cron_files suspicious_content
  new_cron_files=$(find /etc/cron.d /etc/cron.daily /etc/cron.hourly /etc/cron.weekly /etc/cron.monthly /var/spool/cron/crontabs /var/spool/cron -maxdepth 1 -type f -mtime -1 2>/dev/null)
  if [ -n "$new_cron_files" ]; then
    suspicious_content=$(cat $new_cron_files 2>/dev/null | grep -iE '(curl|wget)[^|]*\|[[:space:]]*(sh|bash)|base64[[:space:]]+-d|/dev/shm/|/tmp/[^[:space:]]*\.(sh|py|pl)' || true)
    if [ -n "$suspicious_content" ]; then
      add_alert "suspicious_scheduled_task" "critical" \
        "New scheduled task with content typical of malware staging (download+execute, base64-decoded payload, or a script under /tmp or /dev/shm): $(printf '%s' "$suspicious_content" | tr '\n' ' ' | cut -c1-300)" \
        "files=$(printf '%s' "$new_cron_files" | tr '\n' ',')"
    fi
  fi

  # Rule 4: burst of obfuscated/suspicious commands in root's recent shell
  # history — best-effort only. Bash history is written on shell exit (or
  # per HISTFILE flush settings), not in real time, and reading another
  # user's history requires root — this catches what's already been
  # flushed to disk, not a live stream. Flags if ANY of the last 50 lines
  # match a common obfuscation/staging pattern.
  # MITRE T1027 (Obfuscated Files or Information) + T1059.004 (Unix Shell).
  if [ -r /root/.bash_history ]; then
    local suspicious_cmds
    suspicious_cmds=$(tail -50 /root/.bash_history 2>/dev/null | grep -iE 'base64[[:space:]]+-d|eval[[:space:]]*\$\(|(curl|wget)[^|]*\|[[:space:]]*(sh|bash)|chmod[[:space:]]+\+x[[:space:]]+/tmp|nc[[:space:]]+-e' || true)
    if [ -n "$suspicious_cmds" ]; then
      add_alert "obfuscated_command_burst" "medium" \
        "Recent command(s) in root's shell history with patterns typical of obfuscation or post-exploitation: $(printf '%s' "$suspicious_cmds" | tr '\n' ' ' | cut -c1-300)" \
        "commands=$(printf '%s' "$suspicious_cmds" | tr '\n' ';' | cut -c1-500)"
    fi
  fi

  # ---------------------------------------------------------------
  # Rules 5-14: ported from real SigmaHQ/sigma detection rules
  # (github.com/SigmaHQ/sigma, Detection Rule License — commercial use
  # allowed, source rule cited per rule below as required by the DRL).
  # Each keeps its own rule name / MITRE mapping rather than being merged
  # into an existing rule, so the SOC's MITRE coverage table reflects each
  # technique individually instead of one catch-all bucket.
  # ---------------------------------------------------------------

  # Rule 5: SSH daemon internal errors — Sigma "Suspicious OpenSSH Daemon
  # Error" (e76b413a-83d0-4b94-8e4c-85db4a5b8bdc). MITRE T1190.
  if command -v journalctl >/dev/null 2>&1; then
    local sshd_err_args=()
    local p
    for p in "unexpected internal error" "unknown or unsupported key type" \
      "invalid certificate signing key" "invalid elliptic curve value" \
      "incorrect signature" "error in libcrypto" \
      "unexpected bytes remain after decoding" \
      "fatal: buffer_get_string: bad string" \
      "Local: crc32 compensation attack" "bad client public DH value" \
      "Corrupted MAC on input"; do
      sshd_err_args+=(-e "$p")
    done
    local sshd_err_hit
    sshd_err_hit=$(journalctl -u ssh -u sshd --since "24 hours ago" 2>/dev/null | grep -F "${sshd_err_args[@]}" | head -5)
    if [ -n "$sshd_err_hit" ]; then
      add_alert "sigma_ssh_daemon_error" "medium" \
        "The SSH daemon logged unusual internal errors (possible exploitation attempt against SSH itself): $(printf '%s' "$sshd_err_hit" | tr '\n' ' ' | cut -c1-300)" \
        "detail=$(printf '%s' "$sshd_err_hit" | tr '\n' ';' | cut -c1-500)"
    fi
  fi

  # Rule 6: crontab modified — Sigma "Modifying Crontab"
  # (af202fd3-7bff-4212-a25a-fb34606cfcbe). MITRE T1053.003.
  if command -v journalctl >/dev/null 2>&1; then
    local crontab_hit
    crontab_hit=$(journalctl -u cron -u crond --since "24 hours ago" 2>/dev/null | grep -F "REPLACE" | head -5)
    if [ -n "$crontab_hit" ]; then
      add_alert "sigma_crontab_modified" "low" \
        "A user crontab was modified in the last 24 hours: $(printf '%s' "$crontab_hit" | tr '\n' ' ' | cut -c1-300)" \
        "detail=$(printf '%s' "$crontab_hit" | tr '\n' ';' | cut -c1-500)"
    fi
  fi

  # Rule 7: privileged user created — Sigma "Privileged User Has Been
  # Created" (0ac15ec3-d24f-4246-aa2a-3077bb1cf90e). MITRE T1136.001, T1098.
  if command -v journalctl >/dev/null 2>&1; then
    local priv_user_hit priv_user_names
    priv_user_hit=$(journalctl --since "24 hours ago" 2>/dev/null | grep "new user" | grep -E "GID=0,|UID=0,|GID=10,|GID=27,")
    if [ -n "$priv_user_hit" ]; then
      priv_user_names=$(printf '%s\n' "$priv_user_hit" | grep -oE 'name=[a-zA-Z0-9_-]+' | cut -d= -f2 | sort -u | paste -sd',' -)
      add_alert "sigma_privileged_user_created" "critical" \
        "A privileged account was created (root/wheel/sudo UID or GID): $(printf '%s' "$priv_user_hit" | tr '\n' ' ' | cut -c1-300)" \
        "accounts=${priv_user_names:-unknown}"
    fi
  fi

  # Rule 8: bash history tampering — Sigma "Linux Command History
  # Tampering" (fdc88d25-96fb-4b7c-9633-c0e417fdbd4e). MITRE T1070.003.
  if [ -r /root/.bash_history ]; then
    local histtamper_args=()
    for p in 'cat /dev/null >.*sh_history' 'cat /dev/zero >.*sh_history' \
      'chattr \+i.*sh_history' 'echo "" >.*sh_history' 'empty_bash_history' \
      'export HISTFILESIZE=0' 'history -c' 'history -w' \
      'ln -sf /dev/null.*sh_history' 'ln -sf /dev/zero.*sh_history' \
      'rm .*sh_history' 'shopt -ou history' 'shopt -uo history' \
      'shred .*sh_history' 'truncate -s0.*sh_history' 'truncate -s 0.*sh_history'; do
      histtamper_args+=(-e "$p")
    done
    local histtamper_hit
    histtamper_hit=$(tail -50 /root/.bash_history 2>/dev/null | grep -E "${histtamper_args[@]}")
    if [ -n "$histtamper_hit" ]; then
      add_alert "sigma_history_tampering" "critical" \
        "Command(s) attempting to delete or manipulate the shell's own history (covering tracks): $(printf '%s' "$histtamper_hit" | tr '\n' ' ' | cut -c1-300)" \
        "commands=$(printf '%s' "$histtamper_hit" | tr '\n' ';' | cut -c1-500)"
    fi
  fi

  # Rule 9: suspicious shell command patterns — Sigma "Suspicious Activity
  # in Shell Commands" (2aa1440c-9ae9-4d92-84a7-a9e5f5e31695). MITRE T1059.004.
  if [ -r /root/.bash_history ]; then
    local shellsusp_args=()
    for p in 'wget .*http.*\| *(perl|sh|bash)' 'python -m SimpleHTTPServer' \
      '-m http\.server' 'import pty; *pty\.spawn' 'socat exec:' \
      'socat -O /tmp/' 'socat tcp-connect' 'echo binary >>' \
      'wget .*; *chmod \+x' 'wget .*; *chmod 777' \
      'cd /tmp \|\| cd /var/run \|\| cd /mnt' 'stop;service iptables stop;' \
      'stop;SuSEfirewall2 stop;' 'chmod 777 2020' '>>/etc/rc\.local' \
      'base64 -d /tmp/' '\| base64 -d' '/chmod u\+s' 'chmod \+s /tmp/' \
      'chmod u\+s /tmp/' '/tmp/haxhax' '/tmp/ns_sploit' 'nc -l -p' \
      'cp /bin/ksh' 'cp /bin/sh ' '/tmp/.*\.b64' '/tmp/ysocereal\.jar' \
      '/tmp/x ' '; *chmod \+x /tmp/'; do
      shellsusp_args+=(-e "$p")
    done
    local shellsusp_hit
    shellsusp_hit=$(tail -50 /root/.bash_history 2>/dev/null | grep -iE "${shellsusp_args[@]}")
    if [ -n "$shellsusp_hit" ]; then
      add_alert "sigma_suspicious_shell_commands" "critical" \
        "Command(s) in the history with patterns typical of exploitation (download+execute, improvised HTTP server, permission escalation under /tmp): $(printf '%s' "$shellsusp_hit" | tr '\n' ' ' | cut -c1-300)" \
        "commands=$(printf '%s' "$shellsusp_hit" | tr '\n' ';' | cut -c1-500)"
    fi
  fi

  # Rule 10: suspicious general log entries — Sigma "Suspicious Log
  # Entries" (f64b6e9a-5d9d-48a5-8289-e1dd2b3876e1). No MITRE technique
  # tagged upstream (Impact tactic only). Note: "entered promiscuous mode"
  # is normally a kernel/dmesg line — this only catches it if the host's
  # journal includes kernel facility messages (default on most distros).
  if command -v journalctl >/dev/null 2>&1; then
    local suslog_hit
    suslog_hit=$(journalctl --since "24 hours ago" 2>/dev/null | grep -F -e "entered promiscuous mode" -e "Deactivating service" -e "Oversized packet received from" -e "imuxsock begins to drop messages" | head -5)
    if [ -n "$suslog_hit" ]; then
      add_alert "sigma_suspicious_log_entries" "low" \
        "Unusual log entries (network promiscuous mode, an unexpectedly deactivated service, or log messages being dropped): $(printf '%s' "$suslog_hit" | tr '\n' ' ' | cut -c1-300)" \
        "detail=$(printf '%s' "$suslog_hit" | tr '\n' ';' | cut -c1-500)"
    fi
  fi

  # Rule 11: reverse shell signatures — Sigma "Suspicious Reverse Shell
  # Command Line" (738d9bcf-6999-4fdb-b4ac-3033037db8ab). MITRE T1059.004.
  # Representative subset of the upstream keyword list (the full list also
  # includes several Perl/Python/Ruby one-liners whose exact source text
  # wasn't confirmed against the live rule — omitted rather than guessed).
  if [ -r /root/.bash_history ]; then
    local revshell_args=()
    for p in 'bash -i >& /dev/tcp/' 'bash -i >& /dev/udp/' \
      'sh -i >\$ /dev/udp/' 'sh -i >\$ /dev/tcp/' 'nc -e /bin/sh' \
      '/bin/sh \| nc' 'mknod /tmp/backpipe p && nc' \
      '/bin/sh -i <&3 >&3 2>&3' 'uname -a; *w; *id; */bin/bash -i' \
      'nc -lvvp' 'xterm -display'; do
      revshell_args+=(-e "$p")
    done
    local revshell_hit
    revshell_hit=$(tail -50 /root/.bash_history 2>/dev/null | grep -E "${revshell_args[@]}")
    if [ -n "$revshell_hit" ]; then
      add_alert "sigma_reverse_shell" "critical" \
        "Command(s) in the history matching a reverse shell signature: $(printf '%s' "$revshell_hit" | tr '\n' ' ' | cut -c1-300)" \
        "commands=$(printf '%s' "$revshell_hit" | tr '\n' ';' | cut -c1-500)"
    fi
  fi

  # Rule 12: symlink to /etc/passwd — Sigma "Symlink Etc Passwd"
  # (c67fc22a-0be5-4b4f-aad5-2b32c4b69523). MITRE T1204.001.
  if [ -r /root/.bash_history ]; then
    local symlink_hit
    symlink_hit=$(tail -50 /root/.bash_history 2>/dev/null | grep -F -e "ln -s -f /etc/passwd" -e "ln -s /etc/passwd")
    if [ -n "$symlink_hit" ]; then
      add_alert "sigma_symlink_etc_passwd" "critical" \
        "Command(s) creating a symlink pointing at /etc/passwd: $(printf '%s' "$symlink_hit" | tr '\n' ' ' | cut -c1-300)" \
        "file=/etc/passwd commands=$(printf '%s' "$symlink_hit" | tr '\n' ';' | cut -c1-500)"
    fi
  fi

  # Rule 13: syslog cleared — Sigma "Commands to Clear or Remove the
  # Syslog - Builtin" (e09eb557-96d2-4de9-ba2d-30f712a5afd3). MITRE T1565.001.
  if [ -r /root/.bash_history ]; then
    local syslogclr_hit
    syslogclr_hit=$(tail -50 /root/.bash_history 2>/dev/null | grep -F -e "rm /var/log/syslog" -e "rm -r /var/log/syslog" -e "rm -f /var/log/syslog" -e "rm -rf /var/log/syslog" -e "mv /var/log/syslog" -e ">/var/log/syslog" -e "> /var/log/syslog" | grep -v "/syslog\.")
    if [ -n "$syslogclr_hit" ]; then
      add_alert "sigma_syslog_cleared" "critical" \
        "Command(s) deleting or moving /var/log/syslog (possible attempt to cover tracks): $(printf '%s' "$syslogclr_hit" | tr '\n' ' ' | cut -c1-300)" \
        "file=/var/log/syslog commands=$(printf '%s' "$syslogclr_hit" | tr '\n' ';' | cut -c1-500)"
    fi
  fi

  # Rule 14: /dev/tcp usage — Sigma "Suspicious Use of /dev/tcp"
  # (6cc5fceb-9a71-4c23-aeeb-963abe0b279c). No MITRE technique tagged
  # upstream (Reconnaissance tactic only).
  if [ -r /root/.bash_history ]; then
    local devtcp_args=()
    for p in 'cat </dev/tcp/' 'exec 3<>/dev/tcp/' 'echo >/dev/tcp/' \
      '0<&196;exec 196<>/dev/tcp/' 'exec 5<>/dev/tcp/' '(sh)0>/dev/tcp/'; do
      devtcp_args+=(-e "$p")
    done
    local devtcp_hit
    devtcp_hit=$(tail -50 /root/.bash_history 2>/dev/null | grep -F "${devtcp_args[@]}")
    if [ -n "$devtcp_hit" ]; then
      add_alert "sigma_dev_tcp_usage" "medium" \
        "Use of /dev/tcp typical of port scanning or manual exfiltration: $(printf '%s' "$devtcp_hit" | tr '\n' ' ' | cut -c1-300)" \
        "commands=$(printf '%s' "$devtcp_hit" | tr '\n' ';' | cut -c1-500)"
    fi
  fi
}

# ---------------------------------------------------------------
# Server inventory ("Server Map") — a point-in-time snapshot of what's
# installed and running, separate from the security-focused findings
# above. Read-only, same as everything else in this script. Capped the
# same way findings/alerts are (500 packages / 200 services / 100 ports)
# so a package-heavy server can't blow up the report payload.
# ---------------------------------------------------------------
INVENTORY_OS=""
INVENTORY_KERNEL=""
INVENTORY_ARCH=""
INVENTORY_LAST_BOOT=""
INVENTORY_DISK_USAGE=""
INVENTORY_PACKAGES_FILE=""
INVENTORY_SERVICES_FILE=""
INVENTORY_PORTS_FILE=""

run_inventory() {
  INVENTORY_OS=$(grep -m1 '^PRETTY_NAME=' /etc/os-release 2>/dev/null | cut -d'"' -f2)
  INVENTORY_KERNEL=$(uname -r 2>/dev/null)
  INVENTORY_ARCH=$(uname -m 2>/dev/null)
  INVENTORY_LAST_BOOT=$(uptime -s 2>/dev/null || who -b 2>/dev/null | awk '{print $3, $4}')
  INVENTORY_DISK_USAGE=$(df -h / 2>/dev/null | tail -1 | awk '{print $5}')

  INVENTORY_PACKAGES_FILE=$(mktemp)
  if command -v dpkg-query >/dev/null 2>&1; then
    dpkg-query -W -f='${Package}\t${Version}\n' 2>/dev/null | sort | head -500 > "$INVENTORY_PACKAGES_FILE"
  elif command -v rpm >/dev/null 2>&1; then
    rpm -qa --queryformat '%{NAME}\t%{VERSION}-%{RELEASE}\n' 2>/dev/null | sort | head -500 > "$INVENTORY_PACKAGES_FILE"
  fi

  INVENTORY_SERVICES_FILE=$(mktemp)
  if command -v systemctl >/dev/null 2>&1; then
    systemctl list-units --type=service --state=running --no-legend --no-pager --plain 2>/dev/null \
      | awk '{print $1}' | sed 's/\.service$//' | head -200 > "$INVENTORY_SERVICES_FILE"
  fi

  INVENTORY_PORTS_FILE=$(mktemp)
  if command -v ss >/dev/null 2>&1; then
    ss -tulnp 2>/dev/null | tail -n +2 | awk '{
      proto=$1; addr=$5; n=split(addr, a, ":"); port=a[n];
      if (port ~ /^[0-9]+$/) print proto"\t"port
    }' | sort -u -t$'\t' -k2,2n | head -100 > "$INVENTORY_PORTS_FILE"
  fi
}

inventory_to_json() {
  if ! command -v python3 >/dev/null 2>&1; then
    echo "{}"
    return
  fi
  local script
  script=$(mktemp)
  cat > "$script" <<'PY'
import json, sys

os_name, kernel, arch, last_boot, disk_usage, pkg_file, svc_file, port_file = sys.argv[1:9]

def read_lines(path):
    try:
        with open(path) as f:
            return [l.rstrip("\n") for l in f if l.strip()]
    except Exception:
        return []

packages = []
for line in read_lines(pkg_file):
    parts = line.split("\t")
    if len(parts) == 2:
        packages.append({"name": parts[0], "version": parts[1]})

services = read_lines(svc_file)

ports = []
for line in read_lines(port_file):
    parts = line.split("\t")
    if len(parts) == 2 and parts[1].isdigit():
        ports.append({"protocol": parts[0], "port": int(parts[1])})

print(json.dumps({
    "os": os_name or None,
    "kernel": kernel or None,
    "arch": arch or None,
    "lastBoot": last_boot or None,
    "diskUsage": disk_usage or None,
    "packages": packages,
    "packageCount": len(packages),
    "services": services,
    "ports": ports,
}))
PY
  python3 "$script" "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8"
  rm -f "$script"
}

# ---------------------------------------------------------------
# JSON output (python3 handles escaping correctly; bash string
# concatenation for JSON is a common source of injection bugs)
# ---------------------------------------------------------------
findings_to_json() {
  if ! command -v python3 >/dev/null 2>&1; then
    echo "python3 is required to build the report payload but was not found." >&2
    exit 1
  fi
  # Written to a temp file rather than `python3 - <<PY`: the heredoc form
  # reads the *script* from stdin, which consumes the very stream we need
  # the piped findings to arrive on — the two uses of stdin collide and
  # the loop below would silently see nothing.
  local script
  script=$(mktemp)
  cat > "$script" <<'PY'
import json, sys
findings = []
for line in sys.stdin:
    line = line.rstrip("\n")
    if not line:
        continue
    parts = line.split("\t")
    if len(parts) != 7:
        continue
    severity, title, detail, rule_id, technique, tactic, playbook = parts
    findings.append({
        "severity": severity, "title": title, "detail": detail,
        "ruleId": rule_id or None, "technique": technique or None,
        "tactic": tactic or None, "playbook": playbook or None,
    })
print(json.dumps(findings))
PY
  python3 "$script"
  rm -f "$script"
}

alerts_to_json() {
  if ! command -v python3 >/dev/null 2>&1; then
    echo "python3 is required to build the report payload but was not found." >&2
    exit 1
  fi
  local script
  script=$(mktemp)
  cat > "$script" <<'PY'
import json, sys
alerts = []
for line in sys.stdin:
    line = line.rstrip("\n")
    if not line:
        continue
    parts = line.split("\t")
    if len(parts) != 4:
        continue
    rule_name, severity, summary, context = parts
    alerts.append({
        "ruleName": rule_name, "severity": severity, "summary": summary,
        "rawContext": {"detail": context} if context else None,
    })
print(json.dumps(alerts))
PY
  python3 "$script"
  rm -f "$script"
}

checks_to_json() {
  if ! command -v python3 >/dev/null 2>&1; then
    echo "python3 is required to build the report payload but was not found." >&2
    exit 1
  fi
  local script
  script=$(mktemp)
  cat > "$script" <<'PY'
import json, sys
checks = []
for line in sys.stdin:
    line = line.rstrip("\n")
    if not line:
        continue
    parts = line.split("\t")
    if len(parts) != 5:
        continue
    name, status, severity, detail, playbook = parts
    checks.append({
        "name": name, "status": status, "severity": severity or None,
        "detail": detail, "playbook": playbook or None,
    })
print(json.dumps(checks))
PY
  python3 "$script"
  rm -f "$script"
}

# --- Opt-in live process monitoring (osquery) ---------------------------
# Everything below is a no-op unless the operator opted in with
# --enable-live-monitoring (remembered via $LIVE_MON_MARKER). It is the one
# part of this agent that installs software and runs a daemon, which is why
# it is gated behind an explicit flag and this clearly-separated block.

# Idempotent: installs osquery only if missing, (re)writes its config and
# flag file, and makes sure the background service is enabled and running.
# Uses the exact audit flags that let osquery claim the kernel audit socket
# even when systemd/snapd already hold it (audit_allow_config + audit_persist),
# and a watchdog capping it at 200MB RAM / 30% CPU so it can never run away
# on a client box. Safe to call every run.
ensure_live_monitoring() {
  if ! command -v osqueryd >/dev/null 2>&1; then
    if command -v apt-get >/dev/null 2>&1; then
      curl -fsSL https://pkg.osquery.io/deb/pubkey.gpg 2>/dev/null | gpg --dearmor -o /usr/share/keyrings/osquery-archive-keyring.gpg 2>/dev/null || return 1
      echo "deb [signed-by=/usr/share/keyrings/osquery-archive-keyring.gpg] https://pkg.osquery.io/deb deb main" > /etc/apt/sources.list.d/osquery.list
      DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true
      DEBIAN_FRONTEND=noninteractive apt-get install -y osquery >/dev/null 2>&1 || return 1
    elif command -v yum >/dev/null 2>&1; then
      curl -fsSL https://pkg.osquery.io/rpm/GPG 2>/dev/null > /etc/pki/rpm-gpg/RPM-GPG-KEY-osquery 2>/dev/null || return 1
      cat > /etc/yum.repos.d/osquery.repo <<'REPO'
[osquery]
name=osquery
baseurl=https://pkg.osquery.io/rpm/
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-osquery
REPO
      yum install -y osquery >/dev/null 2>&1 || return 1
    else
      return 1
    fi
  fi

  mkdir -p /etc/osquery /var/osquery /var/log/osquery

  cat > /etc/osquery/osquery.conf <<'OSQCONF'
{
  "options": {
    "disable_audit": "false",
    "audit_allow_process_events": "true",
    "audit_allow_config": "true",
    "audit_persist": "true",
    "events_expiry": "86400",
    "disable_events": "false"
  },
  "schedule": {
    "solvbeat_process_events": {
      "query": "SELECT pid, path, cmdline, auid AS login_uid, uid, parent, cwd, time FROM process_events;",
      "interval": 60,
      "description": "New process executions, consumed by the Solvbeat agent"
    }
  }
}
OSQCONF

  cat > /etc/osquery/osquery.flags <<'OSQFLAGS'
--database_path=/var/osquery/osquery.db
--logger_path=/var/log/osquery
--logger_plugin=filesystem
--disable_audit=false
--audit_allow_process_events=true
--audit_allow_config=true
--audit_persist=true
--disable_watchdog=false
--watchdog_memory_limit=200
--watchdog_utilization_limit=30
OSQFLAGS

  if command -v systemctl >/dev/null 2>&1; then
    cat > /etc/systemd/system/osqueryd-solvbeat.service <<'OSQSVC'
[Unit]
Description=Solvbeat osquery process monitor
After=network.target

[Service]
ExecStart=/usr/bin/osqueryd --flagfile=/etc/osquery/osquery.flags --config_path=/etc/osquery/osquery.conf
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target
OSQSVC
    systemctl daemon-reload >/dev/null 2>&1 || true
    systemctl enable osqueryd-solvbeat >/dev/null 2>&1 || true
    systemctl restart osqueryd-solvbeat >/dev/null 2>&1 || true
  fi
  return 0
}

# Reads only the process-execution events osquery has logged since the last
# agent run (tracked by byte offset in $STATE_DIR, reset if the log rotated)
# and flags ones matching real malware/post-exploitation patterns: binaries
# executing from world-writable staging dirs, reverse-shell invocations,
# download-and-execute one-liners, and offensive tooling by name. Everything
# is read-only -- it never touches the processes it reports.
run_osquery_process_rules() {
  local results_log="/var/log/osquery/osqueryd.results.log"
  local offset_file="$STATE_DIR/osquery_log_offset"
  [ -f "$results_log" ] || { add_check_result "Live process monitoring" "pass" "" "osquery is enabled but has not logged any process events yet (it records new executions as they happen; the first batch appears within a minute or two of a process starting)." ""; return; }

  local prev_offset cur_size
  prev_offset=$(cat "$offset_file" 2>/dev/null || echo 0)
  cur_size=$(wc -c < "$results_log" 2>/dev/null || echo 0)
  # Log rotated/truncated since last run -- start from the top rather than
  # skipping past the new, shorter file.
  if [ "$prev_offset" -gt "$cur_size" ] 2>/dev/null; then prev_offset=0; fi

  local new_events
  new_events=$(tail -c +$((prev_offset + 1)) "$results_log" 2>/dev/null)
  printf '%s' "$cur_size" > "$offset_file" 2>/dev/null || true

  [ -n "$new_events" ] || { add_check_result "Live process monitoring" "pass" "" "osquery is active; no new process executions since the last run." ""; return; }

  # Parse + apply detection rules in python (robust JSON handling); emit one
  # tab-separated line per suspicious process for the shell to turn into
  # alerts, plus a final SUMMARY line with the total processes seen. Written
  # to a temp file and run (not an inline heredoc inside $()), matching the
  # *_to_json helpers above -- a heredoc inside command substitution trips
  # up bash's parser.
  local pyscript analysis
  pyscript=$(mktemp)
  cat > "$pyscript" <<'PYEOF'
import sys, json, re

STAGING_DIR = r'(?:/tmp/|/dev/shm/|/var/tmp/|/run/)'
STAGING_PATH = re.compile(r'^' + STAGING_DIR)
INTERPRETER = re.compile(r'/(bash|sh|dash|zsh|ksh|python[0-9.]*|perl|php|ruby|node)$', re.I)
# A script under a staging dir passed to an interpreter shows path=/bin/bash
# with the script as an argument -- catch that too, not just direct binaries.
STAGING_ARG = re.compile(r'(?:^|\s)' + STAGING_DIR + r'\S+')
REVERSE = re.compile(r'/dev/tcp/|/dev/udp/|\bnc(?:at)?\b[^|]*\s-e\b|\bbash\s+-i\b|\bsh\s+-i\b|\bmkfifo\b|socat\b[^|]*\bexec|python[0-9]?\s+-c\s+.{0,40}socket', re.I)
DLEXEC  = re.compile(r'(curl|wget)\b[^|]*\|\s*(sh|bash)|base64\s+-d|\beval\s+\$\(', re.I)
TOOLS   = re.compile(r'\b(nmap|masscan|sqlmap|hydra|nikto|gobuster|ffuf|dirb|metasploit|msfconsole|meterpreter|mimikatz|responder|impacket|crackmapexec)\b', re.I)

total = 0
for line in sys.stdin:
    line = line.strip()
    if not line:
        continue
    try:
        ev = json.loads(line)
    except Exception:
        continue
    if ev.get("action") != "added":
        continue
    c = ev.get("columns", {})
    total += 1
    path = c.get("path", "") or ""
    cmd = (c.get("cmdline", "") or "").strip()
    uid = c.get("uid", "")
    haystack = path + " " + cmd
    reason = tech = ""
    if STAGING_PATH.search(path):
        reason, tech = "Executable ran from a world-writable staging directory (%s)" % path, "T1059"
    elif INTERPRETER.search(path) and STAGING_ARG.search(cmd):
        reason, tech = "Script under a world-writable staging directory run via %s" % path, "T1059"
    elif REVERSE.search(haystack):
        reason, tech = "Command matches a reverse-shell pattern", "T1059.004"
    elif DLEXEC.search(haystack):
        reason, tech = "Download-and-execute / decoded-payload pattern", "T1105"
    elif TOOLS.search(haystack):
        reason, tech = "Known offensive security tool executed", "T1046"
    if reason:
        detail = "pid=%s uid=%s path=%s cmd=%s" % (c.get("pid",""), uid, path, cmd[:200])
        print("HIT\t%s\t%s\t%s" % (reason, tech, detail))
print("SUMMARY\t%d" % total)
PYEOF
  analysis=$(printf '%s\n' "$new_events" | python3 "$pyscript")
  rm -f "$pyscript"

  local hit_count=0 seen_total=0
  while IFS=$'\t' read -r kind a b c; do
    if [ "$kind" = "HIT" ]; then
      hit_count=$((hit_count + 1))
      add_alert "live_suspicious_process" "critical" "$a. ($c)" "technique=$b $c"
    elif [ "$kind" = "SUMMARY" ]; then
      seen_total="$a"
    fi
  done <<< "$analysis"

  if [ "$hit_count" -gt 0 ]; then
    add_check_result "Live process monitoring" "fail" "critical" "$hit_count suspicious process execution(s) flagged out of $seen_total seen since the last run (real-time, via osquery)." "Review the flagged process alerts above. Each names the exact binary path and command line. If any is not something you or a known service started, isolate the host and investigate how it was launched."
  else
    add_check_result "Live process monitoring" "pass" "" "$seen_total process execution(s) observed in real time since the last run; none matched a known malicious pattern." ""
  fi
}

run_checks
run_alert_rules
run_inventory

# Live monitoring is enabled if the flag was passed this run, or was passed
# on a previous run (marker persists). --disable-live-monitoring clears it.
# All of this is skipped entirely in dry-run mode, which must never install
# anything or write to the system beyond a preview.
if [ "$MODE" != "dry-run" ]; then
  mkdir -p "$STATE_DIR" 2>/dev/null || true
  if [ "$DISABLE_LIVE_MON" -eq 1 ]; then
    rm -f "$LIVE_MON_MARKER" 2>/dev/null || true
    command -v systemctl >/dev/null 2>&1 && systemctl disable --now osqueryd-solvbeat >/dev/null 2>&1 || true
  fi
  if [ "$LIVE_MON_FLAG" -eq 1 ]; then
    touch "$LIVE_MON_MARKER" 2>/dev/null || true
  fi
  if [ "$DISABLE_LIVE_MON" -eq 0 ] && [ -f "$LIVE_MON_MARKER" ]; then
    if ensure_live_monitoring; then
      run_osquery_process_rules
    else
      add_check_result "Live process monitoring" "fail" "medium" "Live process monitoring is enabled but osquery could not be installed or started on this host (unsupported package manager, or the install failed)." "Check that this host uses apt or yum and has outbound access to pkg.osquery.io, then re-run the agent. Or turn the feature off with --disable-live-monitoring."
    fi
  fi
elif [ "$LIVE_MON_FLAG" -eq 1 ] || [ -f "$LIVE_MON_MARKER" ]; then
  echo "--- live monitoring ---"
  echo "Would ensure osquery is installed and running, then report suspicious process executions since the last run. (Skipped: dry-run makes no system changes.)"
fi

FINDINGS_JSON="[]"
if [ "${#FINDINGS_RAW[@]}" -gt 0 ]; then
  FINDINGS_JSON=$(printf '%s\n' "${FINDINGS_RAW[@]}" | findings_to_json)
fi

ALERTS_JSON="[]"
if [ "${#ALERTS_RAW[@]}" -gt 0 ]; then
  ALERTS_JSON=$(printf '%s\n' "${ALERTS_RAW[@]}" | alerts_to_json)
fi

CHECKS_JSON="[]"
if [ "${#CHECKS_RAW[@]}" -gt 0 ]; then
  CHECKS_JSON=$(printf '%s\n' "${CHECKS_RAW[@]}" | checks_to_json)
fi

INVENTORY_JSON=$(inventory_to_json "$INVENTORY_OS" "$INVENTORY_KERNEL" "$INVENTORY_ARCH" "$INVENTORY_LAST_BOOT" "$INVENTORY_DISK_USAGE" "$INVENTORY_PACKAGES_FILE" "$INVENTORY_SERVICES_FILE" "$INVENTORY_PORTS_FILE")
rm -f "$INVENTORY_PACKAGES_FILE" "$INVENTORY_SERVICES_FILE" "$INVENTORY_PORTS_FILE"

if [ "$MODE" = "dry-run" ]; then
  echo "--- findings ---"
  echo "$FINDINGS_JSON" | python3 -m json.tool 2>/dev/null || echo "$FINDINGS_JSON"
  echo "--- checks ---"
  echo "$CHECKS_JSON" | python3 -m json.tool 2>/dev/null || echo "$CHECKS_JSON"
  echo "--- alerts ---"
  echo "$ALERTS_JSON" | python3 -m json.tool 2>/dev/null || echo "$ALERTS_JSON"
  echo "--- inventory ---"
  echo "$INVENTORY_JSON" | python3 -m json.tool 2>/dev/null || echo "$INVENTORY_JSON"
  exit 0
fi

mkdir -p "$STATE_DIR"
chmod 700 "$STATE_DIR"

if [ "$MODE" = "enroll" ]; then
  if [ -z "$TOKEN" ]; then
    echo "Missing --token=<enrollment token>. Get one from your Solvbeat dashboard." >&2
    exit 1
  fi
  RESPONSE=$(curl -sS -X POST "$API_BASE/agent/enroll" \
    -H "Content-Type: application/json" \
    -d "{\"token\":\"$TOKEN\"}")
  API_KEY=$(echo "$RESPONSE" | python3 -c "import json,sys; print(json.load(sys.stdin).get('apiKey',''))" 2>/dev/null)
  if [ -z "$API_KEY" ]; then
    echo "Enrollment failed: $RESPONSE" >&2
    exit 1
  fi
  printf '%s' "$API_KEY" > "$KEY_FILE"
  chmod 600 "$KEY_FILE"
  echo "Enrolled successfully."
fi

if [ ! -f "$KEY_FILE" ]; then
  echo "No API key found — run with --enroll --token=<token> first." >&2
  exit 1
fi
API_KEY=$(cat "$KEY_FILE")

# FINDINGS_JSON/ALERTS_JSON are piped through stdin rather than embedded
# into a python string literal — embedding them would break if a finding's
# detail ever contained a quote sequence that clashed with the literal's
# delimiters.
PAYLOAD=$(python3 -c "
import json, sys
findings = json.loads(sys.argv[1])
alerts = json.loads(sys.argv[2])
inventory = json.loads(sys.argv[3])
checks = json.loads(sys.argv[5])
print(json.dumps({'findings': findings, 'alerts': alerts, 'inventory': inventory, 'checks': checks, 'agentVersion': sys.argv[4]}))
" "$FINDINGS_JSON" "$ALERTS_JSON" "$INVENTORY_JSON" "$VERSION" "$CHECKS_JSON")

REPORT_RESP=$(curl -sS -X POST "$API_BASE/agent/report" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d "$PAYLOAD")
echo "Solvbeat Agent report sent."

# --- On-demand log retrieval (server-driven, pull not push) -------------
# By default this agent sends only its OWN local detections -- never raw
# logs -- so the volume reaching Solvbeat stays flat no matter how many
# servers report in. Raw logs never leave the client's server unless
# they're actually needed: when an analyst wants deeper context on an
# alert, the backend includes a "requestLogs" directive in the report
# response, and only then does the agent ship that one bounded log window
# for server-side analysis. This is what keeps central storage/cost flat
# (no per-GB ingestion) -- the heavy data is processed at the edge, on the
# server that already owns it.
if [ "$MODE" = "report" ] && [ -n "$REPORT_RESP" ]; then
  REQ=$(printf '%s' "$REPORT_RESP" | python3 -c "
import json,sys
try:
    d=json.load(sys.stdin); r=d.get('requestLogs')
    if r:
        n=int(r.get('lines',400));  n=400 if n<=0 else (2000 if n>2000 else n)
        print((r.get('source') or 'auth')+chr(9)+str(n))
except Exception: pass
" 2>/dev/null)
  if [ -n "$REQ" ]; then
    src=$(printf '%s' "$REQ" | cut -f1)
    maxlines=$(printf '%s' "$REQ" | cut -f2)
    case "$src" in
      syslog) logfiles="/var/log/syslog /var/log/messages" ;;
      *)      logfiles="/var/log/auth.log /var/log/secure" ;;
    esac
    for logfile in $logfiles; do
      [ -r "$logfile" ] || continue
      new_lines=$(tail -n "$maxlines" "$logfile" 2>/dev/null)
      [ -n "$new_lines" ] || continue
      LOGS_JSON=$(printf '%s' "$new_lines" | python3 -c "import json,sys; L=[l for l in sys.stdin.read().split(chr(10)) if l.strip()]; print(json.dumps({'logs':L[-$maxlines:]}))" 2>/dev/null)
      [ -n "$LOGS_JSON" ] || continue
      curl -sS -X POST "$API_BASE/agent/logs" \
        -H "Content-Type: application/json" \
        -H "Authorization: Bearer $API_KEY" \
        -d "$LOGS_JSON" -o /dev/null -w "Solvbeat requested-log analysis sent from $logfile (HTTP %{http_code})\n" || true
      break
    done
  fi
fi

# --- Opt-in approved-action execution (only with --enable-actions) ---
#
# A fixed set of action types exist, each a fixed template -- never the
# free-text command an AI suggested. A Solvbeat analyst must have already
# reviewed and approved the specific alert in the admin panel before
# anything reaches this script; nothing here is triggered by the AI alone.
# Every target is validated before use; any failure is refused and reported
# back, never silently skipped.
#
# Five containment actions (disable_account / remove_cron_task / block_ip /
# rate_limit_ip / allow_ip) run with --enable-actions. Two remediation
# actions that APPLY A FIX -- update_package (apt/dnf/yum, one package) and
# update_wordpress (core or one plugin) -- run only with the separate
# --enable-auto-fix flag, because they change installed software on the
# host, not just firewall/account state. --enable-auto-fix implies
# --enable-actions. They only ever touch the exact package/plugin the
# approved alert names, keep existing config, and report the previous
# version so the change can be rolled back.
if [ "$ENABLE_ACTIONS" -eq 1 ] && [ "$MODE" = "report" ]; then
  LOCAL_IPS=$(hostname -I 2>/dev/null || true)

  execute_action() {
    local action_type="$1" target="$2"
    local success="false" output=""

    case "$action_type" in
      disable_account)
        if [[ "$target" =~ ^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$ ]] && [ "$target" != "root" ] && [ "$target" != "$(whoami)" ]; then
          if usermod -L "$target" 2>/tmp/solvbeat_action_err; then
            success="true"
            output="Account '$target' locked (usermod -L). Reversible with: usermod -U $target"
          else
            output="usermod failed: $(cat /tmp/solvbeat_action_err 2>/dev/null)"
          fi
        else
          output="Refused: target failed validation (empty, root, self, or not a valid username)."
        fi
        ;;
      block_ip)
        if [[ "$target" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] && [ "$target" != "127.0.0.1" ] && [ "$target" != "0.0.0.0" ] && [[ " $LOCAL_IPS " != *" $target "* ]]; then
          if iptables -C INPUT -s "$target" -j DROP 2>/dev/null; then
            success="true"
            output="IP $target was already blocked."
          elif iptables -I INPUT -s "$target" -j DROP 2>/tmp/solvbeat_action_err; then
            success="true"
            output="IP $target blocked (iptables INPUT DROP). Reversible with: iptables -D INPUT -s $target -j DROP"
          else
            output="iptables failed: $(cat /tmp/solvbeat_action_err 2>/dev/null)"
          fi
        else
          output="Refused: target is not a valid, non-local IPv4 address."
        fi
        ;;
      rate_limit_ip)
        if [[ "$target" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] && [ "$target" != "127.0.0.1" ] && [ "$target" != "0.0.0.0" ] && [[ " $LOCAL_IPS " != *" $target "* ]]; then
          if iptables -C INPUT -s "$target" -m limit --limit 10/minute --limit-burst 20 -j ACCEPT 2>/dev/null; then
            success="true"
            output="IP $target was already rate-limited."
          elif iptables -I INPUT -s "$target" -j DROP 2>/tmp/solvbeat_action_err && iptables -I INPUT -s "$target" -m limit --limit 10/minute --limit-burst 20 -j ACCEPT 2>>/tmp/solvbeat_action_err; then
            success="true"
            output="IP $target rate-limited to 10/minute (burst 20); traffic above that is dropped. Reversible with the 'allow_ip' action, or manually: iptables -D INPUT -s $target -m limit --limit 10/minute --limit-burst 20 -j ACCEPT && iptables -D INPUT -s $target -j DROP"
          else
            output="iptables failed: $(cat /tmp/solvbeat_action_err 2>/dev/null)"
          fi
        else
          output="Refused: target is not a valid, non-local IPv4 address."
        fi
        ;;
      allow_ip)
        if [[ "$target" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
          local removed_any="false"
          if iptables -C INPUT -s "$target" -j DROP 2>/dev/null; then
            iptables -D INPUT -s "$target" -j DROP 2>/tmp/solvbeat_action_err && removed_any="true"
          fi
          if iptables -C INPUT -s "$target" -m limit --limit 10/minute --limit-burst 20 -j ACCEPT 2>/dev/null; then
            iptables -D INPUT -s "$target" -m limit --limit 10/minute --limit-burst 20 -j ACCEPT 2>>/tmp/solvbeat_action_err && removed_any="true"
          fi
          if [ "$removed_any" = "true" ]; then
            success="true"
            output="Removed the Solvbeat-added block/rate-limit rule(s) for $target."
          else
            success="true"
            output="No Solvbeat-added block or rate-limit rule found for $target -- nothing to undo."
          fi
        else
          output="Refused: target is not a valid IPv4 address."
        fi
        ;;
      remove_cron_task)
        local real_target
        real_target=$(realpath -m -- "$target" 2>/dev/null)
        if [ -n "$real_target" ] && [[ "$real_target" == /etc/cron.d/* ]] && [ -f "$real_target" ]; then
          mkdir -p /opt/solvbeat-agent/quarantined-cron
          local dest="/opt/solvbeat-agent/quarantined-cron/$(basename "$real_target").$(date +%s)"
          if mv -- "$real_target" "$dest" 2>/tmp/solvbeat_action_err; then
            success="true"
            output="Moved $real_target to $dest (quarantined, not deleted). Restore by moving it back."
          else
            output="mv failed: $(cat /tmp/solvbeat_action_err 2>/dev/null)"
          fi
        else
          output="Refused: only existing files under /etc/cron.d/ are supported for automatic removal."
        fi
        ;;
      update_package)
        # Apply the security update for ONE specific package flagged with a
        # known CVE. Never installs something new, never a blanket upgrade:
        # the package must already be installed AND actually upgradable, or
        # we refuse. Existing config files are kept (--force-confold) so the
        # customer's setup isn't overwritten. The old version is reported so
        # the update can be rolled back.
        if [ "$ENABLE_AUTOFIX" -ne 1 ]; then
          output="Refused: automatic patching is not enabled on this agent. Add --enable-auto-fix to allow it."
        elif [[ "$target" =~ ^[a-zA-Z0-9][a-zA-Z0-9._+-]{0,63}$ ]]; then
          if command -v apt-get >/dev/null 2>&1; then
            local before_v after_v
            before_v=$(dpkg-query -W -f='${Version}' "$target" 2>/dev/null || true)
            if [ -z "$before_v" ]; then
              output="Refused: package '$target' is not installed."
            elif ! DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1; then
              output="Refused: 'apt-get update' failed, not attempting the upgrade."
            elif ! apt list --upgradable 2>/dev/null | grep -q "^${target}/"; then
              success="true"
              output="Package '$target' is already up to date ($before_v) — nothing to upgrade."
            elif DEBIAN_FRONTEND=noninteractive apt-get install -y --only-upgrade -o Dpkg::Options::="--force-confold" -o Dpkg::Options::="--force-confdef" "$target" >/tmp/solvbeat_action_err 2>&1; then
              after_v=$(dpkg-query -W -f='${Version}' "$target" 2>/dev/null || true)
              success="true"
              output="Updated '$target' $before_v -> $after_v (only this package; existing config kept). Rollback: apt-get install $target=$before_v. A service restart or reboot may be needed for the patch to take effect."
            else
              output="apt-get upgrade of '$target' failed: $(tail -3 /tmp/solvbeat_action_err 2>/dev/null)"
            fi
          elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then
            local pm before_v after_v
            pm=$(command -v dnf 2>/dev/null || command -v yum 2>/dev/null)
            before_v=$(rpm -q "$target" 2>/dev/null || true)
            if ! rpm -q "$target" >/dev/null 2>&1; then
              output="Refused: package '$target' is not installed."
            elif "$pm" -y update-minimal "$target" >/tmp/solvbeat_action_err 2>&1 || "$pm" -y update "$target" >>/tmp/solvbeat_action_err 2>&1; then
              after_v=$(rpm -q "$target" 2>/dev/null || true)
              success="true"
              output="Updated '$target' ($before_v -> $after_v). Rollback: $pm downgrade $target. A service restart or reboot may be needed for the patch to take effect."
            else
              output="$pm update of '$target' failed: $(tail -3 /tmp/solvbeat_action_err 2>/dev/null)"
            fi
          else
            output="Refused: no supported package manager (apt/dnf/yum) found."
          fi
        else
          output="Refused: '$target' is not a valid package name."
        fi
        ;;
      update_wordpress)
        # Update WordPress core, or one specific plugin, via wp-cli. Best
        # effort: refuses cleanly if wp-cli or a WordPress install can't be
        # found. target is 'core' or 'plugin:<slug>'.
        if [ "$ENABLE_AUTOFIX" -ne 1 ]; then
          output="Refused: automatic patching is not enabled on this agent. Add --enable-auto-fix to allow it."
        elif ! command -v wp >/dev/null 2>&1; then
          output="Refused: wp-cli is not installed, cannot update WordPress automatically."
        else
          local wproot="" base
          for base in /var/www/html /var/www /srv/www /usr/share/nginx/html; do
            [ -f "$base/wp-config.php" ] && { wproot="$base"; break; }
          done
          if [ -z "$wproot" ]; then
            wproot=$(find /var/www /srv 2>/dev/null -maxdepth 4 -name wp-config.php 2>/dev/null | head -1 | xargs -r dirname 2>/dev/null || true)
          fi
          if [ -z "$wproot" ]; then
            output="Refused: could not locate a WordPress install (no wp-config.php found)."
          elif [ "$target" = "core" ]; then
            local before_v
            before_v=$(wp --allow-root --path="$wproot" core version 2>/dev/null || true)
            if wp --allow-root --path="$wproot" core update >/tmp/solvbeat_action_err 2>&1; then
              success="true"
              output="WordPress core updated ($before_v -> $(wp --allow-root --path="$wproot" core version 2>/dev/null)). Rollback: wp core update --version=$before_v --force."
            else
              output="wp core update failed: $(tail -3 /tmp/solvbeat_action_err 2>/dev/null)"
            fi
          elif [[ "$target" =~ ^plugin:[a-z0-9][a-z0-9-]{0,63}$ ]]; then
            local slug before_v
            slug="${target#plugin:}"
            before_v=$(wp --allow-root --path="$wproot" plugin get "$slug" --field=version 2>/dev/null || true)
            if [ -z "$before_v" ]; then
              output="Refused: plugin '$slug' is not installed."
            elif wp --allow-root --path="$wproot" plugin update "$slug" >/tmp/solvbeat_action_err 2>&1; then
              success="true"
              output="Plugin '$slug' updated ($before_v -> $(wp --allow-root --path="$wproot" plugin get "$slug" --field=version 2>/dev/null))."
            else
              output="wp plugin update '$slug' failed: $(tail -3 /tmp/solvbeat_action_err 2>/dev/null)"
            fi
          else
            output="Refused: target must be 'core' or 'plugin:<slug>'."
          fi
        fi
        ;;
      *)
        output="Unsupported action type: $action_type"
        ;;
    esac
    rm -f /tmp/solvbeat_action_err

    printf '%s\t%s\t%s\n' "$success" "$action_type" "$output"
  }

  PENDING=$(curl -sS --max-time 15 "$API_BASE/agent/pending-actions" -H "Authorization: Bearer $API_KEY" 2>/dev/null || true)
  if [ -n "$PENDING" ]; then
    echo "$PENDING" | python3 -c "
import json, sys
try:
    d = json.load(sys.stdin)
    for a in d.get('actions', []):
        print(f\"{a['id']}\t{a['type']}\t{a.get('target') or ''}\")
except Exception:
    pass
" | while IFS=$'\t' read -r ACTION_ID ACTION_TYPE ACTION_TARGET; do
      [ -z "$ACTION_ID" ] && continue
      RESULT=$(execute_action "$ACTION_TYPE" "$ACTION_TARGET")
      A_SUCCESS=$(printf '%s' "$RESULT" | cut -f1)
      A_OUTPUT=$(printf '%s' "$RESULT" | cut -f3-)
      RESULT_PAYLOAD=$(python3 -c "
import json, sys
print(json.dumps({'alertId': int(sys.argv[1]), 'success': sys.argv[2] == 'true', 'output': sys.argv[3]}))
" "$ACTION_ID" "$A_SUCCESS" "$A_OUTPUT")
      curl -sS --max-time 15 -X POST "$API_BASE/agent/action-result" \
        -H "Content-Type: application/json" \
        -H "Authorization: Bearer $API_KEY" \
        -d "$RESULT_PAYLOAD" -o /dev/null
      echo "Action $ACTION_ID ($ACTION_TYPE): $A_OUTPUT"
    done
  fi
fi
