integrity-audit.sh 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. #!/usr/bin/env bash
  2. # Self-integrity scan — detect worm persistence in Claude Code / VS Code settings.
  3. #
  4. # Flags the 2026 worm IOC: hooks / mcpServers injected into Claude Code and VS Code
  5. # settings, plus GitHub Actions workflows with live OIDC publish trust (the Mini
  6. # Shai-Hulud entry point). Read-only — it reports; you decide. Uses `zizmor` for
  7. # richer workflow analysis when installed.
  8. #
  9. # Usage: integrity-audit.sh [--json] [-q] [-v] [PROJECT_DIR]
  10. # Input: optional PROJECT_DIR positional (default: cwd) + $HOME config locations
  11. # Output: stdout = findings (tab-separated records, or JSON with --json)
  12. # Stderr: section framing, progress, verdict guidance, errors
  13. # Exit: 0 clean, 2 usage, 5 missing-dep (jq, with --json), 10 review-items-found
  14. #
  15. # Note: intentionally NOT `set -e` — a scanner must survive missing files and keep
  16. # going. Errors are handled explicitly.
  17. #
  18. # Examples:
  19. # integrity-audit.sh
  20. # integrity-audit.sh --json | jq '.data.review[]'
  21. # integrity-audit.sh -q ./some/project # quiet: findings only, no framing
  22. set -uo pipefail
  23. EXIT_OK=0; EXIT_USAGE=2; EXIT_MISSING_DEP=5; EXIT_REVIEW=10
  24. JSON=0; QUIET=0; VERBOSE=0; PROJECT_DIR="."
  25. while [[ $# -gt 0 ]]; do
  26. case "$1" in
  27. --json) JSON=1 ;;
  28. -q|--quiet) QUIET=1 ;;
  29. -v|--verbose) VERBOSE=1 ;;
  30. -h|--help)
  31. sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit "$EXIT_OK" ;;
  32. -*) echo "ERROR: unknown flag: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
  33. *) PROJECT_DIR="$1" ;;
  34. esac
  35. shift
  36. done
  37. # stderr framing — colored only when stderr is a TTY and NO_COLOR unset.
  38. if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then
  39. C_Y=$'\033[33m'; C_G=$'\033[32m'; C_D=$'\033[2m'; C_O=$'\033[0m'
  40. else C_Y=""; C_G=""; C_D=""; C_O=""; fi
  41. section() { [[ "$QUIET" -eq 1 ]] && return; printf '%s== %s ==%s %s\n' "$C_D" "$1" "$C_O" "${2:-}" >&2; }
  42. info() { [[ "$QUIET" -eq 1 ]] && return; printf ' %s\n' "$1" >&2; }
  43. vinfo() { [[ "$VERBOSE" -eq 1 ]] && printf ' %s\n' "$1" >&2; }
  44. HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
  45. HAS_ZIZMOR=0; command -v zizmor >/dev/null 2>&1 && HAS_ZIZMOR=1
  46. if [[ "$JSON" -eq 1 && "$HAS_JQ" -eq 0 ]]; then
  47. echo '{"error":{"code":"MISSING_DEPENDENCY","message":"jq required for --json","details":{"install":"apt-get install jq"}}}'
  48. echo "ERROR: jq required for --json output" >&2
  49. exit "$EXIT_MISSING_DEP"
  50. fi
  51. REVIEW_JSON=() # array of compact JSON objects
  52. REVIEW_COUNT=0
  53. # record <category> <source> <kind> <entries-newline-separated>
  54. record() {
  55. local category=$1 source=$2 kind=$3 entries=$4
  56. REVIEW_COUNT=$((REVIEW_COUNT+1))
  57. # tab-separated record to stdout (the data product, non-JSON mode)
  58. if [[ "$JSON" -eq 0 ]]; then
  59. local flat; flat=$(echo "$entries" | paste -sd',' - 2>/dev/null)
  60. printf '%s\t%s\t%s\t%s\n' "$category" "$source" "$kind" "$flat"
  61. fi
  62. if [[ "$HAS_JQ" -eq 1 ]]; then
  63. local obj
  64. obj=$(jq -cn --arg c "$category" --arg s "$source" --arg k "$kind" \
  65. --arg e "$entries" '{category:$c, source:$s, kind:$k, entries:($e|split("\n")|map(select(length>0)))}')
  66. REVIEW_JSON+=("$obj")
  67. fi
  68. printf ' %s[review]%s %s %s: %s\n' "$C_Y" "$C_O" "$kind" "$source" \
  69. "$(echo "$entries" | paste -sd',' - 2>/dev/null)" >&2
  70. }
  71. json_key_entries() { # file key -> newline-separated entry list (jq)
  72. local file=$1 key=$2
  73. [[ -f "$file" && "$HAS_JQ" -eq 1 ]] || return 0
  74. jq -r --arg k "$key" '
  75. if (.[$k] // empty) == null then empty
  76. elif (.[$k]|type)=="object" then (.[$k]|keys[])
  77. elif (.[$k]|type)=="array" then (.[$k][]|tostring)
  78. else (.[$k]|tostring) end' "$file" 2>/dev/null
  79. }
  80. # ─── 1. AI-tool config: hooks / mcpServers across hosts ────────────────────
  81. # Broadened with the MCP host-config map from Perplexity's Bumblebee
  82. # (docs/inventory-sources.md) — the worm targets these persistence surfaces.
  83. section "AI-tool config" "hooks / mcpServers you may not have added (Claude + MCP hosts)"
  84. APPDATA_DIR="${APPDATA:-$HOME/AppData/Roaming}"
  85. CLAUDE_FILES=(
  86. "$HOME/.claude/settings.json" "$HOME/.claude/settings.local.json" "$HOME/.claude.json"
  87. "$HOME/.gemini/settings.json" # Gemini CLI / Code Assist
  88. "$HOME/Library/Application Support/Claude/claude_desktop_config.json" # Claude Desktop (mac)
  89. "$APPDATA_DIR/Claude/claude_desktop_config.json" # Claude Desktop (win)
  90. "$HOME/.config/Claude/claude_desktop_config.json") # Claude Desktop (linux)
  91. # Project-local MCP / Claude configs (skip worktrees — owned by other sessions).
  92. while IFS= read -r f; do CLAUDE_FILES+=("$f"); done < <(
  93. find "$PROJECT_DIR" -maxdepth 4 \
  94. \( -name 'settings*.json' -path '*/.claude/*' \
  95. -o -name '.mcp.json' -o -name 'mcp.json' \
  96. -o -name 'cline_mcp_settings.json' -o -name 'mcp_settings.json' \) \
  97. -not -path '*/worktrees/*' -not -path '*/node_modules/*' 2>/dev/null)
  98. for f in "${CLAUDE_FILES[@]}"; do
  99. [[ -f "$f" ]] || continue
  100. vinfo "scanning $f"
  101. for key in hooks mcpServers; do
  102. entries=$(json_key_entries "$f" "$key")
  103. [[ -n "$entries" ]] && record "aitool_config" "$f" "$key" "$entries"
  104. done
  105. done
  106. # ─── 2. Editor user settings (VS Code + forks) ─────────────────────────────
  107. section "Editor settings" "startup / autorun / task IOCs (VS Code, Cursor, Windsurf, VSCodium)"
  108. EDITOR_SETTINGS=()
  109. for ed in Code Cursor Windsurf VSCodium; do
  110. EDITOR_SETTINGS+=(
  111. "$HOME/.config/$ed/User/settings.json" # Linux
  112. "$HOME/Library/Application Support/$ed/User/settings.json" # macOS
  113. "${APPDATA:-$HOME/AppData/Roaming}/$ed/User/settings.json") # Windows
  114. done
  115. SUSPECT='task.allowAutomaticTasks|automationProfile|shellArgs|runOnStartup|autoRun|"command":'
  116. for f in "${EDITOR_SETTINGS[@]}"; do
  117. [[ -f "$f" ]] || continue
  118. vinfo "scanning $f"
  119. hits=$(grep -nEi "$SUSPECT" "$f" 2>/dev/null)
  120. [[ -n "$hits" ]] && record "vscode_settings" "$f" "autorun_keys" "$hits"
  121. done
  122. info "audit extensions too: code --list-extensions --show-versions (pause <7-day, non-verified)"
  123. # ─── 3. GitHub Actions OIDC publish trust ──────────────────────────────────
  124. section "GitHub Actions" "live OIDC publish trust (Mini Shai-Hulud entry point)"
  125. WF_DIR="$PROJECT_DIR/.github/workflows"
  126. if [[ -d "$WF_DIR" ]]; then
  127. if [[ "$HAS_ZIZMOR" -eq 1 ]]; then
  128. info "running zizmor (richer workflow analysis) — see stderr"
  129. [[ "$QUIET" -eq 0 ]] && zizmor "$WF_DIR" >&2 2>&1 || true
  130. else
  131. # Surface the degradation at info level (NOT verbose-only) — the caller must
  132. # know they're getting the weaker check, or they'll assume full coverage.
  133. info "NOTE: zizmor not installed — using weaker rg-based OIDC check only."
  134. info " Misses pull_request_target / template-injection. Install: uv tool install zizmor"
  135. fi
  136. while IFS= read -r wf; do
  137. [[ -z "$wf" ]] && continue
  138. pub=$(grep -nE 'npm publish|pypi|twine upload|trusted.?publish|registry-url' "$wf" 2>/dev/null)
  139. record "workflow_oidc" "$wf" "id-token-write" "${pub:-id-token: write present}"
  140. done < <(grep -rlE 'id-token:\s*write' "$WF_DIR" 2>/dev/null)
  141. else
  142. info "no .github/workflows in $PROJECT_DIR"
  143. fi
  144. # ─── 4. Shell startup files (persistence) ──────────────────────────────────
  145. # The worm family persists via shell rc files too, not just editor settings —
  146. # our own threat-model IOC list names this. These files are hand-edited, so
  147. # curl|sh / base64-eval / cred-reads / reverse-shell patterns are high-signal.
  148. section "Shell startup files" "curl|sh, base64 eval, cred reads, /dev/tcp (persistence)"
  149. SHELL_RC=(
  150. "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile"
  151. "$HOME/.zshrc" "$HOME/.zprofile" "$HOME/.zshenv"
  152. "$HOME/.config/fish/config.fish"
  153. "$HOME/Documents/PowerShell/Microsoft.PowerShell_profile.ps1"
  154. "$HOME/Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1")
  155. 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'
  156. for f in "${SHELL_RC[@]}"; do
  157. [[ -f "$f" ]] || continue
  158. vinfo "scanning $f"
  159. hits=$(grep -nEi "$SHELL_SUSPECT" "$f" 2>/dev/null)
  160. [[ -n "$hits" ]] && record "shell_rc" "$f" "suspicious_line" "$hits"
  161. done
  162. # ─── 5. Package-manager config (rogue registry / leaked token) ─────────────
  163. section "Package-manager config" ".npmrc / .pypirc registry overrides + tokens"
  164. for f in "$HOME/.npmrc" "$PROJECT_DIR/.npmrc" "$HOME/.pypirc" "$PROJECT_DIR/.pypirc"; do
  165. [[ -f "$f" ]] || continue
  166. vinfo "scanning $f"
  167. hits=$(grep -nEi '^[[:space:]]*registry[[:space:]]*=|_authToken|^[[:space:]]*index-url|password[[:space:]]*=' "$f" 2>/dev/null)
  168. [[ -n "$hits" ]] && record "pkgmgr_config" "$f" "registry_or_token" "$hits"
  169. done
  170. # ─── Output + verdict ──────────────────────────────────────────────────────
  171. if [[ "$JSON" -eq 1 ]]; then
  172. printf '%s\n' "${REVIEW_JSON[@]:-}" | jq -s \
  173. --argjson z "$HAS_ZIZMOR" \
  174. '{data:{review: (map(select(length>0)))}, meta:{count:(map(select(length>0))|length), zizmor_used:($z==1), schema:"axiom.tool.integrity-audit.report/v1"}}'
  175. fi
  176. if [[ "$REVIEW_COUNT" -eq 0 ]]; then
  177. [[ "$QUIET" -eq 0 ]] && printf '%sClean: nothing flagged for review.%s\n' "$C_G" "$C_O" >&2
  178. exit "$EXIT_OK"
  179. fi
  180. if [[ "$QUIET" -eq 0 ]]; then
  181. printf '%s%d item(s) flagged for review — confirm YOU added each.%s\n' "$C_Y" "$REVIEW_COUNT" "$C_O" >&2
  182. cat >&2 <<'EOF'
  183. Not proof of compromise. If any entry is unexplained, treat as an incident:
  184. 1. Isolate the machine. 2. Rotate every reachable credential. 3. Investigate.
  185. EOF
  186. fi
  187. exit "$EXIT_REVIEW"