Add supply-chain advisory source refresh

This commit is contained in:
Affaan Mustafa
2026-05-15 22:39:35 -04:00
committed by Affaan Mustafa
parent 2d46c00763
commit a8e3bcb00f
11 changed files with 675 additions and 13 deletions

View File

@@ -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 <ts> Override the report timestamp
--timeout-ms <n> Per-source refresh timeout (default: ${DEFAULT_TIMEOUT_MS})
--write <path> 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,
};