New policy-loader.mjs reads .llm-security/policy.json with deep-merge against defaults that exactly match existing hardcoded values. Integrated into all 7 hooks: - pre-prompt-inject-scan: injection.mode (env var still takes precedence) - post-session-guard: trifecta.mode, window_size, long_horizon_window - pre-edit-secrets: secrets.additional_patterns - pre-bash-destructive: destructive.additional_blocked - pre-write-pathguard: pathguard.additional_protected - pre-install-supply-chain: supply_chain.additional_blocked_packages - post-mcp-verify: mcp.volume_threshold_bytes, mcp.trusted_servers Backward compatible: no policy file = identical behavior to v5.1.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
714 lines
26 KiB
JavaScript
714 lines
26 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';
|
|
import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
|
|
|
|
// Policy-defined additional blocked packages (merged with built-in lists)
|
|
const POLICY_BLOCKED = new Set(getPolicyValue('supply_chain', 'additional_blocked_packages', []));
|
|
|
|
// ===========================================================================
|
|
// 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) || POLICY_BLOCKED.has(name)) {
|
|
blocks.push(
|
|
`COMPROMISED: ${name}${version ? '@' + version : ''}\n` +
|
|
` ${POLICY_BLOCKED.has(name) ? 'Blocked by policy.' : '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) || POLICY_BLOCKED.has(name)) {
|
|
blocks.push(
|
|
`COMPROMISED: ${name} (PyPI)\n` +
|
|
` ${POLICY_BLOCKED.has(name) ? 'Blocked by policy.' : '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);
|