diff --git a/.git-hooks/_helpers.mts b/.git-hooks/_helpers.mts new file mode 100644 index 000000000..fde8c3fa5 --- /dev/null +++ b/.git-hooks/_helpers.mts @@ -0,0 +1,304 @@ +// Shared helpers for git hooks — API-key allowlist + ANSI colors + +// content scanners. Imported by .git-hooks/{commit-msg,pre-commit, +// pre-push}.mts. No third-party deps; uses only Node built-ins. +// +// Requires Node 25+ for stable .mts type-stripping (no flag needed). +// Earlier Node versions either lacked --experimental-strip-types or +// shipped it under a flag, both unacceptable for hook ergonomics. + +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync, statSync } from 'node:fs' + +// Hard-fail if Node is below 25. This runs at module load — every +// hook invocation imports _helpers.mts before doing anything, so the +// version check is the first thing that happens. +const NODE_MIN_MAJOR = 25 +const nodeMajor = Number.parseInt( + process.versions.node.split('.')[0] || '0', + 10, +) +if (nodeMajor < NODE_MIN_MAJOR) { + process.stderr.write( + `\x1b[0;31m✗ Hook requires Node >= ${NODE_MIN_MAJOR}.0.0 (have v${process.versions.node})\x1b[0m\n`, + ) + process.stderr.write( + 'Install Node 25+ — these hooks rely on stable .mts type stripping.\n', + ) + process.exit(1) +} + +// ── Allowlist constants ──────────────────────────────────────────── +// These exempt known-safe matches from the API-key scanner. Each +// allowlist entry is a substring; if the matched line contains it, +// the line is dropped from the findings. + +// Real public API key shipped in socket-lib test fixtures. Safe to +// appear anywhere in the fleet. +export const ALLOWED_PUBLIC_KEY = + 'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api' + +// Substring marker used in test fixtures (see +// socket-lib/test/unit/utils/fake-tokens.ts). Lines containing this +// are treated as test fixtures. +export const FAKE_TOKEN_MARKER = 'socket-test-fake-token' + +// Legacy lib-scoped marker — accepted during the rename from +// `socket-lib-test-fake-token` to `socket-test-fake-token`. Drop when +// lib's rename PR lands. +export const FAKE_TOKEN_LEGACY = 'socket-lib-test-fake-token' + +// Name of the env var used in shell examples; not a token value. +export const SOCKET_SECURITY_ENV = 'SOCKET_SECURITY_API_KEY=' + +// ── ANSI colors ──────────────────────────────────────────────────── + +export const RED = '\x1b[0;31m' +export const GREEN = '\x1b[0;32m' +export const YELLOW = '\x1b[1;33m' +export const NC = '\x1b[0m' + +// ── Output helpers ───────────────────────────────────────────────── + +export const out = (msg: string): void => { + process.stdout.write(msg + '\n') +} + +export const err = (msg: string): void => { + process.stderr.write(msg + '\n') +} + +export const red = (msg: string): string => `${RED}${msg}${NC}` +export const green = (msg: string): string => `${GREEN}${msg}${NC}` +export const yellow = (msg: string): string => `${YELLOW}${msg}${NC}` + +// ── API-key allowlist filter ─────────────────────────────────────── + +// Drops any line that matches an allowlist entry. +export const filterAllowedApiKeys = (lines: readonly string[]): string[] => { + return lines.filter( + line => + !line.includes(ALLOWED_PUBLIC_KEY) && + !line.includes(FAKE_TOKEN_MARKER) && + !line.includes(FAKE_TOKEN_LEGACY) && + !line.includes(SOCKET_SECURITY_ENV) && + !line.includes('.example'), + ) +} + +// ── Personal-path scanner ────────────────────────────────────────── + +// Real personal paths to flag: /Users/foo/, /home/foo/, C:\Users\foo\. +const PERSONAL_PATH_RE = + /(\/Users\/[^/\s]+\/|\/home\/[^/\s]+\/|C:\\Users\\[^\\]+\\)/ + +// Placeholders we ALLOW (documentation, not real leaks): any path +// component wrapped in <...> or starting with $VAR / ${VAR}. +const PERSONAL_PATH_PLACEHOLDER_RE = + /(\/Users\/<[^>]*>\/|\/home\/<[^>]*>\/|C:\\Users\\<[^>]*>\\|\/Users\/\$\{?[A-Z_]+\}?\/|\/home\/\$\{?[A-Z_]+\}?\/)/ + +export type LineHit = { lineNumber: number; line: string } + +// Returns lines that contain a real personal path (excludes lines +// that are pure placeholders). Caller decides what to do with hits. +export const scanPersonalPaths = (text: string): LineHit[] => { + const hits: LineHit[] = [] + const lines = text.split('\n') + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + if (!PERSONAL_PATH_RE.test(line)) { + continue + } + if (PERSONAL_PATH_PLACEHOLDER_RE.test(line)) { + // Has placeholder — but might also have a real path on the + // same line. Strip placeholder forms and re-test. + const stripped = line.replace( + new RegExp(PERSONAL_PATH_PLACEHOLDER_RE, 'g'), + '', + ) + if (!PERSONAL_PATH_RE.test(stripped)) { + continue + } + } + hits.push({ lineNumber: i + 1, line }) + } + return hits +} + +// ── Secret scanners ──────────────────────────────────────────────── + +const SOCKET_API_KEY_RE = /sktsec_[a-zA-Z0-9_-]+/ +const AWS_KEY_RE = /(aws_access_key|aws_secret|\bAKIA[0-9A-Z]{16}\b)/i +const GITHUB_TOKEN_RE = /gh[ps]_[a-zA-Z0-9]{36}/ +const PRIVATE_KEY_RE = /-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----/ + +export const scanSocketApiKeys = (text: string): LineHit[] => { + const hits: LineHit[] = [] + const lines = text.split('\n') + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + if (SOCKET_API_KEY_RE.test(line)) { + hits.push({ lineNumber: i + 1, line }) + } + } + // Filter the LineHit objects directly so duplicate-content lines + // at different line numbers keep their correct numbers. + const allowedSet = new Set(filterAllowedApiKeys(hits.map(h => h.line))) + return hits.filter(h => allowedSet.has(h.line)) +} + +export const scanAwsKeys = (text: string): LineHit[] => { + const hits: LineHit[] = [] + const lines = text.split('\n') + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + if (AWS_KEY_RE.test(line)) { + hits.push({ lineNumber: i + 1, line }) + } + } + return hits +} + +export const scanGitHubTokens = (text: string): LineHit[] => { + const hits: LineHit[] = [] + const lines = text.split('\n') + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + if (GITHUB_TOKEN_RE.test(line)) { + hits.push({ lineNumber: i + 1, line }) + } + } + return hits +} + +export const scanPrivateKeys = (text: string): LineHit[] => { + const hits: LineHit[] = [] + const lines = text.split('\n') + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + if (PRIVATE_KEY_RE.test(line)) { + hits.push({ lineNumber: i + 1, line }) + } + } + return hits +} + +// ── npx/dlx scanner ──────────────────────────────────────────────── + +const NPX_DLX_RE = /\b(npx|pnpm dlx|yarn dlx)\b/ + +export const scanNpxDlx = (text: string): LineHit[] => { + const hits: LineHit[] = [] + const lines = text.split('\n') + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + if (NPX_DLX_RE.test(line) && !line.includes('# zizmor:')) { + hits.push({ lineNumber: i + 1, line }) + } + } + return hits +} + +// ── Linear issue reference scanner ───────────────────────────────── +// CLAUDE.md "ABSOLUTE RULES": NEVER reference Linear issues in commits. +// Team keys enumerated from the Socket workspace. PATCH listed before +// PAT so the alternation matches the longer prefix first. + +const LINEAR_TEAM_KEYS = + 'ASK|AUTO|BOT|CE|CORE|DAT|DES|DEV|ENG|INFRA|LAB|MAR|MET|OPS|PAR|PATCH|PAT|PLAT|REA|SALES|SBOM|SEC|SMO|SUP|TES|TI|WEB' + +const LINEAR_ISSUE_RE = new RegExp( + `(?:^|[^A-Za-z0-9_])((?:${LINEAR_TEAM_KEYS})-[0-9]+)(?:$|[^A-Za-z0-9_])`, + 'gm', +) + +const LINEAR_URL_RE = /linear\.app\/[A-Za-z0-9/_-]+/g + +export const scanLinearReferences = (commitMsg: string): string[] => { + const hits: string[] = [] + const lines = commitMsg.split('\n').filter(l => !l.startsWith('#')) + const body = lines.join('\n') + for (const m of body.matchAll(LINEAR_ISSUE_RE)) { + hits.push(m[1]!) + } + for (const m of body.matchAll(LINEAR_URL_RE)) { + hits.push(m[0]!) + } + return hits.slice(0, 5) +} + +// ── AI attribution scanner ───────────────────────────────────────── + +const AI_ATTRIBUTION_RE = + /(Generated with.*(Claude|AI)|Co-Authored-By: Claude|Co-Authored-By: AI|🤖 Generated|AI generated|@anthropic\.com|Assistant:|Generated by Claude|Machine generated|Claude Code)/i + +export const containsAiAttribution = (text: string): boolean => + AI_ATTRIBUTION_RE.test(text) + +export const stripAiAttribution = ( + text: string, +): { cleaned: string; removed: number } => { + const lines = text.split('\n') + const kept: string[] = [] + let removed = 0 + for (const line of lines) { + if (AI_ATTRIBUTION_RE.test(line)) { + removed++ + } else { + kept.push(line) + } + } + return { cleaned: kept.join('\n'), removed } +} + +// ── File classification ──────────────────────────────────────────── + +// Files we never scan: hooks themselves, husky shims, test fixtures. +const SKIP_FILE_RE = + /\.(test|spec)\.(m?[jt]s|tsx?|cts|mts)$|\.example$|\/test\/|\/tests\/|fixtures\/|\.git-hooks\/|\.husky\/|node_modules\/|pnpm-lock\.yaml/ + +export const shouldSkipFile = (filePath: string): boolean => + SKIP_FILE_RE.test(filePath) + +// Returns file content as a string. For binaries, runs `strings` to +// extract printable byte sequences (catches paths embedded in WASM +// or other compiled artifacts). +export const readFileForScan = (filePath: string): string => { + if (!existsSync(filePath)) { + return '' + } + try { + if (statSync(filePath).isDirectory()) { + return '' + } + } catch { + return '' + } + // Detect binary via grep -I (matches text-only); if grep says + // binary, fall back to `strings`. + const grepResult = spawnSync('grep', ['-qI', '', filePath]) + if (grepResult.status === 0) { + // Text file. + try { + return readFileSync(filePath, 'utf8') + } catch { + return '' + } + } + // Binary — extract strings. + const stringsResult = spawnSync('strings', [filePath], { + encoding: 'utf8', + }) + return stringsResult.stdout || '' +} + +// ── Git wrappers ─────────────────────────────────────────────────── + +export const git = (...args: string[]): string => { + const result = spawnSync('git', args, { encoding: 'utf8' }) + return result.stdout.trim() +} + +export const gitLines = (...args: string[]): string[] => { + const out = git(...args) + return out ? out.split('\n') : [] +} diff --git a/.git-hooks/_helpers.sh b/.git-hooks/_helpers.sh deleted file mode 100644 index 15e9a4083..000000000 --- a/.git-hooks/_helpers.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/bash -# Shared helpers for git hooks. -# Sourced by .git-hooks/commit-msg, pre-commit, pre-push. -# -# Constants -# --------- -# ALLOWED_PUBLIC_KEY Real public API key shipped in socket-lib test -# fixtures. Safe to appear in commits anywhere. -# FAKE_TOKEN_MARKER Substring marker used in fleet test fixtures. -# FAKE_TOKEN_LEGACY Legacy lib-scoped marker — accepted during the -# rename from `socket-lib-test-fake-token` to -# `socket-test-fake-token`. Drop when socket-lib's -# fixture rename PR lands. -# SOCKET_SECURITY_ENV Env var name used in shell examples; not a token. -# -# Functions -# --------- -# filter_allowed_api_keys Reads stdin, drops allowlist matches (public -# key, fake-token markers, env var name, -# `.example` paths), prints the rest. -# -# Colors -# ------ -# RED, GREEN, YELLOW, NC - -# shellcheck disable=SC2034 # constants sourced by other hooks -ALLOWED_PUBLIC_KEY="sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api" -FAKE_TOKEN_MARKER="socket-test-fake-token" -FAKE_TOKEN_LEGACY="socket-lib-test-fake-token" -SOCKET_SECURITY_ENV="SOCKET_SECURITY_API_KEY=" - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -filter_allowed_api_keys() { - grep -v "$ALLOWED_PUBLIC_KEY" \ - | grep -v "$FAKE_TOKEN_MARKER" \ - | grep -v "$FAKE_TOKEN_LEGACY" \ - | grep -v "$SOCKET_SECURITY_ENV" \ - | grep -v '\.example' -} diff --git a/.git-hooks/commit-msg b/.git-hooks/commit-msg deleted file mode 100755 index 7acf4c56b..000000000 --- a/.git-hooks/commit-msg +++ /dev/null @@ -1,90 +0,0 @@ -#!/bin/bash -# Socket Security Commit-msg Hook -# Additional security layer - validates commit even if pre-commit was bypassed. - -set -e - -# shellcheck source=./_helpers.sh -. "$(dirname "$0")/_helpers.sh" - -ERRORS=0 - -# Get files in this commit (for security checks). -COMMITTED_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || printf "\n") - -# Quick checks for critical issues in committed files. -if [ -n "$COMMITTED_FILES" ]; then - for file in $COMMITTED_FILES; do - if [ -f "$file" ]; then - # Check for Socket API keys (except allowed). - if grep -E 'sktsec_[a-zA-Z0-9_-]+' "$file" 2>/dev/null | filter_allowed_api_keys | grep -q .; then - printf "${RED}✗ SECURITY: Potential API key detected in commit!${NC}\n" - printf "File: %s\n" "$file" - ERRORS=$((ERRORS + 1)) - fi - - # Check for .env files. - if echo "$file" | grep -qE '^\.env(\.[^/]+)?$' && ! echo "$file" | grep -qE '^\.env\.(example|test)$'; then - printf "${RED}✗ SECURITY: .env file in commit!${NC}\n" - ERRORS=$((ERRORS + 1)) - fi - fi - done -fi - -# Block Linear issue references in the commit message. -# Linear tracking lives in Linear; keep commit history tool-agnostic. -# Team keys enumerated from the Socket workspace. PATCH listed before PAT so -# the engine matches the longer prefix first on strings like "PATCH-123". -COMMIT_MSG_FILE="$1" -LINEAR_TEAM_KEYS='ASK|AUTO|BOT|CE|CORE|DAT|DES|DEV|ENG|INFRA|LAB|MAR|MET|OPS|PAR|PATCH|PAT|PLAT|REA|SALES|SBOM|SEC|SMO|SUP|TES|TI|WEB' -if [ -f "$COMMIT_MSG_FILE" ]; then - LINEAR_HITS=$(grep -vE '^#' "$COMMIT_MSG_FILE" 2>/dev/null \ - | grep -oE "(^|[^A-Za-z0-9_])($LINEAR_TEAM_KEYS)-[0-9]+($|[^A-Za-z0-9_])|linear\.app/[A-Za-z0-9/_-]+" \ - | head -5 || true) - if [ -n "$LINEAR_HITS" ]; then - printf "${RED}✗ Commit message references Linear issue(s):${NC}\n" - printf '%s\n' "$LINEAR_HITS" | sed 's/^/ /' - printf "${RED}Linear tracking lives in Linear. Remove the reference from the commit message.${NC}\n" - ERRORS=$((ERRORS + 1)) - fi -fi - -# Auto-strip AI attribution from commit message. -if [ -f "$COMMIT_MSG_FILE" ]; then - # Create a temporary file to store the cleaned message. - TEMP_FILE=$(mktemp) || { - printf "${RED}✗ Failed to create temporary file${NC}\n" >&2 - exit 1 - } - # Ensure cleanup on exit - trap 'rm -f "$TEMP_FILE"' EXIT - REMOVED_LINES=0 - - # Read the commit message line by line and filter out AI attribution. - while IFS= read -r line || [ -n "$line" ]; do - # Check if this line contains AI attribution patterns. - if echo "$line" | grep -qiE "(Generated with|Co-Authored-By: Claude|Co-Authored-By: AI|🤖 Generated|AI generated|Claude Code|@anthropic|Assistant:|Generated by Claude|Machine generated)"; then - REMOVED_LINES=$((REMOVED_LINES + 1)) - else - # Line doesn't contain AI attribution, keep it. - printf '%s\n' "$line" >> "$TEMP_FILE" - fi - done < "$COMMIT_MSG_FILE" - - # Replace the original commit message with the cleaned version. - if [ $REMOVED_LINES -gt 0 ]; then - mv "$TEMP_FILE" "$COMMIT_MSG_FILE" - printf "${GREEN}✓ Auto-stripped${NC} $REMOVED_LINES AI attribution line(s) from commit message\n" - else - # No lines were removed, just clean up the temp file. - rm -f "$TEMP_FILE" - fi -fi - -if [ $ERRORS -gt 0 ]; then - printf "${RED}✗ Commit blocked by security validation${NC}\n" - exit 1 -fi - -exit 0 diff --git a/.git-hooks/commit-msg.mts b/.git-hooks/commit-msg.mts new file mode 100644 index 000000000..e080c6d34 --- /dev/null +++ b/.git-hooks/commit-msg.mts @@ -0,0 +1,111 @@ +#!/usr/bin/env node +// Socket Security Commit-msg Hook +// +// Three responsibilities: +// 1. Block commits that introduce API keys / .env files (security +// layer that runs even when pre-commit is bypassed via +// `--no-verify`). +// 2. Block commits whose message references Linear issues — Socket +// keeps Linear tracking out of git history per CLAUDE.md. +// 3. Auto-strip AI attribution lines from the commit message before +// git records the commit. +// +// Wired via .husky/commit-msg, which invokes this with the path to the +// commit message file as argv[2] (after the script path itself). + +import { existsSync, readFileSync, writeFileSync } from 'node:fs' + +import { basename } from 'node:path' +import process from 'node:process' + +import { + err, + gitLines, + green, + out, + red, + readFileForScan, + scanLinearReferences, + scanSocketApiKeys, + shouldSkipFile, + stripAiAttribution, +} from './_helpers.mts' + +const main = (): number => { + let errors = 0 + const committedFiles = gitLines( + 'diff', + '--cached', + '--name-only', + '--diff-filter=ACM', + ) + + for (const file of committedFiles) { + if (!file || shouldSkipFile(file)) { + continue + } + const text = readFileForScan(file) + if (!text) { + continue + } + + // Socket API keys (allowlist-aware). + const apiHits = scanSocketApiKeys(text) + if (apiHits.length > 0) { + out(red('✗ SECURITY: Potential API key detected in commit!')) + out(`File: ${file}`) + errors++ + } + + // .env files at any depth — allow only .env.example, .env.test, + // .env.precommit (templates / tracked placeholders). + const base = basename(file) + if ( + /^\.env(\.[^/]+)?$/.test(base) && + !/^\.env\.(example|test|precommit)$/.test(base) + ) { + out(red('✗ SECURITY: .env file in commit!')) + out(`File: ${file}`) + errors++ + } + } + + const commitMsgFile = process.argv[2] + if (commitMsgFile && existsSync(commitMsgFile)) { + const original = readFileSync(commitMsgFile, 'utf8') + + // Block Linear issue references in the commit message. Socket + // keeps Linear tracking out of git history; commit messages stay + // tool-agnostic. + const linearHits = scanLinearReferences(original) + if (linearHits.length > 0) { + out(red('✗ Commit message references Linear issue(s):')) + for (const hit of linearHits) { + out(` ${hit}`) + } + out( + red( + 'Linear tracking lives in Linear. Remove the reference from the commit message.', + ), + ) + errors++ + } + + // Auto-strip AI attribution lines from the commit message. + const { cleaned, removed } = stripAiAttribution(original) + if (removed > 0) { + writeFileSync(commitMsgFile, cleaned) + out( + `${green('✓ Auto-stripped')} ${removed} AI attribution line(s) from commit message`, + ) + } + } + + if (errors > 0) { + err(red('✗ Commit blocked by security validation')) + return 1 + } + return 0 +} + +process.exit(main()) diff --git a/.git-hooks/pre-commit.mts b/.git-hooks/pre-commit.mts new file mode 100644 index 000000000..aa3898678 --- /dev/null +++ b/.git-hooks/pre-commit.mts @@ -0,0 +1,186 @@ +#!/usr/bin/env node +// Socket Security Pre-commit Hook +// +// Local-defense layer: scans staged files for sensitive content +// before git records the commit. Mandatory enforcement re-runs in +// pre-push for the final gate. +// +// Bypassable: --no-verify skips this hook entirely. Use sparingly +// (hotfixes, history operations, pre-build states). + +import process from 'node:process' + +import { + err, + gitLines, + green, + out, + red, + readFileForScan, + scanAwsKeys, + scanGitHubTokens, + scanNpxDlx, + scanPersonalPaths, + scanPrivateKeys, + scanSocketApiKeys, + shouldSkipFile, + yellow, +} from './_helpers.mts' + +const main = (): number => { + out(green('Running Socket Security checks...')) + const stagedFiles = gitLines( + 'diff', + '--cached', + '--name-only', + '--diff-filter=ACM', + ) + if (stagedFiles.length === 0) { + out(green('✓ No files to check')) + return 0 + } + + let errors = 0 + + // .DS_Store files. + out('Checking for .DS_Store files...') + const dsStores = stagedFiles.filter(f => f.includes('.DS_Store')) + if (dsStores.length > 0) { + out(red('✗ ERROR: .DS_Store file detected!')) + dsStores.forEach(f => out(f)) + errors++ + } + + // Log files (ignore test logs). + out('Checking for log files...') + const logs = stagedFiles.filter( + f => f.endsWith('.log') && !/test.*\.log$/.test(f), + ) + if (logs.length > 0) { + out(red('✗ ERROR: Log file detected!')) + logs.forEach(f => out(f)) + errors++ + } + + // .env files (allowlist .env.example / .env.test / .env.precommit). + // Match commit-msg.mts allowlist — .env.precommit is a tracked file + // some repos use to disable test API tokens during pre-commit runs. + out('Checking for .env files...') + const envFiles = stagedFiles.filter( + f => + /^\.env(\.[^/]+)?$/.test(f) && + !/^\.env\.(example|test|precommit)$/.test(f), + ) + if (envFiles.length > 0) { + out(red('✗ ERROR: .env file detected!')) + envFiles.forEach(f => out(f)) + out( + 'These files should never be committed. Use .env.example for templates.', + ) + errors++ + } + + // Hardcoded personal paths. + out('Checking for hardcoded personal paths...') + for (const file of stagedFiles) { + if (shouldSkipFile(file)) { + continue + } + const text = readFileForScan(file) + if (!text) { + continue + } + const hits = scanPersonalPaths(text) + if (hits.length > 0) { + out(red(`✗ ERROR: Hardcoded personal path found in: ${file}`)) + hits.slice(0, 3).forEach(h => out(`${h.lineNumber}:${h.line.trim()}`)) + out('Replace with relative paths or environment variables.') + errors++ + } + } + + // Socket API keys (warning, not blocking). + out('Checking for API keys...') + for (const file of stagedFiles) { + if (shouldSkipFile(file)) { + continue + } + const text = readFileForScan(file) + if (!text) { + continue + } + const hits = scanSocketApiKeys(text) + if (hits.length > 0) { + out(yellow(`⚠ WARNING: Potential API key found in: ${file}`)) + hits.slice(0, 3).forEach(h => out(`${h.lineNumber}:${h.line.trim()}`)) + out('If this is a real API key, DO NOT COMMIT IT.') + } + } + + // Other secret patterns (AWS, GitHub, private keys). + out('Checking for potential secrets...') + for (const file of stagedFiles) { + if (shouldSkipFile(file)) { + continue + } + const text = readFileForScan(file) + if (!text) { + continue + } + + const aws = scanAwsKeys(text) + if (aws.length > 0) { + out(red(`✗ ERROR: Potential AWS credentials found in: ${file}`)) + aws.slice(0, 3).forEach(h => out(`${h.lineNumber}:${h.line.trim()}`)) + errors++ + } + + const gh = scanGitHubTokens(text) + if (gh.length > 0) { + out(red(`✗ ERROR: Potential GitHub token found in: ${file}`)) + gh.slice(0, 3).forEach(h => out(`${h.lineNumber}:${h.line.trim()}`)) + errors++ + } + + const pk = scanPrivateKeys(text) + if (pk.length > 0) { + out(red(`✗ ERROR: Private key found in: ${file}`)) + errors++ + } + } + + // npx/dlx usage. + out('Checking for npx/dlx usage...') + for (const file of stagedFiles) { + if ( + file.includes('node_modules/') || + file.endsWith('pnpm-lock.yaml') || + file.includes('.git-hooks/') + ) { + continue + } + const text = readFileForScan(file) + if (!text) { + continue + } + const hits = scanNpxDlx(text) + if (hits.length > 0) { + out(red(`✗ ERROR: npx/dlx usage found in: ${file}`)) + hits.slice(0, 3).forEach(h => out(`${h.lineNumber}:${h.line.trim()}`)) + out("Use 'pnpm exec ' or 'pnpm run