fix(llm-security): hook coverage — pathguard on Edit, trifecta window, pipe-to-shell, provider keys (#9,#10,#12,#13,#11-doc)

#9 pathguard registered matcher Write only, so an Edit/MultiEdit to an existing protected file (settings.json, .env, .ssh, the hooks themselves) bypassed it entirely; matcher now Edit|Write (the script already reads only tool_input.file_path). #10 the primary trifecta detector's 20-entry window counted marker lines, so accumulated markers scrolled a real leg out (false negative); the window now counts tool-call entries. #13 pre-edit-secrets caught bare provider keys only inside a quoted label assignment; added anchored patterns for Anthropic sk-ant, OpenAI sk-proj, fine-grained github_pat_, Google AIza, and JWT eyJ (minimum-length guarded against prose false positives).

#12 the remote-pipe-to-shell block required a shell immediately after the first pipe, so xargs/sudo/tee/env interposition evaded it and the comment falsely claimed xargs was caught; broadened to reach a shell through intermediate segments while leaving shell-OR fallbacks unblocked. #11 (doc only): knowledge/owasp-skills-top10.md claimed pre-bash-destructive blocks persistence commands — it does not; corrected to mark persistence detection as unimplemented/future (the detector itself is deferred to v8). Suite 2004/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-18 10:36:39 +02:00
commit 8a59d616fb
9 changed files with 337 additions and 6 deletions

View file

@ -270,6 +270,35 @@ function readLastEntries(stateFile, n) {
}
}
/**
* Read a window covering the last n TOOL-CALL entries plus any marker entries
* interleaved within that span.
*
* v7.8.3 #10 fix: the primary trifecta detector previously used
* readLastEntries(stateFile, WINDOW_SIZE), which counts raw lines with no type
* filter. Marker entries (warning/volume_warning/escalation_warning/...)
* appended to the same file diluted the 20-line window and scrolled real
* trifecta legs out a false negative. This helper counts actual tool-call
* entries (no `type` field) and returns the slice from the n-th-last tool call
* onward, keeping interleaved markers so dedup checks (hasRecentWarning) still
* see them.
* @param {string} stateFile
* @param {number} n - number of tool-call entries the window must cover
* @returns {object[]}
*/
function readToolCallWindow(stateFile, n) {
const all = readLastEntries(stateFile, 10_000);
let toolCount = 0;
let start = 0;
for (let i = all.length - 1; i >= 0; i--) {
if (!all[i].type) {
toolCount++;
if (toolCount === n) { start = i; break; }
}
}
return all.slice(start);
}
/**
* Clean up state files older than CLEANUP_MAX_AGE_MS.
* Only called on first invocation per session (when state file doesn't exist yet).
@ -860,7 +889,9 @@ const messages = [];
// --- Trifecta detection (skip for neutral-only and delegation-only calls) ---
if (!(classes.length === 1 && (classes[0] === 'neutral' || classes[0] === 'delegation'))) {
const window = readLastEntries(stateFile, WINDOW_SIZE);
// v7.8.3 #10: count tool-call entries, not raw lines — marker entries must
// not dilute the trifecta window (see readToolCallWindow).
const window = readToolCallWindow(stateFile, WINDOW_SIZE);
const { detected, evidence } = checkTrifecta(window);
if (detected && !hasRecentWarning(window)) {

View file

@ -40,8 +40,13 @@ const BLOCK_RULES = [
{
name: 'Pipe-to-shell (curl|sh, wget|sh, curl|bash)',
// Matches: curl ... | sh, curl ... | bash, wget ... | sh, etc.
// Also catches variations with xargs sh, xargs bash
pattern: /(?:curl|wget)\b[^|]*\|\s*(?:bash|sh|zsh|ksh|dash)\b/,
// v7.8.3 #12: also catches a shell reached through interposition —
// intermediate pipe stages (curl x | tee y | sh) and wrapper commands
// with optional flags/assignments (xargs sh, sudo -E bash, env FOO=1 sh,
// nohup bash, command sh — chains like `xargs sudo bash` included).
// The intermediate-segment group requires a non-empty segment so `||`
// (shell OR, e.g. `curl x || sh fallback.sh`) does not match.
pattern: /(?:curl|wget)\b[^|]*\|(?:[^|]+\|)*\s*(?:(?:sudo|xargs|env|nohup|command)(?:\s+(?:-{1,2}[\w=/.-]+|\w+=\S*))*\s+)*(?:bash|sh|zsh|ksh|dash)\b/,
description:
'Piping remote content directly into a shell interpreter allows ' +
'arbitrary remote code execution without inspection. Download the script first, ' +

View file

@ -27,12 +27,24 @@ const SECRET_PATTERNS = [
{ 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}/ },
// v7.8.3 #13 — bare provider keys (see knowledge/secrets-patterns.md).
// Previously these were caught only when wrapped in a quoted label
// assignment (password|secret|token|api_key = "..."); the bare key forms
// slipped through.
{ name: 'Anthropic API Key', pattern: /\bsk-ant-api03-[A-Za-z0-9_-]{93}\b/ },
{ name: 'OpenAI Project Key', pattern: /\bsk-proj-[A-Za-z0-9_-]{40,}\b/ },
{ name: 'GitHub Fine-Grained PAT', pattern: /\bgithub_pat_[A-Za-z0-9_]{82}\b/ },
{ name: 'Google API Key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/ },
{ 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 },
// v7.8.3 #13 — three-part JWT (header.payload.signature, base64url). The
// 10-char part minimum keeps prose fragments (eyJabc.def.ghi) from tripping.
// Kept last so a Bearer-header context reports as 'Authorization header'.
{ name: 'JWT (three-part token)', pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
// Policy-defined additional patterns
...getPolicyValue('secrets', 'additional_patterns', []).map((p, i) => ({
name: `Custom pattern ${i + 1}`,