diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md index 2f5f2039..0d89c420 100644 --- a/.claude/agents/code-reviewer.md +++ b/.claude/agents/code-reviewer.md @@ -14,7 +14,7 @@ Apply the rules from CLAUDE.md sections listed below. Reference the full section **Error Handling**: catch (e) not catch (error), double-quoted error messages, { cause: e } chaining. -**Backward Compatibility**: FORBIDDEN — actively remove compat shims, don't maintain them. +**Compat shims**: FORBIDDEN — actively remove compat shims, don't maintain them. **Test Style**: Functional tests over source scanning. Never read source files and assert on contents. Verify behavior with real function calls. diff --git a/.claude/agents/refactor-cleaner.md b/.claude/agents/refactor-cleaner.md index 4f3230fc..cbcff4de 100644 --- a/.claude/agents/refactor-cleaner.md +++ b/.claude/agents/refactor-cleaner.md @@ -22,4 +22,4 @@ Apply these rules from CLAUDE.md exactly: - Unreachable code paths - Duplicate logic that should be consolidated - Files >400 LOC that should be split (flag to user, don't split without approval) -- Backward compatibility shims (FORBIDDEN per CLAUDE.md — actively remove) +- Compat shims (FORBIDDEN per CLAUDE.md — actively remove) diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index 0659bab9..92e7ba7f 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -1,57 +1,37 @@ #!/bin/bash # Socket Security Pre-push Hook # Security enforcement layer for all pushes. -# Validates all commits being pushed for security issues and AI attribution. +# Validates commits being pushed for AI attribution and secrets. +# +# Architecture: +# .husky/pre-push (thin wrapper) → .git-hooks/pre-push (this file) +# Husky sets core.hooksPath=.husky/_ which delegates to .husky/pre-push. +# This file contains all the actual logic. +# +# Range logic: +# New branch: remote/.. (only new commits) +# Existing: .. (only new commits) +# We never use release tags — that would re-scan already-merged history. 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 API key (used in socket-lib test fixtures). ALLOWED_PUBLIC_KEY="sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api" -# Get the remote name and URL. +# Get the remote name and URL from git (passed as arguments to pre-push hooks). 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 >/dev/null 2>&1; 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. +# Read stdin for refs being pushed (git provides: local_ref local_sha remote_ref remote_sha). 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 @@ -59,40 +39,33 @@ while read local_ref local_sha remote_ref remote_sha; do continue fi - # Skip delete pushes. + # Skip delete pushes (local_sha is all zeros when deleting a remote branch). if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then continue fi - # Get the range of commits being pushed. + # ── Compute commit range ────────────────────────────────────────────── + # Goal: only scan commits that are NEW in this push, never re-scan + # commits already on the remote. This prevents false positives from + # old AI-attributed commits that were merged before the hook existed. 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/@@') + # New branch — compare against the remote's default branch (usually main). + # This ensures we only check commits unique to this 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 "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) - if [ -n "$latest_release" ]; then - range="$latest_release..$local_sha" - else - range="$local_sha" - fi + # No remote default branch (shallow clone, etc.) — skip to avoid + # walking entire history which would cause false positives. + printf "${GREEN}✓ Skipping validation (no baseline to compare against)${NC}\n" + continue 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. @@ -103,13 +76,12 @@ while read local_ref local_sha remote_ref remote_sha; do ERRORS=0 - # ============================================================================ - # CHECK 1: Scan commit messages for AI attribution - # ============================================================================ + # ── CHECK 1: AI attribution in commit messages ──────────────────────── + # Strips these at commit time via commit-msg hook, but this catches + # commits made with --no-verify or on other machines. printf "Checking commit messages for AI attribution...\n" - # Check each commit in the range for AI patterns. - while IFS= read -r commit_sha; do + for commit_sha in $(git rev-list "$range"); 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 @@ -120,59 +92,55 @@ while read local_ref local_sha remote_ref remote_sha; do printf " - %s\n" "$(git log -1 --oneline "$commit_sha")" ERRORS=$((ERRORS + 1)) fi - done < <(git rev-list "$range") + done 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" "$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 - # ============================================================================ - # CHECK 2: File content security checks - # ============================================================================ + # ── CHECK 2: File content security checks ───────────────────────────── + # Scans files changed in the push range for secrets, keys, and mistakes. 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. + # Check for sensitive files (.env, .DS_Store, log 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. + # Check file contents for secrets and hardcoded paths. while IFS= read -r file; do if [ -f "$file" ] && [ ! -d "$file" ]; then - # Skip test files, example files, and hook scripts. + # Skip test files, example files, and hook scripts themselves. if echo "$file" | grep -qE '\.(test|spec)\.(m?[jt]s|tsx?)$|\.example$|/test/|/tests/|fixtures/|\.git-hooks/|\.husky/'; then continue fi # Use strings for binary files, grep directly for text files. - # This correctly extracts printable strings from WASM, .lockb, etc. is_binary=false if grep -qI '' "$file" 2>/dev/null; then is_binary=false @@ -181,40 +149,40 @@ while read local_ref local_sha remote_ref remote_sha; do fi if [ "$is_binary" = true ]; then - file_text=$(strings "$file" 2>/dev/null || echo "") + file_text=$(strings "$file" 2>/dev/null) else - file_text=$(cat "$file" 2>/dev/null || echo "") + file_text=$(cat "$file" 2>/dev/null) fi - # Check for hardcoded user paths. + # Hardcoded personal paths (/Users/foo/, /home/foo/, C:\Users\foo\). if echo "$file_text" | grep -qE '(/Users/[^/\s]+/|/home/[^/\s]+/|C:\\Users\\[^\\]+\\)'; then printf "${RED}✗ BLOCKED: Hardcoded personal path found in: %s${NC}\n" "$file" echo "$file_text" | grep -nE '(/Users/[^/\s]+/|/home/[^/\s]+/|C:\\Users\\[^\\]+\\)' | head -3 ERRORS=$((ERRORS + 1)) fi - # Check for Socket API keys. + # Socket API keys (except allowed public key and test placeholders). if echo "$file_text" | grep -E 'sktsec_[a-zA-Z0-9_-]+' | 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" echo "$file_text" | grep -n 'sktsec_' | 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. + # AWS keys. if echo "$file_text" | grep -iqE '(aws_access_key|aws_secret|AKIA[0-9A-Z]{16})'; then printf "${RED}✗ BLOCKED: Potential AWS credentials found in: %s${NC}\n" "$file" echo "$file_text" | grep -niE '(aws_access_key|aws_secret|AKIA[0-9A-Z]{16})' | head -3 ERRORS=$((ERRORS + 1)) fi - # Check for GitHub tokens. + # GitHub tokens. if echo "$file_text" | grep -qE 'gh[ps]_[a-zA-Z0-9]{36}'; then printf "${RED}✗ BLOCKED: Potential GitHub token found in: %s${NC}\n" "$file" echo "$file_text" | grep -nE 'gh[ps]_[a-zA-Z0-9]{36}' | head -3 ERRORS=$((ERRORS + 1)) fi - # Check for private keys. + # Private keys. if echo "$file_text" | grep -qE -- '-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----'; then printf "${RED}✗ BLOCKED: Private key found in: %s${NC}\n" "$file" ERRORS=$((ERRORS + 1)) diff --git a/package.json b/package.json index 632c1bd9..7de8c33c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "clean": "node scripts/clean.mjs", "claude": "node scripts/claude.mjs", "cover": "node scripts/cover.mjs", - "fix": "node scripts/lint.mjs --fix", + "fix": "node scripts/fix.mjs", "format": "oxfmt --write .", "format:check": "oxfmt --check .", "generate-actions-allow-list": "node scripts/ci/generate-actions-allow-list.mjs", diff --git a/scripts/fix.mjs b/scripts/fix.mjs new file mode 100644 index 00000000..98b01862 --- /dev/null +++ b/scripts/fix.mjs @@ -0,0 +1,77 @@ +/** + * @fileoverview Auto-fix script — runs linters with --fix, then security + * tools (zizmor, agentshield) if available. + * + * Steps: + * 1. pnpm run lint --fix — oxlint + oxfmt + * 2. zizmor --fix .github/ — GitHub Actions workflow fixes (if .github/ exists) + * 3. agentshield scan --fix — Claude config fixes (if .claude/ exists) + */ + +import { existsSync } from 'node:fs' +import process from 'node:process' + +import { getDefaultLogger } from '@socketsecurity/lib/logger' +import { spawn } from '@socketsecurity/lib/spawn' + +const WIN32 = process.platform === 'win32' +const logger = getDefaultLogger() + +async function run(cmd, args, { label, required = true } = {}) { + try { + const result = await spawn(cmd, args, { + shell: WIN32, + stdio: 'inherit', + }) + if (result.code !== 0 && required) { + logger.error(`${label || cmd} failed (exit ${result.code})`) + return result.code + } + if (result.code !== 0) { + // Non-blocking: log warning and continue. + logger.warn(`${label || cmd}: exited ${result.code} (non-blocking)`) + } + return 0 + } catch (e) { + if (!required) { + logger.warn(`${label || cmd}: ${e.message} (non-blocking)`) + return 0 + } + throw e + } +} + +async function main() { + // Step 1: Lint fix — delegates to per-package lint scripts. + const lintExit = await run( + 'pnpm', + ['run', 'lint', '--fix', ...process.argv.slice(2)], + { label: 'lint --fix' }, + ) + if (lintExit) { + process.exitCode = lintExit + } + + // Step 2: zizmor — fixes GitHub Actions workflow security issues. + // Only runs if .github/ directory exists (some repos don't have workflows). + if (existsSync('.github')) { + await run('zizmor', ['--fix', '.github/'], { + label: 'zizmor --fix', + required: false, + }) + } + + // Step 3: AgentShield — fixes Claude config security findings. + // Only runs if .claude/ exists and agentshield binary is installed. + if (existsSync('.claude') && existsSync('node_modules/.bin/agentshield')) { + await run('pnpm', ['exec', 'agentshield', 'scan', '--fix'], { + label: 'agentshield --fix', + required: false, + }) + } +} + +main().catch(e => { + logger.error(e) + process.exitCode = 1 +})