diff --git a/scripts/hooks/governance-capture.js b/scripts/hooks/governance-capture.js index 2d161d232..5f75baeaf 100644 --- a/scripts/hooks/governance-capture.js +++ b/scripts/hooks/governance-capture.js @@ -133,6 +133,13 @@ function summarizeCommand(command) { }; } + if (trimmed.startsWith("'") || trimmed.startsWith('"')) { + return { + commandName: null, + commandFingerprint: fingerprintCommand(trimmed), + }; + } + const firstToken = trimmed.split(/\s+/)[0] || ''; // Static method invocations can attach their arguments to the first token, // for example `[IO.File]::Delete('private-path')`. Keep the operation name diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js index fe8d1d43d..de04c2872 100644 --- a/scripts/lib/powershell-destructive-command.js +++ b/scripts/lib/powershell-destructive-command.js @@ -28,6 +28,7 @@ const RULE_IDS = Object.freeze({ const DELETE_COMMANDS = new Set([ 'remove-item', 'remove-itemproperty', + 'rp', 'ri', 'rm', 'rmdir', @@ -507,6 +508,33 @@ function staticStringResult(body) { return quote === "'" ? content.replace(/''/g, "'") : content.replace(/`(.)/gs, '$1'); } +function leadingStaticStringResult(source) { + const input = String(source || ''); + let index = 0; + while (/\s/.test(input[index] || '')) index += 1; + const quote = input[index]; + if (quote !== "'" && quote !== '"') return null; + index += 1; + let value = ''; + while (index < input.length) { + const char = input[index]; + if (quote === "'" && char === "'" && input[index + 1] === "'") { + value += "'"; + index += 2; + continue; + } + if (quote === '"' && char === '`' && index + 1 < input.length) { + value += input[index + 1]; + index += 2; + continue; + } + if (char === quote) return value; + value += char; + index += 1; + } + return null; +} + function staticScalarResult(body, depth = 0) { if (depth > MAX_SCAN_DEPTH) return null; const value = String(body || '').trim(); @@ -1083,8 +1111,19 @@ function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upst } if (isCommandFlag(token)) { - const payload = tokens.slice(index + 1).join(' '); + let payload = tokens.slice(index + 1).join(' '); const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens) : null; + const payloadReference = tokens.quotedTokens?.[index + 1] === true + ? null + : variableReference(payload); + if (payloadReference) { + const staticValue = scanState?.staticScalars.get(payloadReference); + if (staticValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return; + } + payload = staticValue; + } if (pipelinePayload || (payload && payload !== '-')) { addNestedScan( pipelinePayload || payload, @@ -1558,8 +1597,7 @@ function scanInvokeScriptCalls(source, unquoted, depth, findings, analysis, stat const pattern = /\$executioncontext\.invokecommand\.invokescript\s*\(/gi; while (pattern.exec(unquoted) !== null) { const argumentSource = source.slice(pattern.lastIndex); - const literal = argumentSource.match(/^\s*(?:'(?:''|[^'])*'|"(?:`[\s\S]|[^"])*")/); - const payload = literal ? staticStringResult(literal[0].trim()) : null; + const payload = leadingStaticStringResult(argumentSource); if (payload === null) { findings.add(RULE_IDS.DYNAMIC_EXECUTION); } else { diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index f62b5c803..a92df0b0c 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -2964,8 +2964,10 @@ function runTests() { test('denies direct and nested destructive PowerShell commands', () => { const commands = [ 'Remove-Item -Recurse C:/tmp/demo', + 'rp -Force HKCU:/Software/Demo -Name setting', 'Clear-Disk -Number 2 -RemoveData -Confirm:$false', 'pwsh -Command "Remove-Item -Force C:/tmp/demo"', + "$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command $payload", 'Write-Output "$(Remove-Item -Force C:/tmp/demo)"', '& { Remove-Item -Force C:/tmp/demo }', 'if ($true) { Remove-Item -Force C:/tmp/demo }', diff --git a/tests/hooks/governance-capture.test.js b/tests/hooks/governance-capture.test.js index 528c593e6..2ab6e6099 100644 --- a/tests/hooks/governance-capture.test.js +++ b/tests/hooks/governance-capture.test.js @@ -325,7 +325,7 @@ async function runTests() { })) passed += 1; else failed += 1; if (await test('PowerShell governance normalizes tool casing and redacts assignment prefixes', async () => { - const command = "$password='governance-secret-sentinel'; Remove-Item -Force C:/tmp/demo"; + const command = "$label='governance-private-marker'; Remove-Item -Force C:/tmp/demo"; for (const toolName of ['PowerShell', 'powershell', 'POWERSHELL']) { const events = analyzeForGovernanceEvents({ tool_name: toolName, @@ -337,10 +337,24 @@ async function runTests() { assert.ok(approvalEvent, `${toolName} should raise approval_requested`); assert.strictEqual(approvalEvent.payload.toolName, 'PowerShell'); assert.strictEqual(approvalEvent.payload.commandName, null); - assert.ok(!JSON.stringify(events).includes('governance-secret-sentinel')); + assert.ok(!JSON.stringify(events).includes('governance-private-marker')); } })) passed += 1; else failed += 1; + if (await test('PowerShell governance redacts quoted expression prefixes', async () => { + const command = "'quoted-private-marker' ; Remove-Item -Force C:/tmp/demo"; + const events = analyzeForGovernanceEvents({ + tool_name: 'PowerShell', + tool_input: { command }, + }, { + hookPhase: 'pre', + }); + const approvalEvent = events.find(event => event.eventType === 'approval_requested'); + assert.ok(approvalEvent, 'quoted prefix should still raise approval_requested'); + assert.strictEqual(approvalEvent.payload.commandName, null); + assert.ok(!JSON.stringify(events).includes('quoted-private-marker')); + })) passed += 1; else failed += 1; + if (await test('PowerShell elevation events are captured without raw command leakage', async () => { const commands = [ 'Start-Process -Verb RunAs cmd -ArgumentList elevation-command-sentinel', diff --git a/tests/lib/powershell-destructive-command.test.js b/tests/lib/powershell-destructive-command.test.js index 0589c0225..5e3592676 100644 --- a/tests/lib/powershell-destructive-command.test.js +++ b/tests/lib/powershell-destructive-command.test.js @@ -98,6 +98,7 @@ test('classifies recursive Remove-Item aliases', () => { for (const alias of ['ri', 'rm', 'rmdir', 'rd', 'del', 'erase']) { expectRules(`${alias} -Recurse C:/tmp/demo`, [RULES.REMOVE_RECURSE]); } + expectRules('rp -Force HKCU:/Software/Demo -Name setting', [RULES.REMOVE_FORCE]); expectRules('Remove-ItemProperty -Force HKCU:/Software/Demo -Name setting', [ RULES.REMOVE_FORCE, ]); @@ -455,10 +456,13 @@ test('classifies static execution primitives', () => { "$cmd = 'Remove-Item -Force C:/tmp/demo'; & ([scriptblock]::Create($cmd))", "$name = 'Remove-Item'; & $name -Force C:/tmp/demo", "$args = '-Command \"Remove-Item -Force C:/tmp/demo\"'; Start-Process pwsh -ArgumentList $args", + "$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command $payload", ]) { expectRules(command, [RULES.REMOVE_FORCE]); } expectRules('Invoke-Expression $runtimeValue', [RULES.DYNAMIC_EXECUTION]); + expectRules('pwsh -Command $runtimeValue', [RULES.DYNAMIC_EXECUTION]); + expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command '$payload'"); expectRules('Start-Process pwsh -ArgumentList $runtimeArgs', [RULES.DYNAMIC_EXECUTION]); expectRules("$cmd='Remove-'; $cmd+='Item'; & $cmd -Force C:/tmp/demo", [ RULES.DYNAMIC_EXECUTION, @@ -492,6 +496,13 @@ test('classifies static execution primitives', () => { ); }); +test('scans malformed InvokeScript string arguments in bounded time', () => { + const command = `$ExecutionContext.InvokeCommand.InvokeScript("${'`!'.repeat(10000)}`; + const startedAt = Date.now(); + expectRules(command, [RULES.DYNAMIC_EXECUTION]); + assert.ok(Date.now() - startedAt < 1000, 'malformed string scan should remain bounded'); +}); + test('classifies command names composed from static subexpression output', () => { expectRules('Remove-$(Write-Output Item) -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); expectRules('Clear-$(echo Disk) -Number 2', [RULES.CLEAR_DISK]);