#!/usr/bin/env bash
# check-claude-code-ext.sh - read-only checker for the trojanised Anthropic.claude-code 2.1.313 extension
# served by Cursor's VS Code extension marketplace on 29 Oct 2026 (about 09:27-20:35 GMT). macOS and Linux.
#
# STRICTLY READ-ONLY AND OFFLINE: this script only lists directories, reads files and lists processes.
# It never connects to 134.209.82.225 or anything else, sends no network traffic, never writes, moves or
# deletes a file, never removes keys, crontab entries or LaunchAgents, never runs launchctl/crontab -e,
# and never runs Cursor's remediation scripts. To clean a host, use Cursor's scripts (see the advice printed).
#
# Indicators (source: Cursor security notice, 29 Oct 2026, unless marked):
#   extension folder anthropic.claude-code-2.1.313[-platform]; extension.js containing 134.209.82.225 / svc-telemetry
#   authorized_keys line with comment svc-telemetry or key AAAAC3NzaC1lZDI1NTE5AAAAIDdvylCEJ/k2TpezcTfedrkHyFSK9IZ3vmP6vhY4Ss0P
#   ~/.config/.smt, ~/.config/.smtk, $TMPDIR/.stk_<10 hex>, ~/Library/LaunchAgents/com.apple.sysmond.plist
#   ssh -N -R '*:0:localhost:22' / autossh -M 0 to tunnel@134.209.82.225; beacon http://134.209.82.225:8099/beacon
#   Linux crontab persistence: reported by news (The Hacker News); Cursor publishes no crontab line, so cron is
#   matched only on the IP, beacon, .smt/.smtk/.stk_ names and reverse-tunnel options.
#
# Verdicts / exit codes: CLEAN 0 | INDICATORS FOUND 1 (also 2.1.313 present) | UNKNOWN 2 | error 3
set -u
IP='134.209.82.225'
IPRE='134\.209\.82\.225'
KEYBLOB='AAAAC3NzaC1lZDI1NTE5AAAAIDdvylCEJ/k2TpezcTfedrkHyFSK9IZ3vmP6vhY4Ss0P'
BADVER='2.1.313'
CONTENT_RE="${IPRE}|svc-telemetry|${KEYBLOB}|:8099/beacon|/\.config/\.smtk?([^a-zA-Z]|$)|\.stk_[0-9a-f]{10}"
EDITOR_DIRS='.cursor/extensions .cursor-server/extensions .vscode/extensions .vscode-insiders/extensions .vscode-server/extensions .vscode-server-insiders/extensions .vscode-oss/extensions .windsurf/extensions .windsurf-server/extensions .vscodium/extensions .trae/extensions .kiro/extensions'

usage() {
  cat <<'U'
Usage: check-claude-code-ext.sh [--home DIR]... [--all-users] [options]
  --home DIR       user home to check (repeatable). Default: $HOME
  --all-users      check every home under /Users and /home (plus /root); run as root to read them all
                   (no user homes found -> error 3)
  --cron-dir DIR   crontab spool to read (default: /var/spool/cron/crontabs, /var/spool/cron, /usr/lib/cron/tabs)
  --tmp-dir DIR    temp dir to look for .stk_<hex> (repeatable; default: $TMPDIR and /tmp)
  --ps-file FILE   read a saved "ps -axo pid=,args=" listing instead of the live process list
  --no-crontab-l   do not fall back to "crontab -l" for the current user
  -q, --quiet      only print findings and the verdict
  -h, --help       this help
Exit: 0 CLEAN, 1 INDICATORS FOUND, 2 UNKNOWN, 3 error. Read-only, no network.
U
}
die() { printf 'ERROR: %s\n' "$*" >&2; exit 3; }
QUIET=0
say() { [ "$QUIET" -eq 1 ] || printf '%s\n' "$*"; }
IND=0; UNK=0; FOUND_BAD=0
hit() { IND=$((IND + 1)); printf 'INDICATOR: %s\n' "$*"; }
unk() { UNK=$((UNK + 1)); printf 'UNKNOWN: %s\n' "$*"; }

