{"uuid": "d3ec4a03-5376-4c0c-9509-c251d7a20722", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "GHSA-g7cv-rxg3-hmpx", "type": "seen", "source": "https://gist.github.com/srijanshukla18/49821c8704c4222e2f84221b21340e3f", "content": "#!/usr/bin/env python3\n\"\"\"\nRead-only Mini Shai-Hulud / TanStack campaign scanner.\n\nSources checked 2026-05-13:\n- TanStack GHSA-g7cv-rxg3-hmpx\n- Wiz \"Mini Shai-Hulud Strikes Again\"\n- SafeDep \"Mass Supply Chain Attack Hits TanStack, Mistral AI npm and PyPI Packages\"\n- Snyk \"TanStack npm packages compromised\"\n\nExit codes:\n  0: no known indicators found\n  1: likely affected / exposed / suspicious indicators found\n  2: scanner error\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport hashlib\nimport importlib.metadata\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Iterable\n\n\nAFFECTED_NPM: dict[str, set[str]] = {\n    \"@tanstack/arktype-adapter\": {\"1.166.12\", \"1.166.15\"},\n    \"@tanstack/eslint-plugin-router\": {\"1.161.9\", \"1.161.12\"},\n    \"@tanstack/eslint-plugin-start\": {\"0.0.4\", \"0.0.7\"},\n    \"@tanstack/history\": {\"1.161.9\", \"1.161.12\"},\n    \"@tanstack/nitro-v2-vite-plugin\": {\"1.154.12\", \"1.154.15\"},\n    \"@tanstack/react-router\": {\"1.169.5\", \"1.169.8\"},\n    \"@tanstack/react-router-devtools\": {\"1.166.16\", \"1.166.19\"},\n    \"@tanstack/react-router-ssr-query\": {\"1.166.15\", \"1.166.18\"},\n    \"@tanstack/react-start\": {\"1.167.68\", \"1.167.71\"},\n    \"@tanstack/react-start-client\": {\"1.166.51\", \"1.166.54\"},\n    \"@tanstack/react-start-rsc\": {\"0.0.47\", \"0.0.50\"},\n    \"@tanstack/react-start-server\": {\"1.166.55\", \"1.166.58\"},\n    \"@tanstack/router-cli\": {\"1.166.46\", \"1.166.49\"},\n    \"@tanstack/router-core\": {\"1.169.5\", \"1.169.8\"},\n    \"@tanstack/router-devtools\": {\"1.166.16\", \"1.166.19\"},\n    \"@tanstack/router-devtools-core\": {\"1.167.6\", \"1.167.9\"},\n    \"@tanstack/router-generator\": {\"1.166.45\", \"1.166.48\"},\n    \"@tanstack/router-plugin\": {\"1.167.38\", \"1.167.41\"},\n    \"@tanstack/router-ssr-query-core\": {\"1.168.3\", \"1.168.6\"},\n    \"@tanstack/router-utils\": {\"1.161.11\", \"1.161.14\"},\n    \"@tanstack/router-vite-plugin\": {\"1.166.53\", \"1.166.56\"},\n    \"@tanstack/solid-router\": {\"1.169.5\", \"1.169.8\"},\n    \"@tanstack/solid-router-devtools\": {\"1.166.16\", \"1.166.19\"},\n    \"@tanstack/solid-router-ssr-query\": {\"1.166.15\", \"1.166.18\"},\n    \"@tanstack/solid-start\": {\"1.167.65\", \"1.167.68\"},\n    \"@tanstack/solid-start-client\": {\"1.166.50\", \"1.166.53\"},\n    \"@tanstack/solid-start-server\": {\"1.166.54\", \"1.166.57\"},\n    \"@tanstack/start-client-core\": {\"1.168.5\", \"1.168.8\"},\n    \"@tanstack/start-fn-stubs\": {\"1.161.9\", \"1.161.12\"},\n    \"@tanstack/start-plugin-core\": {\"1.169.23\", \"1.169.26\"},\n    \"@tanstack/start-server-core\": {\"1.167.33\", \"1.167.36\"},\n    \"@tanstack/start-static-server-functions\": {\"1.166.44\", \"1.166.47\"},\n    \"@tanstack/start-storage-context\": {\"1.166.38\", \"1.166.41\"},\n    \"@tanstack/valibot-adapter\": {\"1.166.12\", \"1.166.15\"},\n    \"@tanstack/virtual-file-routes\": {\"1.161.10\", \"1.161.13\"},\n    \"@tanstack/vue-router\": {\"1.169.5\", \"1.169.8\"},\n    \"@tanstack/vue-router-devtools\": {\"1.166.16\", \"1.166.19\"},\n    \"@tanstack/vue-router-ssr-query\": {\"1.166.15\", \"1.166.18\"},\n    \"@tanstack/vue-start\": {\"1.167.61\", \"1.167.64\"},\n    \"@tanstack/vue-start-client\": {\"1.166.46\", \"1.166.49\"},\n    \"@tanstack/vue-start-server\": {\"1.166.50\", \"1.166.53\"},\n    \"@tanstack/zod-adapter\": {\"1.166.12\", \"1.166.15\"},\n    \"@mistralai/mistralai\": {\"2.2.2\", \"2.2.3\", \"2.2.4\"},\n    \"@mistralai/mistralai-azure\": {\"1.7.1\", \"1.7.2\", \"1.7.3\"},\n    \"@mistralai/mistralai-gcp\": {\"1.7.1\", \"1.7.2\", \"1.7.3\"},\n    \"@uipath/access-policy-sdk\": {\"0.3.1\"},\n    \"@uipath/access-policy-tool\": {\"0.3.1\"},\n    \"@uipath/admin-tool\": {\"0.1.1\"},\n    \"@uipath/agent-sdk\": {\"1.0.2\"},\n    \"@uipath/agent-tool\": {\"1.0.1\"},\n    \"@uipath/agent.sdk\": {\"0.0.18\"},\n    \"@uipath/aops-policy-tool\": {\"0.3.1\"},\n    \"@uipath/ap-chat\": {\"1.5.7\"},\n    \"@uipath/api-workflow-tool\": {\"1.0.1\"},\n    \"@uipath/apollo-core\": {\"5.9.2\"},\n    \"@uipath/apollo-react\": {\"4.24.5\"},\n    \"@uipath/apollo-wind\": {\"2.16.2\"},\n    \"@uipath/auth\": {\"1.0.1\"},\n    \"@uipath/case-tool\": {\"1.0.1\"},\n    \"@uipath/cli\": {\"1.0.1\"},\n    \"@uipath/codedagent-tool\": {\"1.0.1\"},\n    \"@uipath/codedagents-tool\": {\"0.1.12\"},\n    \"@uipath/codedapp-tool\": {\"1.0.1\"},\n    \"@uipath/common\": {\"1.0.1\"},\n    \"@uipath/context-grounding-tool\": {\"0.1.1\"},\n    \"@uipath/data-fabric-tool\": {\"1.0.2\"},\n    \"@uipath/docsai-tool\": {\"1.0.1\"},\n    \"@uipath/filesystem\": {\"1.0.1\"},\n    \"@uipath/flow-tool\": {\"1.0.2\"},\n    \"@uipath/functions-tool\": {\"1.0.1\"},\n    \"@uipath/gov-tool\": {\"0.3.1\"},\n    \"@uipath/identity-tool\": {\"0.1.1\"},\n    \"@uipath/insights-sdk\": {\"1.0.1\"},\n    \"@uipath/insights-tool\": {\"1.0.1\"},\n    \"@uipath/integrationservice-sdk\": {\"1.0.2\"},\n    \"@uipath/integrationservice-tool\": {\"1.0.2\"},\n    \"@uipath/llmgw-tool\": {\"1.0.1\"},\n    \"@uipath/maestro-sdk\": {\"1.0.1\"},\n    \"@uipath/maestro-tool\": {\"1.0.1\"},\n    \"@uipath/orchestrator-tool\": {\"1.0.1\"},\n    \"@uipath/packager-tool-apiworkflow\": {\"0.0.19\"},\n    \"@uipath/packager-tool-bpmn\": {\"0.0.9\"},\n    \"@uipath/packager-tool-case\": {\"0.0.9\"},\n    \"@uipath/packager-tool-connector\": {\"0.0.19\"},\n    \"@uipath/packager-tool-flow\": {\"0.0.19\"},\n    \"@uipath/packager-tool-functions\": {\"0.1.1\"},\n    \"@uipath/packager-tool-webapp\": {\"1.0.6\"},\n    \"@uipath/packager-tool-workflowcompiler\": {\"0.0.16\"},\n    \"@uipath/packager-tool-workflowcompiler-browser\": {\"0.0.34\"},\n    \"@uipath/platform-tool\": {\"1.0.1\"},\n    \"@uipath/project-packager\": {\"1.1.16\"},\n    \"@uipath/resource-tool\": {\"1.0.1\"},\n    \"@uipath/resourcecatalog-tool\": {\"0.1.1\"},\n    \"@uipath/resources-tool\": {\"0.1.11\"},\n    \"@uipath/robot\": {\"1.3.4\"},\n    \"@uipath/rpa-legacy-tool\": {\"1.0.1\"},\n    \"@uipath/rpa-tool\": {\"0.9.5\"},\n    \"@uipath/solution-packager\": {\"0.0.35\"},\n    \"@uipath/solution-tool\": {\"1.0.1\"},\n    \"@uipath/solutionpackager-sdk\": {\"1.0.11\"},\n    \"@uipath/solutionpackager-tool-core\": {\"0.0.34\"},\n    \"@uipath/tasks-tool\": {\"1.0.1\"},\n    \"@uipath/telemetry\": {\"0.0.7\"},\n    \"@uipath/test-manager-tool\": {\"1.0.2\"},\n    \"@uipath/tool-workflowcompiler\": {\"0.0.12\"},\n    \"@uipath/traces-tool\": {\"1.0.1\"},\n    \"@uipath/ui-widgets-multi-file-upload\": {\"1.0.1\"},\n    \"@uipath/uipath-python-bridge\": {\"1.0.1\"},\n    \"@uipath/vertical-solutions-tool\": {\"1.0.1\"},\n    \"@uipath/vss\": {\"0.1.6\"},\n    \"@uipath/widget.sdk\": {\"1.2.3\"},\n    \"@squawk/airport-data\": {\"0.7.4\", \"0.7.5\", \"0.7.6\", \"0.7.7\", \"0.7.8\"},\n    \"@squawk/airports\": {\"0.6.2\", \"0.6.3\", \"0.6.4\", \"0.6.5\", \"0.6.6\"},\n    \"@squawk/airspace\": {\"0.8.1\", \"0.8.2\", \"0.8.3\", \"0.8.4\", \"0.8.5\"},\n    \"@squawk/airspace-data\": {\"0.5.3\", \"0.5.4\", \"0.5.5\", \"0.5.6\", \"0.5.7\"},\n    \"@squawk/airway-data\": {\"0.5.4\", \"0.5.5\", \"0.5.6\", \"0.5.7\", \"0.5.8\"},\n    \"@squawk/airways\": {\"0.4.2\", \"0.4.3\", \"0.4.4\", \"0.4.5\", \"0.4.6\"},\n    \"@squawk/fix-data\": {\"0.6.4\", \"0.6.5\", \"0.6.6\", \"0.6.7\", \"0.6.8\"},\n    \"@squawk/fixes\": {\"0.3.2\", \"0.3.3\", \"0.3.4\", \"0.3.5\", \"0.3.6\"},\n    \"@squawk/flight-math\": {\"0.5.4\", \"0.5.5\", \"0.5.6\", \"0.5.7\", \"0.5.8\"},\n    \"@squawk/flightplan\": {\"0.5.2\", \"0.5.3\", \"0.5.4\", \"0.5.5\", \"0.5.6\"},\n    \"@squawk/geo\": {\"0.4.4\", \"0.4.5\", \"0.4.6\", \"0.4.7\", \"0.4.8\"},\n    \"@squawk/icao-registry\": {\"0.5.2\", \"0.5.3\", \"0.5.4\", \"0.5.5\", \"0.5.6\"},\n    \"@squawk/icao-registry-data\": {\"0.8.4\", \"0.8.5\", \"0.8.6\", \"0.8.7\", \"0.8.8\"},\n    \"@squawk/mcp\": {\"0.9.1\", \"0.9.2\", \"0.9.3\", \"0.9.4\", \"0.9.5\"},\n    \"@squawk/navaid-data\": {\"0.6.4\", \"0.6.5\", \"0.6.6\", \"0.6.7\", \"0.6.8\"},\n    \"@squawk/navaids\": {\"0.4.2\", \"0.4.3\", \"0.4.4\", \"0.4.5\", \"0.4.6\"},\n    \"@squawk/notams\": {\"0.3.6\", \"0.3.7\", \"0.3.8\", \"0.3.9\", \"0.3.10\"},\n    \"@squawk/procedure-data\": {\"0.7.3\", \"0.7.4\", \"0.7.5\", \"0.7.6\", \"0.7.7\"},\n    \"@squawk/procedures\": {\"0.5.2\", \"0.5.3\", \"0.5.4\", \"0.5.5\", \"0.5.6\"},\n    \"@squawk/types\": {\"0.8.1\", \"0.8.2\", \"0.8.3\", \"0.8.4\", \"0.8.5\"},\n    \"@squawk/units\": {\"0.4.3\", \"0.4.4\", \"0.4.5\", \"0.4.6\", \"0.4.7\"},\n    \"@squawk/weather\": {\"0.5.6\", \"0.5.7\", \"0.5.8\", \"0.5.9\", \"0.5.10\"},\n    \"@tallyui/components\": {\"1.0.1\", \"1.0.2\", \"1.0.3\"},\n    \"@tallyui/connector-medusa\": {\"1.0.1\", \"1.0.2\", \"1.0.3\"},\n    \"@tallyui/connector-shopify\": {\"1.0.1\", \"1.0.2\", \"1.0.3\"},\n    \"@tallyui/connector-vendure\": {\"1.0.1\", \"1.0.2\", \"1.0.3\"},\n    \"@tallyui/connector-woocommerce\": {\"1.0.1\", \"1.0.2\", \"1.0.3\"},\n    \"@tallyui/core\": {\"0.2.1\", \"0.2.2\", \"0.2.3\"},\n    \"@tallyui/database\": {\"1.0.1\", \"1.0.2\", \"1.0.3\"},\n    \"@tallyui/pos\": {\"0.1.1\", \"0.1.2\", \"0.1.3\"},\n    \"@tallyui/storage-sqlite\": {\"0.2.1\", \"0.2.2\", \"0.2.3\"},\n    \"@tallyui/theme\": {\"0.2.1\", \"0.2.2\", \"0.2.3\"},\n    \"@beproduct/nestjs-auth\": {\n        \"0.1.2\", \"0.1.3\", \"0.1.4\", \"0.1.5\", \"0.1.6\", \"0.1.7\",\n        \"0.1.8\", \"0.1.9\", \"0.1.10\", \"0.1.11\", \"0.1.12\", \"0.1.13\",\n        \"0.1.14\", \"0.1.15\", \"0.1.16\", \"0.1.17\", \"0.1.18\", \"0.1.19\",\n    },\n    \"@draftauth/client\": {\"0.2.1\", \"0.2.2\"},\n    \"@draftauth/core\": {\"0.13.1\", \"0.13.2\"},\n    \"@draftlab/auth\": {\"0.24.1\", \"0.24.2\"},\n    \"@draftlab/auth-router\": {\"0.5.1\", \"0.5.2\"},\n    \"@draftlab/db\": {\"0.16.1\", \"0.16.2\"},\n    \"@supersurkhet/cli\": {\"0.0.2\", \"0.0.3\", \"0.0.4\", \"0.0.5\", \"0.0.6\", \"0.0.7\"},\n    \"@supersurkhet/sdk\": {\"0.0.2\", \"0.0.3\", \"0.0.4\", \"0.0.5\", \"0.0.6\", \"0.0.7\"},\n    \"@taskflow-corp/cli\": {\"0.1.24\", \"0.1.25\", \"0.1.26\", \"0.1.27\", \"0.1.28\", \"0.1.29\"},\n    \"@tolka/cli\": {\"1.0.2\", \"1.0.3\", \"1.0.4\", \"1.0.5\", \"1.0.6\"},\n    \"@mesadev/rest\": {\"0.28.3\"},\n    \"@mesadev/saguaro\": {\"0.4.22\"},\n    \"@mesadev/sdk\": {\"0.28.3\"},\n    \"@ml-toolkit-ts/preprocessing\": {\"1.0.2\", \"1.0.3\"},\n    \"@ml-toolkit-ts/xgboost\": {\"1.0.3\", \"1.0.4\"},\n    \"@dirigible-ai/sdk\": {\"0.6.2\", \"0.6.3\"},\n    \"@opensearch-project/opensearch\": {\"3.5.3\", \"3.6.2\", \"3.7.0\", \"3.8.0\"},\n    \"agentwork-cli\": {\"0.1.4\", \"0.1.5\"},\n    \"cmux-agent-mcp\": {\"0.1.3\", \"0.1.4\", \"0.1.5\", \"0.1.6\", \"0.1.7\", \"0.1.8\"},\n    \"cross-stitch\": {\"1.1.3\", \"1.1.4\", \"1.1.5\", \"1.1.6\", \"1.1.7\"},\n    \"git-branch-selector\": {\"1.3.3\", \"1.3.4\", \"1.3.5\", \"1.3.6\", \"1.3.7\"},\n    \"git-git-git\": {\"1.0.8\", \"1.0.9\", \"1.0.10\", \"1.0.11\", \"1.0.12\"},\n    \"ml-toolkit-ts\": {\"1.0.4\", \"1.0.5\"},\n    \"nextmove-mcp\": {\"0.1.3\", \"0.1.4\", \"0.1.5\", \"0.1.6\", \"0.1.7\"},\n    \"safe-action\": {\"0.8.3\", \"0.8.4\"},\n    \"ts-dna\": {\"3.0.1\", \"3.0.2\", \"3.0.3\", \"3.0.4\", \"3.0.5\"},\n    \"wot-api\": {\"0.8.1\", \"0.8.2\", \"0.8.3\", \"0.8.4\"},\n}\n\nAFFECTED_PYPI: dict[str, set[str]] = {\n    \"mistralai\": {\"2.4.6\"},\n    \"guardrails-ai\": {\"0.10.1\"},\n}\n\nKNOWN_SHA256 = {\n    \"ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c\": \"router_init.js\",\n    \"2ec78d556d696e208927cc503d48e4b5eb56b31abc2870c2ed2e98d6be27fc96\": \"router_init.js/tanstack_runner.js\",\n    \"2258284d65f63829bd67eaba01ef6f1ada2f593f9bbe41678b2df360bd90d3df\": \"setup.mjs\",\n    \"1e8538c6e0563d50da0f2e097e979ebd5294ce1defe01d0b9fe361ba3bed1898\": \"trojanized tarball\",\n}\nKNOWN_SHA1 = {\n    \"e7d582b98ca80690883175470e96f703ef6dc497\": \"router_init.js/tanstack_runner.js\",\n    \"12f35b1081b17d21815b35feb57ab03d02482116\": \"setup.mjs\",\n    \"820fa07a7328b6cf2b417078e103721d4d8f2e79\": \"opensearch_init.js\",\n}\n\nIOC_RE = re.compile(\n    r\"(\"\n    r\"79ac49eedf774dd4b0cfa308722bc463cfe5885c|\"\n    r\"@tanstack/setup|github:tanstack/router|\"\n    r\"router_init\\.js|router_runtime\\.js|tanstack_runner\\.js|opensearch_init\\.js|\"\n    r\"git-tanstack\\.com|83\\.142\\.209\\.194|filev2\\.getsession\\.org|\"\n    r\"seed[123]\\.getsession\\.org|api\\.masscan\\.cloud|litter\\.catbox\\.moe|\"\n    r\"gh-token-monitor|IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner|\"\n    r\"svksjrhjkcejg\"\n    r\")\",\n    re.IGNORECASE,\n)\n\nSUSPICIOUS_FILENAMES = {\n    \"router_init.js\",\n    \"router_runtime.js\",\n    \"tanstack_runner.js\",\n    \"opensearch_init.js\",\n    \"transformers.pyz\",\n    \"gh-token-monitor.sh\",\n    \"com.user.gh-token-monitor.plist\",\n}\n\nLOCKFILE_NAMES = {\n    \"package-lock.json\",\n    \"npm-shrinkwrap.json\",\n    \"pnpm-lock.yaml\",\n    \"yarn.lock\",\n    \"package.json\",\n    \"poetry.lock\",\n    \"uv.lock\",\n    \"Pipfile.lock\",\n    \"pyproject.toml\",\n}\n\nSKIP_DIRS = {\n    \".git\",\n    \".hg\",\n    \".svn\",\n    \".cache\",\n    \".Trash\",\n    \"Trash\",\n    \"__pycache__\",\n    \".mypy_cache\",\n    \".pytest_cache\",\n    \".next\",\n    \".turbo\",\n    \".parcel-cache\",\n    \"dist\",\n    \"build\",\n    \"target\",\n}\n\n\n@dataclass(frozen=True)\nclass Finding:\n    severity: str\n    category: str\n    path: str\n    detail: str\n\n\nclass Scanner:\n    def __init__(self, roots: list[Path], max_file_mb: int = 25, verbose: bool = False) -&gt; None:\n        self.roots = roots\n        self.max_file_bytes = max_file_mb * 1024 * 1024\n        self.verbose = verbose\n        self.findings: list[Finding] = []\n        self.errors: list[str] = []\n        self.node_modules_dirs: set[Path] = set()\n        self.seen_files: set[Path] = set()\n\n    def add(self, severity: str, category: str, path: Path | str, detail: str) -&gt; None:\n        self.findings.append(Finding(severity, category, str(path), detail))\n\n    def error(self, path: Path | str, detail: str) -&gt; None:\n        self.errors.append(f\"{path}: {detail}\")\n\n    def scan(self) -&gt; int:\n        self.scan_known_paths()\n        self.scan_processes()\n        self.scan_active_python_env()\n        self.walk_roots()\n        self.add_global_node_modules_dirs()\n        self.scan_node_modules_dirs()\n        self.print_report()\n        if self.has_actionable_findings():\n            return 1\n        return 0\n\n    def has_actionable_findings(self) -&gt; bool:\n        return any(f.severity in {\"CRITICAL\", \"HIGH\", \"MEDIUM\"} for f in self.findings)\n\n    def scan_known_paths(self) -&gt; None:\n        home = Path.home()\n        known = [\n            home / \"Library/LaunchAgents/com.user.gh-token-monitor.plist\",\n            home / \".config/systemd/user/gh-token-monitor.service\",\n            home / \".local/bin/gh-token-monitor.sh\",\n            Path(\"/tmp/transformers.pyz\"),\n        ]\n        for path in known:\n            if path.exists():\n                self.add(\"CRITICAL\", \"persistence/artifact\", path, \"known Mini Shai-Hulud persistence or payload path exists\")\n                self.scan_suspicious_file(path)\n\n    def scan_processes(self) -&gt; None:\n        try:\n            proc = subprocess.run(\n                [\"ps\", \"-axo\", \"pid=,command=\"],\n                text=True,\n                capture_output=True,\n                timeout=8,\n                check=False,\n            )\n        except Exception as exc:\n            self.error(\"ps\", f\"could not inspect process list: {exc}\")\n            return\n        needles = (\"gh-token-monitor\", \"router_runtime.js\", \"router_init.js\", \"tanstack_runner.js\", \"git-tanstack.com\")\n        for line in proc.stdout.splitlines():\n            if any(n in line for n in needles):\n                if Path(__file__).name in line:\n                    continue\n                self.add(\"CRITICAL\", \"process\", \"ps\", line.strip())\n\n    def scan_active_python_env(self) -&gt; None:\n        for dist in importlib.metadata.distributions():\n            try:\n                name = norm_pypi_name(dist.metadata[\"Name\"])\n                version = dist.version\n            except Exception:\n                continue\n            self.check_pypi_version(name, version, Path(str(dist.locate_file(\"\"))), \"active Python environment\")\n\n    def walk_roots(self) -&gt; None:\n        for root in self.roots:\n            if not root.exists():\n                self.error(root, \"root does not exist\")\n                continue\n            for dirpath, dirnames, filenames in os.walk(root, followlinks=False):\n                current = Path(dirpath)\n                self.prune_dirs(current, dirnames)\n\n                if current.name == \"node_modules\":\n                    self.node_modules_dirs.add(current)\n                    dirnames[:] = []\n                    continue\n\n                self.scan_dist_info_dir(current)\n\n                for name in filenames:\n                    path = current / name\n                    if path in self.seen_files:\n                        continue\n                    self.seen_files.add(path)\n                    self.inspect_interesting_file(path)\n\n    def prune_dirs(self, current: Path, dirnames: list[str]) -&gt; None:\n        home = Path.home()\n        keep: list[str] = []\n        for name in dirnames:\n            if name in SKIP_DIRS:\n                continue\n            if current == home and name == \"Library\":\n                # Known LaunchAgent path is checked directly; the rest is too large/noisy.\n                continue\n            if name.endswith(\".photoslibrary\"):\n                continue\n            keep.append(name)\n        dirnames[:] = keep\n\n    def inspect_interesting_file(self, path: Path) -&gt; None:\n        name = path.name\n        lower_path = path.as_posix().lower()\n\n        if name == \"settings.json\" and \"/.claude/\" in lower_path:\n            self.scan_claude_settings(path)\n            return\n        if name == \"tasks.json\" and \"/.vscode/\" in lower_path:\n            self.scan_vscode_tasks(path)\n            return\n\n        if name in SUSPICIOUS_FILENAMES:\n            self.scan_suspicious_file(path)\n            return\n        if name == \"setup.mjs\" and (\"/.claude/\" in lower_path or \"/.vscode/\" in lower_path):\n            self.add(\"HIGH\", \"ai-editor-persistence\", path, \"setup.mjs under .claude/.vscode\")\n            self.scan_suspicious_file(path)\n            return\n\n        if name in LOCKFILE_NAMES or is_requirements_file(name):\n            self.scan_lock_or_manifest(path)\n\n    def scan_claude_settings(self, path: Path) -&gt; None:\n        text = self.read_text(path)\n        if text is None:\n            return\n        if IOC_RE.search(text):\n            self.add(\"CRITICAL\", \"claude-code-persistence\", path, \"Claude settings contain Mini Shai-Hulud IOC\")\n        elif re.search(r\"SessionStart\", text, re.IGNORECASE):\n            self.add(\"MEDIUM\", \"claude-code-hook\", path, \"Claude SessionStart hook exists; review manually\")\n\n    def scan_vscode_tasks(self, path: Path) -&gt; None:\n        text = self.read_text(path)\n        if text is None:\n            return\n        has_folder_open = re.search(r\"runOn[\\\"'\\s:]+folderOpen\", text, re.IGNORECASE) or (\n            \"runOn\" in text and \"folderOpen\" in text\n        )\n        if IOC_RE.search(text):\n            self.add(\"CRITICAL\", \"vscode-persistence\", path, \"VS Code tasks contain Mini Shai-Hulud IOC\")\n        elif has_folder_open:\n            self.add(\"MEDIUM\", \"vscode-folder-open-task\", path, \"VS Code folder-open task exists; review manually\")\n\n    def scan_suspicious_file(self, path: Path) -&gt; None:\n        try:\n            size = path.stat().st_size\n        except OSError as exc:\n            self.error(path, f\"stat failed: {exc}\")\n            return\n\n        if size &gt; self.max_file_bytes:\n            self.add(\"MEDIUM\", \"large-suspicious-file\", path, f\"{path.name} exists but is larger than scan limit ({size} bytes)\")\n            return\n\n        try:\n            data = path.read_bytes()\n        except OSError as exc:\n            self.error(path, f\"read failed: {exc}\")\n            return\n\n        sha256 = hashlib.sha256(data).hexdigest()\n        sha1 = hashlib.sha1(data).hexdigest()\n        if sha256 in KNOWN_SHA256:\n            self.add(\"CRITICAL\", \"malware-hash\", path, f\"SHA256 matches {KNOWN_SHA256[sha256]} ({sha256})\")\n            return\n        if sha1 in KNOWN_SHA1:\n            self.add(\"CRITICAL\", \"malware-hash\", path, f\"SHA1 matches {KNOWN_SHA1[sha1]} ({sha1})\")\n            return\n\n        text_sample = data[: min(len(data), 5 * 1024 * 1024)].decode(\"utf-8\", errors=\"ignore\")\n        if IOC_RE.search(text_sample):\n            self.add(\"CRITICAL\", \"malware-string\", path, f\"{path.name} contains Mini Shai-Hulud IOC\")\n        elif path.name in {\"router_init.js\", \"router_runtime.js\", \"tanstack_runner.js\", \"opensearch_init.js\", \"transformers.pyz\"}:\n            self.add(\"HIGH\", \"suspicious-filename\", path, f\"{path.name} exists; hash/string did not match known IOC\")\n\n    def scan_lock_or_manifest(self, path: Path) -&gt; None:\n        if path.name in {\"package-lock.json\", \"npm-shrinkwrap.json\", \"package.json\"}:\n            if self.scan_json_npm_file(path):\n                return\n        text = self.read_text(path)\n        if text is None:\n            return\n        if path.name in {\"pnpm-lock.yaml\", \"yarn.lock\", \"bun.lock\", \"package-lock.json\", \"npm-shrinkwrap.json\", \"package.json\"}:\n            self.scan_npm_text(path, text)\n        if path.name in {\"poetry.lock\", \"uv.lock\", \"Pipfile.lock\", \"pyproject.toml\"} or is_requirements_file(path.name):\n            self.scan_pypi_text(path, text)\n        if IOC_RE.search(text):\n            self.add(\"HIGH\", \"lockfile-ioc\", path, \"lockfile/manifest contains Mini Shai-Hulud IOC\")\n\n    def scan_json_npm_file(self, path: Path) -&gt; bool:\n        text = self.read_text(path)\n        if text is None:\n            return False\n        try:\n            data = json.loads(text)\n        except json.JSONDecodeError:\n            return False\n\n        if isinstance(data, dict):\n            if path.name == \"package.json\":\n                self.scan_package_manifest_json(path, data)\n            packages = data.get(\"packages\")\n            if isinstance(packages, dict):\n                for key, meta in packages.items():\n                    if not isinstance(meta, dict):\n                        continue\n                    name = meta.get(\"name\") or name_from_node_modules_key(key)\n                    version = meta.get(\"version\")\n                    if isinstance(name, str) and isinstance(version, str):\n                        self.check_npm_version(name, version, path, f\"lockfile package entry {key}\")\n                    self.scan_manifest_dependency_iocs(path, meta, f\"lockfile package entry {key}\")\n            deps = data.get(\"dependencies\")\n            if isinstance(deps, dict):\n                self.scan_dependency_tree(path, deps, \"lockfile dependencies\")\n        return True\n\n    def scan_package_manifest_json(self, path: Path, data: dict) -&gt; None:\n        name = data.get(\"name\")\n        version = data.get(\"version\")\n        if isinstance(name, str) and isinstance(version, str):\n            self.check_npm_version(name, version, path, \"package.json package identity\")\n        self.scan_manifest_dependency_iocs(path, data, \"package.json dependencies\")\n        for section in (\"dependencies\", \"devDependencies\", \"optionalDependencies\", \"peerDependencies\"):\n            deps = data.get(section)\n            if not isinstance(deps, dict):\n                continue\n            for dep, spec in deps.items():\n                if isinstance(dep, str) and isinstance(spec, str):\n                    if dep in AFFECTED_NPM and spec.lstrip(\"=v\") in AFFECTED_NPM[dep]:\n                        self.add(\"HIGH\", \"npm-manifest-exposure\", path, f\"{dep}@{spec} in {section}\")\n                    if dep == \"@tanstack/setup\" or \"github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c\" in spec:\n                        self.add(\"CRITICAL\", \"npm-manifest-ioc\", path, f\"{dep}: {spec}\")\n\n    def scan_manifest_dependency_iocs(self, path: Path, data: dict, context: str) -&gt; None:\n        for section in (\"dependencies\", \"devDependencies\", \"optionalDependencies\", \"peerDependencies\"):\n            deps = data.get(section)\n            if not isinstance(deps, dict):\n                continue\n            for dep, spec in deps.items():\n                joined = f\"{dep}: {spec}\"\n                if IOC_RE.search(joined):\n                    self.add(\"CRITICAL\", \"npm-dependency-ioc\", path, f\"{context}: {joined}\")\n\n    def scan_dependency_tree(self, path: Path, deps: dict, context: str) -&gt; None:\n        for name, meta in deps.items():\n            if isinstance(meta, dict):\n                version = meta.get(\"version\")\n                if isinstance(version, str):\n                    self.check_npm_version(name, version, path, context)\n                child = meta.get(\"dependencies\")\n                if isinstance(child, dict):\n                    self.scan_dependency_tree(path, child, context)\n\n    def scan_npm_text(self, path: Path, text: str) -&gt; None:\n        for pkg, versions in AFFECTED_NPM.items():\n            if pkg not in text:\n                continue\n            for version in versions:\n                patterns = [\n                    re.escape(pkg) + r\"@\" + re.escape(version) + r\"(\\b|[^0-9A-Za-z_.-])\",\n                    re.escape(pkg) + r\"['\\\"]?\\s*[:@]\\s*['\\\"]?[\\^~=&gt; ]*\" + re.escape(version) + r\"\\b\",\n                    re.escape(pkg) + r\"[\\s\\S]{0,500}?version['\\\"]?\\s*[:=]\\s*['\\\"]\" + re.escape(version) + r\"['\\\"]\",\n                ]\n                if any(re.search(p, text) for p in patterns):\n                    self.add(\"HIGH\", \"npm-lockfile-exposure\", path, f\"{pkg}@{version}\")\n                    break\n\n    def scan_pypi_text(self, path: Path, text: str) -&gt; None:\n        norm_text = text.lower()\n        for pkg, versions in AFFECTED_PYPI.items():\n            if pkg not in norm_text and pkg.replace(\"-\", \"_\") not in norm_text:\n                continue\n            for version in versions:\n                pkg_re = re.escape(pkg).replace(\"\\\\-\", \"[-_.]\")\n                patterns = [\n                    r\"(?im)^\\s*\" + pkg_re + r\"\\s*(==|===)\\s*\" + re.escape(version) + r\"\\b\",\n                    r\"name\\s*=\\s*['\\\"]\" + pkg_re + r\"['\\\"][\\s\\S]{0,300}?version\\s*=\\s*['\\\"]\" + re.escape(version) + r\"['\\\"]\",\n                    pkg_re + r\"[\\s\\S]{0,300}?\" + re.escape(version),\n                ]\n                if any(re.search(p, text, flags=re.IGNORECASE) for p in patterns):\n                    self.add(\"HIGH\", \"pypi-lockfile-exposure\", path, f\"{pkg}=={version}\")\n                    break\n\n    def scan_dist_info_dir(self, path: Path) -&gt; None:\n        name = path.name\n        if not name.endswith(\".dist-info\"):\n            return\n        match = re.match(r\"(?P.+)-(?P[0-9][^-]*)\\.dist-info$\", name)\n        if match:\n            pkg = norm_pypi_name(match.group(\"name\"))\n            version = match.group(\"version\")\n            self.check_pypi_version(pkg, version, path, \"installed Python dist-info\")\n            return\n        metadata = path / \"METADATA\"\n        if metadata.exists():\n            text = self.read_text(metadata)\n            if text:\n                pkg_match = re.search(r\"(?im)^Name:\\s*(.+)$\", text)\n                version_match = re.search(r\"(?im)^Version:\\s*(.+)$\", text)\n                if pkg_match and version_match:\n                    self.check_pypi_version(\n                        norm_pypi_name(pkg_match.group(1).strip()),\n                        version_match.group(1).strip(),\n                        metadata,\n                        \"installed Python metadata\",\n                    )\n\n    def add_global_node_modules_dirs(self) -&gt; None:\n        for cmd in ([\"npm\", \"root\", \"-g\"], [\"pnpm\", \"root\", \"-g\"]):\n            try:\n                proc = subprocess.run(cmd, text=True, capture_output=True, timeout=8, check=False)\n            except Exception:\n                continue\n            if proc.returncode == 0:\n                path = Path(proc.stdout.strip())\n                if path.exists():\n                    self.node_modules_dirs.add(path)\n\n    def scan_node_modules_dirs(self) -&gt; None:\n        for nm in sorted(self.node_modules_dirs):\n            for pkg, versions in AFFECTED_NPM.items():\n                pkg_path = nm / package_to_path(pkg)\n                package_json = pkg_path / \"package.json\"\n                if package_json.exists():\n                    text = self.read_text(package_json)\n                    if not text:\n                        continue\n                    try:\n                        data = json.loads(text)\n                    except json.JSONDecodeError:\n                        self.scan_npm_text(package_json, text)\n                        continue\n                    version = data.get(\"version\")\n                    if isinstance(version, str):\n                        self.check_npm_version(pkg, version, package_json, \"installed node_modules package\")\n                    for suspicious in (\"router_init.js\", \"tanstack_runner.js\", \"router_runtime.js\", \"setup.mjs\", \"opensearch_init.js\"):\n                        candidate = pkg_path / suspicious\n                        if candidate.exists():\n                            self.scan_suspicious_file(candidate)\n\n    def check_npm_version(self, name: str, version: str, path: Path, context: str) -&gt; None:\n        if name in AFFECTED_NPM and version in AFFECTED_NPM[name]:\n            self.add(\"HIGH\", \"npm-exposure\", path, f\"{name}@{version} ({context})\")\n\n    def check_pypi_version(self, name: str, version: str, path: Path, context: str) -&gt; None:\n        norm = norm_pypi_name(name)\n        if norm in AFFECTED_PYPI and version in AFFECTED_PYPI[norm]:\n            self.add(\"HIGH\", \"pypi-exposure\", path, f\"{norm}=={version} ({context})\")\n\n    def read_text(self, path: Path) -&gt; str | None:\n        try:\n            size = path.stat().st_size\n            if size &gt; self.max_file_bytes:\n                if self.verbose:\n                    self.error(path, f\"skipped large file ({size} bytes)\")\n                return None\n            return path.read_text(encoding=\"utf-8\", errors=\"ignore\")\n        except OSError as exc:\n            self.error(path, f\"read failed: {exc}\")\n            return None\n\n    def print_report(self) -&gt; None:\n        print(\"Mini Shai-Hulud / TanStack campaign scanner\")\n        print(f\"Roots scanned: {', '.join(str(p) for p in self.roots)}\")\n        print(f\"Node modules roots inspected: {len(self.node_modules_dirs)}\")\n        print()\n\n        severity_order = {\"CRITICAL\": 0, \"HIGH\": 1, \"MEDIUM\": 2, \"INFO\": 3}\n        findings = sorted(self.findings, key=lambda f: (severity_order.get(f.severity, 9), f.path, f.detail))\n        if findings:\n            print(\"FINDINGS:\")\n            for f in findings:\n                print(f\"- [{f.severity}] {f.category}: {f.path}\")\n                print(f\"  {f.detail}\")\n            print()\n        else:\n            print(\"FINDINGS: none\")\n            print()\n\n        if self.errors and self.verbose:\n            print(\"SCAN WARNINGS:\")\n            for err in self.errors[:50]:\n                print(f\"- {err}\")\n            if len(self.errors) &gt; 50:\n                print(f\"- ... {len(self.errors) - 50} more\")\n            print()\n\n        if any(f.severity in {\"CRITICAL\", \"HIGH\"} for f in findings):\n            print(\"RESULT: LIKELY AFFECTED OR EXPOSED\")\n            print(\"Do not rotate GitHub/npm/cloud tokens until gh-token-monitor persistence is contained/removed.\")\n        elif any(f.severity == \"MEDIUM\" for f in findings):\n            print(\"RESULT: SUSPICIOUS CONFIG FOUND; REVIEW MANUALLY\")\n        else:\n            print(\"RESULT: NO KNOWN MINI SHAI-HULUD INDICATORS FOUND\")\n\n\ndef is_requirements_file(name: str) -&gt; bool:\n    lower = name.lower()\n    return lower == \"requirements.txt\" or (lower.startswith(\"requirements\") and lower.endswith(\".txt\"))\n\n\ndef norm_pypi_name(name: str) -&gt; str:\n    return re.sub(r\"[-_.]+\", \"-\", name).lower()\n\n\ndef package_to_path(package: str) -&gt; Path:\n    return Path(*package.split(\"/\"))\n\n\ndef name_from_node_modules_key(key: str) -&gt; str | None:\n    prefix = \"node_modules/\"\n    if prefix not in key:\n        return None\n    return key.rsplit(prefix, 1)[-1]\n\n\ndef default_roots() -&gt; list[Path]:\n    home = Path.home().resolve()\n    cwd = Path.cwd().resolve()\n    roots = [home]\n    if home not in cwd.parents and cwd != home:\n        roots.append(cwd)\n    return roots\n\n\ndef parse_args(argv: list[str]) -&gt; argparse.Namespace:\n    parser = argparse.ArgumentParser(description=\"Read-only scanner for Mini Shai-Hulud campaign indicators.\")\n    parser.add_argument(\"roots\", nargs=\"*\", type=Path, help=\"roots to scan; defaults to your home directory\")\n    parser.add_argument(\"--max-file-mb\", type=int, default=25, help=\"max file size to inspect, default 25\")\n    parser.add_argument(\"--verbose\", action=\"store_true\", help=\"print scan warnings\")\n    return parser.parse_args(argv)\n\n\ndef main(argv: list[str]) -&gt; int:\n    args = parse_args(argv)\n    roots = [p.expanduser().resolve() for p in args.roots] if args.roots else default_roots()\n    try:\n        scanner = Scanner(roots=roots, max_file_mb=args.max_file_mb, verbose=args.verbose)\n        return scanner.scan()\n    except KeyboardInterrupt:\n        print(\"Interrupted\", file=sys.stderr)\n        return 2\n    except Exception as exc:\n        print(f\"Scanner error: {exc}\", file=sys.stderr)\n        return 2\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main(sys.argv[1:]))\n", "creation_timestamp": "2026-05-12T20:26:22.000000Z"}