From e9a48c5020fd872da544e7f31c70c29950dc10f6 Mon Sep 17 00:00:00 2001 From: jdalton Date: Thu, 9 Apr 2026 21:36:16 -0400 Subject: [PATCH 1/8] feat(security): add /setup-security-tools and pre-push scanning One command to set up AgentShield, zizmor, and Socket Firewall. Downloads binaries with SHA-256 verification, creates PATH shims (bash + Windows .cmd), and adds blocking scans to pre-push hook. --- .claude/commands/setup-security-tools.md | 38 ++ .claude/hooks/setup-security-tools/README.md | 73 ++++ .claude/hooks/setup-security-tools/index.mts | 342 ++++++++++++++++++ .../setup-security-tools/package-lock.json | 31 ++ .../hooks/setup-security-tools/package.json | 9 + .claude/skills/security-scan/SKILL.md | 7 + .git-hooks/pre-push | 222 ++++++++++++ .gitignore | 2 + 8 files changed, 724 insertions(+) create mode 100644 .claude/commands/setup-security-tools.md create mode 100644 .claude/hooks/setup-security-tools/README.md create mode 100644 .claude/hooks/setup-security-tools/index.mts create mode 100644 .claude/hooks/setup-security-tools/package-lock.json create mode 100644 .claude/hooks/setup-security-tools/package.json create mode 100755 .git-hooks/pre-push diff --git a/.claude/commands/setup-security-tools.md b/.claude/commands/setup-security-tools.md new file mode 100644 index 000000000..6462f04fa --- /dev/null +++ b/.claude/commands/setup-security-tools.md @@ -0,0 +1,38 @@ +Set up all Socket security tools for local development. + +## What this sets up + +1. **AgentShield** — scans Claude config for prompt injection and secrets +2. **Zizmor** — static analysis for GitHub Actions workflows +3. **SFW (Socket Firewall)** — intercepts package manager commands to scan for malware + +## Setup + +First, ask the user if they have a Socket API key for SFW enterprise features. + +If they do: +1. Ask them to provide it +2. Write it to `.env.local` as `SOCKET_API_KEY=` (create if needed) +3. Verify `.env.local` is in `.gitignore` — if not, add it and warn + +If they don't, proceed with SFW free mode. + +Then run: +```bash +node .claude/hooks/setup-security-tools/index.mts +``` + +After the script completes, add the SFW shim directory to PATH: +```bash +export PATH="$HOME/.socket/sfw/shims:$PATH" +``` + +## Notes + +- Safe to re-run (idempotent) +- AgentShield needs `pnpm install` (it's a devDep) +- Zizmor is cached at `~/.socket/zizmor/bin/` +- SFW binary is cached via dlx at `~/.socket/_dlx/` +- SFW shims are shared across repos at `~/.socket/sfw/shims/` +- `.env.local` must NEVER be committed +- `/update` will check for new versions of these tools via `node .claude/hooks/setup-security-tools/update.mts` diff --git a/.claude/hooks/setup-security-tools/README.md b/.claude/hooks/setup-security-tools/README.md new file mode 100644 index 000000000..96c301596 --- /dev/null +++ b/.claude/hooks/setup-security-tools/README.md @@ -0,0 +1,73 @@ +# setup-security-tools Hook + +Sets up all three Socket security tools for local development in one command. + +## Tools + +### 1. AgentShield +Scans your Claude Code configuration (`.claude/` directory) for security issues like prompt injection, leaked secrets, and overly permissive tool permissions. + +**How it's installed**: Already a devDependency (`ecc-agentshield`). The setup script just verifies it's available — if not, run `pnpm install`. + +### 2. Zizmor +Static analysis tool for GitHub Actions workflows. Catches unpinned actions, secret exposure, template injection, and permission issues. + +**How it's installed**: Binary downloaded from [GitHub releases](https://github.com/woodruffw/zizmor/releases), SHA-256 verified, cached at `~/.socket/zizmor/bin/zizmor`. If you already have it via `brew install zizmor`, the download is skipped. + +### 3. SFW (Socket Firewall) +Intercepts package manager commands (`npm install`, `pnpm add`, etc.) and scans packages against Socket.dev's malware database before installation. + +**How it's installed**: Binary downloaded from GitHub, SHA-256 verified, cached via the dlx system at `~/.socket/_dlx/`. Small wrapper scripts ("shims") are created at `~/.socket/sfw/shims/` that transparently route commands through the firewall. + +**Free vs Enterprise**: If you have a `SOCKET_API_KEY` (in env, `.env`, or `.env.local`), enterprise mode is used with additional ecosystem support (gem, bundler, nuget, go). Otherwise, free mode covers npm, yarn, pnpm, pip, uv, and cargo. + +## How to use + +``` +/setup-security-tools +``` + +Claude will ask if you have an API key, then run the setup script. + +## What gets installed where + +| Tool | Location | Persists across repos? | +|------|----------|----------------------| +| AgentShield | `node_modules/.bin/agentshield` | No (per-repo devDep) | +| Zizmor | `~/.socket/zizmor/bin/zizmor` | Yes | +| SFW binary | `~/.socket/_dlx//sfw` | Yes | +| SFW shims | `~/.socket/sfw/shims/npm`, etc. | Yes | + +## Pre-push integration + +The `.git-hooks/pre-push` hook automatically runs: +- **AgentShield scan** (blocks push on failure) +- **Zizmor scan** (blocks push on failure) + +This means every push is checked — you don't have to remember to run `/security-scan`. + +## Re-running + +Safe to run multiple times: +- AgentShield: just re-checks availability +- Zizmor: skips download if cached binary matches expected version +- SFW: skips download if cached, only rewrites shims if content changed + +## Copying to another repo + +Self-contained. To add to another Socket repo: + +1. Copy `.claude/hooks/setup-security-tools/` and `.claude/commands/setup-security-tools.md` +2. Run `cd .claude/hooks/setup-security-tools && npm install` +3. Ensure `.claude/hooks/` is not gitignored (add `!/.claude/hooks/` to `.gitignore`) +4. Ensure `ecc-agentshield` is a devDep in the target repo + +## Troubleshooting + +**"AgentShield not found"** — Run `pnpm install`. It's the `ecc-agentshield` devDependency. + +**"zizmor found but wrong version"** — The script downloads the expected version to `~/.socket/zizmor/bin/`. Your system version (e.g. from brew) will be ignored in favor of the correct version. + +**"No supported package managers found"** — SFW only creates shims for package managers found on your PATH. Install npm/pnpm/etc. first. + +**SFW shims not intercepting** — Make sure `~/.socket/sfw/shims` is at the *front* of PATH. Run `which npm` — it should point to the shim, not the real binary. diff --git a/.claude/hooks/setup-security-tools/index.mts b/.claude/hooks/setup-security-tools/index.mts new file mode 100644 index 000000000..3d349b5a1 --- /dev/null +++ b/.claude/hooks/setup-security-tools/index.mts @@ -0,0 +1,342 @@ +#!/usr/bin/env node +// Setup script for Socket security tools. +// +// Configures three tools: +// 1. AgentShield — scans Claude AI config for prompt injection / secrets. +// Already a devDep (ecc-agentshield); this script verifies it's installed. +// 2. Zizmor — static analysis for GitHub Actions workflows. Downloads the +// correct binary, verifies SHA-256, caches at ~/.socket/zizmor/bin/zizmor. +// 3. SFW (Socket Firewall) — intercepts package manager commands to scan +// for malware. Downloads binary, verifies SHA-256, creates PATH shims. +// Enterprise vs free determined by SOCKET_API_KEY in env / .env / .env.local. + +import { createHash } from 'node:crypto' +import { existsSync, createReadStream, readFileSync, promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { whichSync } from '@socketsecurity/lib/bin' +import { downloadBinary } from '@socketsecurity/lib/dlx/binary' +import { httpDownload } from '@socketsecurity/lib/http-request' +import { getDefaultLogger } from '@socketsecurity/lib/logger' +import { getSocketHomePath } from '@socketsecurity/lib/paths/socket' +import { spawn, spawnSync } from '@socketsecurity/lib/spawn' + +const logger = getDefaultLogger() + +// ── Zizmor constants ── + +const ZIZMOR_VERSION = '1.23.1' + +const ZIZMOR_CHECKSUMS: Record = { + __proto__: null as unknown as string, + 'zizmor-aarch64-apple-darwin.tar.gz': + '2632561b974c69f952258c1ab4b7432d5c7f92e555704155c3ac28a2910bd717', + 'zizmor-aarch64-unknown-linux-gnu.tar.gz': + '3725d7cd7102e4d70827186389f7d5930b6878232930d0a3eb058d7e5b47e658', + 'zizmor-x86_64-apple-darwin.tar.gz': + '89d5ed42081dd9d0433a10b7545fac42b35f1f030885c278b9712b32c66f2597', + 'zizmor-x86_64-pc-windows-msvc.zip': + '33c2293ff02834720dd7cd8b47348aafb2e95a19bdc993c0ecaca9c804ade92a', + 'zizmor-x86_64-unknown-linux-gnu.tar.gz': + '67a8df0a14352dd81882e14876653d097b99b0f4f6b6fe798edc0320cff27aff', +} + +const ZIZMOR_ASSET_MAP: Record = { + __proto__: null as unknown as string, + 'darwin-arm64': 'zizmor-aarch64-apple-darwin.tar.gz', + 'darwin-x64': 'zizmor-x86_64-apple-darwin.tar.gz', + 'linux-arm64': 'zizmor-aarch64-unknown-linux-gnu.tar.gz', + 'linux-x64': 'zizmor-x86_64-unknown-linux-gnu.tar.gz', + 'win32-x64': 'zizmor-x86_64-pc-windows-msvc.zip', +} + +// ── SFW constants ── + +const SFW_ENTERPRISE_CHECKSUMS: Record = { + __proto__: null as unknown as string, + 'linux-arm64': '671270231617142404a1564e52672f79b806f9df3f232fcc7606329c0246da55', + 'linux-x86_64': '9115b4ca8021eb173eb9e9c3627deb7f1066f8debd48c5c9d9f3caabb2a26a4b', + 'macos-arm64': 'acad0b517601bb7408e2e611c9226f47dcccbd83333d7fc5157f1d32ed2b953d', + 'macos-x86_64': '01d64d40effda35c31f8d8ee1fed1388aac0a11aba40d47fba8a36024b77500c', + 'windows-x86_64': '9a50e1ddaf038138c3f85418dc5df0113bbe6fc884f5abe158beaa9aea18d70a', +} + +const SFW_FREE_CHECKSUMS: Record = { + __proto__: null as unknown as string, + 'linux-arm64': 'df2eedb2daf2572eee047adb8bfd81c9069edcb200fc7d3710fca98ec3ca81a1', + 'linux-x86_64': '4a1e8b65e90fce7d5fd066cf0af6c93d512065fa4222a475c8d959a6bc14b9ff', + 'macos-arm64': 'bf1616fc44ac49f1cb2067fedfa127a3ae65d6ec6d634efbb3098cfa355e5555', + 'macos-x86_64': '724ccea19d847b79db8cc8e38f5f18ce2dd32336007f42b11bed7d2e5f4a2566', + 'windows-x86_64': 'c953e62ad7928d4d8f2302f5737884ea1a757babc26bed6a42b9b6b68a5d54af', +} + +const SFW_PLATFORM_MAP: Record = { + __proto__: null as unknown as string, + 'darwin-arm64': 'macos-arm64', + 'darwin-x64': 'macos-x86_64', + 'linux-arm64': 'linux-arm64', + 'linux-x64': 'linux-x86_64', + 'win32-x64': 'windows-x86_64', +} + +const SFW_FREE_ECOSYSTEMS = ['npm', 'yarn', 'pnpm', 'pip', 'uv', 'cargo'] +const SFW_ENTERPRISE_EXTRA = ['gem', 'bundler', 'nuget'] + +// ── Shared helpers ── + +function findApiKey(): string | undefined { + const envKey = process.env['SOCKET_API_KEY'] + if (envKey) return envKey + for (const filename of ['.env.local', '.env']) { + const filepath = path.join(process.cwd(), filename) + if (existsSync(filepath)) { + try { + const content = readFileSync(filepath, 'utf8') + const match = /^SOCKET_API_KEY\s*=\s*(.+)$/m.exec(content) + if (match) { + return match[1]! + .replace(/\s*#.*$/, '') // Strip inline comments. + .trim() // Strip whitespace before quote removal. + .replace(/^["']|["']$/g, '') // Strip surrounding quotes. + } + } catch { + // Ignore read errors. + } + } + } + return undefined +} + +async function sha256File(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = createHash('sha256') + const stream = createReadStream(filePath) + stream.on('data', (chunk: Buffer) => hash.update(chunk)) + stream.on('end', () => resolve(hash.digest('hex'))) + stream.on('error', reject) + }) +} + +// ── AgentShield ── + +function setupAgentShield(): boolean { + logger.log('=== AgentShield ===') + const bin = whichSync('agentshield', { nothrow: true }) + if (bin && typeof bin === 'string') { + const result = spawnSync(bin, ['--version'], { stdio: 'pipe' }) + const ver = typeof result.stdout === 'string' + ? result.stdout.trim() + : result.stdout.toString().trim() + logger.log(`Found: ${bin} (${ver})`) + return true + } + logger.warn('Not found. Run "pnpm install" to install ecc-agentshield.') + return false +} + +// ── Zizmor ── + +async function checkZizmorVersion(binPath: string): Promise { + try { + const result = await spawn(binPath, ['--version'], { stdio: 'pipe' }) + const output = typeof result.stdout === 'string' + ? result.stdout.trim() + : result.stdout.toString().trim() + return output.includes(ZIZMOR_VERSION) + } catch { + return false + } +} + +async function setupZizmor(): Promise { + logger.log('=== Zizmor ===') + + // Check PATH first (e.g. brew install). + const systemBin = whichSync('zizmor', { nothrow: true }) + if (systemBin && typeof systemBin === 'string') { + if (await checkZizmorVersion(systemBin)) { + logger.log(`Found on PATH: ${systemBin} (v${ZIZMOR_VERSION})`) + return true + } + logger.log(`Found on PATH but wrong version (need v${ZIZMOR_VERSION})`) + } + + // Check cached binary. + const ext = process.platform === 'win32' ? '.exe' : '' + const binDir = path.join(getSocketHomePath(), 'zizmor', 'bin') + const binPath = path.join(binDir, `zizmor${ext}`) + if (existsSync(binPath) && await checkZizmorVersion(binPath)) { + logger.log(`Cached: ${binPath} (v${ZIZMOR_VERSION})`) + return true + } + + // Download. + const platformKey = `${process.platform}-${process.arch}` + const asset = ZIZMOR_ASSET_MAP[platformKey] + if (!asset) throw new Error(`Unsupported platform: ${platformKey}`) + const expectedSha = ZIZMOR_CHECKSUMS[asset] + if (!expectedSha) throw new Error(`No checksum for: ${asset}`) + const url = `https://github.com/woodruffw/zizmor/releases/download/v${ZIZMOR_VERSION}/${asset}` + const isZip = asset.endsWith('.zip') + + logger.log(`Downloading zizmor v${ZIZMOR_VERSION} (${asset})...`) + const tmpFile = path.join(tmpdir(), `zizmor-${Date.now()}-${asset}`) + try { + await httpDownload(url, tmpFile, { sha256: expectedSha }) + logger.log('Download complete, checksum verified.') + + // Extract. + const extractDir = path.join(tmpdir(), `zizmor-extract-${Date.now()}`) + await fs.mkdir(extractDir, { recursive: true }) + if (isZip) { + await spawn('powershell', ['-NoProfile', '-Command', + `Expand-Archive -Path '${tmpFile}' -DestinationPath '${extractDir}' -Force`], { stdio: 'pipe' }) + } else { + await spawn('tar', ['xzf', tmpFile, '-C', extractDir], { stdio: 'pipe' }) + } + + // Install. + const extractedBin = path.join(extractDir, `zizmor${ext}`) + if (!existsSync(extractedBin)) throw new Error(`Binary not found after extraction: ${extractedBin}`) + await fs.mkdir(binDir, { recursive: true }) + await fs.copyFile(extractedBin, binPath) + await fs.chmod(binPath, 0o755) + await fs.rm(extractDir, { recursive: true, force: true }) + + logger.log(`Installed to ${binPath}`) + return true + } finally { + if (existsSync(tmpFile)) await fs.unlink(tmpFile).catch(() => {}) + } +} + +// ── SFW ── + +async function setupSfw(apiKey: string | undefined): Promise { + const isEnterprise = !!apiKey + logger.log(`=== Socket Firewall (${isEnterprise ? 'enterprise' : 'free'}) ===`) + + // Platform. + const platformKey = `${process.platform}-${process.arch}` + const sfwPlatform = SFW_PLATFORM_MAP[platformKey] + if (!sfwPlatform) throw new Error(`Unsupported platform: ${platformKey}`) + + // Checksum + asset. + const checksums = isEnterprise ? SFW_ENTERPRISE_CHECKSUMS : SFW_FREE_CHECKSUMS + const sha256 = checksums[sfwPlatform] + if (!sha256) throw new Error(`No checksum for: ${sfwPlatform}`) + const prefix = isEnterprise ? 'sfw' : 'sfw-free' + const suffix = sfwPlatform.startsWith('windows') ? '.exe' : '' + const asset = `${prefix}-${sfwPlatform}${suffix}` + const repo = isEnterprise ? 'SocketDev/firewall-release' : 'SocketDev/sfw-free' + const url = `https://github.com/${repo}/releases/latest/download/${asset}` + const binaryName = isEnterprise ? 'sfw' : 'sfw-free' + + // Download (with cache + checksum). + const { binaryPath, downloaded } = await downloadBinary({ url, name: binaryName, sha256 }) + logger.log(downloaded ? `Downloaded to ${binaryPath}` : `Cached at ${binaryPath}`) + + // Create shims. + const isWindows = process.platform === 'win32' + const shimDir = path.join(getSocketHomePath(), 'sfw', 'shims') + await fs.mkdir(shimDir, { recursive: true }) + const ecosystems = [...SFW_FREE_ECOSYSTEMS] + if (isEnterprise) { + ecosystems.push(...SFW_ENTERPRISE_EXTRA) + if (process.platform === 'linux') ecosystems.push('go') + } + const cleanPath = (process.env['PATH'] ?? '').split(path.delimiter) + .filter(p => p !== shimDir).join(path.delimiter) + const created: string[] = [] + for (const cmd of ecosystems) { + const realBin = whichSync(cmd, { nothrow: true, path: cleanPath }) + if (!realBin || typeof realBin !== 'string') continue + + // Bash shim (macOS/Linux). + const bashLines = [ + '#!/bin/bash', + `export PATH="$(echo "$PATH" | tr ':' '\\n' | grep -vxF '${shimDir}' | paste -sd: -)"`, + ] + if (isEnterprise) { + // Read API key from env at runtime — never embed secrets in scripts. + bashLines.push( + 'if [ -z "$SOCKET_API_KEY" ]; then', + ' for f in .env.local .env; do', + ' if [ -f "$f" ]; then', + ' _val="$(grep -m1 "^SOCKET_API_KEY\\s*=" "$f" | sed "s/^[^=]*=\\s*//" | sed "s/\\s*#.*//" | sed "s/^[\"\\x27]\\(.*\\)[\"\\x27]$/\\1/")"', + ' if [ -n "$_val" ]; then SOCKET_API_KEY="$_val"; break; fi', + ' fi', + ' done', + ' export SOCKET_API_KEY', + 'fi', + ) + } + if (!isEnterprise) { + // Workaround: sfw-free does not yet set GIT_SSL_CAINFO (temporary). + bashLines.push('export GIT_SSL_NO_VERIFY=true') + } + bashLines.push(`exec "${binaryPath}" "${realBin}" "$@"`) + const bashContent = bashLines.join('\n') + '\n' + const bashPath = path.join(shimDir, cmd) + if (!existsSync(bashPath) || await fs.readFile(bashPath, 'utf8').catch(() => '') !== bashContent) { + await fs.writeFile(bashPath, bashContent, { mode: 0o755 }) + } + created.push(cmd) + + // Windows .cmd shim (strips shim dir from PATH, then execs through sfw). + if (isWindows) { + const cmdContent = + `@echo off\r\n` + + `set "PATH=;%PATH%;"\r\n` + + `set "PATH=%PATH:;${shimDir};=%"\r\n` + + `set "PATH=%PATH:~1,-1%"\r\n` + + `"${binaryPath}" "${realBin}" %*\r\n` + const cmdPath = path.join(shimDir, `${cmd}.cmd`) + if (!existsSync(cmdPath) || await fs.readFile(cmdPath, 'utf8').catch(() => '') !== cmdContent) { + await fs.writeFile(cmdPath, cmdContent) + } + } + } + + if (created.length) { + logger.log(`Shims: ${created.join(', ')}`) + logger.log(`Shim dir: ${shimDir}`) + logger.log(`Activate: export PATH="${shimDir}:$PATH"`) + } else { + logger.warn('No supported package managers found on PATH.') + } + return true +} + +// ── Main ── + +async function main(): Promise { + logger.log('Setting up Socket security tools...\n') + + const apiKey = findApiKey() + + const agentshieldOk = setupAgentShield() + logger.log('') + const zizmorOk = await setupZizmor() + logger.log('') + const sfwOk = await setupSfw(apiKey) + logger.log('') + + logger.log('=== Summary ===') + logger.log(`AgentShield: ${agentshieldOk ? 'ready' : 'NOT AVAILABLE'}`) + logger.log(`Zizmor: ${zizmorOk ? 'ready' : 'FAILED'}`) + logger.log(`SFW: ${sfwOk ? 'ready' : 'FAILED'}`) + + if (agentshieldOk && zizmorOk && sfwOk) { + logger.log('\nAll security tools ready.') + } else { + logger.warn('\nSome tools not available. See above.') + } +} + +main().catch((e: unknown) => { + logger.error(e instanceof Error ? e.message : String(e)) + process.exitCode = 1 +}) diff --git a/.claude/hooks/setup-security-tools/package-lock.json b/.claude/hooks/setup-security-tools/package-lock.json new file mode 100644 index 000000000..e5070b268 --- /dev/null +++ b/.claude/hooks/setup-security-tools/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": "@socketsecurity/hook-setup-security-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@socketsecurity/hook-setup-security-tools", + "dependencies": { + "@socketsecurity/lib": "5.15.0" + } + }, + "node_modules/@socketsecurity/lib": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/lib/-/lib-5.15.0.tgz", + "integrity": "sha512-+I7+lR0WBCXWgRxMTQx+N70azONVGr68ndi25pz53D6QLdIQ8gfBgOgC34opECXL9lPUqVCMYNr3XFS/bHABIQ==", + "license": "MIT", + "engines": { + "node": ">=22", + "pnpm": ">=10.25.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/.claude/hooks/setup-security-tools/package.json b/.claude/hooks/setup-security-tools/package.json new file mode 100644 index 000000000..37fee40ad --- /dev/null +++ b/.claude/hooks/setup-security-tools/package.json @@ -0,0 +1,9 @@ +{ + "name": "@socketsecurity/hook-setup-security-tools", + "private": true, + "type": "module", + "main": "./index.mts", + "dependencies": { + "@socketsecurity/lib": "5.15.0" + } +} diff --git a/.claude/skills/security-scan/SKILL.md b/.claude/skills/security-scan/SKILL.md index 161fb5bfa..640bf210d 100644 --- a/.claude/skills/security-scan/SKILL.md +++ b/.claude/skills/security-scan/SKILL.md @@ -7,6 +7,13 @@ description: Runs a multi-tool security scan — AgentShield for Claude config, Multi-tool security scanning pipeline for the repository. +## Related: check-new-deps Hook + +This repo includes a pre-tool hook (`.claude/hooks/check-new-deps/`) that automatically +checks new dependencies against Socket.dev's malware API before Claude adds them. +The hook runs on every Edit/Write to manifest files — see its README for details. +This skill covers broader security scanning; the hook provides real-time dependency protection. + ## When to Use - After modifying `.claude/` config, settings, hooks, or agent definitions diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push new file mode 100755 index 000000000..2bf9a70e6 --- /dev/null +++ b/.git-hooks/pre-push @@ -0,0 +1,222 @@ +#!/bin/bash +# Socket Security Pre-push Hook +# Security enforcement layer for all pushes. +# Validates all commits being pushed for security issues and AI attribution. + +set -e + +# Colors for output. +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +NC='\033[0m' + +printf "${GREEN}Running mandatory pre-push validation...${NC}\n" + +# Allowed public API key (used in socket-lib). +ALLOWED_PUBLIC_KEY="sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api" + +# Get the remote name and URL. +remote="$1" +url="$2" + +TOTAL_ERRORS=0 + +# ============================================================================ +# PRE-CHECK 1: AgentShield scan on Claude config (blocks push on failure) +# ============================================================================ +if command -v agentshield >/dev/null 2>&1 || [ -x "$(pnpm bin 2>/dev/null)/agentshield" ]; then + AGENTSHIELD="$(command -v agentshield 2>/dev/null || echo "$(pnpm bin)/agentshield")" + if ! "$AGENTSHIELD" scan --quiet 2>/dev/null; then + printf "${RED}✗ AgentShield: security issues found in Claude config${NC}\n" + printf "Run 'pnpm exec agentshield scan' for details\n" + TOTAL_ERRORS=$((TOTAL_ERRORS + 1)) + fi +fi + +# ============================================================================ +# PRE-CHECK 2: zizmor scan on GitHub Actions workflows +# ============================================================================ +ZIZMOR="" +if command -v zizmor >/dev/null 2>&1; then + ZIZMOR="$(command -v zizmor)" +elif [ -x "$HOME/.socket/zizmor/bin/zizmor" ]; then + ZIZMOR="$HOME/.socket/zizmor/bin/zizmor" +fi +if [ -n "$ZIZMOR" ] && [ -d ".github/" ]; then + if ! "$ZIZMOR" .github/ 2>/dev/null; then + printf "${RED}✗ Zizmor: workflow security issues found${NC}\n" + printf "Run 'zizmor .github/' for details\n" + TOTAL_ERRORS=$((TOTAL_ERRORS + 1)) + fi +fi + +# Read stdin for refs being pushed. +while read local_ref local_sha remote_ref remote_sha; do + # Skip tag pushes: tags point to existing commits already validated. + if echo "$local_ref" | grep -q '^refs/tags/'; then + printf "${GREEN}Skipping tag push: %s${NC}\n" "$local_ref" + continue + fi + + # Skip delete pushes. + if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then + continue + fi + + # Get the range of commits being pushed. + if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then + # New branch - only check commits not on the default remote branch. + default_branch=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') + if [ -z "$default_branch" ]; then + default_branch="main" + fi + if git rev-parse "origin/$default_branch" >/dev/null 2>&1; then + range="origin/$default_branch..$local_sha" + else + # No remote default branch, fall back to release tag. + latest_release=$(git tag --list 'v*' --sort=-version:refname --merged "$local_sha" | head -1) + if [ -n "$latest_release" ]; then + range="$latest_release..$local_sha" + else + range="$local_sha" + fi + fi + else + # Existing branch - check new commits since remote. + # Limit scope to commits after the latest published release on this branch. + latest_release=$(git tag --list 'v*' --sort=-version:refname --merged "$remote_sha" | head -1) + if [ -n "$latest_release" ]; then + # Only check commits after the latest release that are being pushed. + range="$latest_release..$local_sha" + else + # No release tags found, check new commits only. + range="$remote_sha..$local_sha" + fi + fi + + # Validate the computed range before using it. + if ! git rev-list "$range" >/dev/null 2>&1; then + printf "${RED}✗ Invalid commit range: %s${NC}\n" "$range" >&2 + exit 1 + fi + + ERRORS=0 + + # ============================================================================ + # CHECK 1: Scan commit messages for AI attribution + # ============================================================================ + printf "Checking commit messages for AI attribution...\n" + + # Check each commit in the range for AI patterns. + while IFS= read -r commit_sha; do + full_msg=$(git log -1 --format='%B' "$commit_sha") + + if echo "$full_msg" | grep -qiE "(Generated with.*(Claude|AI)|Co-Authored-By: Claude|Co-Authored-By: AI|🤖 Generated|AI generated|@anthropic\.com|Assistant:|Generated by Claude|Machine generated)"; then + if [ $ERRORS -eq 0 ]; then + printf "${RED}✗ BLOCKED: AI attribution found in commit messages!${NC}\n" + printf "Commits with AI attribution:\n" + fi + printf " - %s\n" "$(git log -1 --oneline "$commit_sha")" + ERRORS=$((ERRORS + 1)) + fi + done < <(git rev-list "$range") + + if [ $ERRORS -gt 0 ]; then + printf "\n" + printf "These commits were likely created with --no-verify, bypassing the\n" + printf "commit-msg hook that strips AI attribution.\n" + printf "\n" + printf "To fix:\n" + printf " git rebase -i %s\n" "$remote_sha" + printf " Mark commits as 'reword', remove AI attribution, save\n" + printf " git push\n" + fi + + # ============================================================================ + # CHECK 2: File content security checks + # ============================================================================ + printf "Checking files for security issues...\n" + + # Get all files changed in these commits. + CHANGED_FILES=$(git diff --name-only "$range" 2>/dev/null || echo "") + + if [ -n "$CHANGED_FILES" ]; then + # Check for sensitive files. + if echo "$CHANGED_FILES" | grep -qE '^\.env(\.local)?$'; then + printf "${RED}✗ BLOCKED: Attempting to push .env file!${NC}\n" + printf "Files: %s\n" "$(echo "$CHANGED_FILES" | grep -E '^\.env(\.local)?$')" + ERRORS=$((ERRORS + 1)) + fi + + # Check for .DS_Store. + if echo "$CHANGED_FILES" | grep -q '\.DS_Store'; then + printf "${RED}✗ BLOCKED: .DS_Store file in push!${NC}\n" + printf "Files: %s\n" "$(echo "$CHANGED_FILES" | grep '\.DS_Store')" + ERRORS=$((ERRORS + 1)) + fi + + # Check for log files. + if echo "$CHANGED_FILES" | grep -E '\.log$' | grep -v 'test.*\.log' | grep -q .; then + printf "${RED}✗ BLOCKED: Log file in push!${NC}\n" + printf "Files: %s\n" "$(echo "$CHANGED_FILES" | grep -E '\.log$' | grep -v 'test.*\.log')" + ERRORS=$((ERRORS + 1)) + fi + + # Check file contents for secrets. + while IFS= read -r file; do + if [ -f "$file" ] && [ ! -d "$file" ]; then + # Skip test files, example files, and hook scripts. + if echo "$file" | grep -qE '\.(test|spec)\.(m?[jt]s|tsx?)$|\.example$|/test/|/tests/|fixtures/|\.git-hooks/|\.husky/'; then + continue + fi + + # Check for hardcoded user paths. + if grep -E '(/Users/[^/\s]+/|/home/[^/\s]+/|C:\\Users\\[^\\]+\\)' "$file" 2>/dev/null | grep -q .; then + printf "${RED}✗ BLOCKED: Hardcoded personal path found in: %s${NC}\n" "$file" + grep -n -E '(/Users/[^/\s]+/|/home/[^/\s]+/|C:\\Users\\[^\\]+\\)' "$file" | head -3 + ERRORS=$((ERRORS + 1)) + fi + + # Check for Socket API keys. + if grep -E 'sktsec_[a-zA-Z0-9_-]+' "$file" 2>/dev/null | grep -v "$ALLOWED_PUBLIC_KEY" | grep -v 'your_api_key_here' | grep -v 'SOCKET_SECURITY_API_KEY=' | grep -v 'fake-token' | grep -v 'test-token' | grep -q .; then + printf "${RED}✗ BLOCKED: Real API key detected in: %s${NC}\n" "$file" + grep -n 'sktsec_' "$file" | grep -v "$ALLOWED_PUBLIC_KEY" | grep -v 'your_api_key_here' | grep -v 'fake-token' | grep -v 'test-token' | head -3 + ERRORS=$((ERRORS + 1)) + fi + + # Check for AWS keys. + if grep -iE '(aws_access_key|aws_secret|AKIA[0-9A-Z]{16})' "$file" 2>/dev/null | grep -q .; then + printf "${RED}✗ BLOCKED: Potential AWS credentials found in: %s${NC}\n" "$file" + grep -n -iE '(aws_access_key|aws_secret|AKIA[0-9A-Z]{16})' "$file" | head -3 + ERRORS=$((ERRORS + 1)) + fi + + # Check for GitHub tokens. + if grep -E 'gh[ps]_[a-zA-Z0-9]{36}' "$file" 2>/dev/null | grep -q .; then + printf "${RED}✗ BLOCKED: Potential GitHub token found in: %s${NC}\n" "$file" + grep -n -E 'gh[ps]_[a-zA-Z0-9]{36}' "$file" | head -3 + ERRORS=$((ERRORS + 1)) + fi + + # Check for private keys. + if grep -E '-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----' "$file" 2>/dev/null | grep -q .; then + printf "${RED}✗ BLOCKED: Private key found in: %s${NC}\n" "$file" + ERRORS=$((ERRORS + 1)) + fi + fi + done <<< "$CHANGED_FILES" + fi + + TOTAL_ERRORS=$((TOTAL_ERRORS + ERRORS)) +done + +if [ $TOTAL_ERRORS -gt 0 ]; then + printf "\n" + printf "${RED}✗ Push blocked by mandatory validation!${NC}\n" + printf "Fix the issues above before pushing.\n" + exit 1 +fi + +printf "${GREEN}✓ All mandatory validation passed!${NC}\n" +exit 0 diff --git a/.gitignore b/.gitignore index 240ac97eb..0da4c3ab5 100644 --- a/.gitignore +++ b/.gitignore @@ -77,7 +77,9 @@ yarn-error.log* /.claude/* !/.claude/agents/ !/.claude/commands/ +!/.claude/hooks/ !/.claude/ops/ +!/.claude/settings.json !/.claude/skills/ # ============================================================================ From c4e73b397e82ab979da4dc497b5db4273183b47a Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 10 Apr 2026 12:27:04 -0400 Subject: [PATCH 2/8] fix(security): address review feedback on setup-security-tools - Remove GIT_SSL_NO_VERIFY=true from SFW free shims (MITM risk) - Remove dead sha256File function and unused createReadStream/createHash imports - Fix pre-push: skip scan when no baseline exists instead of scanning all history - Fix pre-push: use remote_sha..local_sha for existing branches to avoid re-scanning already-pushed commits --- .claude/hooks/setup-security-tools/index.mts | 17 +---------------- .git-hooks/pre-push | 16 +++++----------- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/.claude/hooks/setup-security-tools/index.mts b/.claude/hooks/setup-security-tools/index.mts index 3d349b5a1..3df366cc0 100644 --- a/.claude/hooks/setup-security-tools/index.mts +++ b/.claude/hooks/setup-security-tools/index.mts @@ -10,8 +10,7 @@ // for malware. Downloads binary, verifies SHA-256, creates PATH shims. // Enterprise vs free determined by SOCKET_API_KEY in env / .env / .env.local. -import { createHash } from 'node:crypto' -import { existsSync, createReadStream, readFileSync, promises as fs } from 'node:fs' +import { existsSync, readFileSync, promises as fs } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import process from 'node:process' @@ -109,16 +108,6 @@ function findApiKey(): string | undefined { return undefined } -async function sha256File(filePath: string): Promise { - return new Promise((resolve, reject) => { - const hash = createHash('sha256') - const stream = createReadStream(filePath) - stream.on('data', (chunk: Buffer) => hash.update(chunk)) - stream.on('end', () => resolve(hash.digest('hex'))) - stream.on('error', reject) - }) -} - // ── AgentShield ── function setupAgentShield(): boolean { @@ -273,10 +262,6 @@ async function setupSfw(apiKey: string | undefined): Promise { 'fi', ) } - if (!isEnterprise) { - // Workaround: sfw-free does not yet set GIT_SSL_CAINFO (temporary). - bashLines.push('export GIT_SSL_NO_VERIFY=true') - } bashLines.push(`exec "${binaryPath}" "${realBin}" "$@"`) const bashContent = bashLines.join('\n') + '\n' const bashPath = path.join(shimDir, cmd) diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index 2bf9a70e6..4ff82f73e 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -79,20 +79,14 @@ while read local_ref local_sha remote_ref remote_sha; do if [ -n "$latest_release" ]; then range="$latest_release..$local_sha" else - range="$local_sha" + # No remote branch or tags — skip scan to avoid walking entire history. + printf "${GREEN}✓ Skipping validation (no baseline to compare against)${NC}\n" + continue fi fi else - # Existing branch - check new commits since remote. - # Limit scope to commits after the latest published release on this branch. - latest_release=$(git tag --list 'v*' --sort=-version:refname --merged "$remote_sha" | head -1) - if [ -n "$latest_release" ]; then - # Only check commits after the latest release that are being pushed. - range="$latest_release..$local_sha" - else - # No release tags found, check new commits only. - range="$remote_sha..$local_sha" - fi + # Existing branch — only check commits not yet on the remote. + range="$remote_sha..$local_sha" fi # Validate the computed range before using it. From c62bacc086863c0780bc343ac0680e3754a90c8a Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 10 Apr 2026 12:49:07 -0400 Subject: [PATCH 3/8] fix(security): use $remote var, remove dead code, fix setupSfw return --- .claude/hooks/setup-security-tools/index.mts | 2 +- .git-hooks/pre-push | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.claude/hooks/setup-security-tools/index.mts b/.claude/hooks/setup-security-tools/index.mts index 3df366cc0..9ef8c4cb3 100644 --- a/.claude/hooks/setup-security-tools/index.mts +++ b/.claude/hooks/setup-security-tools/index.mts @@ -292,7 +292,7 @@ async function setupSfw(apiKey: string | undefined): Promise { } else { logger.warn('No supported package managers found on PATH.') } - return true + return !!created.length } // ── Main ── diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index 4ff82f73e..f1f5c8d4b 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -2,12 +2,13 @@ # Socket Security Pre-push Hook # Security enforcement layer for all pushes. # Validates all commits being pushed for security issues and AI attribution. +# TODO: The security scanning logic (CHECK 2) is duplicated from .husky/pre-push. +# Consider extracting shared checks into a common script. set -e # Colors for output. RED='\033[0;31m' -YELLOW='\033[1;33m' GREEN='\033[0;32m' NC='\033[0m' @@ -67,12 +68,12 @@ while read local_ref local_sha remote_ref remote_sha; do # Get the range of commits being pushed. if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then # New branch - only check commits not on the default remote branch. - default_branch=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') + default_branch=$(git symbolic-ref "refs/remotes/$remote/HEAD" 2>/dev/null | sed "s@^refs/remotes/$remote/@@") if [ -z "$default_branch" ]; then default_branch="main" fi - if git rev-parse "origin/$default_branch" >/dev/null 2>&1; then - range="origin/$default_branch..$local_sha" + if git rev-parse "$remote/$default_branch" >/dev/null 2>&1; then + range="$remote/$default_branch..$local_sha" else # No remote default branch, fall back to release tag. latest_release=$(git tag --list 'v*' --sort=-version:refname --merged "$local_sha" | head -1) From f19999f166253f448aa23ce7bda3e20b0f04fd23 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 10 Apr 2026 12:49:23 -0400 Subject: [PATCH 4/8] fix(security): address round-2 review feedback - Use $remote variable instead of hardcoded 'origin' in pre-push hook - Remove unused YELLOW color variable - Return false from setupSfw when no shims were created - Add sync note referencing .husky/security-checks.sh --- .git-hooks/pre-push | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index f1f5c8d4b..099b7e93d 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -2,8 +2,7 @@ # Socket Security Pre-push Hook # Security enforcement layer for all pushes. # Validates all commits being pushed for security issues and AI attribution. -# TODO: The security scanning logic (CHECK 2) is duplicated from .husky/pre-push. -# Consider extracting shared checks into a common script. +# NOTE: Security checks parallel .husky/security-checks.sh — keep in sync. set -e From 2f8e5996007a5224c474efa99daae24d19f2db03 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 10 Apr 2026 13:09:13 -0400 Subject: [PATCH 5/8] fix(security): add API key env file loading to Windows enterprise shims Windows .cmd shims for enterprise SFW had no equivalent of the bash shim's .env.local/.env file reading logic. Enterprise features would silently fail unless SOCKET_API_KEY was set as an OS-level env variable. --- .claude/hooks/setup-security-tools/index.mts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.claude/hooks/setup-security-tools/index.mts b/.claude/hooks/setup-security-tools/index.mts index 9ef8c4cb3..787602e13 100644 --- a/.claude/hooks/setup-security-tools/index.mts +++ b/.claude/hooks/setup-security-tools/index.mts @@ -272,11 +272,26 @@ async function setupSfw(apiKey: string | undefined): Promise { // Windows .cmd shim (strips shim dir from PATH, then execs through sfw). if (isWindows) { + let cmdApiKeyBlock = '' + if (isEnterprise) { + // Read API key from .env files at runtime — mirrors the bash shim logic. + cmdApiKeyBlock = + `if not defined SOCKET_API_KEY (\r\n` + + ` for %%F in (.env.local .env) do (\r\n` + + ` if exist "%%F" (\r\n` + + ` for /f "tokens=1,* delims==" %%A in ('findstr /b "SOCKET_API_KEY" "%%F"') do (\r\n` + + ` set "SOCKET_API_KEY=%%B"\r\n` + + ` )\r\n` + + ` )\r\n` + + ` )\r\n` + + `)\r\n` + } const cmdContent = `@echo off\r\n` + `set "PATH=;%PATH%;"\r\n` + `set "PATH=%PATH:;${shimDir};=%"\r\n` + `set "PATH=%PATH:~1,-1%"\r\n` + + cmdApiKeyBlock + `"${binaryPath}" "${realBin}" %*\r\n` const cmdPath = path.join(shimDir, `${cmd}.cmd`) if (!existsSync(cmdPath) || await fs.readFile(cmdPath, 'utf8').catch(() => '') !== cmdContent) { From bf5169b2565147c657361bcd1147f182656e2abc Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 10 Apr 2026 13:39:43 -0400 Subject: [PATCH 6/8] fix(security): redirect stdin for pre-checks, fix rebase suggestion - Redirect agentshield and zizmor stdin from /dev/null to prevent them from consuming git's ref data pipe, which would cause the while-read loop to silently iterate zero times - Use computed range base instead of $remote_sha in rebase suggestion so new-branch pushes don't print the zero SHA --- .git-hooks/pre-push | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index 099b7e93d..c7ece69d0 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -27,7 +27,7 @@ TOTAL_ERRORS=0 # ============================================================================ if command -v agentshield >/dev/null 2>&1 || [ -x "$(pnpm bin 2>/dev/null)/agentshield" ]; then AGENTSHIELD="$(command -v agentshield 2>/dev/null || echo "$(pnpm bin)/agentshield")" - if ! "$AGENTSHIELD" scan --quiet 2>/dev/null; then + if ! "$AGENTSHIELD" scan --quiet < /dev/null 2>/dev/null; then printf "${RED}✗ AgentShield: security issues found in Claude config${NC}\n" printf "Run 'pnpm exec agentshield scan' for details\n" TOTAL_ERRORS=$((TOTAL_ERRORS + 1)) @@ -44,7 +44,7 @@ elif [ -x "$HOME/.socket/zizmor/bin/zizmor" ]; then ZIZMOR="$HOME/.socket/zizmor/bin/zizmor" fi if [ -n "$ZIZMOR" ] && [ -d ".github/" ]; then - if ! "$ZIZMOR" .github/ 2>/dev/null; then + if ! "$ZIZMOR" .github/ < /dev/null 2>/dev/null; then printf "${RED}✗ Zizmor: workflow security issues found${NC}\n" printf "Run 'zizmor .github/' for details\n" TOTAL_ERRORS=$((TOTAL_ERRORS + 1)) @@ -121,8 +121,9 @@ while read local_ref local_sha remote_ref remote_sha; do printf "These commits were likely created with --no-verify, bypassing the\n" printf "commit-msg hook that strips AI attribution.\n" printf "\n" + range_base="${range%..*}" printf "To fix:\n" - printf " git rebase -i %s\n" "$remote_sha" + printf " git rebase -i %s\n" "$range_base" printf " Mark commits as 'reword', remove AI attribution, save\n" printf " git push\n" fi From 0b4b700ad6a9db5c8d69f52685505f83bf1fe7cb Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 10 Apr 2026 22:36:27 -0400 Subject: [PATCH 7/8] fix: pin SFW version in download URL and fix shell range parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SFW: Replace /releases/latest/download/ with pinned /releases/download/v1.6.1/ to match hardcoded checksums. Latest URL breaks on new releases. - pre-push: Fix ${range%..*} → ${range%%\.\.*} for correct base SHA extraction. --- .claude/hooks/setup-security-tools/index.mts | 6 +++++- .git-hooks/pre-push | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/setup-security-tools/index.mts b/.claude/hooks/setup-security-tools/index.mts index 787602e13..18d6b2d20 100644 --- a/.claude/hooks/setup-security-tools/index.mts +++ b/.claude/hooks/setup-security-tools/index.mts @@ -53,6 +53,10 @@ const ZIZMOR_ASSET_MAP: Record = { // ── SFW constants ── +// Pinned SFW version — checksums below are for this exact version. +// Using /releases/latest/ would break when a new release ships with different checksums. +const SFW_VERSION = 'v1.6.1' + const SFW_ENTERPRISE_CHECKSUMS: Record = { __proto__: null as unknown as string, 'linux-arm64': '671270231617142404a1564e52672f79b806f9df3f232fcc7606329c0246da55', @@ -220,7 +224,7 @@ async function setupSfw(apiKey: string | undefined): Promise { const suffix = sfwPlatform.startsWith('windows') ? '.exe' : '' const asset = `${prefix}-${sfwPlatform}${suffix}` const repo = isEnterprise ? 'SocketDev/firewall-release' : 'SocketDev/sfw-free' - const url = `https://github.com/${repo}/releases/latest/download/${asset}` + const url = `https://github.com/${repo}/releases/download/${SFW_VERSION}/${asset}` const binaryName = isEnterprise ? 'sfw' : 'sfw-free' // Download (with cache + checksum). diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index c7ece69d0..f66d4ffa8 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -121,7 +121,7 @@ while read local_ref local_sha remote_ref remote_sha; do printf "These commits were likely created with --no-verify, bypassing the\n" printf "commit-msg hook that strips AI attribution.\n" printf "\n" - range_base="${range%..*}" + range_base="${range%%\.\.*}" printf "To fix:\n" printf " git rebase -i %s\n" "$range_base" printf " Mark commits as 'reword', remove AI attribution, save\n" From 86ff827eba6080b2f6c4890928fe9f75b9221f54 Mon Sep 17 00:00:00 2001 From: jdalton Date: Sat, 11 Apr 2026 08:50:56 -0400 Subject: [PATCH 8/8] refactor: load security tool config from external-tools.json Extract hardcoded zizmor/sfw versions, checksums, platform maps, and ecosystems into external-tools.json with Zod schema validation. Makes tool updates a JSON edit instead of code changes. --- .../setup-security-tools/external-tools.json | 64 ++++++++++ .claude/hooks/setup-security-tools/index.mts | 113 ++++++------------ 2 files changed, 102 insertions(+), 75 deletions(-) create mode 100644 .claude/hooks/setup-security-tools/external-tools.json diff --git a/.claude/hooks/setup-security-tools/external-tools.json b/.claude/hooks/setup-security-tools/external-tools.json new file mode 100644 index 000000000..95482e5a7 --- /dev/null +++ b/.claude/hooks/setup-security-tools/external-tools.json @@ -0,0 +1,64 @@ +{ + "description": "Security tools for Claude Code hooks (self-contained, no external deps)", + "tools": { + "zizmor": { + "description": "GitHub Actions security scanner", + "version": "1.23.1", + "repository": "woodruffw/zizmor", + "assets": { + "darwin-arm64": "zizmor-aarch64-apple-darwin.tar.gz", + "darwin-x64": "zizmor-x86_64-apple-darwin.tar.gz", + "linux-arm64": "zizmor-aarch64-unknown-linux-gnu.tar.gz", + "linux-x64": "zizmor-x86_64-unknown-linux-gnu.tar.gz", + "win32-x64": "zizmor-x86_64-pc-windows-msvc.zip" + }, + "checksums": { + "zizmor-aarch64-apple-darwin.tar.gz": "2632561b974c69f952258c1ab4b7432d5c7f92e555704155c3ac28a2910bd717", + "zizmor-aarch64-unknown-linux-gnu.tar.gz": "3725d7cd7102e4d70827186389f7d5930b6878232930d0a3eb058d7e5b47e658", + "zizmor-x86_64-apple-darwin.tar.gz": "89d5ed42081dd9d0433a10b7545fac42b35f1f030885c278b9712b32c66f2597", + "zizmor-x86_64-pc-windows-msvc.zip": "33c2293ff02834720dd7cd8b47348aafb2e95a19bdc993c0ecaca9c804ade92a", + "zizmor-x86_64-unknown-linux-gnu.tar.gz": "67a8df0a14352dd81882e14876653d097b99b0f4f6b6fe798edc0320cff27aff" + } + }, + "sfw-free": { + "description": "Socket Firewall (free tier)", + "version": "v1.6.1", + "repository": "SocketDev/sfw-free", + "platforms": { + "darwin-arm64": "macos-arm64", + "darwin-x64": "macos-x86_64", + "linux-arm64": "linux-arm64", + "linux-x64": "linux-x86_64", + "win32-x64": "windows-x86_64" + }, + "checksums": { + "linux-arm64": "df2eedb2daf2572eee047adb8bfd81c9069edcb200fc7d3710fca98ec3ca81a1", + "linux-x86_64": "4a1e8b65e90fce7d5fd066cf0af6c93d512065fa4222a475c8d959a6bc14b9ff", + "macos-arm64": "bf1616fc44ac49f1cb2067fedfa127a3ae65d6ec6d634efbb3098cfa355e5555", + "macos-x86_64": "724ccea19d847b79db8cc8e38f5f18ce2dd32336007f42b11bed7d2e5f4a2566", + "windows-x86_64": "c953e62ad7928d4d8f2302f5737884ea1a757babc26bed6a42b9b6b68a5d54af" + }, + "ecosystems": ["npm", "yarn", "pnpm", "pip", "uv", "cargo"] + }, + "sfw-enterprise": { + "description": "Socket Firewall (enterprise tier)", + "version": "v1.6.1", + "repository": "SocketDev/firewall-release", + "platforms": { + "darwin-arm64": "macos-arm64", + "darwin-x64": "macos-x86_64", + "linux-arm64": "linux-arm64", + "linux-x64": "linux-x86_64", + "win32-x64": "windows-x86_64" + }, + "checksums": { + "linux-arm64": "671270231617142404a1564e52672f79b806f9df3f232fcc7606329c0246da55", + "linux-x86_64": "9115b4ca8021eb173eb9e9c3627deb7f1066f8debd48c5c9d9f3caabb2a26a4b", + "macos-arm64": "acad0b517601bb7408e2e611c9226f47dcccbd83333d7fc5157f1d32ed2b953d", + "macos-x86_64": "01d64d40effda35c31f8d8ee1fed1388aac0a11aba40d47fba8a36024b77500c", + "windows-x86_64": "9a50e1ddaf038138c3f85418dc5df0113bbe6fc884f5abe158beaa9aea18d70a" + }, + "ecosystems": ["npm", "yarn", "pnpm", "pip", "uv", "cargo", "gem", "bundler", "nuget"] + } + } +} diff --git a/.claude/hooks/setup-security-tools/index.mts b/.claude/hooks/setup-security-tools/index.mts index 18d6b2d20..97ef9e07c 100644 --- a/.claude/hooks/setup-security-tools/index.mts +++ b/.claude/hooks/setup-security-tools/index.mts @@ -14,6 +14,7 @@ import { existsSync, readFileSync, promises as fs } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import process from 'node:process' +import { fileURLToPath } from 'node:url' import { whichSync } from '@socketsecurity/lib/bin' import { downloadBinary } from '@socketsecurity/lib/dlx/binary' @@ -21,71 +22,35 @@ import { httpDownload } from '@socketsecurity/lib/http-request' import { getDefaultLogger } from '@socketsecurity/lib/logger' import { getSocketHomePath } from '@socketsecurity/lib/paths/socket' import { spawn, spawnSync } from '@socketsecurity/lib/spawn' +import { z } from 'zod' const logger = getDefaultLogger() -// ── Zizmor constants ── - -const ZIZMOR_VERSION = '1.23.1' - -const ZIZMOR_CHECKSUMS: Record = { - __proto__: null as unknown as string, - 'zizmor-aarch64-apple-darwin.tar.gz': - '2632561b974c69f952258c1ab4b7432d5c7f92e555704155c3ac28a2910bd717', - 'zizmor-aarch64-unknown-linux-gnu.tar.gz': - '3725d7cd7102e4d70827186389f7d5930b6878232930d0a3eb058d7e5b47e658', - 'zizmor-x86_64-apple-darwin.tar.gz': - '89d5ed42081dd9d0433a10b7545fac42b35f1f030885c278b9712b32c66f2597', - 'zizmor-x86_64-pc-windows-msvc.zip': - '33c2293ff02834720dd7cd8b47348aafb2e95a19bdc993c0ecaca9c804ade92a', - 'zizmor-x86_64-unknown-linux-gnu.tar.gz': - '67a8df0a14352dd81882e14876653d097b99b0f4f6b6fe798edc0320cff27aff', -} - -const ZIZMOR_ASSET_MAP: Record = { - __proto__: null as unknown as string, - 'darwin-arm64': 'zizmor-aarch64-apple-darwin.tar.gz', - 'darwin-x64': 'zizmor-x86_64-apple-darwin.tar.gz', - 'linux-arm64': 'zizmor-aarch64-unknown-linux-gnu.tar.gz', - 'linux-x64': 'zizmor-x86_64-unknown-linux-gnu.tar.gz', - 'win32-x64': 'zizmor-x86_64-pc-windows-msvc.zip', -} - -// ── SFW constants ── - -// Pinned SFW version — checksums below are for this exact version. -// Using /releases/latest/ would break when a new release ships with different checksums. -const SFW_VERSION = 'v1.6.1' +// ── Tool config loaded from external-tools.json (self-contained) ── -const SFW_ENTERPRISE_CHECKSUMS: Record = { - __proto__: null as unknown as string, - 'linux-arm64': '671270231617142404a1564e52672f79b806f9df3f232fcc7606329c0246da55', - 'linux-x86_64': '9115b4ca8021eb173eb9e9c3627deb7f1066f8debd48c5c9d9f3caabb2a26a4b', - 'macos-arm64': 'acad0b517601bb7408e2e611c9226f47dcccbd83333d7fc5157f1d32ed2b953d', - 'macos-x86_64': '01d64d40effda35c31f8d8ee1fed1388aac0a11aba40d47fba8a36024b77500c', - 'windows-x86_64': '9a50e1ddaf038138c3f85418dc5df0113bbe6fc884f5abe158beaa9aea18d70a', -} +const toolSchema = z.object({ + description: z.string().optional(), + version: z.string(), + repository: z.string().optional(), + assets: z.record(z.string(), z.string()).optional(), + platforms: z.record(z.string(), z.string()).optional(), + checksums: z.record(z.string(), z.string()).optional(), + ecosystems: z.array(z.string()).optional(), +}) -const SFW_FREE_CHECKSUMS: Record = { - __proto__: null as unknown as string, - 'linux-arm64': 'df2eedb2daf2572eee047adb8bfd81c9069edcb200fc7d3710fca98ec3ca81a1', - 'linux-x86_64': '4a1e8b65e90fce7d5fd066cf0af6c93d512065fa4222a475c8d959a6bc14b9ff', - 'macos-arm64': 'bf1616fc44ac49f1cb2067fedfa127a3ae65d6ec6d634efbb3098cfa355e5555', - 'macos-x86_64': '724ccea19d847b79db8cc8e38f5f18ce2dd32336007f42b11bed7d2e5f4a2566', - 'windows-x86_64': 'c953e62ad7928d4d8f2302f5737884ea1a757babc26bed6a42b9b6b68a5d54af', -} +const configSchema = z.object({ + description: z.string().optional(), + tools: z.record(z.string(), toolSchema), +}) -const SFW_PLATFORM_MAP: Record = { - __proto__: null as unknown as string, - 'darwin-arm64': 'macos-arm64', - 'darwin-x64': 'macos-x86_64', - 'linux-arm64': 'linux-arm64', - 'linux-x64': 'linux-x86_64', - 'win32-x64': 'windows-x86_64', -} +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const configPath = path.join(__dirname, 'external-tools.json') +const rawConfig = JSON.parse(readFileSync(configPath, 'utf8')) +const config = configSchema.parse(rawConfig) -const SFW_FREE_ECOSYSTEMS = ['npm', 'yarn', 'pnpm', 'pip', 'uv', 'cargo'] -const SFW_ENTERPRISE_EXTRA = ['gem', 'bundler', 'nuget'] +const ZIZMOR = config.tools['zizmor']! +const SFW_FREE = config.tools['sfw-free']! +const SFW_ENTERPRISE = config.tools['sfw-enterprise']! // ── Shared helpers ── @@ -137,7 +102,7 @@ async function checkZizmorVersion(binPath: string): Promise { const output = typeof result.stdout === 'string' ? result.stdout.trim() : result.stdout.toString().trim() - return output.includes(ZIZMOR_VERSION) + return output.includes(ZIZMOR.version) } catch { return false } @@ -150,10 +115,10 @@ async function setupZizmor(): Promise { const systemBin = whichSync('zizmor', { nothrow: true }) if (systemBin && typeof systemBin === 'string') { if (await checkZizmorVersion(systemBin)) { - logger.log(`Found on PATH: ${systemBin} (v${ZIZMOR_VERSION})`) + logger.log(`Found on PATH: ${systemBin} (v${ZIZMOR.version})`) return true } - logger.log(`Found on PATH but wrong version (need v${ZIZMOR_VERSION})`) + logger.log(`Found on PATH but wrong version (need v${ZIZMOR.version})`) } // Check cached binary. @@ -161,20 +126,20 @@ async function setupZizmor(): Promise { const binDir = path.join(getSocketHomePath(), 'zizmor', 'bin') const binPath = path.join(binDir, `zizmor${ext}`) if (existsSync(binPath) && await checkZizmorVersion(binPath)) { - logger.log(`Cached: ${binPath} (v${ZIZMOR_VERSION})`) + logger.log(`Cached: ${binPath} (v${ZIZMOR.version})`) return true } // Download. const platformKey = `${process.platform}-${process.arch}` - const asset = ZIZMOR_ASSET_MAP[platformKey] + const asset = ZIZMOR.assets?.[platformKey] if (!asset) throw new Error(`Unsupported platform: ${platformKey}`) - const expectedSha = ZIZMOR_CHECKSUMS[asset] + const expectedSha = ZIZMOR.checksums?.[asset] if (!expectedSha) throw new Error(`No checksum for: ${asset}`) - const url = `https://github.com/woodruffw/zizmor/releases/download/v${ZIZMOR_VERSION}/${asset}` + const url = `https://github.com/${ZIZMOR.repository}/releases/download/v${ZIZMOR.version}/${asset}` const isZip = asset.endsWith('.zip') - logger.log(`Downloading zizmor v${ZIZMOR_VERSION} (${asset})...`) + logger.log(`Downloading zizmor v${ZIZMOR.version} (${asset})...`) const tmpFile = path.join(tmpdir(), `zizmor-${Date.now()}-${asset}`) try { await httpDownload(url, tmpFile, { sha256: expectedSha }) @@ -209,22 +174,21 @@ async function setupZizmor(): Promise { async function setupSfw(apiKey: string | undefined): Promise { const isEnterprise = !!apiKey + const sfwConfig = isEnterprise ? SFW_ENTERPRISE : SFW_FREE logger.log(`=== Socket Firewall (${isEnterprise ? 'enterprise' : 'free'}) ===`) // Platform. const platformKey = `${process.platform}-${process.arch}` - const sfwPlatform = SFW_PLATFORM_MAP[platformKey] + const sfwPlatform = sfwConfig.platforms?.[platformKey] if (!sfwPlatform) throw new Error(`Unsupported platform: ${platformKey}`) // Checksum + asset. - const checksums = isEnterprise ? SFW_ENTERPRISE_CHECKSUMS : SFW_FREE_CHECKSUMS - const sha256 = checksums[sfwPlatform] + const sha256 = sfwConfig.checksums?.[sfwPlatform] if (!sha256) throw new Error(`No checksum for: ${sfwPlatform}`) const prefix = isEnterprise ? 'sfw' : 'sfw-free' const suffix = sfwPlatform.startsWith('windows') ? '.exe' : '' const asset = `${prefix}-${sfwPlatform}${suffix}` - const repo = isEnterprise ? 'SocketDev/firewall-release' : 'SocketDev/sfw-free' - const url = `https://github.com/${repo}/releases/download/${SFW_VERSION}/${asset}` + const url = `https://github.com/${sfwConfig.repository}/releases/download/${sfwConfig.version}/${asset}` const binaryName = isEnterprise ? 'sfw' : 'sfw-free' // Download (with cache + checksum). @@ -235,10 +199,9 @@ async function setupSfw(apiKey: string | undefined): Promise { const isWindows = process.platform === 'win32' const shimDir = path.join(getSocketHomePath(), 'sfw', 'shims') await fs.mkdir(shimDir, { recursive: true }) - const ecosystems = [...SFW_FREE_ECOSYSTEMS] - if (isEnterprise) { - ecosystems.push(...SFW_ENTERPRISE_EXTRA) - if (process.platform === 'linux') ecosystems.push('go') + const ecosystems = [...(sfwConfig.ecosystems ?? [])] + if (isEnterprise && process.platform === 'linux') { + ecosystems.push('go') } const cleanPath = (process.env['PATH'] ?? '').split(path.delimiter) .filter(p => p !== shimDir).join(path.delimiter)