HOMES=(); TMPS=(); CRONDIRS=(); ALL=0; PSFILE=""; CRONL=1
while [ $# -gt 0 ]; do
  case "$1" in
    --home) [ $# -ge 2 ] || die "--home needs a value"; HOMES+=("$2"); shift 2 ;;
    --all-users) ALL=1; shift ;;
    --cron-dir) [ $# -ge 2 ] || die "--cron-dir needs a value"; CRONDIRS+=("$2"); shift 2 ;;
    --tmp-dir) [ $# -ge 2 ] || die "--tmp-dir needs a value"; TMPS+=("$2"); shift 2 ;;
    --ps-file) [ $# -ge 2 ] || die "--ps-file needs a value"; PSFILE=$2; shift 2 ;;
    --no-crontab-l) CRONL=0; shift ;;
    -q|--quiet) QUIET=1; shift ;;
    -h|--help) usage; exit 0 ;;
    *) die "unknown argument: $1 (see --help)" ;;
  esac
done

if [ "$ALL" -eq 1 ]; then
  # CHECK_USERS_ROOTS overrides the parent folders of user homes (used by the tests)
  for r in ${CHECK_USERS_ROOTS:-/Users /home}; do
   for d in "$r"/*; do
    [ -d "$d" ] || continue
    case "$d" in /Users/Shared) continue ;; esac
    HOMES+=("$d")
   done
  done
  [ -n "${CHECK_USERS_ROOTS:-}" ] || { [ -d /root ] && HOMES+=(/root); }
fi
if [ ${#HOMES[@]} -eq 0 ]; then
  [ "$ALL" -eq 0 ] || die "--all-users found no user homes"
  HOMES=("${HOME:-}")
fi
for h in "${HOMES[@]}"; do
  if [ -z "$h" ] || [ ! -d "$h" ]; then die "cannot read directory: ${h:-<empty HOME>}"; fi
done
[ -z "$PSFILE" ] || [ -r "$PSFILE" ] || die "cannot read --ps-file: $PSFILE"
if [ ${#CRONDIRS[@]} -gt 0 ]; then
  for c in "${CRONDIRS[@]}"; do [ -d "$c" ] || die "cannot read directory: $c"; done
else
  CRONDIRS=(/var/spool/cron/crontabs /var/spool/cron /usr/lib/cron/tabs)
fi
if [ ${#TMPS[@]} -gt 0 ]; then
  for t in "${TMPS[@]}"; do [ -d "$t" ] || die "cannot read directory: $t"; done
else
  [ -n "${TMPDIR:-}" ] && TMPS+=("${TMPDIR%/}")
  TMPS+=(/tmp)
fi

# grep a file for content indicators; prints matching lines (first 3), returns 0 on match
scan_file() {
  local f=$1 label=$2
  if [ ! -r "$f" ]; then unk "$label: $f exists but is not readable (run as that user or root)"; return 1; fi
  if grep -Eq -- "$CONTENT_RE" "$f" 2>/dev/null; then
    hit "$label: $f references the attacker IP / svc-telemetry key / payload files"
    grep -En -- "$CONTENT_RE" "$f" 2>/dev/null | head -n 3 | cut -c1-220 | sed 's/^/      /'
    return 0
  fi
  return 1
}

say "Claude Code extension 2.1.313 checker (read-only, offline) - $(date '+%Y-%m-%d %H:%M %Z' 2>/dev/null)"
say "Homes: ${HOMES[*]}"

for H in "${HOMES[@]}"; do
  U=$(basename "$H")
  say ""
  say "== $H =="
  # 1. extensions
  n=0
  for rel in $EDITOR_DIRS; do
    ed="$H/$rel"
    [ -d "$ed" ] || continue
    if [ ! -r "$ed" ] || [ ! -x "$ed" ]; then unk "extensions dir not readable: $ed"; continue; fi
    for x in "$ed"/[Aa]nthropic.claude-code-*; do
      [ -e "$x" ] || continue
      n=$((n + 1))
      name=$(basename "$x")
      ver=$(printf '%s' "$name" | sed -E 's/^[Aa]nthropic\.claude-code-([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
      if [ "$ver" = "$BADVER" ]; then
        FOUND_BAD=1
        hit "malicious version $BADVER installed: $x"
      else
        say "  extension: $x (version $ver)"
      fi
      for js in "$x/extension.js" "$x/dist/extension.js"; do
        if [ -f "$js" ]; then scan_file "$js" "extension code" || true; fi
      done
    done
  done
  [ "$n" -gt 0 ] || say "  no anthropic.claude-code extension folders found"

  # 2. authorized_keys
  for ak in "$H/.ssh/authorized_keys" "$H/.ssh/authorized_keys2"; do
    [ -e "$ak" ] || continue
    scan_file "$ak" "authorized_keys" || say "  $ak: no svc-telemetry key"
  done
  # 3. known_hosts (plaintext entries only; hashed entries cannot be matched without ssh-keygen -F, not run)
  if [ -e "$H/.ssh/known_hosts" ]; then
    if [ ! -r "$H/.ssh/known_hosts" ]; then unk "known_hosts not readable: $H/.ssh/known_hosts"
    elif grep -Eq -- "(^|[,\[])${IPRE}([],: ]|$)" "$H/.ssh/known_hosts"; then hit "known_hosts has an entry for $IP: $H/.ssh/known_hosts"
    else say "  known_hosts: no plaintext entry for $IP (hashed entries are not checked)"; fi
  fi
  # 4. payload files
  for p in "$H/.config/.smt" "$H/.config/.smtk" "$H/Library/LaunchAgents/com.apple.sysmond.plist"; do
    { [ -e "$p" ] || [ -L "$p" ]; } && hit "payload/persistence file present: $p"
  done
  # 5. LaunchAgents (any plist referencing the IOCs)
  if [ -d "$H/Library/LaunchAgents" ]; then
    for pl in "$H/Library/LaunchAgents"/*.plist; do
      [ -f "$pl" ] || continue
      scan_file "$pl" "LaunchAgent" || true
    done
  fi
  # 6. crontab spool for this user
  seen=0
  for cd in "${CRONDIRS[@]}"; do
    cf="$cd/$U"
    [ -e "$cf" ] || continue
    seen=1
    scan_file "$cf" "crontab" || say "  crontab $cf: nothing suspicious"
  done
  if [ "$seen" -eq 0 ] && [ "$CRONL" -eq 1 ] && [ "$H" = "${HOME:-}" ] && command -v crontab >/dev/null 2>&1; then
    ct=$(crontab -l 2>/dev/null || true)
    if printf '%s\n' "$ct" | grep -Eq -- "$CONTENT_RE"; then hit "crontab (crontab -l for $(id -un 2>/dev/null)) references IOCs"; printf '%s\n' "$ct" | grep -E -- "$CONTENT_RE" | head -n 3 | sed 's/^/      /'
    else say "  crontab -l: nothing suspicious"; fi
  fi
done

# 7. temp .stk_ files
for t in "${TMPS[@]}"; do
  [ -d "$t" ] || continue
  for s in "$t"/.stk_*; do
    [ -e "$s" ] || continue
    case "$(basename "$s")" in .stk_[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) hit "tunnel key file present: $s" ;; esac
  done
done

# 8. processes
say ""
if [ -n "$PSFILE" ]; then PS=$(cat "$PSFILE"); src="$PSFILE"
else PS=$(ps -axo pid=,args= 2>/dev/null || ps -eo pid=,args= 2>/dev/null); src="ps"; fi
if [ -z "$PS" ] && [ -z "$PSFILE" ]; then unk "could not list processes"
else
  m=$(printf '%s\n' "$PS" | grep -E -- "(autossh|(^|[ /])ssh( |$)).*(-R|${IPRE})|${IPRE}|\.stk_[0-9a-f]{10}|/\.config/\.smtk?( |$)" | grep -v -- 'check-claude-code-ext' || true)
  if [ -n "$m" ]; then hit "running ssh/autossh tunnel or payload process ($src):"; printf '%s\n' "$m" | head -n 5 | cut -c1-220 | sed 's/^/      /'
  else say "Processes ($src): no ssh -R / autossh / $IP processes"; fi
fi

say ""
if [ "$IND" -gt 0 ]; then
  echo "VERDICT: INDICATORS FOUND ($IND)$([ "$FOUND_BAD" -eq 1 ] && echo ' - malicious 2.1.313 present')"
  ec=1
elif [ "$UNK" -gt 0 ]; then echo "VERDICT: UNKNOWN - $UNK location(s) could not be read; re-run as root or as each user"; ec=2
else echo "VERDICT: CLEAN - no indicators in the locations checked"; ec=0; fi
cat <<'A'
ADVICE: Update Anthropic.claude-code to 2.1.314 or later (Open VSX / VS Code Marketplace). If indicators were
found: isolate the host, preserve evidence, then remove with Cursor's scripts from
https://cursor.com/blog/claude-code-extension-security-notice (remediate-macos.sh / remediate-windows.ps1).
Uninstalling the extension is not enough. Consider a rebuild, and rotate credentials and sessions from a trusted
machine. This checker changed nothing.
A
exit "$ec"
