BREAKING CHANGE: the {NNN} in CA-{SCANNER}-{NNN} identifies the check that
produced the finding. It used to be the finding's position in that scanner's
output for that run, which made it unstable across CONFIGURATIONS, not just
across releases as STATE framed it. Measured on two fixtures: "No custom
subagents" was CA-GAP-007 on minimal-project and CA-GAP-004 on healthy-project.
A user who fixed an unrelated earlier gap silently renumbered every later one,
so a .config-audit-ignore pin retargeted to a neighbouring finding with no
version change at all.
Second measured arm: README already documented the opposite scheme. It and the
scanner headers describe ~20 numbers as check codes (CA-SKL-003 = oversized
body, CA-PLH-015 = folder shadowing, CA-TOK-006 = schema deferral), and the
counter could only produce those in the all-fire case -- source-order positions
are 4, 3 and 8. The documentation described the scheme; the implementation was
what was wrong. Every published number is preserved by construction and pinned
exhaustively in tests/lib/finding-codes.test.mjs.
scanners/lib/finding-codes.mjs is the single authority. Every finding() call
passes a `code`; an undeclared or missing one THROWS. No counter fallback --
that would reproduce D1's findGapId -> 'unknown' silent degradation and let a
half-converted scanner ship IDs that look valid. findingCounter/resetCounter
are deleted outright, not left as no-ops. Retirement is now a mechanism:
RETIRED_CODES tombstones a withdrawn key so its number is never reissued,
seeded with GAP t3_8 -- the D1 removal that opened this chunk.
IDs are consequently NOT unique per finding: one check failing in three files
emits three findings sharing an ID. That inverts which consumer is correct, so
every f.id/findingId site was classified before the change. diff-engine and
most of fix-engine already keyed on scanner+title+file (drift was never lying);
fix-engine's verification did not, and keyed on the ID alone -- fixing one of
two sibling instances marked both fixed, and the untouched one, still present
in the re-scan, was reported as a REGRESSION. Red test first, then keyed on
(findingId, file), which both planFixes and applyFixes already carry.
plugin-health's crossIds Set was measured and is a clean negative: cross
findings are allFindings.slice(crossPluginStart) and codes 18/19 are emitted
only in that tail, so the partition holds by construction.
unknownSuppressions() reports a pin that names no declared check, in the
--output-file payload (ux-rules rule 2 -- a stderr-only warning is invisible to
the commands) and only when one exists, so a clean config is byte-identical.
That is what makes the break safe: a stale pin goes loud instead of dying quiet.
Frozen tests/snapshots/v5.0.0/ untouched on disk. IDs are masked out of that
comparison (mask-finding-ids.mjs) rather than re-derived -- re-deriving
positional IDs would assert the retired scheme against itself, and #58's
isGapEntry off-by-one is the measured example of that misfiring. The dead
re-derivation is removed from strip-retired-gap.mjs. default-output snapshots
re-approved after confirming the diff is IDs and nothing else.
Guards, each seen red against its own defect: a missing code (scanner errors
out mid-sweep), an orphan declaration, a resurrected retired key, and a
documented ID naming no check. The sweep asserts the union across all 16
scanners, never per scanner -- a per-scanner assertion goes green on a partial
conversion.
Fasit written before implementation: docs/mbug28-id-semantics-fasit.local.md,
including one correction made before running (CML has 12 checks over 13 call
sites -- the anchored and calibrated char-budget arms are one check, which a
repeated-title sweep found and my call-site count had missed).
Suite 1535 -> 1573, 0 failing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyqCQKK2ornJ1jFWwqx17E
278 lines
12 KiB
JavaScript
278 lines
12 KiB
JavaScript
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { resolve, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
|
import { scan } from '../../scanners/hook-validator.mjs';
|
|
import { findingId } from '../../scanners/lib/finding-codes.mjs';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const FIXTURES = resolve(__dirname, '../fixtures');
|
|
|
|
describe('HKV scanner — healthy project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
|
|
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
|
|
});
|
|
|
|
it('returns status ok', () => {
|
|
assert.strictEqual(result.status, 'ok');
|
|
});
|
|
|
|
it('has scanner prefix HKV', () => {
|
|
assert.strictEqual(result.scanner, 'HKV');
|
|
});
|
|
|
|
it('finds no critical or high issues', () => {
|
|
const serious = result.findings.filter(f => f.severity === 'critical' || f.severity === 'high');
|
|
assert.strictEqual(serious.length, 0, `Found: ${serious.map(f => f.title).join(', ')}`);
|
|
});
|
|
|
|
it('all finding IDs match CA-HKV-NNN', () => {
|
|
for (const f of result.findings) {
|
|
assert.match(f.id, /^CA-HKV-\d{3}$/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('HKV scanner — broken project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
|
|
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
|
|
});
|
|
|
|
it('detects unknown hook event', () => {
|
|
// CA-HKV-001 in broken-project, evidence='InvalidEvent'.
|
|
const found = result.findings.some(f => f.scanner === 'HKV' && /InvalidEvent/.test(f.evidence || ''));
|
|
assert.ok(found, 'Should detect InvalidEvent');
|
|
});
|
|
|
|
it('detects object matcher (should be string)', () => {
|
|
const found = result.findings.some(f => f.scanner === 'HKV' && f.id === findingId('HKV', 'matcher-not-string'));
|
|
assert.ok(found, 'Should detect nested object matcher');
|
|
});
|
|
|
|
it('detects invalid handler type', () => {
|
|
// CA-HKV-003 in broken-project, evidence='type: "invalid_type"'.
|
|
const found = result.findings.some(f => f.scanner === 'HKV' && /invalid_type/.test(f.evidence || ''));
|
|
assert.ok(found, 'Should detect invalid_type');
|
|
});
|
|
|
|
it('detects timeout below minimum', () => {
|
|
// CA-HKV-004 in broken-project, evidence='timeout: 500'.
|
|
const found = result.findings.some(f => f.scanner === 'HKV' && /timeout:\s*500/.test(f.evidence || ''));
|
|
assert.ok(found, 'Should detect timeout of 500ms');
|
|
});
|
|
|
|
it('marks unknown event as high severity', () => {
|
|
// CA-HKV-001 in broken-project = unknown-event finding (evidence='InvalidEvent').
|
|
const f = result.findings.find(x => x.scanner === 'HKV' && /InvalidEvent/.test(x.evidence || ''));
|
|
assert.strictEqual(f?.severity, 'high');
|
|
});
|
|
});
|
|
|
|
describe('HKV scanner — verbose hook output (v5 M5)', () => {
|
|
it('flags hook script with > 50 console.log/stdout.write lines (low)', async () => {
|
|
const path = resolve(FIXTURES, 'hooks-verbose');
|
|
const discovery = await discoverConfigFiles(path);
|
|
const result = await scan(path, discovery);
|
|
// Verbose-hook finding in hooks-verbose; evidence carries the line-count metric.
|
|
const f = result.findings.find(x => x.scanner === 'HKV' && /console_log_or_stdout_lines=/.test(x.evidence || ''));
|
|
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 () => {
|
|
const path = resolve(FIXTURES, 'hooks-quiet');
|
|
const discovery = await discoverConfigFiles(path);
|
|
const result = await scan(path, discovery);
|
|
const f = result.findings.find(x => x.scanner === 'HKV' && /console_log_or_stdout_lines=/.test(x.evidence || ''));
|
|
assert.equal(f, undefined, `expected no verbose-hook finding; got id=${f?.id}`);
|
|
});
|
|
});
|
|
|
|
describe('HKV scanner — additionalContext injection advisory (v5.10 B5)', () => {
|
|
it('flags a hook that injects unfiltered command output into additionalContext (info advisory)', async () => {
|
|
const path = resolve(FIXTURES, 'hooks-additional-context');
|
|
const discovery = await discoverConfigFiles(path);
|
|
const result = await scan(path, discovery);
|
|
const f = result.findings.find(
|
|
x => x.scanner === 'HKV' && /additional_context_unfiltered=true/.test(x.evidence || ''),
|
|
);
|
|
assert.ok(f, `expected additionalContext advisory; got: ${result.findings.map(x => x.title).join(' | ')}`);
|
|
assert.equal(f.severity, 'info', `expected info (advisory), got ${f.severity}`);
|
|
// The chatty.sh script (SessionStart) is the one flagged, not filtered.sh.
|
|
assert.match(f.file || '', /chatty\.sh$/);
|
|
// Precision caveat must be disclosed in the description (low-precision advisory).
|
|
assert.match(f.description || '', /every time|enters Claude's context|advisory/i);
|
|
});
|
|
|
|
it('does NOT flag the filtered (grep|head) sibling hook', async () => {
|
|
const path = resolve(FIXTURES, 'hooks-additional-context');
|
|
const discovery = await discoverConfigFiles(path);
|
|
const result = await scan(path, discovery);
|
|
const filtered = result.findings.find(
|
|
x => x.scanner === 'HKV' && /additional_context_unfiltered=true/.test(x.evidence || '') && /filtered\.sh$/.test(x.file || ''),
|
|
);
|
|
assert.equal(filtered, undefined, `filtered.sh should not be flagged; got id=${filtered?.id}`);
|
|
});
|
|
|
|
it('does NOT flag a quiet hook with no additionalContext', async () => {
|
|
const path = resolve(FIXTURES, 'hooks-quiet');
|
|
const discovery = await discoverConfigFiles(path);
|
|
const result = await scan(path, discovery);
|
|
const f = result.findings.find(
|
|
x => x.scanner === 'HKV' && /additional_context_unfiltered=true/.test(x.evidence || ''),
|
|
);
|
|
assert.equal(f, undefined, `expected no additionalContext advisory; got id=${f?.id}`);
|
|
});
|
|
});
|
|
|
|
describe('HKV scanner — CC 2.1.152 MessageDisplay event (Batch 1 false-positive fix)', () => {
|
|
// The pre-write path-guard blocks committing settings.json/hooks.json, so
|
|
// this suite materializes a hermetic temp fixture at runtime.
|
|
let tmpRoot;
|
|
let result;
|
|
|
|
const VALID_NEW = ['MessageDisplay'];
|
|
|
|
beforeEach(async () => {
|
|
tmpRoot = await mkdtemp(join(tmpdir(), 'ca-hkv-events-'));
|
|
await mkdir(join(tmpRoot, '.claude'), { recursive: true });
|
|
const settings = {
|
|
hooks: {
|
|
// 'echo …' commands skip the script-existence check (extractScriptPath
|
|
// only resolves bash/node/sh), isolating the event-name validation.
|
|
MessageDisplay: [{ hooks: [{ type: 'command', command: 'echo display' }] }],
|
|
},
|
|
};
|
|
await writeFile(
|
|
join(tmpRoot, '.claude', 'settings.json'),
|
|
JSON.stringify(settings, null, 2) + '\n',
|
|
'utf8',
|
|
);
|
|
const discovery = await discoverConfigFiles(tmpRoot);
|
|
result = await scan(tmpRoot, discovery);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
for (const event of VALID_NEW) {
|
|
it(`does NOT flag "${event}" as an unknown hook event`, () => {
|
|
const unknown = result.findings.find(f =>
|
|
f.title === 'Unknown hook event' && f.evidence === event);
|
|
assert.equal(unknown, undefined, `${event} should be in VALID_EVENTS`);
|
|
});
|
|
}
|
|
|
|
it('produces zero findings for a valid MessageDisplay config', () => {
|
|
assert.equal(result.findings.length, 0,
|
|
`expected clean scan; got: ${result.findings.map(f => `${f.title}:${f.evidence || ''}`).join(' | ')}`);
|
|
});
|
|
});
|
|
|
|
describe('HKV scanner — post-session is NOT a settings.json hook event (Verifiseringsplikt)', () => {
|
|
// Verified 2026-06-20 against hooks.md + the 2.1.169 changelog: the
|
|
// `post-session` hook in that changelog is a SELF-HOSTED-RUNNER
|
|
// workspace-lifecycle hook, NOT a settings.json hook event. It is absent
|
|
// from hooks.md's (all-PascalCase) event list, so a settings.json hook
|
|
// keyed on it never fires and MUST be flagged.
|
|
let tmpRoot;
|
|
let result;
|
|
|
|
beforeEach(async () => {
|
|
tmpRoot = await mkdtemp(join(tmpdir(), 'ca-hkv-postsession-'));
|
|
await mkdir(join(tmpRoot, '.claude'), { recursive: true });
|
|
const settings = {
|
|
hooks: {
|
|
'post-session': [{ hooks: [{ type: 'command', command: 'echo bye' }] }],
|
|
},
|
|
};
|
|
await writeFile(
|
|
join(tmpRoot, '.claude', 'settings.json'),
|
|
JSON.stringify(settings, null, 2) + '\n',
|
|
'utf8',
|
|
);
|
|
const discovery = await discoverConfigFiles(tmpRoot);
|
|
result = await scan(tmpRoot, discovery);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('flags "post-session" as an unknown hook event', () => {
|
|
const f = result.findings.find(x =>
|
|
x.title === 'Unknown hook event' && x.evidence === 'post-session');
|
|
assert.ok(f, 'post-session must be flagged as an unknown settings.json hook event');
|
|
});
|
|
});
|
|
|
|
describe('HKV scanner — Setup/UserPromptExpansion/PostToolBatch events (Batch 2 false-positive fix)', () => {
|
|
// Three more events verified live against code.claude.com/docs/en/hooks.md
|
|
// (2026-06-19): Setup (session-level), UserPromptExpansion (per-turn),
|
|
// PostToolBatch (agentic loop). Same hermetic temp-fixture pattern — the
|
|
// path-guard blocks committing settings.json/hooks.json fixtures.
|
|
let tmpRoot;
|
|
let result;
|
|
|
|
const NEW_EVENTS = ['Setup', 'UserPromptExpansion', 'PostToolBatch'];
|
|
|
|
beforeEach(async () => {
|
|
tmpRoot = await mkdtemp(join(tmpdir(), 'ca-hkv-events2-'));
|
|
await mkdir(join(tmpRoot, '.claude'), { recursive: true });
|
|
const settings = {
|
|
hooks: {
|
|
// 'echo …' commands skip the script-existence check, isolating
|
|
// event-name validation.
|
|
Setup: [{ hooks: [{ type: 'command', command: 'echo setup' }] }],
|
|
UserPromptExpansion: [{ hooks: [{ type: 'command', command: 'echo expand' }] }],
|
|
PostToolBatch: [{ hooks: [{ type: 'command', command: 'echo batch' }] }],
|
|
},
|
|
};
|
|
await writeFile(
|
|
join(tmpRoot, '.claude', 'settings.json'),
|
|
JSON.stringify(settings, null, 2) + '\n',
|
|
'utf8',
|
|
);
|
|
const discovery = await discoverConfigFiles(tmpRoot);
|
|
result = await scan(tmpRoot, discovery);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
for (const event of NEW_EVENTS) {
|
|
it(`does NOT flag "${event}" as an unknown hook event`, () => {
|
|
const unknown = result.findings.find(f =>
|
|
f.title === 'Unknown hook event' && f.evidence === event);
|
|
assert.equal(unknown, undefined, `${event} should be in VALID_EVENTS`);
|
|
});
|
|
}
|
|
|
|
it('produces zero findings for a valid Setup + UserPromptExpansion + PostToolBatch config', () => {
|
|
assert.equal(result.findings.length, 0,
|
|
`expected clean scan; got: ${result.findings.map(f => `${f.title}:${f.evidence || ''}`).join(' | ')}`);
|
|
});
|
|
});
|
|
|
|
describe('HKV scanner — empty project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
|
|
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
|
|
});
|
|
|
|
it('returns status ok with 0 findings', () => {
|
|
assert.strictEqual(result.status, 'ok');
|
|
assert.strictEqual(result.findings.length, 0);
|
|
});
|
|
});
|