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/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 new file mode 100644 index 000000000..97ef9e07c --- /dev/null +++ b/.claude/hooks/setup-security-tools/index.mts @@ -0,0 +1,309 @@ +#!/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 { 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' +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() + +// ── Tool config loaded from external-tools.json (self-contained) ── + +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 configSchema = z.object({ + description: z.string().optional(), + tools: z.record(z.string(), toolSchema), +}) + +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 ZIZMOR = config.tools['zizmor']! +const SFW_FREE = config.tools['sfw-free']! +const SFW_ENTERPRISE = config.tools['sfw-enterprise']! + +// ── 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 +} + +// ── 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.assets?.[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/${ZIZMOR.repository}/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 + const sfwConfig = isEnterprise ? SFW_ENTERPRISE : SFW_FREE + logger.log(`=== Socket Firewall (${isEnterprise ? 'enterprise' : 'free'}) ===`) + + // Platform. + const platformKey = `${process.platform}-${process.arch}` + const sfwPlatform = sfwConfig.platforms?.[platformKey] + if (!sfwPlatform) throw new Error(`Unsupported platform: ${platformKey}`) + + // Checksum + asset. + 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 url = `https://github.com/${sfwConfig.repository}/releases/download/${sfwConfig.version}/${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 = [...(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) + 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', + ) + } + 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) { + 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) { + 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 !!created.length +} + +// ── 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..f66d4ffa8 --- /dev/null +++ b/.git-hooks/pre-push @@ -0,0 +1,217 @@ +#!/bin/bash +# Socket Security Pre-push Hook +# Security enforcement layer for all pushes. +# Validates all commits being pushed for security issues and AI attribution. +# NOTE: Security checks parallel .husky/security-checks.sh — keep in sync. + +set -e + +# Colors for output. +RED='\033[0;31m' +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 < /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)) + 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/ < /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)) + 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/$remote/HEAD" 2>/dev/null | sed "s@^refs/remotes/$remote/@@") + if [ -z "$default_branch" ]; then + default_branch="main" + fi + 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) + if [ -n "$latest_release" ]; then + range="$latest_release..$local_sha" + else + # 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 — only check commits not yet on the remote. + range="$remote_sha..$local_sha" + 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" + 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" + 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/ # ============================================================================