fix: prevent IOC scanner false positives on hook filenames and scan .cursor configs (#2245)

* fix: prevent IOC scanner false positives on hook filenames and scan .cursor configs

The supply-chain IOC scanner matched CRITICAL_TEXT_INDICATORS with plain
substring search, so legitimate hook filenames that merely end with a known
payload name (e.g. the stock Cursor hook before-shell-execution.js vs the
payload execution.js) were flagged as CRITICAL. Indicator matching now
requires a non-filename character before the match.

Also add .cursor/ to the special config paths so Cursor hooks.json files
(a known persistence vector already listed in PERSISTENCE_FILENAMES) are
actually inspected in normal checkouts - previously they were only scanned
by accident when the repo path happened to contain /.claude/.

* test: cover underscore-prefixed filenames in IOC boundary suppression

Make explicit that '_' is treated as a filename word character, so
snake_case hook names like post_execution.js are intentionally not
flagged by the execution.js indicator (real payload references appear
after '/', quotes, or whitespace).
This commit is contained in:
daiki75 2026-06-16 02:48:50 +09:00 committed by GitHub
parent d293941643
commit e3f18d2376
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 51 additions and 6 deletions

View File

@ -414,6 +414,7 @@ function normalizeForMatch(value) {
function isInSpecialConfigPath(filePath) { function isInSpecialConfigPath(filePath) {
const normalized = normalizedPath(filePath); const normalized = normalizedPath(filePath);
return /\/\.claude\//.test(normalized) return /\/\.claude\//.test(normalized)
|| /\/\.cursor\//.test(normalized)
|| /\/\.vscode\//.test(normalized) || /\/\.vscode\//.test(normalized)
|| /\/\.kiro\/settings\//.test(normalized) || /\/\.kiro\/settings\//.test(normalized)
|| /\/Library\/LaunchAgents\//.test(normalized) || /\/Library\/LaunchAgents\//.test(normalized)
@ -661,21 +662,26 @@ function scanFile(filePath, rootDir, findings) {
for (const indicator of CRITICAL_TEXT_INDICATORS) { for (const indicator of CRITICAL_TEXT_INDICATORS) {
const normalizedIndicator = normalizeForMatch(indicator); const normalizedIndicator = normalizeForMatch(indicator);
let index = lowerText.indexOf(normalizedIndicator); // Require a non-filename character before the indicator so legitimate
while (index !== -1) { // names that merely end with an IOC filename (e.g. the stock Cursor hook
if (!indexInRanges(index, defensiveClaudeDenyRanges)) { // `before-shell-execution.js` vs the payload `execution.js`) do not match.
const indicatorPattern = new RegExp(
`(?<![a-z0-9_-])${escapeRegExp(normalizedIndicator)}`,
'g',
);
let match;
while ((match = indicatorPattern.exec(lowerText)) !== null) {
if (!indexInRanges(match.index, defensiveClaudeDenyRanges)) {
addFinding( addFinding(
findings, findings,
'critical', 'critical',
relativePath, relativePath,
lineForIndex(text, index), lineForIndex(text, match.index),
indicator, indicator,
'Known active supply-chain IOC is present', 'Known active supply-chain IOC is present',
); );
break; break;
} }
index = lowerText.indexOf(normalizedIndicator, index + normalizedIndicator.length);
} }
} }

View File

@ -310,6 +310,45 @@ function run() {
}); });
})) passed++; else failed++; })) passed++; else failed++;
if (test('does not flag legitimate hook filenames ending in an IOC filename', () => {
withFixture({
'.cursor/hooks.json': JSON.stringify({
hooks: {
beforeShellExecution: [{
command: 'node .cursor/hooks/before-shell-execution.js',
}],
afterShellExecution: [{
command: 'node .cursor/hooks/after-shell-execution.js',
}],
beforeMCPExecution: [{
command: 'node .cursor/hooks/before-mcp-execution.js',
}],
afterFileEdit: [{
command: 'node .cursor/hooks/post_execution.js',
}],
},
}, null, 2),
}, rootDir => {
const result = scanSupplyChainIocs({ rootDir });
assert.deepStrictEqual(result.findings, []);
});
})) passed++; else failed++;
if (test('still flags the bare execution.js payload after a path separator', () => {
withFixture({
'.cursor/hooks.json': JSON.stringify({
hooks: {
sessionStart: [{
command: 'node .cursor/hooks/execution.js',
}],
},
}, null, 2),
}, rootDir => {
const result = scanSupplyChainIocs({ rootDir });
assert.ok(result.findings.some(finding => finding.indicator === 'execution.js'));
});
})) passed++; else failed++;
if (test('rejects user-level VS Code task persistence when home scan is enabled', () => { if (test('rejects user-level VS Code task persistence when home scan is enabled', () => {
withFixture({ withFixture({
'home/Library/Application Support/Code/User/tasks.json': JSON.stringify({ 'home/Library/Application Support/Code/User/tasks.json': JSON.stringify({