diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js index f99216016..77f3ac095 100644 --- a/scripts/lib/powershell-destructive-command.js +++ b/scripts/lib/powershell-destructive-command.js @@ -1164,6 +1164,13 @@ function createScanState() { function collectStaticScalarAssignments(input, state) { const variable = String.raw`(\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.[A-Za-z_][\w-]*)*)`; + const firstReferences = new Map(); + const referencePattern = new RegExp(variable, 'g'); + let reference; + while ((reference = referencePattern.exec(input)) !== null) { + const name = reference[1].toLowerCase(); + if (!firstReferences.has(name)) firstReferences.set(name, reference.index); + } const assignmentCounts = new Map(); const assignmentPattern = new RegExp(`${variable}\\s*(?:\\+=|-=|\\*=|\\/=|%=|=)`, 'g'); let assignmentMatch; @@ -1177,11 +1184,18 @@ function collectStaticScalarAssignments(input, state) { ); let match; while ((match = pattern.exec(input)) !== null) { + const name = match[1].toLowerCase(); + // The scan pre-collects immutable scalars for nested executable bodies. + // A value assigned after an earlier reference cannot explain that use. + // Keep it unresolved so dynamic execution remains gated. Counting even + // quoted references is deliberately conservative, with a linear scan. + const assignmentIndex = match.index + match[0].indexOf(match[1]); + if (firstReferences.get(name) !== assignmentIndex) continue; if (match[3] !== undefined && /(^|[^`])\$/.test(match[3])) continue; const value = match[2] !== undefined ? match[2].replace(/''/g, "'") : decodeDoubleQuotedString(match[3]); - state.staticScalars.set(match[1].toLowerCase(), value); + state.staticScalars.set(name, value); } for (const [name, count] of assignmentCounts) { if (count !== 1) state.staticScalars.delete(name); diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 3735db1d9..cb173c2b4 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -2991,7 +2991,8 @@ function runTests() { 'cmd /c pwsh -Command "Remove-Item -Force C:/tmp/demo"', '@"\n" # $(Remove-Item -Force C:/tmp/demo)\n"@', '& ‘Remove-Item’ -Force C:/tmp/demo', - 'Invoke-Expression $runtimeValue' + 'Invoke-Expression $runtimeValue', + 'pwsh -Command "$payload"; $payload = "Write-Output ok"' ]; for (const command of commands) { diff --git a/tests/hooks/governance-capture.test.js b/tests/hooks/governance-capture.test.js index 9ee30fed3..c7cc46c52 100644 --- a/tests/hooks/governance-capture.test.js +++ b/tests/hooks/governance-capture.test.js @@ -251,6 +251,10 @@ async function runTests() { command: 'pwsh -Command "Write-Output ready; $runtimePayload"', expectedRules: ['powershell.dynamic-execution'], }, + { + command: 'pwsh -Command "$payload"; $payload = "Write-Output ok"', + expectedRules: ['powershell.dynamic-execution'], + }, { command: 'pwsh -Command $runtimePayload -Force C:/private/runtime-command-sentinel', expectedRules: ['powershell.dynamic-execution'], diff --git a/tests/lib/powershell-destructive-command.test.js b/tests/lib/powershell-destructive-command.test.js index 569cbb18d..7a4ae31cf 100644 --- a/tests/lib/powershell-destructive-command.test.js +++ b/tests/lib/powershell-destructive-command.test.js @@ -190,6 +190,22 @@ test('classifies pipeline recursion evidence upstream of Remove-Item', () => { console.log('\nNested shell payloads:'); +test('does not resolve earlier invocations from later scalar assignments', () => { + for (const invocation of [ + 'pwsh -Command "$payload"', + 'pwsh -Command:$payload', + 'pwsh -EncodedCommand:$payload', + 'Invoke-Expression $payload', + '& $payload', + ]) { + expectRules(`${invocation}; $payload = 'Write-Output ok'`, [RULES.DYNAMIC_EXECUTION]); + } + expectRules('pwsh -Command "$payload"; $payload = "Remove-Item -Force C:/tmp/demo"', [ + RULES.DYNAMIC_EXECUTION, + ]); + expectSafe('$payload = "Write-Output ok"; pwsh -Command "$payload"'); +}); + test('classifies powershell and pwsh command payloads recursively', () => { expectRules( 'powershell -Command "Remove-Item -Recurse C:/tmp/demo"',