From 61c230c1638f7e7754d2389a5ff26be87d985d93 Mon Sep 17 00:00:00 2001 From: Dante Date: Wed, 23 Sep 2026 00:52:28 +0800 Subject: [PATCH] fix: distinguish PowerShell foreach statements --- scripts/lib/powershell-destructive-command.js | 28 +++++++++++-------- .../powershell-destructive-command.test.js | 25 +++++++++++++++++ 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js index 6ec294453..8fad906a5 100644 --- a/scripts/lib/powershell-destructive-command.js +++ b/scripts/lib/powershell-destructive-command.js @@ -423,18 +423,21 @@ 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) && + /^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 +853,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 +869,7 @@ function extractExecutableContainers(input, options = {}) { options: { executeBareScriptBlocks: Boolean(options.executeBareScriptBlocks) || invokedAfter || executesNestedScriptBlocks || - (!isScriptBlock && invokesContainerResult(prefix)), + (!isScriptBlock && invokesContainer), }, }); } else { @@ -883,7 +889,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 +910,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..cd023a3c1 100644 --- a/tests/lib/powershell-destructive-command.test.js +++ b/tests/lib/powershell-destructive-command.test.js @@ -472,6 +472,24 @@ 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, + ]); +}); + 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 +638,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, ]);