#!/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:` ); } // 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);