diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js index 6ec294453..c5b90d048 100644 --- a/scripts/lib/powershell-destructive-command.js +++ b/scripts/lib/powershell-destructive-command.js @@ -423,18 +423,22 @@ function currentClause(prefix) { return prefix.slice(clauseStart + 1).trim(); } -function invokesContainerResult(prefix) { +function invokesContainerResult(prefix, options = {}) { const clause = currentClause(prefix); const pipelineStart = clause.lastIndexOf('|'); const pipelineCommand = clause.slice(pipelineStart + 1).trim(); + const isForeachLoopHeader = Boolean(options.groupingExpression) && + pipelineStart === -1 && + /^foreach$/i.test(pipelineCommand); return /(?:^|\s)(?:&|\.)\s*$/.test(clause) || /\.\s*(?:foreach|where)\s*$/i.test(clause) || /-(?:action|begin|command|end|expression|filter|initializationscript|parallel|process|scriptblock)(?:\s*:\s*)?$/i.test(clause) || - /^(?:(?:[\w.-]+\\)?(?:foreach-object|where-object|foreach|where|invoke-command|start-job|measure-command)|%|\?)(?:\s|$)/i.test(pipelineCommand); + (!isForeachLoopHeader && + /^(?:(?:[\w.-]+\\)?(?:foreach-object|where-object|foreach|where|invoke-command|start-job|measure-command)|%|\?)(?:\s|$)/i.test(pipelineCommand)); } -function invokesDynamicResult(prefix) { - return invokesContainerResult(prefix) || +function invokesDynamicResult(prefix, options = {}) { + return invokesContainerResult(prefix, options) || /(?:^|\s)(?:iex|invoke-expression)\s*$/i.test(currentClause(prefix)); } @@ -850,6 +854,9 @@ function extractExecutableContainers(input, options = {}) { const withinDoubleQuote = quote === '"'; const prefix = context; + const invokesContainer = invokesContainerResult(prefix, { + groupingExpression: isGroupingExpression, + }); const invokedAfter = isInvokedAfterContainer(input, group.end); const createsScriptBlock = /\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create\s*$/i.test( currentClause(prefix) @@ -863,7 +870,7 @@ function extractExecutableContainers(input, options = {}) { options: { executeBareScriptBlocks: Boolean(options.executeBareScriptBlocks) || invokedAfter || executesNestedScriptBlocks || - (!isScriptBlock && invokesContainerResult(prefix)), + (!isScriptBlock && invokesContainer), }, }); } else { @@ -883,7 +890,7 @@ function extractExecutableContainers(input, options = {}) { } let resolvedCommand = null; if (!isScriptBlock) { - if (isSubexpression || invokesContainerResult(prefix)) { + if (isSubexpression || invokesContainer) { resolvedCommand = staticOutputResult(group.body); if (resolvedCommand === null && isSubexpression) { const scalarReference = variableReference(group.body); @@ -904,16 +911,16 @@ function extractExecutableContainers(input, options = {}) { const executableBlockExpression = /\{|\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create/i.test( maskQuotedStrings(group.body) ); - if (!resolvedCommand && !isScriptBlock && invokesDynamicResult(prefix) && !executableBlockExpression) { + if (!resolvedCommand && !isScriptBlock && invokesDynamicResult(prefix, { + groupingExpression: isGroupingExpression, + }) && !executableBlockExpression) { resolvedCommand = DYNAMIC_EXECUTION_MARKER; } if (resolvedCommand) { - for (let offset = 0; offset < resolvedCommand.length; offset += 1) { - masked[index + offset] = resolvedCommand[offset]; - } + masked[index] = resolvedCommand; if (!withinDoubleQuote) appendContext(resolvedCommand); } else if (isScriptBlock) { - if (invokesContainerResult(prefix)) { + if (invokesContainer) { context = prefix; } else { resetContext(); diff --git a/tests/lib/powershell-destructive-command.test.js b/tests/lib/powershell-destructive-command.test.js index 43f01994a..16562ef2c 100644 --- a/tests/lib/powershell-destructive-command.test.js +++ b/tests/lib/powershell-destructive-command.test.js @@ -472,6 +472,56 @@ test('classifies invoked functions and filters across executable containers', () for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]); }); +test('distinguishes foreach statements from the pipeline alias', () => { + for (const command of [ + "foreach ($r in 'aaaaaaaaaaaaa') { $r }", + "foreach ($r in 'aaaaaaaaaaaaaa') { $r }", + "foreach ($s in 'BTCUSDT','ETHUSDT','SOLUSDT') { $s }", + ]) { + expectSafe(command); + } + + expectRules( + "foreach ($r in 'aaaaaaaaaaaaaa') { Remove-Item -Force C:/tmp/demo }", + [RULES.REMOVE_FORCE] + ); + expectRules('1 | foreach { Remove-Item -Force C:/tmp/demo }', [ + RULES.REMOVE_FORCE, + ]); + expectRules('1 | foreach ({ Remove-Item -Force C:/tmp/demo })', [ + RULES.REMOVE_FORCE, + ]); +}); + +test('foreach headers and nested bodies retain destructive scanning', () => { + for (const command of [ + 'foreach ($x in $(Remove-Item -Force C:/tmp/demo)) { $x }', + 'foreach ($x in (Remove-Item -Force C:/tmp/demo)) { $x }', + 'FoReAcH \n ($x in @("a")) { Remove-Item -Force C:/tmp/demo }', + 'Get-Date; foreach ($x in "a") { foreach ($y in "b") { Remove-Item -Force C:/tmp/demo } }', + ]) expectRules(command, [RULES.REMOVE_FORCE]); + for (const command of [ + 'FoReAcH \n ($x in @("a", "b")) { $x }', + 'Get-Date; foreach ($x in "a") { $x }', + 'foreach ($x in @({ Remove-Item -Force C:/tmp/demo })) { $x }', + 'Write-Output "foreach ($x in Remove-Item -Force C:/tmp/demo)"', + ]) expectSafe(command); +}); + +test('short dynamic groups preserve following command boundaries', () => { + for (const command of [ + 'iex ($a)\nRemove-Item -Force C:/tmp/demo', + 'iex ($a) | Remove-Item -Force C:/tmp/demo', + '& ($a); iex ($b); Remove-Item -Force C:/tmp/demo', + ]) expectRules(command, [RULES.DYNAMIC_EXECUTION, RULES.REMOVE_FORCE]); +}); + +test('resolved scalar output longer than its group preserves following commands', () => { + expectRules("$name='Remove-Item'; & $($name) -Force C:/tmp/demo; Clear-Content C:/tmp/demo", [ + RULES.REMOVE_FORCE, RULES.CLEAR_CONTENT, + ]); +}); + test('classifies invoked static script-block variables but leaves assignments inert', () => { expectSafe('$cleanup = { Remove-Item -Force C:/tmp/demo }'); expectRules('$cleanup = { Remove-Item -Force C:/tmp/demo }; & $cleanup', [ @@ -620,6 +670,13 @@ test('classifies static execution primitives', () => { expectRules('& (Get-Command Remove-Item) -Force C:/tmp/demo', [ RULES.DYNAMIC_EXECUTION, ]); + for (const command of [ + 'iex ($a); Remove-Item -Force C:/tmp/demo', + 'Invoke-Expression ($a); Remove-Item -Force C:/tmp/demo', + '& ($a); Remove-Item -Force C:/tmp/demo', + ]) { + expectRules(command, [RULES.DYNAMIC_EXECUTION, RULES.REMOVE_FORCE]); + } expectRules("iex ('Remove-'+'Item -Force C:/tmp/demo')", [ RULES.DYNAMIC_EXECUTION, ]);