feat(llm-security-copilot): port llm-security v5.1.0 to GitHub Copilot CLI
Full port of llm-security plugin for internal use on Windows with GitHub Copilot CLI. Protocol translation layer (copilot-hook-runner.mjs) normalizes Copilot camelCase I/O to Claude Code snake_case format — all original hook scripts run unmodified. - 8 hooks with protocol translation (stdin/stdout/exit code) - 18 SKILL.md skills (Agent Skills Open Standard) - 6 .agent.md agent definitions - 20 scanners + 14 scanner lib modules (unchanged) - 14 knowledge files (unchanged) - 39 test files including copilot-port-verify.mjs (17 tests) - Windows-ready: node:path, os.tmpdir(), process.execPath, no bash Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
901bf0ae12
commit
f418a8fe08
169 changed files with 37631 additions and 0 deletions
284
plugins/llm-security-copilot/scanners/lib/supply-chain-data.mjs
Normal file
284
plugins/llm-security-copilot/scanners/lib/supply-chain-data.mjs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
// 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue