feat(config-audit): HKV flags verbose hook output (v5 M5) [skip-docs]
Static heuristic — counts console.log / process.stdout.write lines per referenced hook script. > 50 → low CA-HKV-NNN finding. New fixtures: - hooks-verbose/ (61 verbose lines → triggers) - hooks-quiet/ (5 lines → no finding) 580 → 582 tests, all green.
This commit is contained in:
parent
7181862644
commit
910567d661
6 changed files with 153 additions and 0 deletions
|
|
@ -36,6 +36,11 @@ const VALID_TYPES = new Set(['command', 'http', 'prompt', 'agent']);
|
||||||
const MIN_TIMEOUT = 1000;
|
const MIN_TIMEOUT = 1000;
|
||||||
const MAX_TIMEOUT = 300000; // 5 minutes
|
const MAX_TIMEOUT = 300000; // 5 minutes
|
||||||
|
|
||||||
|
/** v5 M5: hook scripts that flood stdout fragment the cache prefix on every
|
||||||
|
* fire and slow Claude Code's UI. Static heuristic — count log lines. */
|
||||||
|
const VERBOSE_HOOK_LINE_THRESHOLD = 50;
|
||||||
|
const VERBOSE_HOOK_LINE_RX = /\b(?:console\.log|process\.stdout\.write)\s*\(/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan all hooks.json files and hook configs in settings.json.
|
* Scan all hooks.json files and hook configs in settings.json.
|
||||||
* @param {string} targetPath
|
* @param {string} targetPath
|
||||||
|
|
@ -198,8 +203,10 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
||||||
if (hook.type === 'command' && hook.command) {
|
if (hook.type === 'command' && hook.command) {
|
||||||
const scriptPath = extractScriptPath(hook.command, baseDir);
|
const scriptPath = extractScriptPath(hook.command, baseDir);
|
||||||
if (scriptPath) {
|
if (scriptPath) {
|
||||||
|
let scriptExists = false;
|
||||||
try {
|
try {
|
||||||
await stat(scriptPath);
|
await stat(scriptPath);
|
||||||
|
scriptExists = true;
|
||||||
} catch {
|
} catch {
|
||||||
findings.push(finding({
|
findings.push(finding({
|
||||||
scanner: SCANNER,
|
scanner: SCANNER,
|
||||||
|
|
@ -212,6 +219,31 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
||||||
autoFixable: false,
|
autoFixable: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v5 M5: count verbose stdout writes when the script exists.
|
||||||
|
if (scriptExists) {
|
||||||
|
const verboseCount = await countVerboseLines(scriptPath);
|
||||||
|
if (verboseCount > VERBOSE_HOOK_LINE_THRESHOLD) {
|
||||||
|
findings.push(finding({
|
||||||
|
scanner: SCANNER,
|
||||||
|
severity: SEVERITY.low,
|
||||||
|
title: 'Verbose hook output (loud script)',
|
||||||
|
description:
|
||||||
|
`${file.relPath}: "${event}" runs ${scriptPath.split('/').slice(-2).join('/')} ` +
|
||||||
|
`which has ${verboseCount} console.log / process.stdout.write lines ` +
|
||||||
|
`(>${VERBOSE_HOOK_LINE_THRESHOLD}). Loud hooks slow the UI and bloat ` +
|
||||||
|
'session transcripts on every fire.',
|
||||||
|
file: scriptPath,
|
||||||
|
evidence:
|
||||||
|
`console_log_or_stdout_lines=${verboseCount}; ` +
|
||||||
|
`threshold=${VERBOSE_HOOK_LINE_THRESHOLD}`,
|
||||||
|
recommendation:
|
||||||
|
'Trim debug logging from hooks. Keep hook output to actionable signals; ' +
|
||||||
|
'route verbose diagnostics to a log file instead of stdout.',
|
||||||
|
autoFixable: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,6 +278,20 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count lines containing console.log( or process.stdout.write( in a hook script.
|
||||||
|
* Static heuristic — does not execute the script.
|
||||||
|
*/
|
||||||
|
async function countVerboseLines(scriptPath) {
|
||||||
|
const content = await readTextFile(scriptPath);
|
||||||
|
if (!content) return 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
if (VERBOSE_HOOK_LINE_RX.test(line)) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract a filesystem path from a hook command string.
|
* Extract a filesystem path from a hook command string.
|
||||||
* Handles ${CLAUDE_PLUGIN_ROOT} variable substitution.
|
* Handles ${CLAUDE_PLUGIN_ROOT} variable substitution.
|
||||||
|
|
|
||||||
7
plugins/config-audit/tests/fixtures/hooks-quiet/hooks/hooks.json
vendored
Normal file
7
plugins/config-audit/tests/fixtures/hooks-quiet/hooks/hooks.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "node ./scripts/quiet.mjs", "timeout": 5000 }] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
7
plugins/config-audit/tests/fixtures/hooks-quiet/hooks/scripts/quiet.mjs
vendored
Normal file
7
plugins/config-audit/tests/fixtures/hooks-quiet/hooks/scripts/quiet.mjs
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Quiet hook
|
||||||
|
console.log("step 0");
|
||||||
|
console.log("step 1");
|
||||||
|
console.log("step 2");
|
||||||
|
console.log("step 3");
|
||||||
|
console.log("step 4");
|
||||||
7
plugins/config-audit/tests/fixtures/hooks-verbose/hooks/hooks.json
vendored
Normal file
7
plugins/config-audit/tests/fixtures/hooks-verbose/hooks/hooks.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "node ./scripts/loud.mjs", "timeout": 5000 }] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
64
plugins/config-audit/tests/fixtures/hooks-verbose/hooks/scripts/loud.mjs
vendored
Normal file
64
plugins/config-audit/tests/fixtures/hooks-verbose/hooks/scripts/loud.mjs
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Verbose hook for v5 M5 fixture
|
||||||
|
console.log("step 0");
|
||||||
|
console.log("step 1");
|
||||||
|
console.log("step 2");
|
||||||
|
console.log("step 3");
|
||||||
|
console.log("step 4");
|
||||||
|
console.log("step 5");
|
||||||
|
console.log("step 6");
|
||||||
|
console.log("step 7");
|
||||||
|
console.log("step 8");
|
||||||
|
console.log("step 9");
|
||||||
|
console.log("step 10");
|
||||||
|
console.log("step 11");
|
||||||
|
console.log("step 12");
|
||||||
|
console.log("step 13");
|
||||||
|
console.log("step 14");
|
||||||
|
console.log("step 15");
|
||||||
|
console.log("step 16");
|
||||||
|
console.log("step 17");
|
||||||
|
console.log("step 18");
|
||||||
|
console.log("step 19");
|
||||||
|
console.log("step 20");
|
||||||
|
console.log("step 21");
|
||||||
|
console.log("step 22");
|
||||||
|
console.log("step 23");
|
||||||
|
console.log("step 24");
|
||||||
|
console.log("step 25");
|
||||||
|
console.log("step 26");
|
||||||
|
console.log("step 27");
|
||||||
|
console.log("step 28");
|
||||||
|
console.log("step 29");
|
||||||
|
console.log("step 30");
|
||||||
|
console.log("step 31");
|
||||||
|
console.log("step 32");
|
||||||
|
console.log("step 33");
|
||||||
|
console.log("step 34");
|
||||||
|
console.log("step 35");
|
||||||
|
console.log("step 36");
|
||||||
|
console.log("step 37");
|
||||||
|
console.log("step 38");
|
||||||
|
console.log("step 39");
|
||||||
|
console.log("step 40");
|
||||||
|
console.log("step 41");
|
||||||
|
console.log("step 42");
|
||||||
|
console.log("step 43");
|
||||||
|
console.log("step 44");
|
||||||
|
console.log("step 45");
|
||||||
|
console.log("step 46");
|
||||||
|
console.log("step 47");
|
||||||
|
console.log("step 48");
|
||||||
|
console.log("step 49");
|
||||||
|
console.log("step 50");
|
||||||
|
console.log("step 51");
|
||||||
|
console.log("step 52");
|
||||||
|
console.log("step 53");
|
||||||
|
console.log("step 54");
|
||||||
|
console.log("step 55");
|
||||||
|
console.log("step 56");
|
||||||
|
console.log("step 57");
|
||||||
|
console.log("step 58");
|
||||||
|
console.log("step 59");
|
||||||
|
process.stdout.write("trailing
|
||||||
|
");
|
||||||
|
|
@ -71,6 +71,28 @@ describe('HKV scanner — broken project', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('HKV scanner — verbose hook output (v5 M5)', () => {
|
||||||
|
it('flags hook script with > 50 console.log/stdout.write lines (low)', async () => {
|
||||||
|
resetCounter();
|
||||||
|
const path = resolve(FIXTURES, 'hooks-verbose');
|
||||||
|
const discovery = await discoverConfigFiles(path);
|
||||||
|
const result = await scan(path, discovery);
|
||||||
|
const f = result.findings.find(x => /verbose hook output/i.test(x.title || ''));
|
||||||
|
assert.ok(f, `expected verbose-hook finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
|
||||||
|
assert.equal(f.severity, 'low', `expected low, got ${f.severity}`);
|
||||||
|
assert.match(f.evidence || '', /console_log_or_stdout_lines=6\d/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT flag a quiet hook script', async () => {
|
||||||
|
resetCounter();
|
||||||
|
const path = resolve(FIXTURES, 'hooks-quiet');
|
||||||
|
const discovery = await discoverConfigFiles(path);
|
||||||
|
const result = await scan(path, discovery);
|
||||||
|
const f = result.findings.find(x => /verbose hook output/i.test(x.title || ''));
|
||||||
|
assert.equal(f, undefined, `expected no verbose-hook finding; got: ${f?.title}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('HKV scanner — empty project', () => {
|
describe('HKV scanner — empty project', () => {
|
||||||
let result;
|
let result;
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue