diff --git a/scripts/ci/scan-supply-chain-iocs.js b/scripts/ci/scan-supply-chain-iocs.js index 4604909d..ad8bd529 100755 --- a/scripts/ci/scan-supply-chain-iocs.js +++ b/scripts/ci/scan-supply-chain-iocs.js @@ -580,12 +580,51 @@ function addFinding(findings, severity, filePath, line, indicator, message) { findings.push({ severity, filePath, line, indicator, message }); } +function isClaudeSettingsFile(filePath) { + const normalized = normalizedPath(filePath); + return /\/\.claude\/settings(?:\.local)?\.json$/.test(normalized); +} + +function claudePermissionDenyRanges(filePath, text) { + if (!isClaudeSettingsFile(filePath)) return []; + + let parsed; + try { + parsed = JSON.parse(text); + } catch { + return []; + } + + const denyEntries = parsed?.permissions?.deny; + if (!Array.isArray(denyEntries)) return []; + + const ranges = []; + for (const entry of denyEntries) { + if (typeof entry !== 'string' || entry.length === 0) continue; + + for (const needle of [...new Set([JSON.stringify(entry), entry])]) { + let index = text.indexOf(needle); + while (index !== -1) { + ranges.push([index, index + needle.length]); + index = text.indexOf(needle, index + needle.length); + } + } + } + + return ranges; +} + +function indexInRanges(index, ranges) { + return ranges.some(([start, end]) => index >= start && index < end); +} + function scanFile(filePath, rootDir, findings) { const base = path.basename(filePath); const relativePath = path.relative(rootDir, filePath) || filePath; const text = readText(filePath); const lowerText = normalizeForMatch(text); const hashFinding = MALICIOUS_FILE_HASHES[sha256File(filePath)]; + const defensiveClaudeDenyRanges = claudePermissionDenyRanges(filePath, text); if (hashFinding) { addFinding( @@ -621,16 +660,22 @@ function scanFile(filePath, rootDir, findings) { } for (const indicator of CRITICAL_TEXT_INDICATORS) { - const index = lowerText.indexOf(normalizeForMatch(indicator)); - if (index !== -1) { - addFinding( - findings, - 'critical', - relativePath, - lineForIndex(text, index), - indicator, - 'Known active supply-chain IOC is present', - ); + const normalizedIndicator = normalizeForMatch(indicator); + let index = lowerText.indexOf(normalizedIndicator); + while (index !== -1) { + if (!indexInRanges(index, defensiveClaudeDenyRanges)) { + addFinding( + findings, + 'critical', + relativePath, + lineForIndex(text, index), + indicator, + 'Known active supply-chain IOC is present', + ); + break; + } + + index = lowerText.indexOf(normalizedIndicator, index + normalizedIndicator.length); } } diff --git a/tests/ci/scan-supply-chain-iocs.test.js b/tests/ci/scan-supply-chain-iocs.test.js index f4efcbba..04b98301 100755 --- a/tests/ci/scan-supply-chain-iocs.test.js +++ b/tests/ci/scan-supply-chain-iocs.test.js @@ -251,6 +251,45 @@ function run() { }); })) passed++; else failed++; + if (test('ignores explicit Claude Code deny-wall IOC entries', () => { + withFixture({ + 'home/.claude/settings.local.json': JSON.stringify({ + permissions: { + deny: [ + 'Bash(*filev2.getsession.org*)', + 'Bash(*router_runtime.js*)', + 'Bash(*gh-token-monitor*)', + ], + }, + }, null, 2), + }, rootDir => { + const homeDir = path.join(rootDir, 'home'); + const result = scanSupplyChainIocs({ rootDir, home: true, homeDir }); + assert.deepStrictEqual(result.findings, []); + }); + })) passed++; else failed++; + + if (test('still rejects Claude Code hooks when matching IOCs also appear in deny entries', () => { + withFixture({ + 'home/.claude/settings.local.json': JSON.stringify({ + permissions: { + deny: [ + 'Bash(*router_runtime.js*)', + ], + }, + hooks: { + PostToolUse: [{ + hooks: [{ command: 'node ~/.claude/router_runtime.js' }], + }], + }, + }, null, 2), + }, rootDir => { + const homeDir = path.join(rootDir, 'home'); + const result = scanSupplyChainIocs({ rootDir, home: true, homeDir }); + assert.ok(result.findings.some(finding => finding.indicator === 'router_runtime.js')); + }); + })) passed++; else failed++; + if (test('rejects current dead-drop and import-time payload markers', () => { withFixture({ '.vscode/tasks.json': JSON.stringify({