284 lines
9.3 KiB
JavaScript
284 lines
9.3 KiB
JavaScript
// supply-chain-data.mjs — Shared blocklists, parsers, and OSV.dev API for supply chain checks
|
|
// Used by: pre-install-supply-chain.mjs (hook) and supply-chain-recheck.mjs (scanner)
|
|
// Zero external dependencies (Node.js builtins only).
|
|
|
|
import { execSync } from 'node:child_process';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cross-platform HTTP helper (replaces curl subprocess)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Fetch JSON from a URL with timeout. Cross-platform (no curl dependency).
|
|
* @param {string} url
|
|
* @param {object} [options] - fetch options (method, headers, body)
|
|
* @param {number} [timeoutMs=8000]
|
|
* @returns {Promise<object|null>} Parsed JSON or null on failure
|
|
*/
|
|
async function fetchJSON(url, options = {}, timeoutMs = 8000) {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
const res = await fetch(url, { ...options, signal: controller.signal });
|
|
clearTimeout(timer);
|
|
if (!res.ok) return null;
|
|
return await res.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Age threshold for new package detection (hours)
|
|
// ===========================================================================
|
|
|
|
export const AGE_THRESHOLD_HOURS = 72;
|
|
|
|
// ===========================================================================
|
|
// KNOWN COMPROMISED — curated blocklists per ecosystem
|
|
// '*' = all versions blocked (entirely malicious package)
|
|
// ===========================================================================
|
|
|
|
export const NPM_COMPROMISED = {
|
|
'axios': ['1.14.1', '0.30.4'],
|
|
'event-stream': ['3.3.6'],
|
|
'ua-parser-js': ['0.7.29', '0.8.0', '1.0.0'],
|
|
'coa': ['2.0.3', '2.0.4', '2.1.1', '2.1.3'],
|
|
'rc': ['1.2.9', '1.3.9', '2.3.9'],
|
|
'colors': ['1.4.1', '1.4.2'],
|
|
'faker': ['6.6.6'],
|
|
'node-ipc': ['10.1.1', '10.1.2', '10.1.3', '11.0.0', '11.1.0'],
|
|
'peacenotwar': ['*'],
|
|
'plain-crypto-js': ['*'],
|
|
};
|
|
|
|
export const PIP_COMPROMISED = {
|
|
'colourama': ['*'],
|
|
'python3-dateutil': ['*'],
|
|
'jeIlyfish': ['*'],
|
|
'python-binance': ['*'],
|
|
'openai-api': ['*'],
|
|
'requesocks': ['*'],
|
|
'python-mongo': ['*'],
|
|
'nmap-python': ['*'],
|
|
'beautifulsoup': ['*'],
|
|
'djanga': ['*'],
|
|
'httpslib2': ['*'],
|
|
'urllib4': ['*'],
|
|
'pipsqlite3': ['*'],
|
|
'torlogging': ['*'],
|
|
'flasck': ['*'],
|
|
'matploltlib': ['*'],
|
|
'discordi': ['*'],
|
|
'numpyi': ['*'],
|
|
'pycryptdome': ['*'],
|
|
};
|
|
|
|
export const CARGO_COMPROMISED = {
|
|
'rustdecimal': ['*'],
|
|
'cratesio': ['*'],
|
|
};
|
|
|
|
export const GEM_COMPROMISED = {
|
|
'rest-client': ['1.6.13'],
|
|
'strong_password': ['0.0.7'],
|
|
'bootstrap-sass': ['3.2.0.3'],
|
|
};
|
|
|
|
export const DOCKER_SUSPICIOUS = [
|
|
/xmrig/i,
|
|
/cryptonight/i,
|
|
/monero-?miner/i,
|
|
/coin-?hive/i,
|
|
];
|
|
|
|
// Popular PyPI packages for typosquat detection (used by hook)
|
|
export const POPULAR_PIP = [
|
|
'requests', 'flask', 'django', 'numpy', 'pandas', 'scipy', 'matplotlib',
|
|
'tensorflow', 'torch', 'opencv-python', 'pillow', 'beautifulsoup4',
|
|
'sqlalchemy', 'celery', 'redis', 'boto3', 'openai', 'anthropic',
|
|
'fastapi', 'uvicorn', 'pydantic', 'httpx', 'aiohttp', 'colorama',
|
|
'cryptography', 'pycryptodome', 'paramiko', 'fabric', 'pytest',
|
|
'setuptools', 'pip', 'wheel', 'twine', 'black', 'mypy', 'ruff',
|
|
'python-dateutil', 'jellyfish', 'pymongo', 'psycopg2', 'python-nmap',
|
|
'discord.py', 'selenium', 'scrapy', 'lxml', 'pyyaml',
|
|
];
|
|
|
|
// ===========================================================================
|
|
// Helper functions
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Check if a package name+version is on a compromised blocklist.
|
|
* @param {Record<string, string[]>} list - Blocklist object
|
|
* @param {string} name - Package name
|
|
* @param {string|null} version - Package version (null = any)
|
|
* @returns {boolean}
|
|
*/
|
|
export function isCompromised(list, name, version) {
|
|
const bad = list[name];
|
|
if (!bad) return false;
|
|
if (bad.includes('*')) return true;
|
|
if (version && bad.includes(version)) return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Parse an npm package specifier (e.g. "@scope/pkg@1.0.0" or "pkg@1.0.0").
|
|
* @param {string} spec
|
|
* @returns {{ name: string, version: string|null }}
|
|
*/
|
|
export function parseSpec(spec) {
|
|
if (spec.startsWith('@')) {
|
|
const rest = spec.slice(1);
|
|
const atIdx = rest.lastIndexOf('@');
|
|
if (atIdx > 0) return { name: '@' + rest.slice(0, atIdx), version: rest.slice(atIdx + 1) };
|
|
return { name: spec, version: null };
|
|
}
|
|
const atIdx = spec.lastIndexOf('@');
|
|
if (atIdx > 0) return { name: spec.slice(0, atIdx), version: spec.slice(atIdx + 1) };
|
|
return { name: spec, version: null };
|
|
}
|
|
|
|
/**
|
|
* Parse a pip package specifier (e.g. "requests==2.28.0" or "flask>=2.0").
|
|
* @param {string} spec
|
|
* @returns {{ name: string, version: string|null }}
|
|
*/
|
|
export function parsePipSpec(spec) {
|
|
const eqIdx = spec.indexOf('==');
|
|
if (eqIdx > 0) return { name: spec.slice(0, eqIdx), version: spec.slice(eqIdx + 2) };
|
|
const match = spec.match(/^([a-zA-Z0-9_.-]+)/);
|
|
return { name: match ? match[1] : spec, version: null };
|
|
}
|
|
|
|
/**
|
|
* Execute a shell command safely with timeout.
|
|
* @param {string} cmd
|
|
* @param {number} [timeoutMs=10000]
|
|
* @returns {string|null}
|
|
*/
|
|
export function execSafe(cmd, timeoutMs = 10000) {
|
|
try {
|
|
return execSync(cmd, { timeout: timeoutMs, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
} catch (err) {
|
|
return err.stdout || null;
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// OSV.dev API — unified vulnerability database
|
|
// ===========================================================================
|
|
|
|
/** Map ecosystem names to OSV format. */
|
|
export const OSV_ECOSYSTEM_MAP = {
|
|
npm: 'npm',
|
|
pip: 'PyPI',
|
|
cargo: 'crates.io',
|
|
gem: 'RubyGems',
|
|
go: 'Go',
|
|
};
|
|
|
|
/**
|
|
* Extract severity from an OSV vulnerability record.
|
|
* @param {object} vuln - OSV vulnerability object
|
|
* @returns {string} - 'CRITICAL', 'HIGH', or 'MEDIUM'
|
|
*/
|
|
export function extractOSVSeverity(vuln) {
|
|
const dbSev = vuln.database_specific?.severity;
|
|
if (dbSev) return dbSev.toUpperCase();
|
|
|
|
const ecoSev = vuln.ecosystem_specific?.severity;
|
|
if (ecoSev) return ecoSev.toUpperCase();
|
|
|
|
for (const sev of vuln.severity || []) {
|
|
if (sev.score && typeof sev.score === 'number') {
|
|
if (sev.score >= 9.0) return 'CRITICAL';
|
|
if (sev.score >= 7.0) return 'HIGH';
|
|
return 'MEDIUM';
|
|
}
|
|
}
|
|
|
|
if (vuln.id?.startsWith('GHSA') || vuln.id?.startsWith('CVE')) return 'HIGH';
|
|
return 'MEDIUM';
|
|
}
|
|
|
|
/**
|
|
* Query OSV.dev for vulnerabilities on a single package version.
|
|
* Used by the hook (real-time, single package).
|
|
* @param {string} ecosystem - 'npm', 'pip', 'cargo', 'gem', 'go'
|
|
* @param {string} name
|
|
* @param {string} version
|
|
* @returns {Promise<{ critical: object[], high: object[] }>}
|
|
*/
|
|
export async function queryOSV(ecosystem, name, version) {
|
|
const critical = [];
|
|
const high = [];
|
|
|
|
const osvEcosystem = OSV_ECOSYSTEM_MAP[ecosystem];
|
|
if (!osvEcosystem) return { critical, high };
|
|
|
|
try {
|
|
const result = await fetchJSON('https://api.osv.dev/v1/query', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
version,
|
|
package: { name, ecosystem: osvEcosystem },
|
|
}),
|
|
}, 8000);
|
|
if (!result) return { critical, high };
|
|
|
|
for (const vuln of result.vulns || []) {
|
|
const severity = extractOSVSeverity(vuln);
|
|
const entry = {
|
|
id: vuln.id,
|
|
summary: (vuln.summary || vuln.details || 'No description').slice(0, 120),
|
|
severity,
|
|
};
|
|
if (severity === 'CRITICAL') critical.push(entry);
|
|
else if (severity === 'HIGH') high.push(entry);
|
|
}
|
|
} catch { /* network error — fail open */ }
|
|
|
|
return { critical, high };
|
|
}
|
|
|
|
/**
|
|
* Query OSV.dev batch API for multiple packages at once.
|
|
* Used by the scanner (periodic re-check of all lockfile deps).
|
|
* Falls back gracefully if network is unavailable.
|
|
* @param {{ ecosystem: string, name: string, version: string }[]} packages
|
|
* @returns {Promise<{ results: Array<{ vulns: object[] }>, offline: boolean }>}
|
|
*/
|
|
export async function queryOSVBatch(packages) {
|
|
if (packages.length === 0) return { results: [], offline: false };
|
|
|
|
const queries = packages.map(pkg => ({
|
|
version: pkg.version,
|
|
package: { name: pkg.name, ecosystem: OSV_ECOSYSTEM_MAP[pkg.ecosystem] || pkg.ecosystem },
|
|
}));
|
|
|
|
// OSV batch API accepts max 1000 queries per request
|
|
const BATCH_SIZE = 1000;
|
|
const allResults = [];
|
|
|
|
for (let i = 0; i < queries.length; i += BATCH_SIZE) {
|
|
const batch = queries.slice(i, i + BATCH_SIZE);
|
|
|
|
try {
|
|
const result = await fetchJSON('https://api.osv.dev/v1/querybatch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ queries: batch }),
|
|
}, 15000);
|
|
if (!result) return { results: [], offline: true };
|
|
|
|
allResults.push(...(result.results || []));
|
|
} catch {
|
|
return { results: [], offline: true };
|
|
}
|
|
}
|
|
|
|
return { results: allResults, offline: false };
|
|
}
|