#!/usr/bin/env bash
# Scantide Linux Local Device Check
# Version: 0.61-version-relevance-gate

set -u

OUTDIR="${OUTDIR:-./scantide-linux-report}"
SCRIPT_VERSION="0.63"
VERSION_FEED_URL="${VERSION_FEED_URL:-https://www.scantide.com/helpfiles/ScantideLAN-version.json}"
SCANTIDE_CVE_API="${SCANTIDE_CVE_API:-https://www.scantide.com/cve_api.php}"
SCANTIDE_LIFECYCLE_API="${SCANTIDE_LIFECYCLE_API:-https://www.scantide.com/lifecycle_api.php}"
SCANTIDE_REMEDIATION_API="${SCANTIDE_REMEDIATION_API:-https://www.scantide.com/remediation_api.php}"
REMEDIATION_TIMEOUT=30
SCANTIDE_API_KEY="${SCANTIDE_API_KEY:-}"
SCANTIDE_EMAIL="${SCANTIDE_EMAIL:-}"

USE_SCANTIDE_API=true
USE_SCANTIDE_LIFECYCLE=true
DEEP_SCAN=false
CVE_TIMEOUT=90
LIFECYCLE_TIMEOUT=60
VERSION_TIMEOUT=10
CHECK_VERSION=true
AUTO_UPDATE_LINUX=ask
KEEP_DEBUG_FILES=false
EXTENDED_SLOW_PACKAGE_DISCOVERY=false
LIFECYCLE_TSV="${LIFECYCLE_TSV:-}"
CVE_TABLE="${CVE_TABLE:-}"
AVAILABLE_UPDATES="unknown"
SECURITY_UPDATES="unknown"
REBOOT_REQUIRED_SUMMARY="unknown"

TS="$(date '+%Y%m%d_%H%M%S')"
REMEDIATION_PAYLOAD="$OUTDIR/remediation_payload_$TS.json"
REMEDIATION_JSON="$OUTDIR/remediation_response_$TS.json"
REMEDIATION_STATUS="$OUTDIR/remediation_status_$TS.txt"
HOST="$(hostname -f 2>/dev/null || hostname)"
REPORT="$OUTDIR/ScantideLinuxLocalCheck_$TS.html"
JSON_REPORT="$OUTDIR/ScantideLinuxLocalCheck_$TS.json"
PKGFILE="$OUTDIR/packages_$TS.txt"
SERVICEFILE="$OUTDIR/service_inventory_for_cve_$TS.txt"
PACKAGE_EXTRA_TSV="$OUTDIR/package_extra_discovery_$TS.tsv"
PACKAGE_EXTRA_RAW="$OUTDIR/package_extra_discovery_$TS.txt"
CVE_PAYLOAD="$OUTDIR/cve_payload_$TS.json"
CVE_RESPONSE="$OUTDIR/cve_response_$TS.json"
CVE_RAW="$OUTDIR/scantide_cve_api_raw_$TS.txt"
LIFECYCLE_PAYLOAD="$OUTDIR/lifecycle_payload_$TS.json"
LIFECYCLE_RESPONSE="$OUTDIR/lifecycle_response_$TS.json"
LIFECYCLE_RAW="$OUTDIR/scantide_lifecycle_api_raw_$TS.txt"
FINDINGS="$OUTDIR/findings_$TS.tsv"
CONTAINERFILE="$OUTDIR/container_inventory_$TS.txt"
CONTAINER_TSV="$OUTDIR/container_inventory_$TS.tsv"
PORTFILE="$OUTDIR/listening_ports_$TS.tsv"
ROLEFILE="$OUTDIR/server_roles_$TS.txt"
HARDENING_RAW="$OUTDIR/hardening_evidence_$TS.txt"
ACCOUNTS_RAW="$OUTDIR/accounts_privileges_$TS.txt"
ACCOUNTS_TSV="$OUTDIR/accounts_privileges_$TS.tsv"
SYSCTL_RAW="$OUTDIR/sysctl_hardening_$TS.txt"
SYSCTL_TSV="$OUTDIR/sysctl_hardening_$TS.tsv"
FS_RAW="$OUTDIR/filesystem_permissions_$TS.txt"
FS_TSV="$OUTDIR/filesystem_permissions_$TS.tsv"
AUDIT_RAW="$OUTDIR/audit_logging_$TS.txt"
AUDIT_TSV="$OUTDIR/audit_logging_$TS.tsv"
LSM_RAW="$OUTDIR/lsm_status_$TS.txt"
LSM_TSV="$OUTDIR/lsm_status_$TS.tsv"
UPDATES_RAW="$OUTDIR/update_posture_$TS.txt"
UPDATES_TSV="$OUTDIR/update_posture_$TS.tsv"
UPDATE_SUMMARY_TSV="$OUTDIR/update_summary_$TS.tsv"
OPTIONAL_TOOLS_RAW="$OUTDIR/optional_security_tools_$TS.txt"
OPTIONAL_TOOLS_TSV="$OUTDIR/optional_security_tools_$TS.tsv"
EXPOSURE_TSV="$OUTDIR/exposure_classification_$TS.tsv"
CONFIG_TSV="$OUTDIR/config_inventory_$TS.tsv"
CONFIG_RAW="$OUTDIR/config_inventory_$TS.txt"
SECRETS_TSV="$OUTDIR/possible_secret_exposure_$TS.tsv"
SECRETS_RAW="$OUTDIR/possible_secret_exposure_$TS.txt"
PERSISTENCE_TSV="$OUTDIR/persistence_inventory_$TS.tsv"
PERSISTENCE_RAW="$OUTDIR/persistence_inventory_$TS.txt"
WEBSTACK_TSV="$OUTDIR/web_stack_posture_$TS.tsv"
WEBSTACK_RAW="$OUTDIR/web_stack_posture_$TS.txt"
WEBSITES_TSV="$OUTDIR/enabled_websites_$TS.tsv"
WEBSITES_RAW="$OUTDIR/enabled_websites_$TS.txt"
BACKUP_TSV="$OUTDIR/backup_restore_clues_$TS.tsv"
BACKUP_RAW="$OUTDIR/backup_restore_clues_$TS.txt"
OPSHEALTH_TSV="$OUTDIR/ops_health_$TS.tsv"
OPSHEALTH_RAW="$OUTDIR/ops_health_$TS.txt"
TLSLOCAL_TSV="$OUTDIR/local_tls_ports_$TS.tsv"
TLSLOCAL_RAW="$OUTDIR/local_tls_ports_$TS.txt"
ADMINPANELS_TSV="$OUTDIR/admin_panels_$TS.tsv"
ADMINPANELS_RAW="$OUTDIR/admin_panels_$TS.txt"
ADVHEALTH_TSV="$OUTDIR/advanced_health_$TS.tsv"
ADVHEALTH_RAW="$OUTDIR/advanced_health_$TS.txt"
OPRISK_TSV="$OUTDIR/operational_risk_$TS.tsv"
LATESTVERSIONS_TSV="$OUTDIR/latest_versions_$TS.tsv"
LATESTVERSIONS_RAW="$OUTDIR/latest_versions_$TS.txt"
STRENGTHS_TSV="$OUTDIR/strengths_$TS.tsv"
VERIFY_TSV="$OUTDIR/manual_verification_$TS.tsv"
SCORE_TSV="$OUTDIR/security_score_categories_$TS.tsv"
PLATFORMFILE="$OUTDIR/platform_inventory_$TS.txt"
QUICKACTIONS="$OUTDIR/quick_actions_$TS.tsv"
VERSIONFILE="$OUTDIR/version_check_$TS.txt"
VERSIONJSON="$OUTDIR/version_feed_$TS.json"

while [ "$#" -gt 0 ]; do
  case "$1" in
    --deep)
      DEEP_SCAN=true
      shift
      ;;
    --no-api)
      USE_SCANTIDE_API=false
      USE_SCANTIDE_LIFECYCLE=false
      shift
      ;;
    --no-cve)
      USE_SCANTIDE_API=false
      shift
      ;;
    --no-lifecycle)
      USE_SCANTIDE_LIFECYCLE=false
      shift
      ;;
    --no-version-check)
      CHECK_VERSION=false
      shift
      ;;
    --update-now)
      AUTO_UPDATE_LINUX=yes
      shift
      ;;
    --no-update)
      AUTO_UPDATE_LINUX=no
      shift
      ;;
    --keep-debug-files|--debug)
      KEEP_DEBUG_FILES=true
      shift
      ;;
    --slow-package-discovery)
      EXTENDED_SLOW_PACKAGE_DISCOVERY=true
      shift
      ;;
    --api-key=*)
      SCANTIDE_API_KEY="${1#*=}"
      shift
      ;;
    --api-key)
      if [ "${2:-}" = "" ]; then
        echo "Missing value after --api-key" >&2
        exit 2
      fi
      SCANTIDE_API_KEY="$2"
      shift 2
      ;;
    --email=*)
      SCANTIDE_EMAIL="${1#*=}"
      shift
      ;;
    --email)
      if [ "${2:-}" = "" ]; then
        echo "Missing value after --email" >&2
        exit 2
      fi
      SCANTIDE_EMAIL="$2"
      shift 2
      ;;
    -h|--help)
      echo "Usage: sudo ./scantide-linux-localcheck.sh [--deep] [--keep-debug-files|--debug] [--slow-package-discovery] [--update-now|--no-update] [--no-api] [--no-cve] [--no-lifecycle] [--no-version-check] [--api-key KEY|--api-key=KEY] [--email EMAIL|--email=EMAIL]"
      exit 0
      ;;
    *)
      echo "Unknown argument: $1" >&2
      echo "Use --help for usage. Clean output is default; use --keep-debug-files to preserve intermediate evidence files." >&2
      exit 2
      ;;
  esac
done

mkdir -p "$OUTDIR"

log(){ echo "[$(date '+%H:%M:%S')] $1"; }
cmd_exists(){ command -v "$1" >/dev/null 2>&1; }

check_linux_version_feed() {
  VERSION_FEED_STATUS="Not checked"
  VERSION_FEED_REMOTE=""
  VERSION_FEED_MINIMUM=""
  VERSION_FEED_RELEASED=""
  VERSION_FEED_WARNING=""

  if command -v curl >/dev/null 2>&1; then
    VERSION_FETCH_TOOL="curl"
  elif command -v wget >/dev/null 2>&1; then
    VERSION_FETCH_TOOL="wget"
  else
    VERSION_FEED_STATUS="Skipped: curl/wget unavailable"
    VERSION_FEED_WARNING="Could not check Linux script version because neither curl nor wget is available."
    return
  fi

  tmp_feed="$OUTDIR/scantide_version_feed_$TS.json"

  if [ "${VERSION_FETCH_TOOL:-}" = "curl" ]; then
    curl -fsSL --max-time 12 "$VERSION_FEED_URL" -o "$tmp_feed" 2>/dev/null || true
  elif [ "${VERSION_FETCH_TOOL:-}" = "wget" ]; then
    wget -q --timeout=12 "$VERSION_FEED_URL" -O "$tmp_feed" 2>/dev/null || true
  fi

  if [ ! -s "$tmp_feed" ]; then
    VERSION_FEED_STATUS="Unavailable"
    VERSION_FEED_WARNING="Could not fetch Scantide version feed. Scan continues, but update status is unknown."
    return
  fi

  if cmd_exists python3; then
    parsed="$(python3 - "$tmp_feed" <<'PY'
import json, sys
p=sys.argv[1]
try:
    d=json.load(open(p, "r", encoding="utf-8"))
except Exception:
    print("|||")
    raise SystemExit
remote = str(d.get("linuxLocalCheckVersion") or d.get("linux",{}).get("version") or "")
minimum = str(d.get("minimumRecommendedLinuxLocalCheckVersion") or d.get("linux",{}).get("minimumRecommendedVersion") or remote)
released = str(d.get("linuxLocalCheckReleased") or d.get("linux",{}).get("released") or d.get("released") or "")
url = str(d.get("linuxLocalCheckUrl") or d.get("linux",{}).get("downloadUrl") or "")
print(remote + "|" + minimum + "|" + released + "|" + url)
PY
)"
    VERSION_FEED_REMOTE="$(printf '%s' "$parsed" | cut -d'|' -f1)"
    VERSION_FEED_MINIMUM="$(printf '%s' "$parsed" | cut -d'|' -f2)"
    VERSION_FEED_RELEASED="$(printf '%s' "$parsed" | cut -d'|' -f3)"
    VERSION_FEED_DOWNLOAD="$(printf '%s' "$parsed" | cut -d'|' -f4)"
  else
    VERSION_FEED_REMOTE="$(grep -E '"linuxLocalCheckVersion"\s*:' "$tmp_feed" | head -1 | sed -E 's/.*"linuxLocalCheckVersion"\s*:\s*"([^"]+)".*/\1/')"
    VERSION_FEED_MINIMUM="$(grep -E '"minimumRecommendedLinuxLocalCheckVersion"\s*:' "$tmp_feed" | head -1 | sed -E 's/.*"minimumRecommendedLinuxLocalCheckVersion"\s*:\s*"([^"]+)".*/\1/')"
    VERSION_FEED_RELEASED="$(grep -E '"linuxLocalCheckReleased"|"released"\s*:' "$tmp_feed" | head -1 | sed -E 's/.*:\s*"([^"]+)".*/\1/')"
    VERSION_FEED_DOWNLOAD="$(grep -E '"linuxLocalCheckUrl"\s*:' "$tmp_feed" | head -1 | sed -E 's/.*"linuxLocalCheckUrl"\s*:\s*"([^"]+)".*/\1/')"
  fi

  [ -z "${VERSION_FEED_MINIMUM:-}" ] && VERSION_FEED_MINIMUM="$VERSION_FEED_REMOTE"

  if [ -z "${VERSION_FEED_REMOTE:-}" ]; then
    VERSION_FEED_STATUS="Feed missing Linux version"
    VERSION_FEED_WARNING="The online version feed was fetched, but it does not contain linuxLocalCheckVersion. Scan continues, but update status is unknown."
    return
  fi

  compare_versions() {
    # returns -1 if $1 < $2, 0 if equal, 1 if $1 > $2
    python3 - "$1" "$2" <<'PY' 2>/dev/null || echo 0
import sys, re
def parts(v):
    return [int(x) for x in re.findall(r'\d+', v)[:6]]
a=parts(sys.argv[1]); b=parts(sys.argv[2])
n=max(len(a),len(b)); a+= [0]*(n-len(a)); b += [0]*(n-len(b))
print(-1 if a<b else 1 if a>b else 0)
PY
  }

  cmp_remote="$(compare_versions "$SCRIPT_VERSION" "$VERSION_FEED_REMOTE")"
  cmp_min="$(compare_versions "$SCRIPT_VERSION" "$VERSION_FEED_MINIMUM")"

  if [ "$cmp_remote" = "0" ]; then
    VERSION_FEED_STATUS="Current"
  elif [ "$cmp_remote" = "-1" ]; then
    VERSION_FEED_STATUS="Update available"
    VERSION_FEED_WARNING="This Linux local check script is older than the online Linux version feed. Local: $SCRIPT_VERSION, online: $VERSION_FEED_REMOTE."
  else
    VERSION_FEED_STATUS="Newer than feed"
    VERSION_FEED_WARNING="This Linux local check script is newer than the online Linux version feed. Local: $SCRIPT_VERSION, online: $VERSION_FEED_REMOTE. Verify that the published version feed is updated."
  fi

  if [ "$cmp_min" = "-1" ]; then
    VERSION_FEED_WARNING="This Linux local check script is below the minimum recommended Linux version. Local: $SCRIPT_VERSION, minimum recommended: $VERSION_FEED_MINIMUM."
  fi
}


write_active_version_status_file() {
  : > "$VERSIONFILE"
  {
    echo "local_script_version	$SCRIPT_VERSION"
    echo "version_feed_url	$VERSION_FEED_URL"
    echo "status	${VERSION_FEED_STATUS:-Not checked}"
    echo "message	${VERSION_FEED_WARNING:-}"
    echo "linuxLocalCheckVersion	${VERSION_FEED_REMOTE:-}"
    echo "remote_version	${VERSION_FEED_REMOTE:-}"
    echo "version	${VERSION_FEED_REMOTE:-}"
    echo "minimumRecommendedLinuxLocalCheckVersion	${VERSION_FEED_MINIMUM:-}"
    echo "released	${VERSION_FEED_RELEASED:-}"
    echo "linuxLocalCheckUrl	${VERSION_FEED_DOWNLOAD:-}"
    echo "linuxScriptUrl	${VERSION_FEED_DOWNLOAD:-}"
  } >> "$VERSIONFILE"
}

supports_color() {
  [ -t 1 ] && [ "${NO_COLOR:-}" = "" ]
}

color_code() {
  case "$1" in
    green) printf '[32m' ;;
    yellow) printf '[33m' ;;
    red) printf '[31m' ;;
    gray) printf '[90m' ;;
    reset) printf '[0m' ;;
  esac
}

stage_plain() {
  local label="$1"; shift
  local msg="$*"
  printf '[%s] %-7s %s
' "$(date '+%H:%M:%S')" "$label" "$msg"
}

stage_done() {
  local color="$1"; shift
  local label="$1"; shift
  local msg="$*"
  if supports_color; then
    printf '[%s] %s%-7s%s %s
' "$(date '+%H:%M:%S')" "$(color_code "$color")" "$label" "$(color_code reset)" "$msg"
  else
    printf '[%s] %-7s %s
' "$(date '+%H:%M:%S')" "$label" "$msg"
  fi
}

stage_start() { [ "${SCANTIDE_SHOW_RUN_LINES:-false}" = "true" ] && stage_plain "RUN" "$*" || true; }
stage_ok() { stage_done green "OK" "$*"; }
stage_skip() { stage_done gray "SKIP" "$*"; }
stage_warn() { stage_done yellow "WARN" "$*"; }
stage_fail() { stage_done red "FAIL" "$*"; }


stage_warn_with_context() {
  local msg="$1"
  local detail="${2:-}"
  if [ -n "$detail" ]; then
    stage_warn "$msg - $detail"
  else
    stage_warn "$msg - command returned non-zero; check the matching raw evidence section in the report."
  fi
}

has_scantide_credentials() {
  [ -n "${SCANTIDE_API_KEY:-}" ] && [ -n "${SCANTIDE_EMAIL:-}" ]
}



html_escape(){ sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g'; }
json_escape(){ printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/	/ /g'; }


safe_version_probe() {
  local seconds="$1"
  shift

  # Keep probe failures and timeout kills out of the console.
  # Some runtimes/package tools can hang or be killed by timeout; that is expected and non-fatal.
  if cmd_exists timeout; then
    timeout --kill-after=1s "$seconds" "$@" >/tmp/scantide_probe_out_$$ 2>/tmp/scantide_probe_err_$$ || true
    cat /tmp/scantide_probe_out_$$ 2>/dev/null || true
    rm -f /tmp/scantide_probe_out_$$ /tmp/scantide_probe_err_$$ 2>/dev/null || true
  else
    "$@" 2>/dev/null || true
  fi
}


run_quick() {
  local seconds="$1"
  shift
  if cmd_exists timeout; then
    timeout --kill-after=1s "$seconds" "$@" 2>/dev/null || true
  else
    "$@" 2>/dev/null || true
  fi
}

run_timeout() {
  local seconds="$1"
  shift
  if cmd_exists timeout; then timeout "$seconds" "$@" 2>&1; else "$@" 2>&1; fi
}

version_base() {
  printf '%s' "$1" | grep -Eo '[0-9]+(\.[0-9]+){1,3}' | head -1
}

version_compare_state() {
  local local_v remote_v
  local_v="$(version_base "$1")"
  remote_v="$(version_base "$2")"

  if [ -z "$local_v" ] || [ -z "$remote_v" ]; then
    echo "unknown"
    return
  fi

  if [ "$local_v" = "$remote_v" ]; then
    echo "current"
    return
  fi

  if printf '%s
%s
' "$local_v" "$remote_v" | sort -V | head -1 | grep -qx "$local_v"; then
    echo "older"
  else
    echo "newer"
  fi
}

json_get_field() {
  local file="$1"
  local key="$2"
  if [ ! -s "$file" ]; then return; fi

  if cmd_exists python3; then
    python3 - "$file" "$key" <<'PY_JSON_GET'
import json, sys
try:
    data=json.load(open(sys.argv[1], encoding='utf-8'))
    value=data
    for part in sys.argv[2].split('.'):
        if isinstance(value, dict):
            value=value.get(part, '')
        else:
            value=''
            break
    if isinstance(value, (dict, list)):
        print(json.dumps(value, ensure_ascii=False))
    elif value is None:
        print('')
    else:
        print(str(value))
except Exception:
    print('')
PY_JSON_GET
  else
    grep -E '"'"$key"'"[[:space:]]*:' "$file" | head -1 | sed -E 's/^.*:[[:space:]]*"?([^",}]*)"?.*$/\1/'
  fi
}


version_status_value() {
  local key="$1"
  [ -s "$VERSIONFILE" ] || return 0
  awk -F '\t' -v k="$key" '$1==k{print $2; exit}' "$VERSIONFILE" 2>/dev/null || true
}

download_with_curl_or_wget() {
  local url="$1"
  local dest="$2"
  [ -z "$url" ] && return 1
  if cmd_exists curl; then
    curl -fsSL --max-time 30 "$url" -o "$dest"
  elif cmd_exists wget; then
    wget -q -T 30 "$url" -O "$dest"
  else
    return 1
  fi
}

maybe_update_linux_script() {
  [ "$CHECK_VERSION" = "true" ] || return 0

  local state remote linux_url linux_sha current_path tmp backup answer

  # The active version checker stores these in memory for the report.
  # Older/legacy code may also write VERSIONFILE, so keep that as fallback.
  remote="${VERSION_FEED_REMOTE:-}"
  [ -z "$remote" ] && remote="$(version_status_value linuxLocalCheckVersion)"
  [ -z "$remote" ] && remote="$(version_status_value remote_version)"
  [ -z "$remote" ] && remote="$(version_status_value version)"

  linux_url="${VERSION_FEED_DOWNLOAD:-}"
  [ -z "$linux_url" ] && linux_url="$(version_status_value linuxLocalCheckUrl)"
  [ -z "$linux_url" ] && linux_url="$(version_status_value linuxScriptUrl)"

  linux_sha="$(version_status_value linuxLocalCheckSha256)"

  case "${VERSION_FEED_STATUS:-}" in
    "Update available") state="older" ;;
    "Current") state="current" ;;
    "Newer than feed") state="newer" ;;
    *) state="$(version_status_value status)" ;;
  esac

  # If status is still unavailable, compare local and remote directly.
  if [ -z "${state:-}" ] && [ -n "$remote" ]; then
    state="$(version_compare_state "$SCRIPT_VERSION" "$remote")"
  fi

  stage_plain "INFO" "Local Linux script version: $SCRIPT_VERSION"
  stage_plain "INFO" "Online Linux script version: ${remote:-unknown}"

  if [ -z "$remote" ]; then
    stage_warn "Online Linux script version could not be determined from the version feed."
    return 0
  fi

  if [ "$state" != "older" ]; then
    stage_ok "Local script is not older than the online version."
    return 0
  fi

  if [ -z "$linux_url" ]; then
    stage_warn "Online version is newer, but linuxLocalCheckUrl/linuxScriptUrl is not present in the version feed."
    return 0
  fi

  if [ "${AUTO_UPDATE_LINUX:-ask}" = "no" ]; then
    stage_warn "Online version is newer, but update was disabled by --no-update."
    return 0
  fi

  if [ "${AUTO_UPDATE_LINUX:-ask}" = "ask" ]; then
    if [ ! -t 0 ]; then
      stage_warn "Online version is newer. Interactive update prompt skipped because stdin is not a terminal. Use --update-now to update automatically."
      return 0
    fi
    printf "A newer Scantide Linux script is available (%s > %s). Update now? [y/N] " "${remote:-unknown}" "$SCRIPT_VERSION"
    read -r answer || answer="n"
    case "$answer" in
      y|Y|yes|YES) ;;
      *) stage_warn "Update skipped by user."; return 0 ;;
    esac
  fi

  current_path="$(readlink -f "$0" 2>/dev/null || printf '%s' "$0")"
  tmp="${current_path}.new.$$"
  backup="${current_path}.bak.$(date '+%Y%m%d_%H%M%S')"

  stage_plain "INFO" "Downloading updated Linux script from $linux_url"
  if ! download_with_curl_or_wget "$linux_url" "$tmp"; then
    rm -f "$tmp" 2>/dev/null || true
    stage_fail "Update download failed. Continuing with current script."
    return 0
  fi

  if [ -n "$linux_sha" ] && cmd_exists sha256sum; then
    actual="$(sha256sum "$tmp" | awk '{print $1}')"
    if [ "$actual" != "$linux_sha" ]; then
      rm -f "$tmp" 2>/dev/null || true
      stage_fail "Downloaded script SHA256 mismatch. Update aborted."
      return 0
    fi
  fi

  if ! bash -n "$tmp" 2>/dev/null; then
    rm -f "$tmp" 2>/dev/null || true
    stage_fail "Downloaded script failed syntax check. Update aborted."
    return 0
  fi

  cp "$current_path" "$backup" 2>/dev/null || true
  chmod --reference="$current_path" "$tmp" 2>/dev/null || chmod +x "$tmp"
  if mv "$tmp" "$current_path"; then
    stage_ok "Scantide Linux script updated. Backup: $backup"
    stage_warn "Run the command again to use the updated script."
    exit 0
  else
    rm -f "$tmp" 2>/dev/null || true
    stage_fail "Could not replace current script. Check permissions."
    return 0
  fi
}

check_scantide_version_feed_legacy_disabled() {
  : > "$VERSIONFILE"

  {
    echo "local_script_version	$SCRIPT_VERSION"
    echo "version_feed_url	$VERSION_FEED_URL"
  } >> "$VERSIONFILE"

  if [ "$CHECK_VERSION" != "true" ]; then
    echo "status	skipped" >> "$VERSIONFILE"
    echo "message	Version check skipped by --no-version-check." >> "$VERSIONFILE"
    return
  fi

  if ! cmd_exists curl && ! cmd_exists wget; then
    echo "status	unavailable" >> "$VERSIONFILE"
    echo "message	Version check could not run because neither curl nor wget is available." >> "$VERSIONFILE"
    return
  fi

  local fetch_ok="false"
  if cmd_exists curl; then
    if curl --max-time "$VERSION_TIMEOUT" -fsSL "$VERSION_FEED_URL" -o "$VERSIONJSON" 2>/dev/null; then
      fetch_ok="true"
    fi
  elif cmd_exists wget; then
    if wget -q -T "$VERSION_TIMEOUT" "$VERSION_FEED_URL" -O "$VERSIONJSON" 2>/dev/null; then
      fetch_ok="true"
    fi
  fi

  if [ "$fetch_ok" != "true" ] || [ ! -s "$VERSIONJSON" ]; then
    echo "status	failed" >> "$VERSIONFILE"
    echo "message	Could not fetch Scantide version feed. Scan will continue." >> "$VERSIONFILE"
    return
  fi

  local remote_version released product family localcheck_url lifecycle_api_version lifecycle_api_url linux_url linux_sha state message critical breaking
  remote_version="$(json_get_field "$VERSIONJSON" linuxLocalCheckVersion)"; [ -z "$remote_version" ] && remote_version="$(json_get_field "$VERSIONJSON" version)"
  released="$(json_get_field "$VERSIONJSON" released)"
  product="$(json_get_field "$VERSIONJSON" product)"
  family="$(json_get_field "$VERSIONJSON" family)"
  localcheck_url="$(json_get_field "$VERSIONJSON" localCheckUrl)"
  lifecycle_api_version="$(json_get_field "$VERSIONJSON" serverLifecycleApiVersion)"
  lifecycle_api_url="$(json_get_field "$VERSIONJSON" lifecycleApiUrl)"
  critical="$(json_get_field "$VERSIONJSON" critical)"
  breaking="$(json_get_field "$VERSIONJSON" breaking)"

  # Future-proof optional Linux-specific fields. The current shared PowerShell feed may not contain these yet.
  linux_url="$(json_get_field "$VERSIONJSON" linuxLocalCheckUrl)"; [ -z "$linux_url" ] && linux_url="$(json_get_field "$VERSIONJSON" linuxScriptUrl)"
  linux_sha="$(json_get_field "$VERSIONJSON" sha256.linuxLocalCheck)"

  state="$(version_compare_state "$SCRIPT_VERSION" "$remote_version")"
  case "$state" in
    current) message="Local Linux script is aligned with the Scantide family feed version." ;;
    older) message="A newer Scantide family version is available. Use --update support when the feed exposes linuxLocalCheckUrl, or download the latest published Linux script manually." ;;
    newer) message="Local Linux script is newer than the public feed. This is normal for preview/test builds." ;;
    *) message="Could not compare local and remote versions." ;;
  esac

  {
    echo "status	$state"
    echo "message	$message"
    echo "remote_version	$remote_version"
    echo "released	$released"
    echo "product	$product"
    echo "family	$family"
    echo "critical	$critical"
    echo "breaking	$breaking"
    echo "localCheckUrl	$localcheck_url"
    echo "linuxLocalCheckUrl	$linux_url"
    echo "linuxLocalCheckSha256	$linux_sha"
    echo "serverLifecycleApiVersion	$lifecycle_api_version"
    echo "lifecycleApiUrl	$lifecycle_api_url"
  } >> "$VERSIONFILE"
}

version_value() {
  local key="$1"
  [ -s "$VERSIONFILE" ] || return
  awk -F '\t' -v k="$key" '$1==k{print $2; exit}' "$VERSIONFILE"
}

render_version_check_html() {
  local status cls msg
  status="${VERSION_FEED_STATUS:-Not checked}"
  msg="${VERSION_FEED_WARNING:-The installed Scantide Auditor Linux script matches the online Linux version feed.}"
  cls="source-warn"

  case "$status" in
    Current|"Newer than feed") cls="source-ok" ;;
    "Update available"|"Feed missing Linux version"|"Unavailable"|"Skipped:"*) cls="source-warn" ;;
    *) cls="source-warn" ;;
  esac

  echo "<table><tr><th>Item</th><th>Value</th></tr>"
  echo "<tr><th>status</th><td><span class='source-badge $cls'>$(printf '%s' "$status" | html_escape)</span></td></tr>"
  echo "<tr><th>message</th><td>$(printf '%s' "$msg" | html_escape)</td></tr>"
  echo "<tr><th>localLinuxScriptVersion</th><td>$(printf '%s' "$SCRIPT_VERSION" | html_escape)</td></tr>"
  echo "<tr><th>onlineLinuxScriptVersion</th><td>$(printf '%s' "${VERSION_FEED_REMOTE:-Unknown}" | html_escape)</td></tr>"
  echo "<tr><th>minimumRecommendedLinuxScriptVersion</th><td>$(printf '%s' "${VERSION_FEED_MINIMUM:-Unknown}" | html_escape)</td></tr>"
  echo "<tr><th>released</th><td>$(printf '%s' "${VERSION_FEED_RELEASED:-Unknown}" | html_escape)</td></tr>"
  echo "<tr><th>linuxLocalCheckUrl</th><td>$(printf '%s' "${VERSION_FEED_DOWNLOAD:-}" | html_escape)</td></tr>"
  echo "<tr><th>versionFeedUrl</th><td>$(printf '%s' "$VERSION_FEED_URL" | html_escape)</td></tr>"
  echo "</table>"
}

clean_version() {
  local text="$1"
  text="${text#*:}"
  text="${text%%-*}"
  text="${text%%+*}"
  text="${text%%~*}"

  if echo "$text" | grep -Eq '[0-9]+(\.[0-9]+){1,3}'; then
    echo "$text" | grep -Eo '[0-9]+(\.[0-9]+){1,3}' | head -1
  else
    echo "$text"
  fi
}

add_finding() {
  local severity="$1"
  local area="$2"
  local title="$3"
  local evidence="$4"
  local recommendation="$5"
  printf "%s\t%s\t%s\t%s\t%s\n" "$severity" "$area" "$title" "$evidence" "$recommendation" >> "$FINDINGS"
}

add_service() {
  local product="$1"
  local version="$2"
  local source="$3"

  version="$(clean_version "$version")"
  [ -z "$product" ] && return
  [ -z "$version" ] && return
  [ "$version" = "unknown" ] && return

  printf "%s\t%s\t%s\n" "$product" "$version" "$source" >> "$SERVICEFILE"
}

add_hardening_evidence() {
  local title="$1"
  local command_text="$2"
  local output_text="$3"
  {
    echo "### $title"
    echo "Command: $command_text"
    echo "$output_text"
    echo ""
  } >> "$HARDENING_RAW"
}


conf_value() {
  local file="$1" key="$2"
  [ -r "$file" ] || return
  awk -v k="$key" 'BEGIN{IGNORECASE=1} /^[[:space:]]*#/ || /^[[:space:]]*$/ {next} {line=$0; sub(/[[:space:]]+#.*$/, "", line); split(line,a,/=[[:space:]]*|[[:space:]]+/); if(tolower(a[1])==tolower(k)){val=line; sub(/^[[:space:]]*[^[:space:]=]+[[:space:]=]+/, "", val); gsub(/^[[:space:]]+|[[:space:]]+$/, "", val); found=val}} END{if(found!="") print found}' "$file"
}

add_tsv_row() {
  local file="$1"; shift; local first=1 v
  for v in "$@"; do [ "$first" -eq 0 ] && printf '\t' >> "$file"; first=0; printf '%s' "$v" >> "$file"; done
  printf '\n' >> "$file"
}

collect_accounts_privileges() {
  : > "$ACCOUNTS_RAW"; : > "$ACCOUNTS_TSV"
  printf "severity\tarea\titem\tevidence\trecommendation\n" >> "$ACCOUNTS_TSV"
  {
    echo "### UID 0 accounts"; awk -F: '$3==0 {print}' /etc/passwd 2>/dev/null || true; echo
    echo "### Interactive shell accounts"; awk -F: '$7 !~ /(nologin|false|sync|shutdown|halt)$/ {print $1 ":" $3 ":" $6 ":" $7}' /etc/passwd 2>/dev/null || true; echo
    echo "### sudo group"; getent group sudo 2>/dev/null || true; echo
    echo "### wheel group"; getent group wheel 2>/dev/null || true; echo
    echo "### root password status"; passwd -S root 2>/dev/null || true; echo
    echo "### sudoers NOPASSWD entries"; grep -RInE 'NOPASSWD|!authenticate' /etc/sudoers /etc/sudoers.d 2>/dev/null || true
  } >> "$ACCOUNTS_RAW"
  uid0_count="$(awk -F: '$3==0 {c++} END{print c+0}' /etc/passwd 2>/dev/null)"
  if [ "${uid0_count:-0}" -gt 1 ]; then ev="$(awk -F: '$3==0 {print $1}' /etc/passwd 2>/dev/null | paste -sd ', ' -)"; add_tsv_row "$ACCOUNTS_TSV" High Accounts "Multiple UID 0 accounts" "$ev" "Keep only root as UID 0 unless documented."; add_finding High Accounts "Multiple UID 0 accounts" "$ev" "Keep only root as UID 0 unless documented."; fi
  root_state="$(passwd -S root 2>/dev/null | awk '{print $2}' || true)"
  if [ "$root_state" = P ]; then add_tsv_row "$ACCOUNTS_TSV" Medium Accounts "Root account password appears enabled" "passwd -S root reports password set" "Prefer locked root password and sudo-based administration where practical."; add_finding Medium Accounts "Root account password appears enabled" "passwd -S root reports password set" "Prefer locked root password and sudo-based administration where practical."; fi
  interactive="$(awk -F: '$3<1000 && $1!="root" && $7 !~ /(nologin|false|sync|shutdown|halt)$/ {print $1 ":" $7}' /etc/passwd 2>/dev/null | head -20)"
  if [ -n "$interactive" ]; then add_tsv_row "$ACCOUNTS_TSV" Medium Accounts "System/service accounts with interactive shell" "$interactive" "Review whether service accounts need interactive shells."; add_finding Medium Accounts "System/service accounts with interactive shell" "$interactive" "Review whether service accounts need interactive shells."; fi
  nopasswd="$(grep -RInE 'NOPASSWD|!authenticate' /etc/sudoers /etc/sudoers.d 2>/dev/null | head -20 || true)"
  if [ -n "$nopasswd" ]; then add_tsv_row "$ACCOUNTS_TSV" Medium Sudo "Passwordless sudo entries found" "$nopasswd" "Review NOPASSWD sudo entries and restrict them to justified automation."; add_finding Medium Sudo "Passwordless sudo entries found" "$nopasswd" "Review NOPASSWD sudo entries and restrict them to justified automation."; fi
}

collect_sysctl_hardening() {
  : > "$SYSCTL_RAW"; : > "$SYSCTL_TSV"; printf "severity\tarea\tkey\tactual\texpected\trecommendation\n" >> "$SYSCTL_TSV"
  check_sysctl(){ local key="$1" expected="$2" sev="$3" rec="$4"; actual="$(sysctl -n "$key" 2>/dev/null || echo missing)"; printf "%s\t%s\t%s\n" "$key" "$actual" "$expected" >> "$SYSCTL_RAW"; if [ "$actual" != "$expected" ]; then add_tsv_row "$SYSCTL_TSV" "$sev" "Kernel/sysctl" "$key" "$actual" "$expected" "$rec"; add_finding "$sev" "Kernel/sysctl" "$key is not hardened" "Actual=$actual Expected=$expected" "$rec"; fi; }
  check_sysctl net.ipv4.tcp_syncookies 1 Medium "Enable TCP SYN cookies."
  check_sysctl net.ipv4.conf.all.accept_source_route 0 Medium "Disable source-routed packets."
  check_sysctl net.ipv4.conf.default.accept_source_route 0 Medium "Disable source-routed packets by default."
  check_sysctl net.ipv4.conf.all.accept_redirects 0 Medium "Disable ICMP redirects."
  check_sysctl net.ipv4.conf.default.accept_redirects 0 Medium "Disable ICMP redirects by default."
  check_sysctl net.ipv4.conf.all.send_redirects 0 Medium "Disable sending redirects."
  check_sysctl net.ipv4.conf.default.send_redirects 0 Medium "Disable sending redirects by default."
  check_sysctl net.ipv4.icmp_echo_ignore_broadcasts 1 Low "Ignore broadcast ICMP echo requests."
  check_sysctl kernel.randomize_va_space 2 Medium "Enable full ASLR."
  check_sysctl kernel.kptr_restrict 1 Low "Restrict kernel pointer exposure."
  check_sysctl kernel.dmesg_restrict 1 Low "Restrict dmesg access to privileged users."
  check_sysctl fs.suid_dumpable 0 Medium "Disable core dumps for SUID programs."
  check_sysctl kernel.sysrq 0 Low "Disable SysRq unless operationally required."
}

collect_filesystem_permissions() {
  : > "$FS_RAW"; : > "$FS_TSV"; printf "severity\tarea\titem\tevidence\trecommendation\n" >> "$FS_TSV"
  for p in /etc/passwd /etc/shadow /etc/ssh/sshd_config; do [ -e "$p" ] && echo "$p mode=$(stat -c '%a' "$p" 2>/dev/null) owner=$(stat -c '%U:%G' "$p" 2>/dev/null)" >> "$FS_RAW"; done
  if [ -e /etc/shadow ]; then mode="$(stat -c '%a' /etc/shadow 2>/dev/null)"; if [ "$mode" != 600 ] && [ "$mode" != 640 ]; then add_tsv_row "$FS_TSV" High "Filesystem permissions" "/etc/shadow permission review" "mode=$mode expected=600-or-640" "Restrict /etc/shadow to root/shadow access only."; add_finding High "Filesystem permissions" "/etc/shadow permission review" "mode=$mode expected=600-or-640" "Restrict /etc/shadow to root/shadow access only."; fi; fi
  if cmd_exists find; then
    ww_dirs="$(find /tmp /var/tmp /dev/shm -xdev -type d -perm -0002 ! -perm -1000 -print 2>/dev/null | head -50 || true)"; echo "### World-writable dirs missing sticky bit" >> "$FS_RAW"; echo "$ww_dirs" >> "$FS_RAW"
    if [ -n "$ww_dirs" ]; then add_tsv_row "$FS_TSV" High "Filesystem permissions" "World-writable directories without sticky bit" "$ww_dirs" "Set sticky bit on shared writable directories."; add_finding High "Filesystem permissions" "World-writable directories without sticky bit" "$ww_dirs" "Set sticky bit on shared writable directories."; fi
    if [ "$DEEP_SCAN" = true ]; then echo "### SUID/SGID binaries" >> "$FS_RAW"; find / -xdev \( -perm -4000 -o -perm -2000 \) -type f -print 2>/dev/null | head -200 >> "$FS_RAW" || true; echo "### World-writable files" >> "$FS_RAW"; ww_files="$(find / -xdev -type f -perm -0002 -print 2>/dev/null | head -200 || true)"; echo "$ww_files" >> "$FS_RAW"; if [ -n "$ww_files" ]; then add_tsv_row "$FS_TSV" Medium "Filesystem permissions" "World-writable files detected" "$(printf '%s' "$ww_files" | head -20)" "Review world-writable files; they are rarely required."; add_finding Medium "Filesystem permissions" "World-writable files detected" "$(printf '%s' "$ww_files" | head -20)" "Review world-writable files; they are rarely required."; fi; else echo "Deep filesystem scan skipped. Use --deep for SUID/SGID and world-writable file evidence." >> "$FS_RAW"; fi
  fi
  echo "### Mount options" >> "$FS_RAW"; findmnt -rn -o TARGET,OPTIONS /tmp /var/tmp /home /dev/shm 2>/dev/null >> "$FS_RAW" || true
}

collect_audit_logging() {
  : > "$AUDIT_RAW"; : > "$AUDIT_TSV"; printf "severity\tarea\titem\tevidence\trecommendation\n" >> "$AUDIT_TSV"
  svc_state(){ local svc="$1"; if cmd_exists systemctl; then printf '%s/%s' "$(systemctl is-enabled "$svc" 2>/dev/null)" "$(systemctl is-active "$svc" 2>/dev/null)"; else echo systemctl-unavailable; fi; }
  { echo "auditd: $(svc_state auditd)"; echo "rsyslog: $(svc_state rsyslog)"; echo "syslog-ng: $(svc_state syslog-ng)"; echo "journald storage:"; grep -RInE '^[[:space:]]*Storage=' /etc/systemd/journald.conf /etc/systemd/journald.conf.d 2>/dev/null || true; echo "remote logging hints:"; grep -RInE '@@?[A-Za-z0-9_.-]+|omfwd|target=' /etc/rsyslog.conf /etc/rsyslog.d /etc/syslog-ng 2>/dev/null | head -50 || true; echo "logrotate:"; command -v logrotate 2>/dev/null || true; echo "AIDE:"; command -v aide 2>/dev/null || true; } >> "$AUDIT_RAW"
  audit_state="$(svc_state auditd)"; if ! printf '%s' "$audit_state" | grep -q '/active'; then add_tsv_row "$AUDIT_TSV" Medium "Audit/logging" "auditd not active" "$audit_state" "Enable auditd where local audit trail requirements apply."; add_finding Medium "Audit/logging" "auditd not active" "$audit_state" "Enable auditd where local audit trail requirements apply."; fi
  if ! grep -RiqE '@@?[A-Za-z0-9_.-]+|omfwd|target=' /etc/rsyslog.conf /etc/rsyslog.d /etc/syslog-ng 2>/dev/null; then add_tsv_row "$AUDIT_TSV" Info "Audit/logging" "No obvious remote syslog forwarding detected" "No remote logging pattern found" "Consider remote log forwarding for important servers."; fi
  if ! cmd_exists aide; then add_tsv_row "$AUDIT_TSV" Info "Audit/logging" "AIDE not installed" "aide command not found" "Consider file integrity monitoring for sensitive servers."; fi
}

collect_lsm_status() {
  : > "$LSM_RAW"; : > "$LSM_TSV"; printf "severity\tarea\titem\tevidence\trecommendation\n" >> "$LSM_TSV"
  { echo "### AppArmor"; if cmd_exists aa-status; then aa-status 2>&1; else echo aa-status not found; fi; echo; echo "### SELinux"; if cmd_exists getenforce; then getenforce 2>&1; else echo getenforce not found; fi; cmd_exists sestatus && sestatus 2>&1 || true; } >> "$LSM_RAW"
  if [ "$DISTRO_ID" = ubuntu ] || printf '%s' "$DISTRO_FAMILY" | grep -qi debian; then if cmd_exists aa-status; then if ! aa-status 2>/dev/null | grep -qi 'profiles are in enforce mode'; then add_tsv_row "$LSM_TSV" Medium LSM "AppArmor not clearly enforcing profiles" "$(aa-status 2>&1 | head -20)" "Enable/enforce AppArmor profiles where available."; add_finding Medium LSM "AppArmor not clearly enforcing profiles" "$(aa-status 2>&1 | head -20)" "Enable/enforce AppArmor profiles where available."; fi; else add_tsv_row "$LSM_TSV" Medium LSM "AppArmor tooling not found" "aa-status unavailable" "Install/use AppArmor tooling where appropriate for this distro."; fi; fi
  if printf '%s %s' "$DISTRO_ID" "$DISTRO_FAMILY" | grep -Eqi 'rhel|fedora|centos|rocky|almalinux|suse'; then if cmd_exists getenforce; then sel="$(getenforce 2>/dev/null || true)"; if [ "$sel" != Enforcing ]; then add_tsv_row "$LSM_TSV" Medium LSM "SELinux is not enforcing" "$sel" "Use SELinux enforcing mode where supported."; add_finding Medium LSM "SELinux is not enforcing" "$sel" "Use SELinux enforcing mode where supported."; fi; fi; fi
}

collect_update_posture() {
  : > "$UPDATES_RAW"; : > "$UPDATES_TSV"; printf "severity\tarea\titem\tevidence\trecommendation\n" >> "$UPDATES_TSV"
  echo "### Reboot required" >> "$UPDATES_RAW"; [ -f /var/run/reboot-required ] && cat /var/run/reboot-required >> "$UPDATES_RAW" || echo "No /var/run/reboot-required file" >> "$UPDATES_RAW"
  if [ -f /var/run/reboot-required ]; then add_tsv_row "$UPDATES_TSV" Medium Updates "Reboot required" "/var/run/reboot-required exists" "Reboot during a maintenance window to activate pending kernel/library updates."; add_finding Medium Updates "Reboot required" "/var/run/reboot-required exists" "Reboot during a maintenance window to activate pending kernel/library updates."; fi
  case "$DISTRO_ID" in ubuntu|debian|linuxmint) if cmd_exists apt-get; then apt-get -s upgrade 2>/dev/null | tee -a "$UPDATES_RAW" | awk '/^Inst /{u++} /security|Security/{s++} END{print u+0 "\t" s+0}' > "$OUTDIR/update_counts_$TS.tmp"; read -r updates security < "$OUTDIR/update_counts_$TS.tmp" || true; if [ "${security:-0}" -gt 0 ]; then add_tsv_row "$UPDATES_TSV" High Updates "Pending security updates" "$security security-related packages, $updates total upgrades simulated" "Apply pending security updates."; add_finding High Updates "Pending security updates" "$security security-related packages, $updates total upgrades simulated" "Apply pending security updates."; elif [ "${updates:-0}" -gt 0 ]; then add_tsv_row "$UPDATES_TSV" Medium Updates "Pending package updates" "$updates upgrades simulated" "Review and apply pending updates."; fi; fi; cmd_exists pro && pro security-status 2>&1 | head -80 >> "$UPDATES_RAW" || true;; fedora|rhel|centos|rocky|almalinux|ol) cmd_exists dnf && dnf -q check-update 2>&1 | head -200 >> "$UPDATES_RAW" || true; cmd_exists yum && yum -q check-update 2>&1 | head -200 >> "$UPDATES_RAW" || true;; sles|suse|opensuse*) cmd_exists zypper && zypper -q list-updates 2>&1 | head -200 >> "$UPDATES_RAW" || true;; alpine) cmd_exists apk && apk version -l '<' 2>&1 | head -200 >> "$UPDATES_RAW" || true;; esac
}


collect_update_summary() {
  : > "$UPDATE_SUMMARY_TSV"
  printf "metric\tvalue\tevidence\n" >> "$UPDATE_SUMMARY_TSV"

  total_updates="unknown"
  security_updates="unknown"
  reboot_required="No"
  source="unknown"

  [ -f /var/run/reboot-required ] && reboot_required="Yes"

  case "$DISTRO_ID" in
    ubuntu|debian|linuxmint)
      if [ -x /usr/lib/update-notifier/apt-check ]; then
        motd_counts="$(/usr/lib/update-notifier/apt-check 2>/dev/null || true)"
        if printf '%s' "$motd_counts" | grep -Eq '^[0-9]+;[0-9]+'; then
          total_updates="$(printf '%s' "$motd_counts" | cut -d';' -f1)"
          security_updates="$(printf '%s' "$motd_counts" | cut -d';' -f2)"
          source="/usr/lib/update-notifier/apt-check"
        fi
      fi

      if [ "$total_updates" = "unknown" ] && cmd_exists apt-get; then
        sim="$(apt-get -s upgrade 2>/dev/null || true)"
        total_updates="$(printf '%s\n' "$sim" | awk '/^Inst /{c++} END{print c+0}')"
        security_updates="$(printf '%s\n' "$sim" | awk '/^Inst / && ($0 ~ /security|Security|Ubuntu-Security|ubuntu-security|Debian-Security|debian-security/){c++} END{print c+0}')"
        source="apt-get -s upgrade fallback"
      fi

      if [ "$security_updates" = "0" ] && cmd_exists apt; then
        sec_alt="$(apt list --upgradable 2>/dev/null | grep -Ei 'security|ubuntu-security|debian-security' | wc -l | tr -d ' ')"
        [ "${sec_alt:-0}" -gt 0 ] && security_updates="$sec_alt" && source="$source + apt list security pattern"
      fi
      ;;
    fedora|rhel|centos|rocky|almalinux|ol)
      if cmd_exists dnf; then
        total_updates="$(dnf -q check-update 2>/dev/null | awk 'NF>=3 && $1 !~ /^(Last|Obsoleting|Security:)/ {c++} END{print c+0}')"
        security_updates="$(dnf -q updateinfo list security 2>/dev/null | awk 'NF>0 && $1 !~ /^(Last|Security:)/ {c++} END{print c+0}')"
        source="dnf check-update + dnf updateinfo list security"
      elif cmd_exists yum; then
        total_updates="$(yum -q check-update 2>/dev/null | awk 'NF>=3 && $1 !~ /^(Loaded|Obsoleting)/ {c++} END{print c+0}')"
        security_updates="$(yum -q updateinfo list security 2>/dev/null | awk 'NF>0 {c++} END{print c+0}')"
        source="yum check-update + yum updateinfo list security"
      fi
      ;;
    sles|suse|opensuse*)
      if cmd_exists zypper; then
        total_updates="$(zypper -q list-updates 2>/dev/null | awk -F'|' 'NF>3 && $1 !~ /Repository|^[-+]/ {c++} END{print c+0}')"
        security_updates="$(zypper -q list-patches --category security 2>/dev/null | awk -F'|' 'NF>3 && $1 !~ /Repository|^[-+]/ {c++} END{print c+0}')"
        source="zypper list-updates + zypper list-patches --category security"
      fi
      ;;
    alpine)
      if cmd_exists apk; then
        total_updates="$(apk version -l '<' 2>/dev/null | awk 'NF>0{c++} END{print c+0}')"
        security_updates="unknown"
        source="apk version -l <"
      fi
      ;;
  esac

  printf "available_updates\t%s\t%s\n" "$total_updates" "$source" >> "$UPDATE_SUMMARY_TSV"
  printf "security_updates\t%s\t%s\n" "$security_updates" "$source" >> "$UPDATE_SUMMARY_TSV"
  printf "reboot_required\t%s\t/var/run/reboot-required\n" "$reboot_required" >> "$UPDATE_SUMMARY_TSV"

  if [ "$total_updates" != "unknown" ] && [ "$total_updates" -gt 0 ] 2>/dev/null; then
    add_finding "Medium" "Updates" "Available package updates detected" "$total_updates available updates" "Review and apply available package updates."
  fi
  if [ "$security_updates" != "unknown" ] && [ "$security_updates" -gt 0 ] 2>/dev/null; then
    add_finding "High" "Updates" "Available security updates detected" "$security_updates security updates" "Apply security updates promptly."
  fi
}

update_summary_value() {
  local key="$1"
  [ -s "$UPDATE_SUMMARY_TSV" ] && awk -F '\t' -v k="$key" '$1==k{print $2; exit}' "$UPDATE_SUMMARY_TSV"
}


refresh_update_summary_vars() {
  AVAILABLE_UPDATES="$(update_summary_value available_updates 2>/dev/null || true)"
  SECURITY_UPDATES="$(update_summary_value security_updates 2>/dev/null || true)"
  REBOOT_REQUIRED_SUMMARY="$(update_summary_value reboot_required 2>/dev/null || true)"

  [ -z "${AVAILABLE_UPDATES:-}" ] && AVAILABLE_UPDATES="unknown"
  [ -z "${SECURITY_UPDATES:-}" ] && SECURITY_UPDATES="unknown"
  [ -z "${REBOOT_REQUIRED_SUMMARY:-}" ] && REBOOT_REQUIRED_SUMMARY="unknown"
}




remediation_id_for_finding() {
  local title="$1"
  local area="$2"
  local t
  t="$(printf '%s %s' "$area" "$title" | tr '[:upper:]' '[:lower:]')"

  case "$t" in
    *"ssh root login"*) echo "SSH_ROOT_LOGIN" ;;
    *"ssh password authentication"*) echo "SSH_PASSWORD_AUTH" ;;
    *"empty passwords"*) echo "SSH_EMPTY_PASSWORDS" ;;
    *"x11 forwarding"*) echo "SSH_X11_FORWARDING" ;;
    *"tcp forwarding"*|*"agent forwarding"*) echo "SSH_FORWARDING" ;;
    *"maxauthtries"*) echo "SSH_MAXAUTHTRIES" ;;
    *"idle timeout"*|*"clientalive"*) echo "SSH_IDLE_TIMEOUT" ;;
    *"allowusers"*|*"allowgroups"*) echo "SSH_ALLOWUSERS" ;;
    *"weak ssh crypto"*) echo "SSH_WEAK_CRYPTO" ;;
    *"ufw is inactive"*) echo "FIREWALL_UFW_INACTIVE" ;;
    *"iptables input policy is accept"*) echo "FIREWALL_INPUT_ACCEPT" ;;
    *"redis protected"*) echo "REDIS_PROTECTED_MODE" ;;
    *"redis authentication"*) echo "REDIS_AUTH" ;;
    *"multiple uid 0"*) echo "ACCOUNTS_UID0_MULTIPLE" ;;
    *"root account password"*) echo "ACCOUNTS_ROOT_PASSWORD_ENABLED" ;;
    *"service accounts with interactive shell"*|*"system/service accounts"*) echo "ACCOUNTS_SERVICE_SHELL" ;;
    *"passwordless sudo"*|*"nopasswd"*) echo "SUDO_NOPASSWD" ;;
    *"sudo logging"*) echo "SUDO_LOGGING" ;;
    *"tcp_syncookies"*) echo "SYSCTL_TCP_SYNCOOKIES" ;;
    *"accept_source_route"*) echo "SYSCTL_SOURCE_ROUTE" ;;
    *"accept_redirects"*|*"send_redirects"*) echo "SYSCTL_REDIRECTS" ;;
    *"rp_filter"*) echo "SYSCTL_RPFILTER" ;;
    *"randomize_va_space"*|*"aslr"*) echo "SYSCTL_ASLR" ;;
    *"dmesg_restrict"*) echo "SYSCTL_DMESG" ;;
    *"kptr_restrict"*) echo "SYSCTL_KPTR" ;;
    *"sysrq"*) echo "SYSCTL_SYSRQ" ;;
    *"/etc/passwd"*) echo "FS_PASSWD_PERMS" ;;
    *"/etc/shadow"*) echo "FS_SHADOW_PERMS" ;;
    *"sshd_config permission"*) echo "FS_SSHD_CONFIG_PERMS" ;;
    *"world-writable directories"*) echo "FS_WORLD_WRITABLE_DIR" ;;
    *"world-writable files"*) echo "FS_WORLD_WRITABLE_FILE" ;;
    *"mount option"*|*"/tmp noexec"*|*"dev/shm"*) echo "MOUNT_TMP_OPTIONS" ;;
    *"auditd"*) echo "AUDITD_INACTIVE" ;;
    *"remote syslog"*|*"remote log"*) echo "LOGGING_REMOTE" ;;
    *"aide"*) echo "AIDE_MISSING" ;;
    *"apparmor"*) echo "APPARMOR_NOT_ENFORCING" ;;
    *"selinux"*) echo "SELINUX_NOT_ENFORCING" ;;
    *"security updates"*) echo "SECURITY_UPDATES_AVAILABLE" ;;
    *"package updates"*|*"available updates"*) echo "UPDATES_AVAILABLE" ;;
    *"reboot required"*) echo "REBOOT_REQUIRED" ;;
    *"directory listing"*) echo "WEB_DIRECTORY_LISTING" ;;
    *"website is not clearly bound to ssl/tls"*) echo "WEB_SSL_MISSING" ;;
    *"ssl site has no certificate path"*) echo "WEB_CERT_MISSING" ;;
    *"ssl certificate is expired"*) echo "WEB_CERT_EXPIRED" ;;
    *"ssl certificate expires soon"*) echo "WEB_CERT_EXPIRING" ;;
    *"document root is world-writable"*) echo "WEB_DOCROOT_WORLD_WRITABLE" ;;
    *"document root is group-writable"*) echo "WEB_DOCROOT_GROUP_WRITABLE" ;;
    *"servertokens"*|*"serversignature"*) echo "WEB_APACHE_TOKENS" ;;
    *"server_tokens"*) echo "WEB_NGINX_TOKENS" ;;
    *"php-fpm"*|*"security.limit_extensions"*) echo "WEB_PHPFPM_EXTENSIONS" ;;
    *"all interfaces"*) echo "EXPOSURE_ALL_INTERFACES" ;;
    *"possible secrets"*|*"credential exposure"*) echo "SECRET_PATTERN" ;;


    *"secure boot is not enabled"*) echo "BOOT_SECUREBOOT_DISABLED" ;;
    *"grub password not detected"*) echo "BOOT_GRUB_PASSWORD" ;;
    *"kernel lockdown mode is not active"*) echo "KERNEL_LOCKDOWN" ;;
    *"kernel is tainted"*) echo "KERNEL_TAINTED" ;;
    *"unsigned or unverified kernel module"*) echo "KERNEL_UNSIGNED_MODULE" ;;
    *"read-only mount detected"*) echo "STORAGE_READONLY_MOUNT" ;;
    *"filesystem or disk error evidence"*) echo "STORAGE_FS_ERRORS" ;;
    *"software raid may be degraded"*) echo "STORAGE_RAID_DEGRADED" ;;
    *"zfs pool issue detected"*) echo "STORAGE_ZPOOL_DEGRADED" ;;
    *"multiple default routes"*) echo "NETWORK_DEFAULT_ROUTES" ;;
    *"resolver configuration missing"*) echo "NETWORK_DNS_RESOLVER" ;;
    *"ipv4 forwarding enabled"*) echo "NETWORK_IP_FORWARDING" ;;
    *"telnet listener detected"*|*"rlogin listener detected"*|*"rexec listener detected"*|*"tftp listener detected"*|*"finger listener detected"*|*"rpcbind listener detected"*|*"cups listener detected"*|*"avahi"*) echo "DANGEROUS_SERVICE" ;;
    *"docker socket has broad write permissions"*) echo "CONTAINER_DOCKER_SOCKET" ;;
    *"privileged container running"*) echo "CONTAINER_PRIVILEGED" ;;
    *"container uses host networking"*) echo "CONTAINER_HOST_NETWORK" ;;
    *"container may run as root"*) echo "CONTAINER_ROOT_USER" ;;
    *"container bind-mounts host root"*) echo "CONTAINER_HOST_ROOT_MOUNT" ;;
    *"kubelet"*|*"kubernetes config file permissions"*) echo "KUBERNETES_REVIEW" ;;
    *"database/search service listens on all interfaces"*) echo "DATABASE_EXPOSURE" ;;
    *"smtp tls not clearly enforced"*|*"possible open relay"*) echo "MAIL_SECURITY_REVIEW" ;;
    *"php expose_php is enabled"*|*"php disable_functions is empty"*|*"hsts header not detected"*|*"x-frame-options header not detected"*|*"csp header not detected"*) echo "WEB_APP_SECURITY_HEADERS" ;;
    *"cron file is writable"*|*"cron job references temporary path"*) echo "CRON_SECURITY" ;;
    *"enabled services not currently running"*) echo "STARTUP_SERVICE_REVIEW" ;;
    *"recent failed login records"*) echo "LOGIN_FAILURE_REVIEW" ;;
    *"local certificate file is expired"*|*"local certificate file expires soon"*) echo "LOCAL_CERTIFICATE_REVIEW" ;;
    *"load average is high"*|*"swap usage is high"*|*"oom killer evidence"*) echo "PERFORMANCE_HEALTH" ;;
    *"systemd failed units"*) echo "SYSTEMD_FAILED_UNITS" ;;
    *"ntp is not synchronized"*|*"time offset appears high"*) echo "NTP_TIME_SYNC" ;;
    *"filesystem almost full"*|*"filesystem free space is low"*) echo "DISK_FREE_SPACE" ;;
    *"inode usage"*) echo "DISK_INODE_USAGE" ;;
    *"unattended-upgrades"*|*"automatic security patching"*) echo "AUTO_PATCHING" ;;
    *"weak tls protocol"*|*"modern tls protocol"*) echo "TLS_PROTOCOLS" ;;
    *"local https certificate"*|*"certificate expires soon"*|*"certificate is expired"*) echo "TLS_CERTIFICATE_EXPIRY" ;;
    *"cockpit appears exposed"*|*"webmin appears exposed"*|*"portainer appears exposed"*|*"proxmox"*|*"grafana"*|*"kibana"*|*"admin panel"*) echo "ADMIN_PANEL_EXPOSURE" ;;
    *"backup"*) echo "BACKUP_MISSING" ;;
    *"vmware tools"*) echo "VMWARE_TOOLS_UPDATE" ;;
    *"cve"*) echo "CVE_REVIEW" ;;
    *"lifecycle"*) echo "LIFECYCLE_REVIEW" ;;
    *) echo "" ;;
  esac
}

build_remediation_payload() {
  : > "$REMEDIATION_PAYLOAD"

  if ! cmd_exists python3; then
    {
      printf '{'
      printf '"api_key":"%s",' "$(json_escape "$SCANTIDE_API_KEY")"
      printf '"email":"%s",' "$(json_escape "$SCANTIDE_EMAIL")"
      printf '"platform":"linux",'
      printf '"scanner_version":"%s",' "$(json_escape "$SCRIPT_VERSION")"
      printf '"source":"linux-local",'
      printf '"os":"%s",' "$(json_escape "$DISTRO_NAME")"
      printf '"host":"%s",' "$(json_escape "$HOST")"
      printf '"finding_ids":['
      first=1
      if [ -s "$FINDINGS" ]; then
        while IFS="$(printf '\t')" read -r severity area title evidence recommendation; do
          rid="$(remediation_id_for_finding "$title" "$area")"
          [ -z "$rid" ] && continue
          [ "$first" -eq 0 ] && printf ','
          first=0
          printf '"%s"' "$(json_escape "$rid")"
        done < "$FINDINGS"
      fi
      printf ']}'
    } > "$REMEDIATION_PAYLOAD"
    return
  fi

  python3 - "$FINDINGS" "$REMEDIATION_PAYLOAD" "$SCANTIDE_API_KEY" "$SCANTIDE_EMAIL" "$SCRIPT_VERSION" "$DISTRO_NAME" "$HOST" <<'PY_REMED_PAYLOAD'
import json, sys, re
findings, out, api_key, email, scanner_version, os_name, host = sys.argv[1:8]

def rid_for(title, area):
    t=(area+" "+title).lower()
    rules=[
      ("ssh root login","SSH_ROOT_LOGIN"), ("ssh password authentication","SSH_PASSWORD_AUTH"),
      ("empty passwords","SSH_EMPTY_PASSWORDS"), ("x11 forwarding","SSH_X11_FORWARDING"),
      ("tcp forwarding","SSH_FORWARDING"), ("agent forwarding","SSH_FORWARDING"),
      ("maxauthtries","SSH_MAXAUTHTRIES"), ("idle timeout","SSH_IDLE_TIMEOUT"), ("clientalive","SSH_IDLE_TIMEOUT"),
      ("allowusers","SSH_ALLOWUSERS"), ("allowgroups","SSH_ALLOWUSERS"), ("weak ssh crypto","SSH_WEAK_CRYPTO"),
      ("ufw is inactive","FIREWALL_UFW_INACTIVE"), ("iptables input policy is accept","FIREWALL_INPUT_ACCEPT"),
      ("redis protected","REDIS_PROTECTED_MODE"), ("redis authentication","REDIS_AUTH"),
      ("multiple uid 0","ACCOUNTS_UID0_MULTIPLE"), ("root account password","ACCOUNTS_ROOT_PASSWORD_ENABLED"),
      ("service accounts with interactive shell","ACCOUNTS_SERVICE_SHELL"), ("system/service accounts","ACCOUNTS_SERVICE_SHELL"),
      ("passwordless sudo","SUDO_NOPASSWD"), ("nopasswd","SUDO_NOPASSWD"), ("sudo logging","SUDO_LOGGING"),
      ("tcp_syncookies","SYSCTL_TCP_SYNCOOKIES"), ("accept_source_route","SYSCTL_SOURCE_ROUTE"),
      ("accept_redirects","SYSCTL_REDIRECTS"), ("send_redirects","SYSCTL_REDIRECTS"), ("rp_filter","SYSCTL_RPFILTER"),
      ("randomize_va_space","SYSCTL_ASLR"), ("aslr","SYSCTL_ASLR"), ("dmesg_restrict","SYSCTL_DMESG"),
      ("kptr_restrict","SYSCTL_KPTR"), ("sysrq","SYSCTL_SYSRQ"), ("/etc/passwd","FS_PASSWD_PERMS"),
      ("/etc/shadow","FS_SHADOW_PERMS"), ("sshd_config permission","FS_SSHD_CONFIG_PERMS"),
      ("world-writable directories","FS_WORLD_WRITABLE_DIR"), ("world-writable files","FS_WORLD_WRITABLE_FILE"),
      ("mount option","MOUNT_TMP_OPTIONS"), ("/tmp noexec","MOUNT_TMP_OPTIONS"), ("dev/shm","MOUNT_TMP_OPTIONS"),
      ("auditd","AUDITD_INACTIVE"), ("remote syslog","LOGGING_REMOTE"), ("remote log","LOGGING_REMOTE"),
      ("aide","AIDE_MISSING"), ("apparmor","APPARMOR_NOT_ENFORCING"), ("selinux","SELINUX_NOT_ENFORCING"),
      ("security updates","SECURITY_UPDATES_AVAILABLE"), ("package updates","UPDATES_AVAILABLE"), ("available updates","UPDATES_AVAILABLE"),
      ("reboot required","REBOOT_REQUIRED"), ("directory listing","WEB_DIRECTORY_LISTING"),
      ("servertokens","WEB_APACHE_TOKENS"), ("serversignature","WEB_APACHE_TOKENS"), ("server_tokens","WEB_NGINX_TOKENS"),
      ("php-fpm","WEB_PHPFPM_EXTENSIONS"), ("security.limit_extensions","WEB_PHPFPM_EXTENSIONS"),
      ("all interfaces","EXPOSURE_ALL_INTERFACES"), ("possible secrets","SECRET_PATTERN"), ("credential exposure","SECRET_PATTERN"),
      ("backup","BACKUP_MISSING"), ("vmware tools","VMWARE_TOOLS_UPDATE"), ("cve","CVE_REVIEW"), ("lifecycle","LIFECYCLE_REVIEW"),
    ]
    for needle, rid in rules:
        if needle in t:
            return rid
    return ""

ids=[]
finding_context=[]
try:
    with open(findings, encoding="utf-8", errors="ignore") as f:
        for line in f:
            parts=line.rstrip("\n").split("\t")
            if len(parts) < 5:
                continue
            severity, area, title, evidence, recommendation = parts[:5]
            rid=rid_for(title, area)
            if rid and rid not in ids:
                ids.append(rid)
            finding_context.append({"id": rid, "severity": severity, "area": area, "title": title})
except FileNotFoundError:
    pass

payload={
    "api_key": api_key,
    "email": email,
    "platform": "linux",
    "scanner_version": scanner_version,
    "source": "linux-local",
    "os": os_name,
    "host": host,
    "finding_ids": ids,
    "findings": finding_context[:200]
}
open(out, "w", encoding="utf-8").write(json.dumps(payload, ensure_ascii=False))
PY_REMED_PAYLOAD
}

fetch_linux_remediation_catalog() {
  if ! has_scantide_credentials; then
    : > "$REMEDIATION_STATUS"
    echo "url	$SCANTIDE_REMEDIATION_API" >> "$REMEDIATION_STATUS"
    echo "status	auth_missing" >> "$REMEDIATION_STATUS"
    echo "message	API key/email not supplied. Remediation API check was not called." >> "$REMEDIATION_STATUS"
    stage_skip "Remediation API skipped - API key/email not supplied."
    return 0
  fi
  : > "$REMEDIATION_STATUS"
  echo "url	$SCANTIDE_REMEDIATION_API" >> "$REMEDIATION_STATUS"

  if [ -z "${SCANTIDE_API_KEY:-}" ] || [ -z "${SCANTIDE_EMAIL:-}" ]; then
    echo "status	auth_missing" >> "$REMEDIATION_STATUS"
    echo "message	API key/email not supplied. Remediation API check was not called." >> "$REMEDIATION_STATUS"
    return
  fi

  if ! cmd_exists curl; then
    echo "status	unavailable" >> "$REMEDIATION_STATUS"
    echo "message	curl unavailable; remediation API not called." >> "$REMEDIATION_STATUS"
    return
  fi

  build_remediation_payload

  REMEDIATION_HTTP_STATUS="$(curl --http1.1 --max-time "$REMEDIATION_TIMEOUT" -sS \
    -o "$REMEDIATION_JSON" \
    -w "%{http_code}" \
    -X POST "$SCANTIDE_REMEDIATION_API" \
    -H "Content-Type: application/json; charset=utf-8" \
    -H "X-API-Key: $SCANTIDE_API_KEY" \
    -H "X-Scantide-Email: $SCANTIDE_EMAIL" \
    -H "User-Agent: ScantideLinuxLocalCheck/0.57-Bash" \
    --data-binary @"$REMEDIATION_PAYLOAD" \
    2>"$OUTDIR/remediation_curl_error_$TS.txt")"

  echo "http_status	$REMEDIATION_HTTP_STATUS" >> "$REMEDIATION_STATUS"

  if [ "$REMEDIATION_HTTP_STATUS" = "200" ] && [ -s "$REMEDIATION_JSON" ]; then
    echo "status	ok" >> "$REMEDIATION_STATUS"
    echo "message	Authenticated remediation guidance loaded." >> "$REMEDIATION_STATUS"
  elif [ "$REMEDIATION_HTTP_STATUS" = "401" ] || [ "$REMEDIATION_HTTP_STATUS" = "403" ]; then
    echo "status	auth_failed" >> "$REMEDIATION_STATUS"
    echo "message	Remediation API authentication failed. Guidance is unavailable." >> "$REMEDIATION_STATUS"
  else
    echo "status	failed" >> "$REMEDIATION_STATUS"
    echo "message	Remediation API did not return guidance. Basic recommendations remain in the report." >> "$REMEDIATION_STATUS"
  fi
}

render_remediation_for_finding() {
  local title="$1"
  local area="$2"

  if [ ! -s "$REMEDIATION_JSON" ] || ! cmd_exists python3; then
    return
  fi

  python3 - "$REMEDIATION_JSON" "$title" "$area" "$DISTRO_ID" "$DISTRO_FAMILY" <<'PY_REMEDIATION'
import json, sys, html, re
path, title, area, distro_id, distro_family = sys.argv[1:6]

def rid_for(title, area):
    t=(area+" "+title).lower()
    rules=[
      ("ssh root login","SSH_ROOT_LOGIN"), ("ssh password authentication","SSH_PASSWORD_AUTH"), ("empty passwords","SSH_EMPTY_PASSWORDS"),
      ("x11 forwarding","SSH_X11_FORWARDING"), ("tcp forwarding","SSH_FORWARDING"), ("agent forwarding","SSH_FORWARDING"),
      ("maxauthtries","SSH_MAXAUTHTRIES"), ("idle timeout","SSH_IDLE_TIMEOUT"), ("clientalive","SSH_IDLE_TIMEOUT"),
      ("allowusers","SSH_ALLOWUSERS"), ("allowgroups","SSH_ALLOWUSERS"), ("weak ssh crypto","SSH_WEAK_CRYPTO"),
      ("ufw is inactive","FIREWALL_UFW_INACTIVE"), ("iptables input policy is accept","FIREWALL_INPUT_ACCEPT"),
      ("redis protected","REDIS_PROTECTED_MODE"), ("redis authentication","REDIS_AUTH"), ("multiple uid 0","ACCOUNTS_UID0_MULTIPLE"),
      ("root account password","ACCOUNTS_ROOT_PASSWORD_ENABLED"), ("service accounts with interactive shell","ACCOUNTS_SERVICE_SHELL"),
      ("system/service accounts","ACCOUNTS_SERVICE_SHELL"), ("passwordless sudo","SUDO_NOPASSWD"), ("nopasswd","SUDO_NOPASSWD"),
      ("sudo logging","SUDO_LOGGING"), ("tcp_syncookies","SYSCTL_TCP_SYNCOOKIES"), ("accept_source_route","SYSCTL_SOURCE_ROUTE"),
      ("accept_redirects","SYSCTL_REDIRECTS"), ("send_redirects","SYSCTL_REDIRECTS"), ("rp_filter","SYSCTL_RPFILTER"),
      ("randomize_va_space","SYSCTL_ASLR"), ("aslr","SYSCTL_ASLR"), ("dmesg_restrict","SYSCTL_DMESG"), ("kptr_restrict","SYSCTL_KPTR"),
      ("sysrq","SYSCTL_SYSRQ"), ("/etc/passwd","FS_PASSWD_PERMS"), ("/etc/shadow","FS_SHADOW_PERMS"),
      ("sshd_config permission","FS_SSHD_CONFIG_PERMS"), ("world-writable directories","FS_WORLD_WRITABLE_DIR"),
      ("world-writable files","FS_WORLD_WRITABLE_FILE"), ("mount option","MOUNT_TMP_OPTIONS"), ("/tmp noexec","MOUNT_TMP_OPTIONS"),
      ("dev/shm","MOUNT_TMP_OPTIONS"), ("auditd","AUDITD_INACTIVE"), ("remote syslog","LOGGING_REMOTE"), ("remote log","LOGGING_REMOTE"),
      ("aide","AIDE_MISSING"), ("apparmor","APPARMOR_NOT_ENFORCING"), ("selinux","SELINUX_NOT_ENFORCING"),
      ("security updates","SECURITY_UPDATES_AVAILABLE"), ("package updates","UPDATES_AVAILABLE"), ("available updates","UPDATES_AVAILABLE"),
      ("reboot required","REBOOT_REQUIRED"), ("directory listing","WEB_DIRECTORY_LISTING"), ("servertokens","WEB_APACHE_TOKENS"),
      ("serversignature","WEB_APACHE_TOKENS"), ("server_tokens","WEB_NGINX_TOKENS"), ("php-fpm","WEB_PHPFPM_EXTENSIONS"),
      ("security.limit_extensions","WEB_PHPFPM_EXTENSIONS"), ("all interfaces","EXPOSURE_ALL_INTERFACES"),
      ("possible secrets","SECRET_PATTERN"), ("credential exposure","SECRET_PATTERN"), ("backup","BACKUP_MISSING"),
      ("vmware tools","VMWARE_TOOLS_UPDATE"), ("cve","CVE_REVIEW"), ("lifecycle","LIFECYCLE_REVIEW"),
    ]
    for needle, rid in rules:
        if needle in t:
            return rid
    return ""

try:
    data=json.load(open(path, encoding="utf-8"))
except Exception:
    sys.exit(0)

rid = rid_for(title, area)
if not rid:
    sys.exit(0)

items = data.get("items")
if items is None and isinstance(data.get("data"), dict):
    items = data["data"].get("items")
if items is None:
    items = data.get("remediations")

rec = None
if isinstance(items, dict):
    rec = items.get(rid)
elif isinstance(items, list):
    for x in items:
        if isinstance(x, dict) and (x.get("id") == rid or x.get("recommendation_id") == rid):
            rec = x
            break
if not rec:
    sys.exit(0)

def esc(x): return html.escape("" if x is None else str(x))
def commands_for_distro(cmds):
    if not isinstance(cmds, dict): return []
    fam=(distro_id+" "+distro_family).lower()
    keys=[]
    if any(x in fam for x in ["ubuntu","debian","linuxmint"]): keys += ["debian_ubuntu","ubuntu","debian"]
    if any(x in fam for x in ["rhel","rocky","almalinux","fedora","centos","ol"]): keys += ["rhel_rocky_alma_fedora","rhel","fedora"]
    if any(x in fam for x in ["suse","opensuse","sles"]): keys += ["suse"]
    if "alpine" in fam: keys += ["alpine"]
    keys += ["generic"]
    for k in keys:
        if k in cmds and isinstance(cmds[k], list):
            return cmds[k]
    return []

diff=esc(rec.get("difficulty",""))
mins=esc(rec.get("estimatedMinutes", rec.get("estimated_minutes","")))
restart=esc(rec.get("restartRequired", rec.get("restart_required","")))
why=esc(rec.get("why",""))
recommendation=esc(rec.get("recommendation",""))
commands=commands_for_distro(rec.get("commands",{}))
verify=rec.get("verify",[])
refs=rec.get("references",[])

print("<details class='remediation'><summary>Practical remediation guidance</summary>")
print("<table>")
print(f"<tr><th>Recommendation ID</th><td>{esc(rid)}</td><th>Difficulty</th><td>{diff}</td></tr>")
print(f"<tr><th>Estimated time</th><td>{mins} minutes</td><th>Restart</th><td>{restart}</td></tr>")
if why:
    print(f"<tr><th>Why it matters</th><td colspan='3'>{why}</td></tr>")
if recommendation:
    print(f"<tr><th>Recommended fix</th><td colspan='3'>{recommendation}</td></tr>")
print("</table>")
if commands:
    print("<b>Suggested commands to review:</b>")
    print("<pre>"+esc("\n".join(map(str, commands)))+"</pre>")
if verify:
    print("<b>Verify:</b>")
    print("<pre>"+esc("\n".join(map(str, verify)))+"</pre>")
if refs:
    print("<b>References:</b><br>")
    for r in refs[:4]:
        print(f"<span class='muted'>{esc(r)}</span><br>")
print("</details>")
PY_REMEDIATION
}


render_findings_cards_html() {
  if [ ! -s "$FINDINGS" ]; then
    echo "<div class='scope-ok'><b>No local findings generated by the current rule set.</b></div>"
    return
  fi

  echo "<div class='button-row'>"
  echo "<button class='filter-chip' onclick='filterText(\"High\")'>High</button>"
  echo "<button class='filter-chip' onclick='filterText(\"Medium\")'>Medium</button>"
  echo "<button class='filter-chip' onclick='filterText(\"SSH\")'>SSH</button>"
  echo "<button class='filter-chip' onclick='filterText(\"Redis\")'>Redis</button>"
  echo "<button class='filter-chip' onclick='filterText(\"Kernel/sysctl\")'>Kernel/sysctl</button>"
  echo "<button class='filter-chip' onclick='filterText(\"Updates\")'>Updates</button>"
  echo "</div>"

  echo "<div class='finding-grid'>"
  while IFS="$(printf '\t')" read -r severity area title evidence recommendation rest; do
    [ -z "${title:-}" ] && continue

    sevclass="sev-info"
    case "$severity" in
      Critical) sevclass="sev-critical" ;;
      High) sevclass="sev-high" ;;
      Medium) sevclass="sev-medium" ;;
      Low) sevclass="sev-low" ;;
      Info) sevclass="sev-info" ;;
    esac

    rid="$(remediation_id_for_finding "$title" "$area")"
    remediation_html="$(render_remediation_for_finding "$title" "$area")"

    echo "<article class='finding-card' data-severity='$(printf '%s' "$severity" | html_escape)' data-area='$(printf '%s' "$area" | html_escape)'>"
    echo "<div class='finding-head'>"
    echo "<div>"
    echo "<div class='finding-title'>$(printf '%s' "$title" | html_escape)</div>"
    echo "<div class='finding-meta'><span class='sev $sevclass'>$(printf '%s' "$severity" | html_escape)</span><span class='meta-pill'>$(printf '%s' "$area" | html_escape)</span>"
    if [ -n "$rid" ]; then
      echo "<span class='meta-pill'>Remediation ID: $(printf '%s' "$rid" | html_escape)</span>"
    else
      echo "<span class='meta-pill'>No remediation ID mapped</span>"
    fi
    echo "</div></div>"
    echo "</div>"
    echo "<div class='finding-body'>"
    echo "<div class='finding-cols'>"
    echo "<div class='finding-box'><h4>Evidence</h4><div>$(printf '%s' "$evidence" | html_escape)</div></div>"
    echo "<div class='finding-box'><h4>Recommendation</h4><div>$(printf '%s' "$recommendation" | html_escape)</div></div>"
    echo "</div>"
    if [ -n "$remediation_html" ]; then
      echo "$remediation_html"
    else
      echo "<details class='remediation'><summary>Practical remediation guidance</summary><div class='note'>No authenticated remediation guidance was returned for this finding yet. Basic recommendation is shown above.</div></details>"
    fi
    echo "</div>"
    echo "</article>"
  done < "$FINDINGS"
  echo "</div>"
}

render_quick_actions_cards_html() {
  if [ ! -s "$QUICKACTIONS" ]; then
    echo "<div class='scope-ok'><b>No quick actions generated.</b></div>"
    return
  fi

  echo "<div class='finding-grid'>"
  while IFS="$(printf '\t')" read -r prio action reason rest; do
    [ -z "${action:-}" ] && continue
    remediation_html="$(render_remediation_for_finding "$action" "Quick Actions")"
    rid="$(remediation_id_for_finding "$action" "Quick Actions")"
    echo "<article class='finding-card'>"
    echo "<div class='finding-head'><div><div class='finding-title'>$(printf '%s' "$action" | html_escape)</div><div class='finding-meta'><span class='meta-pill'>Priority $(printf '%s' "$prio" | html_escape)</span>"
    if [ -n "$rid" ]; then echo "<span class='meta-pill'>Remediation ID: $(printf '%s' "$rid" | html_escape)</span>"; fi
    echo "</div></div></div>"
    echo "<div class='finding-body'><div class='finding-box'><h4>Reason</h4><div>$(printf '%s' "$reason" | html_escape)</div></div>"
    if [ -n "$remediation_html" ]; then
      echo "$remediation_html"
    fi
    echo "</div></article>"
  done < "$QUICKACTIONS"
  echo "</div>"
}

render_remediation_status_html() {
  if [ ! -s "$REMEDIATION_STATUS" ]; then
    echo "<div class='note'>Authenticated remediation guidance status unavailable.</div>"
    return
  fi
  echo "<table><tr><th>Item</th><th>Value</th></tr>"
  while IFS="$(printf '	')" read -r k v; do
    [ -z "${k:-}" ] && continue
    echo "<tr><td>$(printf '%s' "$k" | html_escape)</td><td>$(printf '%s' "$v" | html_escape)</td></tr>"
  done < "$REMEDIATION_STATUS"

  if [ -s "$REMEDIATION_JSON" ] && cmd_exists python3; then
    python3 - "$REMEDIATION_JSON" <<'PY_REMED_STATUS'
import json, sys, html
try:
    d=json.load(open(sys.argv[1], encoding="utf-8"))
except Exception:
    sys.exit(0)
s=d.get("summary",{}) if isinstance(d.get("summary"),dict) else {}
items=d.get("items",{})
count=len(items) if isinstance(items,dict) else len(items) if isinstance(items,list) else 0
print(f"<tr><td>guidance_items_returned</td><td>{count}</td></tr>")
for k in ["requested","returned","missing","duration_ms"]:
    if k in s:
        print(f"<tr><td>{html.escape(str(k))}</td><td>{html.escape(str(s[k]))}</td></tr>")
PY_REMED_STATUS
  fi

  echo "</table>"
}

render_update_summary_html() {
  if [ ! -s "$UPDATE_SUMMARY_TSV" ]; then
    echo "<div class='note'>No update summary was generated.</div>"
    return
  fi
  echo "<table><tr><th>Metric</th><th>Value</th><th>Evidence source</th></tr>"
  tail -n +2 "$UPDATE_SUMMARY_TSV" | while IFS="$(printf '\t')" read -r metric value evidence; do
    echo "<tr><td>$(printf '%s' "$metric" | html_escape)</td><td><b>$(printf '%s' "$value" | html_escape)</b></td><td>$(printf '%s' "$evidence" | html_escape)</td></tr>"
  done
  echo "</table>"
}

collect_optional_security_tools() {
stage_start "Collecting public/local exposure classification"
if classify_exposure; then stage_ok "Exposure classification completed."; else stage_warn "Exposure classification completed with warnings."; fi
stage_start "Collecting config file inventory"
if collect_config_inventory; then stage_ok "Config file inventory completed."; else stage_warn "Config file inventory completed with warnings."; fi
stage_start "Collecting possible secret exposure clues"
if collect_secret_exposure_light; then stage_ok "Secret exposure clue stage completed."; else stage_warn "Secret exposure clue stage completed with warnings."; fi
stage_start "Collecting cron/systemd persistence inventory"
if collect_persistence_inventory; then stage_ok "Persistence inventory completed."; else stage_warn "Persistence inventory completed with warnings."; fi
stage_start "Collecting web stack posture"
if collect_web_stack_posture; then stage_ok "Web stack posture completed."; else stage_warn "Web stack posture completed with warnings."; fi
stage_start "Checking enabled websites, SSL certificates and permissions"
if collect_enabled_websites_ssl; then stage_ok "Enabled website/SSL checks completed."; else stage_warn "Enabled website/SSL checks completed with warnings."; fi
stage_start "Testing local HTTPS TLS protocols and certificates"
if collect_local_tls_https_checks; then stage_ok "Local HTTPS TLS checks completed."; else stage_warn "Local HTTPS TLS checks completed with warnings."; fi
stage_start "Checking exposed admin panels"
if collect_admin_panel_exposure; then stage_ok "Admin panel exposure checks completed."; else stage_warn "Admin panel exposure checks completed with warnings."; fi
stage_start "Collecting backup/restore clues"
if collect_backup_restore_clues_enhanced; then stage_ok "Backup/restore clue stage completed."; else stage_warn "Backup/restore clue stage completed with warnings."; fi
stage_start "Building strengths and manual verification list"
if build_strengths_and_verify; then stage_ok "Strengths/manual verification list completed."; else stage_warn "Strengths/manual verification list completed with warnings."; fi
stage_plain "INFO" "Continuing to scoring and report generation..."
stage_start "Building operational risk view"
if build_operational_risk; then stage_ok "Operational risk view completed."; else stage_warn "Operational risk view completed with warnings."; fi
stage_start "Building score categories"
if build_score_categories; then
  SECURITY_SCORE="${OVERALL_SECURITY_SCORE:-100}"
  SCORE_CLASS="${OVERALL_SECURITY_CLASS:-ok}"
  SCORE_BAND="${OVERALL_SECURITY_BAND:-Unknown}"
  stage_ok "Score categories completed. Overall score: ${SECURITY_SCORE:-unknown} (${SCORE_BAND:-unknown})."
else
  SECURITY_SCORE="${SECURITY_SCORE:-100}"
  SCORE_CLASS="${SCORE_CLASS:-ok}"
  SCORE_BAND="${SCORE_BAND:-Unknown}"
  stage_warn "Score categories failed; report will use fallback score values."
fi
  : > "$OPTIONAL_TOOLS_RAW"; : > "$OPTIONAL_TOOLS_TSV"; printf "tool\tstatus\tnote\n" >> "$OPTIONAL_TOOLS_TSV"
  check_tool(){ local tool="$1" note="$2"; if cmd_exists "$tool"; then add_tsv_row "$OPTIONAL_TOOLS_TSV" "$tool" installed "$note"; echo "$tool: installed ($(command -v "$tool"))" >> "$OPTIONAL_TOOLS_RAW"; else add_tsv_row "$OPTIONAL_TOOLS_TSV" "$tool" "not installed" "$note"; echo "$tool: not installed" >> "$OPTIONAL_TOOLS_RAW"; fi; }
  check_tool lynis "Optional CIS-style local auditing helper. Not required by Scantide."
  check_tool oscap "Optional OpenSCAP compliance scanner. Not required by Scantide."
  check_tool aide "Optional file integrity monitoring. Not required by Scantide."
  check_tool needrestart "Optional reboot/service restart helper."
  check_tool debsecan "Optional Debian/Ubuntu advisory helper."
}

render_hardening_tsv_html() {
  local file="$1" empty_msg="$2"
  if [ ! -s "$file" ] || [ "$(wc -l < "$file" | tr -d ' ')" -le 1 ]; then echo "<div class='scope-ok'><b>$empty_msg</b></div>"; return; fi
  echo "<table>"; head -1 "$file" | awk -F '\t' '{printf "<tr>"; for(i=1;i<=NF;i++) printf "<th>%s</th>", $i; print "</tr>"}'
  tail -n +2 "$file" | while IFS="$(printf '\t')" read -r c1 c2 c3 c4 c5 c6 rest; do echo "<tr><td>$(printf '%s' "$c1" | html_escape)</td><td>$(printf '%s' "$c2" | html_escape)</td><td>$(printf '%s' "$c3" | html_escape)</td><td>$(printf '%s' "$c4" | html_escape)</td><td>$(printf '%s' "$c5" | html_escape)</td><td>$(printf '%s' "$c6" | html_escape)</td></tr>"; done
  echo "</table>"
}

collect_container_inventory() {
  : > "$CONTAINERFILE"
  : > "$CONTAINER_TSV"

  printf "engine\tname\timage\tstatus\tports\n" >> "$CONTAINER_TSV"

  if cmd_exists docker; then
    echo "### Docker containers" >> "$CONTAINERFILE"
    docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' 2>&1 >> "$CONTAINERFILE" || true
    docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null | while IFS="$(printf '\t')" read -r name image status ports rest; do
      [ -z "${name:-}" ] && continue
      printf "Docker\t%s\t%s\t%s\t%s\n" "$name" "$image" "$status" "$ports" >> "$CONTAINER_TSV"
    done
    echo "" >> "$CONTAINERFILE"
    echo "### Docker images" >> "$CONTAINERFILE"
    docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.CreatedSince}}\t{{.Size}}' 2>&1 >> "$CONTAINERFILE" || true
    echo "" >> "$CONTAINERFILE"
  fi

  if cmd_exists podman; then
    echo "### Podman containers" >> "$CONTAINERFILE"
    podman ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' 2>&1 >> "$CONTAINERFILE" || true
    podman ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null | while IFS="$(printf '\t')" read -r name image status ports rest; do
      [ -z "${name:-}" ] && continue
      printf "Podman\t%s\t%s\t%s\t%s\n" "$name" "$image" "$status" "$ports" >> "$CONTAINER_TSV"
    done
    echo "" >> "$CONTAINERFILE"
    echo "### Podman images" >> "$CONTAINERFILE"
    podman images --format 'table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Created}}\t{{.Size}}' 2>&1 >> "$CONTAINERFILE" || true
    echo "" >> "$CONTAINERFILE"
  fi

  if [ ! -s "$CONTAINERFILE" ]; then
    echo "No Docker or Podman container inventory captured." > "$CONTAINERFILE"
  fi
}

collect_listening_ports() {
  : > "$PORTFILE"
  printf "proto\tlocal_address\tport\tprocess\tpid\tuser\trisk_note\n" >> "$PORTFILE"

  if ! cmd_exists ss; then
    return
  fi

  ss -H -tulpen 2>/dev/null | while IFS= read -r line; do
    proto="$(printf '%s' "$line" | awk '{print $1}')"
    local_field="$(printf '%s' "$line" | awk '{print $5}')"
    users_field="$(printf '%s' "$line" | grep -Eo 'users:\(.*' || true)"

    # Split address and port. Handles 0.0.0.0:22, [::]:22 and *:80.
    port="$(printf '%s' "$local_field" | sed -E 's/^.*:([0-9]+)$/\1/')"
    addr="$(printf '%s' "$local_field" | sed -E 's/:([0-9]+)$//' | sed 's/^\[//;s/\]$//')"

    process="$(printf '%s' "$users_field" | grep -Eo '"[^"]+"' | head -1 | tr -d '"')"
    pid="$(printf '%s' "$users_field" | grep -Eo 'pid=[0-9]+' | head -1 | cut -d= -f2)"
    user="$(printf '%s' "$line" | grep -Eo 'uid:[0-9]+' | head -1 | cut -d: -f2)"

    [ -z "$port" ] && continue
    [ "$addr" = "*" ] && addr="0.0.0.0"
    [ -z "$process" ] && process="unknown"
    [ -z "$pid" ] && pid=""
    [ -z "$user" ] && user=""

    risk=""
    case "$port" in
      21|23|25|110|143|389|6379|9200|9300|11211|27017|3306|5432|5900|5901|10000)
        risk="Review exposure: service is commonly sensitive or abused if Internet-facing."
        ;;
      22)
        risk="SSH: expected on many servers, but should be restricted and hardened."
        ;;
      80|443|8080|8443)
        risk="Web service: verify TLS, headers, exposure and patching."
        ;;
    esac

    printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" "$proto" "$addr" "$port" "$process" "$pid" "$user" "$risk" >> "$PORTFILE"
  done
}

collect_platform_inventory() {
  : > "$PLATFORMFILE"

  virt="unknown"
  virt_detail=""
  platform_label="Unknown / Bare Metal"

  if [ -f /.dockerenv ]; then
    virt="container"
    platform_label="Docker/LXC-style Container"
    virt_detail="/.dockerenv present"
  elif cmd_exists systemd-detect-virt; then
    virt="$(systemd-detect-virt 2>/dev/null || true)"
    virt_detail="systemd-detect-virt=$virt"
    case "$virt" in
      vmware) platform_label="VMware Virtual Machine" ;;
      oracle) platform_label="VirtualBox Virtual Machine" ;;
      microsoft|hyperv) platform_label="Hyper-V / Microsoft Virtual Machine" ;;
      kvm|qemu) platform_label="KVM/QEMU Virtual Machine" ;;
      xen) platform_label="Xen Virtual Machine" ;;
      lxc|lxc-libvirt|systemd-nspawn|docker|podman) platform_label="Linux Container" ;;
      none|"") platform_label="Physical or undetected virtualization" ;;
      *) platform_label="$virt virtualized platform" ;;
    esac
  fi

  if [ "$platform_label" = "Physical or undetected virtualization" ] || [ "$platform_label" = "Unknown / Bare Metal" ]; then
    if cmd_exists lscpu && lscpu 2>/dev/null | grep -qi 'Hypervisor vendor'; then
      hv="$(lscpu 2>/dev/null | awk -F: '/Hypervisor vendor/{gsub(/^ +/,"",$2); print $2; exit}')"
      [ -n "$hv" ] && platform_label="$hv Virtual Machine" && virt_detail="lscpu Hypervisor vendor=$hv"
    fi
  fi

  if cmd_exists vmware-toolbox-cmd || cmd_exists vmtoolsd || pkg_version open-vm-tools >/dev/null 2>&1; then
    vt="$( (vmware-toolbox-cmd -v 2>/dev/null || vmtoolsd -v 2>&1 || pkg_version open-vm-tools) | head -1 )"
    [ -n "$vt" ] && virt_detail="$virt_detail; VMware Tools/open-vm-tools detected: $vt"
  fi

  printf "platform_label\t%s\n" "$platform_label" >> "$PLATFORMFILE"
  printf "virtualization\t%s\n" "$virt" >> "$PLATFORMFILE"
  printf "evidence\t%s\n" "$virt_detail" >> "$PLATFORMFILE"
}

platform_value() {
  local key="$1"
  [ -s "$PLATFORMFILE" ] && awk -F '\t' -v k="$key" '$1==k{print $2; exit}' "$PLATFORMFILE"
}

port_listens() {
  local port="$1"
  [ -s "$PORTFILE" ] && awk -F '\t' -v p="$port" 'NR>1 && $3==p {found=1} END{exit found?0:1}' "$PORTFILE"
}

port_evidence() {
  local ports="$1"
  local out=""
  for p in $ports; do
    if port_listens "$p"; then
      out="$out TCP/$p listening;"
    fi
  done
  printf '%s' "$out"
}

service_summary_counts() {
  [ ! -s "$SERVICEFILE" ] && return
  web=0; mail=0; data=0; proxy=0; net=0; vpn=0; security=0; containers=0; admin=0; virt=0
  while IFS="$(printf '\t')" read -r product version source; do
    case "$product" in
      "Apache HTTP Server"|nginx|PHP|Caddy|Lighttpd|phpMyAdmin) web=$((web+1)) ;;
      Postfix|Exim|OpenSMTPD|Dovecot) mail=$((mail+1)) ;;
      Redis|MySQL|MariaDB|PostgreSQL|MongoDB|Memcached|Elasticsearch|RabbitMQ) data=$((data+1)) ;;
      Squid|HAProxy|Varnish|Traefik) proxy=$((proxy+1)) ;;
      BIND|dnsmasq|"ISC DHCP Server") net=$((net+1)) ;;
      OpenVPN|WireGuard|strongSwan) vpn=$((vpn+1)) ;;
      Fail2ban|ClamAV) security=$((security+1)) ;;
      Docker|Podman|containerd|runc|"Kubernetes kubelet"|kubectl) containers=$((containers+1)) ;;
      Webmin|Cockpit|Grafana|"Zabbix Agent"|"Prometheus Node Exporter") admin=$((admin+1)) ;;
      QEMU|libvirt|"VMware Tools") virt=$((virt+1)) ;;
    esac
  done < "$SERVICEFILE"
  echo "$web	$mail	$data	$proxy	$net	$vpn	$security	$containers	$admin	$virt"
}

render_services_summary_html() {
  vals="$(service_summary_counts)"
  if [ -z "$vals" ]; then
    echo "<div class='note'>No service summary available.</div>"
    return
  fi
  IFS="$(printf '\t')" read -r web mail data proxy net vpn security containers admin virt <<EOF_SUMMARY
$vals
EOF_SUMMARY
  echo "<table><tr><th>Category</th><th>Count</th><th>Meaning</th></tr>"
  echo "<tr><td>Web/Application</td><td>$web</td><td>Web servers, PHP/runtime or web admin apps.</td></tr>"
  echo "<tr><td>Mail</td><td>$mail</td><td>SMTP/IMAP/POP components.</td></tr>"
  echo "<tr><td>Database/Cache</td><td>$data</td><td>Databases, caches and message/search services.</td></tr>"
  echo "<tr><td>Proxy/Load Balancer</td><td>$proxy</td><td>Proxy, cache or load-balancing components.</td></tr>"
  echo "<tr><td>DNS/DHCP</td><td>$net</td><td>Network name/addressing services.</td></tr>"
  echo "<tr><td>VPN</td><td>$vpn</td><td>VPN/tunnel components.</td></tr>"
  echo "<tr><td>Security/Monitoring</td><td>$security</td><td>Security agents and local monitoring components.</td></tr>"
  echo "<tr><td>Containers</td><td>$containers</td><td>Container runtime/orchestration components.</td></tr>"
  echo "<tr><td>Admin/Monitoring UI</td><td>$admin</td><td>Web or agent-based admin/monitoring tools.</td></tr>"
  echo "<tr><td>Virtualization/Guest Tools</td><td>$virt</td><td>Hypervisor, virtualization or guest-agent tooling.</td></tr>"
  echo "</table>"
}

detect_server_roles() {
  : > "$ROLEFILE"

  has_service() {
    [ -s "$SERVICEFILE" ] && awk -F '\t' -v s="$1" 'tolower($1)==tolower(s){found=1} END{exit found?0:1}' "$SERVICEFILE"
  }

  add_role() {
    local role="$1"
    local confidence="$2"
    local evidence="$3"
    local score="$4"
    printf "%s\t%s\t%s\t%s\n" "$role" "$confidence" "$evidence" "$score" >> "$ROLEFILE"
  }

  # Role confidence is based on detected software plus listening ports.
  if has_service "Apache HTTP Server" || has_service "nginx" || has_service "PHP" || has_service "Caddy" || has_service "Lighttpd"; then
    ev="Detected web/runtime components:"
    score=0
    for svc in "Apache HTTP Server" nginx PHP Caddy Lighttpd; do has_service "$svc" && ev="$ev $svc;" && score=$((score+2)); done
    pe="$(port_evidence '80 443 8080 8443 8000 9000')"
    [ -n "$pe" ] && ev="$ev Ports: $pe" && score=$((score+3))
    conf="Medium"; [ "$score" -ge 5 ] && conf="High"
    add_role "Web / Application Server" "$conf" "$ev" "$score"
  fi

  if has_service "Postfix" || has_service "Exim" || has_service "OpenSMTPD" || has_service "Dovecot"; then
    ev="Detected mail components:"
    score=0
    for svc in Postfix Exim OpenSMTPD Dovecot; do has_service "$svc" && ev="$ev $svc;" && score=$((score+2)); done
    pe="$(port_evidence '25 465 587 110 143 993 995')"
    [ -n "$pe" ] && ev="$ev Ports: $pe" && score=$((score+3))
    conf="Medium"; [ "$score" -ge 5 ] && conf="High"
    add_role "Mail Server" "$conf" "$ev" "$score"
  fi

  if has_service "Squid" || has_service "HAProxy" || has_service "Varnish" || has_service "Traefik"; then
    ev="Detected proxy/load-balancer components:"
    score=0
    for svc in Squid HAProxy Varnish Traefik; do has_service "$svc" && ev="$ev $svc;" && score=$((score+2)); done
    pe="$(port_evidence '80 443 3128 8080 8443 6081')"
    [ -n "$pe" ] && ev="$ev Ports: $pe" && score=$((score+3))
    conf="Medium"; [ "$score" -ge 5 ] && conf="High"
    add_role "Proxy / Cache / Load Balancer" "$conf" "$ev" "$score"
  fi

  if has_service "Redis" || has_service "MySQL" || has_service "MariaDB" || has_service "PostgreSQL" || has_service "MongoDB" || has_service "Memcached" || has_service "Elasticsearch"; then
    ev="Detected data services:"
    score=0
    for svc in Redis MySQL MariaDB PostgreSQL MongoDB Memcached Elasticsearch; do has_service "$svc" && ev="$ev $svc;" && score=$((score+2)); done
    pe="$(port_evidence '6379 3306 5432 27017 11211 9200 9300')"
    [ -n "$pe" ] && ev="$ev Ports: $pe" && score=$((score+3))
    conf="Medium"; [ "$score" -ge 5 ] && conf="High"
    add_role "Database / Cache Server" "$conf" "$ev" "$score"
  fi

  if has_service "Docker" || has_service "Podman" || has_service "containerd" || has_service "Kubernetes kubelet"; then
    ev="Detected container components:"
    score=0
    for svc in Docker Podman containerd "Kubernetes kubelet"; do has_service "$svc" && ev="$ev $svc;" && score=$((score+2)); done
    pe="$(port_evidence '2375 2376 6443 10250')"
    [ -n "$pe" ] && ev="$ev Ports: $pe" && score=$((score+3))
    conf="Medium"; [ "$score" -ge 5 ] && conf="High"
    add_role "Container Host" "$conf" "$ev" "$score"
  fi

  if has_service "BIND" || has_service "dnsmasq" || has_service "ISC DHCP Server"; then
    ev="Detected DNS/DHCP components:"
    score=0
    for svc in BIND dnsmasq "ISC DHCP Server"; do has_service "$svc" && ev="$ev $svc;" && score=$((score+2)); done
    pe="$(port_evidence '53 67 68')"
    [ -n "$pe" ] && ev="$ev Ports: $pe" && score=$((score+3))
    conf="Medium"; [ "$score" -ge 5 ] && conf="High"
    add_role "DNS / DHCP Server" "$conf" "$ev" "$score"
  fi

  if has_service "OpenVPN" || has_service "WireGuard" || has_service "strongSwan"; then
    ev="Detected VPN components:"
    score=0
    for svc in OpenVPN WireGuard strongSwan; do has_service "$svc" && ev="$ev $svc;" && score=$((score+2)); done
    pe="$(port_evidence '1194 51820 500 4500')"
    [ -n "$pe" ] && ev="$ev Ports: $pe" && score=$((score+3))
    conf="Medium"; [ "$score" -ge 5 ] && conf="High"
    add_role "VPN Server" "$conf" "$ev" "$score"
  fi

  if has_service "Samba" || has_service "NFS" || has_service "CUPS"; then
    ev="Detected file/print services:"
    score=0
    for svc in Samba NFS CUPS; do has_service "$svc" && ev="$ev $svc;" && score=$((score+2)); done
    pe="$(port_evidence '139 445 2049 631')"
    [ -n "$pe" ] && ev="$ev Ports: $pe" && score=$((score+3))
    conf="Medium"; [ "$score" -ge 5 ] && conf="High"
    add_role "File / Print Server" "$conf" "$ev" "$score"
  fi

  if has_service "Webmin" || has_service "Cockpit" || has_service "Grafana" || has_service "Zabbix Agent" || has_service "Prometheus Node Exporter"; then
    ev="Detected admin/monitoring components:"
    score=0
    for svc in Webmin Cockpit Grafana "Zabbix Agent" "Prometheus Node Exporter"; do has_service "$svc" && ev="$ev $svc;" && score=$((score+2)); done
    pe="$(port_evidence '10000 9090 3000 9100 10050')"
    [ -n "$pe" ] && ev="$ev Ports: $pe" && score=$((score+3))
    conf="Medium"; [ "$score" -ge 5 ] && conf="High"
    add_role "Admin / Monitoring Node" "$conf" "$ev" "$score"
  fi

  # VMware/open-vm-tools is platform context, not a business/server role.
  # It is shown under Platform / Virtualization and Software Inventory instead.

  # Scantide-like SaaS/application platform role when several components combine.
  if (has_service "Apache HTTP Server" || has_service nginx) && has_service PHP && (has_service Redis || has_service HAProxy || has_service Postfix); then
    add_role "Likely SaaS / Web Platform" "High" "Combined application stack detected: web server + PHP + supporting service such as Redis/HAProxy/Postfix." "10"
  fi

  if [ ! -s "$ROLEFILE" ]; then
    echo "General Linux Server\tLow\tNo strong role-specific service combination was detected.\t1" > "$ROLEFILE"
  fi
}

render_listening_ports_html() {
  if [ ! -s "$PORTFILE" ]; then
    echo "<div class='note'>No parsed listening-port inventory was available. Raw ss output is shown below if available.</div>"
    cmd_exists ss && echo "<pre>$(ss -tulpen 2>&1 | html_escape)</pre>"
    return
  fi

  echo "<table><tr><th>Proto</th><th>Address</th><th>Port</th><th>Process</th><th>PID</th><th>User/UID</th><th>Comment</th></tr>"
  tail -n +2 "$PORTFILE" | while IFS="$(printf '\t')" read -r proto addr port process pid user risk; do
    [ -z "${port:-}" ] && continue
    badge="source-ok"
    [ -n "${risk:-}" ] && badge="source-warn"
    echo "<tr><td>$(printf '%s' "$proto" | html_escape)</td><td>$(printf '%s' "$addr" | html_escape)</td><td><b>$(printf '%s' "$port" | html_escape)</b></td><td>$(printf '%s' "$process" | html_escape)</td><td>$(printf '%s' "$pid" | html_escape)</td><td>$(printf '%s' "$user" | html_escape)</td><td><span class='source-badge $badge'>$(printf '%s' "${risk:-Expected/unknown}" | html_escape)</span></td></tr>"
  done
  echo "</table>"
}

render_container_inventory_html() {
  if [ ! -s "$CONTAINER_TSV" ] || [ "$(wc -l < "$CONTAINER_TSV" | tr -d ' ')" -le 1 ]; then
    echo "<div class='note'>No Docker or Podman containers were detected.</div>"
    return
  fi

  echo "<table><tr><th>Engine</th><th>Name</th><th>Image</th><th>Status</th><th>Ports</th></tr>"
  tail -n +2 "$CONTAINER_TSV" | while IFS="$(printf '\t')" read -r engine name image status ports; do
    [ -z "${name:-}" ] && continue
    badge="source-ok"
    printf '%s' "$ports" | grep -Eq '0\.0\.0\.0|::|[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' && badge="source-warn"
    echo "<tr><td>$(printf '%s' "$engine" | html_escape)</td><td>$(printf '%s' "$name" | html_escape)</td><td>$(printf '%s' "$image" | html_escape)</td><td>$(printf '%s' "$status" | html_escape)</td><td><span class='source-badge $badge'>$(printf '%s' "${ports:-none}" | html_escape)</span></td></tr>"
  done
  echo "</table>"
}

primary_role() {
  if [ -s "$ROLEFILE" ]; then
    awk -F '\t' '$1 !~ /^(VMware Guest|Virtual Machine|Container Host|Physical Server|Platform Context)$/ {print}' "$ROLEFILE" | sort -t "$(printf '\t')" -k4,4nr | head -1 | awk -F '\t' '{print $1}'
  else
    echo "Unknown"
  fi
}

primary_role_confidence() {
  if [ -s "$ROLEFILE" ]; then
    awk -F '\t' '$1 !~ /^(VMware Guest|Virtual Machine|Container Host|Physical Server|Platform Context)$/ {print}' "$ROLEFILE" | sort -t "$(printf '\t')" -k4,4nr | head -1 | awk -F '\t' '{print $2}'
  else
    echo "Unknown"
  fi
}

render_server_roles_html() {
  if [ ! -s "$ROLEFILE" ]; then
    echo "<div class='note'>No role detection was performed.</div>"
    return
  fi
  echo "<table><tr><th>Likely role</th><th>Confidence</th><th>Evidence</th></tr>"
  sort -t "$(printf '\t')" -k4,4nr "$ROLEFILE" | while IFS="$(printf '\t')" read -r role confidence evidence score; do
    [ -z "${role:-}" ] && continue
    cls="source-warn"
    [ "$confidence" = "High" ] && cls="source-ok"
    [ "$confidence" = "Low" ] && cls="source-warn"
    echo "<tr><td><b>$(printf '%s' "$role" | html_escape)</b></td><td><span class='source-badge $cls'>$(printf '%s' "$confidence" | html_escape)</span></td><td>$(printf '%s' "$evidence" | html_escape)</td></tr>"
  done
  echo "</table>"
}

render_quick_actions_html() {
  if [ ! -s "$QUICKACTIONS" ]; then
    echo "<div class='scope-ok'><b>No quick actions generated by the current rule set.</b></div>"
    return
  fi
  echo "<table><tr><th>Priority</th><th>Action</th><th>Reason</th></tr>"
  sort -t "$(printf '\t')" -k1,1n "$QUICKACTIONS" | while IFS="$(printf '\t')" read -r prio action reason; do
    [ -z "${action:-}" ] && continue
    remediation_html="$(render_remediation_for_finding "$action" "Quick Actions")"
echo "<tr><td>$(printf '%s' "$prio" | html_escape)</td><td><b>$(printf '%s' "$action" | html_escape)</b>$remediation_html</td><td>$(printf '%s' "$reason" | html_escape)</td></tr>"
  done
  echo "</table>"
}



classify_exposure() {
  : > "$EXPOSURE_TSV"
  printf "proto\taddress\tport\tprocess\texposure\tcomment\n" >> "$EXPOSURE_TSV"
  [ -s "$PORTFILE" ] || return

  tail -n +2 "$PORTFILE" | while IFS="$(printf '\t')" read -r proto addr port process pid user risk; do
    [ -z "${port:-}" ] && continue
    exposure="Unknown"
    comment=""
    case "$addr" in
      127.*|::1|"Loopback"*) exposure="Loopback only"; comment="Only locally reachable on this address." ;;
      0.0.0.0|::|"*") exposure="All interfaces"; comment="Potentially reachable on every bound interface. Verify firewall and routing." ;;
      10.*|192.168.*|172.16.*|172.17.*|172.18.*|172.19.*|172.2*|172.30.*|172.31.*) exposure="LAN-facing"; comment="Private-address listener. Verify network segmentation." ;;
      fe80:*) exposure="IPv6 link-local"; comment="IPv6 link-local listener." ;;
      *) exposure="Specific address"; comment="Bound to a specific interface/address." ;;
    esac
    printf "%s\t%s\t%s\t%s\t%s\t%s\n" "$proto" "$addr" "$port" "$process" "$exposure" "$comment" >> "$EXPOSURE_TSV"
  done
}

collect_config_inventory() {
  : > "$CONFIG_TSV"; : > "$CONFIG_RAW"
  printf "path\texists\towner\tmode\tmodified\tcomment\n" >> "$CONFIG_TSV"

  add_config_path() {
    local path="$1"; local comment="$2"
    if [ -e "$path" ]; then
      owner="$(stat -c '%U:%G' "$path" 2>/dev/null || true)"
      mode="$(stat -c '%a' "$path" 2>/dev/null || true)"
      mod="$(stat -c '%y' "$path" 2>/dev/null | cut -d'.' -f1 || true)"
      printf "%s\tyes\t%s\t%s\t%s\t%s\n" "$path" "$owner" "$mode" "$mod" "$comment" >> "$CONFIG_TSV"
      echo "$path owner=$owner mode=$mode modified=$mod comment=$comment" >> "$CONFIG_RAW"
    else
      printf "%s\tno\t\t\t\t%s\n" "$path" "$comment" >> "$CONFIG_TSV"
    fi
  }

  add_config_path "/etc/ssh/sshd_config" "SSH server configuration"
  add_config_path "/etc/sudoers" "sudo policy"
  add_config_path "/etc/sysctl.conf" "kernel/sysctl settings"
  add_config_path "/etc/ufw/ufw.conf" "UFW configuration"
  add_config_path "/etc/fstab" "filesystem mount configuration"
  add_config_path "/etc/apache2/apache2.conf" "Apache global config"
  add_config_path "/etc/httpd/conf/httpd.conf" "Apache/RHEL global config"
  add_config_path "/etc/nginx/nginx.conf" "nginx global config"
  add_config_path "/etc/redis/redis.conf" "Redis configuration"
  add_config_path "/etc/postfix/main.cf" "Postfix configuration"
  add_config_path "/etc/squid/squid.conf" "Squid configuration"
  add_config_path "/etc/audit/auditd.conf" "auditd configuration"
  add_config_path "/etc/systemd/journald.conf" "journald configuration"
}

collect_secret_exposure_light() {
  : > "$SECRETS_TSV"; : > "$SECRETS_RAW"
  printf "severity\tlocation\tmatch_type\tevidence\trecommendation\n" >> "$SECRETS_TSV"

  if [ "$DEEP_SCAN" != "true" ]; then
    echo "Secret exposure scan skipped. Use --deep to run limited read-only pattern checks." > "$SECRETS_RAW"
    return
  fi

  patterns='(password|passwd|pwd|secret|api[_-]?key|token|private[_-]?key|aws_access_key_id|aws_secret_access_key)[[:space:]]*[:=]'
  for base in /etc /var/www /opt/scantide; do
    [ -d "$base" ] || continue
    echo "### Searching $base" >> "$SECRETS_RAW"
    matches="$(grep -RInE "$patterns" "$base" 2>/dev/null | grep -Ev '/(node_modules|vendor|cache|logs?)/' | head -80 || true)"
    [ -n "$matches" ] && echo "$matches" >> "$SECRETS_RAW"
    if [ -n "$matches" ]; then
      printf "Medium\t%s\tpossible secret pattern\t%s\tReview matched files and remove hard-coded secrets where possible.\n" "$base" "$(printf '%s' "$matches" | head -10 | tr '\n' '; ')" >> "$SECRETS_TSV"
      add_finding "Medium" "Credential Exposure" "Possible secrets in $base" "$(printf '%s' "$matches" | head -10 | tr '\n' '; ')" "Review matched files and remove hard-coded secrets where possible."
    fi
  done
}

collect_persistence_inventory() {
  : > "$PERSISTENCE_TSV"; : > "$PERSISTENCE_RAW"
  printf "type\tname\tstate\tevidence\n" >> "$PERSISTENCE_TSV"

  echo "### Enabled systemd services" >> "$PERSISTENCE_RAW"
  if cmd_exists systemctl; then
    systemctl list-unit-files --type=service --state=enabled 2>/dev/null | head -250 >> "$PERSISTENCE_RAW" || true
    systemctl list-unit-files --type=service --state=enabled --no-legend 2>/dev/null | head -200 | while read -r name state rest; do
      [ -n "$name" ] && printf "systemd-service\t%s\t%s\tenabled service\n" "$name" "$state" >> "$PERSISTENCE_TSV"
    done

    echo "### systemd timers" >> "$PERSISTENCE_RAW"
    systemctl list-timers --all 2>/dev/null | head -250 >> "$PERSISTENCE_RAW" || true
    systemctl list-unit-files --type=timer --no-legend 2>/dev/null | head -200 | while read -r name state rest; do
      [ -n "$name" ] && printf "systemd-timer\t%s\t%s\ttimer unit\n" "$name" "$state" >> "$PERSISTENCE_TSV"
    done
  fi

  echo "### Cron locations" >> "$PERSISTENCE_RAW"
  for f in /etc/crontab /etc/cron.d/* /var/spool/cron/crontabs/* /var/spool/cron/*; do
    [ -e "$f" ] || continue
    echo "--- $f" >> "$PERSISTENCE_RAW"
    sed -n '1,120p' "$f" 2>/dev/null >> "$PERSISTENCE_RAW" || true
    printf "cron\t%s\tpresent\tcron/autostart entry file\n" "$f" >> "$PERSISTENCE_TSV"
  done
}


collect_enabled_websites_ssl() {
  : > "$WEBSITES_TSV"
  : > "$WEBSITES_RAW"
  printf "server\tengine\tconfig\tlisten_ssl\tdocument_root\tdocroot_owner\tdocroot_mode\tdocroot_permission_status\tcertificate\tcert_status\tcert_days_remaining\tssl_note\n" >> "$WEBSITES_TSV"

  check_cert_file() {
    local cert="$1"
    if [ -z "$cert" ] || [ ! -r "$cert" ] || ! cmd_exists openssl; then
      printf "unknown\t\t"
      return
    fi

    end_epoch="$(openssl x509 -enddate -noout -in "$cert" 2>/dev/null | cut -d= -f2- | xargs -I{} date -d "{}" +%s 2>/dev/null || true)"
    now_epoch="$(date +%s)"
    if [ -z "$end_epoch" ]; then
      printf "unreadable\t\t"
      return
    fi

    days="$(( (end_epoch - now_epoch) / 86400 ))"
    if [ "$days" -lt 0 ]; then
      printf "expired\t%s\t" "$days"
    elif [ "$days" -le 14 ]; then
      printf "expires_soon\t%s\t" "$days"
    elif [ "$days" -le 30 ]; then
      printf "expires_within_30_days\t%s\t" "$days"
    else
      printf "valid\t%s\t" "$days"
    fi
  }

  docroot_status() {
    local root="$1"
    if [ -z "$root" ] || [ ! -e "$root" ]; then
      printf "missing"
      return
    fi
    mode="$(stat -c '%a' "$root" 2>/dev/null || true)"
    if [ -z "$mode" ]; then
      printf "unknown"
      return
    fi

    # Last digit is "others"; second-last is group. Writable bits are 2,3,6,7.
    other="${mode: -1}"
    group="${mode: -2:1}"
    if printf '%s' "$other" | grep -Eq '2|3|6|7'; then
      printf "world_writable"
    elif printf '%s' "$group" | grep -Eq '2|3|6|7'; then
      printf "group_writable_review"
    else
      printf "ok"
    fi
  }

  add_website_row() {
    local server="$1"
    local engine="$2"
    local cfg="$3"
    local ssl="$4"
    local root="$5"
    local cert="$6"
    local note="$7"

    [ -z "$server" ] && server="default/unknown"
    [ -z "$root" ] && root=""
    [ -z "$cert" ] && cert=""

    owner=""
    mode=""
    perm_status="unknown"
    if [ -n "$root" ] && [ -e "$root" ]; then
      owner="$(stat -c '%U:%G' "$root" 2>/dev/null || true)"
      mode="$(stat -c '%a' "$root" 2>/dev/null || true)"
      perm_status="$(docroot_status "$root")"
    elif [ -n "$root" ]; then
      perm_status="missing"
    fi

    cert_info="$(check_cert_file "$cert")"
    cert_status="$(printf '%s' "$cert_info" | awk '{print $1}')"
    cert_days="$(printf '%s' "$cert_info" | awk '{print $2}')"

    printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n" \
      "$server" "$engine" "$cfg" "$ssl" "$root" "$owner" "$mode" "$perm_status" "$cert" "$cert_status" "$cert_days" "$note" >> "$WEBSITES_TSV"

    # Findings
    if [ "$ssl" != "yes" ]; then
      add_finding "Medium" "Web Sites" "Website is not clearly bound to SSL/TLS" "$engine site $server config=$cfg" "Bind the site to HTTPS/443 and redirect HTTP to HTTPS where appropriate."
    fi

    if [ "$perm_status" = "world_writable" ]; then
      add_finding "High" "Web Sites" "Website document root is world-writable" "$root mode=$mode owner=$owner" "Remove world-writable permissions from website document root."
    elif [ "$perm_status" = "group_writable_review" ]; then
      add_finding "Medium" "Web Sites" "Website document root is group-writable" "$root mode=$mode owner=$owner" "Review group write access to the website document root."
    elif [ "$perm_status" = "missing" ]; then
      add_finding "Medium" "Web Sites" "Website document root is missing" "$server root=$root config=$cfg" "Verify the site document root path and deployment state."
    fi

    if [ "$ssl" = "yes" ]; then
      if [ -z "$cert" ]; then
        add_finding "Medium" "Web Sites" "SSL site has no certificate path detected" "$engine site $server config=$cfg" "Verify certificate configuration for the SSL-enabled site."
      elif [ "$cert_status" = "expired" ]; then
        add_finding "High" "Web Sites" "Website SSL certificate is expired" "$server cert=$cert days=$cert_days" "Renew the certificate immediately."
      elif [ "$cert_status" = "expires_soon" ] || [ "$cert_status" = "expires_within_30_days" ]; then
        add_finding "Medium" "Web Sites" "Website SSL certificate expires soon" "$server cert=$cert days_remaining=$cert_days" "Renew or verify automatic renewal before expiry."
      elif [ "$cert_status" = "unreadable" ] || [ "$cert_status" = "unknown" ]; then
        add_finding "Low" "Web Sites" "Website SSL certificate could not be validated locally" "$server cert=$cert status=$cert_status" "Verify certificate path, permissions and certificate validity."
      fi
    fi
  }

  echo "### Apache enabled sites" >> "$WEBSITES_RAW"
  if [ -d /etc/apache2/sites-enabled ]; then
    for cfg in /etc/apache2/sites-enabled/*; do
      [ -f "$cfg" ] || [ -L "$cfg" ] || continue
      echo "--- $cfg" >> "$WEBSITES_RAW"
      sed -n '1,220p' "$cfg" 2>/dev/null >> "$WEBSITES_RAW" || true

      server="$(awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*ServerName[[:space:]]+/ {print $2; exit}' "$cfg" 2>/dev/null || true)"
      root="$(awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*DocumentRoot[[:space:]]+/ {print $2; exit}' "$cfg" 2>/dev/null || true)"
      cert="$(awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*SSLCertificateFile[[:space:]]+/ {print $2; exit}' "$cfg" 2>/dev/null || true)"
      ssl="no"
      note=""
      if grep -Eiq '<VirtualHost[[:space:]][^>]*:443|SSLEngine[[:space:]]+on' "$cfg" 2>/dev/null; then
        ssl="yes"
      fi
      if [ "$ssl" = "yes" ] && [ -z "$cert" ]; then
        note="SSL hint found but no SSLCertificateFile detected in this config file."
      fi
      add_website_row "$server" "apache" "$cfg" "$ssl" "$root" "$cert" "$note"
    done
  fi

  # RHEL-style Apache/httpd configs
  if [ -d /etc/httpd/conf.d ]; then
    echo "### httpd conf.d" >> "$WEBSITES_RAW"
    for cfg in /etc/httpd/conf.d/*.conf; do
      [ -f "$cfg" ] || continue
      echo "--- $cfg" >> "$WEBSITES_RAW"
      sed -n '1,220p' "$cfg" 2>/dev/null >> "$WEBSITES_RAW" || true

      if grep -Eiq 'ServerName|DocumentRoot|VirtualHost|SSLCertificateFile' "$cfg" 2>/dev/null; then
        server="$(awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*ServerName[[:space:]]+/ {print $2; exit}' "$cfg" 2>/dev/null || true)"
        root="$(awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*DocumentRoot[[:space:]]+/ {print $2; exit}' "$cfg" 2>/dev/null || true)"
        cert="$(awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*SSLCertificateFile[[:space:]]+/ {print $2; exit}' "$cfg" 2>/dev/null || true)"
        ssl="no"
        grep -Eiq '<VirtualHost[[:space:]][^>]*:443|SSLEngine[[:space:]]+on' "$cfg" 2>/dev/null && ssl="yes"
        add_website_row "$server" "apache/httpd" "$cfg" "$ssl" "$root" "$cert" ""
      fi
    done
  fi

  echo "### nginx enabled sites" >> "$WEBSITES_RAW"
  for cfg in /etc/nginx/sites-enabled/* /etc/nginx/conf.d/*.conf; do
    [ -f "$cfg" ] || [ -L "$cfg" ] || continue
    echo "--- $cfg" >> "$WEBSITES_RAW"
    sed -n '1,260p' "$cfg" 2>/dev/null >> "$WEBSITES_RAW" || true

    if grep -Eiq 'server_name|root[[:space:]]+|listen[[:space:]]+443|ssl_certificate' "$cfg" 2>/dev/null; then
      server="$(awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*server_name[[:space:]]+/ {gsub(";","",$2); print $2; exit}' "$cfg" 2>/dev/null || true)"
      root="$(awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*root[[:space:]]+/ {gsub(";","",$2); print $2; exit}' "$cfg" 2>/dev/null || true)"
      cert="$(awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*ssl_certificate[[:space:]]+/ && $1 !~ /_key/ {gsub(";","",$2); print $2; exit}' "$cfg" 2>/dev/null || true)"
      ssl="no"
      grep -Eiq 'listen[[:space:]]+.*443|ssl_certificate' "$cfg" 2>/dev/null && ssl="yes"
      add_website_row "$server" "nginx" "$cfg" "$ssl" "$root" "$cert" ""
    fi
  done

  count="$(awk 'NR>1{c++} END{print c+0}' "$WEBSITES_TSV" 2>/dev/null || echo 0)"
  if [ "${count:-0}" -eq 0 ]; then
    echo "No enabled Apache/nginx site configs detected in standard locations." >> "$WEBSITES_RAW"
  fi
}


write_summary_json_report() {
  # Minimal machine-readable summary. The full human evidence remains embedded in the HTML report.
  {
    printf '{\n'
    printf '  "product": "Scantide Auditor Linux",\n'
    printf '  "script_version": "%s",\n' "$(json_escape "$SCRIPT_VERSION")"
    printf '  "generated": "%s",\n' "$(json_escape "$(date '+%Y-%m-%d %H:%M:%S')")"
    printf '  "host": "%s",\n' "$(json_escape "$HOST")"
    printf '  "distro": "%s",\n' "$(json_escape "$DISTRO_NAME")"
    printf '  "kernel": "%s",\n' "$(json_escape "$(uname -r 2>/dev/null || true)")"
    printf '  "score": "%s",\n' "$(json_escape "${SECURITY_SCORE:-unknown}")"
    printf '  "score_band": "%s",\n' "$(json_escape "${OVERALL_SECURITY_BAND:-unknown}")"
    printf '  "findings_count": "%s",\n' "$(json_escape "${FINDINGCOUNT:-0}")"
    printf '  "cve_api_status": "%s",\n' "$(json_escape "${HTTP_STATUS_DISPLAY:-Not run}")"
    printf '  "lifecycle_api_status": "%s",\n' "$(json_escape "${LIFECYCLE_STATUS_DISPLAY:-Not run}")"
    printf '  "debug_files_kept": "%s",\n' "$(json_escape "$KEEP_DEBUG_FILES")"
    printf '  "report": "%s"\n' "$(json_escape "$REPORT")"
    printf '}\n'
  } > "$JSON_REPORT"
}

cleanup_intermediate_files() {
  if [ "${KEEP_DEBUG_FILES:-false}" = "true" ]; then
    stage_warn "Debug/evidence files preserved because --keep-debug-files/--debug was used."
    return 0
  fi

  # Keep only final user-facing artifacts.
  find "$OUTDIR" -maxdepth 1 -type f \
    ! -name "$(basename "$REPORT")" \
    ! -name "$(basename "$JSON_REPORT")" \
    -name "*_$TS.*" \
    -delete 2>/dev/null || true

  stage_ok "Temporary working/evidence files removed. Use --keep-debug-files to preserve them."
}


is_real_storage_mount() {
  local fs="$1"
  local mount="$2"
  local fstype="$3"
  local opts="${4:-}"

  # Exclude pseudo/virtual/read-only image mounts that routinely show 100%.
  case "$fstype" in
    tmpfs|devtmpfs|squashfs|overlay|aufs|proc|sysfs|devpts|cgroup|cgroup2|securityfs|debugfs|tracefs|fusectl|configfs|efivarfs|mqueue|hugetlbfs|pstore|autofs|rpc_pipefs|iso9660|udf)
      return 1
      ;;
  esac

  case "$fs" in
    /dev/loop*|loop*|udev|tmpfs|overlay)
      return 1
      ;;
  esac

  case "$mount" in
    /snap/*|/var/lib/snapd/snap/*|/cdrom|/media/*|/mnt/cdrom|/run/*|/dev/*|/proc/*|/sys/*)
      return 1
      ;;
  esac

  # Read-only loop/image mounts are not actionable disk/inode warnings.
  if printf '%s' "$opts" | grep -Eq '(^|,)ro(,|$)'; then
    case "$fs" in
      /dev/loop*|loop*) return 1 ;;
    esac
  fi

  return 0
}

collect_ops_health_checks() {
  : > "$OPSHEALTH_TSV"
  : > "$OPSHEALTH_RAW"
  printf "severity\tarea\titem\tevidence\trecommendation\n" >> "$OPSHEALTH_TSV"

  add_ops_row() {
    local sev="$1" area="$2" item="$3" evidence="$4" rec="$5"
    printf "%s\t%s\t%s\t%s\t%s\n" "$sev" "$area" "$item" "$evidence" "$rec" >> "$OPSHEALTH_TSV"
    [ "$sev" != "Info" ] && add_finding "$sev" "$area" "$item" "$evidence" "$rec"
  }

  {
    echo "### systemd failed units"
    if cmd_exists systemctl; then
      systemctl --failed --no-pager 2>&1 || true
    else
      echo "systemctl unavailable"
    fi
    echo
    echo "### time sync"
    timedatectl status 2>&1 || true
    echo
    cmd_exists chronyc && { echo "### chronyc tracking"; chronyc tracking 2>&1 || true; echo "### chronyc sources"; chronyc sources -v 2>&1 || true; }
    cmd_exists ntpq && { echo "### ntpq peers"; ntpq -pn 2>&1 || true; }
    echo
    echo "### disk free and inode use"
    df -PT 2>&1 || true
    echo
    df -Pi 2>&1 || true
    echo
    echo "### automatic security patching"
    systemctl is-enabled unattended-upgrades 2>/dev/null || true
    systemctl is-active unattended-upgrades 2>/dev/null || true
    systemctl list-timers --all 2>/dev/null | grep -Ei 'apt|dnf|yum|zypper|unattended|dnf-automatic' || true
    grep -RInE 'Unattended-Upgrade|Automatic-Reboot|apply_updates|download_updates' /etc/apt/apt.conf.d /etc/dnf /etc/yum /etc/zypp 2>/dev/null | head -100 || true
  } >> "$OPSHEALTH_RAW"

  # systemd failed units
  if cmd_exists systemctl; then
    failed_units="$(systemctl --failed --no-legend --plain 2>/dev/null | awk 'NF{print $1}' | paste -sd ', ' -)"
    failed_count="$(systemctl --failed --no-legend --plain 2>/dev/null | awk 'NF{c++} END{print c+0}')"
    if [ "${failed_count:-0}" -gt 0 ]; then
      add_ops_row "Medium" "Operations" "systemd failed units detected" "$failed_count failed unit(s): $failed_units" "Review failed units with systemctl --failed and journalctl -u <unit>."
    else
      printf "Info\tOperations\tsystemd failed units\tNone detected\tNo action required.\n" >> "$OPSHEALTH_TSV"
    fi
  fi

  # NTP/time sync
  if cmd_exists timedatectl; then
    ntp_sync="$(timedatectl show -p NTPSynchronized --value 2>/dev/null || true)"
    ntp_service="$(timedatectl show -p NTP --value 2>/dev/null || true)"
    if [ "$ntp_sync" != "yes" ]; then
      add_ops_row "Medium" "Time sync" "NTP is not synchronized" "NTPSynchronized=${ntp_sync:-unknown}, NTP=${ntp_service:-unknown}" "Enable and verify chrony/systemd-timesyncd/ntpd and confirm time synchronization."
    else
      printf "Info\tTime sync\tNTP synchronized\tYes\tNo action required.\n" >> "$OPSHEALTH_TSV"
    fi
  else
    add_ops_row "Low" "Time sync" "timedatectl unavailable" "Could not query timedatectl" "Verify NTP/time synchronization manually."
  fi

  if cmd_exists chronyc; then
    offset="$(chronyc tracking 2>/dev/null | awk -F: '/Last offset|System time/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2; exit}' | awk '{print $1}' || true)"
    if printf '%s' "$offset" | grep -Eq '^-?[0-9.]+$'; then
      # chrony reports seconds in many fields. Warn above 1 second.
      awk -v o="$offset" 'BEGIN{if(o<0)o=-o; exit !(o>1)}' && add_ops_row "Medium" "Time sync" "NTP time offset appears high" "chrony offset=${offset}s" "Investigate NTP sources and virtualization time sync."
    fi
  fi

  # Disk free / inode warnings.
  # Use findmnt metadata to avoid false positives from snap squashfs, loop devices, CD/DVD media and pseudo mounts.
  if cmd_exists findmnt; then
    findmnt -rn -o SOURCE,TARGET,FSTYPE,OPTIONS 2>/dev/null | while IFS= read -r mline; do
      fs="$(printf '%s' "$mline" | awk '{print $1}')"
      mount="$(printf '%s' "$mline" | awk '{print $2}')"
      fstype="$(printf '%s' "$mline" | awk '{print $3}')"
      opts="$(printf '%s' "$mline" | cut -d' ' -f4-)"
      is_real_storage_mount "$fs" "$mount" "$fstype" "$opts" || continue

      use="$(df -P "$mount" 2>/dev/null | awk 'NR==2{gsub("%","",$5); print $5}')"
      avail="$(df -hP "$mount" 2>/dev/null | awk 'NR==2{print $4}')"
      if [ "${use:-0}" -ge 95 ] 2>/dev/null; then
        add_ops_row "High" "Storage" "Filesystem almost full" "$mount use=${use}% avail=$avail fs=$fs type=$fstype" "Free disk space or expand the filesystem."
      elif [ "${use:-0}" -ge 85 ] 2>/dev/null; then
        add_ops_row "Medium" "Storage" "Filesystem free space is low" "$mount use=${use}% avail=$avail fs=$fs type=$fstype" "Review disk usage and growth trend."
      fi

      iuse="$(df -Pi "$mount" 2>/dev/null | awk 'NR==2{gsub("%","",$5); print $5}')"
      if [ "${iuse:-0}" -ge 95 ] 2>/dev/null; then
        add_ops_row "High" "Storage" "Filesystem inode usage is critical" "$mount inode_use=${iuse}% fs=$fs type=$fstype" "Free files/inodes or expand inode capacity."
      elif [ "${iuse:-0}" -ge 85 ] 2>/dev/null; then
        add_ops_row "Medium" "Storage" "Filesystem inode usage is high" "$mount inode_use=${iuse}% fs=$fs type=$fstype" "Review many-small-file growth and cleanup policy."
      fi
    done
  else
    df -PT 2>/dev/null | awk 'NR>1 && $1 !~ /^\/dev\/loop/ && $2 !~ /tmpfs|devtmpfs|squashfs|overlay|iso9660|udf/ && $7 !~ /^\/snap\// {gsub("%","",$6); print $1 "\t" $7 "\t" $6 "\t" $5}' | while IFS="$(printf '\t')" read -r fs mount use avail; do
      [ -z "$mount" ] && continue
      if [ "${use:-0}" -ge 95 ] 2>/dev/null; then
        add_ops_row "High" "Storage" "Filesystem almost full" "$mount use=${use}% avail=$avail fs=$fs" "Free disk space or expand the filesystem."
      elif [ "${use:-0}" -ge 85 ] 2>/dev/null; then
        add_ops_row "Medium" "Storage" "Filesystem free space is low" "$mount use=${use}% avail=$avail fs=$fs" "Review disk usage and growth trend."
      fi
    done

    df -Pi 2>/dev/null | awk 'NR>1 && $1 !~ /^\/dev\/loop/ && $6 !~ /^\/snap\// {gsub("%","",$5); print $1 "\t" $6 "\t" $5}' | while IFS="$(printf '\t')" read -r fs mount iuse; do
      [ -z "$mount" ] && continue
      if [ "${iuse:-0}" -ge 95 ] 2>/dev/null; then
        add_ops_row "High" "Storage" "Filesystem inode usage is critical" "$mount inode_use=${iuse}% fs=$fs" "Free files/inodes or expand inode capacity."
      elif [ "${iuse:-0}" -ge 85 ] 2>/dev/null; then
        add_ops_row "Medium" "Storage" "Filesystem inode usage is high" "$mount inode_use=${iuse}% fs=$fs" "Review many-small-file growth and cleanup policy."
      fi
    done
  fi

  # Automatic security patching
  auto_patch_status="unknown"
  if [ "$DISTRO_ID" = "ubuntu" ] || [ "$DISTRO_ID" = "debian" ] || printf '%s' "$DISTRO_FAMILY" | grep -qi debian; then
    if dpkg -s unattended-upgrades >/dev/null 2>&1; then
      enabled="$(systemctl is-enabled unattended-upgrades 2>/dev/null || true)"
      active="$(systemctl is-active unattended-upgrades 2>/dev/null || true)"
      apt_periodic="$(grep -RhsE 'APT::Periodic::Unattended-Upgrade[[:space:]]+"1"' /etc/apt/apt.conf.d 2>/dev/null | head -1 || true)"
      if [ "$enabled" = "enabled" ] || [ -n "$apt_periodic" ]; then
        auto_patch_status="enabled"
        printf "Info\tPatch management\tunattended-upgrades configured\tservice=${enabled:-unknown}/${active:-unknown}\tReview policy and maintenance windows.\n" >> "$OPSHEALTH_TSV"
      else
        add_ops_row "Low" "Patch management" "unattended-upgrades installed but not clearly enabled" "service=${enabled:-unknown}/${active:-unknown}" "Enable unattended security updates where appropriate, or document manual patch process."
      fi
    else
      add_ops_row "Low" "Patch management" "unattended-upgrades not installed" "Package not detected" "Consider unattended-upgrades or document manual security patch process."
    fi
  elif printf '%s %s' "$DISTRO_ID" "$DISTRO_FAMILY" | grep -Eqi 'rhel|fedora|centos|rocky|almalinux|ol'; then
    if rpm -q dnf-automatic >/dev/null 2>&1 || rpm -q yum-cron >/dev/null 2>&1; then
      enabled="$(systemctl is-enabled dnf-automatic.timer yum-cron 2>/dev/null | paste -sd ',' - || true)"
      printf "Info\tPatch management\tautomatic update tooling detected\t$enabled\tReview dnf-automatic/yum-cron policy.\n" >> "$OPSHEALTH_TSV"
    else
      add_ops_row "Low" "Patch management" "automatic security patching tool not detected" "dnf-automatic/yum-cron not detected" "Consider dnf-automatic/yum-cron or document manual security patch process."
    fi
  elif printf '%s %s' "$DISTRO_ID" "$DISTRO_FAMILY" | grep -Eqi 'suse|sles|opensuse'; then
    if systemctl list-timers --all 2>/dev/null | grep -qi zypper; then
      printf "Info\tPatch management\tzypper update timer detected\tTimer found\tReview zypper patch policy.\n" >> "$OPSHEALTH_TSV"
    else
      add_ops_row "Low" "Patch management" "automatic zypper patching not detected" "No obvious zypper timer found" "Consider SUSE patch automation or document manual patch process."
    fi
  fi
}

collect_local_tls_https_checks() {
  : > "$TLSLOCAL_TSV"
  : > "$TLSLOCAL_RAW"
  printf "severity\tarea\titem\tevidence\trecommendation\n" >> "$TLSLOCAL_TSV"

  add_tls_row() {
    local sev="$1" area="$2" item="$3" evidence="$4" rec="$5"
    printf "%s\t%s\t%s\t%s\t%s\n" "$sev" "$area" "$item" "$evidence" "$rec" >> "$TLSLOCAL_TSV"
    [ "$sev" != "Info" ] && add_finding "$sev" "$area" "$item" "$evidence" "$rec"
  }

  if ! cmd_exists openssl; then
    add_tls_row "Low" "TLS" "OpenSSL command unavailable" "openssl not found" "Install openssl or verify local HTTPS protocol posture manually."
    return 0
  fi

  ports="$(awk -F '\t' 'NR>1 && ($2=="443" || $2=="8443" || $2=="9443" || $2=="10443") {print $2}' "$PORTFILE" 2>/dev/null | sort -n | uniq)"
  [ -z "$ports" ] && ports="$(ss -ltn 2>/dev/null | awk '{print $4}' | sed -nE 's/.*:([0-9]+)$/\1/p' | grep -E '^(443|8443|9443|10443)$' | sort -n | uniq || true)"

  if [ -z "$ports" ]; then
    printf "Info\tTLS\tNo local HTTPS ports detected\tNo common HTTPS listeners found\tNo action required.\n" >> "$TLSLOCAL_TSV"
    return 0
  fi

  for port in $ports; do
    echo "### TLS check localhost:$port" >> "$TLSLOCAL_RAW"
    cert_subject=""
    cert_issuer=""
    cert_days=""
    proto_ok=""
    proto_bad=""

    # Cert info
    cert_text="$(echo | openssl s_client -connect "127.0.0.1:$port" -servername "$HOST" -showcerts 2>/dev/null | openssl x509 -noout -subject -issuer -enddate 2>/dev/null || true)"
    echo "$cert_text" >> "$TLSLOCAL_RAW"
    cert_subject="$(printf '%s\n' "$cert_text" | awk -F= '/^subject=/{print substr($0,9); exit}')"
    cert_issuer="$(printf '%s\n' "$cert_text" | awk -F= '/^issuer=/{print substr($0,8); exit}')"
    enddate="$(printf '%s\n' "$cert_text" | awk -F= '/^notAfter=/{print $2; exit}')"
    if [ -n "$enddate" ]; then
      end_epoch="$(date -d "$enddate" +%s 2>/dev/null || true)"
      now_epoch="$(date +%s)"
      [ -n "$end_epoch" ] && cert_days="$(( (end_epoch - now_epoch) / 86400 ))"
    fi

    for pflag in -tls1 -tls1_1; do
      if echo | openssl s_client "$pflag" -connect "127.0.0.1:$port" -servername "$HOST" >/dev/null 2>&1; then
        proto_bad="$proto_bad $pflag"
      fi
    done
    for pflag in -tls1_2 -tls1_3; do
      if echo | openssl s_client "$pflag" -connect "127.0.0.1:$port" -servername "$HOST" >/dev/null 2>&1; then
        proto_ok="$proto_ok $pflag"
      fi
    done

    cipher="$(echo | openssl s_client -connect "127.0.0.1:$port" -servername "$HOST" 2>/dev/null | awk -F: '/Cipher[[:space:]]*:/ {gsub(/^[ \t]+/,"",$2); print $2; exit}' || true)"
    echo "protocol_ok=$proto_ok protocol_bad=$proto_bad cipher=$cipher days=$cert_days subject=$cert_subject issuer=$cert_issuer" >> "$TLSLOCAL_RAW"

    if [ -n "$proto_bad" ]; then
      add_tls_row "High" "TLS" "Weak TLS protocol accepted locally" "port=$port weak_protocols=$proto_bad" "Disable TLS 1.0/1.1 on the local HTTPS service."
    fi
    if [ -z "$proto_ok" ]; then
      add_tls_row "Medium" "TLS" "Modern TLS protocol not confirmed" "port=$port TLS1.2/1.3 not confirmed" "Verify HTTPS configuration supports TLS 1.2 or TLS 1.3."
    fi
    if [ -z "$cert_subject" ]; then
      add_tls_row "Medium" "TLS" "Could not read local HTTPS certificate" "port=$port" "Verify certificate chain and local HTTPS service configuration."
    elif [ -n "$cert_days" ] && [ "$cert_days" -lt 0 ] 2>/dev/null; then
      add_tls_row "High" "TLS" "Local HTTPS certificate is expired" "port=$port days_remaining=$cert_days subject=$cert_subject issuer=$cert_issuer" "Renew the certificate immediately."
    elif [ -n "$cert_days" ] && [ "$cert_days" -le 30 ] 2>/dev/null; then
      add_tls_row "Medium" "TLS" "Local HTTPS certificate expires soon" "port=$port days_remaining=$cert_days subject=$cert_subject issuer=$cert_issuer" "Renew or verify automatic certificate renewal."
    else
      printf "Info\tTLS\tLocal HTTPS TLS check completed\tport=%s proto_ok=%s cipher=%s cert_days=%s\tNo immediate TLS finding generated.\n" "$port" "$proto_ok" "$cipher" "$cert_days" >> "$TLSLOCAL_TSV"
    fi
  done
}

collect_admin_panel_exposure() {
  : > "$ADMINPANELS_TSV"
  : > "$ADMINPANELS_RAW"
  printf "severity\tarea\tpanel\tport\tbind\tevidence\trecommendation\n" >> "$ADMINPANELS_TSV"

  add_admin_row() {
    local sev="$1" panel="$2" port="$3" bind="$4" evidence="$5" rec="$6"
    printf "%s\tAdmin Panels\t%s\t%s\t%s\t%s\t%s\n" "$sev" "$panel" "$port" "$bind" "$evidence" "$rec" >> "$ADMINPANELS_TSV"
    [ "$sev" != "Info" ] && add_finding "$sev" "Admin Panels" "$panel appears exposed" "port=$port bind=$bind $evidence" "$rec"
  }

  echo "### Admin panel exposure" >> "$ADMINPANELS_RAW"
  ss -ltnp 2>/dev/null >> "$ADMINPANELS_RAW" || ss -ltn 2>/dev/null >> "$ADMINPANELS_RAW" || true

  check_panel() {
    local panel="$1" port="$2"
    local rec="$3"
    matches="$(ss -ltnp 2>/dev/null | awk -v p=":$port" '$4 ~ p"$" {print $0}' || true)"
    [ -z "$matches" ] && matches="$(ss -ltn 2>/dev/null | awk -v p=":$port" '$4 ~ p"$" {print $0}' || true)"
    [ -z "$matches" ] && return 0

    bind="$(printf '%s\n' "$matches" | awk '{print $4}' | paste -sd ',' -)"
    sev="Medium"
    if printf '%s' "$bind" | grep -Eq '(^|,)(0\.0\.0\.0|\[::\]|\*:|::):?'; then
      sev="High"
    fi
    add_admin_row "$sev" "$panel" "$port" "$bind" "$matches" "$rec"
  }

  check_panel "Cockpit" "9090" "Restrict Cockpit exposure with firewall/VPN and strong authentication, or disable if unused."
  check_panel "Webmin" "10000" "Restrict Webmin to trusted networks/VPN and keep it updated."
  check_panel "Portainer" "9000" "Restrict Portainer exposure and enforce strong authentication."
  check_panel "Portainer HTTPS" "9443" "Restrict Portainer HTTPS exposure and enforce strong authentication."
  check_panel "Proxmox VE" "8006" "Restrict Proxmox management UI to admin networks/VPN only."
  check_panel "Grafana" "3000" "Restrict Grafana exposure and verify authentication."
  check_panel "Kibana" "5601" "Restrict Kibana exposure and verify authentication."
  check_panel "Prometheus" "9090" "Restrict Prometheus/admin metrics exposure."
  check_panel "Node Exporter" "9100" "Restrict metrics endpoints to monitoring networks."
  check_panel "pgAdmin" "5050" "Restrict pgAdmin exposure and enforce strong authentication."
  check_panel "phpMyAdmin/common web admin" "8080" "Verify this port is not an exposed admin panel unless intended."
}

collect_backup_restore_clues_enhanced() {
  # Keep existing backup collector if present, then add last-modified evidence.
  collect_backup_restore_clues 2>/dev/null || true

  echo "### Enhanced backup evidence" >> "$BACKUP_RAW"
  {
    echo "Backup tools:"
    for c in restic borg rclone rsync duplicity timeshift veeamconfig proxmox-backup-client pgbackrest wal-g barman bacula-fd bareos-fd; do
      if cmd_exists "$c"; then echo "$c: $(command -v "$c")"; fi
    done
    echo
    echo "Likely backup directories recent files:"
    for d in /backup /backups /var/backups /srv/backup /srv/backups /mnt/backup /mnt/backups; do
      [ -d "$d" ] || continue
      echo "--- $d"
      find "$d" -xdev -type f -printf '%TY-%Tm-%Td %TH:%TM %s %p\n' 2>/dev/null | sort -r | head -10
    done
    echo
    echo "Backup-related timers/services:"
    systemctl list-timers --all 2>/dev/null | grep -Ei 'backup|borg|restic|rsync|duplicity|veeam|proxmox' || true
    systemctl list-unit-files 2>/dev/null | grep -Ei 'backup|borg|restic|rsync|duplicity|veeam|bacula|bareos' || true
  } >> "$BACKUP_RAW"

  tool_count=0
  for c in restic borg rclone rsync duplicity timeshift veeamconfig proxmox-backup-client pgbackrest wal-g barman bacula-fd bareos-fd; do
    cmd_exists "$c" && tool_count=$((tool_count+1))
  done

  newest=""
  for d in /backup /backups /var/backups /srv/backup /srv/backups /mnt/backup /mnt/backups; do
    [ -d "$d" ] || continue
    item="$(find "$d" -xdev -type f -printf '%T@ %TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | sort -nr | head -1 || true)"
    [ -n "$item" ] && newest="$item" && break
  done

  if [ "$tool_count" -eq 0 ] && [ -z "$newest" ]; then
    add_finding "Info" "Backups" "No obvious backup tooling or backup directory evidence detected" "No common backup tools/directories found" "Verify backups and restore tests manually."
  elif [ -n "$newest" ]; then
    backup_date="$(printf '%s' "$newest" | awk '{print $2" "$3}')"
    backup_path="$(printf '%s' "$newest" | cut -d' ' -f4-)"
    printf "Info\tBackups\tLatest backup-like file evidence\t%s %s\tVerify restore tests.\n" "$backup_date" "$backup_path" >> "$BACKUP_TSV"
  fi
}

render_ops_health_html() {
  if [ ! -s "$OPSHEALTH_TSV" ]; then echo "<div class='note'>No operations health data collected.</div>"; return; fi
  echo "<table><tr><th>Severity</th><th>Area</th><th>Item</th><th>Evidence</th><th>Recommendation</th></tr>"
  tail -n +2 "$OPSHEALTH_TSV" | while IFS="$(printf '\t')" read -r sev area item evidence rec; do
    cls="sev-info"; [ "$sev" = "High" ] && cls="sev-high"; [ "$sev" = "Medium" ] && cls="sev-medium"; [ "$sev" = "Low" ] && cls="sev-low"
    echo "<tr><td><span class='sev $cls'>$(printf '%s' "$sev" | html_escape)</span></td><td>$(printf '%s' "$area" | html_escape)</td><td><b>$(printf '%s' "$item" | html_escape)</b></td><td>$(printf '%s' "$evidence" | html_escape)</td><td>$(printf '%s' "$rec" | html_escape)</td></tr>"
  done
  echo "</table><details><summary>Raw operations health evidence</summary><pre>$(cat "$OPSHEALTH_RAW" 2>/dev/null | html_escape)</pre></details>"
}

render_local_tls_html() {
  if [ ! -s "$TLSLOCAL_TSV" ]; then echo "<div class='note'>No local TLS data collected.</div>"; return; fi
  echo "<table><tr><th>Severity</th><th>Area</th><th>Item</th><th>Evidence</th><th>Recommendation</th></tr>"
  tail -n +2 "$TLSLOCAL_TSV" | while IFS="$(printf '\t')" read -r sev area item evidence rec; do
    cls="sev-info"; [ "$sev" = "High" ] && cls="sev-high"; [ "$sev" = "Medium" ] && cls="sev-medium"; [ "$sev" = "Low" ] && cls="sev-low"
    echo "<tr><td><span class='sev $cls'>$(printf '%s' "$sev" | html_escape)</span></td><td>$(printf '%s' "$area" | html_escape)</td><td><b>$(printf '%s' "$item" | html_escape)</b></td><td>$(printf '%s' "$evidence" | html_escape)</td><td>$(printf '%s' "$rec" | html_escape)</td></tr>"
  done
  echo "</table><details><summary>Raw local TLS evidence</summary><pre>$(cat "$TLSLOCAL_RAW" 2>/dev/null | html_escape)</pre></details>"
}

render_admin_panels_html() {
  if [ ! -s "$ADMINPANELS_TSV" ] || [ "$(wc -l < "$ADMINPANELS_TSV" | tr -d ' ')" -le 1 ]; then
    echo "<div class='scope-ok'><b>No common admin panel listeners detected on standard ports.</b></div>"
    return
  fi
  echo "<table><tr><th>Severity</th><th>Panel</th><th>Port</th><th>Bind</th><th>Evidence</th><th>Recommendation</th></tr>"
  tail -n +2 "$ADMINPANELS_TSV" | while IFS="$(printf '\t')" read -r sev area panel port bind evidence rec; do
    cls="sev-info"; [ "$sev" = "High" ] && cls="sev-high"; [ "$sev" = "Medium" ] && cls="sev-medium"; [ "$sev" = "Low" ] && cls="sev-low"
    echo "<tr><td><span class='sev $cls'>$(printf '%s' "$sev" | html_escape)</span></td><td><b>$(printf '%s' "$panel" | html_escape)</b></td><td>$(printf '%s' "$port" | html_escape)</td><td>$(printf '%s' "$bind" | html_escape)</td><td>$(printf '%s' "$evidence" | html_escape)</td><td>$(printf '%s' "$rec" | html_escape)</td></tr>"
  done
  echo "</table><details><summary>Raw admin panel evidence</summary><pre>$(cat "$ADMINPANELS_RAW" 2>/dev/null | html_escape)</pre></details>"
}

render_enabled_websites_html() {
  if [ ! -s "$WEBSITES_TSV" ] || [ "$(wc -l < "$WEBSITES_TSV" | tr -d ' ')" -le 1 ]; then
    echo "<div class='scope-ok'><b>No enabled Apache/nginx website definitions were detected in standard locations.</b></div>"
    return
  fi

  echo "<table><tr><th>Server</th><th>Engine</th><th>SSL</th><th>Document root</th><th>Permissions</th><th>Certificate</th><th>Certificate status</th><th>Days</th><th>Config</th></tr>"
  tail -n +2 "$WEBSITES_TSV" | while IFS="$(printf '\t')" read -r server engine cfg ssl root owner mode perm cert cert_status days note; do
    ssl_badge="source-warn"
    [ "$ssl" = "yes" ] && ssl_badge="source-ok"

    cert_badge="source-warn"
    [ "$cert_status" = "valid" ] && cert_badge="source-ok"
    [ "$cert_status" = "expired" ] && cert_badge="source-fail"

    perm_badge="source-ok"
    [ "$perm" = "world_writable" ] && perm_badge="source-fail"
    [ "$perm" = "group_writable_review" ] && perm_badge="source-warn"
    [ "$perm" = "missing" ] && perm_badge="source-fail"

    echo "<tr>"
    echo "<td>$(printf '%s' "$server" | html_escape)</td>"
    echo "<td>$(printf '%s' "$engine" | html_escape)</td>"
    echo "<td><span class='source-badge $ssl_badge'>$(printf '%s' "$ssl" | html_escape)</span></td>"
    echo "<td>$(printf '%s' "$root" | html_escape)<br><span class='muted'>$(printf '%s' "$owner" | html_escape) mode $(printf '%s' "$mode" | html_escape)</span></td>"
    echo "<td><span class='source-badge $perm_badge'>$(printf '%s' "$perm" | html_escape)</span></td>"
    echo "<td>$(printf '%s' "$cert" | html_escape)</td>"
    echo "<td><span class='source-badge $cert_badge'>$(printf '%s' "$cert_status" | html_escape)</span></td>"
    echo "<td>$(printf '%s' "$days" | html_escape)</td>"
    echo "<td>$(printf '%s' "$cfg" | html_escape)<br><span class='muted'>$(printf '%s' "$note" | html_escape)</span></td>"
    echo "</tr>"
  done
  echo "</table>"
  echo "<details><summary>Raw enabled website evidence</summary><pre>$(cat "$WEBSITES_RAW" 2>/dev/null | html_escape)</pre></details>"
}

collect_web_stack_posture() {
  : > "$WEBSTACK_TSV"; : > "$WEBSTACK_RAW"
  printf "severity\tcomponent\titem\tevidence\trecommendation\n" >> "$WEBSTACK_TSV"

  if cmd_exists apache2ctl || cmd_exists apachectl; then
    apcmd="$(command -v apache2ctl 2>/dev/null || command -v apachectl 2>/dev/null)"
    {
      echo "### Apache modules"
      "$apcmd" -M 2>&1 | head -200 || true
      echo "### Apache vhosts"
      "$apcmd" -S 2>&1 | head -200 || true
    } >> "$WEBSTACK_RAW"
    if ! grep -RiqE '^[[:space:]]*ServerTokens[[:space:]]+Prod' /etc/apache2 /etc/httpd 2>/dev/null; then
      printf "Low\tApache\tServerTokens not clearly set to Prod\tNo ServerTokens Prod found\tSet ServerTokens Prod to reduce banner detail.\n" >> "$WEBSTACK_TSV"
    fi
    if ! grep -RiqE '^[[:space:]]*ServerSignature[[:space:]]+Off' /etc/apache2 /etc/httpd 2>/dev/null; then
      printf "Low\tApache\tServerSignature not clearly Off\tNo ServerSignature Off found\tSet ServerSignature Off.\n" >> "$WEBSTACK_TSV"
    fi
    if grep -RiqE 'Options[[:space:]].*Indexes' /etc/apache2 /etc/httpd 2>/dev/null; then
      printf "Medium\tApache\tDirectory listing may be enabled\tOptions Indexes found\tDisable Indexes unless intentionally required.\n" >> "$WEBSTACK_TSV"
      add_finding "Medium" "Web Stack" "Apache directory listing may be enabled" "Options Indexes found" "Disable Indexes unless intentionally required."
    fi
  fi

  if cmd_exists nginx; then
    {
      echo "### nginx config test"
      nginx -T 2>&1 | head -400 || true
    } >> "$WEBSTACK_RAW"
    if ! nginx -T 2>/dev/null | grep -qi 'server_tokens[[:space:]]\+off'; then
      printf "Low\tnginx\tserver_tokens not clearly off\tNo server_tokens off found\tSet server_tokens off.\n" >> "$WEBSTACK_TSV"
    fi
    if nginx -T 2>/dev/null | grep -qi 'autoindex[[:space:]]\+on'; then
      printf "Medium\tnginx\tDirectory listing may be enabled\tautoindex on found\tDisable autoindex unless intentionally required.\n" >> "$WEBSTACK_TSV"
      add_finding "Medium" "Web Stack" "nginx directory listing may be enabled" "autoindex on found" "Disable autoindex unless intentionally required."
    fi
  fi

  if cmd_exists php-fpm8.3 || cmd_exists php-fpm || ls /etc/php/*/fpm/pool.d/*.conf >/dev/null 2>&1; then
    echo "### PHP-FPM pools" >> "$WEBSTACK_RAW"
    grep -RInE '^\[|^user|^group|^listen|^pm\.|^security\.limit_extensions' /etc/php/*/fpm/pool.d /etc/php-fpm.d 2>/dev/null | head -250 >> "$WEBSTACK_RAW" || true
    if ! grep -RiqE '^security\.limit_extensions' /etc/php/*/fpm/pool.d /etc/php-fpm.d 2>/dev/null; then
      printf "Info\tPHP-FPM\tsecurity.limit_extensions not found\tNo security.limit_extensions found in pool configs\tConsider explicitly limiting PHP-FPM executable extensions.\n" >> "$WEBSTACK_TSV"
    fi
  fi
}

collect_backup_restore_clues() {
  : > "$BACKUP_TSV"; : > "$BACKUP_RAW"
  printf "tool\tstatus\tevidence\n" >> "$BACKUP_TSV"

  for tool in restic borg borgbackup rsnapshot veeam rclone duplicity tar; do
    if cmd_exists "$tool"; then
      printf "%s\tinstalled\t%s\n" "$tool" "$(command -v "$tool")" >> "$BACKUP_TSV"
      echo "$tool installed at $(command -v "$tool")" >> "$BACKUP_RAW"
    fi
  done

  echo "### Backup-like cron/systemd clues" >> "$BACKUP_RAW"
  grep -RInEi 'backup|restic|borg|rsnapshot|rclone|duplicity|veeam|tar .*backup' /etc/cron* /var/spool/cron* /etc/systemd/system 2>/dev/null | head -120 >> "$BACKUP_RAW" || true
}

build_strengths_and_verify() {
  : > "$STRENGTHS_TSV"; : > "$VERIFY_TSV"
  printf "area\tstrength\tevidence\n" >> "$STRENGTHS_TSV"
  printf "area\titem\treason\n" >> "$VERIFY_TSV"

  # Strengths
  if [ "$VERSION_FEED_STATUS" = "Current" ]; then
    printf "Version\tScanner is current\tLocal script matches Linux version feed\n" >> "$STRENGTHS_TSV"
  fi
  if [ -s "$LIFECYCLE_RESPONSE" ] && grep -q '"end_of_support"[[:space:]]*:[[:space:]]*0' "$LIFECYCLE_RESPONSE" 2>/dev/null; then
    printf "Lifecycle\tNo EOL lifecycle items returned\tLifecycle API did not report end-of-support items\n" >> "$STRENGTHS_TSV"
  fi
  if grep -q $'\tLoopback only\t' "$EXPOSURE_TSV" 2>/dev/null; then
    printf "Exposure\tSome sensitive services are loopback-only\tAt least one listener is bound to loopback\n" >> "$STRENGTHS_TSV"
  fi
  if cmd_exists fail2ban-client; then
    printf "Hardening\tFail2ban detected\tfail2ban-client present\n" >> "$STRENGTHS_TSV"
  fi

  # Manual verification reminders
  printf "CVE\tConfirm review-only CVE candidates against distro advisories\tLinux distributions backport fixes and product-version matching can be noisy\n" >> "$VERIFY_TSV"
  printf "Firewall\tVerify upstream/cloud/firewall policy\tLocal firewall state alone does not prove external exposure\n" >> "$VERIFY_TSV"
  printf "Backups\tVerify restore tests\tTool presence or cron clues do not prove recoverability\n" >> "$VERIFY_TSV"
  printf "Secrets\tReview possible secret matches manually\tPattern matching can produce false positives\n" >> "$VERIFY_TSV"
}

build_score_categories() {
  : > "${SCORE_TSV:-$OUTDIR/security_score_categories_$TS.tsv}"
  printf "category	score	penalty	max_penalty	reason	disclaimer
" >> "$SCORE_TSV"

  score_area() {
    local category="$1"
    local regex="$2"
    local max="$3"
    local reason="$4"
    local caveat="$5"
    local raw penalty score

    if [ -s "${FINDINGS:-}" ]; then
      raw="$(awk -F '	' -v r="$regex" '
        BEGIN{IGNORECASE=1}
        NF>=3 && ($2 ~ r || $3 ~ r) {
          if($1=="Critical") p+=20
          else if($1=="High") p+=12
          else if($1=="Medium") p+=6
          else if($1=="Low") p+=2
          else if($1=="Info") p+=1
        }
        END{print p+0}
      ' "$FINDINGS" 2>/dev/null || echo 0)"
    else
      raw=0
    fi

    case "${raw:-0}" in ''|*[!0-9]*) raw=0 ;; esac
    penalty="$raw"
    [ "$penalty" -gt "$max" ] 2>/dev/null && penalty="$max"
    score=$((100 - (penalty * 100 / max)))

    printf "%s	%s	%s	%s	%s	%s
" "$category" "$score" "$penalty" "$max" "$reason" "$caveat" >> "$SCORE_TSV"
  }

  score_area "SSH" "SSH" 20 "SSH authentication and daemon hardening findings." "SSH risk depends on exposure, MFA, bastions, key policy and upstream firewalling."
  score_area "Firewall / Exposure" "Firewall|Exposure|All interfaces|Ports|Admin Panels" 20 "Host firewall findings and listener exposure classification." "Local listener state does not prove internet exposure. Upstream firewalls, cloud security groups and routing must be verified."
  score_area "Identity / Sudo" "Accounts|Sudo|UID 0|root account|service accounts" 15 "Root, service-account and sudo posture findings." "Administrative exceptions may be intentional; review against documented operating procedure."
  score_area "Updates / Reboot" "Updates|Reboot|security updates|package updates|Patch management|automatic" 15 "Pending update, security update and reboot-required posture." "Package update counts are distro-tool output and can be affected by stale repositories or pinned packages."
  score_area "Kernel / sysctl" "Kernel|sysctl|tcp_syncookies|redirects|source_route|ASLR|dmesg|kptr|sysrq" 15 "Kernel, network and sysctl hardening findings." "Some sysctl values are role-dependent; routers, VPN gateways and appliances can need different settings."
  score_area "Audit / Logging / LSM" "Audit|logging|LSM|AppArmor|SELinux|AIDE|remote syslog|Operations|Time sync|Storage" 15 "auditd, logging, AppArmor/SELinux and integrity-monitoring posture." "Tool absence is not proof of missing control if another logging or EDR platform provides equivalent coverage."
  score_area "Web / Services" "Web|Web Sites|Apache|nginx|Redis|Service|SSL|certificate|document root|TLS" 15 "Web, Redis, websites and service-specific hardening findings." "Service checks are config-pattern based and should be confirmed against active runtime configuration."
  score_area "CVE Review" "CVE|vulnerab" 15 "CVE candidates returned by the Scantide CVE API." "Linux distributions commonly backport fixes. CVE candidates are review leads, not confirmed vulnerable proof."
  score_area "Lifecycle" "Lifecycle|end of support|unsupported|latest version|update available|Latest Version" 15 "Lifecycle and update-available findings for OS, kernel and major services." "Lifecycle latest-version data can differ between vendor, distro and community-observed sources."

  OVERALL_SECURITY_SCORE="$(awk -F '	' 'NR>1{s+=$2;c++} END{if(c>0) print int(s/c); else print 100}' "$SCORE_TSV" 2>/dev/null || echo 100)"
  case "${OVERALL_SECURITY_SCORE:-100}" in ''|*[!0-9]*) OVERALL_SECURITY_SCORE=100 ;; esac

  OVERALL_SECURITY_BAND="Excellent"
  OVERALL_SECURITY_CLASS="ok"
  if [ "$OVERALL_SECURITY_SCORE" -lt 30 ]; then
    OVERALL_SECURITY_BAND="Critical"; OVERALL_SECURITY_CLASS="risk"
  elif [ "$OVERALL_SECURITY_SCORE" -lt 50 ]; then
    OVERALL_SECURITY_BAND="Poor"; OVERALL_SECURITY_CLASS="risk"
  elif [ "$OVERALL_SECURITY_SCORE" -lt 70 ]; then
    OVERALL_SECURITY_BAND="Needs attention"; OVERALL_SECURITY_CLASS="warn"
  elif [ "$OVERALL_SECURITY_SCORE" -lt 85 ]; then
    OVERALL_SECURITY_BAND="Good"; OVERALL_SECURITY_CLASS="warn"
  else
    OVERALL_SECURITY_BAND="Excellent"; OVERALL_SECURITY_CLASS="ok"
  fi

  return 0
}

render_simple_tsv_table() {
  local file="$1"; local empty="$2"
  if [ ! -s "$file" ] || [ "$(wc -l < "$file" | tr -d ' ')" -le 1 ]; then
    echo "<div class='scope-ok'><b>$(printf '%s' "$empty" | html_escape)</b></div>"
    return
  fi
  echo "<table>"
  head -1 "$file" | awk -F '\t' '{printf "<tr>"; for(i=1;i<=NF;i++) printf "<th>%s</th>", $i; print "</tr>"}'
  tail -n +2 "$file" | while IFS="$(printf '\t')" read -r a b c d e f g h rest; do
    echo "<tr><td>$(printf '%s' "$a" | html_escape)</td><td>$(printf '%s' "$b" | html_escape)</td><td>$(printf '%s' "$c" | html_escape)</td><td>$(printf '%s' "$d" | html_escape)</td><td>$(printf '%s' "$e" | html_escape)</td><td>$(printf '%s' "$f" | html_escape)</td><td>$(printf '%s' "$g" | html_escape)</td><td>$(printf '%s' "$h" | html_escape)</td></tr>"
  done
  echo "</table>"
}


hero_score_value() {
  if [ -s "${SCORE_TSV:-}" ]; then
    awk -F '\t' 'NR>1{s+=$2;c++} END{if(c>0) print int(s/c); else print 100}' "$SCORE_TSV" 2>/dev/null || printf '%s' "${SECURITY_SCORE:-100}"
  else
    printf '%s' "${SECURITY_SCORE:-100}"
  fi
}

hero_score_band() {
  local s
  s="$(hero_score_value)"
  case "$s" in ''|*[!0-9]*) echo "Unknown"; return ;;
  esac
  if [ "$s" -lt 30 ]; then echo "Critical"
  elif [ "$s" -lt 50 ]; then echo "Poor"
  elif [ "$s" -lt 70 ]; then echo "Needs attention"
  elif [ "$s" -lt 85 ]; then echo "Good"
  else echo "Excellent"
  fi
}

hero_score_class() {
  local s
  s="$(hero_score_value)"
  case "$s" in ''|*[!0-9]*) echo "warn"; return ;;
  esac
  if [ "$s" -lt 50 ]; then echo "risk"
  elif [ "$s" -lt 85 ]; then echo "warn"
  else echo "ok"
  fi
}

render_score_categories_html() {
  if [ ! -s "$SCORE_TSV" ]; then
    echo "<div class='note'>No score categories available.</div>"
    return
  fi

  echo "<div class='disclaimer'><b>Scoring disclaimer:</b> The Scantide Linux score is a triage indicator, not a compliance grade, breach prediction, or proof of vulnerability. The model is intentionally capped by category so one noisy area cannot force every server to zero. Always review the evidence, compensating controls, distro backports and business role before deciding remediation priority.</div>"
  echo "<table><tr><th>Overall score</th><td><span class='value ${OVERALL_SECURITY_CLASS:-warn}'>${OVERALL_SECURITY_SCORE:-unknown}</span></td><th>Band</th><td><b>${OVERALL_SECURITY_BAND:-Unknown}</b></td></tr></table>"
  echo "<table><tr><th>Category</th><th>Score</th><th>Penalty</th><th>Cap</th><th>Reason</th><th>Important caveat</th></tr>"
  tail -n +2 "$SCORE_TSV" | while IFS="$(printf '	')" read -r cat score penalty max reason disclaimer; do
    cls="ok"
    [ "$score" -lt 70 ] 2>/dev/null && cls="warn"
    [ "$score" -lt 50 ] 2>/dev/null && cls="risk"
    echo "<tr><td><b>$(printf '%s' "$cat" | html_escape)</b><div class='scorebar'><span style='width:${score}%'></span></div></td><td><span class='$cls'><b>$score</b></span></td><td>$penalty</td><td>$max</td><td>$(printf '%s' "$reason" | html_escape)</td><td>$(printf '%s' "$disclaimer" | html_escape)</td></tr>"
  done
  echo "</table>"
  echo "<div class='note'><b>Score bands:</b> 85-100 Excellent, 70-84 Good, 50-69 Needs attention, 30-49 Poor, below 30 Critical. These bands are advisory and should not replace judgement or formal compliance tooling.</div>"
}

render_top_risks_strengths_html() {
  echo "<div class='top-risk-grid'>"
  echo "<div class='mini-card'><h3>Top Risks</h3><ol>"
  if [ -s "$FINDINGS" ]; then
    awk -F '\t' 'BEGIN{rank["Critical"]=1;rank["High"]=2;rank["Medium"]=3;rank["Low"]=4;rank["Info"]=5} NF>=3 && $3!="" {print rank[$1] "\t" $3 "\t" $2}' "$FINDINGS" | sort -n | head -5 | while IFS="$(printf '\t')" read -r r title area; do
      echo "<li><b>$(printf '%s' "$title" | html_escape)</b><br><span class='muted'>$(printf '%s' "$area" | html_escape)</span></li>"
    done
  else
    echo "<li>No findings generated by current rules.</li>"
  fi
  echo "</ol></div>"
  echo "<div class='mini-card'><h3>Top Strengths</h3><ol>"
  if [ -s "$STRENGTHS_TSV" ] && [ "$(wc -l < "$STRENGTHS_TSV" | tr -d ' ')" -gt 1 ]; then
    tail -n +2 "$STRENGTHS_TSV" | head -5 | while IFS="$(printf '\t')" read -r area strength evidence; do
      echo "<li><b>$(printf '%s' "$strength" | html_escape)</b><br><span class='muted'>$(printf '%s' "$evidence" | html_escape)</span></li>"
    done
  else
    echo "<li>No strengths generated by current rules.</li>"
  fi
  echo "</ol></div>"
  echo "</div>"
}



render_findings_table_html() {
  render_findings_cards_html
}

render_quick_actions_table_html() {
  render_quick_actions_cards_html
}

# previous renderer retained below
render_findings_table_html() {
  if [ ! -s "$FINDINGS" ]; then
    echo "<div class='scope-ok'><b>No local findings generated by the current rule set.</b></div>"
    return
  fi

  echo "<table><tr><th>Severity</th><th>Area</th><th>Finding / Evidence</th><th>Recommendation</th></tr>"
  while IFS="$(printf '\t')" read -r severity area title evidence recommendation; do
    [ -z "${title:-}" ] && continue
    sevclass="sev-info"
    case "$severity" in
      Critical) sevclass="sev-critical" ;;
      High) sevclass="sev-high" ;;
      Medium) sevclass="sev-medium" ;;
      Low) sevclass="sev-low" ;;
      Info) sevclass="sev-info" ;;
    esac
    remediation_html="$(render_remediation_for_finding "$title" "$area")"
    echo "<tr><td><span class='sev $sevclass'>$(printf '%s' "$severity" | html_escape)</span></td><td>$(printf '%s' "$area" | html_escape)</td><td><b>$(printf '%s' "$title" | html_escape)</b><br><span class='muted'>$(printf '%s' "$evidence" | html_escape)</span>$remediation_html</td><td>$(printf '%s' "$recommendation" | html_escape)</td></tr>"
  done < "$FINDINGS"
  echo "</table>"
}

detect_distro() {
  DISTRO_NAME="Unknown Linux"
  DISTRO_ID="unknown"
  DISTRO_FAMILY="unknown"
  DISTRO_VERSION_ID=""
  DISTRO_VERSION_CODENAME=""

  if [ -f /etc/os-release ]; then
    . /etc/os-release
    DISTRO_NAME="${PRETTY_NAME:-${NAME:-Unknown Linux}}"
    DISTRO_ID="${ID:-unknown}"
    DISTRO_FAMILY="${ID_LIKE:-$DISTRO_ID}"
    DISTRO_VERSION_ID="${VERSION_ID:-}"
    DISTRO_VERSION_CODENAME="${VERSION_CODENAME:-}"
  fi
}

collect_packages() {
  case "$DISTRO_ID" in
    ubuntu|debian|linuxmint)
      cmd_exists dpkg-query && run_timeout 60 dpkg-query -W -f='${binary:Package}\t${Version}\n' > "$PKGFILE" 2>/dev/null || true
      ;;
    fedora|rhel|centos|rocky|almalinux|ol)
      cmd_exists rpm && run_timeout 60 rpm -qa --qf '%{NAME}\t%{VERSION}-%{RELEASE}\n' > "$PKGFILE" 2>/dev/null || true
      ;;
    alpine)
      cmd_exists apk && run_timeout 60 apk info -vv | awk '{print $1 "\t" $2}' > "$PKGFILE" 2>/dev/null || true
      ;;
  esac
}

pkg_version() {
  local pkg="$1"
  [ -s "$PKGFILE" ] && awk -F '\t' -v p="$pkg" '$1 == p {print $2; exit}' "$PKGFILE"
}

build_service_inventory() {
  : > "$SERVICEFILE"

  add_service "Linux kernel" "$(uname -r)" "uname -r"

  detect_cmd_version() {
    local product="$1"
    local cmd="$2"
    local source="$3"
    local pattern="${4:-[0-9]+(\.[0-9]+){1,3}}"

    if cmd_exists "$cmd"; then
      v="$("$cmd" --version 2>&1 | grep -Eo "$pattern" | head -1)"
      add_service "$product" "$v" "$source"
    fi
  }

  # Web servers / web admin
  if cmd_exists apache2; then
    v="$(apache2 -v 2>/dev/null | grep -Eo 'Apache/[0-9.]+' | head -1 | cut -d/ -f2)"
    add_service "Apache HTTP Server" "$v" "apache2 -v"
  elif cmd_exists httpd; then
    v="$(httpd -v 2>/dev/null | grep -Eo 'Apache/[0-9.]+' | head -1 | cut -d/ -f2)"
    add_service "Apache HTTP Server" "$v" "httpd -v"
  fi

  if cmd_exists nginx; then
    v="$(nginx -v 2>&1 | grep -Eo 'nginx/[0-9.]+' | cut -d/ -f2)"
    add_service "nginx" "$v" "nginx -v"
  fi

  detect_cmd_version "Lighttpd" "lighttpd" "lighttpd -v"
  detect_cmd_version "Caddy" "caddy" "caddy version"
  detect_cmd_version "Webmin" "webmin" "webmin --version"
  detect_cmd_version "Cockpit" "cockpit-bridge" "cockpit-bridge --version"

  # PHP / web apps
  if cmd_exists php; then
    v="$(php -v 2>/dev/null | head -1 | grep -Eo '[0-9]+(\.[0-9]+){1,3}' | head -1)"
    add_service "PHP" "$v" "php -v"
  fi

  if [ -d /usr/share/phpmyadmin ] || [ -d /var/www/html/phpmyadmin ]; then
    v="$(pkg_version phpmyadmin)"
    add_service "phpMyAdmin" "$v" "phpmyadmin package/path"
  fi

  # TLS / SSH
  if cmd_exists openssl; then
    v="$(openssl version 2>/dev/null | grep -Eo '[0-9]+(\.[0-9]+){1,3}' | head -1)"
    add_service "OpenSSL" "$v" "openssl version"
  fi

  if cmd_exists ssh; then
    v="$(ssh -V 2>&1 | grep -Eo 'OpenSSH_[0-9]+(\.[0-9]+){1,3}' | sed 's/OpenSSH_//' | head -1)"
    add_service "OpenSSH" "$v" "ssh -V"
  fi

  # Mail: SMTP / IMAP / POP
  if cmd_exists postconf; then
    v="$(postconf -h mail_version 2>/dev/null | head -1)"
    add_service "Postfix" "$v" "postconf mail_version"
  fi

  if cmd_exists exim || cmd_exists exim4; then
    eximcmd="$(command -v exim 2>/dev/null || command -v exim4 2>/dev/null)"
    v="$("$eximcmd" -bV 2>/dev/null | head -1 | grep -Eo '[0-9]+(\.[0-9]+){1,3}' | head -1)"
    add_service "Exim" "$v" "exim -bV"
  fi

  if cmd_exists smtpd; then
    v="$(smtpd -h 2>&1 | grep -Eo '[0-9]+(\.[0-9]+){1,3}' | head -1)"
    add_service "OpenSMTPD" "$v" "smtpd"
  fi

  if cmd_exists dovecot; then
    v="$(dovecot --version 2>/dev/null | head -1)"
    add_service "Dovecot" "$v" "dovecot --version"
  fi

  # FTP
  detect_cmd_version "vsftpd" "vsftpd" "vsftpd --version"
  detect_cmd_version "ProFTPD" "proftpd" "proftpd --version"
  detect_cmd_version "Pure-FTPd" "pure-ftpd" "pure-ftpd --version"

  # Databases / caches
  if cmd_exists redis-server; then
    v="$(redis-server --version 2>/dev/null | grep -Eo 'v=[0-9]+(\.[0-9]+){1,3}' | cut -d= -f2 | head -1)"
    add_service "Redis" "$v" "redis-server --version"
  fi

  if cmd_exists mysqld; then
    v="$(mysqld --version 2>/dev/null | grep -Eo '[0-9]+(\.[0-9]+){1,3}' | head -1)"
    add_service "MySQL" "$v" "mysqld --version"
  fi

  if cmd_exists mariadbd; then
    v="$(mariadbd --version 2>/dev/null | grep -Eo '[0-9]+(\.[0-9]+){1,3}' | head -1)"
    add_service "MariaDB" "$v" "mariadbd --version"
  elif cmd_exists mysql && mysql --version 2>/dev/null | grep -qi mariadb; then
    v="$(mysql --version 2>/dev/null | grep -Eo '[0-9]+(\.[0-9]+){1,3}' | head -1)"
    add_service "MariaDB" "$v" "mysql --version"
  fi

  if cmd_exists postgres; then
    v="$(postgres --version 2>/dev/null | grep -Eo '[0-9]+(\.[0-9]+){1,3}' | head -1)"
    add_service "PostgreSQL" "$v" "postgres --version"
  elif cmd_exists psql; then
    v="$(psql --version 2>/dev/null | grep -Eo '[0-9]+(\.[0-9]+){1,3}' | head -1)"
    add_service "PostgreSQL" "$v" "psql --version"
  fi

  detect_cmd_version "MongoDB" "mongod" "mongod --version"
  detect_cmd_version "Memcached" "memcached" "memcached -h"
  detect_cmd_version "Elasticsearch" "elasticsearch" "elasticsearch --version"
  detect_cmd_version "RabbitMQ" "rabbitmqctl" "rabbitmqctl version"

  # DNS / DHCP
  if cmd_exists named; then
    v="$(named -v 2>/dev/null | grep -Eo '[0-9]+(\.[0-9]+){1,3}' | head -1)"
    add_service "BIND" "$v" "named -v"
  elif cmd_exists dig; then
    v="$(dig -v 2>/dev/null | grep -Eo '[0-9]+(\.[0-9]+){1,3}' | head -1)"
    add_service "BIND" "$v" "dig -v"
  fi

  detect_cmd_version "dnsmasq" "dnsmasq" "dnsmasq --version"
  detect_cmd_version "ISC DHCP Server" "dhcpd" "dhcpd --version"

  # Proxy / load balancer
  if cmd_exists haproxy; then
    v="$(haproxy -v 2>/dev/null | head -1 | grep -Eo 'version [0-9]+(\.[0-9]+){1,3}' | awk '{print $2}')"
    add_service "HAProxy" "$v" "haproxy -v"
  fi

  if cmd_exists squid; then
    v="$(squid -v 2>/dev/null | head -1 | grep -Eo 'Version [0-9]+(\.[0-9]+){1,3}' | awk '{print $2}')"
    add_service "Squid" "$v" "squid -v"
  fi

  detect_cmd_version "Varnish" "varnishd" "varnishd -V"
  detect_cmd_version "Traefik" "traefik" "traefik version"

  # Containers / virtualization
  detect_cmd_version "Docker" "docker" "docker --version"
  detect_cmd_version "Podman" "podman" "podman --version"
  detect_cmd_version "containerd" "containerd" "containerd --version"
  detect_cmd_version "runc" "runc" "runc --version"
  detect_cmd_version "Kubernetes kubelet" "kubelet" "kubelet --version"
  detect_cmd_version "kubectl" "kubectl" "kubectl version"
  detect_cmd_version "QEMU" "qemu-system-x86_64" "qemu-system-x86_64 --version"
  detect_cmd_version "libvirt" "libvirtd" "libvirtd --version"

  # VMware Tools / open-vm-tools
  if cmd_exists vmware-toolbox-cmd; then
    v="$(vmware-toolbox-cmd -v 2>/dev/null | grep -Eo '[0-9]+(\.[0-9]+){1,4}' | head -1)"
    add_service "VMware Tools" "$v" "vmware-toolbox-cmd -v"
  elif cmd_exists vmtoolsd; then
    v="$(vmtoolsd -v 2>&1 | grep -Eo '[0-9]+(\.[0-9]+){1,4}' | head -1)"
    [ -z "$v" ] && v="$(pkg_version open-vm-tools)"
    add_service "VMware Tools" "$v" "vmtoolsd -v / open-vm-tools package"
  else
    v="$(pkg_version open-vm-tools)"
    [ -n "$v" ] && add_service "VMware Tools" "$v" "open-vm-tools package"
  fi

  # Runtimes / languages
  detect_cmd_version "Node.js" "node" "node --version"
  detect_cmd_version "npm" "npm" "npm --version"
  detect_cmd_version "Python" "python3" "python3 --version"
  detect_cmd_version "Ruby" "ruby" "ruby --version"
  detect_cmd_version "Go" "go" "go version"
  detect_cmd_version "Rust" "rustc" "rustc --version"

  if cmd_exists perl; then
    v="$(perl -e 'printf "%vd\n",$^V' 2>/dev/null)"
    add_service "Perl" "$v" "perl version"
  fi

  if cmd_exists java; then
    v="$(java -version 2>&1 | head -1 | grep -Eo '[0-9]+(\.[0-9]+){1,3}' | head -1)"
    add_service "Java" "$v" "java -version"
  fi

  # VPN / security / monitoring
  detect_cmd_version "OpenVPN" "openvpn" "openvpn --version"
  detect_cmd_version "WireGuard" "wg" "wg"
  detect_cmd_version "strongSwan" "ipsec" "ipsec version"
  detect_cmd_version "Fail2ban" "fail2ban-client" "fail2ban-client version"
  detect_cmd_version "ClamAV" "clamscan" "clamscan --version"
  detect_cmd_version "Zabbix Agent" "zabbix_agentd" "zabbix_agentd --version"
  detect_cmd_version "Prometheus Node Exporter" "node_exporter" "node_exporter --version"
  detect_cmd_version "Grafana" "grafana-server" "grafana-server -v"

  # Print / file services
  detect_cmd_version "CUPS" "cupsd" "cupsd -v"
  detect_cmd_version "Samba" "smbd" "smbd --version"
  detect_cmd_version "NFS" "rpc.nfsd" "rpc.nfsd --version"

  if [ -s "$SERVICEFILE" ]; then
    awk -F '\t' '!seen[$1]++ {print}' "$SERVICEFILE" > "$SERVICEFILE.tmp"
    mv "$SERVICEFILE.tmp" "$SERVICEFILE"
  fi
}

build_cve_payload() {
  {
    printf '{'
    printf '"api_key":"%s",' "$(json_escape "$SCANTIDE_API_KEY")"
    printf '"email":"%s",' "$(json_escape "$SCANTIDE_EMAIL")"
    printf '"allow_remote_nvd":true,'
    printf '"include_intelligence":true,'
    printf '"include_exploit_intelligence":true,'
    printf '"include_product_intelligence":true,'
    printf '"include_kev":true,'
    printf '"include_epss":true,'
    printf '"include_metasploit":true,'
    printf '"include_exploitdb":true,'
    printf '"mode":"local_batch",'
    printf '"client_mode":"local_batch",'
    printf '"source":"linux-local-service-inventory",'
    printf '"os":"%s",' "$(json_escape "$DISTRO_NAME")"
    printf '"host":"%s",' "$(json_escape "$HOST")"
    printf '"queries":['

    first=1
    while IFS="$(printf '\t')" read -r product version source; do
      [ -z "${product:-}" ] && continue
      [ -z "${version:-}" ] && continue
      [ "$first" -eq 0 ] && printf ','
      first=0
      printf '{"product":"%s","version":"%s"}' "$(json_escape "$product")" "$(json_escape "$version")"
    done < "$SERVICEFILE"

    printf ']}'
  } > "$CVE_PAYLOAD"
}


add_extra_package() {
  local name="$1"
  local version="$2"
  local source="$3"
  local type="$4"

  name="$(printf '%s' "$name" | tr '\t' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
  version="$(printf '%s' "$version" | tr '\t' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
  source="$(printf '%s' "$source" | tr '\t' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
  type="$(printf '%s' "$type" | tr '\t' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"

  [ -z "$name" ] && return 0
  [ -z "$version" ] && version="unknown"
  [ -z "$source" ] && source="extra-discovery"
  [ -z "$type" ] && type="extra"

  printf "%s\t%s\t%s\t%s\n" "$name" "$version" "$source" "$type" >> "$PACKAGE_EXTRA_TSV"

  # Feed versioned software into service/CVE inventory when it looks usable.
  if [ "$version" != "unknown" ]; then
    add_service "$name" "$version" "$source/$type"
  fi
}

collect_extra_package_discovery() {
  : > "$PACKAGE_EXTRA_TSV"
  : > "$PACKAGE_EXTRA_RAW"
  printf "name\tversion\tsource\ttype\n" >> "$PACKAGE_EXTRA_TSV"

  {
    echo "### Extra package discovery"
    echo "Generated: $(date '+%Y-%m-%d %H:%M:%S')"
    echo "All ecosystem probes are timeboxed so package discovery cannot stall the full scan."
    echo
  } >> "$PACKAGE_EXTRA_RAW"

  # Snap packages
  if cmd_exists snap; then
    echo "### snap list" >> "$PACKAGE_EXTRA_RAW"
    run_quick 8 snap list | tee -a "$PACKAGE_EXTRA_RAW" | awk 'NR>1 && NF>=2 {print $1 "\t" $2}' | while IFS="$(printf '\t')" read -r n v; do
      add_extra_package "$n" "$v" "snap list" "snap"
    done
  fi

  # Flatpak applications/runtimes
  if cmd_exists flatpak; then
    echo "### flatpak list" >> "$PACKAGE_EXTRA_RAW"
    run_quick 8 flatpak list --app --columns=application,version | tee -a "$PACKAGE_EXTRA_RAW" | while IFS="$(printf '\t')" read -r n v; do
      [ -n "$n" ] && add_extra_package "$n" "${v:-unknown}" "flatpak list --app" "flatpak"
    done
    run_quick 8 flatpak list --runtime --columns=application,version | while IFS="$(printf '\t')" read -r n v; do
      [ -n "$n" ] && add_extra_package "$n" "${v:-unknown}" "flatpak list --runtime" "flatpak-runtime"
    done
  fi

  # Python runtimes and pip packages
  for py in python3 python; do
    if cmd_exists "$py"; then
      "$py" --version 2>&1 | tee -a "$PACKAGE_EXTRA_RAW" | awk '{print $1 "\t" $2}' | while IFS="$(printf '\t')" read -r n v; do
        add_extra_package "$n" "$v" "$py --version" "runtime"
      done
    fi
  done

  if cmd_exists pip3; then
    echo "### pip3 list" >> "$PACKAGE_EXTRA_RAW"
    run_quick 10 pip3 list --format=freeze | head -200 | tee -a "$PACKAGE_EXTRA_RAW" | awk -F'==' 'NF>=2 {print $1 "\t" $2}' | while IFS="$(printf '\t')" read -r n v; do
      add_extra_package "$n" "$v" "pip3 list" "python-pip"
    done
  fi

  if cmd_exists pip; then
    echo "### pip list" >> "$PACKAGE_EXTRA_RAW"
    run_quick 10 pip list --format=freeze | head -200 | tee -a "$PACKAGE_EXTRA_RAW" | awk -F'==' 'NF>=2 {print $1 "\t" $2}' | while IFS="$(printf '\t')" read -r n v; do
      add_extra_package "$n" "$v" "pip list" "python-pip"
    done
  fi

  # Node / npm / yarn / pnpm. Avoid npm list --json because it can be slow/noisy on broken global trees.
  if cmd_exists node; then
    node_v="$(run_quick 4 node --version | sed 's/^v//' | head -1)"
    [ -n "$node_v" ] && add_extra_package "Node.js" "$node_v" "node --version" "runtime"
  fi
  if cmd_exists npm; then
    npm_v="$(run_quick 4 npm --version | head -1)"
    [ -n "$npm_v" ] && add_extra_package "npm" "$npm_v" "npm --version" "runtime"
    echo "### npm global list" >> "$PACKAGE_EXTRA_RAW"
    run_quick 8 npm list -g --depth=0 --parseable | head -200 | tee -a "$PACKAGE_EXTRA_RAW" | while read -r p; do
      [ -z "$p" ] && continue
      n="$(basename "$p")"
      [ "$n" = "node_modules" ] && continue
      # Version lookup per package can be slow, so keep version unknown here.
      add_extra_package "$n" "unknown" "npm list -g --parseable" "node-global"
    done
  fi
  if cmd_exists yarn; then add_extra_package "yarn" "$(run_quick 4 yarn --version | head -1)" "yarn --version" "runtime"; fi
  if cmd_exists pnpm; then add_extra_package "pnpm" "$(run_quick 4 pnpm --version | head -1)" "pnpm --version" "runtime"; fi

  # PHP / Composer. Keep composer global short and optional.
  if cmd_exists php; then
    php_v="$(run_quick 4 php -r 'echo PHP_VERSION;' || true)"
    [ -z "$php_v" ] && php_v="$(run_quick 4 php -v | awk 'NR==1{print $2}')"
    [ -n "$php_v" ] && add_extra_package "PHP" "$php_v" "php version" "runtime"
  fi
  if cmd_exists composer; then
    composer_v="$(run_quick 6 composer --version | awk '{for(i=1;i<=NF;i++) if($i ~ /^[0-9]+\./){print $i; exit}}')"
    [ -n "$composer_v" ] && add_extra_package "Composer" "$composer_v" "composer --version" "runtime"
    echo "### composer global show skipped-by-default-ish/timeboxed" >> "$PACKAGE_EXTRA_RAW"
    run_quick 8 composer global show --no-ansi 2>/dev/null | head -150 | tee -a "$PACKAGE_EXTRA_RAW" | awk 'NF>=2 {print $1 "\t" $2}' | while IFS="$(printf '\t')" read -r n v; do
      add_extra_package "$n" "$v" "composer global show" "php-composer"
    done
  fi

  # Ruby
  if cmd_exists ruby; then
    ruby_v="$(run_quick 4 ruby -v | awk '{print $2}')"
    [ -n "$ruby_v" ] && add_extra_package "Ruby" "$ruby_v" "ruby -v" "runtime"
  fi
  if cmd_exists gem; then
    echo "### gem list" >> "$PACKAGE_EXTRA_RAW"
    run_quick 8 gem list --local | head -200 | tee -a "$PACKAGE_EXTRA_RAW" | sed -nE 's/^([^ ]+) \(([^)]+)\).*/\1\t\2/p' | while IFS="$(printf '\t')" read -r n v; do
      v="$(printf '%s' "$v" | cut -d',' -f1)"
      add_extra_package "$n" "$v" "gem list --local" "ruby-gem"
    done
  fi

  # Java / Maven / Gradle
  if cmd_exists java; then
    java_v="$(run_quick 5 java -version 2>&1 | awk -F '"' '/version/ {print $2; exit}')"
    [ -n "$java_v" ] && add_extra_package "Java" "$java_v" "java -version" "runtime"
  fi
  if cmd_exists mvn; then add_extra_package "Maven" "$(run_quick 6 mvn -version | awk '/Apache Maven/{print $3; exit}')" "mvn -version" "runtime"; fi
  if cmd_exists gradle; then add_extra_package "Gradle" "$(run_quick 6 gradle -version | awk '/Gradle /{print $2; exit}')" "gradle -version" "runtime"; fi

  # Go / Rust
  if cmd_exists go; then add_extra_package "Go" "$(run_quick 4 go version | awk '{print $3}' | sed 's/^go//')" "go version" "runtime"; fi
  if cmd_exists rustc; then add_extra_package "Rust" "$(run_quick 4 rustc --version | awk '{print $2}')" "rustc --version" "runtime"; fi
  if cmd_exists cargo; then add_extra_package "Cargo" "$(run_quick 4 cargo --version | awk '{print $2}')" "cargo --version" "runtime"; fi

  # Direct service version probes
  if cmd_exists postgres; then add_extra_package "PostgreSQL" "$(run_quick 4 postgres --version | awk '{print $NF}')" "postgres --version" "service-runtime"; fi
  if cmd_exists psql; then add_extra_package "PostgreSQL client" "$(run_quick 4 psql --version | awk '{print $3}')" "psql --version" "service-runtime"; fi
  if cmd_exists mysqld; then add_extra_package "MySQL/MariaDB server" "$(run_quick 4 mysqld --version | awk '{for(i=1;i<=NF;i++) if($i ~ /^[0-9]+\./){print $i; exit}}')" "mysqld --version" "service-runtime"; fi
  if cmd_exists mysql; then add_extra_package "MySQL/MariaDB client" "$(run_quick 4 mysql --version | awk '{for(i=1;i<=NF;i++) if($i ~ /^[0-9]+\./){print $i; exit}}')" "mysql --version" "service-runtime"; fi
  if cmd_exists redis-server; then add_extra_package "Redis" "$(run_quick 4 redis-server --version | sed -nE 's/.*v=([^ ]+).*/\1/p')" "redis-server --version" "service-runtime"; fi
  if cmd_exists mongod; then add_extra_package "MongoDB" "$(run_quick 4 mongod --version | awk '/db version/{print $3}' | sed 's/^v//')" "mongod --version" "service-runtime"; fi
  if cmd_exists elasticsearch; then add_extra_package "Elasticsearch" "$(run_quick 6 elasticsearch --version | sed -nE 's/.*Version: ([^,]+).*/\1/p')" "elasticsearch --version" "service-runtime"; fi

  # Web/proxy direct probes
  if cmd_exists nginx; then add_extra_package "Nginx" "$(run_quick 4 nginx -v 2>&1 | sed -nE 's#.*nginx/([^ ]+).*#\1#p')" "nginx -v" "service-runtime"; fi
  if cmd_exists apache2; then add_extra_package "Apache HTTP Server" "$(run_quick 4 apache2 -v | awk -F/ '/Server version/{print $2}' | awk '{print $1}')" "apache2 -v" "service-runtime"; fi
  if cmd_exists httpd; then add_extra_package "Apache HTTP Server" "$(run_quick 4 httpd -v | awk -F/ '/Server version/{print $2}' | awk '{print $1}')" "httpd -v" "service-runtime"; fi
  if cmd_exists haproxy; then add_extra_package "HAProxy" "$(run_quick 4 haproxy -v | awk 'NR==1{print $3}')" "haproxy -v" "service-runtime"; fi
  if cmd_exists squid; then add_extra_package "Squid" "$(run_quick 4 squid -v | awk 'NR==1{print $4}')" "squid -v" "service-runtime"; fi
  if cmd_exists varnishd; then add_extra_package "Varnish" "$(run_quick 4 varnishd -V 2>&1 | awk -F- 'NR==1{print $2}' | awk '{print $1}')" "varnishd -V" "service-runtime"; fi

  # Container images as software hints, timeboxed
  if cmd_exists docker; then
    echo "### docker images" >> "$PACKAGE_EXTRA_RAW"
    run_quick 8 docker images --format '{{.Repository}}\t{{.Tag}}\t{{.ID}}' | head -200 | tee -a "$PACKAGE_EXTRA_RAW" | while IFS="$(printf '\t')" read -r repo tag imgid; do
      [ -n "$repo" ] && [ "$repo" != "<none>" ] && add_extra_package "$repo" "$tag" "docker images" "container-image"
    done
  fi
  if cmd_exists podman; then
    echo "### podman images" >> "$PACKAGE_EXTRA_RAW"
    run_quick 8 podman images --format '{{.Repository}}\t{{.Tag}}\t{{.ID}}' | head -200 | tee -a "$PACKAGE_EXTRA_RAW" | while IFS="$(printf '\t')" read -r repo tag imgid; do
      [ -n "$repo" ] && [ "$repo" != "<none>" ] && add_extra_package "$repo" "$tag" "podman images" "container-image"
    done
  fi

  # De-duplicate extra table
  if [ -s "$PACKAGE_EXTRA_TSV" ]; then
    tmp="$OUTDIR/package_extra_dedup_$TS.tmp"
    head -1 "$PACKAGE_EXTRA_TSV" > "$tmp"
    tail -n +2 "$PACKAGE_EXTRA_TSV" | awk -F '\t' '!seen[$0]++' >> "$tmp"
    mv "$tmp" "$PACKAGE_EXTRA_TSV"
  fi

  return 0
}

render_extra_package_discovery_html() {
  if [ ! -s "$PACKAGE_EXTRA_TSV" ] || [ "$(wc -l < "$PACKAGE_EXTRA_TSV" | tr -d ' ')" -le 1 ]; then
    echo "<div class='note'>No extra package discovery data collected.</div>"
    return
  fi

  echo "<table><tr><th>Name</th><th>Version</th><th>Source</th><th>Type</th></tr>"
  tail -n +2 "$PACKAGE_EXTRA_TSV" | while IFS="$(printf '\t')" read -r name version source type; do
    echo "<tr><td><b>$(printf '%s' "$name" | html_escape)</b></td><td>$(printf '%s' "$version" | html_escape)</td><td>$(printf '%s' "$source" | html_escape)</td><td><span class='source-badge source-warn'>$(printf '%s' "$type" | html_escape)</span></td></tr>"
  done
  echo "</table>"
  echo "<details><summary>Raw extra package discovery evidence</summary><pre>$(cat "$PACKAGE_EXTRA_RAW" 2>/dev/null | html_escape)</pre></details>"
}

add_extra_api_aliases() {
  # Add normalized alias rows for tools where package names differ from lifecycle/CVE catalogs.
  # This improves matching without changing the visible extra discovery table.
  [ -s "${PACKAGE_EXTRA_TSV:-}" ] || return 0

  tail -n +2 "$PACKAGE_EXTRA_TSV" 2>/dev/null | while IFS="$(printf '\t')" read -r name version source type rest; do
    [ -z "${name:-}" ] && continue
    [ -z "${version:-}" ] && continue
    [ "$version" = "unknown" ] && continue

    lname="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')"

    case "$lname" in
      pip)
        add_service "pip" "$version" "extra-discovery/alias"
        add_service "Python pip" "$version" "extra-discovery/alias"
        add_service "Python Package Installer" "$version" "extra-discovery/alias"
        ;;
      python)
        add_service "Python" "$version" "extra-discovery/alias"
        add_service "Python 3" "$version" "extra-discovery/alias"
        ;;
      npm)
        add_service "npm" "$version" "extra-discovery/alias"
        add_service "Node Package Manager" "$version" "extra-discovery/alias"
        ;;
      node.js|node)
        add_service "Node.js" "$version" "extra-discovery/alias"
        add_service "NodeJS" "$version" "extra-discovery/alias"
        add_service "nodejs" "$version" "extra-discovery/alias"
        ;;
      php)
        add_service "PHP" "$version" "extra-discovery/alias"
        add_service "php-cli" "$version" "extra-discovery/alias"
        ;;
      composer)
        add_service "Composer" "$version" "extra-discovery/alias"
        add_service "PHP Composer" "$version" "extra-discovery/alias"
        ;;
      rubygems)
        add_service "RubyGems" "$version" "extra-discovery/alias"
        add_service "Ruby Gems" "$version" "extra-discovery/alias"
        ;;
      ruby)
        add_service "Ruby" "$version" "extra-discovery/alias"
        ;;
      java)
        add_service "Java" "$version" "extra-discovery/alias"
        add_service "OpenJDK" "$version" "extra-discovery/alias"
        ;;
      apache\ http\ server)
        add_service "Apache" "$version" "extra-discovery/alias"
        add_service "Apache HTTP Server" "$version" "extra-discovery/alias"
        add_service "httpd" "$version" "extra-discovery/alias"
        ;;
      nginx)
        add_service "Nginx" "$version" "extra-discovery/alias"
        add_service "nginx" "$version" "extra-discovery/alias"
        ;;
      postgresql|postgresql\ client)
        add_service "PostgreSQL" "$version" "extra-discovery/alias"
        add_service "postgres" "$version" "extra-discovery/alias"
        ;;
      mysql/mariadb\ server|mysql/mariadb\ client)
        add_service "MySQL" "$version" "extra-discovery/alias"
        add_service "MariaDB" "$version" "extra-discovery/alias"
        ;;
    esac
  done

  return 0
}

merge_extra_discovery_into_api_inventory() {
  # Extended runtime/service probes should enrich the same inventory used by CVE/lifecycle lookups.
  # Rows are: name, version, source, type.
  if [ ! -s "${PACKAGE_EXTRA_TSV:-}" ]; then
    return 0
  fi

  # Append versioned extra-discovery rows to the CVE/service inventory.
  # add_service already attempts this during discovery, but this merge makes it explicit and safer.
  tail -n +2 "$PACKAGE_EXTRA_TSV" 2>/dev/null | while IFS="$(printf '\t')" read -r name version source type rest; do
    [ -z "${name:-}" ] && continue
    [ -z "${version:-}" ] && continue
    [ "$version" = "unknown" ] && continue
    add_service "$name" "$version" "extra-discovery/${type:-runtime}/${source:-probe}"
  done

  # Add normalized alias rows for better CVE/lifecycle matching.
  add_extra_api_aliases || true

  # De-duplicate the service inventory to avoid repeated CVE/lifecycle requests.
  if [ -s "$SERVICEFILE" ]; then
    tmp="$OUTDIR/service_inventory_dedup_$TS.tmp"
    awk -F '\t' 'NF>=2 && $1!="" && $2!="" && $2!="unknown" {key=tolower($1)"\t"$2; if(!seen[key]++) print $0}' "$SERVICEFILE" > "$tmp" 2>/dev/null || cp "$SERVICEFILE" "$tmp"
    mv "$tmp" "$SERVICEFILE"
  fi

  return 0
}

run_scantide_cve_api() {
  if ! has_scantide_credentials; then
    : > "$CVE_RAW"
    echo "HTTP Status: Not run" > "$CVE_RAW"
    echo "Skipped: API key/email not supplied. CVE API check was not called." >> "$CVE_RAW"
    stage_skip "CVE API skipped - API key/email not supplied."
    return 0
  fi
  if [ "$USE_SCANTIDE_API" != "true" ]; then
    return
  fi

  if [ ! -s "$SERVICEFILE" ] || ! cmd_exists curl; then
    return
  fi

  build_cve_payload

  HTTP_STATUS="$(curl --http1.1 --max-time "$CVE_TIMEOUT" -sS \
    -o "$CVE_RESPONSE" \
    -w "%{http_code}" \
    -X POST "$SCANTIDE_CVE_API" \
    -H "Content-Type: application/json; charset=utf-8" \
    -H "X-API-Key: $SCANTIDE_API_KEY" \
    -H "User-Agent: ScantideLinuxLocalCheck/0.57-Bash" \
    --data-binary @"$CVE_PAYLOAD" \
    2>"$OUTDIR/cve_curl_error_$TS.txt")"

  {
    echo "HTTP Status: $HTTP_STATUS"
    echo ""
    echo "Payload:"
    cat "$CVE_PAYLOAD"
    echo ""
    echo ""
    echo "Response:"
    if [ -s "$CVE_RESPONSE" ]; then
      cat "$CVE_RESPONSE"
    else
      echo "No response body."
      cat "$OUTDIR/cve_curl_error_$TS.txt" 2>/dev/null
    fi
  } > "$CVE_RAW"
}


build_lifecycle_payload() {
  {
    printf '{'
    printf '"api_key":"%s",' "$(json_escape "$SCANTIDE_API_KEY")"
    printf '"email":"%s",' "$(json_escape "$SCANTIDE_EMAIL")"
    printf '"platform":"linux",'
    printf '"os_family":"linux",'
    printf '"source":"linux-local-service-inventory",'
    printf '"scanner":"ScantideLinuxLocalCheck",'
    printf '"scanner_version":"%s",' "$(json_escape "$SCRIPT_VERSION")"
    printf '"share_lifecycle_inventory":true,'
    printf '"share_software_inventory":true,'
    printf '"opt_in_lifecycle":true,'
    printf '"community_observe":true,'
    printf '"os":"%s",' "$(json_escape "$DISTRO_NAME")"
    printf '"distro_id":"%s",' "$(json_escape "$DISTRO_ID")"
    printf '"host":"%s",' "$(json_escape "$HOST")"
    printf '"items":['

    first=1

    # OS lifecycle item. Use short distro product names that match the Linux lifecycle catalog.
    os_product=""
    case "$DISTRO_ID" in
      ubuntu) os_product="Ubuntu" ;;
      debian) os_product="Debian" ;;
      rhel) os_product="Red Hat Enterprise Linux" ;;
      rocky) os_product="Rocky Linux" ;;
      almalinux) os_product="AlmaLinux" ;;
      centos) os_product="CentOS Stream" ;;
      ol) os_product="Oracle Linux" ;;
      fedora) os_product="Fedora" ;;
      sles|suse|opensuse*) os_product="SUSE Linux Enterprise Server" ;;
      amzn|amazon) os_product="Amazon Linux" ;;
      alpine) os_product="Alpine Linux" ;;
    esac

    if [ -n "$os_product" ] && [ -n "$DISTRO_VERSION_ID" ]; then
      printf '{"product":"%s","name":"%s","software":"%s","version":"%s","installed_version":"%s","input_version":"%s","platform":"linux","os":"%s","source":"/etc/os-release","scanner":"ScantideLinuxLocalCheck","scanner_version":"%s"}' \
        "$(json_escape "$os_product")" \
        "$(json_escape "$os_product")" \
        "$(json_escape "$os_product")" \
        "$(json_escape "$DISTRO_VERSION_ID")" \
        "$(json_escape "$DISTRO_VERSION_ID")" \
        "$(json_escape "$DISTRO_VERSION_ID")" \
        "$(json_escape "$DISTRO_NAME")" \
        "$(json_escape "$SCRIPT_VERSION")"
      first=0
    fi

    while IFS="$(printf '\t')" read -r product version source; do
      [ -z "${product:-}" ] && continue
      [ -z "${version:-}" ] && continue
      [ "$first" -eq 0 ] && printf ','
      first=0
      printf '{"product":"%s","name":"%s","software":"%s","version":"%s","installed_version":"%s","input_version":"%s","platform":"linux","os":"%s","source":"%s","scanner":"ScantideLinuxLocalCheck","scanner_version":"%s"}' \
        "$(json_escape "$product")" \
        "$(json_escape "$product")" \
        "$(json_escape "$product")" \
        "$(json_escape "$version")" \
        "$(json_escape "$version")" \
        "$(json_escape "$version")" \
        "$(json_escape "$DISTRO_NAME")" \
        "$(json_escape "$source")" \
        "$(json_escape "$SCRIPT_VERSION")"
    done < "$SERVICEFILE"

    printf ']}'
  } > "$LIFECYCLE_PAYLOAD"
}

run_scantide_lifecycle_api() {
  if ! has_scantide_credentials; then
    : > "$LIFECYCLE_RAW"
    echo "HTTP Status: Not run" > "$LIFECYCLE_RAW"
    echo "Skipped: API key/email not supplied. Lifecycle API check was not called." >> "$LIFECYCLE_RAW"
    stage_skip "Lifecycle API skipped - API key/email not supplied."
    return 0
  fi
  if [ "$USE_SCANTIDE_LIFECYCLE" != "true" ]; then
    return
  fi

  if ! cmd_exists curl; then
    return
  fi

  build_lifecycle_payload

  LIFECYCLE_HTTP_STATUS="$(curl --http1.1 --max-time "$LIFECYCLE_TIMEOUT" -sS \
    -o "$LIFECYCLE_RESPONSE" \
    -w "%{http_code}" \
    -X POST "$SCANTIDE_LIFECYCLE_API" \
    -H "Content-Type: application/json; charset=utf-8" \
    -H "X-API-Key: $SCANTIDE_API_KEY" \
    -H "X-Scantide-Email: $SCANTIDE_EMAIL" \
    -H "User-Agent: ScantideLinuxLocalCheck/0.57-Bash" \
    --data-binary @"$LIFECYCLE_PAYLOAD" \
    2>"$OUTDIR/lifecycle_curl_error_$TS.txt")"

  {
    echo "HTTP Status: $LIFECYCLE_HTTP_STATUS"
    echo ""
    echo "Payload:"
    cat "$LIFECYCLE_PAYLOAD"
    echo ""
    echo ""
    echo "Response:"
    if [ -s "$LIFECYCLE_RESPONSE" ]; then
      cat "$LIFECYCLE_RESPONSE"
    else
      echo "No response body."
      cat "$OUTDIR/lifecycle_curl_error_$TS.txt" 2>/dev/null
    fi
  } > "$LIFECYCLE_RAW"
}

render_lifecycle_table_html() {
  if [ ! -s "$LIFECYCLE_RESPONSE" ] || ! cmd_exists python3; then
    echo "<div class='note'>Lifecycle result table could not be rendered. Raw evidence is available under Raw / Evidence.</div>"
    return
  fi

  python3 - "$LIFECYCLE_RESPONSE" <<'PY_RENDER_LIFECYCLE'
import json, sys, html
path = sys.argv[1]
try:
    data = json.load(open(path, "r", encoding="utf-8"))
except Exception as e:
    print("<div class='scope-warn'>Could not parse lifecycle JSON: %s</div>" % html.escape(str(e)))
    sys.exit(0)

items = data.get("results") or data.get("items") or data.get("data") or []
summary = data.get("summary") or {}
community = data.get("community_observations") or {}
api_version = data.get("api_version") or ""
platform = data.get("platform_selected") or ""
files = data.get("catalog_files") or []

def esc(x):
    return html.escape("" if x is None else str(x))

def sev_class(sev):
    sev = (sev or "Info").lower()
    if sev == "critical": return "sev-critical"
    if sev == "high": return "sev-high"
    if sev == "medium": return "sev-medium"
    if sev == "low": return "sev-low"
    return "sev-info"

def status_class(status):
    s = (status or "").lower()
    if s in ("end_of_support", "unsupported", "unsupported_major_version", "eol"):
        return "cve-high"
    if s in ("update_available", "outdated", "legacy_review", "extended_support"):
        return "cve-review"
    if s in ("current", "supported"):
        return "cve-suppressed"
    return "cve-review"

print("<div class='note'><b>Lifecycle API:</b> api_version=%s | platform_selected=%s | catalog_files=%s | summary=%s | community_observations=%s</div>" % (
    esc(api_version), esc(platform), esc(", ".join(map(str, files))), esc(summary), esc(community)
))
print("<table>")
print("<tr><th>Product</th><th>Installed</th><th>Matched product</th><th>Category</th><th>Status</th><th>Severity</th><th>Latest / EOL</th><th>Latest source</th><th>Evidence</th><th>Recommendation</th><th>Source</th></tr>")
for r in items:
    product = r.get("product", "")
    version = r.get("installed_version") or r.get("version") or r.get("normalized_version") or ""
    matched = r.get("matched_product") or ""
    category = r.get("category") or ""
    status = r.get("status_label") or r.get("status") or "Unknown"
    raw_status = r.get("status") or ""
    severity = r.get("severity") or "Info"
    latest = r.get("latest_version") or ""
    eol = r.get("eol_date") or ""
    latest_source = r.get("latest_version_source") or r.get("source_label") or ""
    latest_conf = r.get("latest_version_confidence") or r.get("source_confidence") or ""
    latest_detail = r.get("latest_version_detail") or r.get("source_detail") or ""
    latest_eol = []
    if latest: latest_eol.append("Latest: " + str(latest))
    if eol: latest_eol.append("EOL: " + str(eol))
    evidence = r.get("evidence") or ""
    rec = r.get("recommended_action") or ""
    source = r.get("source_label") or r.get("source_strategy") or ""
    confidence = r.get("source_confidence") or ""
    conf_class = "source-warn"
    if str(confidence).lower() == "high":
        conf_class = "source-ok"
    elif str(confidence).lower() in ("low", "unknown", ""):
        conf_class = "source-warn"
    source_html = esc(source)
    if confidence:
        source_html += "<br><span class='source-badge %s'>%s confidence</span>" % (conf_class, esc(confidence))
    print("<tr>")
    print("<td>%s</td>" % esc(product))
    print("<td>%s</td>" % esc(version))
    print("<td>%s</td>" % esc(matched))
    print("<td>%s</td>" % esc(category))
    print("<td><span class='cve-flag %s'>%s</span><br><span class='muted'>%s</span></td>" % (status_class(raw_status), esc(status), esc(raw_status)))
    print("<td><span class='sev %s'>%s</span></td>" % (sev_class(severity), esc(severity)))
    print("<td>%s</td>" % esc(" | ".join(latest_eol) if latest_eol else ""))
    print("<td>%s</td>" % esc(evidence))
    print("<td>%s</td>" % esc(rec))
    print("<td>%s</td>" % source_html)
    print("</tr>")
print("</table>")
PY_RENDER_LIFECYCLE
}

render_cve_table_html() {
  local render_mode="${1:-full}"
  if [ ! -s "$CVE_RESPONSE" ] || ! cmd_exists python3; then
    echo "<div class='note'>CVE intelligence could not be rendered. Raw API evidence remains available under Raw / Evidence.</div>"
    return
  fi

  python3 - "$CVE_RESPONSE" "$render_mode" "$(uname -r 2>/dev/null || true)" <<'PY_RENDER_CVE'
import json, sys, html, re

try:
    data=json.load(open(sys.argv[1], encoding='utf-8'))
except Exception as e:
    print("<div class='scope-warn'>Could not parse CVE intelligence JSON: %s</div>" % html.escape(str(e)))
    raise SystemExit

mode=sys.argv[2] if len(sys.argv)>2 else 'full'
full_kernel=sys.argv[3] if len(sys.argv)>3 else ''
items=data.get('results') or data.get('items') or data.get('data') or []
if isinstance(items, dict): items=list(items.values())

def esc(v): return html.escape('' if v is None else str(v))
def i(v):
    try: return int(float(v or 0))
    except: return 0
def f(v):
    try: return float(v or 0)
    except: return 0.0
def yes(v): return v is True or str(v or '').strip().lower() in ('1','true','yes','y','known')
def arr(v): return v if isinstance(v,list) else ([] if v in (None,'') else [v])
def uniq(values):
    out=[]; seen=set()
    for v in values:
        if isinstance(v,dict): v=v.get('fullname') or v.get('name') or v.get('title') or v.get('module') or v.get('id') or v.get('edb_id') or ''
        v=str(v or '').strip()
        if v and v not in seen: seen.add(v); out.append(v)
    return out

def sev_class(sev):
    return {'critical':'sev-critical','high':'sev-high','medium':'sev-medium','low':'sev-low'}.get(str(sev or '').lower(),'sev-info')
def pill(text, cls='source-warn', title=''):
    return "<span class='source-badge %s'%s>%s</span>"%(cls,(" title='%s'"%esc(title) if title else ''),esc(text))
def pct(v):
    v=f(v); return v*100 if 0 < v <= 1 else v

def intelligence_for(row):
    out={'kev':0,'ransomware':0,'epss':0.0,'msf':0,'edb':0,'msf_items':[],'edb_items':[]}
    intel=row.get('intelligence') or row.get('exploit_intelligence') or {}
    out['kev']+=i(intel.get('kev_count') or intel.get('cisa_kev_count'))
    out['ransomware']+=i(intel.get('ransomware_count')) or (1 if yes(intel.get('known_ransomware_use')) else 0)
    out['epss']=max(out['epss'],pct(intel.get('epss_max') or intel.get('max_epss') or intel.get('epss_score')))
    out['msf']+=i(intel.get('metasploit_count') or intel.get('metasploit_module_count') or intel.get('module_count'))
    out['edb']+=i(intel.get('exploitdb_count') or intel.get('exploit_db_count') or intel.get('edb_count'))
    out['msf_items']+=arr(intel.get('metasploit_modules') or intel.get('modules'))
    out['edb_items']+=arr(intel.get('exploitdb_entries') or intel.get('exploits') or intel.get('exploitdb_ids') or intel.get('edb_ids'))
    for c in arr(row.get('cves'))+arr(row.get('top_cves')):
        if not isinstance(c,dict): continue
        ci=c.get('intelligence') or c.get('exploit_intelligence') or {}
        kev=c.get('kev') or {}; ep=c.get('epss') or {}; ms=c.get('metasploit') or {}; ed=c.get('exploitdb') or c.get('exploit_db') or {}
        if kev and (yes(kev.get('listed')) or kev.get('date_added') or kev.get('required_action')): out['kev']+=1
        else: out['kev']+=i(ci.get('kev_count') or ci.get('cisa_kev_count')) or (1 if yes(ci.get('kev_listed')) else 0)
        if str(kev.get('known_ransomware_campaign_use') or '').lower()=='known' or yes(ci.get('known_ransomware_use')): out['ransomware']+=1
        out['epss']=max(out['epss'],pct(ep.get('score') or ci.get('epss') or ci.get('epss_score') or ci.get('epss_probability')))
        mc=i(ms.get('module_count') or ms.get('count') or ci.get('metasploit_count') or ci.get('module_count'))
        if yes(ms.get('available')) and mc==0: mc=1
        out['msf']+=mc
        ec=i(ed.get('entry_count') or ed.get('count') or ci.get('exploitdb_count') or ci.get('edb_count'))
        if yes(ed.get('available')) and ec==0: ec=1
        out['edb']+=ec
        out['msf_items']+=arr(ms.get('modules') or ms.get('module_fullnames') or ci.get('metasploit_modules'))
        out['edb_items']+=arr(ed.get('entries') or ed.get('exploits') or ed.get('edb_ids') or ci.get('exploitdb_entries') or ci.get('exploitdb_ids'))
    out['msf_items']=uniq(out['msf_items']); out['edb_items']=uniq(out['edb_items'])
    return out

def kernel_git_range(c):
    if not isinstance(c,dict): return False
    vt=str(c.get('version_type') or '').lower()
    evidence=str(c.get('match_evidence') or '').lower()
    ranges=' '.join(str(x) for x in arr(c.get('affected_ranges'))) + ' ' + str(c.get('matched_affected_range') or c.get('affected_versions') or '')
    return vt=='git' or 'git' in evidence or bool(re.search(r'\b[0-9a-f]{12,40}\b', ranges, re.I))

def version_tuple(v):
    nums=re.findall(r'\d+', str(v or ''))
    return tuple(int(x) for x in nums[:6]) if nums else None

def cmp_versions(a,b):
    a=version_tuple(a); b=version_tuple(b)
    if a is None or b is None: return None
    n=max(len(a),len(b)); a=a+(0,)*(n-len(a)); b=b+(0,)*(n-len(b))
    return -1 if a<b else 1 if a>b else 0

def range_entry_matches(installed, m):
    if not isinstance(m,dict): return None
    vt=str(m.get('version_type') or '').lower()
    # Git/hash/custom commit ranges cannot be evaluated as semantic versions.
    vals=' '.join(str(m.get(k) or '') for k in ('version','version_start_including','version_start_excluding','version_end_including','version_end_excluding','original_less_than','original_less_than_or_equal'))
    if vt=='git' or re.search(r'\b[0-9a-f]{12,40}\b', vals, re.I): return None
    usable=False
    checks=[
      ('version_start_including',lambda c:c>=0),
      ('version_start_excluding',lambda c:c>0),
      ('version_end_including',lambda c:c<=0),
      ('version_end_excluding',lambda c:c<0),
      ('original_less_than_or_equal',lambda c:c<=0),
      ('original_less_than',lambda c:c<0),
    ]
    for key,pred in checks:
        bound=m.get(key)
        if bound in (None,''): continue
        c=cmp_versions(installed,bound)
        if c is None: continue
        usable=True
        if not pred(c): return False
    return True if usable else None

def text_range_matches(installed, c):
    text=html.unescape(str(c.get('affected_versions') or c.get('matched_affected_range') or ''))
    if not text: return None
    usable=False
    for op,bound in re.findall(r'(>=|<=|>|<|=)?\s*(\d+(?:\.\d+){1,5})',text):
        comp=cmp_versions(installed,bound)
        if comp is None: continue
        usable=True
        ok={'>' : comp>0, '>=':comp>=0, '<':comp<0, '<=':comp<=0, '=':comp==0, '':comp==0}.get(op,True)
        if not ok:return False
    return True if usable else None

def cve_relevant_to_installed(c, installed, product=''):
    if not isinstance(c,dict): return False
    matched=arr(c.get('matched_affected'))
    evaluated=[]
    for m in matched:
        r=range_entry_matches(installed,m)
        if r is not None:evaluated.append(r)
    if evaluated:
        return any(evaluated)

    tr=text_range_matches(installed,c)
    if tr is not None:
        return tr

    # A semantic installed version cannot be proven relevant by Git commit/hash
    # boundaries. This is especially common in Linux-kernel CVE records.
    # Keep such CVEs out of current installed-version findings; they belong in
    # upstream/product history or distro advisory review instead.
    if kernel_git_range(c):
        return False

    # No explicit comparable range was supplied. Retain as a review candidate,
    # but never because KEV/ransomware/exploit enrichment is present.
    return True

def relevant_cves(row):
    product=str(row.get('product') or row.get('name') or '')
    version=str(row.get('version') or row.get('installed_version') or '')
    raw=arr(row.get('cves')) or arr(row.get('top_cves'))
    return [c for c in raw if cve_relevant_to_installed(c,version,product)]

def applicability_state(row, cves):
    product=str(row.get('product') or row.get('name') or '')
    total=i(row.get('total_cves')); status=str(row.get('match_status') or '').upper()
    confirmed=[c for c in cves if isinstance(c,dict) and (yes(c.get('version_confirmed')) or str(c.get('applicability') or '').lower()=='affected')]
    if 'kernel' in product.lower() and any(kernel_git_range(c) for c in cves if isinstance(c,dict)):
        return ('Kernel range review','source-warn')
    if 'apache' in product.lower() and (confirmed or total):
        return ('Upstream match â€” distro review','source-warn')
    if confirmed or (total and status in ('RANGE_MATCH','VERSION_CONFIRMED','CONFIRMED')):
        return ('Version-confirmed','source-fail')
    if total or cves:
        return ('Review candidates','source-warn')
    return ('No CVE match','source-ok')

def product_history(row):
    # Preferred complete product-family history, when returned by the backend.
    pi=(row.get('product_intelligence') or row.get('historical_product_intelligence')
        or row.get('product_history') or row.get('history') or {})
    if isinstance(pi,list):
        pi={'entries':pi}
    ms=(pi.get('metasploit') or pi.get('msf') or {}) if isinstance(pi,dict) else {}
    ed=(pi.get('exploitdb') or pi.get('exploit_db') or pi.get('edb') or {}) if isinstance(pi,dict) else {}
    ms_count=i(ms.get('module_count') or ms.get('count') or pi.get('metasploit_count') if isinstance(pi,dict) else 0)
    ed_count=i(ed.get('entry_count') or ed.get('count') or pi.get('exploitdb_count') if isinstance(pi,dict) else 0)
    ms_items=uniq(arr(ms.get('modules') or ms.get('module_fullnames') or ms.get('items')))
    ed_items=uniq(arr(ed.get('entries') or ed.get('exploits') or ed.get('edb_ids') or ed.get('ids') or ed.get('items')))
    source='complete'
    # Product-family history must come from the backend product intelligence index.
    # Do not substitute exploits linked only to the currently matched CVEs.
    if ms_count==0 and ed_count==0 and not ms_items and not ed_items:
        source='unavailable'
    return {
      'msf':ms_count,
      'edb':ed_count,
      'msf_items':ms_items,
      'edb_items':ed_items,
      'msf_desc':str(ms.get('description') or ms.get('notes') or ''),
      'edb_desc':str(ed.get('description') or ed.get('notes') or ''),
      'source':source
    }

# Deduplicated report summary.
seen_cves=set(); confirmed=0; candidates=0; kev_ids=set(); ransomware_ids=set(); max_epss=0.0; msf=0; edb=0; hist_msf=0; hist_edb=0
for r in items:
    cves=relevant_cves(r); total=len(cves); status=str(r.get('match_status') or '').upper()
    row_confirmed=0
    for c in cves:
        if not isinstance(c,dict): continue
        cid=str(c.get('cve_id') or c.get('id') or '')
        is_conf=yes(c.get('version_confirmed')) or str(c.get('applicability') or '').lower()=='affected'
        if cid and cid not in seen_cves:
            seen_cves.add(cid)
            if is_conf: confirmed+=1
            else: candidates+=1
        if is_conf: row_confirmed+=1
        kev=c.get('kev') or {}; ci=c.get('intelligence') or {}; ep=c.get('epss') or {}
        if cid and (kev.get('date_added') or yes(kev.get('listed')) or yes(ci.get('kev_listed'))): kev_ids.add(cid)
        if cid and (str(kev.get('known_ransomware_campaign_use') or '').lower()=='known' or yes(ci.get('known_ransomware_use'))): ransomware_ids.add(cid)
        max_epss=max(max_epss,pct(ep.get('score') or ci.get('epss') or ci.get('epss_score')))
    if not cves:
        if total and status in ('RANGE_MATCH','VERSION_CONFIRMED','CONFIRMED'): confirmed+=total
        elif total: candidates+=total
    ri=intelligence_for(r); msf+=ri['msf']; edb+=ri['edb']; max_epss=max(max_epss,ri['epss'])
    ph=product_history(r); hist_msf+=ph['msf']; hist_edb+=ph['edb']

print("<div class='intel-summary-grid'>")
for label,value,sub,cls in [
 ('Confirmed CVEs',confirmed,'Installed-version matches','danger' if confirmed else 'good'),
 ('Review candidates',candidates,'Verify against distro advisories','warn' if candidates else 'good'),
 ('CISA KEV',len(kev_ids),'Known exploited vulnerabilities','danger' if kev_ids else 'good'),
 ('Ransomware-linked',len(ransomware_ids),'KEV campaign evidence','danger' if ransomware_ids else 'good'),
 ('Maximum EPSS','%.1f%%'%max_epss,'Probability of exploitation','warn' if max_epss>=10 else 'good'),
 ('CVE-linked exploits',msf+edb,'Metasploit %d Â· Exploit-DB %d'%(msf,edb),'warn' if msf+edb else 'good'),
 ('Product-family history',hist_msf+hist_edb,'Complete backend history only; unavailable rows excluded','neutral')]:
    print("<div class='intel-stat %s'><div class='intel-stat-label'>%s</div><div class='intel-stat-value'>%s</div><div class='intel-stat-sub'>%s</div></div>"%(cls,esc(label),esc(value),esc(sub)))
print("</div>")
print("<div class='intel-callout'><b>Applicability gate:</b> This rule applies to every product. CVEs with explicit semantic affected ranges that exclude the installed version are removed from current findings. Git/hash-only ranges cannot confirm a semantic installed version and are also excluded from current findings. KEV, ransomware, EPSS or exploit history never overrides version applicability.<br><br><b>How to read this:</b> KEV and CVE-linked exploit intelligence applies to specific CVE IDs. The product-footprint column uses only complete product-family history returned by the backend. CVE-linked exploits are never substituted for product history. Neither is proof that the installed version is exploitable.</div>")

print("<div class='software-intel-table-wrap'><table class='software-intel-table'><thead><tr><th>Software</th><th>Installed</th><th>Applicability</th><th>Highest</th><th>KEV</th><th>EPSS</th><th>Metasploit</th><th>Exploit-DB</th><th>Product exploit footprint</th><th>Top CVEs</th></tr></thead><tbody>")
for r in items:
    product=r.get('product') or r.get('name') or 'Unknown product'; version=r.get('version') or r.get('installed_version') or ''
    display_version=full_kernel if ('kernel' in str(product).lower() and full_kernel) else version
    cves=relevant_cves(r); total=len(cves); highest=r.get('highest_severity') or 'Info'; score=r.get('highest_score') or 0; status=str(r.get('match_status') or '')
    state,state_cls=applicability_state(r,cves)
    ri=intelligence_for(r); ph=product_history(r)
    top=[]
    for c in cves[:5]:
        if isinstance(c,dict):
            cid=c.get('cve_id') or c.get('id') or ''
            if cid: top.append(str(cid))
    hist=[]
    if ph['msf']: hist.append('MSF %d'%ph['msf'])
    if ph['edb']: hist.append('EDB %d'%ph['edb'])
    if ph.get('source')=='unavailable': hist.append('Not returned')
    print("<tr><td><b>%s</b></td><td>%s</td><td>%s</td><td><span class='sev %s'>%s</span> %s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>"%(esc(product),esc(display_version),pill(state,state_cls),sev_class(highest),esc(highest),esc(score),esc(ri['kev'] if ri['kev'] else 'â€”'),esc(('%.1f%%'%ri['epss']) if ri['epss'] else 'â€”'),esc(ri['msf'] if ri['msf'] else 'â€”'),esc(ri['edb'] if ri['edb'] else 'â€”'),esc(' Â· '.join(hist) if hist else 'â€”'),esc(', '.join(top) if top else 'â€”')))
print("</tbody></table></div>")

if mode == 'summary':
    raise SystemExit

for n,r in enumerate(items,1):
    product=r.get('product') or r.get('name') or 'Unknown product'
    version=r.get('version') or r.get('installed_version') or ''
    # Recalculate this inside the detail loop. Previously it leaked from the
    # final row of the summary loop, causing every detail card to show 1.5.3.
    display_version=full_kernel if ('kernel' in str(product).lower() and full_kernel) else version
    cves=relevant_cves(r); total=len(cves); highest=r.get('highest_severity') or 'Info'; score=r.get('highest_score') or 0; status=str(r.get('match_status') or '')
    confirmed_rows=[c for c in cves if isinstance(c,dict) and (yes(c.get('version_confirmed')) or str(c.get('applicability') or '').lower()=='affected')]
    ri=intelligence_for(r); ph=product_history(r)
    state='Version-confirmed' if confirmed_rows or (total and status.upper() in ('RANGE_MATCH','VERSION_CONFIRMED','CONFIRMED')) else ('Review candidates' if total or cves else 'No CVE match')
    state_cls='source-fail' if state=='Version-confirmed' else ('source-warn' if state=='Review candidates' else 'source-ok')
    badges=[pill(state,state_cls),pill('%s %s'%(highest,score),sev_class(highest))]
    if ri['kev']: badges.append(pill('KEV %d'%ri['kev'],'source-fail'))
    if ri['ransomware']: badges.append(pill('Ransomware-linked','source-fail'))
    if ri['epss']: badges.append(pill('EPSS %.1f%%'%ri['epss'],'source-warn'))
    if ri['msf']: badges.append(pill('Metasploit %d'%ri['msf'],'source-warn'))
    if ri['edb']: badges.append(pill('Exploit-DB %d'%ri['edb'],'source-warn'))
    if ph['msf']: badges.append(pill('Historical MSF %d'%ph['msf'],'source-neutral'))
    if ph['edb']: badges.append(pill('Historical EDB %d'%ph['edb'],'source-neutral'))
    print("<article class='software-intel-card'>")
    print("<div class='software-intel-head'><div><h3>%s <span class='muted'>%s</span></h3><div class='intel-badges'>%s</div></div><div class='software-risk-ring %s'>%s</div></div>"%(esc(product),esc(display_version),' '.join(badges),sev_class(highest),esc(score)))
    msg=(r.get('software_status') or {}).get('message') or ''
    if 'kernel' in str(product).lower() and full_kernel:
        msg=("Detected kernel release %s. The CVE catalog query used upstream base version %s. Git/hash-only kernel CVEs are excluded from current installed-version findings because a semantic kernel release cannot be compared safely with commit boundaries.")%(full_kernel,version)
    elif 'apache' in str(product).lower() and (total or cves):
        msg=("Apache CVEs are shown only when their explicit affected range includes the installed version. Remaining upstream matches should still be verified against the exact distribution package revision.")
    if msg: print("<div class='intel-product-note'>%s</div>"%esc(msg))
    if ph['msf'] or ph['edb']:
        print("<details class='intel-details'><summary>Historical product exploit footprint â€” %d Metasploit / %d Exploit-DB</summary>"%(ph['msf'],ph['edb']))
        print("<div class='history-grid'>")
        print("<div class='history-box'><h4>Metasploit history</h4><p>%s</p>%s</div>"%(esc(ph['msf_desc'] or 'Historical modules associated with this product family. Installed-version applicability is not implied.'),("<div class='intel-list'>%s</div>"%'<br>'.join(esc(x) for x in ph['msf_items'][:12]) if ph['msf_items'] else '')))
        print("<div class='history-box'><h4>Exploit-DB history</h4><p>%s</p>%s</div>"%(esc(ph['edb_desc'] or 'Historical public exploit or proof-of-concept records associated with this product family.'),("<div class='intel-list'>%s</div>"%'<br>'.join(esc(x) for x in ph['edb_items'][:12]) if ph['edb_items'] else '')))
        print("</div><div class='muted'>Historical product context only. Confirm the exact installed version against vendor or distribution advisories.</div></details>")
    elif ph.get('source')=='unavailable':
        print("<div class='intel-product-note'><b>Product-family history not returned by CVE API.</b> CVE-linked Metasploit and Exploit-DB evidence is shown above, but the complete product-family footprint used by Emerging / Sensitive Software is not present in this response.</div>")
    if not cves:
        print("<div class='scope-ok'>No CVE rows were returned for this installed product/version.</div>")
    else:
        print("<div class='cve-card-grid'>")
        for c in cves[:20]:
            if not isinstance(c,dict): continue
            cid=c.get('cve_id') or c.get('id') or 'CVE candidate'; sev=c.get('severity') or 'Info'; cvss=c.get('cvss_score') or c.get('score') or ''
            desc=c.get('description') or c.get('summary') or 'No description returned.'; affected=c.get('affected_versions') or c.get('affected_range') or ''
            is_conf=yes(c.get('version_confirmed')) or str(c.get('applicability') or '').lower()=='affected'
            kev=c.get('kev') or {}; ep=c.get('epss') or {}; ms=c.get('metasploit') or {}; ed=c.get('exploitdb') or c.get('exploit_db') or {}; ci=c.get('intelligence') or {}
            cpills=[pill('Affected version' if is_conf else 'Review candidate','source-fail' if is_conf else 'source-warn'),pill('%s %s'%(sev,cvss),sev_class(sev))]
            if kev and (kev.get('date_added') or yes(kev.get('listed'))): cpills.append(pill('CISA KEV','source-fail'))
            eps=pct(ep.get('score') or ci.get('epss') or ci.get('epss_score'))
            if eps: cpills.append(pill('EPSS %.1f%%'%eps,'source-warn'))
            mc=i(ms.get('module_count') or ms.get('count')); ec=i(ed.get('entry_count') or ed.get('count'))
            if yes(ms.get('available')) and not mc: mc=1
            if yes(ed.get('available')) and not ec: ec=1
            if mc: cpills.append(pill('Metasploit %d'%mc,'source-warn'))
            if ec: cpills.append(pill('Exploit-DB %d'%ec,'source-warn'))
            print("<details class='cve-detail-card'><summary><span><b>%s</b> â€” %s</span><span class='intel-badges'>%s</span></summary>"%(esc(cid),esc(desc[:150]),' '.join(cpills)))
            print("<div class='cve-detail-body'><p>%s</p>"%esc(desc))
            if affected: print("<div class='evidence-box'><b>Affected-version evidence</b><br>%s</div>"%esc(affected))
            if kev:
                print("<div class='evidence-box danger-box'><b>CISA KEV</b><br>Added: %s Â· Due: %s Â· Ransomware use: %s<br>%s<br><b>Required action:</b> %s</div>"%(esc(kev.get('date_added')),esc(kev.get('due_date')),esc(kev.get('known_ransomware_campaign_use')),esc(kev.get('notes')),esc(kev.get('required_action'))))
            for title,obj in [('Metasploit',ms),('Exploit-DB',ed)]:
                if not obj: continue
                note=obj.get('mitigation') or obj.get('remediation') or obj.get('solution') or obj.get('workaround') or obj.get('notes') or obj.get('description') or obj.get('summary') or ''
                names=uniq(arr(obj.get('modules') or obj.get('entries') or obj.get('exploits') or obj.get('module_fullnames') or obj.get('edb_ids')))
                print("<div class='evidence-box'><b>%s context</b>%s%s</div>"%(esc(title),("<br>%s"%esc(note) if note else ''),("<div class='intel-list'>%s</div>"%'<br>'.join(esc(x) for x in names[:10]) if names else '')))
            print("</div></details>")
        print("</div>")
    print("</article>")
PY_RENDER_CVE
}

evaluate_findings() {
  : > "$FINDINGS"
  : > "$HARDENING_RAW"

  if [ "$(id -u)" -ne 0 ]; then
    add_finding "Medium" "Scan Scope" "Linux check was not run as root" "Some local checks may be incomplete." "Re-run with sudo for complete visibility."
  fi

  if cmd_exists ufw; then
    UFW_OUT="$(ufw status verbose 2>&1 || true)"
    add_hardening_evidence "UFW status" "ufw status verbose" "$UFW_OUT"
    if printf '%s' "$UFW_OUT" | grep -qi "inactive"; then
      add_finding "Medium" "Firewall" "UFW is inactive" "Host firewall is not active according to ufw." "Enable UFW or verify that an upstream firewall intentionally provides filtering."
    fi
  fi

  if cmd_exists iptables; then
    IPT_OUT="$(iptables -L -n 2>&1 || true)"
    add_hardening_evidence "iptables policy" "iptables -L -n" "$IPT_OUT"
    if printf '%s' "$IPT_OUT" | grep -q "Chain INPUT (policy ACCEPT"; then
      add_finding "Medium" "Firewall" "iptables INPUT policy is ACCEPT" "Default INPUT policy accepts traffic." "Use explicit firewall policy or document external firewall dependency."
    fi
  fi

  if [ -f /etc/ssh/sshd_config ]; then
    SSH_HIGHLIGHTS="$(grep -Ei 'PermitRootLogin|PasswordAuthentication|PubkeyAuthentication|PermitEmptyPasswords|X11Forwarding|AllowUsers|AllowGroups|Port' /etc/ssh/sshd_config 2>/dev/null || true)"
    add_hardening_evidence "SSH hardening highlights" "grep sshd_config" "$SSH_HIGHLIGHTS"
    if grep -Eiq '^\s*PermitRootLogin\s+yes' /etc/ssh/sshd_config; then
      add_finding "High" "SSH Hardening" "SSH root login is enabled" "PermitRootLogin yes" "Set PermitRootLogin no and use named admin accounts with sudo."
    fi
    if grep -Eiq '^\s*PasswordAuthentication\s+yes' /etc/ssh/sshd_config; then
      add_finding "Medium" "SSH Hardening" "SSH password authentication is enabled" "PasswordAuthentication yes" "Prefer SSH keys and set PasswordAuthentication no where practical."
    fi
    if grep -Eiq '^\s*PermitEmptyPasswords\s+yes' /etc/ssh/sshd_config; then
      add_finding "High" "SSH Hardening" "SSH empty passwords are allowed" "PermitEmptyPasswords yes" "Set PermitEmptyPasswords no."
    fi
  fi

  if cmd_exists apache2ctl || cmd_exists apachectl; then
    APACHE_CMD="$(command -v apache2ctl 2>/dev/null || command -v apachectl 2>/dev/null || true)"
    APACHE_OUT="$($APACHE_CMD -t -D DUMP_RUN_CFG 2>&1 || true)"
    add_hardening_evidence "Apache runtime config" "$APACHE_CMD -t -D DUMP_RUN_CFG" "$APACHE_OUT"
    APACHE_TOKENS="$(grep -RihE '^\s*ServerTokens\s+' /etc/apache2 /etc/httpd 2>/dev/null | tail -1 | awk '{print tolower($2)}' || true)"
    APACHE_SIG="$(grep -RihE '^\s*ServerSignature\s+' /etc/apache2 /etc/httpd 2>/dev/null | tail -1 | awk '{print tolower($2)}' || true)"
    [ -z "$APACHE_TOKENS" ] && APACHE_TOKENS="default"
    [ -z "$APACHE_SIG" ] && APACHE_SIG="default"
    if [ "$APACHE_TOKENS" != "prod" ]; then
      add_finding "Low" "Apache Hardening" "Apache ServerTokens is not set to Prod" "ServerTokens=$APACHE_TOKENS" "Set ServerTokens Prod to reduce banner detail."
    fi
    if [ "$APACHE_SIG" != "off" ]; then
      add_finding "Low" "Apache Hardening" "Apache ServerSignature is not Off" "ServerSignature=$APACHE_SIG" "Set ServerSignature Off to avoid verbose error-page signatures."
    fi
  fi

  if cmd_exists nginx; then
    NGINX_CONF="$(nginx -T 2>&1 || true)"
    add_hardening_evidence "Nginx config dump" "nginx -T" "$(printf '%s' "$NGINX_CONF" | head -300)"
    if ! printf '%s' "$NGINX_CONF" | grep -Eiq 'server_tokens\s+off\s*;'; then
      add_finding "Low" "Nginx Hardening" "Nginx server_tokens is not clearly disabled" "server_tokens off was not found in nginx -T output." "Set server_tokens off; in the http/server context."
    fi
  fi

  if cmd_exists redis-server || cmd_exists redis-cli; then
    REDIS_CONF=""
    for f in /etc/redis/redis.conf /etc/redis.conf; do
      [ -f "$f" ] && REDIS_CONF="$f" && break
    done
    if [ -n "$REDIS_CONF" ]; then
      REDIS_RELEVANT="$(grep -Ei '^\s*(bind|protected-mode|requirepass|aclfile|port)\b' "$REDIS_CONF" 2>/dev/null || true)"
      add_hardening_evidence "Redis config highlights" "grep redis.conf" "$REDIS_RELEVANT"
      if ! printf '%s' "$REDIS_RELEVANT" | grep -Eiq '^\s*protected-mode\s+yes'; then
        add_finding "High" "Redis Hardening" "Redis protected-mode is not clearly enabled" "protected-mode yes was not found." "Enable protected-mode and restrict bind/listen addresses."
      fi
      if ! printf '%s' "$REDIS_RELEVANT" | grep -Eiq '^\s*(requirepass|aclfile)\b'; then
        add_finding "Medium" "Redis Hardening" "Redis authentication is not clearly configured" "No requirepass or aclfile setting found in redis.conf highlights." "Configure Redis ACLs/authentication and limit network exposure."
      fi
    fi
  fi

  if cmd_exists ss && ss -tulpen 2>/dev/null | grep -E 'redis-server.*(0\.0\.0\.0|[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+):6379' >/dev/null; then
    add_finding "High" "Exposed Services" "Redis appears reachable on a non-loopback address" "Redis is listening on TCP 6379 outside localhost." "Restrict Redis to localhost/private trusted hosts, require authentication, and firewall the port."
  fi

  if [ -S /var/run/docker.sock ]; then
    DOCKER_SOCK_LS="$(ls -l /var/run/docker.sock 2>/dev/null || true)"
    add_hardening_evidence "Docker socket" "ls -l /var/run/docker.sock" "$DOCKER_SOCK_LS"
    if printf '%s' "$DOCKER_SOCK_LS" | grep -Eq 'srw.rw.rw.'; then
      add_finding "Critical" "Docker Hardening" "Docker socket appears world-writable" "$DOCKER_SOCK_LS" "Restrict docker.sock permissions immediately. Access to docker.sock is effectively root-equivalent."
    elif printf '%s' "$DOCKER_SOCK_LS" | grep -q ' docker '; then
      add_finding "Medium" "Docker Hardening" "Docker socket grants root-equivalent access to docker group" "$DOCKER_SOCK_LS" "Review docker group membership and treat it as privileged/root-equivalent access."
    fi
  fi
}

build_quick_actions() {
  : > "$QUICKACTIONS"
  prio=1

  if [ -s "$FINDINGS" ]; then
    awk -F '\t' '$1=="Critical" || $1=="High" || $1=="Medium" {print $1 "\t" $3 "\t" $5}' "$FINDINGS" | head -8 | while IFS="$(printf '\t')" read -r sev title rec; do
      [ -z "$title" ] && continue
      case "$sev" in Critical) weight=1 ;; High) weight=2 ;; Medium) weight=3 ;; *) weight=4 ;; esac
      printf "%s\t%s\t%s\n" "$weight" "$title" "$rec" >> "$QUICKACTIONS"
    done
  fi

  if [ -s "$LIFECYCLE_RESPONSE" ] && cmd_exists python3; then
    python3 - "$LIFECYCLE_RESPONSE" >> "$QUICKACTIONS" <<'PY_QUICK_ACTIONS'
import json, sys
try:
    data=json.load(open(sys.argv[1], encoding='utf-8'))
except Exception:
    sys.exit(0)
items=data.get('results') or data.get('items') or data.get('data') or []
for r in items:
    st=(r.get('status') or '').lower()
    sev=(r.get('severity') or '').lower()
    if st in ('end_of_support','unsupported_major_version','update_available','extended_support','legacy_review') or sev in ('critical','high'):
        product=r.get('product') or r.get('matched_product') or 'software'
        version=r.get('installed_version') or r.get('normalized_version') or ''
        rec=r.get('recommended_action') or r.get('evidence') or 'Review lifecycle status.'
        weight='2' if st in ('end_of_support','unsupported_major_version') else '4'
        print(f"{weight}\tReview lifecycle for {product} {version}\t{rec}")
PY_QUICK_ACTIONS
  fi

  if [ ! -s "$QUICKACTIONS" ]; then
    printf "9\tReview report evidence\tNo urgent quick actions were generated, but review exposed services, lifecycle and CVE sections.\n" > "$QUICKACTIONS"
  fi
}

sev_badge() {
  local sev="$1"
  case "$sev" in
    Critical) echo "sev-critical" ;;
    High) echo "sev-high" ;;
    Medium) echo "sev-medium" ;;
    Low) echo "sev-low" ;;
    *) echo "sev-info" ;;
  esac
}

render_findings_table() {
  if [ ! -s "$FINDINGS" ]; then
    echo "<div class='scope-ok'><b>No local findings generated by the current rule set.</b></div>" >> "$REPORT"
    return
  fi

  echo "<table><tr><th>Severity</th><th>Area</th><th>Finding / Evidence</th><th>Recommendation</th></tr>" >> "$REPORT"
  while IFS="$(printf '\t')" read -r sev area title evidence rec; do
    cls="$(sev_badge "$sev")"
    echo "<tr><td><span class='sev $cls'>$(printf '%s' "$sev" | html_escape)</span></td><td>$(printf '%s' "$area" | html_escape)</td><td><b>$(printf '%s' "$title" | html_escape)</b><br><span class='muted'>$(printf '%s' "$evidence" | html_escape)</span></td><td>$(printf '%s' "$rec" | html_escape)</td></tr>" >> "$REPORT"
  done < "$FINDINGS"
  echo "</table>" >> "$REPORT"
}

detect_distro

check_linux_version_feed

add_adv_row() {
  local sev="$1" area="$2" item="$3" evidence="$4" rec="$5" risk="${6:-security}"
  printf "%s\t%s\t%s\t%s\t%s\t%s\n" "$sev" "$area" "$item" "$evidence" "$rec" "$risk" >> "$ADVHEALTH_TSV"
  [ "$sev" != "Info" ] && add_finding "$sev" "$area" "$item" "$evidence" "$rec"
}

collect_advanced_health_checks() {
  : > "$ADVHEALTH_TSV"
  : > "$ADVHEALTH_RAW"
  printf "severity\tarea\titem\tevidence\trecommendation\trisk_type\n" >> "$ADVHEALTH_TSV"

  {
    echo "### Advanced health/security checks"
    echo "Generated: $(date '+%Y-%m-%d %H:%M:%S')"
    echo
  } >> "$ADVHEALTH_RAW"

  # Boot security
  {
    echo "### Boot security"
    command -v bootctl >/dev/null 2>&1 && bootctl status 2>&1 || true
    command -v mokutil >/dev/null 2>&1 && mokutil --sb-state 2>&1 || true
    test -d /sys/firmware/efi && echo "UEFI=yes" || echo "UEFI=no"
    test -e /dev/tpm0 -o -e /dev/tpmrm0 && echo "TPM device present" || echo "TPM device not detected"
    grep -RInE 'password_pbkdf2|superusers' /boot/grub /etc/grub.d /boot/efi 2>/dev/null || true
  } >> "$ADVHEALTH_RAW"

  if [ -d /sys/firmware/efi ]; then
    printf "Info\tBoot Security\tUEFI boot detected\t/sys/firmware/efi exists\tNo action required.\tmaintainability\n" >> "$ADVHEALTH_TSV"
  else
    add_adv_row "Info" "Boot Security" "Legacy BIOS boot detected" "UEFI firmware path not found" "Verify whether legacy BIOS boot is expected for this server." "maintainability"
  fi

  if command -v mokutil >/dev/null 2>&1; then
    sb="$(mokutil --sb-state 2>/dev/null || true)"
    if printf '%s' "$sb" | grep -qi enabled; then
      printf "Info\tBoot Security\tSecure Boot enabled\t%s\tNo action required.\tsecurity\n" "$sb" >> "$ADVHEALTH_TSV"
    elif [ -d /sys/firmware/efi ]; then
      add_adv_row "Low" "Boot Security" "Secure Boot is not enabled" "$sb" "Consider Secure Boot where supported and compatible with drivers/modules." "security"
    fi
  fi

  if [ -e /dev/tpm0 ] || [ -e /dev/tpmrm0 ]; then
    printf "Info\tBoot Security\tTPM present\tTPM device detected\tNo action required.\tsecurity\n" >> "$ADVHEALTH_TSV"
  fi

  if grep -RqsE 'password_pbkdf2|superusers' /boot/grub /etc/grub.d 2>/dev/null; then
    printf "Info\tBoot Security\tGRUB password appears configured\tGRUB superusers/password directive found\tReview bootloader policy.\tsecurity\n" >> "$ADVHEALTH_TSV"
  else
    add_adv_row "Info" "Boot Security" "GRUB password not detected" "No GRUB password directive found in common locations" "Consider bootloader password on sensitive physical servers." "security"
  fi

  # Kernel security
  {
    echo "### Kernel security"
    test -r /sys/kernel/security/lockdown && cat /sys/kernel/security/lockdown || true
    test -r /proc/sys/kernel/tainted && cat /proc/sys/kernel/tainted || true
    dmesg 2>/dev/null | grep -Ei 'taint|unsigned|verification failed|module verification' | tail -30 || true
    canonical-livepatch status 2>/dev/null || true
    ksplice all show 2>/dev/null || true
  } >> "$ADVHEALTH_RAW"

  if [ -r /sys/kernel/security/lockdown ]; then
    lock="$(cat /sys/kernel/security/lockdown 2>/dev/null)"
    printf '%s' "$lock" | grep -Eq '\[none\]' && add_adv_row "Low" "Kernel Security" "Kernel lockdown mode is not active" "$lock" "Consider kernel lockdown on Secure Boot capable systems where compatible." "security"
  fi
  if [ -r /proc/sys/kernel/tainted ]; then
    taint="$(cat /proc/sys/kernel/tainted 2>/dev/null || echo 0)"
    [ "${taint:-0}" != "0" ] && add_adv_row "Medium" "Kernel Security" "Kernel is tainted" "tainted=$taint" "Review loaded modules, proprietary drivers, previous kernel warnings and supportability." "security"
  fi
  dmesg 2>/dev/null | grep -Eiq 'unsigned module|module verification failed|tainting kernel' && add_adv_row "Medium" "Kernel Security" "Unsigned or unverified kernel module evidence found" "dmesg contains module verification/taint evidence" "Review third-party kernel modules and signing policy." "security"

  # Storage integrity
  {
    echo "### Filesystem/RAID/storage integrity"
    mount | grep -E ' ro[,)]|errors=remount-ro' || true
    dmesg 2>/dev/null | grep -Ei 'I/O error|EXT4-fs error|XFS.*error|BTRFS.*error|read-only filesystem' | tail -40 || true
    cat /proc/mdstat 2>/dev/null || true
    command -v lvs >/dev/null 2>&1 && lvs -a -o lv_name,vg_name,lv_attr,devices 2>&1 || true
    command -v zpool >/dev/null 2>&1 && zpool status 2>&1 || true
    command -v btrfs >/dev/null 2>&1 && btrfs filesystem show 2>&1 || true
    command -v smartctl >/dev/null 2>&1 && smartctl --scan 2>&1 || true
  } >> "$ADVHEALTH_RAW"
  mount | grep -Eq ' ro[,)]' && add_adv_row "High" "Storage" "Read-only mount detected" "mount output contains read-only filesystem" "Investigate filesystem errors, storage problems or intentional read-only mounts." "operational"
  dmesg 2>/dev/null | grep -Eiq 'I/O error|EXT4-fs error|XFS.*error|BTRFS.*error|read-only filesystem' && add_adv_row "High" "Storage" "Filesystem or disk error evidence in kernel log" "dmesg contains storage/filesystem error patterns" "Investigate disk, filesystem and hypervisor/storage backend health." "operational"
  [ -r /proc/mdstat ] && grep -Eq '\[.*_.*\]|recovery|resync|degraded' /proc/mdstat && add_adv_row "High" "Storage" "Software RAID may be degraded or rebuilding" "$(cat /proc/mdstat | tr '\n' ' ')" "Review mdraid status and replace/rebuild failed disks." "operational"
  command -v zpool >/dev/null 2>&1 && zpool status 2>/dev/null | grep -Eiq 'DEGRADED|FAULTED|OFFLINE|UNAVAIL' && add_adv_row "High" "Storage" "ZFS pool issue detected" "zpool status reports degraded/faulted/offline/unavail" "Review zpool status and repair storage redundancy." "operational"

  # Network
  {
    echo "### Network config"
    ip route show 2>&1 || true
    resolvectl status 2>&1 || systemd-resolve --status 2>&1 || cat /etc/resolv.conf 2>&1 || true
    sysctl net.ipv4.ip_forward net.ipv6.conf.all.disable_ipv6 net.bridge.bridge-nf-call-iptables 2>/dev/null || true
  } >> "$ADVHEALTH_RAW"
  defroutes="$(ip route show default 2>/dev/null | wc -l | tr -d ' ')"
  [ "${defroutes:-0}" -gt 1 ] 2>/dev/null && add_adv_row "Low" "Network" "Multiple default routes detected" "$defroutes default routes" "Verify routing design and metrics." "operational"
  [ ! -s /etc/resolv.conf ] && add_adv_row "Medium" "Network" "Resolver configuration missing or empty" "/etc/resolv.conf missing/empty" "Verify DNS resolver configuration." "operational"
  ipf="$(sysctl -n net.ipv4.ip_forward 2>/dev/null || echo 0)"
  [ "$ipf" = "1" ] && add_adv_row "Low" "Network" "IPv4 forwarding enabled" "net.ipv4.ip_forward=1" "Verify this server is intentionally routing/NATing traffic." "security"

  # Dangerous services
  {
    echo "### Dangerous/legacy services"
    ss -ltnup 2>/dev/null | grep -Ei ':(23|513|514|512|69|79|111|515|631)\b' || true
    systemctl list-unit-files 2>/dev/null | grep -Ei 'telnet|rsh|rexec|rlogin|tftp|finger|rpcbind|cups|avahi|smb|nmb|vsftpd' || true
  } >> "$ADVHEALTH_RAW"
  for spec in "23:Telnet" "513:rlogin" "512:rexec" "69:TFTP" "79:Finger" "111:rpcbind" "631:CUPS"; do
    port="${spec%%:*}"; svc="${spec#*:}"
    ss -ltnup 2>/dev/null | grep -Eq ":$port\b" && add_adv_row "Medium" "Dangerous Services" "$svc listener detected" "port=$port listening" "Disable if unused, or restrict exposure to trusted networks." "security"
  done
  systemctl is-active avahi-daemon >/dev/null 2>&1 && add_adv_row "Low" "Dangerous Services" "Avahi/mDNS service active" "avahi-daemon active" "Disable on servers unless service discovery is required." "security"

  # Containers
  {
    echo "### Container security"
    ls -l /var/run/docker.sock /run/docker.sock 2>/dev/null || true
    docker ps --format '{{.ID}}\t{{.Image}}\t{{.Names}}\t{{.Ports}}' 2>/dev/null || true
    podman ps --format '{{.ID}}\t{{.Image}}\t{{.Names}}\t{{.Ports}}' 2>/dev/null || true
  } >> "$ADVHEALTH_RAW"
  for sock in /var/run/docker.sock /run/docker.sock; do
    if [ -S "$sock" ]; then
      mode="$(stat -c '%a %U:%G' "$sock" 2>/dev/null || true)"
      printf '%s' "$mode" | grep -Eq '^[0-9]*[2367]$|^[0-9][2367][0-9]' && add_adv_row "High" "Containers" "Docker socket has broad write permissions" "$sock $mode" "Restrict Docker socket permissions; docker access is effectively root." "security"
    fi
  done
  if command -v docker >/dev/null 2>&1 && docker ps -q 2>/dev/null | grep -q .; then
    for cid in $(docker ps -q 2>/dev/null); do
      cname="$(docker inspect -f '{{.Name}}' "$cid" 2>/dev/null | sed 's#^/##')"
      priv="$(docker inspect -f '{{.HostConfig.Privileged}}' "$cid" 2>/dev/null || true)"
      net="$(docker inspect -f '{{.HostConfig.NetworkMode}}' "$cid" 2>/dev/null || true)"
      user="$(docker inspect -f '{{.Config.User}}' "$cid" 2>/dev/null || true)"
      binds="$(docker inspect -f '{{range .HostConfig.Binds}}{{.}} {{end}}' "$cid" 2>/dev/null || true)"
      [ "$priv" = "true" ] && add_adv_row "High" "Containers" "Privileged container running" "$cname privileged=true" "Avoid privileged containers unless strictly required." "security"
      [ "$net" = "host" ] && add_adv_row "Medium" "Containers" "Container uses host networking" "$cname network=host" "Review whether host networking is required." "security"
      [ -z "$user" ] && add_adv_row "Low" "Containers" "Container may run as root" "$cname user not set" "Run containers as non-root where practical." "security"
      printf '%s' "$binds" | grep -Eq '(^| )/:(|[^ ]*):' && add_adv_row "High" "Containers" "Container bind-mounts host root" "$cname binds=$binds" "Avoid mounting host root into containers." "security"
    done
  fi

  # Kubernetes
  {
    echo "### Kubernetes"
    ps aux 2>/dev/null | grep -E 'kubelet|kube-apiserver|etcd' | grep -v grep || true
    ss -ltnp 2>/dev/null | grep -E ':(6443|10250|10255|2379|2380)\b' || true
  } >> "$ADVHEALTH_RAW"
  if pgrep -x kubelet >/dev/null 2>&1 || ss -ltn 2>/dev/null | grep -Eq ':(10250|10255)\b'; then
    ss -ltn 2>/dev/null | grep -Eq ':10255\b' && add_adv_row "High" "Kubernetes" "Read-only kubelet port appears exposed" "TCP/10255 listening" "Disable read-only kubelet port." "security"
    ss -ltn 2>/dev/null | grep -Eq ':10250\b' && add_adv_row "Medium" "Kubernetes" "Kubelet API port detected" "TCP/10250 listening" "Verify kubelet authn/authz and firewall exposure." "security"
  fi

  # DB/Mail/Web app
  {
    echo "### Database/Mail/Web app"
    ss -ltnp 2>/dev/null | grep -E ':(3306|5432|6379|27017|9200|9300|25|465|587|143|993|110|995)\b' || true
    postconf -n 2>/dev/null | head -120 || true
    php -i 2>/dev/null | grep -Ei 'expose_php|disable_functions|upload_max_filesize|open_basedir' || true
  } >> "$ADVHEALTH_RAW"
  for p in 3306 5432 6379 27017 9200 9300; do
    ss -ltn 2>/dev/null | grep -Eq "(0\.0\.0\.0|::|\*).*[.:]$p\b" && add_adv_row "Medium" "Database Security" "Database/search service listens on all interfaces" "port=$p all-interface listener" "Restrict bind address and firewall exposure." "security"
  done
  if command -v postconf >/dev/null 2>&1 && ss -ltn 2>/dev/null | grep -Eq ':(25|465|587)\b'; then
    smtpd_tls="$(postconf -h smtpd_tls_security_level 2>/dev/null || true)"
    [ -z "$smtpd_tls" ] || [ "$smtpd_tls" = "none" ] && add_adv_row "Medium" "Mail Security" "SMTP TLS not clearly enforced" "smtpd_tls_security_level=${smtpd_tls:-empty}" "Review SMTP TLS policy." "security"
    relay="$(postconf -h mynetworks 2>/dev/null || true)"
    printf '%s' "$relay" | grep -Eq '0\.0\.0\.0/0|::/0' && add_adv_row "High" "Mail Security" "Possible open relay network scope" "mynetworks=$relay" "Review Postfix relay restrictions immediately." "security"
  fi
  if command -v php >/dev/null 2>&1; then
    exp="$(php -i 2>/dev/null | awk -F'=> ' '/expose_php/{print $2; exit}' | awk '{print $1}')"
    [ "$exp" = "On" ] && add_adv_row "Low" "Web Application" "PHP expose_php is enabled" "expose_php=On" "Set expose_php=Off." "security"
    dis="$(php -i 2>/dev/null | awk -F'=> ' '/disable_functions/{print $2; exit}')"
    [ -z "$dis" ] || [ "$dis" = "no value" ] && add_adv_row "Info" "Web Application" "PHP disable_functions is empty" "disable_functions=${dis:-empty}" "Review whether dangerous PHP functions should be disabled for shared/untrusted apps." "security"
  fi
  for p in 80 443 8080 8443; do
    hdr="$(curl -kIs --max-time 3 "https://127.0.0.1:$p/" 2>/dev/null || curl -Is --max-time 3 "http://127.0.0.1:$p/" 2>/dev/null || true)"
    [ -z "$hdr" ] && continue
    printf '%s' "$hdr" | grep -qi '^Strict-Transport-Security:' || add_adv_row "Low" "Web Application" "HSTS header not detected locally" "port=$p" "Add HSTS where HTTPS is enforced and compatible." "security"
    printf '%s' "$hdr" | grep -qi '^X-Frame-Options:' || add_adv_row "Low" "Web Application" "X-Frame-Options header not detected locally" "port=$p" "Add X-Frame-Options or CSP frame-ancestors." "security"
    printf '%s' "$hdr" | grep -qi '^Content-Security-Policy:' || add_adv_row "Info" "Web Application" "CSP header not detected locally" "port=$p" "Consider CSP for web applications where practical." "security"
  done

  # Cron / startup / login / logs / certs / cloud / performance
  {
    echo "### Cron/startup/login/logs/certs/cloud/performance"
    find /etc/cron.d /etc/cron.daily /etc/cron.hourly /etc/cron.weekly /etc/cron.monthly /var/spool/cron /var/spool/cron/crontabs -type f -maxdepth 2 -print -exec ls -l {} \; 2>/dev/null || true
    grep -RInE '/tmp|/var/tmp|curl|wget|nc |bash -c|base64' /etc/cron* /var/spool/cron* 2>/dev/null || true
    uptime 2>&1 || true
    last -n 10 2>&1 || true
    lastb -n 10 2>&1 || true
    journalctl --disk-usage 2>&1 || true
    find /etc /opt /usr/local -xdev \( -name '*.crt' -o -name '*.pem' -o -name '*.cer' \) -type f 2>/dev/null | head -200
    systemd-detect-virt 2>&1 || true
    cloud-init status 2>&1 || true
    free -h 2>&1 || true
    vmstat 1 2 2>&1 || true
    dmesg 2>/dev/null | grep -Ei 'out of memory|oom-killer|killed process' | tail -20 || true
  } >> "$ADVHEALTH_RAW"
  find /etc/cron.d /etc/cron.daily /etc/cron.hourly /etc/cron.weekly /etc/cron.monthly /var/spool/cron /var/spool/cron/crontabs -type f 2>/dev/null | while read -r cf; do
    mode="$(stat -c '%a' "$cf" 2>/dev/null || true)"
    if [ -n "$mode" ]; then
      other="${mode: -1}"; group="${mode: -2:1}"
      printf '%s%s' "$group" "$other" | grep -Eq '[2367]' && add_adv_row "High" "Cron" "Cron file is writable by group/others" "$cf mode=$mode" "Remove unsafe write permissions from cron files." "security"
    fi
    grep -Eq '/tmp|/var/tmp' "$cf" 2>/dev/null && add_adv_row "Medium" "Cron" "Cron job references temporary path" "$cf references tmp path" "Review cron job for tampering risk." "security"
  done
  if command -v systemctl >/dev/null 2>&1; then
    enabled_not_running="$(systemctl list-unit-files --type=service --state=enabled --no-legend 2>/dev/null | awk '{print $1}' | while read -r u; do systemctl is-active "$u" >/dev/null 2>&1 || echo "$u"; done | head -20 | paste -sd ', ' -)"
    [ -n "$enabled_not_running" ] && add_adv_row "Low" "Startup Services" "Enabled services not currently running" "$enabled_not_running" "Review whether enabled services should be running or disabled." "operational"
  fi
  if command -v lastb >/dev/null 2>&1; then
    fb="$(lastb -n 20 2>/dev/null | grep -vc '^$' || true)"
    [ "${fb:-0}" -gt 5 ] 2>/dev/null && add_adv_row "Low" "Login Activity" "Recent failed login records detected" "$fb recent lastb rows" "Review authentication logs and brute-force controls." "security"
  fi
  command -v journalctl >/dev/null 2>&1 && printf "Info\tLog Health\tJournal disk usage\t%s\tReview retention policy.\toperational\n" "$(journalctl --disk-usage 2>/dev/null | grep -Eo '[0-9.]+[MG]B' | tail -1 || echo unknown)" >> "$ADVHEALTH_TSV"
  find /etc /opt /usr/local -xdev \( -name '*.crt' -o -name '*.pem' -o -name '*.cer' \) -type f 2>/dev/null | head -100 | while read -r cert; do
    if openssl x509 -in "$cert" -noout -enddate >/dev/null 2>&1; then
      end="$(openssl x509 -in "$cert" -noout -enddate 2>/dev/null | cut -d= -f2-)"
      end_epoch="$(date -d "$end" +%s 2>/dev/null || true)"
      now_epoch="$(date +%s)"
      if [ -n "$end_epoch" ]; then
        days="$(( (end_epoch - now_epoch) / 86400 ))"
        [ "$days" -lt 0 ] && add_adv_row "Medium" "Certificates" "Local certificate file is expired" "$cert days_remaining=$days" "Renew or remove expired certificate file if unused." "security"
        [ "$days" -ge 0 ] && [ "$days" -le 30 ] && add_adv_row "Low" "Certificates" "Local certificate file expires soon" "$cert days_remaining=$days" "Renew if the certificate is still in use." "security"
      fi
    fi
  done
  virt="$(systemd-detect-virt 2>/dev/null || true)"
  [ -n "$virt" ] && printf "Info\tPlatform\tVirtualization/cloud hint\t%s\tUse platform-specific patching/backup/agent guidance.\tmaintainability\n" "$virt" >> "$ADVHEALTH_TSV"
  command -v cloud-init >/dev/null 2>&1 && printf "Info\tCloud\tcloud-init detected\tcloud-init present\tReview cloud-init status and metadata exposure.\tmaintainability\n" >> "$ADVHEALTH_TSV"
  load1="$(awk '{print $1}' /proc/loadavg 2>/dev/null || echo 0)"
  cpus="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)"
  awk -v l="$load1" -v c="$cpus" 'BEGIN{exit !(l > c*2)}' && add_adv_row "Medium" "Performance" "Load average is high" "load1=$load1 cpus=$cpus" "Review CPU, I/O wait and workload saturation." "operational"
  swap_used="$(free -m 2>/dev/null | awk '/Swap:/ {print $3}')"
  swap_total="$(free -m 2>/dev/null | awk '/Swap:/ {print $2}')"
  if [ "${swap_total:-0}" -gt 0 ] 2>/dev/null && [ "${swap_used:-0}" -gt 0 ] 2>/dev/null; then
    pct=$(( swap_used * 100 / swap_total ))
    [ "$pct" -gt 50 ] && add_adv_row "Medium" "Performance" "Swap usage is high" "swap_used=${pct}%" "Review memory pressure and top consumers." "operational"
  fi
  dmesg 2>/dev/null | grep -Eiq 'out of memory|oom-killer|killed process' && add_adv_row "High" "Performance" "OOM killer evidence found" "dmesg contains OOM killer patterns" "Investigate memory pressure and affected processes." "operational"
}

normalize_product_name() {
  printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9.+_-]+/ /g;s/^[ ]+//;s/[ ]+$//'
}

version_status_guess() {
  local installed="$1" latest="$2"
  [ -z "$latest" ] || [ "$latest" = "unknown" ] && { echo "unknown"; return; }
  [ -z "$installed" ] || [ "$installed" = "unknown" ] && { echo "unknown"; return; }
  if [ "$installed" = "$latest" ]; then echo "current"; return; fi
  # Basic dotted number compare when both begin numeric.
  if printf '%s\n%s\n' "$installed" "$latest" | grep -Eq '^[0-9]'; then
    newest="$(printf '%s\n%s\n' "$installed" "$latest" | sort -V 2>/dev/null | tail -1)"
    if [ "$newest" = "$latest" ] && [ "$installed" != "$latest" ]; then echo "update_available"; else echo "newer_or_different"; fi
  else
    echo "review"
  fi
}

audit_lifecycle_payload_inventory() {
  {
    echo "### Lifecycle payload inventory audit"
    echo "Payload file: $LIFECYCLE_PAYLOAD"
    if [ -s "$LIFECYCLE_PAYLOAD" ] && command -v python3 >/dev/null 2>&1; then
      python3 - "$LIFECYCLE_PAYLOAD" <<'PY_AUDIT_LIFECYCLE_PAYLOAD'
import json, sys
try:
    d=json.load(open(sys.argv[1],encoding="utf-8"))
    items=d.get("items") or []
    print("items_sent=%s" % len(items))
    for r in items[:300]:
        print("%s\t%s\t%s" % (r.get("product",""), r.get("version",""), r.get("source","")))
except Exception as e:
    print("payload_audit_error=%s" % e)
PY_AUDIT_LIFECYCLE_PAYLOAD
    else
      echo "Lifecycle payload missing or python3 unavailable."
    fi
  } >> "$LATESTVERSIONS_RAW" 2>/dev/null || true
}

build_latest_version_intelligence() {
  : > "$LATESTVERSIONS_TSV"
  : > "$LATESTVERSIONS_RAW"
  printf "product\tinstalled_version\tlatest_version\tstatus\tsource\tconfidence\tnote\n" >> "$LATESTVERSIONS_TSV"

  {
    echo "### Latest version intelligence"
    echo "Generated: $(date '+%Y-%m-%d %H:%M:%S')"
    echo "Sources: lifecycle API response, lifecycle table/raw response and local runtime/software inventory."
    echo
  } >> "$LATESTVERSIONS_RAW"

  add_latest_row() {
    local product="$1" installed="$2" latest="$3" source="$4" confidence="$5" note="$6"
    product="$(printf '%s' "$product" | tr '\t' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
    installed="$(printf '%s' "$installed" | tr '\t' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
    latest="$(printf '%s' "$latest" | tr '\t' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
    [ -z "$product" ] && return 0
    [ -z "$installed" ] && installed="unknown"
    [ -z "$latest" ] && latest="unknown"
    case "$(printf '%s' "$note" | tr '[:upper:]' '[:lower:]')" in
      current|ok|matched_current)
        [ "$latest" = "unknown" ] && [ "$installed" != "unknown" ] && latest="$installed"
        ;;
    esac
    [ -z "$source" ] && source="unknown"
    [ -z "$confidence" ] && confidence="unknown"
    status="$(version_status_guess "$installed" "$latest")"
    printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" "$product" "$installed" "$latest" "$status" "$source" "$confidence" "$note" >> "$LATESTVERSIONS_TSV"
  }

  audit_lifecycle_payload_inventory || true

  # Parse actual lifecycle API response JSON first. Do not parse LIFECYCLE_RAW here; it contains wrappers.
  if [ -n "${LIFECYCLE_RESPONSE:-}" ] && [ -s "${LIFECYCLE_RESPONSE:-}" ] && command -v python3 >/dev/null 2>&1; then
    echo "--- parsing lifecycle response $LIFECYCLE_RESPONSE" >> "$LATESTVERSIONS_RAW"
    latest_tmp="$OUTDIR/latest_response_rows_$TS.tsv"
    python3 - "$LIFECYCLE_RESPONSE" > "$latest_tmp" 2>>"$LATESTVERSIONS_RAW" <<'PY_LATEST_RESPONSE'
import json, sys

def first(d, names):
    if not isinstance(d, dict):
        return ""
    lower={str(k).lower():k for k in d.keys()}
    for n in names:
        k=lower.get(n.lower())
        if k is not None and d.get(k) not in (None, ""):
            return d.get(k)
    return ""

def find_latest(d):
    return first(d, [
        "latest_version","latestversion","latest_version_resolved","latestversionresolved",
        "latest_observed_version","latestobservedversion",
        "latest_observed","latestobserved","latest","latest_release","latestrelease",
        "current_latest","currentlatest","vendor_latest","vendorlatest","recommended_version",
        "recommendedversion","available_version","availableversion","newest_version",
        "newestversion","upstream_version","upstreamversion","server_latest","serverlatest",
        "community_latest","communitylatest"
    ])

def find_installed(d):
    return first(d, [
        "installed_version","installedversion","current_version","currentversion",
        "version","normalized_version","normalizedversion","detected_version",
        "detectedversion","package_version","packageversion","input_version","inputversion"
    ])

def find_product(d):
    return first(d, [
        "product","name","software","package","input_product","inputproduct",
        "matched_product","matchedproduct","catalog_product","catalogproduct","display_name","displayname"
    ])

def find_source(d):
    return first(d, [
        "latest_version_source","latestversionsource","source","latest_source","latestsource",
        "source_label","sourcelabel","catalog","catalog_file","catalogfile","provider"
    ]) or "lifecycle-api"

def find_conf(d):
    return first(d, [
        "latest_version_confidence","latestversionconfidence","confidence",
        "latest_confidence","latestconfidence","source_confidence","sourceconfidence",
        "match_confidence","matchconfidence"
    ]) or ""

def find_note(d):
    return first(d, [
        "latest_version_detail","latestversiondetail","status","status_label","statuslabel",
        "note","evidence","recommendation","recommended_action","recommendedaction","source_detail","sourcedetail"
    ]) or ""

try:
    data=json.load(open(sys.argv[1],encoding="utf-8",errors="ignore"))
except Exception as e:
    print("PARSE_ERROR\t\t\tlifecycle-api\t\t%s" % e)
    sys.exit(0)

def nested_latest(x):
    if not isinstance(x, dict):
        return ""
    for key in ("latest_info","latestInfo","community","observed","lifecycle","metadata","version_info","versionInfo"):
        v=x.get(key)
        if isinstance(v,dict):
            lv=find_latest(v)
            if lv:
                return lv
    return ""

def walk(x):
    if isinstance(x, dict):
        p=find_product(x)
        inst=find_installed(x)
        latest=find_latest(x) or nested_latest(x)
        note=find_note(x)
        if not latest and inst and str(note).lower() in ("current","ok","matched_current"):
            latest=inst
        if p and (latest or inst):
            print("%s\t%s\t%s\t%s\t%s\t%s" % (p, inst, latest, find_source(x), find_conf(x), note))
        for v in x.values():
            walk(v)
    elif isinstance(x, list):
        for v in x:
            walk(v)

walk(data)
PY_LATEST_RESPONSE
    if [ -s "$latest_tmp" ]; then
      while IFS="$(printf '\t')" read -r p inst latest src conf note; do
        add_latest_row "$p" "$inst" "$latest" "$src" "$conf" "$note"
      done < "$latest_tmp"
    fi
  else
    echo "Lifecycle response not available; latest-version table will use local inventory fallback." >> "$LATESTVERSIONS_RAW"
  fi

  # Parse lifecycle TSV/table files if present.
  for lf in "${LIFECYCLE_TSV:-}" "${LIFECYCLE_ROWS:-}" "${LIFECYCLE_TABLE:-}"; do
    [ -n "$lf" ] && [ -s "$lf" ] || continue
    echo "--- parsing lifecycle table $lf" >> "$LATESTVERSIONS_RAW"
    awk -F '\t' '
      NR==1 {
        for(i=1;i<=NF;i++){h=tolower($i); col[h]=i}
        next
      }
      NR>1 {
        p=""; inst=""; latest=""; src="lifecycle-table"; conf=""; note=""
        for (h in col) {
          if (h ~ /^(product|name|software|package|matched_product)$/) p=$col[h]
          if (h ~ /(installed|current|detected|normalized|input).*version/ || h=="version") inst=$col[h]
          if (h ~ /(latest|observed|available|newest|upstream|vendor|community).*version/ || h=="latest") latest=$col[h]
          if (h ~ /^source$|catalog|provider/) src=$col[h]
          if (h ~ /confidence/) conf=$col[h]
          if (h ~ /status|note|evidence/) note=$col[h]
        }
        if(p!="") print p "\t" inst "\t" latest "\t" src "\t" conf "\t" note
      }
    ' "$lf" 2>/dev/null | while IFS="$(printf '\t')" read -r p inst latest src conf note; do
      add_latest_row "$p" "$inst" "$latest" "$src" "$conf" "$note"
    done
  done

  # Ensure every sent lifecycle item appears as fallback. SERVICEFILE has no header.
  if [ -s "${SERVICEFILE:-}" ]; then
    while IFS="$(printf '\t')" read -r product version source rest; do
      [ -z "${product:-}" ] && continue
      [ -z "${version:-}" ] && continue
      add_latest_row "$product" "$version" "unknown" "${source:-service-inventory}" "local" "Sent to lifecycle API; no latest version returned or parsed."
    done < "$SERVICEFILE"
  fi

  # PACKAGE_EXTRA_TSV has a header.
  if [ -s "${PACKAGE_EXTRA_TSV:-}" ]; then
    tail -n +2 "$PACKAGE_EXTRA_TSV" 2>/dev/null | while IFS="$(printf '\t')" read -r product version source type rest; do
      [ -z "${product:-}" ] && continue
      [ -z "${version:-}" ] && continue
      add_latest_row "$product" "$version" "unknown" "${source:-extra-discovery}/${type:-runtime}" "local" "Detected locally; no latest version returned or parsed."
    done
  fi

  # Deduplicate. Prefer known latest over unknown; prefer lifecycle/API over local fallback.
  if [ -s "$LATESTVERSIONS_TSV" ]; then
    tmp="$OUTDIR/latest_versions_dedup_$TS.tmp"
    head -1 "$LATESTVERSIONS_TSV" > "$tmp"
    tail -n +2 "$LATESTVERSIONS_TSV" | awk -F '\t' '
      function norm(s){gsub(/[^A-Za-z0-9.+_-]+/," ",s); return tolower(s)}
      {
        k=norm($1)"\t"$2
        score=0
        if($3!="" && $3!="unknown") score+=100
        if(tolower($5) ~ /api|lifecycle|catalog|community/) score+=20
        if(tolower($4)=="current") score+=5
        if(!(k in best) || score>best[k]) {best[k]=score; row[k]=$0}
      }
      END{for(k in row) print row[k]}
    ' | sort -f >> "$tmp"
    mv "$tmp" "$LATESTVERSIONS_TSV"
  fi

  return 0
}

render_latest_versions_html() {
  if [ ! -s "$LATESTVERSIONS_TSV" ] || [ "$(wc -l < "$LATESTVERSIONS_TSV" | tr -d ' ')" -le 1 ]; then
    echo "<div class='note'>No latest-version intelligence available.</div>"
    return
  fi
  echo "<table><tr><th>Product</th><th>Installed</th><th>Latest / observed latest</th><th>Status</th><th>Latest source</th><th>Confidence</th><th>Detail / note</th></tr>"
  tail -n +2 "$LATESTVERSIONS_TSV" | while IFS="$(printf '\t')" read -r product installed latest status source confidence note; do
    cls="source-warn"
    [ "$status" = "current" ] && cls="source-ok"
    [ "$status" = "update_available" ] && cls="source-fail"
    rowcls=""
    [ "$status" = "update_available" ] && rowcls=" class='update-row'"
    [ "$status" = "current" ] && rowcls=" class='current-row'"
    echo "<tr$rowcls><td><b>$(printf '%s' "$product" | html_escape)</b></td><td>$(printf '%s' "$installed" | html_escape)</td><td>$(printf '%s' "$latest" | html_escape)</td><td><span class='source-badge $cls'>$(printf '%s' "$status" | html_escape)</span></td><td>$(printf '%s' "$source" | html_escape)</td><td>$(printf '%s' "$confidence" | html_escape)</td><td>$(printf '%s' "$note" | html_escape)</td></tr>"
  done
  echo "</table><details><summary>Raw latest-version evidence</summary><pre>$(cat "$LATESTVERSIONS_RAW" 2>/dev/null | html_escape)</pre></details>"
  return 0

}

build_operational_risk() {
  : > "$OPRISK_TSV"
  printf "category\tscore\tband\treason\n" >> "$OPRISK_TSV"
  risk_score_category() {
    local category="$1" regex="$2" max="$3" reason="$4"
    local penalty score band
    penalty="$(awk -F '\t' -v r="$regex" 'BEGIN{IGNORECASE=1} NR>1 && ($2 ~ r || $6 ~ r) {if($1=="High")p+=20; else if($1=="Medium")p+=10; else if($1=="Low")p+=4; else p+=1} END{print p+0}' "$ADVHEALTH_TSV" 2>/dev/null || echo 0)"
    [ "$penalty" -gt "$max" ] 2>/dev/null && penalty="$max"
    score=$((100 - (penalty * 100 / max)))
    if [ "$score" -lt 40 ]; then band="High"; elif [ "$score" -lt 70 ]; then band="Medium"; else band="Low"; fi
    printf "%s\t%s\t%s\t%s\n" "$category" "$score" "$band" "$reason" >> "$OPRISK_TSV"
  }
  risk_score_category "Operational Risk" "operational|Storage|Performance|Startup|Log Health|Network" 60 "Availability, storage, service and runtime health signals."
  risk_score_category "Maintainability" "maintainability|Platform|Cloud|Boot Security" 40 "Platform clarity, cloud/virtualization context and supportability signals."
  risk_score_category "Patching Risk" "Patch|Updates|Kernel|Lifecycle" 40 "Patch automation, pending updates and lifecycle review signals."
  risk_score_category "Backup Confidence" "Backups|Storage" 40 "Backup tooling, evidence and storage state signals."
  risk_score_category "Recovery Readiness" "Backups|systemd failed|Storage|Performance" 50 "Restore readiness, failed units and outage recovery clues."
}

render_advanced_health_html() {
  if [ ! -s "$ADVHEALTH_TSV" ]; then echo "<div class='note'>No advanced health data collected.</div>"; return; fi
  echo "<table><tr><th>Severity</th><th>Area</th><th>Item</th><th>Evidence</th><th>Recommendation</th><th>Risk type</th></tr>"
  tail -n +2 "$ADVHEALTH_TSV" | while IFS="$(printf '\t')" read -r sev area item evidence rec risk; do
    cls="sev-info"; [ "$sev" = "High" ] && cls="sev-high"; [ "$sev" = "Medium" ] && cls="sev-medium"; [ "$sev" = "Low" ] && cls="sev-low"
    echo "<tr><td><span class='sev $cls'>$(printf '%s' "$sev" | html_escape)</span></td><td>$(printf '%s' "$area" | html_escape)</td><td><b>$(printf '%s' "$item" | html_escape)</b></td><td>$(printf '%s' "$evidence" | html_escape)</td><td>$(printf '%s' "$rec" | html_escape)</td><td>$(printf '%s' "$risk" | html_escape)</td></tr>"
  done
  echo "</table><details><summary>Raw advanced health evidence</summary><pre>$(cat "$ADVHEALTH_RAW" 2>/dev/null | html_escape)</pre></details>"
}

render_operational_risk_html() {
  if [ ! -s "$OPRISK_TSV" ]; then echo "<div class='note'>No operational risk score available.</div>"; return; fi
  echo "<div class='cards'>"
  tail -n +2 "$OPRISK_TSV" | while IFS="$(printf '\t')" read -r cat score band reason; do
    cls="ok"; [ "$band" = "Medium" ] && cls="warn"; [ "$band" = "High" ] && cls="risk"
    echo "<div class='card'><div class='label'>$(printf '%s' "$cat" | html_escape)</div><div class='value $cls'>$band</div><small>Score $score/100 ÃƒÂ¢Ã¢â€šÂ¬Ã¢â‚¬Â $(printf '%s' "$reason" | html_escape)</small></div>"
  done
  echo "</div><div class='note'>Operational risk is separate from security score. It highlights reliability, maintainability, patching, backup and recovery readiness signals for infrastructure operations.</div>"
}

# Override: fast, non-stalling runtime discovery.
# The earlier extended discovery function is intentionally replaced here by defining
# the same function name again before the main execution flow starts.
collect_extra_package_discovery() {
  : > "$PACKAGE_EXTRA_TSV"
  : > "$PACKAGE_EXTRA_RAW"
  printf "name\tversion\tsource\ttype\n" >> "$PACKAGE_EXTRA_TSV"

  {
    echo "### Fast runtime discovery"
    echo "Generated: $(date '+%Y-%m-%d %H:%M:%S')"
    echo "Default mode only runs quick --version style probes. Slow package-manager inventory is skipped unless --slow-package-discovery is used. Timeout/killed probes are silent and non-fatal."
    echo
  } >> "$PACKAGE_EXTRA_RAW"

  probe_add() {
    local product="$1"; shift
    local source="$1"; shift
    local type="$1"; shift
    local ver
    ver="$(safe_version_probe 3 "$@" | head -1 | tr '\t' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
    [ -n "$ver" ] && add_extra_package "$product" "$ver" "$source" "$type"
  }

  if cmd_exists python3; then v="$(safe_version_probe 3 python3 --version | awk '{print $2}')"; [ -n "$v" ] && add_extra_package "Python" "$v" "python3 --version" "runtime"; fi
  if cmd_exists python; then v="$(safe_version_probe 3 python --version | awk '{print $2}')"; [ -n "$v" ] && add_extra_package "Python" "$v" "python --version" "runtime"; fi
  if cmd_exists pip3; then v="$(safe_version_probe 3 pip3 --version | awk '{print $2}')"; [ -n "$v" ] && add_extra_package "pip" "$v" "pip3 --version" "runtime"; fi
  if cmd_exists pip; then v="$(safe_version_probe 3 pip --version | awk '{print $2}')"; [ -n "$v" ] && add_extra_package "pip" "$v" "pip --version" "runtime"; fi
  if cmd_exists python3; then v="$(safe_version_probe 3 python3 -m pip --version | awk '{print $2}')"; [ -n "$v" ] && add_extra_package "pip" "$v" "python3 -m pip --version" "runtime"; fi
  if cmd_exists python; then v="$(safe_version_probe 3 python -m pip --version | awk '{print $2}')"; [ -n "$v" ] && add_extra_package "pip" "$v" "python -m pip --version" "runtime"; fi

  if cmd_exists node; then v="$(safe_version_probe 3 node --version | sed 's/^v//')"; [ -n "$v" ] && add_extra_package "Node.js" "$v" "node --version" "runtime"; fi
  if cmd_exists npm; then v="$(safe_version_probe 3 npm --version)"; [ -n "$v" ] && add_extra_package "npm" "$v" "npm --version" "runtime"; fi
  if cmd_exists yarn; then v="$(safe_version_probe 3 yarn --version)"; [ -n "$v" ] && add_extra_package "yarn" "$v" "yarn --version" "runtime"; fi
  if cmd_exists pnpm; then v="$(safe_version_probe 3 pnpm --version)"; [ -n "$v" ] && add_extra_package "pnpm" "$v" "pnpm --version" "runtime"; fi

  if cmd_exists php; then
    v="$(safe_version_probe 3 php -r 'echo PHP_VERSION;' || true)"
    [ -z "$v" ] && v="$(safe_version_probe 3 php -v | awk 'NR==1{print $2}')"
    [ -n "$v" ] && add_extra_package "PHP" "$v" "php version" "runtime"
  fi
  if cmd_exists composer; then v="$(safe_version_probe 3 composer --version | awk '{for(i=1;i<=NF;i++) if($i ~ /^[0-9]+\./){print $i; exit}}')"; [ -n "$v" ] && add_extra_package "Composer" "$v" "composer --version" "runtime"; fi

  if cmd_exists ruby; then v="$(safe_version_probe 3 ruby -v | awk '{print $2}')"; [ -n "$v" ] && add_extra_package "Ruby" "$v" "ruby -v" "runtime"; fi
  if cmd_exists gem; then v="$(safe_version_probe 3 gem --version)"; [ -n "$v" ] && add_extra_package "RubyGems" "$v" "gem --version" "runtime"; fi

  if cmd_exists java; then v="$(safe_version_probe 4 java -version 2>&1 | awk -F '"' '/version/ {print $2; exit}')"; [ -n "$v" ] && add_extra_package "Java" "$v" "java -version" "runtime"; fi
  if cmd_exists mvn; then v="$(safe_version_probe 4 mvn -version | awk '/Apache Maven/{print $3; exit}')"; [ -n "$v" ] && add_extra_package "Maven" "$v" "mvn -version" "runtime"; fi
  if cmd_exists gradle; then v="$(safe_version_probe 4 gradle -version | awk '/Gradle /{print $2; exit}')"; [ -n "$v" ] && add_extra_package "Gradle" "$v" "gradle -version" "runtime"; fi

  if cmd_exists go; then v="$(safe_version_probe 3 go version | awk '{print $3}' | sed 's/^go//')"; [ -n "$v" ] && add_extra_package "Go" "$v" "go version" "runtime"; fi
  if cmd_exists rustc; then v="$(safe_version_probe 3 rustc --version | awk '{print $2}')"; [ -n "$v" ] && add_extra_package "Rust" "$v" "rustc --version" "runtime"; fi
  if cmd_exists cargo; then v="$(safe_version_probe 3 cargo --version | awk '{print $2}')"; [ -n "$v" ] && add_extra_package "Cargo" "$v" "cargo --version" "runtime"; fi

  if cmd_exists nginx; then v="$(safe_version_probe 3 nginx -v 2>&1 | sed -nE 's#.*nginx/([^ ]+).*#\1#p')"; [ -n "$v" ] && add_extra_package "Nginx" "$v" "nginx -v" "service-runtime"; fi
  if cmd_exists apache2; then v="$(safe_version_probe 3 apache2 -v | awk -F/ '/Server version/{print $2}' | awk '{print $1}')"; [ -n "$v" ] && add_extra_package "Apache HTTP Server" "$v" "apache2 -v" "service-runtime"; fi
  if cmd_exists httpd; then v="$(safe_version_probe 3 httpd -v | awk -F/ '/Server version/{print $2}' | awk '{print $1}')"; [ -n "$v" ] && add_extra_package "Apache HTTP Server" "$v" "httpd -v" "service-runtime"; fi
  if cmd_exists squid; then v="$(safe_version_probe 3 squid -v | awk 'NR==1{print $4}')"; [ -n "$v" ] && add_extra_package "Squid" "$v" "squid -v" "service-runtime"; fi
  if cmd_exists haproxy; then v="$(safe_version_probe 3 haproxy -v | awk 'NR==1{print $3}')"; [ -n "$v" ] && add_extra_package "HAProxy" "$v" "haproxy -v" "service-runtime"; fi
  if cmd_exists varnishd; then v="$(safe_version_probe 3 varnishd -V 2>&1 | awk -F- 'NR==1{print $2}' | awk '{print $1}')"; [ -n "$v" ] && add_extra_package "Varnish" "$v" "varnishd -V" "service-runtime"; fi
  if cmd_exists redis-server; then v="$(safe_version_probe 3 redis-server --version | sed -nE 's/.*v=([^ ]+).*/\1/p')"; [ -n "$v" ] && add_extra_package "Redis" "$v" "redis-server --version" "service-runtime"; fi
  if cmd_exists postgres; then v="$(safe_version_probe 3 postgres --version | awk '{print $NF}')"; [ -n "$v" ] && add_extra_package "PostgreSQL" "$v" "postgres --version" "service-runtime"; fi
  if cmd_exists psql; then v="$(safe_version_probe 3 psql --version | awk '{print $3}')"; [ -n "$v" ] && add_extra_package "PostgreSQL client" "$v" "psql --version" "service-runtime"; fi
  if cmd_exists mysqld; then v="$(safe_version_probe 3 mysqld --version | awk '{for(i=1;i<=NF;i++) if($i ~ /^[0-9]+\./){print $i; exit}}')"; [ -n "$v" ] && add_extra_package "MySQL/MariaDB server" "$v" "mysqld --version" "service-runtime"; fi
  if cmd_exists mysql; then v="$(safe_version_probe 3 mysql --version | awk '{for(i=1;i<=NF;i++) if($i ~ /^[0-9]+\./){print $i; exit}}')"; [ -n "$v" ] && add_extra_package "MySQL/MariaDB client" "$v" "mysql --version" "service-runtime"; fi

  if [ "${EXTENDED_SLOW_PACKAGE_DISCOVERY:-false}" = "true" ]; then
    echo "### Slow package discovery enabled" >> "$PACKAGE_EXTRA_RAW"
    if cmd_exists snap; then safe_version_probe 8 snap list | head -150 | awk 'NR>1 && NF>=2 {print $1 "\t" $2}' | while IFS="$(printf '\t')" read -r n v; do add_extra_package "$n" "$v" "snap list" "snap"; done; fi
    if cmd_exists flatpak; then safe_version_probe 8 flatpak list --app --columns=application,version | head -150 | while IFS="$(printf '\t')" read -r n v; do [ -n "$n" ] && add_extra_package "$n" "${v:-unknown}" "flatpak list --app" "flatpak"; done; fi
    if cmd_exists pip3; then safe_version_probe 8 pip3 list --format=freeze | head -150 | awk -F'==' 'NF>=2 {print $1 "\t" $2}' | while IFS="$(printf '\t')" read -r n v; do add_extra_package "$n" "$v" "pip3 list" "python-pip"; done; fi
    if cmd_exists docker; then safe_version_probe 8 docker images --format '{{.Repository}}\t{{.Tag}}\t{{.ID}}' | head -150 | while IFS="$(printf '\t')" read -r repo tag imgid; do [ -n "$repo" ] && [ "$repo" != "<none>" ] && add_extra_package "$repo" "$tag" "docker images" "container-image"; done; fi
    if cmd_exists podman; then safe_version_probe 8 podman images --format '{{.Repository}}\t{{.Tag}}\t{{.ID}}' | head -150 | while IFS="$(printf '\t')" read -r repo tag imgid; do [ -n "$repo" ] && [ "$repo" != "<none>" ] && add_extra_package "$repo" "$tag" "podman images" "container-image"; done; fi
  else
    echo "Slow package-manager inventory skipped. Use --slow-package-discovery to include Snap/Flatpak/pip/container image listings." >> "$PACKAGE_EXTRA_RAW"
  fi

  if [ -s "$PACKAGE_EXTRA_TSV" ]; then
    tmp="$OUTDIR/package_extra_dedup_$TS.tmp"
    head -1 "$PACKAGE_EXTRA_TSV" > "$tmp"
    tail -n +2 "$PACKAGE_EXTRA_TSV" | awk -F '\t' '!seen[$0]++' >> "$tmp"
    mv "$tmp" "$PACKAGE_EXTRA_TSV"
  fi

  return 0
}

log "Starting Scantide Linux local check on $HOST"
if has_scantide_credentials; then stage_ok "API credentials supplied - CVE, lifecycle and remediation API stages are enabled."; else stage_skip "No API key/email supplied - CVE, lifecycle and remediation API stages will be skipped."; fi
log "Checking Scantide version feed"
: # old family version check disabled for Linux-only reporting
write_active_version_status_file
maybe_update_linux_script
stage_start "Collecting packages"
if collect_packages; then stage_ok "Package inventory completed."; else stage_warn "Package inventory completed with warnings."; fi
stage_plain "INFO" "Collecting fast runtime discovery..."
if collect_extra_package_discovery; then stage_ok "Extended package/runtime discovery completed."; else stage_warn "Extended package/runtime discovery completed with warnings; continuing."; fi
stage_start "Merging extended discovery into CVE/lifecycle inventory"
if merge_extra_discovery_into_api_inventory; then stage_ok "Extended discovery merged into CVE/lifecycle inventory."; else stage_warn "Extended discovery merge completed with warnings."; fi
stage_start "Building service inventory"
if build_service_inventory; then stage_ok "Service inventory completed."; else stage_warn "Service inventory completed with warnings."; fi

stage_start "Collecting container inventory"
if collect_container_inventory; then stage_ok "Container inventory completed."; else stage_warn "Container inventory completed with warnings."; fi

stage_start "Collecting listening ports"
if collect_listening_ports; then stage_ok "Listening port inventory completed."; else stage_warn "Listening port inventory completed with warnings."; fi

stage_start "Collecting platform inventory"
if collect_platform_inventory; then stage_ok "Platform inventory completed."; else stage_warn "Platform inventory completed with warnings."; fi

stage_start "Detecting likely server roles"
if detect_server_roles; then stage_ok "Server role detection completed."; else stage_warn "Server role detection completed with warnings."; fi

stage_start "Running CVE API"
if run_scantide_cve_api; then stage_ok "CVE API stage completed."; else stage_warn_with_context "CVE API stage completed with warnings" "HTTP status ${CVE_HTTP_STATUS:-unknown}; see $CVE_RAW"; fi

stage_start "Running lifecycle API"
if run_scantide_lifecycle_api; then stage_ok "Lifecycle API stage completed."; else stage_warn_with_context "Lifecycle API stage completed with warnings" "HTTP status ${LIFECYCLE_HTTP_STATUS:-unknown}; see $LIFECYCLE_RAW"; fi

stage_start "Building latest-version intelligence"
if build_latest_version_intelligence; then stage_ok "Latest-version intelligence completed."; else stage_warn_with_context "Latest-version intelligence completed with warnings" "lifecycle response/payload parsing issue; see Raw latest-version evidence"; fi

log "Evaluating local findings"
evaluate_findings
stage_start "Collecting accounts and sudo posture"
if collect_accounts_privileges; then stage_ok "Accounts and sudo posture completed."; else stage_warn "Accounts and sudo posture completed with warnings."; fi
stage_start "Collecting sysctl/kernel hardening"
if collect_sysctl_hardening; then stage_ok "sysctl/kernel hardening completed."; else stage_warn "sysctl/kernel hardening completed with warnings."; fi
stage_start "Collecting filesystem permission evidence"
if collect_filesystem_permissions; then stage_ok "Filesystem permission checks completed."; else stage_warn "Filesystem permission checks completed with warnings."; fi
stage_start "Collecting audit/logging posture"
if collect_audit_logging; then stage_ok "Audit/logging posture completed."; else stage_warn "Audit/logging posture completed with warnings."; fi
stage_start "Collecting AppArmor/SELinux status"
if collect_lsm_status; then stage_ok "AppArmor/SELinux status completed."; else stage_warn "AppArmor/SELinux status completed with warnings."; fi
stage_start "Collecting update/reboot posture"
if collect_update_posture; then stage_ok "Update/reboot posture completed."; else stage_warn "Update/reboot posture completed with warnings."; fi
stage_start "Collecting login/update summary counts"
if collect_update_summary; then stage_ok "Login/update summary completed."; else stage_warn "Login/update summary completed with warnings."; fi
stage_start "Collecting system health, time sync, disk and auto-patching posture"
if collect_ops_health_checks; then stage_ok "Operations health checks completed."; else stage_warn "Operations health checks completed with warnings."; fi
stage_start "Collecting advanced security and operational health checks"
if collect_advanced_health_checks; then stage_ok "Advanced security/operational checks completed."; else stage_warn_with_context "Advanced security/operational checks completed with warnings" "one or more probes returned non-zero; see Advanced Health raw evidence"; fi
stage_start "Checking optional security tools"
if collect_optional_security_tools; then stage_ok "Optional security tools check completed."; else stage_warn "Optional security tools check completed with warnings."; fi

stage_start "Building quick actions"
if build_quick_actions; then stage_ok "Quick actions completed."; else stage_warn "Quick actions completed with warnings."; fi
stage_start "Fetching authenticated remediation guidance"
if fetch_linux_remediation_catalog; then stage_ok "Remediation guidance stage completed."; else stage_warn "Remediation guidance stage did not complete cleanly."; fi

PKGCOUNT=0
SERVICECOUNT=0
FINDINGCOUNT=0
HTTP_STATUS_DISPLAY="Not run"
LIFECYCLE_STATUS_DISPLAY="Not run"
LIFECYCLE_FINDINGS=0
VERSION_STATUS_DISPLAY="${VERSION_FEED_STATUS:-Not checked}"
VERSION_STATUS_CLASS="warn"

[ -s "$PKGFILE" ] && PKGCOUNT="$(wc -l < "$PKGFILE" | tr -d ' ')"
[ -s "$SERVICEFILE" ] && SERVICECOUNT="$(wc -l < "$SERVICEFILE" | tr -d ' ')"
[ -s "$FINDINGS" ] && FINDINGCOUNT="$(wc -l < "$FINDINGS" | tr -d ' ')"
[ -s "$CVE_RAW" ] && HTTP_STATUS_DISPLAY="$(grep -m1 '^HTTP Status:' "$CVE_RAW" | cut -d: -f2- | xargs)"
[ -s "$LIFECYCLE_RAW" ] && LIFECYCLE_STATUS_DISPLAY="$(grep -m1 '^HTTP Status:' "$LIFECYCLE_RAW" | cut -d: -f2- | xargs)"
case "$VERSION_STATUS_DISPLAY" in
  Current|"Newer than feed") VERSION_STATUS_CLASS="ok" ;;
  Unavailable|"Feed missing Linux version"|"Update available") VERSION_STATUS_CLASS="warn" ;;
  *) VERSION_STATUS_CLASS="warn" ;;
esac
HTTP_STATUS_CLASS="warn"
LIFECYCLE_STATUS_CLASS="warn"
case "$HTTP_STATUS_DISPLAY" in
  200) HTTP_STATUS_CLASS="ok" ;;
  "Not run") HTTP_STATUS_CLASS="warn" ;;
  *) HTTP_STATUS_CLASS="risk" ;;
esac
case "$LIFECYCLE_STATUS_DISPLAY" in
  200) LIFECYCLE_STATUS_CLASS="ok" ;;
  "Not run") LIFECYCLE_STATUS_CLASS="warn" ;;
  *) LIFECYCLE_STATUS_CLASS="risk" ;;
esac
if [ -s "$LIFECYCLE_RESPONSE" ] && cmd_exists python3; then
  LIFECYCLE_FINDINGS="$(python3 - "$LIFECYCLE_RESPONSE" <<'PY_COUNT_LIFE'
import json, sys
try:
    d=json.load(open(sys.argv[1], encoding='utf-8'))
    s=d.get('summary') or {}
    print(s.get('findings', 0))
except Exception:
    print(0)
PY_COUNT_LIFE
)"
fi


CVE_CONFIRMED_COUNT=0
CVE_CANDIDATE_COUNT=0
CVE_KEV_COUNT=0
CVE_RANSOMWARE_COUNT=0
CVE_MAX_EPSS="0.0"
CVE_MSF_COUNT=0
CVE_EDB_COUNT=0
CVE_HISTORY_MSF_COUNT=0
CVE_HISTORY_EDB_COUNT=0
if [ -s "$CVE_RESPONSE" ] && cmd_exists python3; then
  CVE_INTEL_METRICS="$(python3 - "$CVE_RESPONSE" <<'PY_CVE_METRICS'
import json,sys
def i(v):
 try:return int(float(v or 0))
 except:return 0
def f(v):
 try:return float(v or 0)
 except:return 0.0
def yes(v):return v is True or str(v or '').lower() in ('1','true','yes','known')
def arr(v):return v if isinstance(v,list) else []
try:d=json.load(open(sys.argv[1],encoding='utf-8'))
except: print('0|0|0|0|0.0|0|0|0|0');raise SystemExit
rows=d.get('results') or d.get('items') or d.get('data') or []
if isinstance(rows,dict):rows=list(rows.values())
seen=set();confirmed=candidates=msf=edb=hmsf=hedb=0;kevs=set();ransom=set();mx=0.0
for r in rows:
 cs=arr(r.get('cves')) or arr(r.get('top_cves')); total=i(r.get('total_cves')); st=str(r.get('match_status') or '').upper()
 if not cs:
  if total and st in ('RANGE_MATCH','VERSION_CONFIRMED','CONFIRMED'): confirmed+=total
  else:candidates+=total
 for c in cs:
  if not isinstance(c,dict):continue
  cid=str(c.get('cve_id') or c.get('id') or ''); conf=yes(c.get('version_confirmed')) or str(c.get('applicability') or '').lower()=='affected'
  if cid and cid not in seen: seen.add(cid); confirmed+=1 if conf else 0; candidates+=0 if conf else 1
  kev=c.get('kev') or {}; ep=c.get('epss') or {}; ci=c.get('intelligence') or {}; m=c.get('metasploit') or {}; e=c.get('exploitdb') or c.get('exploit_db') or {}
  if cid and (kev.get('date_added') or yes(kev.get('listed')) or yes(ci.get('kev_listed'))):kevs.add(cid)
  if cid and (str(kev.get('known_ransomware_campaign_use') or '').lower()=='known' or yes(ci.get('known_ransomware_use'))):ransom.add(cid)
  ev=f(ep.get('score') or ci.get('epss') or ci.get('epss_score'));mx=max(mx,ev*100 if 0<ev<=1 else ev)
  mc=i(m.get('module_count') or m.get('count'));ec=i(e.get('entry_count') or e.get('count'));msf+=mc or (1 if yes(m.get('available')) else 0);edb+=ec or (1 if yes(e.get('available')) else 0)
 intel=r.get('intelligence') or {};msf+=i(intel.get('metasploit_count') or intel.get('module_count'));edb+=i(intel.get('exploitdb_count') or intel.get('edb_count'))
 ev=f(intel.get('epss_max') or intel.get('max_epss') or intel.get('epss_score'));mx=max(mx,ev*100 if 0<ev<=1 else ev)
 pi=r.get('product_intelligence') or r.get('historical_product_intelligence') or r.get('product_history') or {};pm=pi.get('metasploit') or pi.get('msf') or {};pe=pi.get('exploitdb') or pi.get('exploit_db') or pi.get('edb') or {}
 pmc=i(pm.get('module_count') or pm.get('count'));pec=i(pe.get('entry_count') or pe.get('count'))
 # Honest fallback: minimum indexed footprint from CVE-linked records.
 if pmc==0 and pec==0:
  pmc=i(intel.get('metasploit_count') or intel.get('module_count'))
  pec=i(intel.get('exploitdb_count') or intel.get('edb_count'))
  if pmc==0 and pec==0:
   for c in cs:
    if not isinstance(c,dict):continue
    m=c.get('metasploit') or {};e=c.get('exploitdb') or c.get('exploit_db') or {}
    pmc+=i(m.get('module_count') or m.get('count')) or (1 if yes(m.get('available')) else 0)
    pec+=i(e.get('entry_count') or e.get('count')) or (1 if yes(e.get('available')) else 0)
 hmsf+=pmc;hedb+=pec
print(f'{confirmed}|{candidates}|{len(kevs)}|{len(ransom)}|{mx:.1f}|{msf}|{edb}|{hmsf}|{hedb}')
PY_CVE_METRICS
)"
  IFS='|' read -r CVE_CONFIRMED_COUNT CVE_CANDIDATE_COUNT CVE_KEV_COUNT CVE_RANSOMWARE_COUNT CVE_MAX_EPSS CVE_MSF_COUNT CVE_EDB_COUNT CVE_HISTORY_MSF_COUNT CVE_HISTORY_EDB_COUNT <<EOF_CVE_METRICS
$CVE_INTEL_METRICS
EOF_CVE_METRICS
fi

CRITICAL_FINDINGS=0
HIGH_FINDINGS=0
MEDIUM_FINDINGS=0
LOW_FINDINGS=0
if [ -s "$FINDINGS" ]; then
  CRITICAL_FINDINGS="$(awk -F '	' '$1=="Critical"{c++} END{print c+0}' "$FINDINGS")"
  HIGH_FINDINGS="$(awk -F '	' '$1=="High"{c++} END{print c+0}' "$FINDINGS")"
  MEDIUM_FINDINGS="$(awk -F '	' '$1=="Medium"{c++} END{print c+0}' "$FINDINGS")"
  LOW_FINDINGS="$(awk -F '	' '$1=="Low"{c++} END{print c+0}' "$FINDINGS")"
fi
SCORE_PENALTY=$((CRITICAL_FINDINGS*25 + HIGH_FINDINGS*15 + MEDIUM_FINDINGS*8 + LOW_FINDINGS*3 + LIFECYCLE_FINDINGS*3))
SECURITY_SCORE=$((100 - SCORE_PENALTY))
[ "$SECURITY_SCORE" -lt 0 ] && SECURITY_SCORE=0
SCORE_CLASS="ok"
[ "$SECURITY_SCORE" -lt 80 ] && SCORE_CLASS="warn"
[ "$SECURITY_SCORE" -lt 60 ] && SCORE_CLASS="risk"
CONTAINERCOUNT=0
PORTCOUNT=0
ROLECOUNT=0
[ -s "$PORTFILE" ] && PORTCOUNT="$(( $(wc -l < "$PORTFILE" | tr -d ' ') - 1 ))" && [ "$PORTCOUNT" -lt 0 ] && PORTCOUNT=0
[ -s "$ROLEFILE" ] && ROLECOUNT="$(wc -l < "$ROLEFILE" | tr -d ' ')"
if [ -s "$CONTAINER_TSV" ]; then
  CONTAINERCOUNT="$(( $(wc -l < "$CONTAINER_TSV" | tr -d ' ') - 1 ))"
  [ "$CONTAINERCOUNT" -lt 0 ] && CONTAINERCOUNT=0
fi
PLATFORM_LABEL="$(platform_value platform_label)"
[ -z "$PLATFORM_LABEL" ] && PLATFORM_LABEL="Unknown"
PRIMARY_ROLE="$(primary_role)"
PRIMARY_ROLE_CONFIDENCE="$(primary_role_confidence)"
[ -z "$PRIMARY_ROLE" ] && PRIMARY_ROLE="General Linux Server"
[ -z "$PRIMARY_ROLE_CONFIDENCE" ] && PRIMARY_ROLE_CONFIDENCE="Low"

refresh_update_summary_vars
stage_plain "INFO" "Rendering self-contained HTML report..."
cat > "$REPORT" <<EOF
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<title>Scantide Linux Local Check</title>
<style>
body{font-family:Segoe UI,Arial;background:#eef2f7;margin:0;padding:22px;color:#172033}
.container{max-width:1500px;margin:auto;background:white;border-radius:12px;box-shadow:0 10px 32px #0002;overflow:hidden}
.header{background:linear-gradient(135deg,#1f4f8f,#667eea);color:white;padding:28px 32px}
.summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;background:#f8fafc;padding:22px}
.card{background:white;border:1px solid #e5e7eb;border-radius:12px;padding:16px;box-shadow:0 2px 8px #0001}
.card small{display:block;color:#64748b;margin-top:4px;font-size:.72em}
.label{text-transform:uppercase;color:#64748b;font-size:.78em;font-weight:700}
.value{font-size:2em;font-weight:800}
.risk{color:#dc2626}.ok{color:#16a34a}.warn{color:#d97706}
.section{padding:20px 28px;border-top:1px solid #e5e7eb}
table{width:100%;border-collapse:collapse;font-size:.86em;margin-top:10px}
th{background:#334155;color:white;text-align:left;padding:9px}
td{border-bottom:1px solid #e5e7eb;padding:8px;vertical-align:top;word-break:break-word}
pre{background:#0b1020;color:#e5e7eb;padding:14px;border-radius:8px;overflow:auto;max-height:520px}
.muted{color:#64748b}
.sev{display:inline-block;border-radius:999px;padding:4px 9px;font-weight:800;font-size:.78em}
.sev-critical{background:#7f1d1d;color:white}.sev-high{background:#fee2e2;color:#991b1b}.sev-medium{background:#ffedd5;color:#9a3412}.sev-low{background:#fef9c3;color:#854d0e}.sev-info{background:#dbeafe;color:#1e40af}.sev-ok{background:#dcfce7;color:#166534}
.cve-flag{display:inline-block;border-radius:999px;padding:3px 8px;font-weight:800;font-size:.76em}
.cve-critical{background:#991b1b;color:white}.cve-high{background:#fee2e2;color:#991b1b}.cve-medium{background:#ffedd5;color:#9a3412}.cve-review{background:#e0f2fe;color:#075985}.cve-suppressed{background:#e2e8f0;color:#475569}
.source-badge{display:inline-block;border-radius:999px;padding:4px 9px;font-weight:900;font-size:.74em;white-space:nowrap;border:1px solid transparent}
.source-ok{background:#dcfce7;color:#166534;border-color:#bbf7d0}.source-warn{background:#fef3c7;color:#92400e;border-color:#fde68a}.source-fail{background:#fee2e2;color:#991b1b;border-color:#fecaca}
.note{background:#eff6ff;border-left:5px solid #2563eb;padding:12px;border-radius:8px;margin:10px 0}
.disclaimer{background:#fff7ed;border-left:6px solid #f97316;padding:14px;border-radius:8px;margin:10px 0;color:#7c2d12}
.scope-warn{background:#fff7ed;border-left:6px solid #f97316;padding:14px;border-radius:8px;margin:10px 0;color:#7c2d12}
.scope-ok{background:#ecfdf5;border-left:6px solid #16a34a;padding:14px;border-radius:8px;margin:10px 0;color:#14532d}
.tabs{display:flex;gap:8px;flex-wrap:wrap;background:#f8fafc;border-top:1px solid #e5e7eb;border-bottom:1px solid #e5e7eb;padding:12px 20px}
.tabbtn{border:1px solid #cbd5e1;background:white;color:#334155;border-radius:999px;padding:8px 12px;font-weight:700;cursor:pointer}
.tabbtn.active{background:#1f4f8f;color:white;border-color:#1f4f8f}
.tabpane{display:none}.tabpane.active{display:block}
.toolbar{position:sticky;top:0;z-index:30;background:#ffffffea;backdrop-filter:blur(8px);border-bottom:1px solid #e5e7eb;padding:12px 20px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.toolbar input{border:1px solid #cbd5e1;border-radius:8px;padding:8px 10px;font-size:.9em}
.btn{display:inline-block;border:1px solid #1d4ed8;background:#2563eb;color:#fff;border-radius:8px;padding:8px 12px;font-weight:700;cursor:pointer;text-decoration:none}
.btn.secondary{background:#f8fafc;color:#1e293b;border-color:#cbd5e1}
details{border:1px solid #e5e7eb;border-radius:10px;padding:10px;margin:10px 0;background:#fff}summary{cursor:pointer;font-weight:800;color:#334155}.remediation{background:#f8fafc;border-color:#bfdbfe;margin-top:10px}.remediation summary{color:#1d4ed8}.remediation pre{max-height:280px}

.finding-grid{display:grid;gap:12px;margin-top:12px}
.finding-card{border:1px solid #e5e7eb;border-radius:14px;background:#fff;box-shadow:0 2px 8px #0001;overflow:hidden}
.finding-head{display:flex;gap:12px;align-items:flex-start;justify-content:space-between;padding:14px 16px;background:#f8fafc;border-bottom:1px solid #e5e7eb}
.finding-title{font-size:1.05em;font-weight:900;color:#0f172a}
.finding-meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px}
.meta-pill{display:inline-block;border-radius:999px;padding:4px 9px;background:#e2e8f0;color:#334155;font-weight:800;font-size:.74em}
.finding-body{padding:14px 16px}
.finding-cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}
.finding-box{border:1px solid #e5e7eb;border-radius:10px;background:#fff;padding:10px}
.finding-box h4{margin:0 0 6px 0;color:#334155;font-size:.85em;text-transform:uppercase;letter-spacing:.02em}
.finding-card details.remediation{margin-top:12px}
.finding-actions{margin-top:10px}

.source-neutral{background:#eef2ff;color:#3730a3;border-color:#c7d2fe}
.intel-summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(175px,1fr));gap:12px;margin:16px 0}
.software-intel-table-wrap{overflow-x:auto;margin:16px 0 20px;border:1px solid #dbe3ee;border-radius:14px;background:#fff;box-shadow:0 6px 18px #0f172a0a}.software-intel-table{min-width:1180px;margin:0}.software-intel-table th{background:#1e3a5f;color:#fff}.software-intel-table td{vertical-align:top}.software-intel-table tbody tr:hover td{background:#f8fafc}
.intel-stat{border:1px solid #e5e7eb;border-radius:14px;padding:14px;background:#fff;box-shadow:0 4px 14px #0000000d}
.intel-stat.danger{border-left:5px solid #dc2626}.intel-stat.warn{border-left:5px solid #f59e0b}.intel-stat.good{border-left:5px solid #16a34a}.intel-stat.neutral{border-left:5px solid #6366f1}
.intel-stat-label{font-size:.75em;text-transform:uppercase;font-weight:900;color:#64748b}.intel-stat-value{font-size:1.8em;font-weight:900;color:#0f172a;margin:3px 0}.intel-stat-sub{font-size:.78em;color:#64748b}
.intel-callout{background:linear-gradient(135deg,#eef2ff,#f8fafc);border:1px solid #c7d2fe;border-left:5px solid #4f46e5;border-radius:12px;padding:13px 15px;margin:14px 0;color:#312e81}
.software-intel-card{border:1px solid #e2e8f0;border-radius:18px;background:#fff;margin:18px 0;box-shadow:0 10px 28px #0f172a0f;overflow:hidden}
.software-intel-head{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;padding:18px 20px;background:linear-gradient(135deg,#f8fafc,#eef2ff);border-bottom:1px solid #e2e8f0}.software-intel-head h3{margin:0 0 10px;font-size:1.18em}.intel-badges{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.software-risk-ring{min-width:52px;height:52px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:900;background:#fff;border:5px solid #cbd5e1}.software-risk-ring.sev-critical{border-color:#7f1d1d;color:#7f1d1d}.software-risk-ring.sev-high{border-color:#dc2626;color:#991b1b}.software-risk-ring.sev-medium{border-color:#f59e0b;color:#92400e}.software-risk-ring.sev-low{border-color:#eab308;color:#854d0e}
.intel-product-note{padding:12px 20px;background:#fffbeb;border-bottom:1px solid #fde68a;color:#78350f}.intel-details{margin:14px 20px!important}.history-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px;margin:12px 0}.history-box{border:1px solid #e2e8f0;border-radius:12px;padding:12px;background:#f8fafc}.history-box h4{margin:0 0 7px}.intel-list{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.8em;background:#0f172a;color:#e2e8f0;border-radius:8px;padding:10px;margin-top:8px;max-height:220px;overflow:auto}
.cve-card-grid{display:grid;gap:10px;padding:14px 20px 20px}.cve-detail-card{margin:0!important;border-color:#cbd5e1!important}.cve-detail-card summary{display:flex;justify-content:space-between;gap:14px;align-items:center}.cve-detail-body{padding:10px 4px 2px}.evidence-box{border:1px solid #e2e8f0;background:#f8fafc;border-radius:10px;padding:11px;margin:9px 0}.danger-box{background:#fef2f2;border-color:#fecaca;color:#7f1d1d}
@media(max-width:760px){.software-intel-head,.cve-detail-card summary{flex-direction:column}.software-risk-ring{align-self:flex-start}.intel-summary-grid{grid-template-columns:1fr 1fr}}

@media print{.toolbar,.tabs,.btn{display:none!important}.tabpane{display:block!important}}

.card.clickable-card{cursor:pointer;transition:transform .15s ease,box-shadow .15s ease,border-color .15s ease}
.card.clickable-card:hover{transform:translateY(-2px);box-shadow:0 8px 22px #0002;border-color:#93c5fd}
.card.clickable-card:focus{outline:3px solid #93c5fd;outline-offset:2px}
.filter-row-hidden{display:none!important}
.section-anchor{scroll-margin-top:82px}
.button-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
.filter-chip{border:1px solid #cbd5e1;background:#fff;color:#334155;border-radius:999px;padding:7px 10px;font-weight:800;cursor:pointer}
.filter-chip:hover{background:#eff6ff}
.scorebar{height:10px;background:#e5e7eb;border-radius:999px;overflow:hidden;margin-top:6px}
.scorebar span{display:block;height:100%;background:linear-gradient(90deg,#ef4444,#f59e0b,#16a34a)}
.top-risk-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}
.mini-card{border:1px solid #e5e7eb;background:#fff;border-radius:12px;padding:12px}
.update-row{background:#fffbeb!important}.update-row td{background:#fffbeb!important}.current-row{background:#f0fdf4!important}.unknown-row{background:#fff!important}

</style>
</head>
<body>
<div class='container'>
<div class='header'>
<h1>Scantide Linux Local Security Check</h1>
<div>Host: $HOST | Distro: $DISTRO_NAME | Generated: $(date '+%Y-%m-%d %H:%M:%S') | v$SCRIPT_VERSION</div>
</div>

<div class='summary'>
<div class='card clickable-card' tabindex='0' onclick='jumpToSection("version-check")'><div class='label'>Scanner Version</div><div class='value $VERSION_STATUS_CLASS' style='font-size:1.15em'>$VERSION_STATUS_DISPLAY</div><small>Local $SCRIPT_VERSION</small></div>
<div class='card clickable-card' tabindex='0' onclick='jumpToSection("score-section")'><div class='label'>Security Score</div><div class='value $(hero_score_class)'>$(hero_score_value)</div><small>$(hero_score_band) | category-capped score</small></div>
<div class='card'><div class='label'>Primary Role</div><div class='value' style='font-size:1.15em'>$PRIMARY_ROLE</div><small>$PRIMARY_ROLE_CONFIDENCE confidence</small></div>
<div class='card'><div class='label'>Platform</div><div class='value' style='font-size:1.15em'>$PLATFORM_LABEL</div><small>Virtualization / host context</small></div>
<div class='card clickable-card' tabindex='0' onclick='jumpToSection("findings-section")'><div class='label'>Local Findings</div><div class='value warn'>$FINDINGCOUNT</div></div>
<div class='card clickable-card' tabindex='0' onclick='jumpToSection("cve-section")'><div class='label'>Software Intelligence</div><div class='value warn'>$CVE_CONFIRMED_COUNT</div><small>$CVE_CANDIDATE_COUNT review Â· KEV $CVE_KEV_COUNT Â· EPSS $CVE_MAX_EPSS%</small><small>MSF $CVE_MSF_COUNT Â· EDB $CVE_EDB_COUNT Â· footprint $((CVE_HISTORY_MSF_COUNT+CVE_HISTORY_EDB_COUNT))</small></div>
<div class='card clickable-card' tabindex='0' onclick='jumpToSection("lifecycle-section")'><div class='label'>Lifecycle Findings</div><div class='value warn'>$LIFECYCLE_FINDINGS</div><small>Linux lifecycle catalog</small></div>
<div class='card clickable-card' tabindex='0' onclick='jumpToSection("update-summary-section")'><div class='label'>Updates</div><div class='value warn' style='font-size:1.15em'>${AVAILABLE_UPDATES:-unknown}</div><small>${SECURITY_UPDATES:-unknown} security | reboot: ${REBOOT_REQUIRED_SUMMARY:-unknown}</small></div>
<div class='card'><div class='label'>Packages</div><div class='value'>$PKGCOUNT</div><small>Evidence only</small></div>
<div class='card'><div class='label'>Containers</div><div class='value'>$CONTAINERCOUNT</div><small>Docker/Podman inventory</small></div>
<div class='card clickable-card' tabindex='0' onclick='jumpToSection("ports-section")'><div class='label'>Listening Ports</div><div class='value'>$PORTCOUNT</div><small>ss parsed inventory</small></div>
<div class='card'><div class='label'>Likely Roles</div><div class='value'>$ROLECOUNT</div><small>Detected server roles</small></div>
<div class='card'><div class='label'>CVE API</div><div class='value $HTTP_STATUS_CLASS'>$HTTP_STATUS_DISPLAY</div><small>HTTP status</small></div>
<div class='card'><div class='label'>Lifecycle API</div><div class='value $LIFECYCLE_STATUS_CLASS'>$LIFECYCLE_STATUS_DISPLAY</div><small>HTTP status</small></div>
<div class='card'><div class='label'>Root Context</div><div class='value ok'>$([ "$(id -u)" -eq 0 ] && echo "Yes" || echo "No")</div></div>
<div class='card'><div class='label'>Kernel</div><div class='value'>$(uname -r | cut -d- -f1)</div><small>$(uname -r)</small></div>
</div>

<div class='toolbar'>
<input id='globalFilter' type='search' placeholder='Filter visible report rows...'>
<button class='btn secondary' onclick='clearFilter()'>Clear filters</button>
<button class='btn secondary' onclick='showTab("all")'>All sections</button>
<button class='filter-chip' onclick='filterSeverity("High")'>High</button>
<button class='filter-chip' onclick='filterSeverity("Medium")'>Medium</button>
<button class='filter-chip' onclick='filterText("Update")'>Updates</button>
<button class='filter-chip' onclick='filterText("SSH")'>SSH</button>
<button class='filter-chip' onclick='filterText("Redis")'>Redis</button>
<button class='filter-chip' onclick='filterText("All interfaces")'>All interfaces</button>
</div>

<div class='tabs'>
<button class='tabbtn active' onclick='showTab("overview")'>Overview</button>
<button class='tabbtn' onclick='showTab("findings")'>Findings</button>
<button class='tabbtn' onclick='showTab("software")'>Software / CVE</button>
<button class='tabbtn' onclick='showTab("lifecycle")'>Lifecycle</button>
<button class='tabbtn' onclick='showTab("endpoint")'>Endpoint</button>
<button class='tabbtn' onclick='showTab("raw")'>Raw / Evidence</button>
<button class='tabbtn' onclick='showTab("all")'>All sections</button>
</div>

<div class='section'>
<h2>Important Disclaimer</h2>
<div class='disclaimer'><b>Read this first:</b> Scantide Linux Local Check is an assessment and awareness tool. It is not an EDR, antivirus, patch manager, vulnerability scanner certification, patch manager, penetration test, or compliance certification. Findings are generated from local read-only evidence, version banners, configuration patterns and API lookups. False positives and false negatives are possible. Linux distributions often backport security fixes, so CVE matches are review leads and must be confirmed against distro advisory/package data before being treated as confirmed vulnerabilities. Lifecycle and latest-version data may differ between upstream vendors, Linux distributions and community-observed versions. Scoring is a triage aid only.</div>
</div>
EOF

echo "<div class='section section-anchor' id='version-check'><h2>Linux Script Version Check</h2>" >> "$REPORT"
if [ -n "${VERSION_FEED_WARNING:-}" ]; then
  echo "<div class='scope-warn'><b>Version warning:</b> $(printf '%s' "$VERSION_FEED_WARNING" | html_escape)</div>" >> "$REPORT"
else
  echo "<div class='scope-ok'><b>Version status:</b> $(printf '%s' "$VERSION_FEED_STATUS" | html_escape)</div>" >> "$REPORT"
fi
echo "<table><tr><th>Local Linux script</th><td>$(printf '%s' "$SCRIPT_VERSION" | html_escape)</td><th>Online Linux script</th><td>$(printf '%s' "${VERSION_FEED_REMOTE:-Unknown}" | html_escape)</td></tr><tr><th>Minimum recommended Linux script</th><td>$(printf '%s' "${VERSION_FEED_MINIMUM:-Unknown}" | html_escape)</td><th>Released</th><td>$(printf '%s' "${VERSION_FEED_RELEASED:-Unknown}" | html_escape)</td></tr><tr><th>Feed URL</th><td colspan='3'>$(printf '%s' "$VERSION_FEED_URL" | html_escape)</td></tr></table>" >> "$REPORT"
echo "</div>" >> "$REPORT"

echo "
<div class='section section-anchor' id='remediation-catalog-section'><details><summary>Authenticated Remediation Guidance status</summary><div class='note'>Authenticated guidance is fetched from the Scantide remediation API after findings are generated. The scanner remains read-only; commands are shown for administrator review only and are never executed automatically.</div>$(render_remediation_status_html)</details></div>

<div class='tabpane active' data-tab='overview'>

<div class='section section-anchor' id='executive-overview-section'><h2>Executive Overview</h2><div class='note'>Quick triage summary. Start here, then use Quick Actions and Executive Findings for the practical work.</div>
<div class='cards'>
  <div class='card clickable-card' tabindex='0' onclick='jumpToSection("findings-section")'><div class='label'>Findings</div><div class='value warn'>$FINDINGCOUNT</div><small>Executive findings</small></div>
  <div class='card clickable-card' tabindex='0' onclick='jumpToSection("quick-actions-section")'><div class='label'>Quick Actions</div><div class='value warn'>$(awk 'END{print NR+0}' "$QUICKACTIONS" 2>/dev/null)</div><small>Prioritized remediation steps</small></div>
  <div class='card clickable-card' tabindex='0' onclick='jumpToSection("update-summary-section")'><div class='label'>Updates</div><div class='value warn'>${AVAILABLE_UPDATES:-unknown}</div><small>${SECURITY_UPDATES:-unknown} security | reboot: ${REBOOT_REQUIRED_SUMMARY:-unknown}</small></div>
  <div class='card clickable-card' tabindex='0' onclick='jumpToSection("cve-section")'><div class='label'>Software Intelligence</div><div class='value warn'>$CVE_CONFIRMED_COUNT</div><small>$CVE_CANDIDATE_COUNT review Â· KEV $CVE_KEV_COUNT Â· MSF $CVE_MSF_COUNT Â· EDB $CVE_EDB_COUNT</small></div>
</div></div>
" >> "$REPORT"
echo "<div class='section section-anchor' id='software-intelligence-overview'><h2>Software Intelligence Summary</h2><div class='note'>Installed software, CVE applicability, CISA KEV, EPSS, Metasploit, Exploit-DB and historical product exploit context. Open the Software / CVE tab for descriptions, affected-version evidence and mitigation notes.</div>" >> "$REPORT"
render_cve_table_html summary >> "$REPORT"
echo "<div class='button-row'><button class='btn' onclick='showTab(\"software\");setTimeout(function(){document.getElementById(\"cve-section\").scrollIntoView({behavior: \"smooth\"});},50)'>Open full software intelligence</button></div></div>" >> "$REPORT"
echo "<div class='section'><h2>System Overview</h2><table>" >> "$REPORT"
echo "<tr><th>Host</th><td>$HOST</td><th>Distro</th><td>$DISTRO_NAME</td></tr>" >> "$REPORT"
echo "<tr><th>Kernel</th><td>$(uname -r)</td><th>Architecture</th><td>$(uname -m)</td></tr>" >> "$REPORT"
echo "<tr><th>Root</th><td>$([ "$(id -u)" -eq 0 ] && echo Yes || echo No)</td><th>Deep scan</th><td>$DEEP_SCAN</td></tr>" >> "$REPORT"
echo "<tr><th>Platform</th><td>$PLATFORM_LABEL</td><th>Primary role</th><td>$PRIMARY_ROLE ($PRIMARY_ROLE_CONFIDENCE)</td></tr>" >> "$REPORT"
echo "<tr><th>Script version</th><td>$SCRIPT_VERSION</td><th>Version feed status</th><td>$VERSION_STATUS_DISPLAY</td></tr>" >> "$REPORT"
echo "</table></div>" >> "$REPORT"

echo "<div class='section section-anchor' id='version-check-legacy'><h2>Scantide Version Check</h2><div class='note'>The Linux local check reuses the Scantide Auditor version feed. If the shared feed later exposes <code>linuxLocalCheckUrl</code> and a SHA256 value, the Linux runner can use those fields for safe update/download workflows.</div>" >> "$REPORT"
render_version_check_html >> "$REPORT"
echo "</div>" >> "$REPORT"

echo "<div class='section section-anchor' id='quick-actions-section'><h2>Quick Actions</h2><div class='note'>Top actions are generated from high/medium local findings and lifecycle review items. Cards expand with authenticated remediation guidance when available.</div>
$(render_quick_actions_cards_html)
</div>
<div class='section section-anchor' id='roles-section'><h2>Likely Server Role Detection</h2><div class='note'>Roles are inferred from detected application/network services and boosted by listening-port evidence. Virtualization/guest tools are shown as platform context, not as the server's primary business role.</div>" >> "$REPORT"
render_server_roles_html >> "$REPORT"
echo "</div>" >> "$REPORT"

echo "<div class='section section-anchor' id='platform-section'><h2>Platform / Virtualization</h2><div class='note'>Shows whether this appears to be a physical server, VM, container or guest system. VMware Tools/open-vm-tools is also included in software inventory when detected.</div><table>" >> "$REPORT"
[ -s "$PLATFORMFILE" ] && while IFS="$(printf '	')" read -r k v; do echo "<tr><th>$(printf '%s' "$k" | html_escape)</th><td>$(printf '%s' "$v" | html_escape)</td></tr>" >> "$REPORT"; done < "$PLATFORMFILE"
echo "</table></div>" >> "$REPORT"

echo "<div class='section'><h2>Services Summary</h2><div class='note'>Summarizes detected service categories so the report quickly explains what the server appears to do.</div>" >> "$REPORT"
render_services_summary_html >> "$REPORT"
echo "</div>" >> "$REPORT"

echo "
<div class='section'><h2>Top Risks and Strengths</h2><div class='note'>A short executive view before the technical evidence.</div>
$(render_top_risks_strengths_html)</div>
<div class='section'><h2>What To Verify Manually</h2><div class='note'>Items that need human confirmation because Linux distro backports, upstream firewalls and operational context can change the meaning.</div>
<div class='section section-anchor' id='score-section'><h2>Scantide Linux Security Score</h2><div class='note'>The score is category-capped and explainable. See Security Score Categories for the full breakdown and caveats.</div>$(render_score_categories_html)</div>" >> "$REPORT"

echo "<div class='section'><h2>System Information</h2>" >> "$REPORT"
cmd_exists lscpu && echo "<details><summary>CPU information</summary><pre>$(lscpu 2>&1 | html_escape)</pre></details>" >> "$REPORT"
cmd_exists free && echo "<details><summary>Memory information</summary><pre>$(free -h 2>&1 | html_escape)</pre></details>" >> "$REPORT"
cmd_exists df && echo "<details><summary>Disk usage</summary><pre>$(df -h 2>&1 | html_escape)</pre></details>" >> "$REPORT"
echo "</div></div>" >> "$REPORT"

echo "<div class='tabpane' data-tab='findings'><div class='section section-anchor' id='findings-section'><h2>Executive Findings</h2><div class='note'>Each finding is shown as a card with evidence, recommendation and authenticated remediation guidance when available.</div>

$(render_simple_tsv_table "$VERIFY_TSV" "No manual verification reminders generated.")</div>

$(render_findings_cards_html)
</div></div>
<div class='tabpane' data-tab='software'>" >> "$REPORT"
echo "<div class='section section-anchor' id='cve-section'><h2>Installed Software CVE &amp; Exploit Intelligence</h2><div class='note'><b>What this means:</b> Scantide first gates CVEs against the installed version, then separates CVE applicability, CVE-linked exploitation intelligence, and product-level exploit history. Product-level Metasploit or Exploit-DB evidence is review-only and never increases confirmed CVE totals. Linux distributions may backport security fixes, so even a backend range match should still be checked against the distro advisory/package revision before remediation.</div>" >> "$REPORT"
render_cve_table_html full >> "$REPORT"
echo "</div>" >> "$REPORT"

echo "<div class='section'><h2>Service Inventory Used For CVE Checks</h2><div class='note'>This is preferred over raw package inventory. It uses actual service/runtime binaries such as apache2 -v, php -v, openssl version and ssh -V.</div><table><tr><th>Product</th><th>Version</th><th>Source</th></tr>" >> "$REPORT"
if [ -s "$SERVICEFILE" ]; then
  while IFS="$(printf '\t')" read -r product version source; do
    echo "<tr><td>$(printf '%s' "$product" | html_escape)</td><td>$(printf '%s' "$version" | html_escape)</td><td>$(printf '%s' "$source" | html_escape)</td></tr>" >> "$REPORT"
  done < "$SERVICEFILE"
else
  echo "<tr><td colspan='3' class='muted'>No service inventory captured.</td></tr>" >> "$REPORT"
fi
echo "</table></div>" >> "$REPORT"

echo "

<div class='section section-anchor' id='latest-version-section'><h2>Latest Version Intelligence</h2><div class='note'>Shows installed version next to the latest or latest-observed version when lifecycle/community data is available. Rows with possible updates are highlighted in light yellow. Linux distributions may backport fixes, so use this as update intelligence, not proof of vulnerability.</div>
$(render_latest_versions_html)</div>

<div class='section section-anchor' id='extra-package-discovery-section'><h2>Extended Package / Runtime Discovery</h2><div class='note'>Additional software discovery beyond OS package managers. Versioned rows from this section are merged into the CVE and lifecycle API inventory. Common tools such as pip/npm/Composer also create normalized alias rows to improve matching. Includes Snap, Flatpak, pip, npm, Composer, Ruby gems, Java, Go, Rust, direct service probes and container images where available. These are included as review signals and are merged into the CVE/lifecycle inventory when a usable version is detected.</div>
$(render_extra_package_discovery_html)</div>
</div>" >> "$REPORT"


echo "<div class='tabpane' data-tab='lifecycle'>" >> "$REPORT"
echo "<div class='section section-anchor' id='lifecycle-section'><h2>Linux Lifecycle Review</h2><div class='note'><b>What this means:</b> Lifecycle checks are separate from CVE checks. The Linux scanner sends OS, kernel and detected service/runtime versions with <code>platform=linux</code>, so the API loads the Linux lifecycle catalog instead of the Windows/user-app catalog.</div>" >> "$REPORT"
render_lifecycle_table_html >> "$REPORT"
echo "</div></div>" >> "$REPORT"

echo "<div class='tabpane' data-tab='endpoint'>" >> "$REPORT"
echo "
<div class='section section-anchor' id='exposure-section'><h2>Public vs Local Exposure</h2><div class='note'>Classifies listeners as loopback-only, LAN-facing, all-interfaces, IPv6 link-local or specific-address. This makes port risk easier to understand.</div>
$(render_simple_tsv_table "$EXPOSURE_TSV" "No exposure classification available.")</div>

<div class='section'><h2>Config File Inventory</h2><div class='note'>Shows whether important configuration files exist, with owner, permissions and modified timestamp.</div>
$(render_simple_tsv_table "$CONFIG_TSV" "No config file inventory available.")</div>

<div class='section'><h2>Cron / systemd Persistence Review</h2><div class='note'>Read-only inventory of enabled services, timers and cron/autostart locations.</div>
$(render_simple_tsv_table "$PERSISTENCE_TSV" "No persistence inventory available.")
<details><summary>Raw persistence evidence</summary><pre>$(cat "$PERSISTENCE_RAW" 2>/dev/null | html_escape)</pre></details></div>

<div class='section'><h2>Web Stack Posture</h2><div class='note'>Checks Apache/nginx/PHP-FPM posture such as banner settings, directory listing hints, vhosts and pool evidence.</div>
$(render_simple_tsv_table "$WEBSTACK_TSV" "No web stack posture findings generated.")
<details><summary>Raw web stack evidence</summary><pre>$(cat "$WEBSTACK_RAW" 2>/dev/null | html_escape)</pre></details></div>




<div class='section section-anchor' id='operational-risk-section'><h2>Operational Risk</h2><div class='note'>Operational risk is separate from the security score and focuses on reliability, maintainability, patching, backup confidence and recovery readiness.</div>
$(render_operational_risk_html)</div>
<div class='section section-anchor' id='advanced-health-section'><h2>Advanced Security / Operations Checks</h2><div class='note'>Additional read-only checks for boot security, kernel state, storage integrity, network routing/DNS, legacy services, containers, Kubernetes, database/mail/web app posture, cron/startup, login activity, logs, certificates, cloud/virtualization and performance health.</div>
$(render_advanced_health_html)</div>

<div class='section section-anchor' id='ops-health-section'><h2>Operations Health</h2><div class='note'>Read-only checks for failed systemd units, time synchronization, disk free space, inode usage and automatic security patching posture. Snap loop mounts, CD/DVD media, squashfs, tmpfs, overlay and other pseudo/read-only image mounts are excluded from disk/inode warnings.</div>
$(render_ops_health_html)</div>
<div class='section section-anchor' id='local-tls-section'><h2>Local HTTPS TLS / Certificate Checks</h2><div class='note'>Tests common local HTTPS listeners for weak TLS protocol acceptance, modern TLS support and certificate expiry. This is a localhost test and does not replace full external TLS assessment.</div>
$(render_local_tls_html)</div>
<div class='section section-anchor' id='admin-panels-section'><h2>Exposed Admin Panels</h2><div class='note'>Looks for common admin panels on standard ports such as Cockpit, Webmin, Portainer, Proxmox, Grafana, Kibana and metrics endpoints.</div>
$(render_admin_panels_html)</div>

<div class='section section-anchor' id='enabled-websites-section'><h2>Enabled Websites / SSL / Certificates</h2><div class='note'>Read-only review of enabled Apache/nginx site definitions in standard locations. Checks whether sites appear bound to SSL/443, whether document-root permissions look safe, and whether configured certificate files are readable, valid and not near expiry.</div>
$(render_enabled_websites_html)</div>

<div class='section'><h2>Backup / Restore Clues</h2><div class='note'>Detects common backup tooling and backup-like cron/systemd clues. Presence does not prove that restores have been tested.</div>
$(render_simple_tsv_table "$BACKUP_TSV" "No backup/restore tooling clues detected.")
<details><summary>Raw backup evidence</summary><pre>$(cat "$BACKUP_RAW" 2>/dev/null | html_escape)</pre></details></div>

<div class='section'><h2>Possible Secret Exposure</h2><div class='note'>Only runs with <code>--deep</code>. It is a lightweight read-only pattern check for possible hard-coded secrets in selected locations.</div>
$(render_simple_tsv_table "$SECRETS_TSV" "Secret exposure scan was skipped or produced no findings.")
<details><summary>Raw secret-pattern evidence</summary><pre>$(cat "$SECRETS_RAW" 2>/dev/null | html_escape)</pre></details></div>

<div class='section section-anchor' id='ports-section'><h2>Listening Ports</h2><div class='note'>A listening port is not automatically bad, but it should be expected, patched and scoped by firewall policy. Sensitive ports are marked for exposure review.</div>" >> "$REPORT"
render_listening_ports_html >> "$REPORT"
echo "</div>" >> "$REPORT"

echo "<div class='section'><h2>Container Inventory</h2><div class='note'>Shows Docker/Podman containers when available. Container images can carry their own lifecycle/CVE risk and should be reviewed separately from the host OS.</div>" >> "$REPORT"
render_container_inventory_html >> "$REPORT"
echo "<details><summary>Raw container evidence</summary><pre>" >> "$REPORT"
[ -s "$CONTAINERFILE" ] && cat "$CONTAINERFILE" | html_escape >> "$REPORT" || echo "No container inventory captured." >> "$REPORT"
echo "</pre></details></div>" >> "$REPORT"

echo "<div class='section'><h2>Firewall</h2>" >> "$REPORT"
cmd_exists ufw && echo "<details><summary>UFW status</summary><pre>$(ufw status verbose 2>&1 | html_escape)</pre></details>" >> "$REPORT"
cmd_exists nft && echo "<details><summary>nftables ruleset</summary><pre>$(nft list ruleset 2>&1 | html_escape)</pre></details>" >> "$REPORT"
cmd_exists iptables && echo "<details><summary>iptables rules</summary><pre>$(iptables -L -n -v 2>&1 | html_escape)</pre></details>" >> "$REPORT"
echo "</div>" >> "$REPORT"

echo "<div class='section'><h2>SSH Hardening</h2>" >> "$REPORT"
if [ -f /etc/ssh/sshd_config ]; then
  echo "<details open><summary>sshd_config highlights</summary><pre>$(grep -Ei 'PermitRootLogin|PasswordAuthentication|PermitEmptyPasswords|X11Forwarding|Port' /etc/ssh/sshd_config 2>/dev/null | html_escape)</pre></details>" >> "$REPORT"
else
  echo "<div class='note'>No sshd_config found.</div>" >> "$REPORT"
fi
echo "</div>" >> "$REPORT"


echo "<div class='section'><h2>Accounts and Privileges</h2><div class='note'>Checks UID 0 accounts, root password state, interactive service accounts, sudo group membership and passwordless sudo entries.</div>" >> "$REPORT"
render_hardening_tsv_html "$ACCOUNTS_TSV" "No account or sudo privilege findings were generated." >> "$REPORT"
echo "<details><summary>Raw accounts and sudo evidence</summary><pre>" >> "$REPORT"; [ -s "$ACCOUNTS_RAW" ] && cat "$ACCOUNTS_RAW" | html_escape >> "$REPORT"; echo "</pre></details></div>" >> "$REPORT"

echo "<div class='section'><h2>Kernel / sysctl Hardening</h2><div class='note'>Reviews common kernel and network hardening settings such as redirects, source routing, ASLR, dmesg restrictions and SUID core dump handling.</div>" >> "$REPORT"
render_hardening_tsv_html "$SYSCTL_TSV" "No sysctl hardening findings were generated." >> "$REPORT"
echo "<details><summary>Raw sysctl evidence</summary><pre>" >> "$REPORT"; [ -s "$SYSCTL_RAW" ] && cat "$SYSCTL_RAW" | html_escape >> "$REPORT"; echo "</pre></details></div>" >> "$REPORT"

echo "<div class='section'><h2>Filesystem Permissions</h2><div class='note'>Checks key system file permissions, world-writable shared directories and, with <code>--deep</code>, SUID/SGID and world-writable file evidence.</div>" >> "$REPORT"
render_hardening_tsv_html "$FS_TSV" "No filesystem permission findings were generated." >> "$REPORT"
echo "<details><summary>Raw filesystem evidence</summary><pre>" >> "$REPORT"; [ -s "$FS_RAW" ] && cat "$FS_RAW" | html_escape >> "$REPORT"; echo "</pre></details></div>" >> "$REPORT"

echo "<div class='section'><h2>Audit, Logging and Integrity</h2><div class='note'>Checks auditd, syslog, remote logging hints, journald persistence, logrotate and AIDE presence.</div>" >> "$REPORT"
render_hardening_tsv_html "$AUDIT_TSV" "No audit/logging findings were generated." >> "$REPORT"
echo "<details><summary>Raw audit/logging evidence</summary><pre>" >> "$REPORT"; [ -s "$AUDIT_RAW" ] && cat "$AUDIT_RAW" | html_escape >> "$REPORT"; echo "</pre></details></div>" >> "$REPORT"

echo "<div class='section'><h2>AppArmor / SELinux</h2><div class='note'>Checks AppArmor on Debian/Ubuntu-style systems and SELinux on RHEL/Fedora/SUSE-style systems where tooling exists.</div>" >> "$REPORT"
render_hardening_tsv_html "$LSM_TSV" "No AppArmor/SELinux findings were generated." >> "$REPORT"
echo "<details><summary>Raw LSM evidence</summary><pre>" >> "$REPORT"; [ -s "$LSM_RAW" ] && cat "$LSM_RAW" | html_escape >> "$REPORT"; echo "</pre></details></div>" >> "$REPORT"

echo "
<div class='section section-anchor' id='update-summary-section'><h2>Login / Update Summary</h2><div class='note'>Shows the same kind of update counts admins often see when logging in: available package updates, security updates and reboot-required state. Uses distro-native query/simulation commands only.</div>
$(render_update_summary_html)</div>

<div class='section'><h2>Patch / Update Posture</h2><div class='note'>Looks for reboot-required state and pending update evidence using the distro package manager where practical. This complements lifecycle checks.</div>" >> "$REPORT"
render_hardening_tsv_html "$UPDATES_TSV" "No pending update/reboot findings were generated." >> "$REPORT"
echo "<details><summary>Raw update evidence</summary><pre>" >> "$REPORT"; [ -s "$UPDATES_RAW" ] && cat "$UPDATES_RAW" | html_escape >> "$REPORT"; echo "</pre></details></div>" >> "$REPORT"

echo "<div class='section'><h2>Optional Security Tools</h2><div class='note'>Scantide does not require these tools, but detects whether optional helpers such as Lynis, OpenSCAP, AIDE, needrestart and debsecan are present.</div><table><tr><th>Tool</th><th>Status</th><th>Note</th></tr>" >> "$REPORT"
[ -s "$OPTIONAL_TOOLS_TSV" ] && tail -n +2 "$OPTIONAL_TOOLS_TSV" | while IFS="$(printf '	')" read -r tool status note; do echo "<tr><td>$(printf '%s' "$tool" | html_escape)</td><td>$(printf '%s' "$status" | html_escape)</td><td>$(printf '%s' "$note" | html_escape)</td></tr>" >> "$REPORT"; done
echo "</table><details><summary>Raw optional tools evidence</summary><pre>" >> "$REPORT"; [ -s "$OPTIONAL_TOOLS_RAW" ] && cat "$OPTIONAL_TOOLS_RAW" | html_escape >> "$REPORT"; echo "</pre></details></div>" >> "$REPORT"

echo "<div class='section'><h2>Service Hardening Evidence</h2><div class='note'>Collected configuration highlights for SSH, Apache, Nginx, Redis and Docker where available.</div><details><summary>Show hardening evidence</summary><pre>" >> "$REPORT"
[ -s "$HARDENING_RAW" ] && cat "$HARDENING_RAW" | html_escape >> "$REPORT" || echo "No hardening evidence captured." >> "$REPORT"
echo "</pre></details></div></div>" >> "$REPORT"

echo "<div class='tabpane' data-tab='raw'>" >> "$REPORT"
echo "<div class='section'><h2>Version Feed Evidence</h2><details><summary>Show version feed raw JSON</summary><pre>" >> "$REPORT"
[ -s "$VERSIONJSON" ] && cat "$VERSIONJSON" | html_escape >> "$REPORT" || echo "No version feed JSON captured." >> "$REPORT"
echo "</pre></details></div>" >> "$REPORT"

echo "<div class='section'><h2>Raw CVE API Evidence</h2><details><summary>Show raw CVE API payload/response</summary><pre>" >> "$REPORT"
[ -s "$CVE_RAW" ] && head -2000 "$CVE_RAW" | html_escape >> "$REPORT" || echo "No CVE raw evidence." >> "$REPORT"
echo "</pre></details></div>" >> "$REPORT"

echo "<div class='section'><h2>Raw Lifecycle API Evidence</h2><details><summary>Show raw lifecycle API payload/response</summary><pre>" >> "$REPORT"
[ -s "$LIFECYCLE_RAW" ] && head -2000 "$LIFECYCLE_RAW" | html_escape >> "$REPORT" || echo "No lifecycle raw evidence." >> "$REPORT"
echo "</pre></details></div>" >> "$REPORT"


echo "<div class='section'><h2>Hardening Depth Raw Evidence</h2>" >> "$REPORT"
echo "<details><summary>Accounts / sudo</summary><pre>" >> "$REPORT"; [ -s "$ACCOUNTS_RAW" ] && cat "$ACCOUNTS_RAW" | html_escape >> "$REPORT"; echo "</pre></details>" >> "$REPORT"
echo "<details><summary>sysctl / kernel</summary><pre>" >> "$REPORT"; [ -s "$SYSCTL_RAW" ] && cat "$SYSCTL_RAW" | html_escape >> "$REPORT"; echo "</pre></details>" >> "$REPORT"
echo "<details><summary>filesystem permissions</summary><pre>" >> "$REPORT"; [ -s "$FS_RAW" ] && cat "$FS_RAW" | html_escape >> "$REPORT"; echo "</pre></details>" >> "$REPORT"
echo "<details><summary>audit / logging</summary><pre>" >> "$REPORT"; [ -s "$AUDIT_RAW" ] && cat "$AUDIT_RAW" | html_escape >> "$REPORT"; echo "</pre></details>" >> "$REPORT"
echo "<details><summary>AppArmor / SELinux</summary><pre>" >> "$REPORT"; [ -s "$LSM_RAW" ] && cat "$LSM_RAW" | html_escape >> "$REPORT"; echo "</pre></details>" >> "$REPORT"
echo "<details><summary>updates</summary><pre>" >> "$REPORT"; [ -s "$UPDATES_RAW" ] && cat "$UPDATES_RAW" | html_escape >> "$REPORT"; echo "</pre></details>" >> "$REPORT"
echo "<details><summary>optional tools</summary><pre>" >> "$REPORT"; [ -s "$OPTIONAL_TOOLS_RAW" ] && cat "$OPTIONAL_TOOLS_RAW" | html_escape >> "$REPORT"; echo "</pre></details></div>" >> "$REPORT"

echo "<div class='section'><h2>Parsed Listening Port Inventory</h2><details><summary>Show parsed listening-port TSV</summary><pre>" >> "$REPORT"
[ -s "$PORTFILE" ] && cat "$PORTFILE" | html_escape >> "$REPORT" || echo "No parsed port inventory." >> "$REPORT"
echo "</pre></details></div>" >> "$REPORT"

echo "<div class='section'><h2>Installed Package Inventory</h2><div class='note'>Package inventory is included as evidence, but CVE checks use service/runtime inventory to reduce false positives.</div><details><summary>Show first 500 installed packages</summary><pre>" >> "$REPORT"
[ -s "$PKGFILE" ] && head -500 "$PKGFILE" | html_escape >> "$REPORT" || echo "No package inventory." >> "$REPORT"
echo "</pre></details></div>" >> "$REPORT"

echo "<div class='section'><h2>Local Risk Indicators</h2>" >> "$REPORT"
if [ "$DEEP_SCAN" = true ]; then
  echo "<h3>SUID binaries</h3><pre>$(find / -xdev -perm -4000 -type f 2>/dev/null | head -300 | html_escape)</pre>" >> "$REPORT"
  echo "<h3>World-writable directories</h3><pre>$(find / -xdev -type d -perm -0002 2>/dev/null | head -300 | html_escape)</pre>" >> "$REPORT"
else
  echo "<div class='note'>Fast mode. Use --deep for fuller filesystem checks.</div>" >> "$REPORT"
  echo "<h3>SUID binaries in common paths</h3><pre>$(find /usr/bin /usr/sbin /bin /sbin /usr/local/bin -xdev -perm -4000 -type f 2>/dev/null | head -150 | html_escape)</pre>" >> "$REPORT"
fi
echo "</div></div>" >> "$REPORT"

cat >> "$REPORT" <<'EOF'

<script>
function setActiveTabButton(tab){
  document.querySelectorAll('.tabbtn').forEach(function(b){ b.classList.remove('active'); });
  document.querySelectorAll('.tabbtn').forEach(function(b){
    if ((b.getAttribute('onclick') || '').indexOf('"' + tab + '"') >= 0) b.classList.add('active');
  });
}
function showTab(tab){
  document.querySelectorAll('.tabpane').forEach(function(p){
    p.classList.remove('active');
    if(tab === 'all' || p.getAttribute('data-tab') === tab){ p.classList.add('active'); }
  });
  setActiveTabButton(tab);
}
function tabForSection(id){
  var el=document.getElementById(id);
  if(!el) return 'overview';
  var pane=el.closest('.tabpane');
  return pane ? (pane.getAttribute('data-tab') || 'overview') : 'overview';
}
function jumpToSection(id){
  showTab(tabForSection(id));
  setTimeout(function(){
    var el = document.getElementById(id);
    if(el){ el.scrollIntoView({behavior:'smooth', block:'start'}); }
  }, 60);
}
function applyFilter(value){
  var q = (value || '').toLowerCase();
  document.querySelectorAll('table tr').forEach(function(row){
    if(row.querySelector('th')) { row.classList.remove('filter-row-hidden'); return; }
    var txt = row.textContent.toLowerCase();
    if(!q || txt.indexOf(q) >= 0){ row.classList.remove('filter-row-hidden'); }
    else { row.classList.add('filter-row-hidden'); }
  });
}
function filterText(value){
  var input = document.getElementById('globalFilter');
  if(input) input.value = value;
  showTab('all');
  applyFilter(value);
}
function filterSeverity(value){ filterText(value); }
function clearFilter(){
  var input = document.getElementById('globalFilter');
  if(input) input.value = '';
  applyFilter('');
}
document.addEventListener('DOMContentLoaded', function(){
  var input = document.getElementById('globalFilter');
  if(input){ input.addEventListener('input', function(){ applyFilter(input.value); }); }
  document.querySelectorAll('.clickable-card').forEach(function(card){
    card.addEventListener('keydown', function(e){
      if(e.key === 'Enter' || e.key === ' '){ e.preventDefault(); card.click(); }
    });
  });
});
</script>

</div>
</body>
</html>
EOF

if write_summary_json_report; then stage_ok "JSON summary written."; else stage_warn "JSON summary could not be written."; fi
cleanup_intermediate_files || true
log "Done"
echo "Report created: $REPORT"
echo "JSON summary: $JSON_REPORT"
if [ "${KEEP_DEBUG_FILES:-false}" = "true" ]; then
  echo "Debug/evidence files preserved:"

else
  echo "Temporary working files removed. Use --keep-debug-files to keep full evidence files."
fi

exit 0 # Scantide clean end guard