| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202 |
- #!/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"
|