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>
This commit is contained in:
parent
0439e0f650
commit
8ec320f40c
9 changed files with 300 additions and 13 deletions
|
|
@ -20,6 +20,7 @@ import { join } from 'node:path';
|
|||
import { tmpdir } from 'node:os';
|
||||
import { scanForInjection } from '../../scanners/lib/injection-patterns.mjs';
|
||||
import { checkDescriptionDrift } from '../../scanners/lib/mcp-description-cache.mjs';
|
||||
import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secret patterns — same set as pre-edit-secrets.mjs so any secret that
|
||||
|
|
@ -73,7 +74,7 @@ const MIN_INJECTION_SCAN_LENGTH = 100;
|
|||
// a session. Warns when a single tool produces disproportionate output.
|
||||
// State file: ${os.tmpdir()}/llm-security-mcp-volume-${ppid}.json
|
||||
// ---------------------------------------------------------------------------
|
||||
const MCP_TOOL_VOLUME_THRESHOLD = 100_000; // 100 KB from a single MCP tool
|
||||
const MCP_TOOL_VOLUME_THRESHOLD = getPolicyValue('mcp', 'volume_threshold_bytes', 100_000);
|
||||
const VOLUME_STATE_FILE = join(tmpdir(), `llm-security-mcp-volume-${process.ppid}.json`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -199,6 +200,11 @@ if (!outputText.trim()) {
|
|||
const advisories = [];
|
||||
const isBash = toolName === 'Bash';
|
||||
|
||||
// Policy: trusted MCP servers are exempt from volume tracking and drift checks
|
||||
const trustedServers = new Set(getPolicyValue('mcp', 'trusted_servers', []));
|
||||
const mcpServerName = toolName.includes('mcp__') ? toolName.split('__')[1] : null;
|
||||
const isTrustedMcp = mcpServerName && trustedServers.has(mcpServerName);
|
||||
|
||||
// =========================================================================
|
||||
// Bash-specific checks: secrets, external URLs, large MCP output
|
||||
// These checks are only relevant for shell command output.
|
||||
|
|
@ -322,7 +328,7 @@ if (isHtmlSource && outputText.length >= MIN_INJECTION_SCAN_LENGTH) {
|
|||
// Only relevant for MCP tools that provide a description in tool_input.
|
||||
// =========================================================================
|
||||
const isMcpTool = toolName?.startsWith('mcp__');
|
||||
if (isMcpTool) {
|
||||
if (isMcpTool && !isTrustedMcp) {
|
||||
const description = toolInput?.description || toolInput?.tool_description || '';
|
||||
if (description && typeof description === 'string' && description.length > 10) {
|
||||
try {
|
||||
|
|
@ -345,7 +351,7 @@ if (isMcpTool) {
|
|||
// Tracks cumulative output size per MCP tool within a session. Warns when
|
||||
// a single tool produces disproportionate output (>100 KB cumulative).
|
||||
// =========================================================================
|
||||
if (isMcpTool && outputText.length > 0) {
|
||||
if (isMcpTool && !isTrustedMcp && outputText.length > 0) {
|
||||
const volState = loadVolumeState();
|
||||
volState.volumes[toolName] = (volState.volumes[toolName] || 0) + outputText.length;
|
||||
const toolTotal = volState.volumes[toolName];
|
||||
|
|
|
|||
|
|
@ -43,18 +43,19 @@ import { createHash } from 'node:crypto';
|
|||
import { extractMcpServer } from '../../scanners/lib/mcp-description-cache.mjs';
|
||||
import { jensenShannonDivergence, buildDistribution } from '../../scanners/lib/distribution-stats.mjs';
|
||||
import { writeAuditEvent } from '../../scanners/lib/audit-trail.mjs';
|
||||
import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WINDOW_SIZE = 20;
|
||||
const WINDOW_SIZE = getPolicyValue('trifecta', 'window_size', 20);
|
||||
const STATE_PREFIX = 'llm-security-session-';
|
||||
const STATE_DIR = tmpdir();
|
||||
const CLEANUP_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
// Long-horizon monitoring (OpenAI Atlas, Dec 2025)
|
||||
const LONG_HORIZON_WINDOW = 100;
|
||||
const LONG_HORIZON_WINDOW = getPolicyValue('trifecta', 'long_horizon_window', 100);
|
||||
const SLOW_BURN_MIN_SPREAD = 50;
|
||||
const DRIFT_THRESHOLD = 0.25;
|
||||
const DRIFT_SAMPLE_SIZE = 20;
|
||||
|
|
@ -62,8 +63,9 @@ const DRIFT_SAMPLE_SIZE = 20;
|
|||
// Sub-agent delegation tracking (DeepMind Agent Traps kat. 4, v5.0 S4)
|
||||
const DELEGATION_ESCALATION_WINDOW = 5; // calls after input_source
|
||||
|
||||
// Rule of Two enforcement mode: block | warn | off (default: warn)
|
||||
const TRIFECTA_MODE = (process.env.LLM_SECURITY_TRIFECTA_MODE || 'warn').toLowerCase();
|
||||
// Rule of Two enforcement mode: block | warn | off (env var takes precedence over policy)
|
||||
const policyTrifectaMode = getPolicyValue('trifecta', 'mode', 'warn');
|
||||
const TRIFECTA_MODE = (process.env.LLM_SECURITY_TRIFECTA_MODE || policyTrifectaMode).toLowerCase();
|
||||
|
||||
// Volume tracking thresholds (cumulative bytes per session)
|
||||
const VOLUME_THRESHOLDS = [
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { normalizeBashExpansion } from '../../scanners/lib/bash-normalize.mjs';
|
||||
import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BLOCK rules — exit 2, command is not executed.
|
||||
|
|
@ -78,6 +79,12 @@ const BLOCK_RULES = [
|
|||
'strings, which is a common code injection vector. Blocked. ' +
|
||||
'Refactor to use explicit commands instead.',
|
||||
},
|
||||
// Policy-defined additional blocked patterns
|
||||
...getPolicyValue('destructive', 'additional_blocked', []).map(entry => ({
|
||||
name: entry.name || 'Custom blocked pattern',
|
||||
pattern: new RegExp(entry.pattern),
|
||||
description: entry.description || 'Blocked by policy.',
|
||||
})),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
|
||||
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)
|
||||
|
|
@ -32,6 +33,11 @@ const SECRET_PATTERNS = [
|
|||
{ 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),
|
||||
})),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ import {
|
|||
queryOSV, extractOSVSeverity,
|
||||
} from '../../scanners/lib/supply-chain-data.mjs';
|
||||
import { normalizeBashExpansion } from '../../scanners/lib/bash-normalize.mjs';
|
||||
import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
|
||||
|
||||
// Policy-defined additional blocked packages (merged with built-in lists)
|
||||
const POLICY_BLOCKED = new Set(getPolicyValue('supply_chain', 'additional_blocked_packages', []));
|
||||
|
||||
// ===========================================================================
|
||||
// Read stdin
|
||||
|
|
@ -119,10 +123,10 @@ async function checkNpm() {
|
|||
for (const spec of packages) {
|
||||
const { name, version } = parseSpec(spec);
|
||||
|
||||
if (isCompromised(NPM_COMPROMISED, name, version)) {
|
||||
if (isCompromised(NPM_COMPROMISED, name, version) || POLICY_BLOCKED.has(name)) {
|
||||
blocks.push(
|
||||
`COMPROMISED: ${name}${version ? '@' + version : ''}\n` +
|
||||
` Known supply chain attack. See: https://socket.dev/npm/package/${name}`
|
||||
` ${POLICY_BLOCKED.has(name) ? 'Blocked by policy.' : 'Known supply chain attack.'} See: https://socket.dev/npm/package/${name}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -325,10 +329,10 @@ async function checkPip() {
|
|||
for (const spec of packages) {
|
||||
const { name, version } = parsePipSpec(spec);
|
||||
|
||||
if (isCompromised(PIP_COMPROMISED, name, version)) {
|
||||
if (isCompromised(PIP_COMPROMISED, name, version) || POLICY_BLOCKED.has(name)) {
|
||||
blocks.push(
|
||||
`COMPROMISED: ${name} (PyPI)\n` +
|
||||
` Known malicious package (likely typosquat).\n` +
|
||||
` ${POLICY_BLOCKED.has(name) ? 'Blocked by policy.' : 'Known malicious package (likely typosquat).'}\n` +
|
||||
` See: https://pypi.org/project/${name}/`
|
||||
);
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -21,14 +21,16 @@
|
|||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { scanForInjection } from '../../scanners/lib/injection-patterns.mjs';
|
||||
import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode configuration
|
||||
// Mode configuration (env var takes precedence over policy file)
|
||||
// ---------------------------------------------------------------------------
|
||||
const VALID_MODES = new Set(['block', 'warn', 'off']);
|
||||
const policyMode = getPolicyValue('injection', 'mode', 'block');
|
||||
const mode = VALID_MODES.has(process.env.LLM_SECURITY_INJECTION_MODE)
|
||||
? process.env.LLM_SECURITY_INJECTION_MODE
|
||||
: 'block';
|
||||
: VALID_MODES.has(policyMode) ? policyMode : 'block';
|
||||
|
||||
// Off mode: skip scanning entirely
|
||||
if (mode === 'off') {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { basename, normalize, resolve } from 'node:path';
|
||||
import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sensitive path patterns — 8 categories
|
||||
|
|
@ -68,6 +69,9 @@ const SETTINGS_FILES = [
|
|||
'settings.local.json',
|
||||
];
|
||||
|
||||
/** Category 9: Policy-defined additional protected paths */
|
||||
const POLICY_PATTERNS = getPolicyValue('pathguard', 'additional_protected', []).map(p => new RegExp(p));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -142,6 +146,13 @@ function classifyPath(filePath) {
|
|||
}
|
||||
}
|
||||
|
||||
// Category 9: Policy-defined additional protected paths
|
||||
for (const pat of POLICY_PATTERNS) {
|
||||
if (pat.test(norm)) {
|
||||
return { blocked: true, category: 'policy', reason: `Policy-protected path: ${norm}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { blocked: false, category: '', reason: '' };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue