From 3c4c29ae00f24b3f3e5d53766e5c33fea746a2f6 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Sun, 5 Apr 2026 09:19:11 -0400 Subject: [PATCH 1/3] Add optional Claude Code hook for blocking malicious packages Adds a PreToolUse hook (socket-gate.ts) that intercepts npm/yarn/bun/pnpm install commands and checks packages against the Socket API. Blocks packages with critical or high severity alerts (typosquats, malware, supply chain attacks). Fails open on all errors. Includes tests and README documentation. --- README.md | 61 +++++++++ hooks/socket-gate.test.ts | 112 ++++++++++++++++ hooks/socket-gate.ts | 264 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 437 insertions(+) create mode 100644 hooks/socket-gate.test.ts create mode 100644 hooks/socket-gate.ts diff --git a/README.md b/README.md index 6f8e319..83ee125 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,67 @@ 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+ and a [Socket API key](https://docs.socket.dev/reference/creating-and-managing-api-tokens) (`packages:list` scope). + +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": "SOCKET_API_KEY=your-api-key-here node --experimental-strip-types ~/.claude/hooks/socket-gate.ts" + } + ] + } + ] + } +} +``` + +If `SOCKET_API_KEY` is already in your shell environment, you can omit it from the command. + +### 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"}}' \ + | SOCKET_API_KEY=your-key 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"}}' \ + | SOCKET_API_KEY=your-key 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..c235ff7 --- /dev/null +++ b/hooks/socket-gate.test.ts @@ -0,0 +1,112 @@ +#!/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, env?: Record): string { + return execFileSync('node', ['--experimental-strip-types', hookPath], { + input, + encoding: 'utf-8', + timeout: 30_000, + env: { + ...process.env, + ...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 } + }) +} + +test('socket-gate hook', async (t) => { + const apiKey = process.env['SOCKET_API_KEY'] + + // ======================================== + // Unit tests (no API key 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') + }) + + await t.test('allows when no API key is set', () => { + const result = parseOutput(runHook(makeInput('npm install malicious-pkg'), { SOCKET_API_KEY: '' })) + assert.strictEqual(result.decision, 'allow') + }) + + // ======================================== + // Integration tests (require SOCKET_API_KEY) + // ======================================== + + await t.test('allows safe package (lodash)', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => { + const result = parseOutput(runHook(makeInput('npm install lodash'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('allows safe scoped package (@types/node)', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => { + const result = parseOutput(runHook(makeInput('yarn add @types/node'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('blocks typosquat (browserlist)', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => { + 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: !apiKey && 'SOCKET_API_KEY not set' }, () => { + const result = parseOutput(runHook(makeInput('npm install express@4.18.2'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('handles pnpm add', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => { + const result = parseOutput(runHook(makeInput('pnpm add express'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('handles bun add', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => { + 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..dc8a284 --- /dev/null +++ b/hooks/socket-gate.ts @@ -0,0 +1,264 @@ +#!/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 + * the Socket API. Blocks packages with critical alerts (malware, typosquats) + * and warns on high severity supply chain risks. + * + * Setup: + * 1. Copy this file to ~/.claude/hooks/socket-gate.ts + * 2. Add to ~/.claude/settings.json (see README) + * 3. Set SOCKET_API_KEY env var + * + * Fails open on all errors (network, auth, parse) so it never blocks + * legitimate work. + */ + +import { readFileSync } from 'node:fs' + +// ======================================== +// Types +// ======================================== + +interface HookInput { + session_id: string + tool_name: string + tool_input: Record | string +} + +interface SocketAlert { + type: string + severity: string + category?: string + props?: Record +} + +interface PurlResponseLine { + _type?: string + score?: Record + alerts?: SocketAlert[] + name?: string + namespace?: string + type?: string + version?: string + [key: string]: unknown +} + +// ======================================== +// 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 +} + +// ======================================== +// PURL construction (npm only, inline) +// ======================================== + +export function buildNpmPurl (packageName: string): string { + if (packageName.startsWith('@') && packageName.includes('/')) { + const slash = packageName.indexOf('/') + const scope = encodeURIComponent(packageName.slice(0, slash)) + const name = packageName.slice(slash + 1) + return `pkg:npm/${scope}/${name}` + } + return `pkg:npm/${packageName}` +} + +// ======================================== +// Socket API +// ======================================== + +const DEFAULT_SOCKET_API_URL = 'https://api.socket.dev/v0/purl' + +function getSocketApiUrl (): string { + if (process.env['SOCKET_API_URL']) { + return process.env['SOCKET_API_URL'] + } + return `${DEFAULT_SOCKET_API_URL}?alerts=true&compact=false&fixable=false&licenseattrib=false&licensedetails=false` +} + +export async function checkPackage (packageName: string, apiKey: string): Promise<{ decision: 'allow' | 'deny', reason: string }> { + const purl = buildNpmPurl(packageName) + + const response = await fetch(getSocketApiUrl(), { + method: 'POST', + headers: { + 'user-agent': 'socket-mcp-hook/1.0', + accept: 'application/x-ndjson', + 'content-type': 'application/json', + authorization: `Bearer ${apiKey}` + }, + body: JSON.stringify({ components: [{ purl }] }), + signal: AbortSignal.timeout(15_000) + }) + + if (!response.ok) { + return { decision: 'allow', reason: '' } + } + + const text = await response.text() + if (!text.trim()) { + return { decision: 'allow', reason: '' } + } + + const lines: PurlResponseLine[] = text + .split('\n') + .filter(line => line.trim()) + .map(line => JSON.parse(line) as PurlResponseLine) + .filter(obj => !obj._type) + + if (lines.length === 0) { + return { decision: 'allow', reason: '' } + } + + const pkg = lines[0] + const alerts = pkg.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.type}: ${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.type}: ${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 + } + + const apiKey = process.env['SOCKET_API_KEY'] + if (!apiKey) { + outputAllow() + return + } + + try { + const result = await checkPackage(packageName, apiKey) + if (result.decision === 'deny') { + outputDeny(result.reason) + } else { + outputAllow() + } + } catch { + outputAllow() + } +} + +main().catch(() => { + outputAllow() +}) From df90d327b973dd53fbf35a9853f66aa14cc1f290 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Sun, 5 Apr 2026 09:27:09 -0400 Subject: [PATCH 2/3] Switch hook from direct API to Socket CLI (no API key needed) Uses 'socket package score' instead of calling the /v0/purl endpoint directly. Auth is handled by the CLI's own config (socket login), so no SOCKET_API_KEY env var is required. --- README.md | 13 +++-- hooks/socket-gate.test.ts | 43 +++++++------- hooks/socket-gate.ts | 119 +++++++++++++------------------------- 3 files changed, 69 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index 83ee125..581611b 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,10 @@ The hook fails open on all errors, so it never blocks legitimate work. ### Hook Setup -**Prerequisites:** Node.js 22+ and a [Socket API key](https://docs.socket.dev/reference/creating-and-managing-api-tokens) (`packages:list` scope). +**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: @@ -251,7 +254,7 @@ cp hooks/socket-gate.ts ~/.claude/hooks/ "hooks": [ { "type": "command", - "command": "SOCKET_API_KEY=your-api-key-here node --experimental-strip-types ~/.claude/hooks/socket-gate.ts" + "command": "node --experimental-strip-types ~/.claude/hooks/socket-gate.ts" } ] } @@ -260,8 +263,6 @@ cp hooks/socket-gate.ts ~/.claude/hooks/ } ``` -If `SOCKET_API_KEY` is already in your shell environment, you can omit it from the command. - ### How it works | Alert Severity | Decision | Example | @@ -275,11 +276,11 @@ If `SOCKET_API_KEY` is already in your shell environment, you can omit it from t ```bash # Should block (typosquat) echo '{"session_id":"test","tool_name":"Bash","tool_input":{"command":"npm install browserlist"}}' \ - | SOCKET_API_KEY=your-key node --experimental-strip-types hooks/socket-gate.ts + | 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"}}' \ - | SOCKET_API_KEY=your-key node --experimental-strip-types hooks/socket-gate.ts + | node --experimental-strip-types hooks/socket-gate.ts ``` Inspired by [Jimmy Vo's dependency hook](https://blog.jimmyvo.com/posts/claudes-dependency-hook/). diff --git a/hooks/socket-gate.test.ts b/hooks/socket-gate.test.ts index c235ff7..0e26fdf 100644 --- a/hooks/socket-gate.test.ts +++ b/hooks/socket-gate.test.ts @@ -6,15 +6,12 @@ import { join } from 'node:path' const hookPath = join(import.meta.dirname, 'socket-gate.ts') -function runHook (input: string, env?: Record): string { +function runHook (input: string): string { return execFileSync('node', ['--experimental-strip-types', hookPath], { input, encoding: 'utf-8', - timeout: 30_000, - env: { - ...process.env, - ...env - } + timeout: 60_000, + env: { ...process.env } }).trim() } @@ -34,11 +31,20 @@ function makeInput (command: string): string { }) } -test('socket-gate hook', async (t) => { - const apiKey = process.env['SOCKET_API_KEY'] +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 API key required) + // Unit tests (no Socket CLI required) // ======================================== await t.test('allows non-Bash tools', () => { @@ -69,43 +75,38 @@ test('socket-gate hook', async (t) => { assert.strictEqual(result.decision, 'allow') }) - await t.test('allows when no API key is set', () => { - const result = parseOutput(runHook(makeInput('npm install malicious-pkg'), { SOCKET_API_KEY: '' })) - assert.strictEqual(result.decision, 'allow') - }) - // ======================================== - // Integration tests (require SOCKET_API_KEY) + // Integration tests (require Socket CLI with `socket login`) // ======================================== - await t.test('allows safe package (lodash)', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => { + 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: !apiKey && 'SOCKET_API_KEY not set' }, () => { + 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: !apiKey && 'SOCKET_API_KEY not set' }, () => { + 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: !apiKey && 'SOCKET_API_KEY not set' }, () => { + 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: !apiKey && 'SOCKET_API_KEY not set' }, () => { + 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: !apiKey && 'SOCKET_API_KEY not set' }, () => { + 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 index dc8a284..bb183a9 100644 --- a/hooks/socket-gate.ts +++ b/hooks/socket-gate.ts @@ -3,19 +3,23 @@ * socket-gate.ts — Claude Code PreToolUse hook * * Intercepts npm/yarn/bun/pnpm install commands and checks packages against - * the Socket API. Blocks packages with critical alerts (malware, typosquats) - * and warns on high severity supply chain risks. + * 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. Copy this file to ~/.claude/hooks/socket-gate.ts - * 2. Add to ~/.claude/settings.json (see README) - * 3. Set SOCKET_API_KEY env var + * 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 (network, auth, parse) so it never blocks - * legitimate work. + * 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 @@ -28,21 +32,18 @@ interface HookInput { } interface SocketAlert { - type: string + name: string severity: string category?: string - props?: Record } -interface PurlResponseLine { - _type?: string - score?: Record - alerts?: SocketAlert[] - name?: string - namespace?: string - type?: string - version?: string - [key: string]: unknown +interface SocketScoreResult { + ok?: boolean + data?: { + self?: { + alerts?: SocketAlert[] + } + } } // ======================================== @@ -104,75 +105,34 @@ export function extractPackageName (command: string): string | null { } // ======================================== -// PURL construction (npm only, inline) -// ======================================== - -export function buildNpmPurl (packageName: string): string { - if (packageName.startsWith('@') && packageName.includes('/')) { - const slash = packageName.indexOf('/') - const scope = encodeURIComponent(packageName.slice(0, slash)) - const name = packageName.slice(slash + 1) - return `pkg:npm/${scope}/${name}` - } - return `pkg:npm/${packageName}` -} - -// ======================================== -// Socket API +// Socket CLI // ======================================== -const DEFAULT_SOCKET_API_URL = 'https://api.socket.dev/v0/purl' - -function getSocketApiUrl (): string { - if (process.env['SOCKET_API_URL']) { - return process.env['SOCKET_API_URL'] +function isSocketInstalled (): boolean { + try { + execFileSync('which', ['socket'], { encoding: 'utf-8', timeout: 5_000 }) + return true + } catch { + return false } - return `${DEFAULT_SOCKET_API_URL}?alerts=true&compact=false&fixable=false&licenseattrib=false&licensedetails=false` } -export async function checkPackage (packageName: string, apiKey: string): Promise<{ decision: 'allow' | 'deny', reason: string }> { - const purl = buildNpmPurl(packageName) - - const response = await fetch(getSocketApiUrl(), { - method: 'POST', - headers: { - 'user-agent': 'socket-mcp-hook/1.0', - accept: 'application/x-ndjson', - 'content-type': 'application/json', - authorization: `Bearer ${apiKey}` - }, - body: JSON.stringify({ components: [{ purl }] }), - signal: AbortSignal.timeout(15_000) - }) - - if (!response.ok) { - return { decision: 'allow', reason: '' } - } - - const text = await response.text() - if (!text.trim()) { - return { decision: 'allow', reason: '' } - } - - const lines: PurlResponseLine[] = text - .split('\n') - .filter(line => line.trim()) - .map(line => JSON.parse(line) as PurlResponseLine) - .filter(obj => !obj._type) - - if (lines.length === 0) { - return { decision: 'allow', reason: '' } - } +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 pkg = lines[0] - const alerts = pkg.alerts || [] + 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.type}: ${a.category || 'detected'}`) + .map(a => ` - ${a.name}: ${a.category || 'detected'}`) .join('\n') return { @@ -183,7 +143,7 @@ export async function checkPackage (packageName: string, apiKey: string): Promis if (high.length > 0) { const details = high - .map(a => ` - ${a.type}: ${a.category || 'detected'}`) + .map(a => ` - ${a.name}: ${a.category || 'detected'}`) .join('\n') return { @@ -241,20 +201,21 @@ async function main (): Promise { return } - const apiKey = process.env['SOCKET_API_KEY'] - if (!apiKey) { + if (!isSocketInstalled()) { + // CLI not installed, fail open outputAllow() return } try { - const result = await checkPackage(packageName, apiKey) + const result = checkPackage(packageName) if (result.decision === 'deny') { outputDeny(result.reason) } else { outputAllow() } } catch { + // Fail open on any error outputAllow() } } From f5a8127a512449ad6ad2edac61f26b48411f1b14 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Wed, 22 Apr 2026 17:54:34 -0400 Subject: [PATCH 3/3] Switch hook to direct Socket API, block supplyChain < 0.2 - Drop Socket CLI dependency; call /v0/purl directly with SOCKET_API_KEY - Block when score.supplyChain < 0.2 (typosquats, known malware) - Deny when SOCKET_API_KEY is missing so users are not silently unprotected - Fail open on network, parse, and timeout errors - Add limitations section to README (manifest-edit gap, JS-only scope) - Update tests to match new API-based flow --- README.md | 39 +++++++++--- hooks/socket-gate.test.ts | 42 +++++-------- hooks/socket-gate.ts | 127 ++++++++++++++------------------------ 3 files changed, 93 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 581611b..552004a 100644 --- a/README.md +++ b/README.md @@ -225,16 +225,17 @@ 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. +The repo includes an optional [Claude Code hook](https://code.claude.com/docs/en/hooks) that blocks high-risk packages before installation. When Claude Code runs `npm install`, `yarn add`, `bun add`, or `pnpm add`, the hook calls the Socket `/v0/purl` API and denies the install when the package's supply chain score is below `0.2` (known malware, typosquats, high-risk supply chain signals). ### 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) +- A Socket API key with `packages:list` scope. See [creating API tokens](https://docs.socket.dev/reference/creating-and-managing-api-tokens). Export it in your shell: + ```bash + export SOCKET_API_KEY=your-api-key-here + ``` + This is the same key used by the Socket MCP server. 1. Copy the hook script: @@ -265,11 +266,29 @@ cp hooks/socket-gate.ts ~/.claude/hooks/ ### 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` | +The hook denies installation when `score.supplyChain < 0.2`, allows it otherwise. Examples: + +| Package | `supplyChain` | Decision | +|---------|--------------|----------| +| `express`, `lodash`, `react` | ~0.97+ | Allow | +| `browserlist` (typosquat of `browserslist`) | 0.15 | Block | +| Confirmed malware | 0 | Block | + +### Behavior on errors + +- **`SOCKET_API_KEY` not set** — **deny**, with a setup message. The hook will not silently leave you unprotected. +- **Network, timeout, or parse errors** — **allow**. A Socket API outage will not block legitimate work. + +### Limitations + +This hook is a best-effort guardrail, not a complete defense. Known gaps: + +- **Manifest edits + lockfile installs.** If Claude edits `package.json` directly and then runs bare `npm install` / `npm ci` / `yarn` / `pnpm install`, there is no package name on the command line for the hook to extract, so no check is performed. +- **JavaScript ecosystems only.** `pip`, `cargo`, `gem`, `go get`, etc. are not intercepted. +- **Package-manager invocations only.** Direct downloads (`curl | sh`, `wget`), post-install scripts of already-accepted packages, and transitive dependencies pulled in by an allowed package are not re-checked. +- **Indirect Claude paths.** Sub-agents, MCP tools that shell out, or non-`Bash` tool calls are not covered unless the `matcher` is broadened. + +For defense in depth, pair this hook with the Socket MCP server (for AI-assisted review), [Socket CLI](https://docs.socket.dev/docs/socket-cli) scans in CI, and [Socket Firewall](https://docs.socket.dev/docs/socket-firewall-enterprise) at the registry boundary. ### Testing the hook diff --git a/hooks/socket-gate.test.ts b/hooks/socket-gate.test.ts index 0e26fdf..68a28e6 100644 --- a/hooks/socket-gate.test.ts +++ b/hooks/socket-gate.test.ts @@ -6,12 +6,12 @@ import { join } from 'node:path' const hookPath = join(import.meta.dirname, 'socket-gate.ts') -function runHook (input: string): string { +function runHook (input: string, env: Record = {}): string { return execFileSync('node', ['--experimental-strip-types', hookPath], { input, encoding: 'utf-8', - timeout: 60_000, - env: { ...process.env } + timeout: 30_000, + env: { ...process.env, ...env } }).trim() } @@ -31,22 +31,9 @@ function makeInput (command: string): string { }) } -function socketCliAvailable (): boolean { - try { - execFileSync('which', ['socket'], { encoding: 'utf-8', timeout: 5_000 }) - return true - } catch { - return false - } -} - -const hasCli = socketCliAvailable() +const hasApiKey = !!process.env.SOCKET_API_KEY 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)) @@ -75,38 +62,41 @@ test('socket-gate hook', async (t) => { assert.strictEqual(result.decision, 'allow') }) - // ======================================== - // Integration tests (require Socket CLI with `socket login`) - // ======================================== + await t.test('denies when SOCKET_API_KEY is missing', () => { + const result = parseOutput(runHook(makeInput('npm install lodash'), { SOCKET_API_KEY: '' })) + assert.strictEqual(result.decision, 'deny') + assert.ok(result.reason?.includes('SOCKET_API_KEY'), 'reason should mention the env var') + }) - await t.test('allows safe package (lodash)', { skip: !hasCli && 'Socket CLI not installed' }, () => { + await t.test('allows safe package (lodash)', { skip: !hasApiKey && 'SOCKET_API_KEY not set' }, () => { 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' }, () => { + await t.test('allows safe scoped package (@types/node)', { skip: !hasApiKey && 'SOCKET_API_KEY not set' }, () => { 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' }, () => { + await t.test('blocks typosquat (browserlist)', { skip: !hasApiKey && 'SOCKET_API_KEY not set' }, () => { 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('supply chain score'), 'reason should mention the score') assert.ok(result.reason?.includes('socket.dev'), 'reason should include review link') }) - await t.test('handles versioned install', { skip: !hasCli && 'Socket CLI not installed' }, () => { + await t.test('handles versioned install', { skip: !hasApiKey && 'SOCKET_API_KEY not set' }, () => { 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' }, () => { + await t.test('handles pnpm add', { skip: !hasApiKey && 'SOCKET_API_KEY not set' }, () => { 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' }, () => { + await t.test('handles bun add', { skip: !hasApiKey && 'SOCKET_API_KEY not set' }, () => { 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 index bb183a9..10d197d 100644 --- a/hooks/socket-gate.ts +++ b/hooks/socket-gate.ts @@ -3,27 +3,24 @@ * 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. + * the Socket API. Blocks packages with a supply chain score below 0.2 + * (known malware, typosquats, high-risk supply chain signals). * * Setup: - * 1. Install Socket CLI: npm install -g @socketsecurity/cli && socket login + * 1. export SOCKET_API_KEY=... (same key used by the MCP server) * 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. + * Denies when SOCKET_API_KEY is missing so users are not silently + * unprotected. Fails open on network, parse, or timeout errors so a + * Socket outage does not block legitimate work. */ import { readFileSync } from 'node:fs' -import { execFileSync } from 'node:child_process' -// ======================================== -// Types -// ======================================== +const SOCKET_API_URL = 'https://api.socket.dev/v0/purl' +const SUPPLY_CHAIN_THRESHOLD = 0.2 +const REQUEST_TIMEOUT_MS = 10_000 interface HookInput { session_id: string @@ -31,25 +28,12 @@ interface HookInput { tool_input: Record | string } -interface SocketAlert { - name: string - severity: string - category?: string -} - -interface SocketScoreResult { - ok?: boolean - data?: { - self?: { - alerts?: SocketAlert[] - } +interface PurlResponse { + score?: { + supplyChain?: number } } -// ======================================== -// Hook output helpers (Claude Code PreToolUse format) -// ======================================== - function outputAllow (): void { process.stdout.write(JSON.stringify({ hookSpecificOutput: { @@ -69,10 +53,6 @@ function outputDeny (reason: string): void { })) } -// ======================================== -// Package extraction -// ======================================== - const INSTALL_PATTERNS = [ /npm\s+(?:install|i|add)\s+([^\s-][^\s]*)/i, /yarn\s+add\s+([^\s-][^\s]*)/i, @@ -96,7 +76,6 @@ export function extractPackageName (command: string): string | null { 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, '') } } @@ -104,61 +83,52 @@ export function extractPackageName (command: string): string | null { return null } -// ======================================== -// Socket CLI -// ======================================== - -function isSocketInstalled (): boolean { - try { - execFileSync('which', ['socket'], { encoding: 'utf-8', timeout: 5_000 }) - return true - } catch { - return false +export async function checkPackage ( + packageName: string, + apiKey: string, + fetchImpl: typeof fetch = fetch +): Promise<{ decision: 'allow' | 'deny', reason: string }> { + const auth = Buffer.from(`${apiKey}:`).toString('base64') + + const res = await fetchImpl(SOCKET_API_URL, { + method: 'POST', + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + components: [{ purl: `pkg:npm/${packageName}` }] + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }) + + if (!res.ok) { + throw new Error(`Socket API returned ${res.status}`) } -} -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') + const text = await res.text() + const line = text.split('\n').find(l => l.trim().length > 0) + if (!line) { + throw new Error('Empty response from Socket API') + } - if (critical.length > 0) { - const details = critical - .map(a => ` - ${a.name}: ${a.category || 'detected'}`) - .join('\n') + const parsed: PurlResponse = JSON.parse(line) + const score = parsed.score?.supplyChain - 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 (typeof score !== 'number') { + throw new Error('Missing supplyChain score in response') } - if (high.length > 0) { - const details = high - .map(a => ` - ${a.name}: ${a.category || 'detected'}`) - .join('\n') - + if (score < SUPPLY_CHAIN_THRESHOLD) { 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}` + reason: `Socket blocked "${packageName}": supply chain score is ${score.toFixed(2)} (threshold ${SUPPLY_CHAIN_THRESHOLD}).\n\nReview: https://socket.dev/npm/package/${packageName}` } } return { decision: 'allow', reason: '' } } -// ======================================== -// Main -// ======================================== - async function main (): Promise { let raw: string try { @@ -201,21 +171,20 @@ async function main (): Promise { return } - if (!isSocketInstalled()) { - // CLI not installed, fail open - outputAllow() + const apiKey = process.env.SOCKET_API_KEY + if (!apiKey) { + outputDeny('SOCKET_API_KEY is not set. Export it in your shell (same key used by the Socket MCP server) or remove the hook from ~/.claude/settings.json.') return } try { - const result = checkPackage(packageName) + const result = await checkPackage(packageName, apiKey) if (result.decision === 'deny') { outputDeny(result.reason) } else { outputAllow() } } catch { - // Fail open on any error outputAllow() } }