scan-extensions.sh 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. #!/usr/bin/env bash
  2. # Inventory, recency, and (optional) behavioural scan of installed editor
  3. # extensions, Claude Code plugins, and skills — the "what's on this machine, what
  4. # changed recently, and is any of it malicious?" audit.
  5. #
  6. # DEFAULT (zero-dependency): lists every installed editor extension, Claude plugin,
  7. # and skill with its version + whether it changed within the recency window. The
  8. # 2026 campaign exploits exactly the gap this closes — fresh malicious versions
  9. # live for minutes (Nx Console: 11 min) and most teams have no inventory. This
  10. # mode has NO false positives; it is an inventory, not a verdict.
  11. #
  12. # --deep (auto-detects guarddog + semgrep): runs GuardDog's AST/semgrep behavioural
  13. # rules against editor extensions changed within the window (or --all) — the real
  14. # "unknown bad" engine. If the engine is NOT installed it does NOT pretend: it runs
  15. # inventory + recency, then LOUDLY reports that the behavioural scan was skipped and
  16. # recommends `uv tool install guarddog semgrep` (on-demand — kept off the machine by
  17. # default to stay lean). It never reports "clean" for a scan it didn't run.
  18. # Note: extension bundles are minified, so even AST scanning is best-effort here;
  19. # inventory + recency + IOC (exposure-check.py) remain the backbone for extensions.
  20. #
  21. # Usage: scan-extensions.sh [--json] [--days N] # inventory + recency
  22. # scan-extensions.sh --deep [--all] [--days N] # behavioural (needs guarddog+semgrep)
  23. # Input: editor-extension dirs (SC_EXT_DIRS overrides), ~/.claude/plugins, ~/.claude/skills
  24. # Output: stdout = inventory / findings (tab-separated, or JSON with --json)
  25. # Stderr: framing, plugin SHA inventory, verdict
  26. # Exit: 0 ok (incl. --deep with engine absent — behavioural skipped, not failed),
  27. # 2 usage, 10 behavioural finding(s)
  28. #
  29. # Examples:
  30. # scan-extensions.sh # full inventory + recency
  31. # scan-extensions.sh --days 7 --json # JSON, 7-day recency window
  32. # scan-extensions.sh --deep --days 7 # behavioural-scan extensions changed in 7d
  33. set -uo pipefail
  34. EXIT_OK=0; EXIT_USAGE=2; EXIT_MISSING_DEP=5; EXIT_FINDING=10
  35. JSON=0; QUIET=0; DEEP=0; ALL=0; DAYS=14
  36. while [[ $# -gt 0 ]]; do
  37. case "$1" in
  38. --json) JSON=1 ;;
  39. -q|--quiet) QUIET=1 ;;
  40. --deep) DEEP=1 ;;
  41. --all) ALL=1 ;;
  42. --days) DAYS="${2:?--days needs a value}"; shift ;;
  43. -h|--help) sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'; exit "$EXIT_OK" ;;
  44. -*) echo "ERROR: unknown flag: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
  45. *) echo "ERROR: unexpected argument: $1" >&2; exit "$EXIT_USAGE" ;;
  46. esac
  47. shift
  48. done
  49. HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
  50. if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then C_Y=$'\033[33m'; C_G=$'\033[32m'; C_D=$'\033[2m'; C_R=$'\033[31m'; C_O=$'\033[0m'
  51. else C_Y=""; C_G=""; C_D=""; C_R=""; C_O=""; fi
  52. section(){ [[ "$QUIET" -eq 1 ]] || printf '%s== %s ==%s %s\n' "$C_D" "$1" "$C_O" "${2:-}" >&2; }
  53. info(){ [[ "$QUIET" -eq 1 ]] || printf ' %s\n' "$1" >&2; }
  54. # ── --deep: auto-detect the engine; recommend (don't require) if absent ────
  55. # Lean by default — guarddog+semgrep are NOT kept on the machine. If --deep is asked
  56. # for and they're present, use them; if absent, run inventory+recency and LOUDLY skip
  57. # the behavioural pass (never report a scan we didn't run as clean).
  58. DEEP_OK=0; DEEP_SKIPPED=0
  59. if [[ "$DEEP" -eq 1 ]]; then
  60. if command -v guarddog >/dev/null 2>&1 && command -v semgrep >/dev/null 2>&1 && semgrep --version >/dev/null 2>&1; then
  61. DEEP_OK=1
  62. else
  63. DEEP_SKIPPED=1
  64. fi
  65. fi
  66. now_epoch=$(date +%s); window=$(( DAYS * 86400 ))
  67. EXT_DIRS=("$HOME/.vscode/extensions" "$HOME/.vscode-server/extensions" "$HOME/.vscode-oss/extensions" "$HOME/.cursor/extensions" "$HOME/.windsurf/extensions")
  68. [[ -n "${SC_EXT_DIRS:-}" ]] && IFS="$(printf ':')" read -ra EXT_DIRS <<< "$SC_EXT_DIRS"
  69. INV_JSON=(); FIND_JSON=(); FINDINGS=0; RECENT=0
  70. dir_recent() { # echoes yes/no — any code file in $1 modified within window
  71. local newest
  72. newest=$(find "$1" -type f \( -name '*.js' -o -name '*.ts' -o -name '*.cjs' -o -name '*.mjs' -o -name '*.py' -o -name '*.sh' -o -name 'package.json' \) -printf '%T@\n' 2>/dev/null | sort -rn | head -1)
  73. [[ -n "$newest" && $(( now_epoch - ${newest%.*} )) -lt $window ]] && echo yes || echo no
  74. }
  75. # ── 1. Editor extensions: inventory (+ behavioural if --deep) ──────────────
  76. section "Editor extensions" "inventory + recency <${DAYS}d$( [[ $DEEP_OK -eq 1 ]] && echo ' + GuardDog behavioural' )"
  77. for base in "${EXT_DIRS[@]}"; do
  78. [[ -d "$base" ]] || continue
  79. for ext in "$base"/*/; do
  80. [[ -f "$ext/package.json" ]] || continue
  81. pub=$(jq -r '.publisher // empty' "$ext/package.json" 2>/dev/null)
  82. name=$(jq -r '.name // empty' "$ext/package.json" 2>/dev/null)
  83. ver=$(jq -r '.version // empty' "$ext/package.json" 2>/dev/null)
  84. [[ -z "$pub" || -z "$name" ]] && continue
  85. id="$pub.$name"; recent=$(dir_recent "$ext")
  86. [[ "$recent" == yes ]] && RECENT=$((RECENT+1))
  87. [[ "$JSON" -eq 0 && "$QUIET" -eq 0 ]] && printf '%s\t%s\trecent=%s\n' "$id" "${ver:-?}" "$recent"
  88. [[ "$HAS_JQ" -eq 1 ]] && INV_JSON+=("$(jq -cn --arg i "$id" --arg v "$ver" --argjson r "$([[ $recent == yes ]] && echo true || echo false)" '{kind:"editor-extension",id:$i,version:$v,recent:$r}')")
  89. # behavioural scan: --deep, gated to recent unless --all
  90. if [[ "$DEEP_OK" -eq 1 && ( "$ALL" -eq 1 || "$recent" == yes ) ]]; then
  91. gout=$(PYTHONUTF8=1 guarddog npm scan "$ext" --exit-non-zero-on-finding 2>/dev/null); grc=$?
  92. if [[ $grc -ne 0 ]] && echo "$gout" | grep -qiE 'potentially malicious|source code matches'; then
  93. FINDINGS=$((FINDINGS+1))
  94. printf ' %s[FINDING]%s %s\n' "$C_R" "$C_O" "$id" >&2
  95. echo "$gout" | grep -iE 'found|matches|: This' | head -5 | sed 's/^/ /' >&2
  96. [[ "$HAS_JQ" -eq 1 ]] && FIND_JSON+=("$(jq -cn --arg i "$id" --arg d "$(echo "$gout" | tr '\n' ' ' | head -c 400)" '{id:$i,engine:"guarddog",detail:$d}')")
  97. fi
  98. fi
  99. done
  100. done
  101. # ── 2. Claude Code plugins: inventory + pinned-commit ──────────────────────
  102. section "Claude Code plugins" "pinned-commit inventory — verify each against its marketplace"
  103. PMETA="$HOME/.claude/plugins/installed_plugins.json"
  104. if [[ -f "$PMETA" && "$HAS_JQ" -eq 1 ]]; then
  105. while IFS= read -r line; do info "$line"; done < <(jq -r '.plugins | to_entries[] | .key as $n | .value[] | "\($n) sha=\(.gitCommitSha[0:12]) scope=\(.scope) updated=\(.lastUpdated)"' "$PMETA" 2>/dev/null)
  106. else
  107. info "no installed_plugins.json (no marketplace plugins) or jq missing"
  108. fi
  109. # ── 3. Installed skills: inventory + recency ───────────────────────────────
  110. section "Installed skills" "recency <${DAYS}d (review recently-changed you didn't edit)"
  111. for sk in "$HOME/.claude/skills"/*/; do
  112. [[ -d "$sk" ]] || continue
  113. recent=$(dir_recent "$sk")
  114. if [[ "$recent" == yes ]]; then
  115. RECENT=$((RECENT+1))
  116. [[ "$QUIET" -eq 0 ]] && printf '%s\t(recently changed)\n' "$(basename "$sk")"
  117. fi
  118. done
  119. # ── Output + verdict ───────────────────────────────────────────────────────
  120. if [[ "$JSON" -eq 1 ]]; then
  121. printf '%s\n' "${INV_JSON[@]:-}" | jq -s \
  122. --argjson f "$(printf '%s\n' "${FIND_JSON[@]:-}" | jq -s 'map(select(length>0))' 2>/dev/null || echo '[]')" \
  123. --argjson deep "$DEEP" --argjson days "$DAYS" \
  124. '{data:{inventory: map(select(length>0)), findings:$f}, meta:{deep:($deep==1), recency_days:$days, finding_count:($f|length), schema:"axiom.tool.scan-extensions.report/v1"}}'
  125. fi
  126. if [[ "$DEEP_OK" -eq 1 ]]; then
  127. if [[ "$FINDINGS" -eq 0 ]]; then
  128. [[ "$QUIET" -eq 1 ]] || printf '%sBehavioural: GuardDog found no indicators in scanned extensions.%s\n' "$C_G" "$C_O" >&2
  129. exit "$EXIT_OK"
  130. fi
  131. [[ "$QUIET" -eq 1 ]] || printf '%s%d extension(s) with behavioural findings — inspect + treat as incident.%s\n' "$C_R" "$FINDINGS" "$C_O" >&2
  132. exit "$EXIT_FINDING"
  133. fi
  134. if [[ "$DEEP_SKIPPED" -eq 1 ]]; then
  135. [[ "$QUIET" -eq 1 ]] || {
  136. printf '%sBEHAVIOURAL SCAN SKIPPED%s — guarddog/semgrep not installed (kept off by default).\n' "$C_Y" "$C_O" >&2
  137. printf ' Ran inventory + recency only — this is NOT a clean behavioural verdict.\n' >&2
  138. printf ' Enable on-demand: uv tool install guarddog semgrep (then re-run --deep)\n' >&2
  139. }
  140. fi
  141. [[ "$QUIET" -eq 1 ]] || printf '%sInventory done. %d item(s) changed within %dd — review those; run exposure-check.py for known-IOC matching.%s\n' "$C_D" "$RECENT" "$DAYS" "$C_O" >&2
  142. exit "$EXIT_OK"