diff --git a/docs/security/ecc-039-powershell-gateguard-plan.md b/docs/security/ecc-039-powershell-gateguard-plan.md index bac9f446a..03c971b02 100644 --- a/docs/security/ecc-039-powershell-gateguard-plan.md +++ b/docs/security/ecc-039-powershell-gateguard-plan.md @@ -201,7 +201,7 @@ for static variables embedded in nested double-quoted command payloads. - Focused classifier and hook suites: 531 passed, 0 failed. - Full repository suite: 4,217 passed, 0 failed. -- Coverage gate: passed at 89.23% statements, 81.29% branches, 94.56% +- Coverage gate: passed at 89.23% statements, 81.28% branches, 94.56% functions, and 89.23% lines. - Supply-chain IOC scan: passed for all 224 inspected files. - ESLint, Markdown lint, hook validation, personal-path validation, and diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js index a2da868ba..9f0672163 100644 --- a/scripts/lib/powershell-destructive-command.js +++ b/scripts/lib/powershell-destructive-command.js @@ -520,7 +520,7 @@ function decodeDoubleQuotedString(content) { const escaped = input[index + 1]; index += 1; if (escaped === '\r' && input[index + 1] === '\n') index += 1; - else if (escaped !== '\n') value += escaped; + if (escaped !== '\r' && escaped !== '\n') value += escaped; } return value; } @@ -535,7 +535,7 @@ function expandStaticDoubleQuotedString(content, state, findings) { const escaped = input[index + 1]; index += 1; if (escaped === '\r' && input[index + 1] === '\n') index += 1; - else if (escaped !== '\n') value += escaped; + if (escaped !== '\r' && escaped !== '\n') value += escaped; continue; } if (char !== '$') { @@ -596,7 +596,7 @@ function leadingStaticStringResult(source) { const escaped = input[index + 1]; index += 2; if (escaped === '\r' && input[index] === '\n') index += 1; - else if (escaped !== '\n') value += escaped; + if (escaped !== '\r' && escaped !== '\n') value += escaped; continue; } if (char === quote) return value; @@ -924,11 +924,14 @@ function parseStatements(input) { let segmentQuotedTokens = []; let segmentQuoteKinds = []; let segmentTokenSources = []; + let segmentInlineValueQuoteKinds = []; let word = ''; let wordSource = ''; let wordHasQuotedContent = false; let wordHasUnquotedContent = false; let wordQuoteKind = null; + let wordInlineValueQuoteKind = null; + let wordInlineValueQuoteClosed = false; let quote = null; let parenDepth = 0; let callOperatorPending = false; @@ -941,12 +944,19 @@ function parseStatements(input) { wordHasQuotedContent && !wordHasUnquotedContent ? wordQuoteKind : null ); segmentTokenSources.push(wordSource); + segmentInlineValueQuoteKinds.push( + wordInlineValueQuoteClosed && wordInlineValueQuoteKind !== 'mixed' + ? wordInlineValueQuoteKind + : null + ); } word = ''; wordSource = ''; wordHasQuotedContent = false; wordHasUnquotedContent = false; wordQuoteKind = null; + wordInlineValueQuoteKind = null; + wordInlineValueQuoteClosed = false; }; const flushSegment = () => { flushWord(); @@ -956,6 +966,7 @@ function parseStatements(input) { quotedTokens: { value: segmentQuotedTokens }, quoteKinds: { value: segmentQuoteKinds }, tokenSources: { value: segmentTokenSources }, + inlineValueQuoteKinds: { value: segmentInlineValueQuoteKinds }, }); statement.push(segment); callOperatorPending = false; @@ -964,6 +975,7 @@ function parseStatements(input) { segmentQuotedTokens = []; segmentQuoteKinds = []; segmentTokenSources = []; + segmentInlineValueQuoteKinds = []; }; const flushStatement = () => { flushSegment(); @@ -981,6 +993,7 @@ function parseStatements(input) { index += 1; } else if (char === "'") { quote = null; + if (wordInlineValueQuoteKind === "'") wordInlineValueQuoteClosed = true; } else { word += char; wordSource += char; @@ -1004,6 +1017,7 @@ function parseStatements(input) { index += 1; } } else { + if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; word += escaped; if (quote) wordHasQuotedContent = true; else wordHasUnquotedContent = true; @@ -1014,6 +1028,7 @@ function parseStatements(input) { if (quote === '"') { if (char === '"') { quote = null; + if (wordInlineValueQuoteKind === '"') wordInlineValueQuoteClosed = true; } else { word += char; wordSource += char; @@ -1023,6 +1038,11 @@ function parseStatements(input) { } if (char === "'" || char === '"') { + if (wordInlineValueQuoteClosed) { + wordInlineValueQuoteKind = 'mixed'; + } else if (wordInlineValueQuoteKind === null && /^-+[^:\s]+:$/.test(word)) { + wordInlineValueQuoteKind = char; + } quote = char; wordHasQuotedContent = true; wordQuoteKind = wordQuoteKind === null || wordQuoteKind === char ? char : 'mixed'; @@ -1030,6 +1050,7 @@ function parseStatements(input) { } if (char === '(') { + if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; parenDepth += 1; word += char; wordSource += char; @@ -1037,6 +1058,7 @@ function parseStatements(input) { continue; } if (char === ')' && parenDepth > 0) { + if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; parenDepth -= 1; word += char; wordSource += char; @@ -1062,6 +1084,7 @@ function parseStatements(input) { continue; } + if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; word += char; wordSource += char; wordHasUnquotedContent = true; @@ -1207,17 +1230,50 @@ function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upst const token = tokens[index]; if (isEncodedCommandFlag(token)) { - const decoded = decodeUtf16LeBase64(tokens[index + 1]); + const inlinePayload = parameterValue(token); + let encodedPayload = inlinePayload || tokens[index + 1]; + const payloadIndex = index + 1; + const quoteKind = tokens.quoteKinds?.[payloadIndex]; + const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index]; + if ((inlinePayload && inlineQuoteKind !== "'") || + (!inlinePayload && encodedPayload && quoteKind !== "'")) { + const source = inlinePayload + ? parameterValue(tokens.tokenSources?.[index] || token) + : tokens.tokenSources?.[payloadIndex] ?? encodedPayload; + const expanded = expandStaticDoubleQuotedString( + source || encodedPayload, + scanState, + findings + ); + if (expanded === null) return; + encodedPayload = expanded; + } + const decoded = decodeUtf16LeBase64(encodedPayload); if (decoded !== null) addNestedScan(decoded, depth, findings, analysis, {}, scanState); return; } if (isCommandFlag(token)) { - let payload = tokens.slice(index + 1).join(' '); + const inlinePayload = parameterValue(token); + let payload = inlinePayload + ? [inlinePayload, ...tokens.slice(index + 1)].join(' ') + : tokens.slice(index + 1).join(' '); const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens) : null; const payloadIndex = index + 1; - const hasOnePayloadToken = tokens.length === payloadIndex + 1; - if (hasOnePayloadToken && tokens.quoteKinds?.[payloadIndex] === '"') { + const hasOnePayloadToken = !inlinePayload && tokens.length === payloadIndex + 1; + const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index]; + if (inlinePayload && inlineQuoteKind !== "'") { + const inlineSource = parameterValue(tokens.tokenSources?.[index] || token); + const expanded = expandStaticDoubleQuotedString( + inlineSource || inlinePayload, + scanState, + findings + ); + if (expanded === null) return; + payload = [expanded, ...tokens.slice(index + 1)].join(' '); + } else if (inlinePayload) { + payload = [inlinePayload, ...tokens.slice(index + 1)].join(' '); + } else if (hasOnePayloadToken && tokens.quoteKinds?.[payloadIndex] !== "'") { const expanded = expandStaticDoubleQuotedString( tokens.tokenSources?.[payloadIndex] ?? payload, scanState, diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 9abb1899e..56af9349d 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -2962,11 +2962,17 @@ function runTests() { if ( test('denies direct and nested destructive PowerShell commands', () => { + const encodedPayload = Buffer.from( + 'Remove-Item -Force C:/tmp/demo', + 'utf16le' + ).toString('base64'); 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"', + 'pwsh -Command:"Remove-Item -Force C:/tmp/demo"', + `pwsh -EncodedCommand:${encodedPayload}`, "$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command $payload", "$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command \"$payload\"", "$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output ready; $payload\"", diff --git a/tests/hooks/governance-capture.test.js b/tests/hooks/governance-capture.test.js index e4b280e10..bf48483a1 100644 --- a/tests/hooks/governance-capture.test.js +++ b/tests/hooks/governance-capture.test.js @@ -239,6 +239,10 @@ async function runTests() { command: 'pwsh -Command "Remove-Item -Force C:/private/nested-command-sentinel"', expectedRules: ['powershell.remove-item.force'], }, + { + command: 'pwsh -Command:"Remove-Item -Force C:/private/inline-command-sentinel"', + expectedRules: ['powershell.remove-item.force'], + }, { command: "$payload='Remove-Item -Force C:/private/expanded-command-sentinel'; pwsh -Command \"Write-Output ready; $payload\"", expectedRules: ['powershell.remove-item.force'], @@ -251,6 +255,10 @@ async function runTests() { command: `pwsh -EncodedCommand ${encodedPayload}`, expectedRules: ['powershell.remove-item.wildcard'], }, + { + command: `pwsh -EncodedCommand:${encodedPayload}`, + expectedRules: ['powershell.remove-item.wildcard'], + }, { command: 'Write-Output "$(Remove-Item -Force C:/private/subexpression-command-sentinel)"', expectedRules: ['powershell.remove-item.force'], @@ -302,7 +310,7 @@ async function runTests() { 'Should not store raw command text' ); assert.ok( - !JSON.stringify(approvalEvent).includes(command), + !JSON.stringify(approvalEvent).includes(JSON.stringify(command).slice(1, -1)), 'Serialized governance evidence should not leak the raw command' ); } diff --git a/tests/lib/powershell-destructive-command.test.js b/tests/lib/powershell-destructive-command.test.js index 482cdea48..00d53c279 100644 --- a/tests/lib/powershell-destructive-command.test.js +++ b/tests/lib/powershell-destructive-command.test.js @@ -228,6 +228,8 @@ test('classifies powershell and pwsh command payloads recursively', () => { RULES.REMOVE_FORCE, ]); expectRules('pwsh -cwa "Remove-Item -Force C:/tmp/demo"', [RULES.REMOVE_FORCE]); + expectRules('pwsh -Command:"Remove-Item -Force C:/tmp/demo"', [RULES.REMOVE_FORCE]); + expectRules('pwsh -Command:Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); expectRules( "Start-Process pwsh -ArgumentList '-NoProfile -Command \"Remove-Item -Force C:/tmp/demo\"'", [RULES.REMOVE_FORCE] @@ -277,6 +279,13 @@ test('classifies UTF-16LE EncodedCommand payloads', () => { ).toString('base64'); expectRules(`pwsh -EncodedCommand ${payload}`, [RULES.REMOVE_WILDCARD]); + expectRules(`pwsh -EncodedCommand:${payload}`, [RULES.REMOVE_WILDCARD]); + expectRules(`$payload='${payload}'; pwsh -EncodedCommand:$payload`, [ + RULES.REMOVE_WILDCARD, + ]); + expectRules('pwsh -EncodedCommand $runtimePayload', [RULES.DYNAMIC_EXECUTION]); + expectSafe(`$payload='${payload}'; pwsh -EncodedCommand:\`$payload`); + expectSafe(`$payload='${payload}'; pwsh -EncodedCommand:'$payload'`); }); test('ignores an invalid EncodedCommand payload without throwing', () => { @@ -460,6 +469,7 @@ test('classifies static execution primitives', () => { "$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"$payload\"", "$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output ready; $payload\"", "$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output ready; $($payload)\"", + "$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command:$payload", "$payload = \"Remove-Item `\n-Force C:/tmp/demo\"; pwsh -Command $payload", ]) { expectRules(command, [RULES.REMOVE_FORCE]); @@ -474,6 +484,8 @@ test('classifies static execution primitives', () => { ]); expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command '$payload'"); expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output `$payload\""); + expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command:`$payload"); + 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, @@ -513,6 +525,10 @@ test('classifies static execution primitives', () => { "$ExecutionContext.InvokeCommand.InvokeScript(\"Write-Output safe; `\nRemove-Item -Force C:/tmp/demo\")", [RULES.REMOVE_FORCE] ); + expectRules( + "$ExecutionContext.InvokeCommand.InvokeScript(\"Write-Output safe; `\rRemove-Item -Force C:/tmp/demo\")", + [RULES.REMOVE_FORCE] + ); }); test('scans malformed InvokeScript string arguments in bounded time', () => {