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
459
plugins/llm-security-copilot/scanners/supply-chain-recheck.mjs
Normal file
459
plugins/llm-security-copilot/scanners/supply-chain-recheck.mjs
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
// supply-chain-recheck.mjs — Periodic re-audit of installed dependencies
|
||||
// Parses lockfiles (package-lock.json, yarn.lock, requirements.txt, Pipfile.lock)
|
||||
// and checks against blocklists, OSV.dev batch API, and typosquat detection.
|
||||
//
|
||||
// Unlike pre-install-supply-chain.mjs (hook, checks at install time),
|
||||
// this scanner checks what's ALREADY installed — catching deps that became
|
||||
// compromised after installation.
|
||||
//
|
||||
// Scanner prefix: SCR
|
||||
// OWASP coverage: LLM03 (Supply Chain), ASI04, AST06, MCP04
|
||||
// Zero external dependencies — Node.js builtins only.
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { levenshtein } from './lib/string-utils.mjs';
|
||||
import {
|
||||
NPM_COMPROMISED, PIP_COMPROMISED, CARGO_COMPROMISED, GEM_COMPROMISED,
|
||||
isCompromised, extractOSVSeverity, queryOSVBatch, OSV_ECOSYSTEM_MAP,
|
||||
} from './lib/supply-chain-data.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-package knowledge base loader (for typosquat detection)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _topPackages = null;
|
||||
let _typosquatAllowlist = null;
|
||||
|
||||
async function loadTopPackages() {
|
||||
if (_topPackages) return _topPackages;
|
||||
const knowledgePath = join(__dirname, '..', 'knowledge', 'top-packages.json');
|
||||
try {
|
||||
const raw = await readFile(knowledgePath, 'utf8');
|
||||
_topPackages = JSON.parse(raw);
|
||||
} catch {
|
||||
_topPackages = { npm: [], pypi: [] };
|
||||
}
|
||||
return _topPackages;
|
||||
}
|
||||
|
||||
async function loadTyposquatAllowlist() {
|
||||
if (_typosquatAllowlist) return _typosquatAllowlist;
|
||||
const allowPath = join(__dirname, '..', 'knowledge', 'typosquat-allowlist.json');
|
||||
try {
|
||||
const raw = await readFile(allowPath, 'utf8');
|
||||
const data = JSON.parse(raw);
|
||||
_typosquatAllowlist = {
|
||||
npm: new Set((data.npm || []).map(n => n.toLowerCase().replace(/[_.-]/g, '-'))),
|
||||
pypi: new Set((data.pypi || []).map(n => n.toLowerCase().replace(/[_.-]/g, '-'))),
|
||||
};
|
||||
} catch {
|
||||
_typosquatAllowlist = { npm: new Set(), pypi: new Set() };
|
||||
}
|
||||
return _typosquatAllowlist;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lockfile parsers — extract { name, version, ecosystem } tuples
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse package-lock.json (v2/v3 format with packages field).
|
||||
* @param {string} filePath - Absolute path to package-lock.json
|
||||
* @returns {Promise<{ name: string, version: string, ecosystem: string }[]>}
|
||||
*/
|
||||
async function parsePackageLock(filePath) {
|
||||
const deps = [];
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
const lock = JSON.parse(raw);
|
||||
|
||||
// v3 format: packages object
|
||||
const packages = lock.packages || {};
|
||||
for (const [key, info] of Object.entries(packages)) {
|
||||
if (key === '') continue; // Root package
|
||||
const name = key.replace(/^node_modules\//, '');
|
||||
if (name && info.version) {
|
||||
deps.push({ name, version: info.version, ecosystem: 'npm' });
|
||||
}
|
||||
}
|
||||
|
||||
// v1 fallback: dependencies object
|
||||
if (deps.length === 0 && lock.dependencies) {
|
||||
for (const [name, info] of Object.entries(lock.dependencies)) {
|
||||
if (info.version) {
|
||||
deps.push({ name, version: info.version, ecosystem: 'npm' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* parse error — skip */ }
|
||||
return deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse yarn.lock (v1 format).
|
||||
* Extracts package name and resolved version from each entry.
|
||||
* @param {string} filePath - Absolute path to yarn.lock
|
||||
* @returns {Promise<{ name: string, version: string, ecosystem: string }[]>}
|
||||
*/
|
||||
async function parseYarnLock(filePath) {
|
||||
const deps = [];
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
const lines = raw.split('\n');
|
||||
let currentPkg = null;
|
||||
|
||||
for (const line of lines) {
|
||||
// Package header: "pkg@^1.0.0", "pkg@1.0.0:" or "@scope/pkg@^1.0.0":
|
||||
if (!line.startsWith(' ') && !line.startsWith('#') && line.includes('@')) {
|
||||
const trimmed = line.replace(/[":]/g, '').trim();
|
||||
if (trimmed.startsWith('@')) {
|
||||
// Scoped: @scope/pkg@version
|
||||
const rest = trimmed.slice(1);
|
||||
const atIdx = rest.indexOf('@');
|
||||
if (atIdx > 0) currentPkg = '@' + rest.slice(0, atIdx);
|
||||
} else {
|
||||
const atIdx = trimmed.indexOf('@');
|
||||
if (atIdx > 0) currentPkg = trimmed.slice(0, atIdx);
|
||||
}
|
||||
}
|
||||
// Version line: " version "1.2.3""
|
||||
const versionMatch = line.match(/^\s+version\s+"([^"]+)"/);
|
||||
if (versionMatch && currentPkg) {
|
||||
deps.push({ name: currentPkg, version: versionMatch[1], ecosystem: 'npm' });
|
||||
currentPkg = null;
|
||||
}
|
||||
}
|
||||
} catch { /* parse error — skip */ }
|
||||
return deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse requirements.txt (pip format).
|
||||
* @param {string} filePath - Absolute path to requirements.txt
|
||||
* @returns {Promise<{ name: string, version: string|null, ecosystem: string }[]>}
|
||||
*/
|
||||
async function parseRequirementsTxt(filePath) {
|
||||
const deps = [];
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
for (const rawLine of raw.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#') || line.startsWith('-')) continue;
|
||||
const eqIdx = line.indexOf('==');
|
||||
if (eqIdx > 0) {
|
||||
deps.push({ name: line.slice(0, eqIdx).trim(), version: line.slice(eqIdx + 2).trim(), ecosystem: 'pip' });
|
||||
} else {
|
||||
const match = line.match(/^([a-zA-Z0-9_.-]+)/);
|
||||
if (match) deps.push({ name: match[1], version: null, ecosystem: 'pip' });
|
||||
}
|
||||
}
|
||||
} catch { /* parse error — skip */ }
|
||||
return deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Pipfile.lock (JSON format).
|
||||
* @param {string} filePath - Absolute path to Pipfile.lock
|
||||
* @returns {Promise<{ name: string, version: string, ecosystem: string }[]>}
|
||||
*/
|
||||
async function parsePipfileLock(filePath) {
|
||||
const deps = [];
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
const lock = JSON.parse(raw);
|
||||
for (const section of ['default', 'develop']) {
|
||||
const packages = lock[section] || {};
|
||||
for (const [name, info] of Object.entries(packages)) {
|
||||
const version = typeof info === 'object' && info.version
|
||||
? info.version.replace(/^==/, '')
|
||||
: null;
|
||||
if (version) {
|
||||
deps.push({ name, version, ecosystem: 'pip' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* parse error — skip */ }
|
||||
return deps;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check all dependencies against blocklists.
|
||||
* @param {{ name: string, version: string, ecosystem: string }[]} deps
|
||||
* @param {string} lockfile - Source lockfile name for finding references
|
||||
* @returns {object[]} findings
|
||||
*/
|
||||
function checkBlocklists(deps, lockfile) {
|
||||
const results = [];
|
||||
const lists = { npm: NPM_COMPROMISED, pip: PIP_COMPROMISED, cargo: CARGO_COMPROMISED, gem: GEM_COMPROMISED };
|
||||
|
||||
for (const dep of deps) {
|
||||
const blocklist = lists[dep.ecosystem];
|
||||
if (!blocklist) continue;
|
||||
if (isCompromised(blocklist, dep.name, dep.version)) {
|
||||
results.push(finding({
|
||||
scanner: 'SCR',
|
||||
severity: SEVERITY.CRITICAL,
|
||||
title: `Compromised dependency: ${dep.name}@${dep.version || '*'}`,
|
||||
description:
|
||||
`"${dep.name}"${dep.version ? '@' + dep.version : ''} in ${lockfile} is on the known-compromised blocklist. ` +
|
||||
`This package/version is associated with supply chain attacks (malware, data exfiltration, or sabotage).`,
|
||||
file: lockfile,
|
||||
evidence: `${dep.name}@${dep.version || 'any'} in ${dep.ecosystem} blocklist`,
|
||||
owasp: 'LLM03',
|
||||
recommendation:
|
||||
`Remove "${dep.name}" immediately. If this was a transitive dependency, find and remove ` +
|
||||
`the parent package that requires it. Audit your system for signs of compromise.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check dependencies against OSV.dev batch API for known vulnerabilities.
|
||||
* @param {{ name: string, version: string, ecosystem: string }[]} deps
|
||||
* @param {string} lockfile
|
||||
* @returns {{ findings: object[], offline: boolean }}
|
||||
*/
|
||||
async function checkOSV(deps, lockfile) {
|
||||
// Only query deps that have a version (OSV requires version)
|
||||
const queryable = deps.filter(d => d.version && OSV_ECOSYSTEM_MAP[d.ecosystem]);
|
||||
if (queryable.length === 0) return { findings: [], offline: false };
|
||||
|
||||
const { results, offline } = await queryOSVBatch(queryable);
|
||||
if (offline) return { findings: [], offline: true };
|
||||
|
||||
const findings = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const vulns = results[i]?.vulns || [];
|
||||
if (vulns.length === 0) continue;
|
||||
|
||||
const dep = queryable[i];
|
||||
let hasCritical = false;
|
||||
|
||||
for (const vuln of vulns) {
|
||||
const severity = extractOSVSeverity(vuln);
|
||||
const sevConst = severity === 'CRITICAL' ? SEVERITY.CRITICAL
|
||||
: severity === 'HIGH' ? SEVERITY.HIGH
|
||||
: SEVERITY.MEDIUM;
|
||||
|
||||
if (severity === 'CRITICAL') hasCritical = true;
|
||||
|
||||
findings.push(finding({
|
||||
scanner: 'SCR',
|
||||
severity: sevConst,
|
||||
title: `Known vulnerability: ${dep.name}@${dep.version} (${vuln.id})`,
|
||||
description:
|
||||
`${vuln.id}: ${(vuln.summary || vuln.details || 'No description').slice(0, 200)}. ` +
|
||||
`Found in ${lockfile}.`,
|
||||
file: lockfile,
|
||||
evidence: `${vuln.id} — ${dep.name}@${dep.version}`,
|
||||
owasp: 'LLM03',
|
||||
recommendation:
|
||||
`Upgrade "${dep.name}" to a patched version. Check ${vuln.id} for fix details.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return { findings, offline: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check npm dependencies for typosquatting against top packages.
|
||||
* @param {{ name: string, version: string, ecosystem: string }[]} deps
|
||||
* @param {string[]} topList - Normalized top package names
|
||||
* @param {number} topCutoff - Top N for stricter matching
|
||||
* @param {string} ecosystem
|
||||
* @param {string} lockfile
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function checkTyposquatting(deps, topList, topCutoff, ecosystem, lockfile, allowlist) {
|
||||
const results = [];
|
||||
const checked = new Set();
|
||||
|
||||
for (const dep of deps) {
|
||||
if (dep.ecosystem !== ecosystem) continue;
|
||||
const normalized = dep.name.toLowerCase().replace(/[_.-]/g, '-');
|
||||
if (checked.has(normalized)) continue;
|
||||
checked.add(normalized);
|
||||
|
||||
// Skip known legitimate packages
|
||||
if (allowlist && allowlist.has(normalized)) continue;
|
||||
|
||||
let closestDist = Infinity;
|
||||
let closestPkg = null;
|
||||
let closestIdx = Infinity;
|
||||
|
||||
for (let i = 0; i < topList.length; i++) {
|
||||
const topPkg = topList[i];
|
||||
if (normalized === topPkg) { closestPkg = null; break; } // Exact match — legit
|
||||
if (Math.abs(normalized.length - topPkg.length) > 2) continue;
|
||||
|
||||
const dist = levenshtein(normalized, topPkg);
|
||||
if (dist < closestDist || (dist === closestDist && i < closestIdx)) {
|
||||
closestDist = dist;
|
||||
closestPkg = topPkg;
|
||||
closestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (!closestPkg) continue;
|
||||
|
||||
if (closestDist === 1) {
|
||||
results.push(finding({
|
||||
scanner: 'SCR',
|
||||
severity: SEVERITY.HIGH,
|
||||
title: `Possible typosquatting: "${dep.name}" vs "${closestPkg}" (edit distance 1)`,
|
||||
description:
|
||||
`"${dep.name}" in ${lockfile} is 1 character away from the popular ${ecosystem} package "${closestPkg}". ` +
|
||||
`Typosquatting packages impersonate popular libraries to execute malicious code.`,
|
||||
file: lockfile,
|
||||
evidence: `"${dep.name}" → "${closestPkg}" (Levenshtein: 1)`,
|
||||
owasp: 'LLM03',
|
||||
recommendation:
|
||||
`Verify "${dep.name}" is the intended package. If you meant "${closestPkg}", correct the dependency.`,
|
||||
}));
|
||||
} else if (closestDist === 2 && closestIdx < topCutoff) {
|
||||
results.push(finding({
|
||||
scanner: 'SCR',
|
||||
severity: SEVERITY.MEDIUM,
|
||||
title: `Potential typosquatting: "${dep.name}" vs "${closestPkg}" (edit distance 2)`,
|
||||
description:
|
||||
`"${dep.name}" in ${lockfile} is 2 characters away from the popular ${ecosystem} package "${closestPkg}" ` +
|
||||
`(top ${topCutoff} by downloads).`,
|
||||
file: lockfile,
|
||||
evidence: `"${dep.name}" → "${closestPkg}" (Levenshtein: 2)`,
|
||||
owasp: 'LLM03',
|
||||
recommendation:
|
||||
`Confirm "${dep.name}" is the correct package. Check publish date and author on the registry.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main scanner export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scan targetPath lockfiles for supply chain issues.
|
||||
*
|
||||
* Detection categories:
|
||||
* 1. Blocklist matches (known compromised packages) — CRITICAL
|
||||
* 2. OSV.dev CVE/advisory detection (batch API) — CRITICAL/HIGH/MEDIUM
|
||||
* 3. Typosquatting against top packages — HIGH/MEDIUM
|
||||
*
|
||||
* Lockfiles parsed: package-lock.json, yarn.lock, requirements.txt, Pipfile.lock
|
||||
*
|
||||
* @param {string} targetPath - Absolute root path being scanned
|
||||
* @param {object} discovery - Unused (scanner reads lockfiles by convention)
|
||||
* @returns {Promise<object>} - scannerResult envelope
|
||||
*/
|
||||
export async function scan(targetPath, discovery) {
|
||||
const startMs = Date.now();
|
||||
const allFindings = [];
|
||||
let filesScanned = 0;
|
||||
let osvOffline = false;
|
||||
|
||||
// Discover lockfiles
|
||||
const lockfiles = [
|
||||
{ path: join(targetPath, 'package-lock.json'), parser: parsePackageLock, name: 'package-lock.json', ecosystem: 'npm' },
|
||||
{ path: join(targetPath, 'yarn.lock'), parser: parseYarnLock, name: 'yarn.lock', ecosystem: 'npm' },
|
||||
{ path: join(targetPath, 'requirements.txt'), parser: parseRequirementsTxt, name: 'requirements.txt', ecosystem: 'pip' },
|
||||
{ path: join(targetPath, 'Pipfile.lock'), parser: parsePipfileLock, name: 'Pipfile.lock', ecosystem: 'pip' },
|
||||
];
|
||||
|
||||
// Also check for requirements-*.txt variants
|
||||
for (const variant of ['requirements-dev.txt', 'requirements-prod.txt', 'requirements.lock']) {
|
||||
const varPath = join(targetPath, variant);
|
||||
if (existsSync(varPath)) {
|
||||
lockfiles.push({ path: varPath, parser: parseRequirementsTxt, name: variant, ecosystem: 'pip' });
|
||||
}
|
||||
}
|
||||
|
||||
const presentLockfiles = lockfiles.filter(l => existsSync(l.path));
|
||||
|
||||
if (presentLockfiles.length === 0) {
|
||||
return scannerResult('supply-chain-recheck', 'skipped', [], 0, Date.now() - startMs);
|
||||
}
|
||||
|
||||
try {
|
||||
// Load top packages and allowlist for typosquat detection
|
||||
const [topPkgs, allowlist] = await Promise.all([loadTopPackages(), loadTyposquatAllowlist()]);
|
||||
const npmTop = topPkgs.npm.map(n => n.toLowerCase().replace(/[_.-]/g, '-'));
|
||||
const pypiTop = topPkgs.pypi.map(n => n.toLowerCase().replace(/[_.-]/g, '-'));
|
||||
|
||||
// Parse all lockfiles
|
||||
const allDeps = [];
|
||||
for (const lockfile of presentLockfiles) {
|
||||
filesScanned++;
|
||||
const deps = await lockfile.parser(lockfile.path);
|
||||
|
||||
// 1. Blocklist check
|
||||
allFindings.push(...checkBlocklists(deps, lockfile.name));
|
||||
|
||||
// 3. Typosquat check
|
||||
if (lockfile.ecosystem === 'npm') {
|
||||
allFindings.push(...checkTyposquatting(deps, npmTop, 200, 'npm', lockfile.name, allowlist.npm));
|
||||
} else if (lockfile.ecosystem === 'pip') {
|
||||
allFindings.push(...checkTyposquatting(deps, pypiTop, 100, 'pip', lockfile.name, allowlist.pypi));
|
||||
}
|
||||
|
||||
allDeps.push(...deps.map(d => ({ ...d, lockfile: lockfile.name })));
|
||||
}
|
||||
|
||||
// 2. OSV.dev batch check (all deps from all lockfiles at once)
|
||||
const osvDeps = allDeps.filter(d => d.version);
|
||||
if (osvDeps.length > 0) {
|
||||
const osvResult = await checkOSV(osvDeps, 'lockfiles');
|
||||
|
||||
if (osvResult.offline) {
|
||||
osvOffline = true;
|
||||
allFindings.push(finding({
|
||||
scanner: 'SCR',
|
||||
severity: SEVERITY.INFO,
|
||||
title: 'OSV.dev unreachable — CVE check skipped',
|
||||
description:
|
||||
'Could not reach the OSV.dev API. Blocklist and typosquat checks were performed, ' +
|
||||
'but known vulnerability (CVE) detection was skipped. Re-run when network is available.',
|
||||
owasp: 'LLM03',
|
||||
recommendation: 'Re-run the scanner when network connectivity is restored.',
|
||||
}));
|
||||
} else {
|
||||
// Re-tag findings with correct lockfile names
|
||||
for (const f of osvResult.findings) {
|
||||
// Find the dep this finding refers to
|
||||
const depMatch = f.evidence?.match(/^(\S+)\s*—\s*(\S+?)@/);
|
||||
if (depMatch) {
|
||||
const depName = depMatch[2];
|
||||
const sourceDep = allDeps.find(d => d.name === depName);
|
||||
if (sourceDep) {
|
||||
f.file = sourceDep.lockfile;
|
||||
}
|
||||
}
|
||||
allFindings.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startMs;
|
||||
const result = scannerResult('supply-chain-recheck', 'ok', allFindings, filesScanned, durationMs);
|
||||
if (osvOffline) result.osv_offline = true;
|
||||
return result;
|
||||
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startMs;
|
||||
return scannerResult('supply-chain-recheck', 'error', allFindings, filesScanned, durationMs, err.message);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue