#!/usr/bin/env bash
# Self-integrity scan — detect worm persistence in Claude Code / VS Code settings.
#
# Flags the 2026 worm IOC: hooks / mcpServers injected into Claude Code and VS Code
# settings, plus GitHub Actions workflows with live OIDC publish trust (the Mini
# Shai-Hulud entry point). Read-only — it reports; you decide. Uses `zizmor` for
# richer workflow analysis when installed.
#
# Usage:   integrity-audit.sh [--json] [-q] [-v] [PROJECT_DIR]
# Input:   optional PROJECT_DIR positional (default: cwd) + $HOME config locations
# Output:  stdout = findings (tab-separated records, or JSON with --json)
# Stderr:  section framing, progress, verdict guidance, errors
# Exit:    0 clean, 2 usage, 5 missing-dep (jq, with --json), 10 review-items-found
#
# Note: intentionally NOT `set -e` — a scanner must survive missing files and keep
# going. Errors are handled explicitly.
#
# Examples:
#   integrity-audit.sh
#   integrity-audit.sh --json | jq '.data.review[]'
#   integrity-audit.sh -q ./some/project    # quiet: findings only, no framing

set -uo pipefail

EXIT_OK=0; EXIT_USAGE=2; EXIT_MISSING_DEP=5; EXIT_REVIEW=10

JSON=0; QUIET=0; VERBOSE=0; PROJECT_DIR="."
while [[ $# -gt 0 ]]; do
  case "$1" in
    --json)        JSON=1 ;;
    -q|--quiet)    QUIET=1 ;;
    -v|--verbose)  VERBOSE=1 ;;
    -h|--help)
      sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit "$EXIT_OK" ;;
    -*) echo "ERROR: unknown flag: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
    *)  PROJECT_DIR="$1" ;;
  esac
  shift
done

# stderr framing — colored only when stderr is a TTY and NO_COLOR unset.
if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then
  C_Y=$'\033[33m'; C_G=$'\033[32m'; C_D=$'\033[2m'; C_O=$'\033[0m'
else C_Y=""; C_G=""; C_D=""; C_O=""; fi
section() { [[ "$QUIET" -eq 1 ]] && return; printf '%s== %s ==%s %s\n' "$C_D" "$1" "$C_O" "${2:-}" >&2; }
info()    { [[ "$QUIET" -eq 1 ]] && return; printf '   %s\n' "$1" >&2; }
vinfo()   { [[ "$VERBOSE" -eq 1 ]] && printf '   %s\n' "$1" >&2; }

HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
HAS_ZIZMOR=0; command -v zizmor >/dev/null 2>&1 && HAS_ZIZMOR=1
if [[ "$JSON" -eq 1 && "$HAS_JQ" -eq 0 ]]; then
  echo '{"error":{"code":"MISSING_DEPENDENCY","message":"jq required for --json","details":{"install":"apt-get install jq"}}}'
  echo "ERROR: jq required for --json output" >&2
  exit "$EXIT_MISSING_DEP"
fi

REVIEW_JSON=()    # array of compact JSON objects
REVIEW_COUNT=0

# record <category> <source> <kind> <entries-newline-separated>
record() {
  local category=$1 source=$2 kind=$3 entries=$4
  REVIEW_COUNT=$((REVIEW_COUNT+1))
  # tab-separated record to stdout (the data product, non-JSON mode)
  if [[ "$JSON" -eq 0 ]]; then
    local flat; flat=$(echo "$entries" | paste -sd',' - 2>/dev/null)
    printf '%s\t%s\t%s\t%s\n' "$category" "$source" "$kind" "$flat"
  fi
  if [[ "$HAS_JQ" -eq 1 ]]; then
    local obj
    obj=$(jq -cn --arg c "$category" --arg s "$source" --arg k "$kind" \
      --arg e "$entries" '{category:$c, source:$s, kind:$k, entries:($e|split("\n")|map(select(length>0)))}')
    REVIEW_JSON+=("$obj")
  fi
  printf '   %s[review]%s %s %s: %s\n' "$C_Y" "$C_O" "$kind" "$source" \
    "$(echo "$entries" | paste -sd',' - 2>/dev/null)" >&2
}

json_key_entries() {  # file key -> newline-separated entry list (jq)
  local file=$1 key=$2
  [[ -f "$file" && "$HAS_JQ" -eq 1 ]] || return 0
  jq -r --arg k "$key" '
    if (.[$k] // empty) == null then empty
    elif (.[$k]|type)=="object" then (.[$k]|keys[])
    elif (.[$k]|type)=="array"  then (.[$k][]|tostring)
    else (.[$k]|tostring) end' "$file" 2>/dev/null
}

# ─── 1. AI-tool config: hooks / mcpServers across hosts ────────────────────
# Broadened with the MCP host-config map from Perplexity's Bumblebee
# (docs/inventory-sources.md) — the worm targets these persistence surfaces.
section "AI-tool config" "hooks / mcpServers you may not have added (Claude + MCP hosts)"
APPDATA_DIR="${APPDATA:-$HOME/AppData/Roaming}"
CLAUDE_FILES=(
  "$HOME/.claude/settings.json" "$HOME/.claude/settings.local.json" "$HOME/.claude.json"
  "$HOME/.gemini/settings.json"                                    # Gemini CLI / Code Assist
  "$HOME/Library/Application Support/Claude/claude_desktop_config.json"   # Claude Desktop (mac)
  "$APPDATA_DIR/Claude/claude_desktop_config.json"                 # Claude Desktop (win)
  "$HOME/.config/Claude/claude_desktop_config.json")              # Claude Desktop (linux)
# Project-local MCP / Claude configs (skip worktrees — owned by other sessions).
while IFS= read -r f; do CLAUDE_FILES+=("$f"); done < <(
  find "$PROJECT_DIR" -maxdepth 4 \
    \( -name 'settings*.json' -path '*/.claude/*' \
       -o -name '.mcp.json' -o -name 'mcp.json' \
       -o -name 'cline_mcp_settings.json' -o -name 'mcp_settings.json' \) \
    -not -path '*/worktrees/*' -not -path '*/node_modules/*' 2>/dev/null)
for f in "${CLAUDE_FILES[@]}"; do
  [[ -f "$f" ]] || continue
  vinfo "scanning $f"
  for key in hooks mcpServers; do
    entries=$(json_key_entries "$f" "$key")
    [[ -n "$entries" ]] && record "aitool_config" "$f" "$key" "$entries"
  done
done

# ─── 2. Editor user settings (VS Code + forks) ─────────────────────────────
section "Editor settings" "startup / autorun / task IOCs (VS Code, Cursor, Windsurf, VSCodium)"
EDITOR_SETTINGS=()
for ed in Code Cursor Windsurf VSCodium; do
  EDITOR_SETTINGS+=(
    "$HOME/.config/$ed/User/settings.json"                       # Linux
    "$HOME/Library/Application Support/$ed/User/settings.json"   # macOS
    "${APPDATA:-$HOME/AppData/Roaming}/$ed/User/settings.json")  # Windows
done
SUSPECT='task.allowAutomaticTasks|automationProfile|shellArgs|runOnStartup|autoRun|"command":'
for f in "${EDITOR_SETTINGS[@]}"; do
  [[ -f "$f" ]] || continue
  vinfo "scanning $f"
  hits=$(grep -nEi "$SUSPECT" "$f" 2>/dev/null)
  [[ -n "$hits" ]] && record "vscode_settings" "$f" "autorun_keys" "$hits"
done
info "audit extensions too: code --list-extensions --show-versions (pause <7-day, non-verified)"

# ─── 3. GitHub Actions OIDC publish trust ──────────────────────────────────
section "GitHub Actions" "live OIDC publish trust (Mini Shai-Hulud entry point)"
WF_DIR="$PROJECT_DIR/.github/workflows"
if [[ -d "$WF_DIR" ]]; then
  if [[ "$HAS_ZIZMOR" -eq 1 ]]; then
    info "running zizmor (richer workflow analysis) — see stderr"
    [[ "$QUIET" -eq 0 ]] && zizmor "$WF_DIR" >&2 2>&1 || true
  else
    # Surface the degradation at info level (NOT verbose-only) — the caller must
    # know they're getting the weaker check, or they'll assume full coverage.
    info "NOTE: zizmor not installed — using weaker rg-based OIDC check only."
    info "      Misses pull_request_target / template-injection. Install: uv tool install zizmor"
  fi
  while IFS= read -r wf; do
    [[ -z "$wf" ]] && continue
    pub=$(grep -nE 'npm publish|pypi|twine upload|trusted.?publish|registry-url' "$wf" 2>/dev/null)
    record "workflow_oidc" "$wf" "id-token-write" "${pub:-id-token: write present}"
  done < <(grep -rlE 'id-token:\s*write' "$WF_DIR" 2>/dev/null)
else
  info "no .github/workflows in $PROJECT_DIR"
fi

# ─── 4. Shell startup files (persistence) ──────────────────────────────────
# The worm family persists via shell rc files too, not just editor settings —
# our own threat-model IOC list names this. These files are hand-edited, so
# curl|sh / base64-eval / cred-reads / reverse-shell patterns are high-signal.
section "Shell startup files" "curl|sh, base64 eval, cred reads, /dev/tcp (persistence)"
SHELL_RC=(
  "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile"
  "$HOME/.zshrc" "$HOME/.zprofile" "$HOME/.zshenv"
  "$HOME/.config/fish/config.fish"
  "$HOME/Documents/PowerShell/Microsoft.PowerShell_profile.ps1"
  "$HOME/Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1")
SHELL_SUSPECT='curl[^|]*\|[[:space:]]*(ba)?sh|wget[^|]*\|[[:space:]]*(ba)?sh|base64[[:space:]]+--?d|eval[[:space:]]+"?\$\(|\.claude[/\\]\.?settings|\.aws[/\\]credentials|/dev/tcp/|[Ii]nvoke-Expression[^;]*[Dd]ownload'
for f in "${SHELL_RC[@]}"; do
  [[ -f "$f" ]] || continue
  vinfo "scanning $f"
  hits=$(grep -nEi "$SHELL_SUSPECT" "$f" 2>/dev/null)
  [[ -n "$hits" ]] && record "shell_rc" "$f" "suspicious_line" "$hits"
done

# ─── 5. Package-manager config (rogue registry / leaked token) ─────────────
section "Package-manager config" ".npmrc / .pypirc registry overrides + tokens"
for f in "$HOME/.npmrc" "$PROJECT_DIR/.npmrc" "$HOME/.pypirc" "$PROJECT_DIR/.pypirc"; do
  [[ -f "$f" ]] || continue
  vinfo "scanning $f"
  hits=$(grep -nEi '^[[:space:]]*registry[[:space:]]*=|_authToken|^[[:space:]]*index-url|password[[:space:]]*=' "$f" 2>/dev/null)
  [[ -n "$hits" ]] && record "pkgmgr_config" "$f" "registry_or_token" "$hits"
done

# ─── Output + verdict ──────────────────────────────────────────────────────
if [[ "$JSON" -eq 1 ]]; then
  printf '%s\n' "${REVIEW_JSON[@]:-}" | jq -s \
    --argjson z "$HAS_ZIZMOR" \
    '{data:{review: (map(select(length>0)))}, meta:{count:(map(select(length>0))|length), zizmor_used:($z==1), schema:"axiom.tool.integrity-audit.report/v1"}}'
fi

if [[ "$REVIEW_COUNT" -eq 0 ]]; then
  [[ "$QUIET" -eq 0 ]] && printf '%sClean: nothing flagged for review.%s\n' "$C_G" "$C_O" >&2
  exit "$EXIT_OK"
fi
if [[ "$QUIET" -eq 0 ]]; then
  printf '%s%d item(s) flagged for review — confirm YOU added each.%s\n' "$C_Y" "$REVIEW_COUNT" "$C_O" >&2
  cat >&2 <<'EOF'
   Not proof of compromise. If any entry is unexplained, treat as an incident:
     1. Isolate the machine.  2. Rotate every reachable credential.  3. Investigate.
EOF
fi
exit "$EXIT_REVIEW"
