#!/bin/bash # # Cassandra · malicious extension check · by Ciphero # # What this script does, in order: # 1. Lists the extensions installed in your editors (VS Code, Cursor, # Windsurf and other forks) and browsers (Chrome, Edge, Brave, Arc, # Firefox and others), by folder name: id and version only. If a # version an incident below names is installed, it also hashes that # extension's main file and greps it for the published attacker # strings: the hash and a yes / no go, never the file. That is how # a genuine copy of the version is told from the backdoored one. # 2. Checks a fixed list of places (below): named files and folders, # your shell start-up files, your own crontab, your LaunchAgents, # systemd units and autostart entries, the running process list # and the code of your other extensions, for the traces of # specific incidents. It records yes / no plus the name of what # matched (a file name, an extension folder). It sends no file # contents and no paths, only the answer. # 3. Lists AI tool names and the NAMES (never values) of API-key variables. # 4. Shows you every fact it will send and asks before sending. # 5. Sends them to Ciphero over HTTPS. Ciphero matches the extensions # against known-malicious lists and the marketplaces, and prints # your risk level. # # It installs nothing, writes nothing, schedules nothing, needs no sudo. # Run it only on a machine you own or are allowed to check. By sending # the facts you accept Ciphero's terms (https://ciphero.ai/terms#cassandra) # and privacy policy (https://ciphero.ai/privacy#cassandra). # Incidents it looks for traces of: # CSA-2026-0001 · Malicious Claude Code extensions 2.1.312 and 2.1.313 (Cursor marketplace) # https://cursor.com/blog/claude-code-extension-security-notice # # The readable copy at https://ciphero.ai/cassandra.sh is this same file with # YOUR-CHECK-TOKEN in place of a check token. Run as is, it does every # check above, prints its own summary of the incident traces and stops # before step 4: it has no token, so there is nothing it can send to. # # Everything below sits in main, called on the last line, so a download # cut short runs nothing at all. set -u main() { CASSANDRA_URL="https://ciphero.ai/cassandra/scan/YOUR-CHECK-TOKEN" CASSANDRA_TOKEN="YOUR-CHECK-TOKEN" CASSANDRA_VERSION="3" OUT="" NL=' ' fact() { local IFS="$(printf '\t')"; OUT="${OUT}$*${NL}"; } if [ -t 1 ]; then B="$(printf '\033[1m')"; D="$(printf '\033[2m')"; R="$(printf '\033[0m')" RED="$(printf '\033[38;5;203m')"; AMB="$(printf '\033[38;5;215m')"; YEL="$(printf '\033[38;5;221m')" GRN="$(printf '\033[38;5;114m')"; GLD="$(printf '\033[38;5;179m')" else B=""; D=""; R=""; RED=""; AMB=""; YEL=""; GRN=""; GLD="" fi say() { printf '%s\n' "$*"; } row() { case "$2" in yes) say " ${RED}●${R} $1 ${RED}${B}found${R}" ;; no) say " ${GRN}○${R} ${D}$1${R} ${GRN}clear${R}" ;; na) say " ${D}· $1 n/a${R}" ;; *) say " ${YEL}?${R} $1 ${YEL}could not check${R}" ;; esac } say "" say " ${GLD}${B}◆ CASSANDRA${R} ${D}malicious extension check · by Ciphero${R}" say "" case "$(uname -s)" in Darwin) OS=darwin ;; Linux) OS=linux ;; *) say " This script covers macOS and Linux. On Windows, run the PowerShell line from the Cassandra page."; exit 1 ;; esac fact v "$CASSANDRA_VERSION" fact os "$OS" fact arch "$(uname -m)" fact host "$(hostname -s 2>/dev/null || hostname)" TMPD="${TMPDIR:-/tmp}" # ── Indicator checks: yes / no, plus the name of what matched ──── # Each probe sets ST (yes / no / na / err) and calls hit with the # file or folder name behind a yes: a plist name, a key file, an # extension folder. Never a path under the home directory, never # contents. Globs in the path list expand here, unquoted. ST=no; CHECK_ID="" hit() { fact hit "$CHECK_ID" "$1"; } contains_any() { # needle path... local needle="$1" p; ST=no; shift for p in "$@"; do [ -f "$p" ] || continue if [ -r "$p" ]; then grep -qF -- "$needle" "$p" 2>/dev/null && { ST=yes; hit "$(basename "$p")"; }; else [ "$ST" = yes ] || ST=err; fi done } fingerprint_any() { # fingerprint path... local needle="$1" p; ST=no; shift for p in "$@"; do [ -f "$p" ] || continue command -v ssh-keygen >/dev/null 2>&1 || { ST=err; continue; } if [ -r "$p" ]; then ssh-keygen -lf "$p" -E sha256 /dev/null | grep -qF -- "$needle" && { ST=yes; hit "$(basename "$p")"; }; else [ "$ST" = yes ] || ST=err; fi done } exists_any() { local p; ST=no; for p in "$@"; do [ -e "$p" ] && { ST=yes; hit "$(basename "$p")"; }; done; } launchctl_has() { command -v launchctl >/dev/null 2>&1 || { ST=na; return; } launchctl list 2>/dev/null | grep -qF -- "$1" && ST=yes || ST=no } connected_to() { if command -v lsof >/dev/null 2>&1; then lsof -nP -iTCP 2>/dev/null | grep -qF -- "$1" && ST=yes || ST=no elif command -v ss >/dev/null 2>&1; then ss -tn 2>/dev/null | grep -qF -- "$1" && ST=yes || ST=no elif command -v netstat >/dev/null 2>&1; then netstat -an 2>/dev/null | grep -qF -- "$1" && ST=yes || ST=no else ST=err; fi } crontab_has() { # the user's own crontab; the backdoor ran as the user command -v crontab >/dev/null 2>&1 || { ST=na; return; } crontab -l 2>/dev/null | grep -qF -- "$1" && ST=yes || ST=no } process_has() { # a command line in the process list, our own grep excluded command -v ps >/dev/null 2>&1 || { ST=err; return; } ps -eww -o command= 2>/dev/null | grep -F -- "$1" | grep -qvF -- 'grep -F' && ST=yes || ST=no } # The needle inside the code of any installed extension other than # the versions an incident already names: a re-published variant # under another id or version. Top-level, dist/ and out/ scripts only, # so it stays fast; the folder name is the evidence. EXT_DIRS="" extensions_contain() { # needle local id dir f rel folder nd skip; ST=no while IFS=' ' read -r id dir; do [ -n "$id" ] && [ -d "$dir" ] || continue for f in "$dir"/*/*.js "$dir"/*/dist/*.js "$dir"/*/out/*.js; do [ -f "$f" ] && [ -r "$f" ] || continue rel="${f#"$dir"/}"; folder="${rel%%/*}"; skip=no for nd in $NEEDLES; do case "$folder" in "$nd"*) skip=yes ;; esac; done [ "$skip" = no ] || continue grep -qF -- "$1" "$f" 2>/dev/null && { ST=yes; hit "$id:$folder"; } done done </dev/null && { fact hist "$1" "$nd" obsolete; note_hist "$1" "$nd"; } done } # The editor's own install log (main.log, sharedprocess.log per # session) names the versions it installed and removed: a second # history source, for a bad version that an update already replaced. log_history() { # editor logs-dir [ -d "$2" ] || return local nd f for nd in $NEEDLES; do for f in "$2"/*/main.log "$2"/*/sharedprocess.log; do [ -r "$f" ] && grep -qF -- "$nd" "$f" 2>/dev/null && { fact hist "$1" "$nd" logs; note_hist "$1" "$nd"; break; } done done } sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' else openssl dgst -sha256 "$1" 2>/dev/null | awk '{print $NF}'; fi } vsix_cache() { # editor dir [ -d "$2" ] || return for f in "$2"/*; do [ -f "$f" ] || continue n="$(basename "$f")" for nd in $NEEDLES; do case "$n" in "$nd"*) fact vsix "$1" "$n" "$(sha256_of "$f")" ;; esac done done } # The main file of an installed copy of a version an incident names: # its SHA-256, and whether the published attacker strings are in it. HASHED=""; BACKDOORED="" payload_scan() { # editor dir file "needle needle" string... local ed="$1" dir="$2" file="$3" nds="$4" nd d n hit s; shift 4 [ -d "$dir" ] || return for nd in $nds; do for d in "$dir/$nd"*/; do [ -f "$d$file" ] && [ -r "$d$file" ] || continue n="$(basename "$d")"; hit=no for s in "$@"; do grep -qF -- "$s" "$d$file" 2>/dev/null && { hit=yes; break; }; done [ "$hit" = yes ] && BACKDOORED="${BACKDOORED} ${n}" fact payload "$ed" "$n" "$file" "$(sha256_of "$d$file")" "$hit" HASHED="${HASHED} ${B}${n}${R} ${D}${file} hashed: the hash goes, not the file${R}${NL}" done done } list_exts cursor "$HOME/.cursor/extensions" list_exts cursor "$HOME/.cursor-server/extensions" vsix_cache cursor "$HOME/Library/Application Support/Cursor/CachedExtensionVSIXs" vsix_cache cursor "$HOME/.config/Cursor/CachedExtensionVSIXs" log_history cursor "$HOME/Library/Application Support/Cursor/logs" log_history cursor "$HOME/.config/Cursor/logs" payload_scan cursor "$HOME/.cursor/extensions" extension.js "anthropic.claude-code-2.1.312 anthropic.claude-code-2.1.313" 134.209.82.225 svc-telemetry payload_scan cursor "$HOME/.cursor-server/extensions" extension.js "anthropic.claude-code-2.1.312 anthropic.claude-code-2.1.313" 134.209.82.225 svc-telemetry list_exts vscode "$HOME/.vscode/extensions" list_exts vscode "$HOME/.vscode-server/extensions" vsix_cache vscode "$HOME/Library/Application Support/Code/CachedExtensionVSIXs" vsix_cache vscode "$HOME/.config/Code/CachedExtensionVSIXs" log_history vscode "$HOME/Library/Application Support/Code/logs" log_history vscode "$HOME/.config/Code/logs" payload_scan vscode "$HOME/.vscode/extensions" extension.js "anthropic.claude-code-2.1.312 anthropic.claude-code-2.1.313" 134.209.82.225 svc-telemetry payload_scan vscode "$HOME/.vscode-server/extensions" extension.js "anthropic.claude-code-2.1.312 anthropic.claude-code-2.1.313" 134.209.82.225 svc-telemetry list_exts vscode_insiders "$HOME/.vscode-insiders/extensions" payload_scan vscode_insiders "$HOME/.vscode-insiders/extensions" extension.js "anthropic.claude-code-2.1.312 anthropic.claude-code-2.1.313" 134.209.82.225 svc-telemetry list_exts windsurf "$HOME/.windsurf/extensions" vsix_cache windsurf "$HOME/Library/Application Support/Windsurf/CachedExtensionVSIXs" log_history windsurf "$HOME/Library/Application Support/Windsurf/logs" log_history windsurf "$HOME/.config/Windsurf/logs" payload_scan windsurf "$HOME/.windsurf/extensions" extension.js "anthropic.claude-code-2.1.312 anthropic.claude-code-2.1.313" 134.209.82.225 svc-telemetry list_exts vscodium "$HOME/.vscode-oss/extensions" payload_scan vscodium "$HOME/.vscode-oss/extensions" extension.js "anthropic.claude-code-2.1.312 anthropic.claude-code-2.1.313" 134.209.82.225 svc-telemetry list_exts kiro "$HOME/.kiro/extensions" payload_scan kiro "$HOME/.kiro/extensions" extension.js "anthropic.claude-code-2.1.312 anthropic.claude-code-2.1.313" 134.209.82.225 svc-telemetry list_exts trae "$HOME/.trae/extensions" payload_scan trae "$HOME/.trae/extensions" extension.js "anthropic.claude-code-2.1.312 anthropic.claude-code-2.1.313" 134.209.82.225 svc-telemetry say " ${B}Editor extensions${R} ${D}${EXT_COUNT} found, ids and versions only${R}" [ -n "$HASHED" ] && printf '%s' "$HASHED" # ── Browser extensions: ids only ───────────────────────────────── BEXT_COUNT=0 BSEEN=" " BNAMES="" add_bext() { # browser id version case "$BSEEN" in *" $1:$2 "*) return ;; esac BSEEN="$BSEEN$1:$2 " case " $BNAMES " in *" $1 "*) ;; *) BNAMES="${BNAMES:+$BNAMES }$1" ;; esac fact bext "$1" "$2" "$3"; BEXT_COUNT=$((BEXT_COUNT + 1)) } list_chromium() { # browser root: //Extensions//_0 [ -d "$2" ] || return for ed in "$2"/*/Extensions; do [ -d "$ed" ] || continue for idd in "$ed"/*/; do id="$(basename "$idd")" [ "${#id}" -eq 32 ] || continue case "$id" in *[!a-p]*) continue ;; esac ver="" for vd in "$idd"*/; do [ -d "$vd" ] && ver="$(basename "$vd")"; done add_bext "$1" "$id" "${ver%_*}" done done } list_firefox() { # browser root: //extensions/.xpi [ -d "$2" ] || return for x in "$2"/*/extensions/*.xpi; do [ -f "$x" ] || continue add_bext "$1" "$(basename "$x" .xpi)" "" done } list_chromium chrome "$HOME/Library/Application Support/Google/Chrome" list_chromium chrome "$HOME/.config/google-chrome" list_chromium chrome "$HOME/.var/app/com.google.Chrome/config/google-chrome" list_chromium edge "$HOME/Library/Application Support/Microsoft Edge" list_chromium edge "$HOME/.config/microsoft-edge" list_chromium brave "$HOME/Library/Application Support/BraveSoftware/Brave-Browser" list_chromium brave "$HOME/.config/BraveSoftware/Brave-Browser" list_chromium arc "$HOME/Library/Application Support/Arc/User Data" list_chromium dia "$HOME/Library/Application Support/Dia/User Data" list_chromium comet "$HOME/Library/Application Support/Comet" list_chromium vivaldi "$HOME/Library/Application Support/Vivaldi" list_chromium vivaldi "$HOME/.config/vivaldi" list_chromium chromium "$HOME/Library/Application Support/Chromium" list_chromium chromium "$HOME/.config/chromium" list_chromium chromium "$HOME/snap/chromium/common/chromium" list_firefox firefox "$HOME/Library/Application Support/Firefox/Profiles" list_firefox firefox "$HOME/.mozilla/firefox" list_firefox firefox "$HOME/snap/firefox/common/.mozilla/firefox" say " ${B}Browser extensions${R} ${D}${BEXT_COUNT} found${BNAMES:+ in $BNAMES}, ids and versions only${R}" say "" # ── Incident traces: yes / no, plus the name of what matched ───── say " ${B}Claude Code 2.1.312 and 2.1.313${R} ${D}incident traces${R}" run_check ssh_key_comment "darwin linux" "Attacker SSH key svc-telemetry in authorized_keys" contains_any "svc-telemetry" "$HOME/.ssh/authorized_keys" "$HOME/.ssh/authorized_keys2" run_check ssh_key_fingerprint "darwin linux windows" "Attacker SSH key fingerprint in authorized_keys" fingerprint_any "SHA256:0Z6R+DF64Do2nwQic8a7kkfDLNNk4IiK+2U2Rci4htk" "$HOME/.ssh/authorized_keys" "$HOME/.ssh/authorized_keys2" run_check mac_launch_agent "darwin" "Fake Apple LaunchAgent com.apple.sysmond.plist" exists_any "$HOME/Library/LaunchAgents/com.apple.sysmond.plist" run_check mac_launchctl "darwin" "Reverse-tunnel service com.apple.sysmond loaded" launchctl_has "com.apple.sysmond" run_check unix_tunnel_files "darwin linux" "Tunnel files ~/.config/.smt and ~/.config/.smtk" exists_any "$HOME/.config/.smt" "$HOME/.config/.smtk" run_check unix_stage_files "darwin linux" "Staging files .stk_* in the temp directory" exists_any "$TMPD"/.stk_* "/tmp"/.stk_* run_check cron_entry "darwin linux" "Cron entry running the tunnel script .config/.smt" crontab_has ".config/.smt" run_check unix_shell_rc "darwin linux" "Shell start-up file running the tunnel script" contains_any ".config/.smt" "$HOME/.zshrc" "$HOME/.zprofile" "$HOME/.zshenv" "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile" "$HOME/.config/fish/config.fish" run_check unix_persistence_renamed "darwin linux" "A LaunchAgent systemd unit or autostart entry running the tunnel script under another name" contains_any ".config/.smt" "$HOME/Library/LaunchAgents"/*.plist "$HOME/.config/systemd/user"/*.service "$HOME/.config/autostart"/*.desktop run_check tunnel_key_anywhere "darwin linux windows" "The reverse-tunnel key by fingerprint among your SSH keys" fingerprint_any "SHA256:KOAU57+ZG6u3MG4WwzPzPX2O5YEe9qdOC1Bl+6MVyDQ" "$HOME/.ssh"/* "$HOME/.config/.smtk" run_check tunnel_process "darwin linux windows" "Reverse SSH tunnel process to the attacker server" process_has "tunnel@134.209.82.225" run_check c2_connection "darwin linux windows" "Live connection to the attacker server 134.209.82.225" connected_to "134.209.82.225" run_check ext_code_ioc "darwin linux windows" "The attacker server address inside the code of another installed extension" extensions_contain "134.209.82.225" say "" # ── AI tools and API-key variable NAMES (never values) ─────────── TOOLS="" for b in claude codex cursor-agent gemini aider goose opencode amp copilot ollama lms; do command -v "$b" >/dev/null 2>&1 && { fact tool "$b"; TOOLS="$TOOLS $b"; } done if [ "$OS" = darwin ]; then for a in "Cursor" "Windsurf" "Claude" "ChatGPT" "Visual Studio Code" "Kiro" "Trae" "Zed" "Ollama" "LM Studio"; do if [ -d "/Applications/$a.app" ] || [ -d "$HOME/Applications/$a.app" ]; then fact tool "app:$a"; TOOLS="$TOOLS app:$a"; fi done fi ENV_RE='^(OPENAI|ANTHROPIC|GEMINI|GOOGLE|MISTRAL|GROQ|OPENROUTER|TOGETHER|FIREWORKS|DEEPSEEK|XAI|COHERE|HF|HUGGINGFACE|AWS|AZURE|GITHUB|GH|GITLAB|NPM|PYPI|STRIPE|SLACK|DATABRICKS|SNOWFLAKE)[A-Z0-9_]*(_API_KEY|_API_TOKEN|_ACCESS_TOKEN|_SECRET_KEY)$' ENVS="$( { env | cut -d= -f1; grep -hoE '^[[:space:]]*export[[:space:]]+[A-Z][A-Z0-9_]*=' "$HOME/.zshrc" "$HOME/.zprofile" "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile" 2>/dev/null | sed -E 's/^[[:space:]]*export[[:space:]]+//; s/=$//'; } | grep -E "$ENV_RE" | sort -u)" for e in $ENVS; do fact env "$e"; done say " ${B}AI tools${R} ${D}${TOOLS:- none found}${R}" KEYN="$(echo $ENVS)" say " ${B}Key variable names${R} ${D}${KEYN:-none found}${R}" say "" # ── The readable copy: no token, so nothing to send to ─────────── # Its own summary of the incident traces, then out, before the # consent prompt. The list match needs a check (https://ciphero.ai/cassandra). if [ "$CASSANDRA_TOKEN" = "YOUR-CHECK-TOKEN" ]; then if [ -n "$BACKDOORED" ]; then say " ${RED}${B}■ BACKDOORED BUILD${R} ${B}Installed, and its main file contains the attacker strings:${BACKDOORED}${R}" fi if [ -n "$HISTORY" ]; then say " ${RED}${B}■ BAD VERSION WAS INSTALLED${R} ${B}In the editor's install history:${HISTORY}.${R} An update may have removed it since; what it did while installed is not undone." fi if [ "$FOUND" -gt 0 ]; then say " ${RED}${B}■ TRACES FOUND${R} ${B}${FOUND} of ${CHECKS} published indicators were found on this machine.${R}" elif [ -z "${BACKDOORED}${HISTORY}" ]; then say " ${GRN}${B}■ NO TRACES${R} ${B}None of the ${CHECKS} published indicators were found on this machine.${R}" else say " ${B}None of the ${CHECKS} other published indicators were found.${R}" fi if [ "$FOUND" -gt 0 ] || [ -n "$BACKDOORED" ]; then say " ${GLD}→${R} Treat it as compromised. Steps, per incident: https://ciphero.ai/advisories" elif [ -n "$HISTORY" ]; then say " ${GLD}→${R} Treat the machine as exposed: rotate the keys and tokens it used, from another machine. Steps: https://ciphero.ai/advisories" fi say "" say " ${D}This is the readable copy of the script. It carries no check token, so nothing was sent${R}" say " ${D}and everything above ran here. It did not match your ${EXT_COUNT} editor and ${BEXT_COUNT} browser${R}" say " ${D}extensions against the known-malicious lists or the marketplaces. For that, and a${R}" say " ${D}written report: https://ciphero.ai/cassandra${R}" say "" exit 0 fi # ── Consent: nothing goes until you type yes ───────────────────── NFACTS="$(printf '%s' "$OUT" | grep -c .)" say " ${B}Ready to send ${NFACTS} facts to Ciphero.${R} ${D}No file contents, no key values.${R}" say " ${D}Sending accepts Ciphero's terms and privacy policy: https://ciphero.ai/terms#cassandra${R}" ANSWER="" while :; do if [ "${CASSANDRA_YES:-}" = 1 ]; then ANSWER=y elif (exec < /dev/tty) 2>/dev/null; then printf ' Send them? [y]es / [n]o / [s]how every line: ' > /dev/tty read -r ANSWER < /dev/tty || ANSWER=n else say " No terminal to ask on. Re-run with CASSANDRA_YES=1 to send without asking."; exit 1; fi case "$ANSWER" in s|S|show) printf '%s' "$OUT" | sed 's/^/ /' ;; n|N|no|No|NO) say " Nothing sent."; exit 0 ;; y|Y|yes|Yes|YES) break ;; *) say " ${D}Please answer y, n or s.${R}" ;; esac done RESP="$(printf '%s' "$OUT" | curl -fsS --max-time 40 -X POST -H 'Content-Type: text/plain' --data-binary @- "$CASSANDRA_URL" 2>&1)" || { say " ${RED}Upload failed:${R} $RESP"; exit 1; } say "" printf '%s\n' "$RESP" | while IFS='|' read -r level text; do case "$level" in critical) say " ${RED}${B}■ CRITICAL${R} ${B}$text${R}" ;; high) say " ${AMB}${B}■ HIGH${R} ${B}$text${R}" ;; caution) say " ${YEL}${B}■ CAUTION${R} ${B}$text${R}" ;; clear) say " ${GRN}${B}■ CLEAR${R} ${B}$text${R}" ;; step) say " ${GLD}→${R} $text" ;; error) say " ${RED}${B}■${R} $text" ;; *) say " ${D}$text${R}" ;; esac done say "" } main