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
180 lines
9.4 KiB
JavaScript
180 lines
9.4 KiB
JavaScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { scan } from '../../scanners/disabled-in-schema-scanner.mjs';
|
|
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const FIXTURES = resolve(__dirname, '../fixtures');
|
|
|
|
async function runScanner(fixtureName) {
|
|
const path = resolve(FIXTURES, fixtureName);
|
|
const discovery = await discoverConfigFiles(path);
|
|
return scan(path, discovery);
|
|
}
|
|
|
|
describe('DIS scanner — basic structure', () => {
|
|
it('reports scanner prefix DIS', async () => {
|
|
const result = await runScanner('denied-tools-in-schema');
|
|
assert.equal(result.scanner, 'DIS');
|
|
});
|
|
|
|
it('finding IDs match CA-DIS-NNN pattern', async () => {
|
|
const result = await runScanner('denied-tools-in-schema');
|
|
for (const f of result.findings) {
|
|
assert.match(f.id, /^CA-DIS-\d{3}$/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('DIS scanner — Bash in both arrays → finding', () => {
|
|
it('flags Bash overlap with low severity', async () => {
|
|
const result = await runScanner('denied-tools-in-schema');
|
|
const f = result.findings.find(x => /both permissions\.deny and permissions\.allow/i.test(x.title || ''));
|
|
assert.ok(f, `expected DIS finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
|
|
assert.equal(f.severity, 'low', `expected low, got ${f.severity}`);
|
|
assert.match(String(f.evidence || ''), /Bash/);
|
|
});
|
|
|
|
it('evidence references the allow + deny entries', async () => {
|
|
const result = await runScanner('denied-tools-in-schema');
|
|
const f = result.findings.find(x => /both permissions/i.test(x.title || ''));
|
|
assert.ok(f);
|
|
assert.match(String(f.evidence || ''), /allow=/);
|
|
assert.match(String(f.evidence || ''), /deny=/);
|
|
});
|
|
});
|
|
|
|
describe('DIS scanner — clean settings → no finding', () => {
|
|
it('healthy-project has no DIS findings', async () => {
|
|
const result = await runScanner('healthy-project');
|
|
const f = result.findings.find(x => /both permissions/i.test(x.title || ''));
|
|
assert.equal(f, undefined,
|
|
`expected no DIS finding for healthy-project; got: ${f?.title}`);
|
|
});
|
|
});
|
|
|
|
describe('DIS scanner — orchestrator wiring', () => {
|
|
it('DIS appears in scan-orchestrator scanner list', async () => {
|
|
const orch = await import('../../scanners/scan-orchestrator.mjs');
|
|
const path = resolve(FIXTURES, 'denied-tools-in-schema');
|
|
const env = await orch.runAllScanners(path, { filterFixtures: false });
|
|
const dis = env.scanners.find(r => r.scanner === 'DIS');
|
|
assert.ok(dis, `expected DIS in orchestrator results; got: ${env.scanners.map(r => r.scanner).join(', ')}`);
|
|
});
|
|
});
|
|
|
|
describe('DIS scanner — param-qualified permissions are param-aware', () => {
|
|
// settings.json:
|
|
// allow: Agent(model:sonnet), WebFetch(domain:good.com), Bash(npm:*)
|
|
// deny: Agent(model:opus), WebFetch(domain:evil.com), Bash
|
|
// Only the bare `Bash` deny dominates an allow (Bash(npm:*)). The param-
|
|
// qualified deny/allow pairs are DISTINCT specs (CC 2.1.178 param-matching,
|
|
// 2.1.172 domain rules) and must NOT be flagged as dead config.
|
|
|
|
it('flags exactly the Bash overlap (bare deny covers param allow)', async () => {
|
|
const result = await runScanner('param-qualified-permissions');
|
|
const f = result.findings.find(x => /both permissions/i.test(x.title || ''));
|
|
assert.ok(f, 'expected the Bash overlap to still be flagged');
|
|
assert.match(String(f.evidence || ''), /Bash/);
|
|
assert.match(String(f.description || ''), /contains 1 tool\b/);
|
|
});
|
|
|
|
it('does NOT flag Agent(model:opus)/Agent(model:sonnet) as dead config', async () => {
|
|
const result = await runScanner('param-qualified-permissions');
|
|
const f = result.findings.find(x => /both permissions/i.test(x.title || ''));
|
|
assert.doesNotMatch(String(f?.evidence || ''), /Agent/);
|
|
});
|
|
|
|
it('does NOT flag WebFetch(domain:good.com)/WebFetch(domain:evil.com) as dead config', async () => {
|
|
const result = await runScanner('param-qualified-permissions');
|
|
const f = result.findings.find(x => /both permissions/i.test(x.title || ''));
|
|
assert.doesNotMatch(String(f?.evidence || ''), /WebFetch/);
|
|
});
|
|
});
|
|
|
|
describe('DIS scanner — ineffective allow wildcards (CC skips unanchored tool-name globs)', () => {
|
|
// settings.json allow: ["mcp__*", "B*", "mcp__puppeteer__*", "Bash(npm run *)", "Read"]
|
|
// CC skips `mcp__*` and `B*` (unanchored tool-name globs) — dead config.
|
|
// `mcp__puppeteer__*` (anchored), `Bash(npm run *)` (specifier glob) and `Read` are valid.
|
|
|
|
it('flags the ineffective allow globs with low severity', async () => {
|
|
const result = await runScanner('ineffective-allow-globs');
|
|
const f = result.findings.find(x => /ineffective allow wildcard/i.test(x.title || ''));
|
|
assert.ok(f, `expected ineffective-allow-glob finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
|
|
assert.equal(f.severity, 'low', `expected low, got ${f.severity}`);
|
|
assert.match(f.id, /^CA-DIS-\d{3}$/);
|
|
});
|
|
|
|
it('evidence cites only the unanchored globs, not the valid entries', async () => {
|
|
const result = await runScanner('ineffective-allow-globs');
|
|
const f = result.findings.find(x => /ineffective allow wildcard/i.test(x.title || ''));
|
|
assert.ok(f);
|
|
assert.match(String(f.evidence || ''), /mcp__\*/);
|
|
assert.match(String(f.evidence || ''), /B\*/);
|
|
assert.doesNotMatch(String(f.evidence || ''), /puppeteer/); // anchored MCP glob is valid
|
|
assert.doesNotMatch(String(f.evidence || ''), /npm run/); // specifier glob is valid
|
|
});
|
|
|
|
it('clean settings (no unanchored globs) → no ineffective-allow finding', async () => {
|
|
const result = await runScanner('healthy-project');
|
|
const f = result.findings.find(x => /ineffective allow wildcard/i.test(x.title || ''));
|
|
assert.equal(f, undefined, `expected no ineffective-allow finding; got: ${f?.title}`);
|
|
});
|
|
});
|
|
|
|
describe('DIS scanner — forbidden-param rules CC silently ignores (Tool(param:value) on a canonicalizing field)', () => {
|
|
// fixture forbidden-param-permissions/.claude/settings.json:
|
|
// allow: Read(file_path:/etc/passwd), Bash(npm:*), WebFetch(domain:good.com)
|
|
// deny: Bash(command:rm *), Grep(path:/secrets), Agent(model:opus)
|
|
// ask: WebFetch(url:http://evil.com)
|
|
// CC ignores command:/file_path:/path:/url: and emits a startup warning.
|
|
// deny/ask hits = false security (medium); allow hits = dead config (low).
|
|
// Bash(npm:*), WebFetch(domain:good.com), Agent(model:opus) are VALID — never flagged.
|
|
|
|
it('flags deny/ask forbidden-param rules with MEDIUM severity (false security)', async () => {
|
|
const result = await runScanner('forbidden-param-permissions');
|
|
const f = result.findings.find(x => /silently ignored.*deny\/ask|deny\/ask.*forbidden param/i.test(x.title || ''));
|
|
assert.ok(f, `expected a medium forbidden-param finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
|
|
assert.equal(f.severity, 'medium', `expected medium, got ${f.severity}`);
|
|
assert.match(f.id, /^CA-DIS-\d{3}$/);
|
|
});
|
|
|
|
it('medium evidence cites the deny + ask forbidden entries, not the valid ones', async () => {
|
|
const result = await runScanner('forbidden-param-permissions');
|
|
const f = result.findings.find(x => /silently ignored.*deny\/ask|deny\/ask.*forbidden param/i.test(x.title || ''));
|
|
assert.ok(f);
|
|
assert.match(String(f.evidence || ''), /command:/); // Bash(command:rm *)
|
|
assert.match(String(f.evidence || ''), /path:/); // Grep(path:/secrets)
|
|
assert.match(String(f.evidence || ''), /url:/); // WebFetch(url:...) from ask
|
|
assert.doesNotMatch(String(f.evidence || ''), /Agent/); // model:opus is valid
|
|
});
|
|
|
|
it('flags allow forbidden-param rules with LOW severity (dead config)', async () => {
|
|
const result = await runScanner('forbidden-param-permissions');
|
|
const f = result.findings.find(x => /silently ignored.*allow|allow.*forbidden param/i.test(x.title || ''));
|
|
assert.ok(f, `expected a low forbidden-param finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
|
|
assert.equal(f.severity, 'low', `expected low, got ${f.severity}`);
|
|
assert.match(String(f.evidence || ''), /file_path:/); // Read(file_path:/etc/passwd)
|
|
assert.doesNotMatch(String(f.evidence || ''), /npm:/); // Bash(npm:*) is valid
|
|
assert.doesNotMatch(String(f.evidence || ''), /domain:/); // WebFetch(domain:...) is valid
|
|
});
|
|
|
|
it('valid param/specifier syntax produces no forbidden-param finding', async () => {
|
|
const result = await runScanner('param-qualified-permissions');
|
|
const f = result.findings.find(x => /forbidden param|silently ignored/i.test(x.title || ''));
|
|
assert.equal(f, undefined, `expected no forbidden-param finding for valid syntax; got: ${f?.title}`);
|
|
});
|
|
});
|
|
|
|
describe('DIS scanner — deny-all glob Tool(*) kills a bare allow (end-to-end)', () => {
|
|
// settings.json: allow: ["Bash"], deny: ["Bash(*)"]
|
|
// Bash(*) deny ≡ bare Bash deny → the bare Bash allow is dead config.
|
|
it('flags the bare Bash allow as dead under a Bash(*) deny', async () => {
|
|
const result = await runScanner('deny-all-glob');
|
|
const f = result.findings.find(x => /both permissions\.deny and permissions\.allow/i.test(x.title || ''));
|
|
assert.ok(f, `expected the bare Bash allow to be flagged dead; got: ${result.findings.map(x => x.title).join(' | ')}`);
|
|
assert.match(String(f.evidence || ''), /Bash/);
|
|
});
|
|
});
|