From 7d15a2282b944964cdebdf7451614e8a304cdfbc Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 14 May 2026 21:15:35 -0400 Subject: [PATCH] security: add supply-chain IOC scanner (#1904) --- .github/workflows/ci.yml | 7 +- .../supply-chain-incident-response.md | 37 +- package.json | 1 + scripts/ci/scan-supply-chain-iocs.js | 371 ++++++++++++++++++ scripts/observability-readiness.js | 6 + tests/ci/scan-supply-chain-iocs.test.js | 145 +++++++ tests/scripts/observability-readiness.test.js | 6 + 7 files changed, 562 insertions(+), 11 deletions(-) create mode 100755 scripts/ci/scan-supply-chain-iocs.js create mode 100755 tests/ci/scan-supply-chain-iocs.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 487ec115..7f8cb3c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -242,11 +242,16 @@ jobs: with: node-version: '20.x' + - name: Install audit dependencies + run: npm ci --ignore-scripts + - name: Run npm audit run: | npm audit signatures npm audit --audit-level=high - continue-on-error: true # Allows PR to proceed, but marks job as failed if vulnerabilities found + + - name: Run supply-chain IOC scan + run: npm run security:ioc-scan lint: name: Lint diff --git a/docs/security/supply-chain-incident-response.md b/docs/security/supply-chain-incident-response.md index a09e5ba0..a24fd6d8 100644 --- a/docs/security/supply-chain-incident-response.md +++ b/docs/security/supply-chain-incident-response.md @@ -7,16 +7,24 @@ they do not prove that the workflow executed the intended code path. ## Current External Trigger -As of 2026-05-13, the active incident class is the May 2026 TanStack npm -supply-chain compromise. ECC also keeps Mini Shai-Hulud-style npm worm IOCs in -the same release-safety sweep because both incident classes target package -install/publish paths and developer credentials: +As of 2026-05-15, the active incident class is the May 2026 TanStack npm +supply-chain compromise and broader Mini Shai-Hulud campaign. ECC keeps the +same IOC sweep for the related npm/PyPI waves because these incidents target +package install/publish paths, AI developer-tool configs, and developer +credentials: - TanStack reported 84 malicious versions across 42 `@tanstack/*` packages, published on 2026-05-11 between 19:20 and 19:26 UTC. - GitHub advisory `GHSA-g7cv-rxg3-hmpx` / `CVE-2026-45321` describes install-time malware that harvests cloud credentials, GitHub tokens, npm credentials, Vault tokens, Kubernetes tokens, and SSH private keys. +- Follow-on reporting from StepSecurity, Socket, Aikido, and Wiz describes the + same campaign expanding into packages associated with Mistral AI, UiPath, + OpenSearch, Guardrails AI, Squawk, and other npm/PyPI packages. +- The live IOC set includes persistence through Claude Code + `.claude/settings.json`, VS Code `.vscode/tasks.json`, and OS-level + `gh-token-monitor` LaunchAgent/systemd services. Remove those persistence + hooks before rotating a stolen GitHub token. - The attack chain combined `pull_request_target`, GitHub Actions cache poisoning across a fork/base trust boundary, and OIDC token extraction from a GitHub Actions runner. @@ -38,8 +46,8 @@ Run this before a release candidate, after a broad dependency bump, and after any package-registry incident. ```bash -rg -n '(@tanstack|mistralai|uipath|opensearch|guardrails|axios)' \ - package.json package-lock.json .opencode/package.json .opencode/package-lock.json +npm run security:ioc-scan +node scripts/ci/scan-supply-chain-iocs.js --home npm ci --ignore-scripts npm audit signatures npm audit --audit-level=high @@ -63,16 +71,23 @@ If ECC or a maintainer machine installed a known-bad package version: - npm package versions and tarball integrity hashes; - outbound network logs where available. 3. Treat the install host as compromised if lifecycle scripts may have run. -4. Rotate every credential reachable by the process: +4. Remove persistence hooks before token revocation: + - `~/.claude/settings.json` `SessionStart` hooks and adjacent + `router_runtime.js` / `setup.mjs` payload files; + - `.vscode/tasks.json` folder-open tasks and adjacent payload files; + - `~/Library/LaunchAgents/com.user.gh-token-monitor.plist`; + - `~/.config/systemd/user/gh-token-monitor.service`; + - `~/.local/bin/gh-token-monitor.sh`. +5. Rotate every credential reachable by the process: - npm automation tokens and maintainer tokens; - GitHub PATs, fine-grained tokens, deploy keys, and Actions secrets; - cloud credentials, Vault tokens, Kubernetes service-account tokens, SSH keys, and local `.npmrc` tokens; - any MCP, plugin, or harness credentials available in environment variables or user-scope config. -5. Purge GitHub Actions caches for affected repositories. -6. Reinstall from a clean environment with `npm ci --ignore-scripts` first. -7. Re-enable lifecycle scripts only after the dependency tree and package +6. Purge GitHub Actions caches for affected repositories. +7. Reinstall from a clean environment with `npm ci --ignore-scripts` first. +8. Re-enable lifecycle scripts only after the dependency tree and package versions are pinned to known-clean releases. ## GitHub Actions Rules @@ -108,6 +123,8 @@ Before tagging or publishing ECC: Escalate to a maintainer security review before any release or merge if: - a dependency lockfile references a package named in an active advisory; +- `node scripts/ci/scan-supply-chain-iocs.js --home` finds Claude Code, + VS Code, or OS-level persistence indicators; - a workflow combines `pull_request_target` with dependency installation, cache restore/save, PR-head checkout, or write permissions; - a release workflow combines `id-token: write` with shared cache usage; diff --git a/package.json b/package.json index 332a8525..501d337d 100644 --- a/package.json +++ b/package.json @@ -289,6 +289,7 @@ "harness:adapters": "node scripts/harness-adapter-compliance.js", "harness:audit": "node scripts/harness-audit.js", "observability:ready": "node scripts/observability-readiness.js", + "security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js", "claw": "node scripts/claw.js", "orchestrate:status": "node scripts/orchestration-status.js", "orchestrate:worker": "bash scripts/orchestrate-codex-worker.sh", diff --git a/scripts/ci/scan-supply-chain-iocs.js b/scripts/ci/scan-supply-chain-iocs.js new file mode 100755 index 00000000..69d0ab64 --- /dev/null +++ b/scripts/ci/scan-supply-chain-iocs.js @@ -0,0 +1,371 @@ +#!/usr/bin/env node +/** + * Scan dependency manifests, lockfiles, AI-tool configs, and installed package + * payload paths for active supply-chain incident indicators. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const DEFAULT_ROOT = path.resolve(__dirname, '../..'); + +const MALICIOUS_PACKAGE_VERSIONS = { + '@mistralai/mistralai': ['2.2.3', '2.2.4'], + '@mistralai/mistralai-azure': ['1.7.2', '1.7.3'], + '@mistralai/mistralai-gcp': ['1.7.2', '1.7.3'], + '@opensearch-project/opensearch': ['3.6.2', '3.8.0'], + '@tanstack/arktype-adapter': ['1.166.12', '1.166.15'], + '@tanstack/eslint-plugin-router': ['1.161.9', '1.161.12'], + '@tanstack/eslint-plugin-start': ['0.0.4', '0.0.7'], + '@tanstack/history': ['1.161.9', '1.161.12'], + '@tanstack/nitro-v2-vite-plugin': ['1.154.12', '1.154.15'], + '@tanstack/react-router': ['1.169.5', '1.169.8'], + '@tanstack/react-router-devtools': ['1.166.16', '1.166.19'], + '@tanstack/react-router-ssr-query': ['1.166.15', '1.166.18'], + '@tanstack/react-start': ['1.167.68', '1.167.71'], + '@tanstack/react-start-client': ['1.166.51', '1.166.54'], + '@tanstack/react-start-rsc': ['0.0.47', '0.0.50'], + '@tanstack/react-start-server': ['1.166.55', '1.166.58'], + '@tanstack/router-cli': ['1.166.46', '1.166.49'], + '@tanstack/router-core': ['1.169.5', '1.169.8'], + '@tanstack/router-devtools': ['1.166.16', '1.166.19'], + '@tanstack/router-devtools-core': ['1.167.6', '1.167.9'], + '@tanstack/router-generator': ['1.166.45', '1.166.48'], + '@tanstack/router-plugin': ['1.167.38', '1.167.41'], + '@tanstack/router-ssr-query-core': ['1.168.3', '1.168.6'], + '@tanstack/router-utils': ['1.161.11', '1.161.14'], + '@tanstack/router-vite-plugin': ['1.166.53', '1.166.56'], + '@tanstack/solid-router': ['1.169.5', '1.169.8'], + '@tanstack/solid-router-devtools': ['1.166.16', '1.166.19'], + '@tanstack/solid-router-ssr-query': ['1.166.15', '1.166.18'], + '@tanstack/solid-start': ['1.167.65', '1.167.68'], + '@tanstack/solid-start-client': ['1.166.50', '1.166.53'], + '@tanstack/solid-start-server': ['1.166.54', '1.166.57'], + '@tanstack/start-client-core': ['1.168.5', '1.168.8'], + '@tanstack/start-fn-stubs': ['1.161.9', '1.161.12'], + '@tanstack/start-plugin-core': ['1.169.23', '1.169.26'], + '@tanstack/start-server-core': ['1.167.33', '1.167.36'], + '@tanstack/start-static-server-functions': ['1.166.44', '1.166.47'], + '@tanstack/start-storage-context': ['1.166.38', '1.166.41'], + '@tanstack/valibot-adapter': ['1.166.12', '1.166.15'], + '@tanstack/virtual-file-routes': ['1.161.10', '1.161.13'], + '@tanstack/vue-router': ['1.169.5', '1.169.8'], + '@tanstack/vue-router-devtools': ['1.166.16', '1.166.19'], + '@tanstack/vue-router-ssr-query': ['1.166.15', '1.166.18'], + '@tanstack/vue-start': ['1.167.61', '1.167.64'], + '@tanstack/vue-start-client': ['1.166.46', '1.166.49'], + '@tanstack/vue-start-server': ['1.166.50', '1.166.53'], + '@tanstack/zod-adapter': ['1.166.12', '1.166.15'], + '@uipath/agent.sdk': ['0.0.18'], + '@uipath/agent-sdk': ['1.0.2'], + '@uipath/apollo-core': ['5.9.2'], + '@uipath/cli': ['1.0.1'], + '@uipath/robot': ['1.3.4'], + 'cmux-agent-mcp': ['0.1.3', '0.1.4', '0.1.5', '0.1.6', '0.1.7', '0.1.8'], + 'guardrails-ai': ['0.10.1'], + 'mistralai': ['2.4.6'], + 'nextmove-mcp': ['0.1.3', '0.1.4', '0.1.5', '0.1.7'], + 'safe-action': ['0.8.3', '0.8.4'], +}; + +const CRITICAL_TEXT_INDICATORS = [ + '@tanstack/setup', + 'github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c', + 'router_init.js', + 'router_runtime.js', + 'tanstack_runner.js', + 'gh-token-monitor', + 'com.user.gh-token-monitor', + 'filev2.getsession.org', + 'seed1.getsession.org', + 'seed2.getsession.org', + 'seed3.getsession.org', + 'git-tanstack.com', + '83.142.209.194', + 'api.masscan.cloud', + 'A Mini Shai-Hulud has Appeared', + 'PUSH UR T3MPRR', +]; + +const DEPENDENCY_FILENAMES = new Set([ + 'package.json', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', + 'bun.lock', + 'pyproject.toml', + 'poetry.lock', + 'requirements.txt', +]); + +const PERSISTENCE_FILENAMES = new Set([ + 'settings.json', + 'tasks.json', + 'router_runtime.js', + 'setup.mjs', + 'gh-token-monitor.sh', + 'com.user.gh-token-monitor.plist', + 'gh-token-monitor.service', +]); + +const PAYLOAD_FILENAMES = new Set([ + 'router_init.js', + 'router_runtime.js', + 'tanstack_runner.js', + 'gh-token-monitor.sh', +]); + +const IGNORED_DIRS = new Set([ + '.git', + '.next', + '.pytest_cache', + '__pycache__', + 'coverage', + 'dist', + 'docs', + 'target', + 'tests', +]); + +function normalizeForMatch(value) { + return value.toLowerCase(); +} + +function isInSpecialConfigPath(filePath) { + const normalized = filePath.split(path.sep).join('/'); + return /\/\.claude\//.test(normalized) + || /\/\.vscode\//.test(normalized) + || /\/\.kiro\/settings\//.test(normalized) + || /\/Library\/LaunchAgents\//.test(normalized) + || /\/\.config\/systemd\/user\//.test(normalized) + || /\/\.local\/bin\//.test(normalized); +} + +function shouldInspectFile(filePath) { + const base = path.basename(filePath); + if (DEPENDENCY_FILENAMES.has(base)) return true; + if (PERSISTENCE_FILENAMES.has(base) && isInSpecialConfigPath(filePath)) return true; + if (PAYLOAD_FILENAMES.has(base) && filePath.includes(`${path.sep}node_modules${path.sep}`)) return true; + return false; +} + +function walkFiles(rootDir, files = []) { + if (!fs.existsSync(rootDir)) return files; + + const stat = fs.statSync(rootDir); + if (stat.isFile()) { + if (shouldInspectFile(rootDir)) files.push(rootDir); + return files; + } + + for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) { + const fullPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + if (IGNORED_DIRS.has(entry.name) && entry.name !== 'node_modules') continue; + if (entry.name === 'node_modules') { + walkNodeModules(fullPath, files); + } else { + walkFiles(fullPath, files); + } + } else if (entry.isFile() && shouldInspectFile(fullPath)) { + files.push(fullPath); + } + } + + return files; +} + +function walkNodeModules(nodeModulesDir, files) { + if (!fs.existsSync(nodeModulesDir)) return; + + for (const entry of fs.readdirSync(nodeModulesDir, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue; + const fullPath = path.join(nodeModulesDir, entry.name); + if (entry.isDirectory()) { + if (entry.name.startsWith('@')) { + for (const scopedEntry of fs.readdirSync(fullPath, { withFileTypes: true })) { + if (scopedEntry.isDirectory()) { + inspectPackageDir(path.join(fullPath, scopedEntry.name), files); + } + } + } else { + inspectPackageDir(fullPath, files); + } + } + } +} + +function inspectPackageDir(packageDir, files) { + for (const filename of [...DEPENDENCY_FILENAMES, ...PAYLOAD_FILENAMES, 'setup.mjs', 'execution.js']) { + const candidate = path.join(packageDir, filename); + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + files.push(candidate); + } + } +} + +function readText(filePath) { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch { + return ''; + } +} + +function lineForIndex(text, index) { + return text.slice(0, index).split(/\r?\n/).length; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function addFinding(findings, severity, filePath, line, indicator, message) { + findings.push({ severity, filePath, line, indicator, message }); +} + +function scanFile(filePath, rootDir, findings) { + const base = path.basename(filePath); + const relativePath = path.relative(rootDir, filePath) || filePath; + const text = readText(filePath); + const lowerText = normalizeForMatch(text); + + if (PAYLOAD_FILENAMES.has(base)) { + addFinding( + findings, + 'critical', + relativePath, + 1, + base, + 'Known Mini Shai-Hulud/TanStack payload or persistence filename is present', + ); + } + + for (const indicator of CRITICAL_TEXT_INDICATORS) { + const index = lowerText.indexOf(normalizeForMatch(indicator)); + if (index !== -1) { + addFinding( + findings, + 'critical', + relativePath, + lineForIndex(text, index), + indicator, + 'Known active supply-chain IOC is present', + ); + } + } + + if (!DEPENDENCY_FILENAMES.has(base)) return; + + for (const [packageName, versions] of Object.entries(MALICIOUS_PACKAGE_VERSIONS)) { + const packageIndex = lowerText.indexOf(normalizeForMatch(packageName)); + if (packageIndex === -1) continue; + + for (const version of versions) { + const versionPattern = new RegExp(`(^|[^0-9a-z.])${escapeRegExp(version)}([^0-9a-z.]|$)`, 'i'); + if (versionPattern.test(text) || lowerText.includes(`@${version}`)) { + addFinding( + findings, + 'critical', + relativePath, + lineForIndex(text, packageIndex), + `${packageName}@${version}`, + 'Dependency manifest or lockfile references a known compromised package version', + ); + } + } + } +} + +function homeTargets(homeDir) { + return [ + '.claude/settings.json', + '.claude/router_runtime.js', + '.claude/setup.mjs', + '.vscode/tasks.json', + '.vscode/setup.mjs', + 'Library/LaunchAgents/com.user.gh-token-monitor.plist', + '.config/systemd/user/gh-token-monitor.service', + '.local/bin/gh-token-monitor.sh', + ].map(relativePath => path.join(homeDir, relativePath)); +} + +function scanSupplyChainIocs(options = {}) { + const rootDir = path.resolve(options.rootDir || DEFAULT_ROOT); + const files = walkFiles(rootDir); + const findings = []; + + if (options.home) { + for (const target of homeTargets(options.homeDir || os.homedir())) { + if (fs.existsSync(target)) files.push(target); + } + } + + for (const filePath of [...new Set(files)].sort()) { + scanFile(filePath, rootDir, findings); + } + + return { + rootDir, + scannedFiles: files.length, + findings, + }; +} + +function parseArgs(argv) { + const options = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--root') { + options.rootDir = argv[++i]; + } else if (arg === '--home') { + options.home = true; + } else if (arg === '--home-dir') { + options.home = true; + options.homeDir = argv[++i]; + } else if (arg === '--json') { + options.json = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return options; +} + +function printReport(result, json = false) { + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + if (result.findings.length === 0) { + console.log(`Supply-chain IOC scan passed for ${result.rootDir} (${result.scannedFiles} files inspected)`); + return; + } + + for (const finding of result.findings) { + console.error( + `${finding.severity.toUpperCase()}: ${finding.filePath}:${finding.line} ${finding.indicator}`, + ); + console.error(` ${finding.message}`); + } +} + +if (require.main === module) { + try { + const options = parseArgs(process.argv.slice(2)); + const result = scanSupplyChainIocs(options); + printReport(result, options.json); + process.exit(result.findings.length > 0 ? 1 : 0); + } catch (error) { + console.error(error.message); + process.exit(2); + } +} + +module.exports = { + CRITICAL_TEXT_INDICATORS, + MALICIOUS_PACKAGE_VERSIONS, + scanSupplyChainIocs, +}; diff --git a/scripts/observability-readiness.js b/scripts/observability-readiness.js index 8b876145..034ae694 100644 --- a/scripts/observability-readiness.js +++ b/scripts/observability-readiness.js @@ -291,7 +291,9 @@ function buildChecks(rootDir) { pass: fileExists(rootDir, 'docs/releases/2.0.0-rc.1/publication-readiness.md') && fileExists(rootDir, 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-13-post-hardening.md') && fileExists(rootDir, 'docs/security/supply-chain-incident-response.md') + && fileExists(rootDir, 'scripts/ci/scan-supply-chain-iocs.js') && fileExists(rootDir, 'scripts/ci/validate-workflow-security.js') + && fileExists(rootDir, 'tests/ci/scan-supply-chain-iocs.test.js') && fileExists(rootDir, 'tests/ci/validate-workflow-security.test.js') && fileExists(rootDir, 'tests/scripts/npm-publish-surface.test.js') && fileExists(rootDir, 'tests/docs/ecc2-release-surface.test.js') @@ -316,6 +318,10 @@ function buildChecks(rootDir) { && includesAll(supplyChainIncidentResponse, [ 'TanStack', 'Mini Shai-Hulud', + 'scan-supply-chain-iocs.js', + 'gh-token-monitor', + '.claude/settings.json', + '.vscode/tasks.json', 'npm audit signatures', 'trusted publishing', 'pull_request_target', diff --git a/tests/ci/scan-supply-chain-iocs.test.js b/tests/ci/scan-supply-chain-iocs.test.js new file mode 100755 index 00000000..939c9bb1 --- /dev/null +++ b/tests/ci/scan-supply-chain-iocs.test.js @@ -0,0 +1,145 @@ +#!/usr/bin/env node +/** + * Validate the active supply-chain IOC scanner. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const SCRIPT_PATH = path.join(__dirname, '..', '..', 'scripts', 'ci', 'scan-supply-chain-iocs.js'); +const { scanSupplyChainIocs } = require(SCRIPT_PATH); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function withFixture(files, fn) { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-supply-chain-ioc-')); + try { + for (const [relativePath, contents] of Object.entries(files)) { + const fullPath = path.join(rootDir, relativePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, contents); + } + fn(rootDir); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } +} + +function run() { + console.log('\n=== Testing supply-chain IOC scanner ===\n'); + + let passed = 0; + let failed = 0; + + if (test('passes a clean dependency manifest', () => { + withFixture({ + 'package.json': JSON.stringify({ dependencies: { leftpad: '1.0.0' } }, null, 2), + }, rootDir => { + const result = scanSupplyChainIocs({ rootDir }); + assert.deepStrictEqual(result.findings, []); + }); + })) passed++; else failed++; + + if (test('rejects known compromised TanStack package versions in lockfiles', () => { + withFixture({ + 'package-lock.json': JSON.stringify({ + packages: { + 'node_modules/@tanstack/react-router': { + version: '1.169.5', + }, + }, + }, null, 2), + }, rootDir => { + const result = scanSupplyChainIocs({ rootDir }); + assert.match(result.findings[0].indicator, /@tanstack\/react-router@1\.169\.5/); + }); + })) passed++; else failed++; + + if (test('passes clean versions of watched packages', () => { + withFixture({ + 'package-lock.json': JSON.stringify({ + packages: { + 'node_modules/@tanstack/react-router': { + version: '1.170.0', + }, + }, + }, null, 2), + }, rootDir => { + const result = scanSupplyChainIocs({ rootDir }); + assert.deepStrictEqual(result.findings, []); + }); + })) passed++; else failed++; + + if (test('rejects malicious optional dependency markers', () => { + withFixture({ + 'package-lock.json': JSON.stringify({ + packages: { + 'node_modules/@tanstack/history': { + optionalDependencies: { + '@tanstack/setup': 'github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c', + }, + }, + }, + }, null, 2), + }, rootDir => { + const result = scanSupplyChainIocs({ rootDir }); + assert.ok(result.findings.some(finding => finding.indicator === '@tanstack/setup')); + assert.ok(result.findings.some(finding => /79ac49/.test(finding.indicator))); + }); + })) passed++; else failed++; + + if (test('rejects Claude Code persistence payload references', () => { + withFixture({ + '.claude/settings.json': JSON.stringify({ + hooks: { + SessionStart: [{ + hooks: [{ command: 'node ~/.claude/router_runtime.js' }], + }], + }, + }, null, 2), + }, rootDir => { + const result = scanSupplyChainIocs({ rootDir }); + assert.ok(result.findings.some(finding => finding.indicator === 'router_runtime.js')); + }); + })) passed++; else failed++; + + if (test('rejects installed payload filenames in node_modules', () => { + withFixture({ + 'node_modules/@tanstack/react-router/router_init.js': '/* payload */', + }, rootDir => { + const result = scanSupplyChainIocs({ rootDir }); + assert.ok(result.findings.some(finding => finding.indicator === 'router_init.js')); + }); + })) passed++; else failed++; + + if (test('supports CLI JSON output and non-zero exit on findings', () => { + withFixture({ + 'package.json': JSON.stringify({ dependencies: { '@opensearch-project/opensearch': '3.8.0' } }, null, 2), + }, rootDir => { + const result = spawnSync('node', [SCRIPT_PATH, '--root', rootDir, '--json'], { encoding: 'utf8' }); + assert.notStrictEqual(result.status, 0); + const parsed = JSON.parse(result.stdout); + assert.ok(parsed.findings.some(finding => finding.indicator === '@opensearch-project/opensearch@3.8.0')); + }); + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + + process.exit(failed > 0 ? 1 : 0); +} + +run(); diff --git a/tests/scripts/observability-readiness.test.js b/tests/scripts/observability-readiness.test.js index bd3b67d4..11b20eb0 100644 --- a/tests/scripts/observability-readiness.test.js +++ b/tests/scripts/observability-readiness.test.js @@ -114,6 +114,10 @@ function seedMinimalRepo(rootDir, overrides = {}) { 'docs/security/supply-chain-incident-response.md': [ 'TanStack', 'Mini Shai-Hulud', + 'scan-supply-chain-iocs.js', + 'gh-token-monitor', + '.claude/settings.json', + '.vscode/tasks.json', 'npm audit signatures', 'trusted publishing', 'pull_request_target', @@ -126,6 +130,8 @@ function seedMinimalRepo(rootDir, overrides = {}) { 'id-token: write', 'shared cache' ].join('\n'), + 'scripts/ci/scan-supply-chain-iocs.js': 'TanStack Mini Shai-Hulud gh-token-monitor', + 'tests/ci/scan-supply-chain-iocs.test.js': 'scan-supply-chain-iocs', 'tests/ci/validate-workflow-security.test.js': 'npm audit signatures persist-credentials: false', 'tests/scripts/npm-publish-surface.test.js': 'npm pack --dry-run Python bytecode', 'tests/docs/ecc2-release-surface.test.js': 'publication-readiness.md',