ktg-plugin-marketplace/plugins/llm-security/scanners/lib/supply-chain-data.mjs

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 };
}