fix(permissions): param-aware DIS dead-allow + CNF conflict matching
The DIS scanner collapsed Tool(param) rules to the bare tool name, so Agent(model:opus) deny + Agent(model:sonnet) allow (and the same for WebFetch(domain:...)) were flagged as dead config — a false positive now that CC 2.1.178 matches Tool(param:value) and 2.1.172 adds domain rules. The conflict-detector shared the blind spot from the other side: a wildcard deny like WebFetch(domain:*) did not cover a WebFetch(domain:good.com) allow, so a genuine cross-scope conflict was missed (false negative). New shared scanners/lib/permission-rules.mjs: - parseRule / paramMatches (glob) - dominates(deny, allow) -> DIS dead-allow (deny fully covers allow) - rulesIntersect(a, b) -> CNF cross-scope conflict (match sets intersect) DIS now delegates to dominates; conflict-detector :156 delegates to rulesIntersect. A bare deny still covers all params, so true positives are preserved (Bash deny + Bash(npm:*) allow still flagged). Re-seeded the marketplace-medium snapshots: the false-positive CA-DIS finding (Read(src/**) allow + Read(./.env) deny) is correctly gone. This changes snapshot CONTENT only — envelope schema is unchanged, so --json and --raw stay byte-stable. Full suite: 837/837 green (+25). self-audit PASS, A(100)/A(97).
This commit is contained in:
parent
8216fb4175
commit
bec3f45329
16 changed files with 324 additions and 145 deletions
|
|
@ -10,24 +10,13 @@ import { finding, scannerResult } from './lib/output.mjs';
|
|||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseJson } from './lib/yaml-parser.mjs';
|
||||
import { truncate } from './lib/string-utils.mjs';
|
||||
import { rulesIntersect } from './lib/permission-rules.mjs';
|
||||
|
||||
const SCANNER = 'CNF';
|
||||
|
||||
// Keys checked separately or not meaningful to compare
|
||||
const SKIP_KEYS = new Set(['$schema', 'hooks', 'permissions']);
|
||||
|
||||
/**
|
||||
* Extract the tool name prefix from a permission rule.
|
||||
* e.g., "Bash(npm run *)" → "Bash", "Read(src/**)" → "Read"
|
||||
* @param {string} rule
|
||||
* @returns {{ tool: string, pattern: string }}
|
||||
*/
|
||||
function parsePermissionRule(rule) {
|
||||
const match = rule.match(/^(\w+)\((.+)\)$/);
|
||||
if (match) return { tool: match[1], pattern: match[2] };
|
||||
return { tool: rule, pattern: '*' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten an object's top-level keys into a simple key→value map.
|
||||
* Only first level — we compare top-level settings, not nested.
|
||||
|
|
@ -150,10 +139,8 @@ export async function scan(targetPath, discovery) {
|
|||
|
||||
// Check: allow in A, deny in B (and vice versa)
|
||||
for (const allowRule of aAllow) {
|
||||
const { tool: aTool, pattern: aPattern } = parsePermissionRule(allowRule);
|
||||
for (const denyRule of bDeny) {
|
||||
const { tool: dTool, pattern: dPattern } = parsePermissionRule(denyRule);
|
||||
if (aTool === dTool && (aPattern === dPattern || aPattern === '*' || dPattern === '*')) {
|
||||
if (rulesIntersect(allowRule, denyRule)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
|
|
@ -169,10 +156,8 @@ export async function scan(targetPath, discovery) {
|
|||
|
||||
// Reverse: allow in B, deny in A
|
||||
for (const allowRule of bAllow) {
|
||||
const { tool: bTool, pattern: bPattern } = parsePermissionRule(allowRule);
|
||||
for (const denyRule of aDeny) {
|
||||
const { tool: dTool, pattern: dPattern } = parsePermissionRule(denyRule);
|
||||
if (bTool === dTool && (bPattern === dPattern || bPattern === '*' || dPattern === '*')) {
|
||||
if (rulesIntersect(allowRule, denyRule)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue