#!/usr/bin/env node // Hook: pre-edit-secrets.mjs (consolidated) // Event: PreToolUse (Edit|Write) // Purpose: Detect secrets/credentials in file content before writing. // Consolidates patterns from global, kiur, llm-security, and ms-ai-architect. // // Protocol: // - Read JSON from stdin: { tool_name, tool_input } // - tool_input.file_path — destination path // - tool_input.content — full content (Write) // - tool_input.new_string — replacement text (Edit) // - Block: stderr + exit 2 // - Allow: exit 0 import { readFileSync } from 'node:fs'; import { normalize } from 'node:path'; import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs'; import { SECRET_PATTERNS as COMMONS_SECRET_PATTERNS } from '../../scanners/lib/secret-egress.mjs'; // --------------------------------------------------------------------------- // Secret detection patterns // --------------------------------------------------------------------------- // // The 19 fixed entries (union of global, kiur, llm-security, ms-ai-architect) // were regex literals here until the v8 Phase 5 swap; they now come from // `signatures/secret-egress.json` in the vendored commons, via // scanners/lib/secret-egress.mjs — measured position-by-position before the // swap (order, name, source, flags), zero divergences. Order is load-bearing: // first match wins, and the entry a finding is reported AS depends on it. // See that module's header for what happens when commons is unresolvable. // // Policy-defined patterns are appended after, unchanged: they are the scanned // project's own policy, not commons data, and must never displace the fixed // table's labels. const SECRET_PATTERNS = [ ...COMMONS_SECRET_PATTERNS, ...getPolicyValue('secrets', 'additional_patterns', []).map((p, i) => ({ name: `Custom pattern ${i + 1}`, pattern: new RegExp(p), })), ]; // --------------------------------------------------------------------------- // Exclusions: files that may contain example patterns for documentation // --------------------------------------------------------------------------- function isExcluded(filePath) { if (!filePath) return false; const n = normalize(filePath); if (/[\\/]knowledge[\\/].+\.md$/i.test(n)) return true; if (/[\\/]references[\\/].+\.md$/i.test(n)) return true; if (/\.(test|spec|mock)\.[jt]sx?$/.test(n)) return true; if (/\.(example|template|sample)(\.|$)/.test(n)) return true; return false; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- let input; try { const raw = readFileSync(0, 'utf-8'); input = JSON.parse(raw); } catch { process.exit(0); } const toolInput = input?.tool_input ?? {}; const filePath = toolInput.file_path ?? ''; if (isExcluded(filePath)) process.exit(0); const contentToCheck = [toolInput.content ?? '', toolInput.new_string ?? ''].join('\n'); if (!contentToCheck.trim()) process.exit(0); for (const { name, pattern } of SECRET_PATTERNS) { if (pattern.test(contentToCheck)) { process.stderr.write( `BLOCKED: Potential secret detected — ${name}\n` + ` File: ${filePath || '(unknown)'}\n` + ` Remove the credential before writing. Use or .env.\n` ); process.exit(2); } } process.exit(0);