// 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} 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', ]; // Popular npm packages for scope-hop detection (E13). Subset of // knowledge/top-packages.json npm list focused on names most attractive // as a scope-hop lure. Kept hardcoded to keep hook startup synchronous. export const POPULAR_NPM = [ 'express', 'react', 'react-dom', 'lodash', 'axios', 'chalk', 'commander', 'debug', 'dotenv', 'eslint', 'jest', 'mocha', 'webpack', 'typescript', 'next', 'vue', 'angular', 'moment', 'dayjs', 'uuid', 'minimist', 'yargs', 'semver', 'mkdirp', 'fs-extra', 'cross-env', 'concurrently', 'nodemon', 'prettier', 'ts-node', 'rxjs', 'redux', 'react-redux', 'styled-components', 'tailwindcss', 'postcss', 'autoprefixer', 'sass', 'less', 'parcel', 'lerna', 'http-server', 'serve', 'cors', 'body-parser', 'cookie-parser', 'express-session', 'passport', 'jsonwebtoken', 'bcrypt', 'bcryptjs', 'mongoose', 'sequelize', 'prisma', 'typeorm', 'knex', 'pg', 'mysql2', 'sqlite3', 'ioredis', 'aws-sdk', 'firebase', 'graphql', 'apollo-server', 'socket.io', 'ws', 'puppeteer', 'playwright', 'cheerio', 'jsdom', 'sharp', 'jimp', 'multer', 'nodemailer', 'bull', 'cron', 'winston', 'pino', 'morgan', 'helmet', 'compression', 'joi', 'yup', 'ajv', 'validator', 'marked', 'three', 'chart.js', 'date-fns', 'underscore', 'ramda', 'immer', 'execa', 'shelljs', 'fast-glob', 'micromatch', 'inquirer', 'ora', 'boxen', 'node-fetch', 'got', 'supertest', ]; // Official npm scopes that publish well-known packages. A scoped install // like `@types/lodash` whose unscoped name matches a popular package is // only suspicious if `@types` is NOT on this list. Mirrored into // knowledge/typosquat-allowlist.json as `npm_official_scopes` for the // doc-consistency drift-guard test. export const NPM_OFFICIAL_SCOPES = [ '@types', '@reduxjs', '@nestjs', '@angular', '@nrwl', '@modelcontextprotocol', '@babel', '@testing-library', '@aws-sdk', '@azure', '@google-cloud', '@vue', '@svelte', '@nuxt', '@sveltejs', '@vitejs', '@playwright', '@storybook', '@radix-ui', '@reach', '@emotion', '@mui', ]; /** * E13: scope-hopping detector. Returns null if `name` is not a scope-hop * candidate, or `{ scope, unscoped, spec }` if it is. A scope-hop is a * scoped npm name `@/` where: * - `` is NOT on NPM_OFFICIAL_SCOPES, * - `` is NOT on `extraAllowedScopes` (e.g. policy.json), and * - `` matches a popular npm package (POPULAR_NPM). * * @param {string} name Full package name (`@scope/pkg` or bare) * @param {string[]} [extraAllowedScopes=[]] Additional scopes to whitelist * @returns {{ scope: string, unscoped: string, spec: string } | null} */ export function checkScopeHop(name, extraAllowedScopes = []) { if (typeof name !== 'string') return null; const m = name.match(/^(@[\w-]+)\/(.+)$/); if (!m) return null; const scope = m[1]; const unscoped = m[2]; if (NPM_OFFICIAL_SCOPES.includes(scope)) return null; if (Array.isArray(extraAllowedScopes) && extraAllowedScopes.includes(scope)) return null; if (!POPULAR_NPM.includes(unscoped)) return null; return { scope, unscoped, spec: name }; } // =========================================================================== // Helper functions // =========================================================================== /** * Check if a package name+version is on a compromised blocklist. * @param {Record} 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 }; }