ktg-plugin-marketplace/plugins/llm-security-copilot/hooks/scripts/pre-install-supply-chain.mjs
Kjell Tore Guttormsen f418a8fe08 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>
2026-04-09 21:56:10 +02:00

710 lines
25 KiB
JavaScript

#!/usr/bin/env node
// Hook: pre-install-supply-chain.mjs
// Event: PreToolUse (Bash)
// Purpose: Analyze ALL package installs BEFORE execution.
//
// Covers: npm, yarn, pnpm, npx, pip, pip3, uv, brew, docker, go, cargo, gem
//
// Checks per manager:
// npm/yarn/pnpm: blocklist, npm audit, npm view (scripts + age gate)
// pip/pip3/uv: blocklist, PyPI API (age gate + metadata)
// brew: third-party tap warning, cask verification
// docker: unpinned tags, unverified images, known malicious
// go install: age gate via proxy.golang.org
// cargo: blocklist
// gem: blocklist
//
// Protocol:
// - BLOCK (exit 2): known compromised, critical CVEs, new + install scripts
// - WARN (exit 0): high CVEs, install scripts on established packages
// - Allow (exit 0): everything else
import { readFileSync, existsSync } from 'node:fs';
import {
AGE_THRESHOLD_HOURS,
NPM_COMPROMISED, PIP_COMPROMISED, CARGO_COMPROMISED, GEM_COMPROMISED,
DOCKER_SUSPICIOUS, POPULAR_PIP,
isCompromised, parseSpec, parsePipSpec, execSafe,
queryOSV, extractOSVSeverity,
} from '../../scanners/lib/supply-chain-data.mjs';
import { normalizeBashExpansion } from '../../scanners/lib/bash-normalize.mjs';
// ===========================================================================
// Read stdin
// ===========================================================================
let input;
try {
const raw = readFileSync(0, 'utf-8');
input = JSON.parse(raw);
} catch {
process.exit(0);
}
const command = input?.tool_input?.command;
if (!command || typeof command !== 'string') {
process.exit(0);
}
// First strip bash evasion techniques, then collapse whitespace
const normalized = normalizeBashExpansion(command).replace(/\s+/g, ' ').trim();
// ===========================================================================
// Quick gate — detect any package install command
// ===========================================================================
const GATES = {
npm: /\b(?:npm\s+(?:install|i|ci|add)|yarn\s+(?:add|install)|pnpm\s+(?:add|install|i))\b/,
npx: /\b(?:npx|pnpx)\s+\S/,
pip: /\b(?:pip3?\s+install|python3?\s+-m\s+pip\s+install|uv\s+pip\s+install|uv\s+add)\b/,
brew: /\b(?:brew\s+(?:install|tap))\b/,
docker: /\b(?:docker\s+(?:pull|run))\b/,
go: /\bgo\s+install\b/,
cargo: /\bcargo\s+install\b/,
gem: /\bgem\s+install\b/,
};
const detectedManager = Object.entries(GATES).find(([, re]) => re.test(normalized))?.[0];
if (!detectedManager) {
process.exit(0); // Not a package install command
}
// ===========================================================================
// Utility functions (only hook-specific ones remain; shared ones imported above)
// ===========================================================================
function extractArgs(cmd, installRegex) {
const match = cmd.match(installRegex);
if (!match) return [];
return match[1].split(/\s+/).filter(a => a && !a.startsWith('-') && !['true', 'false'].includes(a));
}
// ===========================================================================
// NPM checks
// ===========================================================================
async function checkNpm() {
const blocks = [];
const warnings = [];
const packages = extractNpmPackages(normalized);
const isBareInstall = packages.length === 0 && !GATES.npx.test(normalized);
if (isBareInstall) {
// Scan lockfile for known compromised
const lockFindings = scanNpmLockfile();
for (const f of lockFindings) {
blocks.push(
`COMPROMISED in lockfile (${f.source}): ${f.name}@${f.version}\n` +
` This package/version is on the known-compromised list.\n` +
` Remove it from your lockfile and package.json before installing.`
);
}
// npm audit
const audit = runNpmAudit();
if (audit.critical.length > 0) {
const list = audit.critical.map(v => ` - ${v.name} (${v.severity}): ${v.title}`).join('\n');
blocks.push(
`npm audit: ${audit.critical.length} CRITICAL vulnerabilities\n${list}\n` +
` Run \`npm audit fix\` or update affected packages before installing.`
);
}
if (audit.high.length > 0) {
const list = audit.high.map(v => ` - ${v.name} (${v.severity}): ${v.title}`).join('\n');
warnings.push(
`npm audit: ${audit.high.length} HIGH vulnerabilities\n${list}\n` +
` Consider running \`npm audit fix\` to resolve.`
);
}
}
for (const spec of packages) {
const { name, version } = parseSpec(spec);
if (isCompromised(NPM_COMPROMISED, name, version)) {
blocks.push(
`COMPROMISED: ${name}${version ? '@' + version : ''}\n` +
` Known supply chain attack. See: https://socket.dev/npm/package/${name}`
);
continue;
}
const meta = inspectNpmPackage(name, version);
if (!meta) continue;
const resolvedVersion = meta.version;
// --- Advisory check (OSV.dev) — catches compromised established packages ---
const advisories = await queryOSV('npm', name, resolvedVersion);
if (advisories.critical.length > 0) {
blocks.push(
`KNOWN VULNERABILITY: ${name}@${resolvedVersion}\n` +
advisories.critical.map(a => ` - [${a.severity}] ${a.id}: ${a.summary}`).join('\n') + '\n' +
` This version has critical advisories. Use a patched version.`
);
continue;
}
if (advisories.high.length > 0) {
warnings.push(
`VULNERABILITY ADVISORY: ${name}@${resolvedVersion}\n` +
advisories.high.map(a => ` - [${a.severity}] ${a.id}: ${a.summary}`).join('\n') + '\n' +
` Consider using a version without known vulnerabilities.`
);
}
// --- Git provenance check — catches hijacked publishes like axios ---
const provenance = checkNpmProvenance(meta);
if (provenance === 'suspicious') {
warnings.push(
`PROVENANCE WARNING: ${name}@${resolvedVersion}\n` +
` This version was published without matching git tag or CI attestation.\n` +
` It may have been published directly to npm (bypass CI) — as in the axios attack.\n` +
` Verify at: https://www.npmjs.com/package/${name}/v/${resolvedVersion}`
);
}
// --- Install scripts check ---
const scriptNames = ['preinstall', 'install', 'postinstall'].filter(s => meta.scripts?.[s]);
if (scriptNames.length === 0) continue;
const ageHours = getNpmPublishAge(meta);
const versionCount = meta.versions?.length || (meta.time ? Object.keys(meta.time).length - 2 : 0);
const isEstablished = versionCount >= 10;
if (ageHours !== null && ageHours < AGE_THRESHOLD_HOURS && !isEstablished) {
blocks.push(
`NEW PACKAGE WITH INSTALL SCRIPTS: ${name}@${resolvedVersion}\n` +
` Has: ${scriptNames.join(', ')}\n` +
` Published: ${Math.round(ageHours)}h ago, ${versionCount} version(s) total\n` +
` New packages with install scripts are the #1 supply chain attack vector.`
);
} else {
warnings.push(
`INSTALL SCRIPTS: ${name}@${resolvedVersion}\n` +
` Has: ${scriptNames.join(', ')}\n` +
` Note: ~/.npmrc has ignore-scripts=true, so these won't run.`
);
}
}
return { blocks, warnings };
}
function extractNpmPackages(cmd) {
const npxMatch = cmd.match(/\b(?:npx|pnpx)\s+(.+)/);
if (npxMatch) {
const args = npxMatch[1].split(/\s+/).filter(a => !a.startsWith('-'));
return args.length > 0 ? [args[0]] : [];
}
if (/\bnpm\s+ci\b/.test(cmd)) return [];
if (/\b(?:npm|yarn|pnpm)\s+(?:install|i)\s*$/.test(cmd.replace(/\s+--?\S+/g, '').trim())) return [];
const match = cmd.match(/\b(?:npm|yarn|pnpm)\s+(?:install|i|add)\s+(.*)/);
if (!match) return [];
return match[1].split(/\s+/).filter(a => a && !a.startsWith('-'));
}
// ---------------------------------------------------------------------------
// npm provenance check — detect publishes that bypassed CI
// If a package has .attestations but this version doesn't, or if the repo
// field exists but the version has no corresponding git tag, flag it.
// ---------------------------------------------------------------------------
function checkNpmProvenance(meta) {
if (!meta) return 'unknown';
// Check if package normally has attestations (npm provenance)
// Packages with sigstore attestations went through CI. Absence is suspicious.
const hasGitRepo = meta.repository?.url || meta.repository;
const hasAttestations = meta._attestations || meta.attestations;
// If the package declares a git repo but this specific version
// has no attestations AND was published very recently, flag it
if (hasGitRepo && !hasAttestations) {
const ageHours = getNpmPublishAge(meta);
// Only flag very recent publishes (< 24h) from packages that normally use CI
if (ageHours !== null && ageHours < 24) {
// Check if previous versions had attestations by checking dist.attestations
// This is a heuristic — not all packages use provenance yet
return 'suspicious';
}
}
return 'ok';
}
function inspectNpmPackage(name, version) {
const spec = version ? `${name}@${version}` : name;
const raw = execSafe(`npm view ${spec} --json`);
if (!raw) return null;
try { return JSON.parse(raw); } catch { return null; }
}
function getNpmPublishAge(meta) {
const timeField = meta?.time;
if (!timeField) return null;
const publishDate = typeof timeField === 'string' ? timeField : timeField[meta.version] || timeField.modified;
if (!publishDate) return null;
return (Date.now() - new Date(publishDate).getTime()) / (1000 * 60 * 60);
}
function scanNpmLockfile() {
const findings = [];
const cwd = process.env.CLAUDE_WORKING_DIR || process.cwd();
const lockPath = `${cwd}/package-lock.json`;
if (existsSync(lockPath)) {
try {
const lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
for (const [key, info] of Object.entries(lock.packages || lock.dependencies || {})) {
const name = key.replace(/^node_modules\//, '');
if (name && isCompromised(NPM_COMPROMISED, name, info.version)) {
findings.push({ name, version: info.version, source: 'package-lock.json' });
}
}
} catch { /* ignore */ }
}
const yarnLock = `${cwd}/yarn.lock`;
if (existsSync(yarnLock)) {
try {
const content = readFileSync(yarnLock, 'utf-8');
for (const [pkg, versions] of Object.entries(NPM_COMPROMISED)) {
for (const v of versions) {
if (v === '*' ? content.includes(`${pkg}@`) : content.includes(`version "${v}"`) && content.includes(`${pkg}@`)) {
findings.push({ name: pkg, version: v === '*' ? '(any)' : v, source: 'yarn.lock' });
}
}
}
} catch { /* ignore */ }
}
return findings;
}
function runNpmAudit() {
const cwd = process.env.CLAUDE_WORKING_DIR || process.cwd();
if (!existsSync(`${cwd}/package-lock.json`)) return { critical: [], high: [] };
const raw = execSafe('npm audit --json', 15000);
if (!raw) return { critical: [], high: [] };
const critical = [];
const high = [];
try {
const audit = JSON.parse(raw);
for (const [name, info] of Object.entries(audit.vulnerabilities || {})) {
const title = Array.isArray(info.via) ? info.via.map(v => typeof v === 'string' ? v : v.title).join(', ') : String(info.via);
const entry = { name, severity: info.severity, title };
if (info.severity === 'critical') critical.push(entry);
else if (info.severity === 'high') high.push(entry);
}
} catch { /* ignore */ }
return { critical, high };
}
// ===========================================================================
// PIP checks
// ===========================================================================
async function checkPip() {
const blocks = [];
const warnings = [];
const packages = extractPipPackages(normalized);
// pip install (bare, from requirements.txt) — scan requirements for known bad
if (packages.length === 0) {
const reqFindings = scanRequirementsTxt();
for (const f of reqFindings) {
blocks.push(
`COMPROMISED in requirements: ${f.name}${f.version ? '==' + f.version : ''}\n` +
` This package is on the known-compromised list (typosquat/malware).`
);
}
return { blocks, warnings };
}
for (const spec of packages) {
const { name, version } = parsePipSpec(spec);
if (isCompromised(PIP_COMPROMISED, name, version)) {
blocks.push(
`COMPROMISED: ${name} (PyPI)\n` +
` Known malicious package (likely typosquat).\n` +
` See: https://pypi.org/project/${name}/`
);
continue;
}
// Check PyPI API for age and metadata
const meta = await inspectPyPIPackage(name, version);
if (!meta) continue;
const resolvedVersion = version || meta.info?.version;
// --- Advisory check (OSV.dev) — catches compromised established packages ---
const advisories = await queryOSV('pip', name, resolvedVersion);
if (advisories.critical.length > 0) {
blocks.push(
`KNOWN VULNERABILITY: ${name}==${resolvedVersion} (PyPI)\n` +
advisories.critical.map(a => ` - [${a.severity}] ${a.id}: ${a.summary}`).join('\n') + '\n' +
` This version has critical advisories. Use a patched version.`
);
continue;
}
if (advisories.high.length > 0) {
warnings.push(
`VULNERABILITY ADVISORY: ${name}==${resolvedVersion} (PyPI)\n` +
advisories.high.map(a => ` - [${a.severity}] ${a.id}: ${a.summary}`).join('\n') + '\n' +
` Consider using a version without known vulnerabilities.`
);
}
const ageHours = getPyPIPublishAge(meta, version);
const releaseCount = Object.keys(meta.releases || {}).length;
const isEstablished = releaseCount >= 10;
// Age gate only for genuinely new packages (few releases).
// Established packages (10+ releases) with a new version are normal — don't block.
if (ageHours !== null && ageHours < AGE_THRESHOLD_HOURS && !isEstablished) {
blocks.push(
`NEW PyPI PACKAGE: ${name}${version ? '==' + version : ''}\n` +
` Published: ${Math.round(ageHours)}h ago (threshold: ${AGE_THRESHOLD_HOURS}h)\n` +
` Only ${releaseCount} release(s) — this looks like a genuinely new package.\n` +
` New PyPI packages may contain malicious setup.py scripts.\n` +
` Wait ${AGE_THRESHOLD_HOURS}h or verify manually first.`
);
}
// Typosquat detection — Levenshtein distance to popular packages
const typosquatOf = checkTyposquat(name);
if (typosquatOf) {
warnings.push(
`POSSIBLE TYPOSQUAT: "${name}" is suspiciously similar to "${typosquatOf}"\n` +
` Verify this is the intended package before installing.`
);
}
}
return { blocks, warnings };
}
function extractPipPackages(cmd) {
// Handle: pip install pkg, pip3 install pkg, python -m pip install pkg, uv pip install pkg, uv add pkg
const match = cmd.match(/\b(?:pip3?\s+install|python3?\s+-m\s+pip\s+install|uv\s+pip\s+install|uv\s+add)\s+(.*)/);
if (!match) return [];
return match[1].split(/\s+/)
.filter(a => a && !a.startsWith('-') && !a.startsWith('/') && !a.endsWith('.txt') && !a.endsWith('.whl') && !a.endsWith('.tar.gz'));
}
async function inspectPyPIPackage(name, version) {
const url = version
? `https://pypi.org/pypi/${name}/${version}/json`
: `https://pypi.org/pypi/${name}/json`;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (!res.ok) return null;
return await res.json();
} catch { return null; }
}
function getPyPIPublishAge(meta, requestedVersion) {
// PyPI returns upload_time per release
const version = requestedVersion || meta?.info?.version;
if (!version || !meta?.releases?.[version]) return null;
const files = meta.releases[version];
if (!files.length) return null;
const uploadTime = files[0].upload_time_iso_8601 || files[0].upload_time;
if (!uploadTime) return null;
return (Date.now() - new Date(uploadTime).getTime()) / (1000 * 60 * 60);
}
function scanRequirementsTxt() {
const findings = [];
const cwd = process.env.CLAUDE_WORKING_DIR || process.cwd();
for (const reqFile of ['requirements.txt', 'requirements-dev.txt', 'requirements.lock']) {
const path = `${cwd}/${reqFile}`;
if (!existsSync(path)) continue;
try {
const lines = readFileSync(path, 'utf-8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) continue;
const { name, version } = parsePipSpec(trimmed);
if (isCompromised(PIP_COMPROMISED, name, version)) {
findings.push({ name, version });
}
}
} catch { /* ignore */ }
}
return findings;
}
// levenshtein and checkTyposquat imported via POPULAR_PIP from supply-chain-data.mjs
// Local wrapper preserving hook's original behavior (normalizes differently than scanner)
function checkTyposquat(name) {
const lower = name.toLowerCase().replace(/[_.-]/g, '');
for (const popular of POPULAR_PIP) {
const popLower = popular.toLowerCase().replace(/[_.-]/g, '');
if (lower === popLower) continue;
const dist = levenshteinLocal(lower, popLower);
if (dist === 1 && lower.length > 3) return popular;
if (lower.length === popLower.length && dist <= 2 && lower.length > 5) {
const diffs = [...lower].filter((c, i) => c !== popLower[i]).length;
if (diffs <= 1) return popular;
}
}
return null;
}
// Hook-local levenshtein (O(m*n) matrix variant preserved for zero-dependency guarantee)
function levenshteinLocal(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1]
? dp[i - 1][j - 1]
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n];
}
// ===========================================================================
// BREW checks
// ===========================================================================
function checkBrew() {
const blocks = [];
const warnings = [];
// brew tap — warn about third-party taps
if (/\bbrew\s+tap\s+/.test(normalized)) {
const tapMatch = normalized.match(/\bbrew\s+tap\s+(\S+)/);
if (tapMatch) {
const tap = tapMatch[1];
if (!tap.startsWith('homebrew/')) {
warnings.push(
`THIRD-PARTY TAP: ${tap}\n` +
` Only official Homebrew taps (homebrew/*) are curated.\n` +
` Third-party taps can contain arbitrary formulae. Verify the source.`
);
}
}
}
// brew install --cask — warn about cask source
if (/\bbrew\s+install\s+.*--cask/.test(normalized) || /\bbrew\s+install\s+--cask/.test(normalized)) {
warnings.push(
`CASK INSTALL: Casks install full macOS applications.\n` +
` Verify the publisher and download source before proceeding.`
);
}
return { blocks, warnings };
}
// ===========================================================================
// DOCKER checks
// ===========================================================================
function checkDocker() {
const blocks = [];
const warnings = [];
const imageMatch = normalized.match(/\bdocker\s+(?:pull|run)\s+(?:--[^\s]+\s+)*(\S+)/);
if (!imageMatch) return { blocks, warnings };
const image = imageMatch[1];
// Check for known malicious patterns
for (const pattern of DOCKER_SUSPICIOUS) {
if (pattern.test(image)) {
blocks.push(
`SUSPICIOUS DOCKER IMAGE: ${image}\n` +
` Matches known malicious pattern (cryptominer/malware).`
);
return { blocks, warnings };
}
}
// Unpinned tag (using :latest or no tag)
if (!image.includes(':') || image.endsWith(':latest')) {
warnings.push(
`UNPINNED DOCKER IMAGE: ${image}\n` +
` Using :latest or no tag means the image can change without notice.\n` +
` Pin to a specific digest: docker pull ${image.split(':')[0]}@sha256:<digest>`
);
}
// Unofficial image (no / means Docker Hub library, but user images have owner/)
if (image.includes('/') && !image.startsWith('library/')) {
const owner = image.split('/')[0];
// Not a known registry
if (!['docker.io', 'ghcr.io', 'gcr.io', 'mcr.microsoft.com', 'registry.k8s.io', 'quay.io', 'public.ecr.aws'].some(r => image.startsWith(r))) {
warnings.push(
`COMMUNITY DOCKER IMAGE: ${image}\n` +
` This is not an official Docker Hub image.\n` +
` Verify the publisher "${owner}" before running.`
);
}
}
return { blocks, warnings };
}
// ===========================================================================
// GO checks
// ===========================================================================
async function checkGo() {
const blocks = [];
const warnings = [];
const match = normalized.match(/\bgo\s+install\s+(\S+)/);
if (!match) return { blocks, warnings };
const pkg = match[1];
// Check module age via proxy.golang.org
const modPath = pkg.replace(/@.*$/, '');
const version = pkg.includes('@') ? pkg.split('@').pop() : null;
if (version && version !== 'latest') {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
const res = await fetch(`https://proxy.golang.org/${modPath}/@v/${version}.info`, { signal: controller.signal });
clearTimeout(timer);
if (res.ok) {
const info = await res.json();
if (info.Time) {
const ageHours = (Date.now() - new Date(info.Time).getTime()) / (1000 * 60 * 60);
if (ageHours < AGE_THRESHOLD_HOURS) {
blocks.push(
`NEW GO MODULE: ${pkg}\n` +
` Published: ${Math.round(ageHours)}h ago (threshold: ${AGE_THRESHOLD_HOURS}h)\n` +
` go install compiles and runs code. Wait or verify manually.`
);
}
}
}
} catch { /* network error — fail open */ }
}
return { blocks, warnings };
}
// ===========================================================================
// CARGO checks
// ===========================================================================
async function checkCargo() {
const blocks = [];
const warnings = [];
const match = normalized.match(/\bcargo\s+install\s+(\S+)/);
if (!match) return { blocks, warnings };
const crate = match[1].replace(/^--.*/, '').trim();
if (!crate) return { blocks, warnings };
if (isCompromised(CARGO_COMPROMISED, crate, null)) {
blocks.push(
`COMPROMISED CRATE: ${crate}\n` +
` Known malicious Rust crate. See: https://crates.io/crates/${crate}`
);
} else {
// Check OSV for known vulns
const vMatch = normalized.match(/--version\s+(\S+)/);
const version = vMatch ? vMatch[1] : null;
if (version) {
const advisories = await queryOSV('cargo', crate, version);
if (advisories.critical.length > 0) {
blocks.push(
`KNOWN VULNERABILITY: ${crate}@${version} (crates.io)\n` +
advisories.critical.map(a => ` - [${a.severity}] ${a.id}: ${a.summary}`).join('\n')
);
}
}
}
return { blocks, warnings };
}
// ===========================================================================
// GEM checks
// ===========================================================================
async function checkGem() {
const blocks = [];
const warnings = [];
const match = normalized.match(/\bgem\s+install\s+(\S+)/);
if (!match) return { blocks, warnings };
const spec = match[1];
const dashV = normalized.match(/-v\s+['"]?([0-9][0-9a-zA-Z._-]*)['"]?/);
const version = dashV ? dashV[1] : null;
if (isCompromised(GEM_COMPROMISED, spec, version)) {
blocks.push(
`COMPROMISED GEM: ${spec}${version ? '@' + version : ''}\n` +
` Known backdoored version. See: https://rubygems.org/gems/${spec}`
);
} else if (version) {
const advisories = await queryOSV('gem', spec, version);
if (advisories.critical.length > 0) {
blocks.push(
`KNOWN VULNERABILITY: ${spec}@${version} (RubyGems)\n` +
advisories.critical.map(a => ` - [${a.severity}] ${a.id}: ${a.summary}`).join('\n')
);
}
}
return { blocks, warnings };
}
// ===========================================================================
// Main — dispatch to correct checker
// ===========================================================================
const checkers = {
npm: checkNpm,
npx: checkNpm, // npx uses the same npm ecosystem
pip: checkPip,
brew: checkBrew,
docker: checkDocker,
go: checkGo,
cargo: checkCargo,
gem: checkGem,
};
const checker = checkers[detectedManager];
if (!checker) process.exit(0);
const { blocks, warnings } = await checker();
if (blocks.length > 0) {
process.stderr.write(
`\n🛑 BLOCKED: Supply chain risk detected [${detectedManager}]\n` +
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n\n` +
blocks.map(b => ` ${b}`).join('\n\n') + '\n\n' +
` The command was NOT executed.\n`
);
process.exit(2);
}
if (warnings.length > 0) {
process.stderr.write(
`\n⚠️ Supply chain advisory [${detectedManager}]:\n` +
warnings.map(w => ` ${w}`).join('\n\n') + '\n\n'
);
}
process.exit(0);