feat: initial open marketplace with llm-security, config-audit, ultraplan-local
This commit is contained in:
commit
f93d6abdae
380 changed files with 65935 additions and 0 deletions
270
plugins/config-audit/scanners/hook-validator.mjs
Normal file
270
plugins/config-audit/scanners/hook-validator.mjs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
/**
|
||||
* HKV Scanner — Hook Validator
|
||||
* Validates hooks.json format, script existence, event validity, timeouts.
|
||||
* Finding IDs: CA-HKV-NNN
|
||||
*/
|
||||
|
||||
import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseJson } from './lib/yaml-parser.mjs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
|
||||
const SCANNER = 'HKV';
|
||||
|
||||
/** All valid hook events (as of April 2026) */
|
||||
const VALID_EVENTS = new Set([
|
||||
'SessionStart', 'InstructionsLoaded', 'UserPromptSubmit',
|
||||
'PreToolUse', 'PermissionRequest', 'PermissionDenied',
|
||||
'PostToolUse', 'PostToolUseFailure',
|
||||
'SubagentStart', 'SubagentStop',
|
||||
'TaskCreated', 'TaskCompleted',
|
||||
'Stop', 'StopFailure',
|
||||
'TeammateIdle', 'Notification',
|
||||
'ConfigChange', 'CwdChanged', 'FileChanged',
|
||||
'WorktreeCreate', 'WorktreeRemove',
|
||||
'PreCompact', 'PostCompact',
|
||||
'Elicitation', 'ElicitationResult',
|
||||
'SessionEnd',
|
||||
]);
|
||||
|
||||
/** Valid hook handler types */
|
||||
const VALID_TYPES = new Set(['command', 'http', 'prompt', 'agent']);
|
||||
|
||||
/** Reasonable timeout range */
|
||||
const MIN_TIMEOUT = 1000;
|
||||
const MAX_TIMEOUT = 300000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Scan all hooks.json files and hook configs in settings.json.
|
||||
* @param {string} targetPath
|
||||
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function scan(targetPath, discovery) {
|
||||
const start = Date.now();
|
||||
const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json');
|
||||
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json');
|
||||
const findings = [];
|
||||
let filesScanned = 0;
|
||||
|
||||
// Scan standalone hooks.json files
|
||||
for (const file of hooksFiles) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (!content) continue;
|
||||
filesScanned++;
|
||||
|
||||
const parsed = parseJson(content);
|
||||
if (parsed === null) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid JSON in hooks.json',
|
||||
description: `${file.relPath} contains invalid JSON. All hooks in this file will be ignored.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Fix JSON syntax errors.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const hooksConfig = parsed.hooks || parsed;
|
||||
await validateHooksObject(hooksConfig, file, findings, dirname(file.absPath));
|
||||
}
|
||||
|
||||
// Scan hooks in settings.json files
|
||||
for (const file of settingsFiles) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (!content) continue;
|
||||
|
||||
const parsed = parseJson(content);
|
||||
if (!parsed || !parsed.hooks) continue;
|
||||
filesScanned++;
|
||||
|
||||
if (Array.isArray(parsed.hooks)) {
|
||||
// Already reported by settings-validator, skip here
|
||||
continue;
|
||||
}
|
||||
|
||||
await validateHooksObject(parsed.hooks, file, findings, dirname(file.absPath));
|
||||
}
|
||||
|
||||
if (hooksFiles.length === 0 && !settingsFiles.some(async f => {
|
||||
const c = await readTextFile(f.absPath);
|
||||
const p = c ? parseJson(c) : null;
|
||||
return p && p.hooks;
|
||||
})) {
|
||||
// No hooks at all — this is noted but not an error
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a hooks object (event key → handler array).
|
||||
*/
|
||||
async function validateHooksObject(hooks, file, findings, baseDir) {
|
||||
if (typeof hooks !== 'object' || Array.isArray(hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Hooks must be an object with event keys',
|
||||
description: `${file.relPath}: hooks is ${Array.isArray(hooks) ? 'an array' : typeof hooks}. Expected object with event names as keys.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Use format: { "PreToolUse": [...], "Stop": [...] }',
|
||||
autoFixable: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [event, handlers] of Object.entries(hooks)) {
|
||||
// Validate event name
|
||||
if (!VALID_EVENTS.has(event)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Unknown hook event',
|
||||
description: `${file.relPath}: "${event}" is not a valid hook event. This hook will never fire.`,
|
||||
file: file.absPath,
|
||||
evidence: event,
|
||||
recommendation: `Valid events: ${[...VALID_EVENTS].slice(0, 8).join(', ')}... (26 total)`,
|
||||
autoFixable: false,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Array.isArray(handlers)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Hook handlers must be an array',
|
||||
description: `${file.relPath}: handlers for "${event}" is not an array.`,
|
||||
file: file.absPath,
|
||||
evidence: `"${event}": ${typeof handlers}`,
|
||||
recommendation: `Use format: "${event}": [{ "hooks": [...] }]`,
|
||||
autoFixable: false,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const handlerGroup of handlers) {
|
||||
// Validate matcher format
|
||||
if (handlerGroup.matcher !== undefined) {
|
||||
if (typeof handlerGroup.matcher === 'object') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Matcher must be a string, not an object',
|
||||
description: `${file.relPath}: "${event}" has a matcher that is an object. Matcher should be a simple string like "Bash" or "Edit|Write".`,
|
||||
file: file.absPath,
|
||||
evidence: JSON.stringify(handlerGroup.matcher),
|
||||
recommendation: 'Change matcher to a string: "matcher": "Bash"',
|
||||
autoFixable: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (!handlerGroup.hooks || !Array.isArray(handlerGroup.hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Missing hooks array in handler group',
|
||||
description: `${file.relPath}: "${event}" handler group is missing the "hooks" array.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Add "hooks": [{ "type": "command", "command": "..." }]',
|
||||
autoFixable: false,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const hook of handlerGroup.hooks) {
|
||||
// Validate handler type
|
||||
if (!hook.type || !VALID_TYPES.has(hook.type)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Invalid hook handler type',
|
||||
description: `${file.relPath}: "${event}" has handler with type "${hook.type || '(missing)'}".`,
|
||||
file: file.absPath,
|
||||
evidence: `type: "${hook.type || ''}"`,
|
||||
recommendation: `Valid types: ${[...VALID_TYPES].join(', ')}`,
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// For command hooks, check script existence
|
||||
if (hook.type === 'command' && hook.command) {
|
||||
const scriptPath = extractScriptPath(hook.command, baseDir);
|
||||
if (scriptPath) {
|
||||
try {
|
||||
await stat(scriptPath);
|
||||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Hook script not found',
|
||||
description: `${file.relPath}: "${event}" references script that does not exist.`,
|
||||
file: file.absPath,
|
||||
evidence: hook.command,
|
||||
recommendation: `Create the script at: ${scriptPath}`,
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout validation
|
||||
if (hook.timeout !== undefined) {
|
||||
if (typeof hook.timeout !== 'number') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Hook timeout must be a number',
|
||||
description: `${file.relPath}: "${event}" has non-numeric timeout.`,
|
||||
file: file.absPath,
|
||||
evidence: `timeout: ${JSON.stringify(hook.timeout)}`,
|
||||
recommendation: 'Set timeout to a number (milliseconds).',
|
||||
autoFixable: true,
|
||||
}));
|
||||
} else if (hook.timeout < MIN_TIMEOUT || hook.timeout > MAX_TIMEOUT) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
title: 'Hook timeout outside recommended range',
|
||||
description: `${file.relPath}: "${event}" timeout is ${hook.timeout}ms. Recommended range: ${MIN_TIMEOUT}-${MAX_TIMEOUT}ms.`,
|
||||
file: file.absPath,
|
||||
evidence: `timeout: ${hook.timeout}`,
|
||||
recommendation: `Set timeout between ${MIN_TIMEOUT} and ${MAX_TIMEOUT}ms.`,
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a filesystem path from a hook command string.
|
||||
* Handles ${CLAUDE_PLUGIN_ROOT} variable substitution.
|
||||
*/
|
||||
function extractScriptPath(command, baseDir) {
|
||||
// Extract the script path from common patterns:
|
||||
// "bash /path/to/script.sh"
|
||||
// "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/foo.mjs"
|
||||
const match = command.match(/(?:bash|node|sh)\s+(.+?)(?:\s|$)/);
|
||||
if (!match) return null;
|
||||
|
||||
let scriptPath = match[1].trim();
|
||||
|
||||
// Replace ${CLAUDE_PLUGIN_ROOT} with baseDir (best guess)
|
||||
scriptPath = scriptPath.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, resolve(baseDir, '..'));
|
||||
scriptPath = scriptPath.replace(/\$CLAUDE_PLUGIN_ROOT/g, resolve(baseDir, '..'));
|
||||
|
||||
// Don't validate absolute paths that use env vars we can't resolve
|
||||
if (scriptPath.includes('$')) return null;
|
||||
|
||||
return resolve(baseDir, scriptPath);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue