Adds a scope-hopping detector to the npm install gate. When a user
installs `@<scope>/<unscoped>`, the hook now emits a MEDIUM warning
on stderr (exit 0, never blocks) if:
- `<unscoped>` matches a popular npm package (POPULAR_NPM, ~80
names from knowledge/top-packages.json), AND
- `<scope>` is not on NPM_OFFICIAL_SCOPES (built-in 22 entries) or
on policy.json `supply_chain.allowed_scopes`.
Why: an attacker publishing `@evilcorp/lodash` cannot squat the bare
`lodash` name, but they can register an unrelated scope and rely on
typo or copy-paste to trick installs. NPM_OFFICIAL_SCOPES anchors the
known-good scopes (@types, @reduxjs, @nestjs, …) so legitimate
installs stay silent.
Implementation:
- `scanners/lib/supply-chain-data.mjs`: exports POPULAR_NPM,
NPM_OFFICIAL_SCOPES, and `checkScopeHop(name, extraAllowedScopes)` —
pure function, no policy/network dependency, fully unit-testable.
- `knowledge/typosquat-allowlist.json`: mirrors NPM_OFFICIAL_SCOPES as
`npm_official_scopes`. A doc-consistency assertion ensures the two
lists never drift.
- `hooks/scripts/pre-install-supply-chain.mjs`: imports checkScopeHop,
reads `supply_chain.allowed_scopes` from policy, and pushes a
warning before existing compromised/audit checks.
Tests:
- 9 new cases in tests/hooks/pre-install-supply-chain.test.mjs:
TP @evilcorp/lodash, TP @attacker/express, allowlist @types,
allowlist @reduxjs, allowlist @modelcontextprotocol, FP unscoped
name not in top-100, bare unscoped name, policy override, defensive
non-string input, NPM_OFFICIAL_SCOPES <-> typosquat-allowlist.json
consistency.
344 lines
12 KiB
JavaScript
344 lines
12 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',
|
|
];
|
|
|
|
// 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 `@<scope>/<unscoped>` where:
|
|
* - `<scope>` is NOT on NPM_OFFICIAL_SCOPES,
|
|
* - `<scope>` is NOT on `extraAllowedScopes` (e.g. policy.json), and
|
|
* - `<unscoped>` 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<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 };
|
|
}
|