From 8c1925722ce7e065c6bdbb9d1ad122bfa57a13af Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 7 Oct 2026 22:31:00 -0400 Subject: [PATCH] refactor(lib): split powershell destructive-command scanners The here-string normalizer, comment stripper, and balanced-group reader each carried their own copy of the same inert-region state machine. They now share createLexState, consumeInertChar, and openInertRegion. The container extractor, statement tokenizer, nested pwsh scanner, cmd scanner, Start-Process scanner, and top-level statement walk are split into small named helpers that each handle one region or one command family. No classification change. Verified against the recorded test inputs plus mutated variants with an order-sensitive comparison of findings. Clears the eleven complexity and prefer-const warnings on this file. --- scripts/lib/powershell-destructive-command.js | 2012 +++++++++-------- 1 file changed, 1080 insertions(+), 932 deletions(-) diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js index c5b90d048..5ecb6cac6 100644 --- a/scripts/lib/powershell-destructive-command.js +++ b/scripts/lib/powershell-destructive-command.js @@ -113,88 +113,169 @@ function isCommandFlag(token) { isParameterPrefix(token, 'commandwithargs') || name === 'cwa'; } +/** + * Shared inert-region tracking for the character scanners below. Comments, + * `${}` variable names, quoted strings, and backtick escapes never contain + * container delimiters or here-string openers, so every scanner skips them + * the same way. + */ +function createLexState() { + return { + quote: null, + lineComment: false, + blockComment: false, + bracedVariable: false, + comment: false, + }; +} + +/** + * Consume the character at `index` when it belongs to an open inert region. + * Returns the last index consumed, or -1 when the character is live and the + * caller must interpret it. `state.comment` reports whether the consumed run + * is comment text. + */ +function consumeInertChar(state, input, index) { + const char = input[index]; + const next = input[index + 1]; + state.comment = false; + if (state.lineComment) { + if (char === '\n' || char === '\r') { + state.lineComment = false; + } else { + state.comment = true; + } + return index; + } + if (state.blockComment) { + if (char === '#' && next === '>') { + state.blockComment = false; + state.comment = true; + return index + 1; + } + state.comment = char !== '\n' && char !== '\r'; + return index; + } + if (state.bracedVariable) { + if (char === '`') return index + 1; + if (char === '}') state.bracedVariable = false; + return index; + } + if (state.quote === "'") { + if (char === "'" && next === "'") return index + 1; + if (char === "'") state.quote = null; + return index; + } + if (char === '`') return index + 1; + if (state.quote === '"') { + if (char === '"') state.quote = null; + return index; + } + return -1; +} + +/** + * Open an inert region at a live character. Returns the last index consumed, + * or -1 when the character does not start a region. + */ +function openInertRegion(state, input, index) { + const char = input[index]; + const next = input[index + 1]; + state.comment = false; + if (char === "'" || char === '"') { + state.quote = char; + return index; + } + if (char === '$' && next === '{') { + state.bracedVariable = true; + return index + 1; + } + if (char === '<' && next === '#') { + state.blockComment = true; + state.comment = true; + return index + 1; + } + if (char === '#') { + state.lineComment = true; + state.comment = true; + return index; + } + return -1; +} + +function hereStringOpenerLineEnd(input, index) { + let openerLineEnd = index + 2; + while (input[openerLineEnd] === ' ' || input[openerLineEnd] === '\t') openerLineEnd += 1; + if (input[openerLineEnd] === '\r' && input[openerLineEnd + 1] === '\n') openerLineEnd += 1; + return openerLineEnd; +} + +function hereStringClosingEnd(input, openerLineEnd, quote) { + for (let lineStart = openerLineEnd + 1; lineStart < input.length;) { + let contentStart = lineStart; + while (input[contentStart] === ' ' || input[contentStart] === '\t') contentStart += 1; + if (input[contentStart] === quote && input[contentStart + 1] === '@') { + return contentStart + 2; + } + while (lineStart < input.length && input[lineStart] !== '\n') lineStart += 1; + if (lineStart < input.length) lineStart += 1; + } + return -1; +} + +// Expandable here-strings execute their unescaped subexpressions while the +// string value is being formed, independently of any later consumer. +function collectHereStringSubexpressions(input, start, end, executablePayloads) { + for (let offset = start; offset < end; offset += 1) { + if (input[offset] === '`') { + offset += 1; + continue; + } + if (input[offset] !== '$' || input[offset + 1] !== '(') continue; + const group = readBalancedGroup(input, offset + 1, '(', ')'); + if (!group || group.end > end) break; + executablePayloads.push(group.body); + offset = group.end - 1; + } +} + +function applyReplacements(output, replacements) { + let normalized = ''; + let cursor = 0; + for (const replacement of replacements) { + normalized += output.slice(cursor, replacement.start).join(''); + normalized += replacement.value; + cursor = replacement.end; + } + return normalized + output.slice(cursor).join(''); +} + function normalizeHereStrings(input, executablePayloads = []) { const output = [...input]; const replacements = []; - let ordinaryQuote = null; - let lineComment = false; - let blockComment = false; - let bracedVariable = false; + const state = createLexState(); for (let index = 0; index < input.length - 1; index += 1) { - const char = input[index]; - const next = input[index + 1]; - if (lineComment) { - if (char === '\n' || char === '\r') lineComment = false; - continue; - } - if (blockComment) { - if (char === '#' && next === '>') { - blockComment = false; - index += 1; - } - continue; - } - if (bracedVariable) { - if (char === '`') index += 1; - else if (char === '}') bracedVariable = false; - continue; - } - if (ordinaryQuote === "'") { - if (char === "'" && input[index + 1] === "'") index += 1; - else if (char === "'") ordinaryQuote = null; - continue; - } - if (char === '`') { - index += 1; - continue; - } - if (ordinaryQuote === '"') { - if (char === '"') ordinaryQuote = null; - continue; - } - - if (char === '$' && next === '{') { - bracedVariable = true; - index += 1; - continue; - } - if (char === '<' && next === '#') { - blockComment = true; - index += 1; - continue; - } - if (char === '#') { - lineComment = true; - continue; - } - - if (input[index] !== '@' || (input[index + 1] !== "'" && input[index + 1] !== '"')) { - if (char === "'" || char === '"') ordinaryQuote = char; + const inertEnd = consumeInertChar(state, input, index); + if (inertEnd !== -1) { + index = inertEnd; continue; } const quote = input[index + 1]; - let openerLineEnd = index + 2; - while (input[openerLineEnd] === ' ' || input[openerLineEnd] === '\t') openerLineEnd += 1; - if (input[openerLineEnd] === '\r' && input[openerLineEnd + 1] === '\n') openerLineEnd += 1; + if (input[index] !== '@' || (quote !== "'" && quote !== '"')) { + const regionEnd = openInertRegion(state, input, index); + if (regionEnd !== -1) index = regionEnd; + continue; + } + + const openerLineEnd = hereStringOpenerLineEnd(input, index); if (input[openerLineEnd] !== '\n') { if (openerLineEnd >= input.length) break; continue; } - let closingEnd = -1; - for (let lineStart = openerLineEnd + 1; lineStart < input.length;) { - let contentStart = lineStart; - while (input[contentStart] === ' ' || input[contentStart] === '\t') contentStart += 1; - if (input[contentStart] === quote && input[contentStart + 1] === '@') { - closingEnd = contentStart + 2; - break; - } - while (lineStart < input.length && input[lineStart] !== '\n') lineStart += 1; - if (lineStart < input.length) lineStart += 1; - } - + const closingEnd = hereStringClosingEnd(input, openerLineEnd, quote); const contentEnd = closingEnd === -1 ? input.length : closingEnd - 2; const content = input.slice(openerLineEnd + 1, contentEnd); @@ -206,21 +287,8 @@ function normalizeHereStrings(input, executablePayloads = []) { start: index, value: `'${content.replace(/'/g, "''")}'`, }); - - // Expandable here-strings execute their unescaped subexpressions while the - // string value is being formed, independently of any later consumer. if (quote === '"') { - for (let offset = openerLineEnd + 1; offset < contentEnd; offset += 1) { - if (input[offset] === '`') { - offset += 1; - continue; - } - if (input[offset] !== '$' || input[offset + 1] !== '(') continue; - const group = readBalancedGroup(input, offset + 1, '(', ')'); - if (!group || group.end > contentEnd) break; - executablePayloads.push(group.body); - offset = group.end - 1; - } + collectHereStringSubexpressions(input, openerLineEnd + 1, contentEnd, executablePayloads); } if (closingEnd === -1) break; @@ -228,94 +296,25 @@ function normalizeHereStrings(input, executablePayloads = []) { } if (replacements.length === 0) return output.join(''); - let normalized = ''; - let cursor = 0; - for (const replacement of replacements) { - normalized += output.slice(cursor, replacement.start).join(''); - normalized += replacement.value; - cursor = replacement.end; + return applyReplacements(output, replacements); +} + +function blankRange(output, start, end) { + for (let offset = start; offset <= end; offset += 1) { + if (output[offset] !== '\n' && output[offset] !== '\r') output[offset] = ' '; } - normalized += output.slice(cursor).join(''); - return normalized; } function stripPowerShellComments(input) { const output = [...input]; - let quote = null; - let lineComment = false; - let blockComment = false; - let bracedVariable = false; + const state = createLexState(); for (let index = 0; index < input.length; index += 1) { - const char = input[index]; - const next = input[index + 1]; - - if (lineComment) { - if (char === '\n' || char === '\r') { - lineComment = false; - } else { - output[index] = ' '; - } - continue; - } - - if (blockComment) { - if (char === '#' && next === '>') { - output[index] = ' '; - output[index + 1] = ' '; - blockComment = false; - index += 1; - } else if (char !== '\n' && char !== '\r') { - output[index] = ' '; - } - continue; - } - - if (bracedVariable) { - if (char === '`') index += 1; - else if (char === '}') bracedVariable = false; - continue; - } - - if (quote === "'") { - if (char === "'" && next === "'") { - index += 1; - } else if (char === "'") { - quote = null; - } - continue; - } - if (char === '`') { - index += 1; - continue; - } - if (quote === '"') { - if (char === '"') quote = null; - continue; - } - if (char === "'" || char === '"') { - quote = char; - continue; - } - - if (char === '$' && next === '{') { - bracedVariable = true; - index += 1; - continue; - } - - if (char === '<' && next === '#') { - output[index] = ' '; - output[index + 1] = ' '; - blockComment = true; - index += 1; - continue; - } - - if (char === '#') { - output[index] = ' '; - lineComment = true; - } + let end = consumeInertChar(state, input, index); + if (end === -1) end = openInertRegion(state, input, index); + if (end === -1) continue; + if (state.comment) blankRange(output, index, end); + index = end; } return output.join(''); @@ -328,71 +327,17 @@ function stripPowerShellComments(input) { */ function readBalancedGroup(input, openingIndex, open, close) { let depth = 1; - let quote = null; - let lineComment = false; - let blockComment = false; - let bracedVariable = false; + const state = createLexState(); for (let index = openingIndex + 1; index < input.length; index += 1) { + let end = consumeInertChar(state, input, index); + if (end === -1) end = openInertRegion(state, input, index); + if (end !== -1) { + index = end; + continue; + } + const char = input[index]; - const next = input[index + 1]; - - if (lineComment) { - if (char === '\n' || char === '\r') lineComment = false; - continue; - } - if (blockComment) { - if (char === '#' && next === '>') { - blockComment = false; - index += 1; - } - continue; - } - if (bracedVariable) { - if (char === '`') index += 1; - else if (char === '}') bracedVariable = false; - continue; - } - - if (quote === "'") { - if (char === "'" && input[index + 1] === "'") { - index += 1; - } else if (char === "'") { - quote = null; - } - continue; - } - if (char === '`') { - index += 1; - continue; - } - - if (quote === '"') { - if (char === '"') quote = null; - continue; - } - - if (char === "'" || char === '"') { - quote = char; - continue; - } - - if (char === '$' && next === '{') { - bracedVariable = true; - index += 1; - continue; - } - - if (char === '<' && next === '#') { - blockComment = true; - index += 1; - continue; - } - if (char === '#') { - lineComment = true; - continue; - } - if (char === open) { depth += 1; } else if (char === close) { @@ -645,8 +590,11 @@ function staticCommandResult(body) { return command && /^[A-Za-z_][\w./\\-]*$/.test(command) ? command : null; } -function staticStringArrayResult(body) { - const input = String(body || ''); +/** + * Split an argument list on top-level commas, keeping quotes and nested + * parentheses intact. Returns null for unbalanced input. + */ +function splitStaticArrayItems(input) { const items = []; let item = ''; let quote = null; @@ -668,17 +616,25 @@ function staticStringArrayResult(body) { item += char; continue; } - if (!quote && char === '(') depth += 1; - if (!quote && char === ')') depth -= 1; - if (!quote && depth === 0 && char === ',') { - items.push(item); - item = ''; - continue; + if (!quote) { + if (char === '(') depth += 1; + else if (char === ')') depth -= 1; + else if (depth === 0 && char === ',') { + items.push(item); + item = ''; + continue; + } } item += char; } if (quote || depth !== 0) return null; items.push(item); + return items; +} + +function staticStringArrayResult(body) { + const items = splitStaticArrayItems(String(body || '')); + if (!items) return null; const values = items.map(value => staticScalarResult(value)); return values.length > 0 && values.every(value => value !== null) ? values.join(' ') @@ -735,203 +691,272 @@ function isPipedToPowerShellStdin(input, end) { * blocks while masking every container from the outer statement pass. `$()` * also executes inside double quotes. Other containers are literal there. */ -function extractExecutableContainers(input, options = {}) { - const bodies = []; - const deferredFunctions = []; - const masked = [...input]; - let quote = null; - let bracedVariable = false; - let context = ''; - let contextTruncated = false; +const CONTEXT_CLAUSE_KEYWORDS = /^(?:if|elseif|else|for|foreach|while|do|switch|default|try|catch|finally|trap|function|filter|workflow|begin|process|end|dynamicparam|clean)\b/i; - const resetContext = () => { - context = ''; - contextTruncated = false; +function createContainerScan(input, options) { + return { + input, + options, + bodies: [], + deferredFunctions: [], + masked: [...input], + quote: null, + bracedVariable: false, + context: '', + contextTruncated: false, }; +} - const appendContext = value => { - for (const contextChar of value) { - if (contextChar === ';' || contextChar === '}') { - resetContext(); - } else if (contextChar === '\n' || contextChar === '\r') { - const clause = currentClause(context); - if (/^(?:if|elseif|else|for|foreach|while|do|switch|default|try|catch|finally|trap|function|filter|workflow|begin|process|end|dynamicparam|clean)\b/i.test(clause)) { - if (context && !context.endsWith(' ')) context += ' '; - } else { - resetContext(); - } - } else if (/\s/.test(contextChar)) { - if (context && !context.endsWith(' ')) context += ' '; +function resetContainerContext(scan) { + scan.context = ''; + scan.contextTruncated = false; +} + +function appendContextSpace(scan) { + if (scan.context && !scan.context.endsWith(' ')) scan.context += ' '; +} + +function appendContainerContext(scan, value) { + for (const contextChar of value) { + if (contextChar === ';' || contextChar === '}') { + resetContainerContext(scan); + } else if (contextChar === '\n' || contextChar === '\r') { + if (CONTEXT_CLAUSE_KEYWORDS.test(currentClause(scan.context))) { + appendContextSpace(scan); } else { - context += contextChar; - } - if (context.length > MAX_CONTEXT_LENGTH) { - context = context.slice(-Math.floor(MAX_CONTEXT_LENGTH / 2)); - contextTruncated = true; + resetContainerContext(scan); } + } else if (/\s/.test(contextChar)) { + appendContextSpace(scan); + } else { + scan.context += contextChar; } + if (scan.context.length > MAX_CONTEXT_LENGTH) { + scan.context = scan.context.slice(-Math.floor(MAX_CONTEXT_LENGTH / 2)); + scan.contextTruncated = true; + } + } +} + +function maskRange(masked, start, end) { + for (let offset = start; offset < end; offset += 1) masked[offset] = ' '; +} + +function consumeBracedVariableChar(scan, index) { + const { input } = scan; + const char = input[index]; + if (char === '`' && index + 1 < input.length) { + appendContainerContext(scan, input[index + 1]); + return index + 1; + } + if (char === '}') { + scan.context += char; + scan.bracedVariable = false; + } else { + appendContainerContext(scan, char); + } + return index; +} + +// A backtick outside quotes folds the escaped character into the clause +// context; inside quotes it only protects the next character. +function consumeContainerEscape(scan, index) { + const { input } = scan; + if (!scan.quote && index + 1 < input.length) { + const escaped = input[index + 1]; + appendContainerContext(scan, escaped === '\n' || escaped === '\r' ? ' ' : escaped); + if (escaped === '\r' && input[index + 2] === '\n') return index + 2; + } + return index + 1; +} + +/** + * Consume characters that cannot open a container: `${}` names, single-quoted + * text, backtick escapes, and quote delimiters. Returns the last consumed + * index, or -1 for a live character. + */ +function consumeContainerInertChar(scan, index) { + const { input } = scan; + const char = input[index]; + if (scan.bracedVariable) return consumeBracedVariableChar(scan, index); + if (scan.quote === "'") { + if (char === "'" && input[index + 1] === "'") return index + 1; + if (char === "'") scan.quote = null; + return index; + } + if (char === '`') return consumeContainerEscape(scan, index); + if (!scan.quote && char === "'") { + scan.quote = "'"; + appendContainerContext(scan, ' '); + return index; + } + if (!scan.quote && char === '$' && input[index + 1] === '{') { + appendContainerContext(scan, '${'); + scan.bracedVariable = true; + return index + 1; + } + if (char === '"') { + scan.quote = scan.quote === '"' ? null : '"'; + if (scan.quote === '"') appendContainerContext(scan, ' '); + return index; + } + return -1; +} + +function containerDescriptor(input, index, flags) { + const { isSubexpression, isArrayExpression, isScriptBlock } = flags; + return { + ...flags, + isHashtable: isScriptBlock && input[index - 1] === '@', + openingIndex: isSubexpression || isArrayExpression ? index + 1 : index, + open: isScriptBlock ? '{' : '(', + close: isScriptBlock ? '}' : ')', }; +} + +/** + * Classify the live character at `index`. Returns a container descriptor, or + * null after folding a non-container character into the clause context. + */ +function readContainerOpener(scan, index) { + const { input, quote } = scan; + const char = input[index]; + const isSubexpression = char === '$' && input[index + 1] === '('; + if (quote === '"' && !isSubexpression) return null; + + const isArrayExpression = !quote && char === '@' && input[index + 1] === '('; + const isGroupingExpression = !quote && char === '('; + const isScriptBlock = !quote && char === '{'; + if (!isSubexpression && !isArrayExpression && !isGroupingExpression && !isScriptBlock) { + if (!quote) appendContainerContext(scan, char); + return null; + } + const isCmdPayloadGroup = isGroupingExpression && + /(?:^|\s)cmd(?:\.exe)?\s+\/[ck](?:\s|$)/i.test(currentClause(scan.context)); + if (isCmdPayloadGroup) { + appendContainerContext(scan, char); + return null; + } + return containerDescriptor(input, index, { + isSubexpression, + isArrayExpression, + isGroupingExpression, + isScriptBlock, + }); +} + +function queueCreatedScriptBlock(scan, group, clause, invokedAfter) { + const { options } = scan; + const createsScriptBlock = /\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create\s*$/i.test(clause); + if (!createsScriptBlock || !(invokedAfter || options.executeBareScriptBlocks)) return; + const scalarReference = variableReference(group.body); + const scriptText = staticStringResult(group.body) || + (scalarReference ? options.staticScalars?.get(scalarReference) : null); + if (scriptText) { + scan.bodies.push({ body: scriptText, options: { executeBareScriptBlocks: true } }); + } +} + +function queueContainerBody(scan, container, group, info) { + const { options } = scan; + const { isScriptBlock, isHashtable } = container; + const { prefix, invokesContainer, invokedAfter } = info; + const clause = currentClause(prefix); + const shouldScan = scan.contextTruncated || !isScriptBlock || isHashtable || invokedAfter || + isExecutableScriptBlock(prefix, options); + if (shouldScan) { + const executesNestedScriptBlocks = isScriptBlock && /^switch\b/i.test(clause); + scan.bodies.push({ + body: group.body, + options: { + executeBareScriptBlocks: Boolean(options.executeBareScriptBlocks) || + invokedAfter || executesNestedScriptBlocks || + (!isScriptBlock && invokesContainer), + }, + }); + } else { + const functionName = deferredScriptBlockName(prefix); + if (functionName) scan.deferredFunctions.push({ body: group.body, functionName }); + } + queueCreatedScriptBlock(scan, group, clause, invokedAfter); +} + +function staticContainerCommand(scan, container, group, info) { + const clause = currentClause(info.prefix); + if (container.isSubexpression || info.invokesContainer) { + const output = staticOutputResult(group.body); + if (output !== null || !container.isSubexpression) return output; + const scalarReference = variableReference(group.body); + return scalarReference ? scan.options.staticScalars?.get(scalarReference) ?? null : null; + } + if (/^(?:start-process|saps|start)\b/i.test(clause)) return staticStringArrayResult(group.body); + if (/^new-object\b/i.test(clause)) return staticTypeNameResult(group.body); + if (isPipedToPowerShellStdin(scan.input, group.end)) return staticScalarResult(group.body); + return staticCommandResult(group.body); +} + +function resolveContainerCommand(scan, container, group, info) { + const resolvedCommand = container.isScriptBlock + ? null + : staticContainerCommand(scan, container, group, info); + if (resolvedCommand || container.isScriptBlock) return resolvedCommand; + const executableBlockExpression = /\{|\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create/i.test( + maskQuotedStrings(group.body) + ); + const dynamic = invokesDynamicResult(info.prefix, { + groupingExpression: container.isGroupingExpression, + }); + return dynamic && !executableBlockExpression ? DYNAMIC_EXECUTION_MARKER : null; +} + +function recordContainer(scan, container, group, index) { + const withinDoubleQuote = scan.quote === '"'; + const prefix = scan.context; + const invokesContainer = invokesContainerResult(prefix, { + groupingExpression: container.isGroupingExpression, + }); + const invokedAfter = isInvokedAfterContainer(scan.input, group.end); + queueContainerBody(scan, container, group, { prefix, invokesContainer, invokedAfter }); + maskRange(scan.masked, index, group.end); + + const resolvedCommand = resolveContainerCommand(scan, container, group, { prefix, invokesContainer }); + if (resolvedCommand) { + scan.masked[index] = resolvedCommand; + if (!withinDoubleQuote) appendContainerContext(scan, resolvedCommand); + } else if (container.isScriptBlock) { + if (invokesContainer) { + scan.context = prefix; + } else { + resetContainerContext(scan); + } + } else if (!withinDoubleQuote) { + appendContainerContext(scan, ' '); + } +} + +function extractExecutableContainers(input, options = {}) { + const scan = createContainerScan(input, options); for (let index = 0; index < input.length; index += 1) { - const char = input[index]; - - if (bracedVariable) { - if (char === '`' && index + 1 < input.length) { - appendContext(input[index + 1]); - index += 1; - } else if (char === '}') { - context += char; - bracedVariable = false; - } else { - appendContext(char); - } + const inertEnd = consumeContainerInertChar(scan, index); + if (inertEnd !== -1) { + index = inertEnd; continue; } - if (quote === "'") { - if (char === "'" && input[index + 1] === "'") { - index += 1; - } else if (char === "'") { - quote = null; - } - continue; - } - if (char === '`') { - if (!quote && index + 1 < input.length) { - const escaped = input[index + 1]; - appendContext(escaped === '\n' || escaped === '\r' ? ' ' : escaped); - if (escaped === '\r' && input[index + 2] === '\n') index += 1; - } - index += 1; - continue; - } + const container = readContainerOpener(scan, index); + if (!container) continue; - if (!quote && char === "'") { - quote = "'"; - appendContext(' '); - continue; - } - - if (!quote && char === '$' && input[index + 1] === '{') { - appendContext('${'); - bracedVariable = true; - index += 1; - continue; - } - - if (char === '"') { - quote = quote === '"' ? null : '"'; - if (quote === '"') appendContext(' '); - continue; - } - - const isSubexpression = char === '$' && input[index + 1] === '('; - if (quote === '"' && !isSubexpression) continue; - - const isArrayExpression = !quote && char === '@' && input[index + 1] === '('; - const isGroupingExpression = !quote && char === '('; - const isScriptBlock = !quote && char === '{'; - const isHashtable = isScriptBlock && input[index - 1] === '@'; - const isCmdPayloadGroup = isGroupingExpression && - /(?:^|\s)cmd(?:\.exe)?\s+\/[ck](?:\s|$)/i.test(currentClause(context)); - if (!isSubexpression && !isArrayExpression && !isGroupingExpression && !isScriptBlock) { - if (!quote) appendContext(char); - continue; - } - if (isCmdPayloadGroup) { - appendContext(char); - continue; - } - - const openingIndex = isSubexpression || isArrayExpression ? index + 1 : index; - const open = isScriptBlock ? '{' : '('; - const close = isScriptBlock ? '}' : ')'; - const group = readBalancedGroup(input, openingIndex, open, close); + const group = readBalancedGroup(input, container.openingIndex, container.open, container.close); if (!group) { - for (let offset = index; offset < input.length; offset += 1) masked[offset] = ' '; + maskRange(scan.masked, index, input.length); break; } - - 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) - ); - const shouldScan = contextTruncated || !isScriptBlock || isHashtable || invokedAfter || - isExecutableScriptBlock(prefix, options); - if (shouldScan) { - const executesNestedScriptBlocks = isScriptBlock && /^switch\b/i.test(currentClause(prefix)); - bodies.push({ - body: group.body, - options: { - executeBareScriptBlocks: Boolean(options.executeBareScriptBlocks) || - invokedAfter || executesNestedScriptBlocks || - (!isScriptBlock && invokesContainer), - }, - }); - } else { - const functionName = deferredScriptBlockName(prefix); - if (functionName) deferredFunctions.push({ body: group.body, functionName }); - } - if (createsScriptBlock && (invokedAfter || options.executeBareScriptBlocks)) { - const scalarReference = variableReference(group.body); - const scriptText = staticStringResult(group.body) || - (scalarReference ? options.staticScalars?.get(scalarReference) : null); - if (scriptText) { - bodies.push({ body: scriptText, options: { executeBareScriptBlocks: true } }); - } - } - for (let offset = index; offset < group.end; offset += 1) { - masked[offset] = ' '; - } - let resolvedCommand = null; - if (!isScriptBlock) { - if (isSubexpression || invokesContainer) { - resolvedCommand = staticOutputResult(group.body); - if (resolvedCommand === null && isSubexpression) { - const scalarReference = variableReference(group.body); - if (scalarReference) { - resolvedCommand = options.staticScalars?.get(scalarReference) ?? null; - } - } - } else if (/^(?:start-process|saps|start)\b/i.test(currentClause(prefix))) { - resolvedCommand = staticStringArrayResult(group.body); - } else if (/^new-object\b/i.test(currentClause(prefix))) { - resolvedCommand = staticTypeNameResult(group.body); - } else if (isPipedToPowerShellStdin(input, group.end)) { - resolvedCommand = staticScalarResult(group.body); - } else { - resolvedCommand = staticCommandResult(group.body); - } - } - const executableBlockExpression = /\{|\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create/i.test( - maskQuotedStrings(group.body) - ); - if (!resolvedCommand && !isScriptBlock && invokesDynamicResult(prefix, { - groupingExpression: isGroupingExpression, - }) && !executableBlockExpression) { - resolvedCommand = DYNAMIC_EXECUTION_MARKER; - } - if (resolvedCommand) { - masked[index] = resolvedCommand; - if (!withinDoubleQuote) appendContext(resolvedCommand); - } else if (isScriptBlock) { - if (invokesContainer) { - context = prefix; - } else { - resetContext(); - } - } else if (!withinDoubleQuote) { - appendContext(' '); - } + recordContainer(scan, container, group, index); index = group.end - 1; } - return { bodies, deferredFunctions, outer: masked.join('') }; + return { bodies: scan.bodies, deferredFunctions: scan.deferredFunctions, outer: scan.masked.join('') }; } /** @@ -939,181 +964,226 @@ function extractExecutableContainers(input, options = {}) { * are interpreted before token comparison so `Rem`ove-Item` normalizes to the * command PowerShell executes. Backslashes remain ordinary characters. */ -function parseStatements(input) { - const statements = []; - let statement = []; - let segment = []; - 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; +function createWordState() { + return { + text: '', + source: '', + hasQuotedContent: false, + hasUnquotedContent: false, + quoteKind: null, + inlineValueQuoteKind: null, + inlineValueQuoteClosed: false, + }; +} - const flushWord = () => { - if (word) { - segment.push(word); - segmentQuotedTokens.push(wordHasQuotedContent && !wordHasUnquotedContent); - segmentQuoteKinds.push( - wordHasQuotedContent && !wordHasUnquotedContent ? wordQuoteKind : null - ); - segmentTokenSources.push(wordSource); - segmentInlineValueQuoteKinds.push( - wordInlineValueQuoteClosed && wordInlineValueQuoteKind !== 'mixed' - ? wordInlineValueQuoteKind - : null - ); +function createSegmentState() { + return { + tokens: [], + quotedTokens: [], + quoteKinds: [], + tokenSources: [], + inlineValueQuoteKinds: [], + }; +} + +function createStatementParser() { + return { + statements: [], + statement: [], + segment: createSegmentState(), + word: createWordState(), + quote: null, + parenDepth: 0, + callOperatorPending: false, + }; +} + +function markInlineValueMixed(word) { + if (word.inlineValueQuoteClosed) word.inlineValueQuoteKind = 'mixed'; +} + +function appendUnquotedChar(word, char) { + word.text += char; + word.source += char; + word.hasUnquotedContent = true; +} + +function appendQuotedChar(word, char) { + word.text += char; + word.source += char; + word.hasQuotedContent = true; +} + +function flushWord(parser) { + const { word, segment } = parser; + if (word.text) { + const quotedOnly = word.hasQuotedContent && !word.hasUnquotedContent; + segment.tokens.push(word.text); + segment.quotedTokens.push(quotedOnly); + segment.quoteKinds.push(quotedOnly ? word.quoteKind : null); + segment.tokenSources.push(word.source); + segment.inlineValueQuoteKinds.push( + word.inlineValueQuoteClosed && word.inlineValueQuoteKind !== 'mixed' + ? word.inlineValueQuoteKind + : null + ); + } + parser.word = createWordState(); +} + +function flushSegment(parser) { + flushWord(parser); + const { segment } = parser; + if (segment.tokens.length) { + Object.defineProperties(segment.tokens, { + invokedByCallOperator: { value: parser.callOperatorPending }, + quotedTokens: { value: segment.quotedTokens }, + quoteKinds: { value: segment.quoteKinds }, + tokenSources: { value: segment.tokenSources }, + inlineValueQuoteKinds: { value: segment.inlineValueQuoteKinds }, + }); + parser.statement.push(segment.tokens); + parser.callOperatorPending = false; + } + parser.segment = createSegmentState(); +} + +function flushStatement(parser) { + flushSegment(parser); + if (parser.statement.length) parser.statements.push(parser.statement); + parser.statement = []; +} + +// Each consume* helper returns the last input index it consumed. +function consumeSingleQuotedChar(parser, input, index) { + const { word } = parser; + const char = input[index]; + if (char === "'" && input[index + 1] === "'") { + word.text += "'"; + word.source += "''"; + return index + 1; + } + if (char === "'") { + parser.quote = null; + if (word.inlineValueQuoteKind === "'") word.inlineValueQuoteClosed = true; + return index; + } + appendQuotedChar(word, char); + return index; +} + +function consumeDoubleQuotedChar(parser, input, index) { + const { word } = parser; + const char = input[index]; + if (char === '"') { + parser.quote = null; + if (word.inlineValueQuoteKind === '"') word.inlineValueQuoteClosed = true; + return index; + } + appendQuotedChar(word, char); + return index; +} + +function consumeBacktickEscape(parser, input, index) { + const { word } = parser; + if (index + 1 >= input.length) { + word.text += '`'; + word.source += '`'; + return index; + } + const escaped = input[index + 1]; + word.source += `\`${escaped}`; + if (escaped === '\n' || escaped === '\r') { + if (escaped === '\r' && input[index + 2] === '\n') { + word.source += '\n'; + return index + 2; } - word = ''; - wordSource = ''; - wordHasQuotedContent = false; - wordHasUnquotedContent = false; - wordQuoteKind = null; - wordInlineValueQuoteKind = null; - wordInlineValueQuoteClosed = false; - }; - const flushSegment = () => { - flushWord(); - if (segment.length) { - Object.defineProperties(segment, { - invokedByCallOperator: { value: callOperatorPending }, - quotedTokens: { value: segmentQuotedTokens }, - quoteKinds: { value: segmentQuoteKinds }, - tokenSources: { value: segmentTokenSources }, - inlineValueQuoteKinds: { value: segmentInlineValueQuoteKinds }, - }); - statement.push(segment); - callOperatorPending = false; - } - segment = []; - segmentQuotedTokens = []; - segmentQuoteKinds = []; - segmentTokenSources = []; - segmentInlineValueQuoteKinds = []; - }; - const flushStatement = () => { - flushSegment(); - if (statement.length) statements.push(statement); - statement = []; - }; + return index + 1; + } + markInlineValueMixed(word); + word.text += escaped; + if (parser.quote) word.hasQuotedContent = true; + else word.hasUnquotedContent = true; + return index + 1; +} + +function openWordQuote(parser, char) { + const { word } = parser; + if (word.inlineValueQuoteClosed) { + word.inlineValueQuoteKind = 'mixed'; + } else if (word.inlineValueQuoteKind === null && /^-+[^:\s]+:$/.test(word.text)) { + word.inlineValueQuoteKind = char; + } + parser.quote = char; + word.hasQuotedContent = true; + word.quoteKind = word.quoteKind === null || word.quoteKind === char ? char : 'mixed'; +} + +// Parentheses, statement and pipeline separators, the call operator, and +// whitespace. Returns false when `char` is an ordinary word character. +function consumeStructuralChar(parser, char) { + if (char === '(') { + markInlineValueMixed(parser.word); + parser.parenDepth += 1; + appendUnquotedChar(parser.word, char); + return true; + } + if (char === ')' && parser.parenDepth > 0) { + markInlineValueMixed(parser.word); + parser.parenDepth -= 1; + appendUnquotedChar(parser.word, char); + return true; + } + if (parser.parenDepth === 0 && (char === ';' || char === '\n' || char === '\r')) { + flushStatement(parser); + return true; + } + if (parser.parenDepth === 0 && char === '|') { + flushSegment(parser); + return true; + } + if (parser.parenDepth === 0 && char === '&') { + if (parser.word.text || parser.segment.tokens.length) flushStatement(parser); + parser.callOperatorPending = true; + return true; + } + if (/\s/.test(char)) { + flushWord(parser); + return true; + } + return false; +} + +function parseStatements(input) { + const parser = createStatementParser(); for (let index = 0; index < input.length; index += 1) { const char = input[index]; - if (quote === "'") { - if (char === "'" && input[index + 1] === "'") { - word += "'"; - wordSource += "''"; - index += 1; - } else if (char === "'") { - quote = null; - if (wordInlineValueQuoteKind === "'") wordInlineValueQuoteClosed = true; - } else { - word += char; - wordSource += char; - wordHasQuotedContent = true; - } + if (parser.quote === "'") { + index = consumeSingleQuotedChar(parser, input, index); continue; } - if (char === '`') { - if (index + 1 >= input.length) { - word += '`'; - wordSource += '`'; - continue; - } - const escaped = input[index + 1]; - wordSource += `\`${escaped}`; - index += 1; - if (escaped === '\n' || escaped === '\r') { - if (escaped === '\r' && input[index + 1] === '\n') { - wordSource += '\n'; - index += 1; - } - } else { - if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; - word += escaped; - if (quote) wordHasQuotedContent = true; - else wordHasUnquotedContent = true; - } + index = consumeBacktickEscape(parser, input, index); continue; } - - if (quote === '"') { - if (char === '"') { - quote = null; - if (wordInlineValueQuoteKind === '"') wordInlineValueQuoteClosed = true; - } else { - word += char; - wordSource += char; - wordHasQuotedContent = true; - } + if (parser.quote === '"') { + index = consumeDoubleQuotedChar(parser, input, index); continue; } - 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'; + openWordQuote(parser, char); continue; } + if (consumeStructuralChar(parser, char)) continue; - if (char === '(') { - if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; - parenDepth += 1; - word += char; - wordSource += char; - wordHasUnquotedContent = true; - continue; - } - if (char === ')' && parenDepth > 0) { - if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; - parenDepth -= 1; - word += char; - wordSource += char; - wordHasUnquotedContent = true; - continue; - } - - if (parenDepth === 0 && (char === ';' || char === '\n' || char === '\r')) { - flushStatement(); - continue; - } - if (parenDepth === 0 && char === '|') { - flushSegment(); - continue; - } - if (parenDepth === 0 && char === '&') { - if (word || segment.length) flushStatement(); - callOperatorPending = true; - continue; - } - if (/\s/.test(char)) { - flushWord(); - continue; - } - - if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; - word += char; - wordSource += char; - wordHasUnquotedContent = true; + markInlineValueMixed(parser.word); + appendUnquotedChar(parser.word, char); } - flushStatement(); - return statements; + flushStatement(parser); + return parser.statements; } function maskQuotedStrings(input) { @@ -1267,84 +1337,101 @@ function staticPipelineInput(tokens, state, findings) { return null; } +function scanEncodedCommandFlag(tokens, index, scan) { + const { findings, scanState } = scan; + const token = tokens[index]; + 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, scan.depth, findings, scan.analysis, {}, scanState); +} + +// Inline `-Command:value` payloads. Returns null when expansion is unresolved. +function resolveInlineCommandPayload(tokens, index, scan) { + const token = tokens[index]; + const inlinePayload = parameterValue(token); + const rest = tokens.slice(index + 1); + if (tokens.inlineValueQuoteKinds?.[index] === "'") return [inlinePayload, ...rest].join(' '); + const inlineSource = parameterValue(tokens.tokenSources?.[index] || token); + const expanded = expandStaticDoubleQuotedString( + inlineSource || inlinePayload, + scan.scanState, + scan.findings + ); + return expanded === null ? null : [expanded, ...rest].join(' '); +} + +// `-Command value` payloads. Returns null when expansion is unresolved. +function resolvePositionalCommandPayload(tokens, payloadIndex, payload, scan) { + const { findings, scanState } = scan; + const quoteKind = tokens.quoteKinds?.[payloadIndex]; + if (tokens[payloadIndex] && quoteKind !== "'") { + const expanded = expandStaticDoubleQuotedString( + tokens.tokenSources?.[payloadIndex] ?? tokens[payloadIndex], + scanState, + findings + ); + return expanded === null ? null : [expanded, ...tokens.slice(payloadIndex + 1)].join(' '); + } + const payloadReference = quoteKind === "'" ? null : variableReference(payload); + if (!payloadReference) return payload; + const staticValue = scanState?.staticScalars.get(payloadReference); + if (staticValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return null; + } + return staticValue; +} + +function scanCommandFlag(tokens, index, scan, upstreamTokens) { + const { findings, scanState } = scan; + const inlinePayload = parameterValue(tokens[index]); + const rawPayload = inlinePayload + ? [inlinePayload, ...tokens.slice(index + 1)].join(' ') + : tokens.slice(index + 1).join(' '); + const pipelinePayload = rawPayload === '-' ? staticPipelineInput(upstreamTokens, scanState, findings) : null; + const payload = inlinePayload + ? resolveInlineCommandPayload(tokens, index, scan) + : resolvePositionalCommandPayload(tokens, index + 1, rawPayload, scan); + if (payload === null) return; + if (pipelinePayload || (payload && payload !== '-')) { + addNestedScan( + pipelinePayload || payload, + scan.depth, + findings, + scan.analysis, + { executeBareScriptBlocks: true }, + scanState + ); + } +} + function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upstreamTokens = null) { + const scan = { depth, findings, analysis, scanState }; for (let index = 1; index < tokens.length; index += 1) { const token = tokens[index]; - if (isEncodedCommandFlag(token)) { - 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); + scanEncodedCommandFlag(tokens, index, scan); return; } - if (isCommandFlag(token)) { - const inlinePayload = parameterValue(token); - let payload = inlinePayload - ? [inlinePayload, ...tokens.slice(index + 1)].join(' ') - : tokens.slice(index + 1).join(' '); - const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens, scanState, findings) : null; - const payloadIndex = index + 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 (tokens[payloadIndex] && tokens.quoteKinds?.[payloadIndex] !== "'") { - const expanded = expandStaticDoubleQuotedString( - tokens.tokenSources?.[payloadIndex] ?? tokens[payloadIndex], - scanState, - findings - ); - if (expanded === null) return; - payload = [expanded, ...tokens.slice(payloadIndex + 1)].join(' '); - } else { - const payloadReference = tokens.quoteKinds?.[payloadIndex] === "'" - ? 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, - depth, - findings, - analysis, - { executeBareScriptBlocks: true }, - scanState - ); - } + scanCommandFlag(tokens, index, scan, upstreamTokens); return; } } @@ -1377,111 +1464,105 @@ function splitCmdSegments(payload) { return segments; } -function scanCmdWords(inputWords, depth, findings, analysis, scanState, wrapperDepth = 0) { - if (wrapperDepth > 64) { - findings.add(RULE_IDS.SCAN_DEPTH_EXCEEDED); - return; - } - - let words = inputWords.filter(Boolean).map(word => String(word)); - if (words.length === 0) return; - words[0] = words[0].replace(/^@+/, '').replace(/^\(+/, ''); - words[words.length - 1] = words[words.length - 1].replace(/\)+$/, ''); - +function stripCmdRedirections(words) { while (words.length > 0 && /^\d*(?:>>?|<>?|< index > 0 && /^else$/i.test(word)); + const trueBranch = elseIndex === -1 ? words : words.slice(0, elseIndex); + scanCmdWords(trueBranch.slice(cmdIfCommandIndex(trueBranch)), scan, wrapperDepth + 1); + if (elseIndex !== -1) { + scanCmdWords(words.slice(elseIndex + 1), scan, wrapperDepth + 1); + } +} + +// Words after `start [options] [title] ...`, skipping options that take a +// value and a leading window title when a known command follows it. +function cmdStartTarget(inputWords) { + let words = inputWords.slice(1); + while (words.length > 0 && /^\//.test(words[0])) { + const option = words.shift().toLowerCase(); + if (/^\/(?:d|node|affinity)$/.test(option)) words.shift(); + } + const knownCommands = new Set([ + ...CMD_DELETE_COMMANDS, + ...POWERSHELL_COMMANDS, + 'call', + 'cmd', + 'for', + 'if', + 'start', + ]); + if (words.length > 1 && !knownCommands.has(commandBasename(words[0]))) { + const commandIndex = words.findIndex(word => knownCommands.has(commandBasename(word))); + if (commandIndex > 0) words = words.slice(commandIndex); + } + return words; +} + +function scanCmdWords(inputWords, scan, wrapperDepth = 0) { + if (wrapperDepth > 64) { + scan.findings.add(RULE_IDS.SCAN_DEPTH_EXCEEDED); + return; + } + + const words = inputWords.filter(Boolean).map(word => String(word)); + if (words.length === 0) return; + words[0] = words[0].replace(/^@+/, '').replace(/^\(+/, ''); + words[words.length - 1] = words[words.length - 1].replace(/\)+$/, ''); + stripCmdRedirections(words); if (words.length === 0) return; - let firstCommand = commandBasename(words[0].replace(/^@+/, '').replace(/^\(+/, '')); + const firstCommand = commandBasename(words[0].replace(/^@+/, '').replace(/^\(+/, '')); if (firstCommand === 'if') { - const elseIndex = words.findIndex((word, index) => index > 0 && /^else$/i.test(word)); - const trueBranch = elseIndex === -1 ? words : words.slice(0, elseIndex); - let commandIndex = 1; - if (/^\/i$/i.test(trueBranch[commandIndex])) commandIndex += 1; - if (/^not$/i.test(trueBranch[commandIndex])) commandIndex += 1; - if (/^(?:exist|defined|errorlevel|cmdextversion)$/i.test(trueBranch[commandIndex])) { - commandIndex += 2; - } else if (/^(?:equ|neq|lss|leq|gtr|geq)$/i.test(trueBranch[commandIndex + 1])) { - commandIndex += 3; - } else { - commandIndex += 1; - } - scanCmdWords( - trueBranch.slice(commandIndex), - depth, - findings, - analysis, - scanState, - wrapperDepth + 1 - ); - if (elseIndex !== -1) { - scanCmdWords( - words.slice(elseIndex + 1), - depth, - findings, - analysis, - scanState, - wrapperDepth + 1 - ); - } + scanCmdIf(words, scan, wrapperDepth); return; } if (firstCommand === 'for') { const doIndex = words.findIndex(word => /^do$/i.test(word)); - if (doIndex !== -1) { - scanCmdWords( - words.slice(doIndex + 1), - depth, - findings, - analysis, - scanState, - wrapperDepth + 1 - ); - } + if (doIndex !== -1) scanCmdWords(words.slice(doIndex + 1), scan, wrapperDepth + 1); return; } if (firstCommand === 'call') { - scanCmdWords(words.slice(1), depth, findings, analysis, scanState, wrapperDepth + 1); + scanCmdWords(words.slice(1), scan, wrapperDepth + 1); return; } if (firstCommand === 'start') { - words = words.slice(1); - while (words.length > 0 && /^\//.test(words[0])) { - const option = words.shift().toLowerCase(); - if (/^\/(?:d|node|affinity)$/.test(option)) words.shift(); - } - const knownCommands = new Set([ - ...CMD_DELETE_COMMANDS, - ...POWERSHELL_COMMANDS, - 'call', - 'cmd', - 'for', - 'if', - 'start', - ]); - if (words.length > 1 && !knownCommands.has(commandBasename(words[0]))) { - const commandIndex = words.findIndex(word => knownCommands.has(commandBasename(word))); - if (commandIndex > 0) words = words.slice(commandIndex); - } - scanCmdWords(words, depth, findings, analysis, scanState, wrapperDepth + 1); + scanCmdWords(cmdStartTarget(words), scan, wrapperDepth + 1); return; } if (POWERSHELL_COMMANDS.has(firstCommand) || firstCommand === 'cmd') { addNestedScan( [firstCommand, ...words.slice(1)].join(' '), - depth, - findings, - analysis, + scan.depth, + scan.findings, + scan.analysis, { executeBareScriptBlocks: true }, - scanState + scan.scanState ); return; } if (CMD_DELETE_COMMANDS.has(firstCommand) && words.slice(1).some(word => /^[-/]s$/i.test(word))) { - findings.add(RULE_IDS.CMD_RECURSIVE_DELETE); + scan.findings.add(RULE_IDS.CMD_RECURSIVE_DELETE); } } @@ -1493,14 +1574,9 @@ function scanCmd(tokens, depth, findings, analysis, scanState) { .slice(flagIndex + 1) .filter(token => token !== '--%') .join(' '); + const scan = { depth, findings, analysis, scanState }; for (const segment of splitCmdSegments(payload)) { - scanCmdWords( - segment.trim().split(/\s+/), - depth, - findings, - analysis, - scanState - ); + scanCmdWords(segment.trim().split(/\s+/), scan); } } @@ -1558,41 +1634,43 @@ function normalizeArgumentList(parts) { return payload.replace(/\s*,\s*/g, ' ').trim(); } -function scanStartProcess(tokens, depth, findings, analysis, scanState) { - const command = commandBasename(tokens[0]); - if (!['start-process', 'saps', 'start'].includes(command)) return; - if (tokens.slice(1).some((token, index) => - !(tokens.quotedTokens || [])[index + 1] && +function hasSplattedStartProcessArguments(tokens, quotedTokens) { + return tokens.slice(1).some((token, index) => + !quotedTokens[index + 1] && /^@(?:(?:global|script|local|private):)?[A-Za-z_][\w-]*$/i.test(token) - )) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - return; - } - - let executable = null; - let argumentParts = null; - const quotedTokens = tokens.quotedTokens || []; + ); +} +function startProcessFilePath(tokens, quotedTokens) { for (let index = 1; index < tokens.length; index += 1) { if (quotedTokens[index]) continue; - const parameter = startProcessParameterName(tokens[index]); - if (parameter !== 'filepath') continue; - executable = parameterValue(tokens[index]) || tokens[index + 1] || null; - break; + if (startProcessParameterName(tokens[index]) !== 'filepath') continue; + return parameterValue(tokens[index]) || tokens[index + 1] || null; } + return null; +} +function startProcessArgumentListParts(tokens, quotedTokens, index) { + const inlineValue = parameterValue(tokens[index]); + const end = tokens.findIndex( + (candidate, candidateIndex) => candidateIndex > index && + !quotedTokens[candidateIndex] && startProcessParameterName(candidate) + ); + const remaining = tokens.slice(index + 1, end === -1 ? tokens.length : end); + return inlineValue ? [inlineValue, ...remaining] : remaining; +} + +// Walk named parameters once: pick up `-ArgumentList` and, when `-FilePath` +// was absent, the first positional value as the executable. +function startProcessNamedTarget(tokens, quotedTokens, filePath) { + let executable = filePath; + let argumentParts = null; for (let index = 1; index < tokens.length; index += 1) { const token = tokens[index]; const quoted = quotedTokens[index] === true; const parameter = quoted ? null : startProcessParameterName(token); if (parameter === 'argumentlist') { - const inlineValue = parameterValue(token); - const end = tokens.findIndex( - (candidate, candidateIndex) => candidateIndex > index && - !quotedTokens[candidateIndex] && startProcessParameterName(candidate) - ); - const remaining = tokens.slice(index + 1, end === -1 ? tokens.length : end); - argumentParts = inlineValue ? [inlineValue, ...remaining] : remaining; + argumentParts = startProcessArgumentListParts(tokens, quotedTokens, index); break; } if (parameter) { @@ -1600,48 +1678,65 @@ function scanStartProcess(tokens, depth, findings, analysis, scanState) { continue; } if (!quoted && token.startsWith('-')) continue; - if (executable !== null) continue; - if (executable === null) { - executable = token; - } - } - - if (argumentParts === null && executable !== null) { - let executableSeen = false; - for (let index = 1; index < tokens.length; index += 1) { - const token = tokens[index]; - const parameter = quotedTokens[index] ? null : startProcessParameterName(token); - if (parameter) { - if (parameter === 'filepath') executableSeen = true; - if (START_PROCESS_VALUE_PARAMETERS.has(parameter) && !parameterValue(token)) index += 1; - continue; - } - if (!executableSeen && token === executable) { - executableSeen = true; - continue; - } - if (executableSeen) { - argumentParts = tokens.slice(index); - break; - } - if (!quotedTokens[index] && token.startsWith('-')) continue; + if (executable === null) executable = token; + } + return { executable, argumentParts }; +} + +// Positional arguments are everything after the executable once named +// parameters and their values are skipped. +function startProcessPositionalArguments(tokens, quotedTokens, executable) { + let executableSeen = false; + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + const parameter = quotedTokens[index] ? null : startProcessParameterName(token); + if (parameter) { + if (parameter === 'filepath') executableSeen = true; + if (START_PROCESS_VALUE_PARAMETERS.has(parameter) && !parameterValue(token)) index += 1; + continue; } + if (!executableSeen && token === executable) { + executableSeen = true; + continue; + } + if (executableSeen) return tokens.slice(index); + } + return null; +} + +function startProcessTarget(tokens, quotedTokens) { + const filePath = startProcessFilePath(tokens, quotedTokens); + const target = startProcessNamedTarget(tokens, quotedTokens, filePath); + if (target.argumentParts === null && target.executable !== null) { + target.argumentParts = startProcessPositionalArguments(tokens, quotedTokens, target.executable); + } + return target; +} + +function scanStartProcess(tokens, depth, findings, analysis, scanState) { + const command = commandBasename(tokens[0]); + if (!['start-process', 'saps', 'start'].includes(command)) return; + const quotedTokens = tokens.quotedTokens || []; + if (hasSplattedStartProcessArguments(tokens, quotedTokens)) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return; } + const { executable, argumentParts } = startProcessTarget(tokens, quotedTokens); const nestedCommand = commandBasename(executable); + if (!POWERSHELL_COMMANDS.has(nestedCommand) && nestedCommand !== 'cmd') return; + let argumentList = argumentParts ? normalizeArgumentList(argumentParts) : ''; - if (POWERSHELL_COMMANDS.has(nestedCommand) || nestedCommand === 'cmd') { - const argumentReference = variableReference(argumentList); - if (argumentReference) { - const staticValue = scanState.staticScalars.get(argumentReference); - if (staticValue === undefined) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - return; - } - argumentList = staticValue; + const argumentReference = variableReference(argumentList); + if (argumentReference) { + const staticValue = scanState.staticScalars.get(argumentReference); + if (staticValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return; } + argumentList = staticValue; } - if ((POWERSHELL_COMMANDS.has(nestedCommand) || nestedCommand === 'cmd') && argumentList) { + if (argumentList) { addNestedScan( `${executable} ${argumentList}`, depth, @@ -1868,18 +1963,14 @@ function scanInvokeScriptCalls(source, unquoted, depth, findings, analysis, stat } } -function scanPowerShell(command, depth, findings, analysis = null, options = {}, scanState = null) { - const raw = normalizeSmartQuotes(command); - if (!raw.trim()) return; +const DIRECT_COMMAND_FINDINGS = Object.freeze({ + 'clear-content': RULE_IDS.CLEAR_CONTENT, + clc: RULE_IDS.CLEAR_CONTENT, + 'clear-disk': RULE_IDS.CLEAR_DISK, + 'format-volume': RULE_IDS.FORMAT_VOLUME, +}); - const state = scanState || createScanState(); - - const hereStringExpressions = []; - const normalizedHereStrings = normalizeHereStrings(raw, hereStringExpressions); - const withoutComments = stripPowerShellComments(normalizedHereStrings); - collectStaticScalarAssignments(withoutComments, state); - const unquoted = maskQuotedStrings(withoutComments); - scanInvokeScriptCalls(withoutComments, unquoted, depth, findings, analysis, state); +function scanStaticDotNetCalls(unquoted, findings, state) { if (/\[\s*(?:system\.)?io\.directory\s*\]\s*::\s*delete\s*\(/i.test(unquoted)) { findings.add(RULE_IDS.DOTNET_DIRECTORY_DELETE); } @@ -1891,6 +1982,201 @@ function scanPowerShell(command, depth, findings, analysis = null, options = {}, while ((activatorMatch = activatorPattern.exec(unquoted)) !== null) { recordInvocation(state, `__class__:${activatorMatch[1].toLowerCase()}`); } +} + +function recordInvokedBlockVariables(unquoted, state) { + const invokedBlockVariable = /(\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.(?!getnewclosure\b)[A-Za-z_][\w-]*)*)(?:\.getnewclosure\s*\(\s*\))+\.\s*(?:invoke|invokereturnasis|invokewithcontext)\s*\(/gi; + let invokedBlockMatch; + while ((invokedBlockMatch = invokedBlockVariable.exec(unquoted)) !== null) { + recordInvocation(state, invokedBlockMatch[1].toLowerCase()); + } +} + +function scanAliasInvocation(commandName, tokens, executable, scan) { + const { state } = scan; + const aliasTarget = state.aliases.get(commandName); + if (aliasTarget) { + addNestedScan( + [aliasTarget, ...tokens.slice(1)].join(' '), + scan.depth, + scan.findings, + scan.analysis, + { executeBareScriptBlocks: true }, + state + ); + } + if (['set-alias', 'new-alias', 'sal', 'nal'].includes(commandName)) { + for (const definition of staticAliasDefinitions(tokens, executable.quotedTokens, state)) { + state.aliases.set(definition.name, definition.value); + } + } +} + +function recordNewObjectClass(tokens, quotedTokens, scan) { + const { findings, state } = scan; + let className = newObjectClassName(tokens, quotedTokens); + const classReference = variableReference(className); + if (classReference) { + const staticClassName = state.staticScalars.get(classReference); + if (staticClassName === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + className = null; + } else { + className = staticClassName; + } + } + if (className && /^[A-Za-z_][\w-]*$/.test(className)) { + recordInvocation(state, `__class__:${className.toLowerCase()}`); + } +} + +function recordClassInvocations(commandName, tokens, executable, scan) { + const { state } = scan; + const classInvocation = commandName.match(/^\[([a-z_][\w-]*)\]::/i); + if (classInvocation) recordInvocation(state, `__class__:${classInvocation[1].toLowerCase()}`); + if (commandName === 'new-object') recordNewObjectClass(tokens, executable.quotedTokens, scan); + const invokedVariable = commandName.match( + /^((?:\$\{[^}]+\}|\$(?:[a-z_][\w-]*:)?[a-z_][\w-]*(?:\[[^\]]+\]|\.[a-z_][\w-]*)*))(?:\.getnewclosure\(\))*\.(?:invoke|invokereturnasis|invokewithcontext)(?:\(|$)/i + ); + if (invokedVariable) recordInvocation(state, invokedVariable[1].toLowerCase()); + if (commandName === '.' && tokens[1]) { + recordInvocation(state, commandBasename(tokens[1])); + } +} + +// `& $variable ...` where the variable holds a static command string. +function scanCallOperatorReference(tokens, upstreamTokens, scan) { + const { findings, state } = scan; + const invokedReference = variableReference(tokens[0]); + if (!invokedReference || state.deferredFunctions.has(invokedReference)) return; + const commandValue = state.staticScalars.get(invokedReference); + if (commandValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + } else if (POWERSHELL_COMMANDS.has(commandBasename(commandValue))) { + scanNestedPowerShell( + [commandValue, ...tokens.slice(1)], + scan.depth, + findings, + scan.analysis, + state, + upstreamTokens + ); + } else { + addNestedScan( + [commandValue, ...tokens.slice(1)].join(' '), + scan.depth, + findings, + scan.analysis, + { executeBareScriptBlocks: true }, + state + ); + } +} + +function scanInvokeExpression(tokens, scan) { + const { findings, state } = scan; + let payload = tokens.slice(1).join(' '); + const payloadReference = variableReference(payload); + if (payloadReference) { + const staticValue = state.staticScalars.get(payloadReference); + if (staticValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return; + } + payload = staticValue; + } + if (payload) { + addNestedScan( + payload, + scan.depth, + findings, + scan.analysis, + { executeBareScriptBlocks: true }, + state + ); + } +} + +function scanCommandFamily(commandName, tokens, upstreamTokens, scan) { + const { findings, state } = scan; + if (POWERSHELL_COMMANDS.has(commandName)) { + scanNestedPowerShell(tokens, scan.depth, findings, scan.analysis, state, upstreamTokens); + } else if (commandName === 'cmd') { + scanCmd(tokens, scan.depth, findings, scan.analysis, state); + } else if (commandName === 'invoke-expression' || commandName === 'iex') { + scanInvokeExpression(tokens, scan); + } else if (Object.hasOwn(DIRECT_COMMAND_FINDINGS, commandName)) { + findings.add(DIRECT_COMMAND_FINDINGS[commandName]); + } +} + +/** + * Scan one pipeline segment. Returns null for inert segments, otherwise + * whether the segment deletes and whether it carries `-Recurse`. + */ +function scanStatementSegment(statement, index, scan) { + const { findings, state } = scan; + const segmentTokens = statement[index]; + const executable = executableSegment(segmentTokens); + const tokens = executable.tokens; + if (tokens.length === 0) return null; + if (executable.firstTokenQuoted && !segmentTokens.invokedByCallOperator) return null; + + const commandName = commandBasename(tokens[0]); + recordInvocation(state, commandName); + scanAliasInvocation(commandName, tokens, executable, scan); + recordClassInvocations(commandName, tokens, executable, scan); + markPowerShellElevation(tokens, scan.analysis); + scanStartProcess(tokens, scan.depth, findings, scan.analysis, state); + scanScriptBlockConsumer(tokens, executable.quotedTokens, findings, state); + if (tokens.some(token => commandBasename(token) === DYNAMIC_EXECUTION_MARKER)) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + } + if (segmentTokens.invokedByCallOperator) { + scanCallOperatorReference(tokens, statement[index - 1], scan); + } + scanCommandFamily(commandName, tokens, statement[index - 1], scan); + + return { + deletes: scanDeleteSegment(tokens, findings, executable.quotedTokens), + recurses: tokens.some( + (token, tokenIndex) => !executable.quotedTokens[tokenIndex] && + isEnabledSwitch(token, 'recurse') + ), + }; +} + +function scanStatement(statement, scan) { + const deleteSegments = new Set(); + const recurseSegments = new Set(); + + for (let index = 0; index < statement.length; index += 1) { + const result = scanStatementSegment(statement, index, scan); + if (!result) continue; + if (result.deletes) deleteSegments.add(index); + if (result.recurses) recurseSegments.add(index); + } + + const hasUpstreamRecurse = [...recurseSegments].some(index => !deleteSegments.has(index)); + if (statement.length > 1 && deleteSegments.size > 0 && hasUpstreamRecurse) { + scan.findings.add(RULE_IDS.PIPELINE_RECURSE); + } +} + +function scanPowerShell(command, depth, findings, analysis = null, options = {}, scanState = null) { + const raw = normalizeSmartQuotes(command); + if (!raw.trim()) return; + + const state = scanState || createScanState(); + const scan = { depth, findings, analysis, state }; + + const hereStringExpressions = []; + const normalizedHereStrings = normalizeHereStrings(raw, hereStringExpressions); + const withoutComments = stripPowerShellComments(normalizedHereStrings); + collectStaticScalarAssignments(withoutComments, state); + const unquoted = maskQuotedStrings(withoutComments); + scanInvokeScriptCalls(withoutComments, unquoted, depth, findings, analysis, state); + scanStaticDotNetCalls(unquoted, findings, state); for (const payload of hereStringExpressions) { addNestedScan(payload, depth, findings, analysis, { executeBareScriptBlocks: true }, state); } @@ -1902,152 +2188,14 @@ function scanPowerShell(command, depth, findings, analysis = null, options = {}, for (const definition of deferredFunctions) { registerDeferredFunction(state, { ...definition, depth }); } - const invokedBlockVariable = /(\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.(?!getnewclosure\b)[A-Za-z_][\w-]*)*)(?:\.getnewclosure\s*\(\s*\))+\.\s*(?:invoke|invokereturnasis|invokewithcontext)\s*\(/gi; - let invokedBlockMatch; - while ((invokedBlockMatch = invokedBlockVariable.exec(unquoted)) !== null) { - recordInvocation(state, invokedBlockMatch[1].toLowerCase()); - } + recordInvokedBlockVariables(unquoted, state); for (const entry of bodies) { addNestedScan(entry.body, depth, findings, analysis, entry.options, state); } for (const statement of parseStatements(outer)) { - const deleteSegments = new Set(); - const recurseSegments = new Set(); - - for (let index = 0; index < statement.length; index += 1) { - const segmentTokens = statement[index]; - const executable = executableSegment(segmentTokens); - const tokens = executable.tokens; - if (tokens.length === 0) continue; - if (executable.firstTokenQuoted && !segmentTokens.invokedByCallOperator) continue; - const commandName = commandBasename(tokens[0]); - recordInvocation(state, commandName); - const aliasTarget = state.aliases.get(commandName); - if (aliasTarget) { - addNestedScan( - [aliasTarget, ...tokens.slice(1)].join(' '), - depth, - findings, - analysis, - { executeBareScriptBlocks: true }, - state - ); - } - if (['set-alias', 'new-alias', 'sal', 'nal'].includes(commandName)) { - for (const definition of staticAliasDefinitions(tokens, executable.quotedTokens, state)) { - state.aliases.set(definition.name, definition.value); - } - } - const classInvocation = commandName.match(/^\[([a-z_][\w-]*)\]::/i); - if (classInvocation) recordInvocation(state, `__class__:${classInvocation[1].toLowerCase()}`); - if (commandName === 'new-object') { - let className = newObjectClassName(tokens, executable.quotedTokens); - const classReference = variableReference(className); - if (classReference) { - const staticClassName = state.staticScalars.get(classReference); - if (staticClassName === undefined) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - className = null; - } else { - className = staticClassName; - } - } - if (className && /^[A-Za-z_][\w-]*$/.test(className)) { - recordInvocation(state, `__class__:${className.toLowerCase()}`); - } - } - const invokedVariable = commandName.match( - /^((?:\$\{[^}]+\}|\$(?:[a-z_][\w-]*:)?[a-z_][\w-]*(?:\[[^\]]+\]|\.[a-z_][\w-]*)*))(?:\.getnewclosure\(\))*\.(?:invoke|invokereturnasis|invokewithcontext)(?:\(|$)/i - ); - if (invokedVariable) recordInvocation(state, invokedVariable[1].toLowerCase()); - if (commandName === '.' && tokens[1]) { - recordInvocation(state, commandBasename(tokens[1])); - } - markPowerShellElevation(tokens, analysis); - scanStartProcess(tokens, depth, findings, analysis, state); - scanScriptBlockConsumer(tokens, executable.quotedTokens, findings, state); - if (tokens.some(token => commandBasename(token) === DYNAMIC_EXECUTION_MARKER)) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - } - - const invokedReference = segmentTokens.invokedByCallOperator - ? variableReference(tokens[0]) - : null; - if (invokedReference && !state.deferredFunctions.has(invokedReference)) { - const commandValue = state.staticScalars.get(invokedReference); - if (commandValue === undefined) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - } else if (POWERSHELL_COMMANDS.has(commandBasename(commandValue))) { - scanNestedPowerShell( - [commandValue, ...tokens.slice(1)], - depth, - findings, - analysis, - state, - statement[index - 1] - ); - } else { - addNestedScan( - [commandValue, ...tokens.slice(1)].join(' '), - depth, - findings, - analysis, - { executeBareScriptBlocks: true }, - state - ); - } - } - - if (POWERSHELL_COMMANDS.has(commandName)) { - scanNestedPowerShell(tokens, depth, findings, analysis, state, statement[index - 1]); - } else if (commandName === 'cmd') { - scanCmd(tokens, depth, findings, analysis, state); - } else if (commandName === 'invoke-expression' || commandName === 'iex') { - let payload = tokens.slice(1).join(' '); - const payloadReference = variableReference(payload); - if (payloadReference) { - const staticValue = state.staticScalars.get(payloadReference); - if (staticValue === undefined) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - payload = ''; - } else { - payload = staticValue; - } - } - if (payload) { - addNestedScan( - payload, - depth, - findings, - analysis, - { executeBareScriptBlocks: true }, - state - ); - } - } else if (commandName === 'clear-content' || commandName === 'clc') { - findings.add(RULE_IDS.CLEAR_CONTENT); - } else if (commandName === 'clear-disk') { - findings.add(RULE_IDS.CLEAR_DISK); - } else if (commandName === 'format-volume') { - findings.add(RULE_IDS.FORMAT_VOLUME); - } - - if (scanDeleteSegment(tokens, findings, executable.quotedTokens)) deleteSegments.add(index); - if (tokens.some( - (token, tokenIndex) => !executable.quotedTokens[tokenIndex] && - isEnabledSwitch(token, 'recurse') - )) { - recurseSegments.add(index); - } - } - - const hasUpstreamRecurse = [...recurseSegments].some(index => !deleteSegments.has(index)); - if (statement.length > 1 && deleteSegments.size > 0 && hasUpstreamRecurse) { - findings.add(RULE_IDS.PIPELINE_RECURSE); - } + scanStatement(statement, scan); } - } function resolveDeferredFunctions(findings, analysis, state) {