diff --git a/README.md b/README.md index 6f8e319..581611b 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,68 @@ This approach automatically uses the latest version without requiring global ins } ``` +## Claude Code Hook (Optional) + +The repo includes an optional [Claude Code hook](https://code.claude.com/docs/en/hooks) that automatically blocks malicious packages before installation. When Claude Code runs `npm install`, `yarn add`, `bun add`, or `pnpm add`, the hook checks the package against Socket and blocks it if critical or high severity alerts are found (typosquats, malware, supply chain attacks). + +The hook fails open on all errors, so it never blocks legitimate work. + +### Hook Setup + +**Prerequisites:** +- Node.js 22+ +- [Socket CLI](https://www.npmjs.com/package/@socketsecurity/cli): `npm install -g @socketsecurity/cli` +- Run `socket login` to authenticate (one-time setup, no env vars needed) + +1. Copy the hook script: + +```bash +mkdir -p ~/.claude/hooks +cp hooks/socket-gate.ts ~/.claude/hooks/ +``` + +2. Add to `~/.claude/settings.json`: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node --experimental-strip-types ~/.claude/hooks/socket-gate.ts" + } + ] + } + ] + } +} +``` + +### How it works + +| Alert Severity | Decision | Example | +|----------------|----------|---------| +| **Critical** | Block installation | `browserlist` (typosquat of `browserslist`) | +| **High** | Block installation | Packages with known supply chain risks | +| **Low/None** | Allow | `express`, `lodash`, `react` | + +### Testing the hook + +```bash +# Should block (typosquat) +echo '{"session_id":"test","tool_name":"Bash","tool_input":{"command":"npm install browserlist"}}' \ + | node --experimental-strip-types hooks/socket-gate.ts + +# Should allow (safe package) +echo '{"session_id":"test","tool_name":"Bash","tool_input":{"command":"npm install express"}}' \ + | node --experimental-strip-types hooks/socket-gate.ts +``` + +Inspired by [Jimmy Vo's dependency hook](https://blog.jimmyvo.com/posts/claudes-dependency-hook/). + ## Tools exposed by the Socket MCP Server ### depscore diff --git a/hooks/socket-gate.test.ts b/hooks/socket-gate.test.ts new file mode 100644 index 0000000..0e26fdf --- /dev/null +++ b/hooks/socket-gate.test.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env node +import { test } from 'node:test' +import assert from 'node:assert' +import { execFileSync } from 'node:child_process' +import { join } from 'node:path' + +const hookPath = join(import.meta.dirname, 'socket-gate.ts') + +function runHook (input: string): string { + return execFileSync('node', ['--experimental-strip-types', hookPath], { + input, + encoding: 'utf-8', + timeout: 60_000, + env: { ...process.env } + }).trim() +} + +function parseOutput (output: string): { decision: string, reason?: string } { + const parsed = JSON.parse(output) + return { + decision: parsed.hookSpecificOutput.permissionDecision, + reason: parsed.hookSpecificOutput.permissionDecisionReason + } +} + +function makeInput (command: string): string { + return JSON.stringify({ + session_id: 'test', + tool_name: 'Bash', + tool_input: { command } + }) +} + +function socketCliAvailable (): boolean { + try { + execFileSync('which', ['socket'], { encoding: 'utf-8', timeout: 5_000 }) + return true + } catch { + return false + } +} + +const hasCli = socketCliAvailable() + +test('socket-gate hook', async (t) => { + // ======================================== + // Unit tests (no Socket CLI required) + // ======================================== + + await t.test('allows non-Bash tools', () => { + const input = JSON.stringify({ session_id: 'test', tool_name: 'Read', tool_input: { path: '/tmp/foo' } }) + const result = parseOutput(runHook(input)) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('allows non-install commands', () => { + const result = parseOutput(runHook(makeInput('ls -la'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('allows lockfile-only installs', () => { + for (const cmd of ['npm install', 'npm i', 'npm ci', 'yarn', 'yarn install', 'bun install', 'pnpm install']) { + const result = parseOutput(runHook(makeInput(cmd))) + assert.strictEqual(result.decision, 'allow', `should allow: ${cmd}`) + } + }) + + await t.test('allows empty input', () => { + const result = parseOutput(runHook('')) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('allows invalid JSON', () => { + const result = parseOutput(runHook('not json')) + assert.strictEqual(result.decision, 'allow') + }) + + // ======================================== + // Integration tests (require Socket CLI with `socket login`) + // ======================================== + + await t.test('allows safe package (lodash)', { skip: !hasCli && 'Socket CLI not installed' }, () => { + const result = parseOutput(runHook(makeInput('npm install lodash'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('allows safe scoped package (@types/node)', { skip: !hasCli && 'Socket CLI not installed' }, () => { + const result = parseOutput(runHook(makeInput('yarn add @types/node'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('blocks typosquat (browserlist)', { skip: !hasCli && 'Socket CLI not installed' }, () => { + const result = parseOutput(runHook(makeInput('npm install browserlist'))) + assert.strictEqual(result.decision, 'deny') + assert.ok(result.reason?.includes('browserlist'), 'reason should mention package name') + assert.ok(result.reason?.includes('socket.dev'), 'reason should include review link') + }) + + await t.test('handles versioned install', { skip: !hasCli && 'Socket CLI not installed' }, () => { + const result = parseOutput(runHook(makeInput('npm install express@4.18.2'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('handles pnpm add', { skip: !hasCli && 'Socket CLI not installed' }, () => { + const result = parseOutput(runHook(makeInput('pnpm add express'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('handles bun add', { skip: !hasCli && 'Socket CLI not installed' }, () => { + const result = parseOutput(runHook(makeInput('bun add express'))) + assert.strictEqual(result.decision, 'allow') + }) +}) diff --git a/hooks/socket-gate.ts b/hooks/socket-gate.ts new file mode 100644 index 0000000..bb183a9 --- /dev/null +++ b/hooks/socket-gate.ts @@ -0,0 +1,225 @@ +#!/usr/bin/env -S node --experimental-strip-types +/** + * socket-gate.ts — Claude Code PreToolUse hook + * + * Intercepts npm/yarn/bun/pnpm install commands and checks packages against + * Socket. Blocks packages with critical alerts (malware, typosquats) + * and high severity supply chain risks. + * + * Uses the Socket CLI (`socket package score`) which handles its own auth + * via `socket login`. No API key env var needed. + * + * Setup: + * 1. Install Socket CLI: npm install -g @socketsecurity/cli && socket login + * 2. Copy this file to ~/.claude/hooks/socket-gate.ts + * 3. Add to ~/.claude/settings.json (see README) + * + * Fails open on all errors (CLI missing, network timeout, parse failures) + * so it never blocks legitimate work. + */ + +import { readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' + +// ======================================== +// Types +// ======================================== + +interface HookInput { + session_id: string + tool_name: string + tool_input: Record | string +} + +interface SocketAlert { + name: string + severity: string + category?: string +} + +interface SocketScoreResult { + ok?: boolean + data?: { + self?: { + alerts?: SocketAlert[] + } + } +} + +// ======================================== +// Hook output helpers (Claude Code PreToolUse format) +// ======================================== + +function outputAllow (): void { + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'allow' + } + })) +} + +function outputDeny (reason: string): void { + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: reason + } + })) +} + +// ======================================== +// Package extraction +// ======================================== + +const INSTALL_PATTERNS = [ + /npm\s+(?:install|i|add)\s+([^\s-][^\s]*)/i, + /yarn\s+add\s+([^\s-][^\s]*)/i, + /bun\s+add\s+([^\s-][^\s]*)/i, + /pnpm\s+add\s+([^\s-][^\s]*)/i +] + +const LOCKFILE_PATTERNS = [ + /^npm\s+(install|i|ci)\s*$/i, + /^yarn\s*(install)?\s*$/i, + /^bun\s+install\s*$/i, + /^pnpm\s+install\s*$/i +] + +export function extractPackageName (command: string): string | null { + if (LOCKFILE_PATTERNS.some(p => p.test(command.trim()))) { + return null + } + + for (const pattern of INSTALL_PATTERNS) { + const match = command.match(pattern) + if (match) { + const pkg = match[1] + // Strip version specifiers: @scope/pkg@1.2.3 -> @scope/pkg + return pkg.replace(/@[\d^~].*/u, '').replace(/@latest$/u, '') + } + } + + return null +} + +// ======================================== +// Socket CLI +// ======================================== + +function isSocketInstalled (): boolean { + try { + execFileSync('which', ['socket'], { encoding: 'utf-8', timeout: 5_000 }) + return true + } catch { + return false + } +} + +export function checkPackage (packageName: string): { decision: 'allow' | 'deny', reason: string } { + const result = execFileSync( + 'socket', + ['package', 'score', 'npm', packageName, '--json', '--no-banner'], + { encoding: 'utf-8', timeout: 30_000, maxBuffer: 10 * 1024 * 1024 } + ) + + const parsed: SocketScoreResult = JSON.parse(result) + const alerts = parsed.data?.self?.alerts || [] + + const critical = alerts.filter(a => a.severity === 'critical') + const high = alerts.filter(a => a.severity === 'high') + + if (critical.length > 0) { + const details = critical + .map(a => ` - ${a.name}: ${a.category || 'detected'}`) + .join('\n') + + return { + decision: 'deny', + reason: `Socket blocked "${packageName}" (${critical.length} critical alert${critical.length > 1 ? 's' : ''}):\n\n${details}\n\nReview: https://socket.dev/npm/package/${packageName}` + } + } + + if (high.length > 0) { + const details = high + .map(a => ` - ${a.name}: ${a.category || 'detected'}`) + .join('\n') + + return { + decision: 'deny', + reason: `Socket blocked "${packageName}" (${high.length} high severity alert${high.length > 1 ? 's' : ''}):\n\n${details}\n\nReview: https://socket.dev/npm/package/${packageName}` + } + } + + return { decision: 'allow', reason: '' } +} + +// ======================================== +// Main +// ======================================== + +async function main (): Promise { + let raw: string + try { + raw = readFileSync(0, 'utf-8') + } catch { + outputAllow() + return + } + + if (!raw.trim()) { + outputAllow() + return + } + + let input: HookInput + try { + input = JSON.parse(raw) + } catch { + outputAllow() + return + } + + if (input.tool_name !== 'Bash') { + outputAllow() + return + } + + const command = typeof input.tool_input === 'string' + ? input.tool_input + : (input.tool_input?.command as string) || '' + + if (!command) { + outputAllow() + return + } + + const packageName = extractPackageName(command) + if (!packageName) { + outputAllow() + return + } + + if (!isSocketInstalled()) { + // CLI not installed, fail open + outputAllow() + return + } + + try { + const result = checkPackage(packageName) + if (result.decision === 'deny') { + outputDeny(result.reason) + } else { + outputAllow() + } + } catch { + // Fail open on any error + outputAllow() + } +} + +main().catch(() => { + outputAllow() +})