ktg-plugin-marketplace/plugins/llm-security/hooks/scripts/pre-edit-secrets.mjs
Kjell Tore Guttormsen 8ec320f40c feat(governance): add policy-as-code — .llm-security/policy.json for distributable hook configuration
New policy-loader.mjs reads .llm-security/policy.json with deep-merge against
defaults that exactly match existing hardcoded values. Integrated into all 7 hooks:
- pre-prompt-inject-scan: injection.mode (env var still takes precedence)
- post-session-guard: trifecta.mode, window_size, long_horizon_window
- pre-edit-secrets: secrets.additional_patterns
- pre-bash-destructive: destructive.additional_blocked
- pre-write-pathguard: pathguard.additional_protected
- pre-install-supply-chain: supply_chain.additional_blocked_packages
- post-mcp-verify: mcp.volume_threshold_bytes, mcp.trusted_servers

Backward compatible: no policy file = identical behavior to v5.1.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 13:37:02 +02:00

84 lines
3.9 KiB
JavaScript

#!/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';
// ---------------------------------------------------------------------------
// Secret detection patterns (union of global, kiur, llm-security, ms-ai-architect)
// ---------------------------------------------------------------------------
const SECRET_PATTERNS = [
{ name: 'AWS Access Key ID', pattern: /AKIA[0-9A-Z]{16}/ },
{ name: 'AWS Secret Access Key', pattern: /(?:aws_secret(?:_access)?_key|AWS_SECRET(?:_ACCESS)?_KEY)\s*[=:]\s*['"]?[0-9a-zA-Z/+=]{40}['"]?/i },
{ name: 'Azure Connection String (AccountKey/SharedAccessKey/sig)', pattern: /(?:AccountKey|SharedAccessKey|sig)=[A-Za-z0-9+/=]{20,}/ },
{ name: 'Azure AD ClientSecret', pattern: /(?:client[_-]?secret|ClientSecret)\s*[=:]\s*['"][^'"]{8,}['"]/i },
{ name: 'Azure AI Services Key', pattern: /Ocp-Apim-Subscription-Key\s*[=:]\s*['"]?[0-9a-f]{32}['"]?/i },
{ name: 'GitHub Token', pattern: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}/ },
{ name: 'npm Token', pattern: /npm_[A-Za-z0-9]{36}/ },
{ name: 'Private Key PEM Block', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/ },
{ name: 'JWT Secret', pattern: /JWT[_-]?SECRET\s*[=:]\s*['"][^'"]{8,}['"]/i },
{ name: 'Slack/Discord Webhook URL', pattern: /https:\/\/(?:hooks\.slack\.com\/services|discord(?:app)?\.com\/api\/webhooks)\// },
{ name: 'Generic credential assignment', pattern: /(?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*['"][^'"]{8,}['"]/i },
{ name: 'Authorization header with token', pattern: /[Bb]earer [A-Za-z0-9\-._~+/]{20,}/ },
{ name: 'Database connection string', pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^\s]+@[^\s]+/i },
// Policy-defined additional 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 <YOUR_KEY_HERE> or .env.\n`
);
process.exit(2);
}
}
process.exit(0);