#!/bin/bash
# SolveBeat 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 SolveBeat dashboard. It never writes to your system beyond its
# own state directory, and it never executes anything sent back from the
# server — the checks below are the entire, fixed feature set. 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
#
set -uo pipefail

API_BASE="https://solvbeat.co.uk/api"
STATE_DIR="/opt/solvbeat-agent"
KEY_FILE="$STATE_DIR/api_key"
VERSION="1.1.0"

MODE="report"
TOKEN=""
for arg in "$@"; do
  case $arg in
    --enroll) MODE="enroll" ;;
    --dry-run) MODE="dry-run" ;;
    --token=*) TOKEN="${arg#*=}" ;;
  esac
done

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 — every check here is read-only. Nothing here modifies the
# system, installs anything, or changes configuration.
# ---------------------------------------------------------------
run_checks() {
  # 1. Pending security updates
  if command -v apt >/dev/null 2>&1; then
    local sec_updates
    sec_updates=$(apt list --upgradable 2>/dev/null | grep -ic "security" || true)
    if [ "${sec_updates:-0}" -gt 0 ]; then
      add_finding "critical" "Pending security updates" "$sec_updates security update(s) available via apt — run 'apt upgrade' to apply them."
    fi
  elif command -v yum >/dev/null 2>&1; then
    local sec_updates
    sec_updates=$(yum check-update --security 2>/dev/null | grep -c '^[a-zA-Z]' || true)
    if [ "${sec_updates:-0}" -gt 0 ]; then
      add_finding "critical" "Pending security updates" "$sec_updates security update(s) available via yum."
    fi
  fi

  # 2. SSH hardening
  if [ -f /etc/ssh/sshd_config ]; then
    if grep -qiE '^[[:space:]]*PermitRootLogin[[:space:]]+yes' /etc/ssh/sshd_config; then
      add_finding "critical" "SSH allows root login" "PermitRootLogin is set to yes in /etc/ssh/sshd_config — disable direct root SSH access."
    fi
    if grep -qiE '^[[:space:]]*PasswordAuthentication[[:space:]]+yes' /etc/ssh/sshd_config; then
      add_finding "medium" "SSH allows password authentication" "PasswordAuthentication is yes — key-only auth is far more resistant to brute force."
    fi
  fi

  # 3. Firewall active
  local firewall_active=0
  if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -qi "Status: active"; then
    firewall_active=1
  elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state 2>/dev/null | grep -q running; then
    firewall_active=1
  elif command -v iptables >/dev/null 2>&1 && iptables -L 2>/dev/null | grep -qvE '^(Chain|target|$)'; then
    firewall_active=1
  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."
  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)"
  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."
    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."
    fi
  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)."
  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."
    fi
  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."
  fi
}

# ---------------------------------------------------------------
# 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"
}

run_checks

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

if [ "$MODE" = "dry-run" ]; then
  echo "$FINDINGS_JSON" | python3 -m json.tool 2>/dev/null || echo "$FINDINGS_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 SolveBeat 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 is piped through stdin rather than embedded into a python
# string literal — embedding it (e.g. via '''$FINDINGS_JSON''') would break
# if a finding's detail ever contained a quote sequence that clashed with
# the literal's delimiters.
PAYLOAD=$(echo "$FINDINGS_JSON" | python3 -c "
import json, sys
findings = json.loads(sys.stdin.read())
print(json.dumps({'findings': findings, 'agentVersion': sys.argv[1]}))
" "$VERSION")

curl -sS -X POST "$API_BASE/agent/report" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d "$PAYLOAD" -o /dev/null -w "SolveBeat Agent report sent (HTTP %{http_code})\n"
