From a8e3bcb00f27c70c925aca66c8fb7377ed9b1d7c Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 15 May 2026 22:39:35 -0400 Subject: [PATCH] Add supply-chain advisory source refresh --- .github/workflows/supply-chain-watch.yml | 10 +- .../supply-chain-incident-response.md | 13 +- package.json | 2 + scripts/ci/supply-chain-advisory-sources.js | 469 ++++++++++++++++++ scripts/operator-readiness-dashboard.js | 13 +- scripts/platform-audit.js | 7 +- .../ci/supply-chain-advisory-sources.test.js | 157 ++++++ tests/ci/supply-chain-watch-workflow.test.js | 4 + tests/scripts/npm-publish-surface.test.js | 2 + .../operator-readiness-dashboard.test.js | 7 +- tests/scripts/platform-audit.test.js | 4 +- 11 files changed, 675 insertions(+), 13 deletions(-) create mode 100644 scripts/ci/supply-chain-advisory-sources.js create mode 100644 tests/ci/supply-chain-advisory-sources.test.js diff --git a/.github/workflows/supply-chain-watch.yml b/.github/workflows/supply-chain-watch.yml index 0bf238f1..dd74bcca 100644 --- a/.github/workflows/supply-chain-watch.yml +++ b/.github/workflows/supply-chain-watch.yml @@ -40,11 +40,17 @@ jobs: - name: Validate IOC scanner fixtures run: node tests/ci/scan-supply-chain-iocs.test.js + - name: Validate advisory source fixtures + run: node tests/ci/supply-chain-advisory-sources.test.js + - name: Generate IOC report run: | mkdir -p artifacts node scripts/ci/scan-supply-chain-iocs.js --json > artifacts/supply-chain-ioc-report.json + - name: Generate advisory source report + run: node scripts/ci/supply-chain-advisory-sources.js --refresh --json > artifacts/supply-chain-advisory-sources.json + - name: Validate workflow hardening rules run: node scripts/ci/validate-workflow-security.js @@ -53,5 +59,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: supply-chain-ioc-report - path: artifacts/supply-chain-ioc-report.json + path: | + artifacts/supply-chain-ioc-report.json + artifacts/supply-chain-advisory-sources.json retention-days: 14 diff --git a/docs/security/supply-chain-incident-response.md b/docs/security/supply-chain-incident-response.md index 903bed07..00459a3e 100644 --- a/docs/security/supply-chain-incident-response.md +++ b/docs/security/supply-chain-incident-response.md @@ -73,6 +73,7 @@ node scripts/ci/scan-supply-chain-iocs.js --home npm ci --ignore-scripts npm audit signatures npm audit --audit-level=high +node scripts/ci/supply-chain-advisory-sources.js --json node scripts/ci/validate-workflow-security.js node tests/scripts/npm-publish-surface.test.js node tests/run-all.js @@ -86,8 +87,10 @@ evidence but do not rotate credentials for a docs-only reference. ECC also runs `.github/workflows/supply-chain-watch.yml` every six hours and on manual dispatch. The workflow is read-only, disables checkout credential persistence, installs with `npm ci --ignore-scripts`, verifies npm registry -signatures, runs the IOC scanner fixtures, emits -`supply-chain-ioc-report.json`, and re-validates GitHub Actions hardening rules. +signatures, runs the IOC scanner fixtures, runs +`scripts/ci/supply-chain-advisory-sources.js --refresh --json`, emits +`supply-chain-ioc-report.json` and `supply-chain-advisory-sources.json`, and +re-validates GitHub Actions hardening rules. Treat a failed scheduled watch as a release blocker until an operator confirms whether the failure is a newly reported advisory, a stale scanner fixture, a @@ -96,6 +99,12 @@ needs new indicators, update `scripts/ci/scan-supply-chain-iocs.js`, add fixture coverage in `tests/ci/scan-supply-chain-iocs.test.js`, refresh this runbook, and attach the latest JSON artifact to the release evidence. +The advisory-source artifact is the ITO-57 status payload. It records the +trusted source registry, live URL refresh warnings, and a Linear-ready summary. +Refresh source coverage through `npm run security:advisory-sources -- --json` +before changing IOC coverage, and attach the artifact to the next Linear project +status update after each significant merge batch. + ## Immediate Response If ECC or a maintainer machine installed a known-bad package version: diff --git a/package.json b/package.json index 57db6a89..f52415e5 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "schemas/", "scripts/catalog.js", "scripts/ci/scan-supply-chain-iocs.js", + "scripts/ci/supply-chain-advisory-sources.js", "scripts/consult.js", "scripts/auto-update.js", "scripts/claw.js", @@ -301,6 +302,7 @@ "platform:audit": "node scripts/platform-audit.js", "discussion:audit": "node scripts/discussion-audit.js", "security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js", + "security:advisory-sources": "node scripts/ci/supply-chain-advisory-sources.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/supply-chain-advisory-sources.js b/scripts/ci/supply-chain-advisory-sources.js new file mode 100644 index 00000000..f091aeac --- /dev/null +++ b/scripts/ci/supply-chain-advisory-sources.js @@ -0,0 +1,469 @@ +#!/usr/bin/env node +/** + * Build a refreshable source report for active supply-chain advisories. + */ + +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const path = require('path'); + +const DEFAULT_GENERATED_AT = () => new Date().toISOString(); +const DEFAULT_TIMEOUT_MS = 5000; +const MAX_REDIRECTS = 5; + +const DEFAULT_ADVISORY_SOURCES = [ + { + id: 'tanstack-postmortem', + title: 'TanStack npm supply-chain compromise postmortem', + publisher: 'TanStack', + url: 'https://tanstack.com/blog/npm-supply-chain-compromise-postmortem', + sourceType: 'primary-incident-postmortem', + ecosystems: ['npm', 'GitHub Actions'], + signals: ['tanstack', 'trusted-publishing-limits', 'github-actions-cache-poisoning'], + }, + { + id: 'github-ghsa-g7cv-rxg3-hmpx', + title: 'GitHub Advisory GHSA-g7cv-rxg3-hmpx / CVE-2026-45321', + publisher: 'GitHub Advisory Database', + url: 'https://github.com/advisories/GHSA-g7cv-rxg3-hmpx', + sourceType: 'security-advisory', + ecosystems: ['npm', 'AI developer tooling'], + signals: ['credential-theft', 'malicious-lifecycle-script', 'tanstack'], + }, + { + id: 'tanstack-followup', + title: 'TanStack incident follow-up', + publisher: 'TanStack', + url: 'https://tanstack.com/blog/incident-followup', + sourceType: 'primary-incident-followup', + ecosystems: ['npm', 'GitHub Actions'], + signals: ['remediation', 'trusted-publishing-limits'], + }, + { + id: 'stepsecurity-mini-shai-hulud', + title: 'Mini Shai-Hulud campaign analysis', + publisher: 'StepSecurity', + url: 'https://www.stepsecurity.io/blog/mini-shai-hulud-is-back-a-self-spreading-supply-chain-attack-hits-the-npm-ecosystem', + sourceType: 'incident-analysis', + ecosystems: ['npm', 'PyPI', 'AI developer tooling'], + signals: ['mini-shai-hulud', 'claude-code-persistence', 'vscode-persistence', 'os-persistence'], + }, + { + id: 'openai-tanstack-response', + title: 'OpenAI response to the TanStack npm supply-chain attack', + publisher: 'OpenAI', + url: 'https://openai.com/index/our-response-to-the-tanstack-npm-supply-chain-attack/', + sourceType: 'vendor-response', + ecosystems: ['npm', 'AI developer tooling'], + signals: ['codex-update', 'developer-tooling-exposure', 'remediation'], + }, + { + id: 'wiz-mini-shai-hulud', + title: 'Mini Shai-Hulud broader npm campaign coverage', + publisher: 'Wiz', + url: 'https://www.wiz.io/blog/mini-shai-hulud-strikes-again-tanstack-more-npm-packages-compromised', + sourceType: 'incident-analysis', + ecosystems: ['npm', 'PyPI', 'AI developer tooling'], + signals: ['mini-shai-hulud', 'opensearch', 'mistral-ai', 'uipath', 'squawk'], + }, + { + id: 'socket-node-ipc', + title: 'node-ipc package compromise', + publisher: 'Socket', + url: 'https://socket.dev/blog/node-ipc-package-compromised', + sourceType: 'incident-analysis', + ecosystems: ['npm'], + signals: ['node-ipc', 'payload-hash', 'destructive-package-behavior'], + }, + { + id: 'npm-trusted-publishers', + title: 'npm trusted publishing documentation', + publisher: 'npm', + url: 'https://docs.npmjs.com/trusted-publishers/', + sourceType: 'registry-control-reference', + ecosystems: ['npm', 'GitHub Actions'], + signals: ['trusted-publishing-limits', 'provenance'], + }, + { + id: 'cisa-npm-compromise', + title: 'CISA widespread supply-chain compromise impacting npm ecosystem', + publisher: 'CISA', + url: 'https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem', + sourceType: 'government-alert', + ecosystems: ['npm'], + signals: ['incident-response', 'credential-rotation', 'npm-compromise'], + }, +]; + +function normalizeArray(values) { + return Array.isArray(values) ? values.filter(Boolean) : []; +} + +function createCheck(id, status, summary, fix) { + return { id, status, summary, fix }; +} + +function uniqueValues(sources, field) { + return new Set(sources.flatMap(source => normalizeArray(source[field]))); +} + +function validateSources(sources) { + const checks = []; + const ids = new Set(); + const duplicateIds = []; + const invalidSources = []; + + for (const source of sources) { + if (ids.has(source.id)) duplicateIds.push(source.id); + ids.add(source.id); + if (!source.id || !source.title || !source.publisher || !source.url) { + invalidSources.push(source.id || '(missing id)'); + } + } + + checks.push(createCheck( + 'advisory-source-count', + sources.length >= 8 ? 'pass' : 'fail', + `${sources.length} advisory sources registered`, + 'Track at least eight sources spanning primary advisories, vendor responses, and registry controls.', + )); + + checks.push(createCheck( + 'advisory-source-shape', + invalidSources.length === 0 && duplicateIds.length === 0 ? 'pass' : 'fail', + invalidSources.length === 0 && duplicateIds.length === 0 + ? 'all sources include id, title, publisher, and URL' + : `invalid sources: ${[...invalidSources, ...duplicateIds].join(', ')}`, + 'Fix duplicate or incomplete advisory source records before relying on the watch artifact.', + )); + + const ecosystems = uniqueValues(sources, 'ecosystems'); + const requiredEcosystems = ['npm', 'PyPI', 'AI developer tooling']; + const missingEcosystems = requiredEcosystems.filter(ecosystem => !ecosystems.has(ecosystem)); + checks.push(createCheck( + 'advisory-ecosystem-coverage', + missingEcosystems.length === 0 ? 'pass' : 'fail', + missingEcosystems.length === 0 + ? 'sources cover npm, PyPI, and AI developer tooling' + : `missing ecosystem coverage: ${missingEcosystems.join(', ')}`, + 'Add sources for every active ecosystem touched by the campaign.', + )); + + const signals = uniqueValues(sources, 'signals'); + const requiredSignals = [ + 'tanstack', + 'mini-shai-hulud', + 'claude-code-persistence', + 'vscode-persistence', + 'os-persistence', + 'node-ipc', + 'trusted-publishing-limits', + 'remediation', + ]; + const missingSignals = requiredSignals.filter(signal => !signals.has(signal)); + checks.push(createCheck( + 'advisory-signal-coverage', + missingSignals.length === 0 ? 'pass' : 'fail', + missingSignals.length === 0 + ? 'sources cover package versions, persistence hooks, provenance limits, and remediation' + : `missing signal coverage: ${missingSignals.join(', ')}`, + 'Update the source registry before adding or removing scanner indicators.', + )); + + return checks; +} + +function refreshStatusFromResult(result) { + if (result && result.ok) { + return { + status: 'ok', + statusCode: result.statusCode || null, + finalUrl: result.finalUrl || null, + checkedAt: result.checkedAt || null, + }; + } + + return { + status: 'warning', + statusCode: result && result.statusCode ? result.statusCode : null, + finalUrl: result && result.finalUrl ? result.finalUrl : null, + checkedAt: result && result.checkedAt ? result.checkedAt : null, + error: result && result.error ? String(result.error) : 'source refresh failed', + }; +} + +async function defaultFetchSource(source, options = {}) { + const checkedAt = options.checkedAt || DEFAULT_GENERATED_AT(); + try { + const result = await requestUrl(source.url, { + timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS, + redirectsRemaining: MAX_REDIRECTS, + method: 'HEAD', + }); + + if (result.statusCode === 405 || result.statusCode === 403) { + return requestUrl(source.url, { + timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS, + redirectsRemaining: MAX_REDIRECTS, + method: 'GET', + checkedAt, + }); + } + + return { ...result, checkedAt }; + } catch (error) { + return { + ok: false, + statusCode: null, + finalUrl: source.url, + checkedAt, + error: error.message, + }; + } +} + +function requestUrl(url, options) { + return new Promise(resolve => { + const parsed = new URL(url); + const client = parsed.protocol === 'http:' ? http : https; + const request = client.request(parsed, { + method: options.method || 'HEAD', + timeout: options.timeoutMs || DEFAULT_TIMEOUT_MS, + headers: { + 'User-Agent': 'ecc-supply-chain-watch/2.0', + Accept: 'text/html,application/json;q=0.9,*/*;q=0.8', + }, + }, response => { + const statusCode = response.statusCode || 0; + const location = response.headers.location; + if ( + statusCode >= 300 + && statusCode < 400 + && location + && options.redirectsRemaining > 0 + ) { + response.resume(); + const nextUrl = new URL(location, parsed).toString(); + resolve(requestUrl(nextUrl, { + ...options, + redirectsRemaining: options.redirectsRemaining - 1, + })); + return; + } + + response.resume(); + response.on('end', () => { + resolve({ + ok: statusCode >= 200 && statusCode < 400, + statusCode, + finalUrl: url, + }); + }); + }); + + request.on('timeout', () => { + request.destroy(new Error(`timed out after ${options.timeoutMs || DEFAULT_TIMEOUT_MS}ms`)); + }); + + request.on('error', error => { + resolve({ + ok: false, + statusCode: null, + finalUrl: url, + error: error.message, + }); + }); + + request.end(); + }); +} + +function buildLinearStatus(report, sources) { + const primaryEvidence = sources + .filter(source => [ + 'primary-incident-postmortem', + 'security-advisory', + 'vendor-response', + 'incident-analysis', + ].includes(source.sourceType)) + .slice(0, 5) + .map(source => `${source.publisher}: ${source.title}`); + + return { + issueId: 'ITO-57', + status: 'in_progress', + summary: report.ready + ? 'Advisory sources current; scheduled supply-chain watch now emits source refresh evidence.' + : 'Advisory source coverage needs repair before release readiness.', + evidence: primaryEvidence, + remaining: 'Linear status synchronization still needs a live connector/status-update pass after each significant merge batch.', + }; +} + +async function buildAdvisorySourceReport(options = {}) { + const generatedAt = options.generatedAt || DEFAULT_GENERATED_AT(); + const sources = (options.sources || DEFAULT_ADVISORY_SOURCES).map(source => ({ + ...source, + ecosystems: normalizeArray(source.ecosystems), + signals: normalizeArray(source.signals), + })); + const checks = validateSources(sources); + const refreshEnabled = Boolean(options.refresh); + const fetchSource = options.fetchSource || defaultFetchSource; + let refreshWarnings = 0; + + const reportSources = []; + for (const source of sources) { + let refreshStatus = { status: 'not_requested' }; + if (refreshEnabled && source.refresh !== false) { + const result = await fetchSource(source, { + timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS, + checkedAt: generatedAt, + }); + refreshStatus = refreshStatusFromResult(result); + if (refreshStatus.status !== 'ok') refreshWarnings += 1; + } + reportSources.push({ ...source, refreshStatus }); + } + + if (refreshEnabled) { + checks.push(createCheck( + 'advisory-refresh', + refreshWarnings === 0 ? 'pass' : 'warn', + refreshWarnings === 0 + ? 'all advisory source URLs responded during refresh' + : `${refreshWarnings} advisory source URL(s) returned warnings during refresh`, + 'Review warning sources manually before changing IOC coverage or release evidence.', + )); + } else { + checks.push(createCheck( + 'advisory-refresh', + 'pass', + 'live advisory refresh not requested for this offline source contract report', + 'Run with --refresh in the scheduled watch to capture live URL status evidence.', + )); + } + + const ready = checks.every(check => check.status !== 'fail'); + const report = { + schema_version: 'ecc.supply-chain-advisory-sources.v1', + generatedAt, + ready, + refresh: { + enabled: refreshEnabled, + ok: refreshEnabled ? refreshWarnings === 0 : null, + warningCount: refreshWarnings, + }, + sources: reportSources, + checks, + }; + + report.linear = { + status: buildLinearStatus(report, reportSources), + }; + + return report; +} + +function parseArgs(argv) { + const options = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--help' || arg === '-h') { + options.help = true; + } else if (arg === '--json') { + options.json = true; + } else if (arg === '--refresh') { + options.refresh = true; + } else if (arg === '--strict-refresh') { + options.strictRefresh = true; + options.refresh = true; + } else if (arg === '--generated-at') { + options.generatedAt = argv[++i]; + } else if (arg === '--timeout-ms') { + options.timeoutMs = Number(argv[++i]); + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) { + throw new Error('--timeout-ms must be a positive number'); + } + } else if (arg === '--write') { + options.writePath = argv[++i]; + if (!options.writePath) throw new Error('--write requires a path'); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return options; +} + +function printHelp() { + console.log(`Usage: node scripts/ci/supply-chain-advisory-sources.js [options] + +Build the active supply-chain advisory source report used by the scheduled +watch workflow and Linear ITO-57 status updates. + +Options: + --json Emit JSON instead of text + --refresh Check source URLs and record warning status + --strict-refresh Fail when a refreshed source URL returns a warning + --generated-at Override the report timestamp + --timeout-ms Per-source refresh timeout (default: ${DEFAULT_TIMEOUT_MS}) + --write Write the report to a file + --help, -h Show this help +`); +} + +function renderText(report) { + const lines = [ + `Supply-chain advisory sources: ${report.ready ? 'ready' : 'blocked'}`, + `Sources: ${report.sources.length}`, + `Refresh: ${report.refresh.enabled ? (report.refresh.ok ? 'ok' : `warnings=${report.refresh.warningCount}`) : 'not requested'}`, + `Linear ${report.linear.status.issueId}: ${report.linear.status.summary}`, + ]; + + for (const check of report.checks) { + lines.push(`- ${check.status.toUpperCase()} ${check.id}: ${check.summary}`); + } + + return `${lines.join('\n')}\n`; +} + +function writeReport(report, writePath) { + const absolutePath = path.resolve(writePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, `${JSON.stringify(report, null, 2)}\n`); +} + +if (require.main === module) { + (async () => { + try { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + process.exit(0); + } + + const report = await buildAdvisorySourceReport(options); + if (options.writePath) writeReport(report, options.writePath); + + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + process.stdout.write(renderText(report)); + } + + const failed = !report.ready || (options.strictRefresh && report.refresh.enabled && !report.refresh.ok); + process.exit(failed ? 1 : 0); + } catch (error) { + console.error(error.message); + process.exit(2); + } + })(); +} + +module.exports = { + DEFAULT_ADVISORY_SOURCES, + buildAdvisorySourceReport, + parseArgs, + renderText, +}; diff --git a/scripts/operator-readiness-dashboard.js b/scripts/operator-readiness-dashboard.js index 28c15234..54ced767 100644 --- a/scripts/operator-readiness-dashboard.js +++ b/scripts/operator-readiness-dashboard.js @@ -287,6 +287,7 @@ function buildRequirements(rootDir, platformReport) { const observabilityReadiness = readText(rootDir, 'docs/architecture/observability-readiness.md'); const stalePrSalvage = readText(rootDir, 'docs/stale-pr-salvage-ledger.md'); const supplyChainRunbook = readText(rootDir, 'docs/security/supply-chain-incident-response.md'); + const supplyChainWorkflow = readText(rootDir, '.github/workflows/supply-chain-watch.yml'); const packageJson = readPackage(rootDir); const scripts = packageJson.scripts || {}; @@ -444,12 +445,16 @@ function buildRequirements(rootDir, platformReport) { 'supply-chain-local-protection', 'Keep Mini Shai-Hulud/TanStack protection loop current', 'supply-chain watch plus runbook', - includesAll(supplyChainRunbook, ['TanStack', 'Mini Shai-Hulud', 'scan-supply-chain-iocs.js']) + includesAll(supplyChainRunbook, ['TanStack', 'Mini Shai-Hulud', 'scan-supply-chain-iocs.js', 'supply-chain-advisory-sources.js']) + && includesAll(supplyChainWorkflow, ['supply-chain-advisory-sources.js', 'supply-chain-advisory-sources.json']) + && scripts['security:advisory-sources'] === 'node scripts/ci/supply-chain-advisory-sources.js' && fileExists(rootDir, '.github/workflows/supply-chain-watch.yml') ? 'current' : 'in_progress', - 'scheduled supply-chain watch and runbook are present', - 'advisory-source refresh automation and Linear status synchronization remain ITO-57 follow-up' + scripts['security:advisory-sources'] === 'node scripts/ci/supply-chain-advisory-sources.js' + ? 'scheduled supply-chain watch now emits IOC and advisory-source refresh artifacts' + : 'scheduled supply-chain watch or advisory-source command is missing', + 'Linear status synchronization remains ITO-57 follow-up after each significant merge batch' ), ]; } @@ -502,7 +507,7 @@ function buildReport(options) { top_actions: topActions, next_work_order: [ 'Regenerate this dashboard from the final release commit before publication evidence is recorded.', - 'Continue ITO-57 with advisory-source refresh automation and Linear status synchronization for the scheduled supply-chain watch.', + 'Continue ITO-57 with Linear status synchronization for the scheduled supply-chain watch advisory-source report.', 'Advance ECC Tools live Marketplace test-account readback before publishing native-payments announcement copy.', 'Resume ITO-45, ITO-46, and ITO-56 only after the generated dashboard and final release gates are refreshed.', ], diff --git a/scripts/platform-audit.js b/scripts/platform-audit.js index 5744bf68..34e66299 100644 --- a/scripts/platform-audit.js +++ b/scripts/platform-audit.js @@ -464,8 +464,11 @@ function buildLocalEvidenceChecks(rootDir) { ), buildCheck( 'supply-chain-runbook', - includesAll(supplyChain, ['TanStack', 'Mini Shai-Hulud', 'node-ipc', 'scan-supply-chain-iocs.js']) ? 'pass' : 'fail', - 'supply-chain runbook covers the current TanStack/Mini Shai-Hulud/node-ipc scanner lane', + includesAll(supplyChain, ['TanStack', 'Mini Shai-Hulud', 'node-ipc', 'scan-supply-chain-iocs.js', 'supply-chain-advisory-sources.js']) + && packageScripts['security:advisory-sources'] === 'node scripts/ci/supply-chain-advisory-sources.js' + ? 'pass' + : 'fail', + 'supply-chain runbook covers the current TanStack/Mini Shai-Hulud/node-ipc scanner and advisory-source lanes', { path: 'docs/security/supply-chain-incident-response.md' } ), buildCheck( diff --git a/tests/ci/supply-chain-advisory-sources.test.js b/tests/ci/supply-chain-advisory-sources.test.js new file mode 100644 index 00000000..e3661403 --- /dev/null +++ b/tests/ci/supply-chain-advisory-sources.test.js @@ -0,0 +1,157 @@ +#!/usr/bin/env node +/** + * Validate the supply-chain advisory source refresh report. + */ + +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', + 'supply-chain-advisory-sources.js', +); + +const { + DEFAULT_ADVISORY_SOURCES, + buildAdvisorySourceReport, +} = require(SCRIPT_PATH); + +async function test(name, fn) { + try { + await fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +async function run() { + console.log('\n=== Testing supply-chain advisory source refresh ===\n'); + + let passed = 0; + let failed = 0; + + if (await test('default sources cover the active npm and PyPI campaign', async () => { + const ids = DEFAULT_ADVISORY_SOURCES.map(source => source.id); + for (const requiredId of [ + 'tanstack-postmortem', + 'github-ghsa-g7cv-rxg3-hmpx', + 'stepsecurity-mini-shai-hulud', + 'openai-tanstack-response', + 'socket-node-ipc', + 'cisa-npm-compromise', + ]) { + assert.ok(ids.includes(requiredId), `Missing advisory source ${requiredId}`); + } + + const ecosystemCoverage = new Set(DEFAULT_ADVISORY_SOURCES.flatMap(source => source.ecosystems)); + assert.ok(ecosystemCoverage.has('npm')); + assert.ok(ecosystemCoverage.has('PyPI')); + assert.ok(ecosystemCoverage.has('AI developer tooling')); + })) passed++; else failed++; + + if (await test('offline report emits passing coverage checks and Linear-ready ITO-57 payload', async () => { + const report = await buildAdvisorySourceReport({ + generatedAt: '2026-05-16T00:00:00.000Z', + refresh: false, + }); + + assert.strictEqual(report.schema_version, 'ecc.supply-chain-advisory-sources.v1'); + assert.strictEqual(report.ready, true); + assert.strictEqual(report.refresh.enabled, false); + assert.ok(report.sources.length >= 8); + assert.ok(report.checks.every(check => check.status === 'pass')); + assert.strictEqual(report.linear.status.issueId, 'ITO-57'); + assert.match(report.linear.status.summary, /advisory sources current/i); + assert.match(report.linear.status.remaining, /Linear status/i); + })) passed++; else failed++; + + if (await test('refresh mode records per-source live check results', async () => { + const calls = []; + const report = await buildAdvisorySourceReport({ + generatedAt: '2026-05-16T00:00:00.000Z', + refresh: true, + fetchSource: async source => { + calls.push(source.id); + return { + ok: true, + statusCode: 200, + finalUrl: source.url, + checkedAt: '2026-05-16T00:00:00.000Z', + }; + }, + }); + + assert.deepStrictEqual( + calls.sort(), + DEFAULT_ADVISORY_SOURCES.filter(source => source.refresh !== false).map(source => source.id).sort(), + ); + assert.strictEqual(report.refresh.enabled, true); + assert.strictEqual(report.refresh.ok, true); + assert.ok(report.sources.every(source => source.refreshStatus.status === 'ok')); + })) passed++; else failed++; + + if (await test('refresh errors are captured as evidence without breaking offline source coverage', async () => { + const report = await buildAdvisorySourceReport({ + generatedAt: '2026-05-16T00:00:00.000Z', + refresh: true, + fetchSource: async source => ({ + ok: source.id !== 'socket-node-ipc', + statusCode: source.id === 'socket-node-ipc' ? 403 : 200, + error: source.id === 'socket-node-ipc' ? 'forbidden' : null, + finalUrl: source.url, + checkedAt: '2026-05-16T00:00:00.000Z', + }), + }); + + const socketSource = report.sources.find(source => source.id === 'socket-node-ipc'); + assert.strictEqual(report.ready, true); + assert.strictEqual(report.refresh.ok, false); + assert.strictEqual(socketSource.refreshStatus.status, 'warning'); + assert.match(socketSource.refreshStatus.error, /forbidden/); + assert.ok(report.checks.some(check => check.id === 'advisory-refresh' && check.status === 'warn')); + })) passed++; else failed++; + + if (await test('CLI JSON can be written as a scheduled workflow artifact', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-advisory-sources-')); + const outputPath = path.join(tempDir, 'advisory-sources.json'); + try { + const result = spawnSync('node', [ + SCRIPT_PATH, + '--json', + '--generated-at', + '2026-05-16T00:00:00.000Z', + '--write', + outputPath, + ], { + encoding: 'utf8', + shell: process.platform === 'win32', + }); + + assert.strictEqual(result.status, 0, result.stderr); + const parsed = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + assert.strictEqual(parsed.schema_version, 'ecc.supply-chain-advisory-sources.v1'); + assert.strictEqual(parsed.ready, true); + assert.ok(parsed.linear.status.evidence.length >= 3); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + + process.exit(failed > 0 ? 1 : 0); +} + +run(); diff --git a/tests/ci/supply-chain-watch-workflow.test.js b/tests/ci/supply-chain-watch-workflow.test.js index f4946ff6..b1388137 100644 --- a/tests/ci/supply-chain-watch-workflow.test.js +++ b/tests/ci/supply-chain-watch-workflow.test.js @@ -58,9 +58,13 @@ function run() { if (test('runs IOC fixtures, emits JSON report, and uploads the artifact', () => { assert.match(source, /node tests\/ci\/scan-supply-chain-iocs\.test\.js/); assert.match(source, /node scripts\/ci\/scan-supply-chain-iocs\.js --json > artifacts\/supply-chain-ioc-report\.json/); + assert.match(source, /node tests\/ci\/supply-chain-advisory-sources\.test\.js/); + assert.match(source, /node scripts\/ci\/supply-chain-advisory-sources\.js --refresh --json > artifacts\/supply-chain-advisory-sources\.json/); assert.match(source, /node scripts\/ci\/validate-workflow-security\.js/); assert.match(source, /uses: actions\/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a/); assert.match(source, /name: supply-chain-ioc-report/); + assert.match(source, /artifacts\/supply-chain-ioc-report\.json/); + assert.match(source, /artifacts\/supply-chain-advisory-sources\.json/); assert.match(source, /retention-days: 14/); })) passed++; else failed++; diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index 5cbe3a2f..df4042d4 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -44,6 +44,7 @@ function buildExpectedPublishPaths(repoRoot) { "scripts/ecc.js", "scripts/catalog.js", "scripts/ci/scan-supply-chain-iocs.js", + "scripts/ci/supply-chain-advisory-sources.js", "scripts/consult.js", "scripts/claw.js", "scripts/discussion-audit.js", @@ -124,6 +125,7 @@ function main() { for (const requiredPath of [ "scripts/catalog.js", "scripts/ci/scan-supply-chain-iocs.js", + "scripts/ci/supply-chain-advisory-sources.js", "scripts/consult.js", "scripts/discussion-audit.js", "scripts/operator-readiness-dashboard.js", diff --git a/tests/scripts/operator-readiness-dashboard.test.js b/tests/scripts/operator-readiness-dashboard.test.js index adc3e686..d20d4400 100644 --- a/tests/scripts/operator-readiness-dashboard.test.js +++ b/tests/scripts/operator-readiness-dashboard.test.js @@ -39,7 +39,8 @@ function seedRepo(rootDir, overrides = {}) { 'observability:ready': 'node scripts/observability-readiness.js', 'operator:dashboard': 'node scripts/operator-readiness-dashboard.js', 'platform:audit': 'node scripts/platform-audit.js', - 'security:ioc-scan': 'node scripts/ci/scan-supply-chain-iocs.js' + 'security:ioc-scan': 'node scripts/ci/scan-supply-chain-iocs.js', + 'security:advisory-sources': 'node scripts/ci/supply-chain-advisory-sources.js' } }, null, 2), 'scripts/operator-readiness-dashboard.js': 'operator dashboard generator', @@ -74,9 +75,9 @@ function seedRepo(rootDir, overrides = {}) { 'docs/stale-pr-salvage-ledger.md': 'Manual review tail', 'docs/architecture/progress-sync-contract.md': 'GitHub PRs/issues/discussions Linear project local handoff repo roadmap scripts/work-items.js', 'docs/architecture/observability-readiness.md': 'observability-readiness.js', - 'docs/security/supply-chain-incident-response.md': 'TanStack Mini Shai-Hulud node-ipc scan-supply-chain-iocs.js', + 'docs/security/supply-chain-incident-response.md': 'TanStack Mini Shai-Hulud node-ipc scan-supply-chain-iocs.js supply-chain-advisory-sources.js', 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md': 'TanStack Mini Shai-Hulud Node IPC follow-up node-ipc IOC scan', - '.github/workflows/supply-chain-watch.yml': 'name: Supply-Chain Watch' + '.github/workflows/supply-chain-watch.yml': 'name: Supply-Chain Watch supply-chain-advisory-sources.js supply-chain-advisory-sources.json' }; for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) { diff --git a/tests/scripts/platform-audit.test.js b/tests/scripts/platform-audit.test.js index 8eff0022..3419d182 100644 --- a/tests/scripts/platform-audit.test.js +++ b/tests/scripts/platform-audit.test.js @@ -35,6 +35,7 @@ function seedRepo(rootDir, overrides = {}) { 'operator:dashboard': 'node scripts/operator-readiness-dashboard.js', 'observability:ready': 'node scripts/observability-readiness.js', 'security:ioc-scan': 'node scripts/ci/scan-supply-chain-iocs.js', + 'security:advisory-sources': 'node scripts/ci/supply-chain-advisory-sources.js', 'harness:audit': 'node scripts/harness-audit.js' } }, null, 2), @@ -55,7 +56,8 @@ function seedRepo(rootDir, overrides = {}) { 'TanStack', 'Mini Shai-Hulud', 'node-ipc', - 'scan-supply-chain-iocs.js' + 'scan-supply-chain-iocs.js', + 'supply-chain-advisory-sources.js' ].join('\n'), 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md': [ 'TanStack',