diff --git a/docs/security/ecc-039-powershell-gateguard-plan.md b/docs/security/ecc-039-powershell-gateguard-plan.md new file mode 100644 index 0000000000..c77889f60c --- /dev/null +++ b/docs/security/ecc-039-powershell-gateguard-plan.md @@ -0,0 +1,255 @@ +# ECC-039 PowerShell GateGuard and Audit Alignment Plan + +## Status + +- Ticket: ECC-039 +- Size: large +- Priority: critical +- Baseline: `origin/main` at `e04ea0b9` +- Source to salvage: PR #2721 at `4a2e59ba` +- Implementation state: implemented and under Gate 2 review + +The fix spans the security enforcement path, governance evidence, configured +hook routing, post-tool dispatch, and cross-platform regression coverage. It is +large because the stale PR changes eight files, conflicts with current `main`, +and must establish one consistent policy/evidence contract. + +## Objective + +Make PowerShell a governed arbitrary-command shell with one destructive-command +classification result shared by pre-execution denial and governance evidence. +Every PowerShell command denied as destructive must produce an +`approval_requested` event when governance capture is enabled. + +## Verified Current State + +Current `main` has no dedicated PowerShell GateGuard route and excludes +PowerShell from governance capture. PR #2721 adds the route and most of the +detector, but its exact head still has these reproduced mismatches: + +| Command class | PR #2721 GateGuard | PR #2721 governance | +|---|---|---| +| Direct recursive `Remove-Item` | deny | approval event | +| Destructive command inside `$()` | allow | approval event | +| Force-only `Remove-Item` | deny | no event | +| Wildcard `Remove-Item` | deny | no event | +| `.NET Directory::Delete` | deny | no event | +| `Clear-Content` | allow | approval event | +| `Format-Volume` | allow | approval event | +| Benign `Get-ChildItem` | allow | no event | + +The focused PR-head suites pass with 166 GateGuard tests and 35 governance +tests. Those green suites do not cover the mismatches above. A direct +`merge-tree` check against current `main` reports conflicts in +`scripts/hooks/gateguard-fact-force.js` and `tests/hooks/hooks.test.js`. + +Applying the stale PR files wholesale would also discard current-main heredoc +filtering, narrow recovery guidance, valid `.*` hook matchers, post-dispatcher +skill tracking, and newer hook tests. + +## Prior Art Review + +The implementation was informed by existing and merged alternatives before any +production code was changed: + +- PR #2721 supplied the original PowerShell route and detection inventory, but + its conflicted head had GateGuard/governance drift and removed backticks + before parsing, which changes PowerShell escape meaning. +- PRs #1912 and #2495 established the useful bounded executable-body traversal + and parser-focused test patterns. Their Bash parser was not reused because + Bash backslashes and backticks have different semantics from PowerShell. +- PR #2902 showed the safe forward-port pattern used here: retain current-main + heredoc filtering, narrow recovery hints, and valid `.*` matchers while + applying only the feature-specific changes. +- PR #2897 reinforced that quoted delimiters must not terminate executable + ranges and that executable expressions inside double quotes still run. +- PR #2865 and related open work cover separate Bash and hook hardening. Those + changes remain outside ECC-039 and were not absorbed into this patch. + +## Design Decision + +Add a pure shared module at +`scripts/lib/powershell-destructive-command.js`. It returns stable, +non-sensitive rule IDs for all matches. GateGuard denies when the result is +non-empty, and governance uses the same result to emit approval evidence. + +The module owns PowerShell-specific parsing and policy: + +- `Remove-Item`, `Remove-ItemProperty`, and built-in aliases +- `-Recurse` and valid unambiguous abbreviations +- `-Force` without recursion +- wildcard targets and opaque splatted parameters +- pipeline-wide recursion evidence +- `.NET` `Directory::Delete` and `File::Delete` +- `cmd /c` recursive deletion +- nested `powershell` and `pwsh -Command` +- `Start-Process` and static nested-shell argument forms +- UTF-16LE `-EncodedCommand` +- `Clear-Content`, `Clear-Disk`, and `Format-Volume` +- static aliases, functions, script blocks, class construction, and common + execution primitives +- fail-closed `powershell.dynamic-execution` evidence when an execution + primitive cannot be resolved safely +- bounded recursion that fails closed after executable nesting exceeds budget + +The parser extracts balanced PowerShell `$()` bodies recursively. It treats +subexpressions outside quotes and inside double quotes as executable, ignores +single-quoted literals, respects backtick-escaped dollar signs, and handles +nested parentheses without deleting escape characters before parsing. + +GateGuard retains its current Bash classifier. The PowerShell path combines the +existing shell-agnostic destructive classifications with the new shared +PowerShell findings. Governance preserves its current Bash approval behavior +and consumes the shared PowerShell findings for the PowerShell tool. + +## Task List + +1. Add red classifier and consumer tests. + - Create `tests/lib/powershell-destructive-command.test.js`. + - Add identical destructive and benign command tables to the GateGuard and + governance consumer tests. + - Prove the direct configured PowerShell route denies a recursive delete, + while `$()` and evidence-parity cases fail before implementation. + +2. Implement the shared PowerShell classifier. + - Port only the valuable detection behavior from PR #2721. + - Return stable rule IDs instead of raw command text or a bare boolean. + - Add quote-aware, nesting-aware `$()` extraction and recursive scanning. + - Preserve bounded work and conservative failure on opaque executable input. + +3. Integrate GateGuard from current `main`. + - Normalize the `PowerShell` tool name. + - Add the PowerShell classifier to the existing shell branch. + - Preserve first-denial and retry state semantics. + - Emit the PowerShell hook ID in routine denial recovery guidance. + - Preserve current heredoc stripping, denial dampening, and narrow recovery + hints. + +4. Integrate governance evidence. + - Add PowerShell to the security-relevant tool set. + - Emit one `approval_requested` event from the shared findings. + - Store stable rule IDs and the existing command fingerprint only. + - Preserve secret redaction and avoid raw command text in events. + +5. Wire the configured entry points. + - Add one dedicated PowerShell PreToolUse GateGuard route to + `hooks/hooks.json`. + - Add PowerShell to the pre-governance matcher. + - Add PowerShell to post-governance dispatch only, keeping Bash-only post + hooks restricted to Bash. + - Preserve current `.*` matcher syntax and all current-main routes. + +6. Exercise the real hook commands. + - Run the exact command read from `hooks/hooks.json` for denial and + governance capture with isolated state and unique sessions. + - Clear ambient GateGuard opt-out variables in fixtures. + - Verify the post-tool dispatcher selects governance for PowerShell. + +7. Complete review and verification. + - Run focused unit and hook suites, then the full repository suite and + coverage. + - Run a security review for parser bypasses, quote false positives, command + leakage, recursion-budget behavior, and Bash regressions. + - Resolve every critical or high finding before commit review. + +## Acceptance Matrix + +| Command class | GateGuard | Governance evidence | +|---|---|---| +| Recursive `Remove-Item` and aliases | deny first attempt | approval event | +| Force-only `Remove-Item` | deny | approval event | +| Wildcard or splatted delete | deny | approval event | +| `.NET Directory::Delete` or `File::Delete` | deny | approval event | +| `Clear-Content`, `Clear-Disk`, `Format-Volume` | deny | approval event | +| Nested `pwsh -Command` or encoded command | deny | approval event | +| Destructive command in unquoted `$()` | deny | approval event | +| Destructive command in double-quoted `$()` | deny | approval event | +| Recursively nested executable `$()` | deny | approval event | +| Same text in a single-quoted literal | no destructive denial | no event | +| Backtick-escaped literal `$()` | no destructive denial | no event | +| Plain `Remove-Item file.txt` | allow under current policy | no event | +| `Get-ChildItem` or `Get-Date` | allow | no event | +| Existing Bash destructive and heredoc cases | unchanged | unchanged | +| Configured PreToolUse route | command denies | event when enabled | +| Configured PostToolUse route | not applicable | reaches governance | + +## Verification + +Run in this order: + +```sh +node tests/lib/powershell-destructive-command.test.js +node tests/hooks/gateguard-fact-force.test.js +node tests/hooks/governance-capture.test.js +node tests/hooks/hooks.test.js +node tests/hooks/posttooluse-dispatcher.test.js +npm test +npm run coverage +git diff --check +``` + +Hosted acceptance requires the repository security scan, lint, coverage, and +the supported Node and package-manager CI matrix at the exact proposed head. + +## Implementation and Verification Results + +The implementation is complete locally and remains uncommitted for Gate 2. +It adds the shared classifier, dedicated PowerShell hook routes, exact +GateGuard/governance rule parity, redacted evidence, case-insensitive tool +matching, and post-tool governance dispatch. + +- Focused classifier and hook suites: 529 passed, 0 failed. +- Full repository suite: 4,215 passed, 0 failed. +- Coverage gate: passed at 89.23% statements, 81.28% branches, 94.55% + functions, and 89.23% lines. +- Supply-chain IOC scan: passed for all 224 inspected files. +- ESLint, Markdown lint, hook validation, personal-path validation, and + `git diff --check`: passed. +- Independent final security replay: no critical or high findings across 109 + destructive cases, 19 benign controls, 9 elevation cases, and 13 + GateGuard/governance parity cases. +- The 40,000-container, approximately 840 KB stress input completed well below + the configured five-second hook timeout and preserved the destructive tail + finding. + +PowerShell itself is not installed in the local PATH, so the repository's +native `install.ps1` delegation checks were skipped by their existing runtime +guard. Classifier, configured-hook, governance, and dispatcher behavior were +still exercised through the Node hook boundary. + +## Risks and Controls + +- PowerShell quoting and backtick semantics can cause bypasses or false + positives. Use explicit executable and literal pairs for each parser case. +- Short parameter prefixes can become ambiguous. Test only valid prefixes for + the intended cmdlets and keep rule IDs visible in unit failures. +- Encoded and deeply nested commands can consume unbounded work. Enforce a + shared recursion budget and fail closed only after executable nesting is + observed. +- Dynamic execution can hide a command from static inspection. Resolve common + static forms and return `powershell.dynamic-execution` for unresolved + execution primitives or shell-launch splats. +- Governance records can leak command content. Reuse the existing fingerprint + and summary path and assert that emitted events contain no raw command. +- A stale-PR merge can regress current hardening. Port PowerShell hunks manually + onto `origin/main` and keep current-main regression tests green. + +## Roadmap and Scope + +This is post-2.2 hardening of the ECC 2 trustworthy substrate. It makes the +policy/evidence seam truthful at configured hook boundaries and prepares for +future evidence contracts while keeping ECC authoritative over policy, +enforcement, canonical evidence, and workflow outcomes. + +Out of scope are a general PowerShell parser, exact interpretation of arbitrary +runtime-generated payloads or reflection, broader Bash classifier refactoring, +public API changes, issue #2921 glob semantics, issue #2886 heredoc redesign, +ExecutionCapsule, sandbox tiers, Feature Fleet, Itô, and Nasiko. Unresolved +execution primitives fail closed instead of being interpreted. Current-main +behavior for #2886 remains covered and unchanged. + +Known non-bypass residuals are conservative classification of unresolved safe +dynamic execution and `Start-Process` splats, plus whole-class scanning when a +class is activated. Whole-class scanning can flag an uncalled destructive +method when a safe sibling member is invoked. Separating constructor and method +resolution is a precision improvement, not a release-blocking enforcement gap. diff --git a/hooks/hooks.json b/hooks/hooks.json index f1c82b5158..62053904e0 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -13,6 +13,18 @@ "description": "Consolidated Bash preflight dispatcher for quality, tmux, push, and GateGuard checks", "id": "pre:bash:dispatcher" }, + { + "matcher": "PowerShell", + "hooks": [ + { + "type": "command", + "command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i 0) { // Gate destructive commands on first attempt; allow retry after facts presented const key = '__destructive__' + crypto.createHash('sha256').update(command).digest('hex').slice(0, 16); if (!isChecked(key)) { @@ -1273,7 +1302,7 @@ function run(rawInput) { return rawInput; // allow retry after facts presented } - // Operator opt-out: skip the routine-bash gate entirely. The destructive + // Operator opt-out: skip the routine shell gate entirely. The destructive // gate above still fires. This is the documented escape hatch for hosts // (Cursor, OpenCode, etc.) where the once-per-session routine gate is // friction without signal. @@ -1285,9 +1314,13 @@ function run(rawInput) { if (!markChecked(ROUTINE_BASH_SESSION_KEY)) { return allowWithStateWarning(); } - return denyResult(routineBashMsg(), { - hookIds: [BASH_HOOK_ID], - narrowRecoveryHint: ROUTINE_BASH_NARROW_RECOVERY_HINT + const hookId = toolName === 'PowerShell' ? POWERSHELL_HOOK_ID : BASH_HOOK_ID; + const narrowRecoveryHint = toolName === 'PowerShell' + ? ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT + : ROUTINE_BASH_NARROW_RECOVERY_HINT; + return denyResult(routineShellMsg(toolName), { + hookIds: [hookId], + narrowRecoveryHint }); } @@ -1297,4 +1330,4 @@ function run(rawInput) { return rawInput; // allow } -module.exports = { run }; +module.exports = { classifyDestructiveCommand, run }; diff --git a/scripts/hooks/governance-capture.js b/scripts/hooks/governance-capture.js index b38187c277..2d161d2327 100644 --- a/scripts/hooks/governance-capture.js +++ b/scripts/hooks/governance-capture.js @@ -19,8 +19,17 @@ 'use strict'; const crypto = require('crypto'); +const { isElevatedPowerShellCommand } = require('../lib/powershell-destructive-command'); const MAX_STDIN = 1024 * 1024; +let destructiveCommandClassifier = null; + +function classifyDestructiveCommand(toolName, command) { + if (!destructiveCommandClassifier) { + destructiveCommandClassifier = require('./gateguard-fact-force').classifyDestructiveCommand; + } + return destructiveCommandClassifier(toolName, command); +} // Patterns that indicate potential hardcoded secrets const SECRET_PATTERNS = [ @@ -34,6 +43,7 @@ const SECRET_PATTERNS = [ // Tool names that represent security-relevant operations const SECURITY_RELEVANT_TOOLS = new Set([ 'Bash', // Could execute arbitrary commands + 'PowerShell', ]); // Commands that require governance approval @@ -123,8 +133,20 @@ function summarizeCommand(command) { }; } + const firstToken = trimmed.split(/\s+/)[0] || ''; + // Static method invocations can attach their arguments to the first token, + // for example `[IO.File]::Delete('private-path')`. Keep the operation name + // while excluding attached argument content from governance evidence. + const operation = firstToken.split('(', 1)[0].replace(/^['"]|['"]$/g, ''); + let commandName = null; + if (/^\[(?:[A-Za-z_][\w]*\.)*[A-Za-z_][\w]*\]::[A-Za-z_][\w-]*$/.test(operation)) { + commandName = operation; + } else if (/^[A-Za-z_][A-Za-z0-9_.:\\/-]*$/.test(operation)) { + commandName = operation.split(/[\\/]/).pop() || null; + } + return { - commandName: trimmed.split(/\s+/)[0] || null, + commandName, commandFingerprint: fingerprintCommand(trimmed), }; } @@ -142,7 +164,11 @@ function emitGovernanceEvent(event) { */ function analyzeForGovernanceEvents(input, context = {}) { const events = []; - const toolName = input.tool_name || ''; + const rawToolName = input.tool_name || ''; + const normalizedToolName = String(rawToolName).toLowerCase(); + const toolName = normalizedToolName === 'powershell' + ? 'PowerShell' + : normalizedToolName === 'bash' ? 'Bash' : rawToolName; const toolInput = input.tool_input || {}; const toolOutput = typeof input.tool_output === 'string' ? input.tool_output : ''; const sessionId = context.sessionId || null; @@ -174,13 +200,17 @@ function analyzeForGovernanceEvents(input, context = {}) { }); } - // 2. Approval-required commands (Bash only) - if (toolName === 'Bash') { + // 2. Approval-required commands. Bash retains its existing approval + // patterns. PowerShell consumes the exact classifier result used by + // GateGuard so denial and governance evidence cannot drift apart. + if (toolName === 'Bash' || toolName === 'PowerShell') { const command = toolInput.command || ''; - const approvalFindings = detectApprovalRequired(command); + const matchedPatterns = toolName === 'PowerShell' + ? classifyDestructiveCommand(toolName, command) + : detectApprovalRequired(command).map(finding => finding.pattern); const commandSummary = summarizeCommand(command); - if (approvalFindings.length > 0) { + if (matchedPatterns.length > 0) { events.push({ id: generateEventId(), sessionId, @@ -189,7 +219,7 @@ function analyzeForGovernanceEvents(input, context = {}) { toolName, hookPhase, ...commandSummary, - matchedPatterns: approvalFindings.map(f => f.pattern), + matchedPatterns, severity: 'high', }, resolvedAt: null, @@ -220,7 +250,9 @@ function analyzeForGovernanceEvents(input, context = {}) { // 4. Security-relevant tool usage tracking if (SECURITY_RELEVANT_TOOLS.has(toolName) && hookPhase === 'post') { const command = toolInput.command || ''; - const hasElevated = /sudo\s/.test(command) || /chmod\s/.test(command) || /chown\s/.test(command); + const hasElevated = toolName === 'PowerShell' + ? isElevatedPowerShellCommand(command) + : /sudo\s/.test(command) || /chmod\s/.test(command) || /chown\s/.test(command); const commandSummary = summarizeCommand(command); if (hasElevated) { diff --git a/scripts/hooks/posttooluse-dispatcher.js b/scripts/hooks/posttooluse-dispatcher.js index ac4345afc7..fcffeb4008 100644 --- a/scripts/hooks/posttooluse-dispatcher.js +++ b/scripts/hooks/posttooluse-dispatcher.js @@ -27,7 +27,7 @@ const SYNC_HOOKS = [ { id: 'post:edit:design-quality-check', matcher: 'Edit|Write|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/design-quality-check.js', run: runDesignQualityCheck }, { id: 'post:edit:accumulator', matcher: 'Edit|Write|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/post-edit-accumulator.js', run: runPostEditAccumulator }, { id: 'post:edit:console-warn', matcher: 'Edit', profiles: 'standard,strict', script: 'scripts/hooks/post-edit-console-warn.js', run: runConsoleWarn }, - { id: 'post:governance-capture', matcher: 'Bash|Write|Edit|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/governance-capture.js', run: runGovernanceCapture }, + { id: 'post:governance-capture', matcher: 'Bash|PowerShell|Write|Edit|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/governance-capture.js', run: runGovernanceCapture }, { id: 'post:session-activity-tracker', matcher: '*', profiles: 'standard,strict', script: 'scripts/hooks/session-activity-tracker.js', run: runSessionActivityTracker }, { id: 'post:ecc-metrics-bridge', matcher: '*', profiles: 'minimal,standard,strict', script: 'scripts/hooks/ecc-metrics-bridge.js', run: runMetricsBridge }, { id: 'post:ecc-context-monitor', matcher: '*', profiles: 'standard,strict', script: 'scripts/hooks/ecc-context-monitor.js', run: runContextMonitor } @@ -55,13 +55,14 @@ function getPluginRoot(env = process.env) { } function matchesTool(matcher, toolName) { + const normalizedToolName = String(toolName || '').toLowerCase(); return ( matcher === '*' || String(matcher || '') .split('|') .map(value => value.trim()) .filter(Boolean) - .includes(String(toolName || '')) + .some(value => value.toLowerCase() === normalizedToolName) ); } diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js new file mode 100644 index 0000000000..fe8d1d43d7 --- /dev/null +++ b/scripts/lib/powershell-destructive-command.js @@ -0,0 +1,1802 @@ +'use strict'; + +/** + * Pure PowerShell destructive-command classifier. + * + * This is deliberately a small policy parser rather than a PowerShell + * interpreter. It understands the quoting, escaping, subexpression, and + * nested-shell forms needed to make GateGuard and governance reach the same + * decision without retaining raw command text. + */ + +const RULE_IDS = Object.freeze({ + REMOVE_RECURSE: 'powershell.remove-item.recurse', + REMOVE_FORCE: 'powershell.remove-item.force', + REMOVE_WILDCARD: 'powershell.remove-item.wildcard', + REMOVE_SPLAT: 'powershell.remove-item.splat', + PIPELINE_RECURSE: 'powershell.remove-item.pipeline-recurse', + CLEAR_CONTENT: 'powershell.clear-content', + CLEAR_DISK: 'powershell.clear-disk', + FORMAT_VOLUME: 'powershell.format-volume', + DOTNET_DIRECTORY_DELETE: 'powershell.dotnet.directory-delete', + DOTNET_FILE_DELETE: 'powershell.dotnet.file-delete', + CMD_RECURSIVE_DELETE: 'powershell.cmd.recursive-delete', + DYNAMIC_EXECUTION: 'powershell.dynamic-execution', + SCAN_DEPTH_EXCEEDED: 'powershell.scan-depth-exceeded', +}); + +const DELETE_COMMANDS = new Set([ + 'remove-item', + 'remove-itemproperty', + 'ri', + 'rm', + 'rmdir', + 'rd', + 'del', + 'erase', +]); + +const POWERSHELL_COMMANDS = new Set(['powershell', 'pwsh']); +const CMD_DELETE_COMMANDS = new Set(['rd', 'rmdir', 'del', 'erase']); +const START_PROCESS_VALUE_PARAMETERS = new Set([ + 'argumentlist', + 'credential', + 'environment', + 'filepath', + 'redirectstandarderror', + 'redirectstandardinput', + 'redirectstandardoutput', + 'verb', + 'windowstyle', + 'workingdirectory', +]); +const START_PROCESS_SWITCH_PARAMETERS = new Set([ + 'loaduserprofile', + 'nonewwindow', + 'passthru', + 'usenewenvironment', + 'wait', +]); +const MAX_SCAN_DEPTH = 4; +const MAX_CONTEXT_LENGTH = 4096; +const DYNAMIC_EXECUTION_MARKER = '__ecc_dynamic_execution__'; + +function normalizeSmartQuotes(value) { + return String(value || '') + .replace(/[\u2018\u2019\u201a\u201b]/g, "'") + .replace(/[\u201c\u201d\u201e]/g, '"') + .replace(/[\u2013\u2014\u2015]/g, '-'); +} + +function commandBasename(value) { + const parts = String(value || '').split(/[\\/]/); + return (parts[parts.length - 1] || '').replace(/\.exe$/i, '').toLowerCase(); +} + +function isParameterPrefix(token, parameter) { + const raw = String(token || ''); + if (!raw.startsWith('-')) return false; + const name = raw.replace(/^-+/, '').split(':')[0].toLowerCase(); + return name.length > 0 && parameter.startsWith(name); +} + +function isEnabledSwitch(token, parameter) { + if (!isParameterPrefix(token, parameter)) return false; + const separator = String(token).indexOf(':'); + if (separator === -1) return true; + return !/^\$?(?:false|null|0)$/i.test(String(token).slice(separator + 1)); +} + +function isEncodedCommandFlag(token) { + return isParameterPrefix(token, 'encodedcommand'); +} + +function isCommandFlag(token) { + const name = String(token || '').replace(/^-+/, '').split(':')[0].toLowerCase(); + return isParameterPrefix(token, 'command') || + isParameterPrefix(token, 'commandwithargs') || name === 'cwa'; +} + +function normalizeHereStrings(input, executablePayloads = []) { + const output = [...input]; + const replacements = []; + let ordinaryQuote = null; + let lineComment = false; + let blockComment = false; + let bracedVariable = false; + + 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; + 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[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 contentEnd = closingEnd === -1 ? input.length : closingEnd - 2; + const content = input.slice(openerLineEnd + 1, contentEnd); + + // Represent a here-string as one ordinary literal token. Standalone + // literals remain inert, while static consumers such as Invoke-Expression + // and `pwsh -Command -` can recover the value from normal token flow. + replacements.push({ + end: closingEnd === -1 ? input.length : closingEnd, + 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; + } + } + + if (closingEnd === -1) break; + index = closingEnd - 1; + } + + 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; + } + 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; + + 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; + } + } + + return output.join(''); +} + +/** + * Read one balanced PowerShell container. Quotes do not affect delimiter + * balance, and a backtick protects exactly the following character. Callers + * stop after the first unmatched opener, which keeps malformed input linear. + */ +function readBalancedGroup(input, openingIndex, open, close) { + let depth = 1; + let quote = null; + let lineComment = false; + let blockComment = false; + let bracedVariable = false; + + for (let index = openingIndex + 1; index < input.length; 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 (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) { + depth -= 1; + if (depth === 0) { + return { + body: input.slice(openingIndex + 1, index), + end: index + 1, + }; + } + } + } + + return null; +} + +/** + * Decide whether a script block is executed at its declaration site. Function + * and variable declarations remain inert, while call operators, control-flow + * clauses, and common script-block-consuming commands execute their bodies. + */ +function currentClause(prefix) { + const clauseStart = Math.max( + prefix.lastIndexOf(';'), + prefix.lastIndexOf('\n'), + prefix.lastIndexOf('\r') + ); + return prefix.slice(clauseStart + 1).trim(); +} + +function invokesContainerResult(prefix) { + const clause = currentClause(prefix); + const pipelineStart = clause.lastIndexOf('|'); + const pipelineCommand = clause.slice(pipelineStart + 1).trim(); + 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); +} + +function invokesDynamicResult(prefix) { + return invokesContainerResult(prefix) || + /(?:^|\s)(?:iex|invoke-expression)\s*$/i.test(currentClause(prefix)); +} + +function deferredScriptBlockName(prefix) { + const clause = currentClause(prefix); + const functionMatch = clause.match(/^(?:function|filter|workflow)\s+(?:(?:global|local|script|private):)?([A-Za-z_][\w-]*)\b/i); + if (functionMatch) return functionMatch[1].toLowerCase(); + const classMatch = clause.match(/^class\s+([A-Za-z_][\w-]*)\b/i); + if (classMatch) return `__class__:${classMatch[1].toLowerCase()}`; + const variableMatch = clause.match( + /^((?:\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.[A-Za-z_][\w-]*)*))\s*=\s*$/ + ); + return variableMatch ? variableMatch[1].toLowerCase() : null; +} + +function isExecutableScriptBlock(prefix, options = {}) { + if (options.executeBareScriptBlocks) return true; + const clause = currentClause(prefix); + const pipelineStart = clause.lastIndexOf('|'); + const pipelineCommand = clause.slice(pipelineStart + 1).trim(); + + if (invokesContainerResult(prefix)) return true; + if (/^(?:if|elseif|else|for|foreach|while|do|switch|default|try|catch|finally|trap|begin|process|end|dynamicparam|clean)\b/i.test(clause)) { + return true; + } + return /^(?:(?:[\w.-]+\\)?(?:foreach-object|where-object|foreach|where|invoke-command|start-job|measure-command)|%|\?)(?:\s|$)/i.test(pipelineCommand); +} + +function isInvokedAfterContainer(input, end) { + let index = end; + const skipSpacing = () => { + while (index < input.length) { + if (/\s/.test(input[index])) { + index += 1; + } else if (input[index] === '`' && /[\r\n]/.test(input[index + 1] || '')) { + index += input[index + 1] === '\r' && input[index + 2] === '\n' ? 3 : 2; + } else { + break; + } + } + }; + + while (index < input.length) { + skipSpacing(); + if (input[index] !== '.') return false; + index += 1; + skipSpacing(); + + let method = ''; + const quote = input[index] === "'" || input[index] === '"' ? input[index++] : null; + while (index < input.length) { + const char = input[index]; + if (char === '`' && index + 1 < input.length) { + method += input[index + 1]; + index += 2; + } else if (quote ? char === quote : !/[A-Za-z]/.test(char)) { + if (quote) index += 1; + break; + } else { + method += char; + index += 1; + } + } + skipSpacing(); + if (input[index] !== '(') return false; + + const normalizedMethod = method.toLowerCase(); + if (['invoke', 'invokereturnasis', 'invokewithcontext'].includes(normalizedMethod)) { + return true; + } + if (normalizedMethod !== 'getnewclosure') return false; + index += 1; + skipSpacing(); + if (input[index] !== ')') return false; + index += 1; + } + return false; +} + +function staticStringResult(body) { + const value = String(body || '').trim(); + if (value.length < 2) return null; + const quote = value[0]; + if ((quote !== "'" && quote !== '"') || value[value.length - 1] !== quote) return null; + const content = value.slice(1, -1); + return quote === "'" ? content.replace(/''/g, "'") : content.replace(/`(.)/gs, '$1'); +} + +function staticScalarResult(body, depth = 0) { + if (depth > MAX_SCAN_DEPTH) return null; + const value = String(body || '').trim(); + const literal = staticStringResult(value); + if (literal !== null) return literal; + + const isSubexpression = value.startsWith('$('); + const openingIndex = isSubexpression ? 1 : 0; + if (value[openingIndex] !== '(') return null; + const group = readBalancedGroup(value, openingIndex, '(', ')'); + if (!group || group.end !== value.length) return null; + return staticScalarResult(group.body, depth + 1); +} + +function staticCommandResult(body) { + const value = staticScalarResult(body); + const command = value === null ? '' : value.trim(); + return command && /^[A-Za-z_][\w./\\-]*$/.test(command) ? command : null; +} + +function staticStringArrayResult(body) { + const input = String(body || ''); + const items = []; + let item = ''; + let quote = null; + let depth = 0; + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + if (char === '`' && quote === '"' && index + 1 < input.length) { + item += char + input[index + 1]; + index += 1; + continue; + } + if (quote === "'" && char === "'" && input[index + 1] === "'") { + item += "''"; + index += 1; + continue; + } + if (char === "'" || char === '"') { + quote = quote === char ? null : (quote || char); + item += char; + continue; + } + if (!quote && char === '(') depth += 1; + if (!quote && char === ')') depth -= 1; + if (!quote && depth === 0 && char === ',') { + items.push(item); + item = ''; + continue; + } + item += char; + } + if (quote || depth !== 0) return null; + items.push(item); + const values = items.map(value => staticScalarResult(value)); + return values.length > 0 && values.every(value => value !== null) + ? values.join(' ') + : null; +} + +function staticTypeNameResult(body) { + const value = String(body || '').trim(); + const match = value.match(/^\[([A-Za-z_][\w-]*)\]$/); + if (match) return match[1]; + const scalar = staticScalarResult(value); + if (scalar !== null && /^[A-Za-z_][\w-]*$/.test(scalar)) return scalar; + const openingIndex = value.startsWith('(') ? 0 : -1; + if (openingIndex === -1) return null; + const group = readBalancedGroup(value, openingIndex, '(', ')'); + return group && group.end === value.length ? staticTypeNameResult(group.body) : null; +} + +function variableReference(value) { + const variable = String(value || '').trim(); + return /^(?:\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.[A-Za-z_][\w-]*)*)$/.test(variable) + ? variable.toLowerCase() + : null; +} + +function staticOutputResult(body, depth = 0) { + if (depth > MAX_SCAN_DEPTH) return null; + const scalar = staticScalarResult(body); + if (scalar !== null) return scalar.trim(); + const value = String(body || '').trim(); + const openingIndex = value.startsWith('$(') ? 1 : 0; + if (value[openingIndex] === '(') { + const group = readBalancedGroup(value, openingIndex, '(', ')'); + if (group && group.end === value.length) { + return staticOutputResult(group.body, depth + 1); + } + } + const statements = parseStatements(value); + if (statements.length !== 1 || statements[0].length !== 1) return null; + const tokens = statements[0][0]; + const command = commandBasename(tokens[0]); + if ((command !== 'write-output' && command !== 'echo') || tokens.length < 2) return null; + return tokens.slice(1).join(' '); +} + +function isPipedToPowerShellStdin(input, end) { + return /^\s*\|\s*(?:pwsh|powershell)(?:\.exe)?\s+-(?:command|c)\s+-\s*(?:[;\r\n]|$)/i.test( + input.slice(end) + ); +} + +/** + * Extract executable `$()`, `@()`, grouping parentheses, and selected script + * 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 resetContext = () => { + 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 += ' '; + } else { + context += contextChar; + } + if (context.length > MAX_CONTEXT_LENGTH) { + context = context.slice(-Math.floor(MAX_CONTEXT_LENGTH / 2)); + contextTruncated = true; + } + } + }; + + 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); + } + 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; + } + + 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); + if (!group) { + for (let offset = index; offset < input.length; offset += 1) masked[offset] = ' '; + break; + } + + const withinDoubleQuote = quote === '"'; + const prefix = context; + 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 && invokesContainerResult(prefix)), + }, + }); + } 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 || invokesContainerResult(prefix)) { + resolvedCommand = staticOutputResult(group.body); + } 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) && !executableBlockExpression) { + resolvedCommand = DYNAMIC_EXECUTION_MARKER; + } + if (resolvedCommand) { + for (let offset = 0; offset < resolvedCommand.length; offset += 1) { + masked[index + offset] = resolvedCommand[offset]; + } + if (!withinDoubleQuote) appendContext(resolvedCommand); + } else if (isScriptBlock) { + if (invokesContainerResult(prefix)) { + context = prefix; + } else { + resetContext(); + } + } else if (!withinDoubleQuote) { + appendContext(' '); + } + index = group.end - 1; + } + + return { bodies, deferredFunctions, outer: masked.join('') }; +} + +/** + * Split PowerShell into statements, pipelines, and dequoted words. Backticks + * 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 word = ''; + let wordHasQuotedContent = false; + let wordHasUnquotedContent = false; + let quote = null; + let parenDepth = 0; + let callOperatorPending = false; + + const flushWord = () => { + if (word) { + segment.push(word); + segmentQuotedTokens.push(wordHasQuotedContent && !wordHasUnquotedContent); + } + word = ''; + wordHasQuotedContent = false; + wordHasUnquotedContent = false; + }; + const flushSegment = () => { + flushWord(); + if (segment.length) { + Object.defineProperties(segment, { + invokedByCallOperator: { value: callOperatorPending }, + quotedTokens: { value: segmentQuotedTokens }, + }); + statement.push(segment); + callOperatorPending = false; + } + segment = []; + segmentQuotedTokens = []; + }; + const flushStatement = () => { + flushSegment(); + if (statement.length) statements.push(statement); + statement = []; + }; + + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + + if (quote === "'") { + if (char === "'" && input[index + 1] === "'") { + word += "'"; + index += 1; + } else if (char === "'") { + quote = null; + } else { + word += char; + wordHasQuotedContent = true; + } + continue; + } + + if (char === '`') { + if (index + 1 >= input.length) { + word += '`'; + continue; + } + const escaped = input[index + 1]; + index += 1; + if (escaped === '\n' || escaped === '\r') { + flushWord(); + if (escaped === '\r' && input[index + 1] === '\n') index += 1; + } else { + word += escaped; + if (quote) wordHasQuotedContent = true; + else wordHasUnquotedContent = true; + } + continue; + } + + if (quote === '"') { + if (char === '"') { + quote = null; + } else { + word += char; + wordHasQuotedContent = true; + } + continue; + } + + if (char === "'" || char === '"') { + quote = char; + wordHasQuotedContent = true; + continue; + } + + if (char === '(') { + parenDepth += 1; + word += char; + wordHasUnquotedContent = true; + continue; + } + if (char === ')' && parenDepth > 0) { + parenDepth -= 1; + word += 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; + } + + word += char; + wordHasUnquotedContent = true; + } + + flushStatement(); + return statements; +} + +function maskQuotedStrings(input) { + let output = ''; + let quote = null; + + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + if (quote === "'") { + output += ' '; + if (char === "'" && input[index + 1] === "'") { + output += ' '; + index += 1; + } else if (char === "'") { + quote = null; + } + continue; + } + if (char === '`') { + if (index + 1 < input.length) { + output += quote ? ' ' : input[index + 1]; + index += 1; + } else { + output += quote ? ' ' : '`'; + } + continue; + } + if (quote === '"') { + output += ' '; + if (char === '"') quote = null; + continue; + } + if (char === "'" || char === '"') { + quote = char; + output += ' '; + continue; + } + output += char; + } + + return output; +} + +function decodeUtf16LeBase64(value) { + const encoded = String(value || '').trim(); + if (!encoded || encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) { + return null; + } + + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.length === 0 || bytes.length % 2 !== 0) return null; + if (bytes.toString('base64').replace(/=+$/, '') !== encoded.replace(/=+$/, '')) return null; + + const decoded = bytes.toString('utf16le'); + if (!decoded || decoded.includes('\uFFFD') || decoded.includes('\u0000')) return null; + return decoded; +} + +function createScanState() { + return { + deferredFunctions: new Map(), + invokedCommands: new Set(), + pendingInvocations: [], + resolvingFunctions: false, + scannedFunctions: new Set(), + staticScalars: new Map(), + aliases: new Map(), + }; +} + +function collectStaticScalarAssignments(input, state) { + const variable = String.raw`(\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.[A-Za-z_][\w-]*)*)`; + const assignmentCounts = new Map(); + const assignmentPattern = new RegExp(`${variable}\\s*(?:\\+=|-=|\\*=|\\/=|%=|=)`, 'g'); + let assignmentMatch; + while ((assignmentMatch = assignmentPattern.exec(input)) !== null) { + const name = assignmentMatch[1].toLowerCase(); + assignmentCounts.set(name, (assignmentCounts.get(name) || 0) + 1); + } + const pattern = new RegExp( + String.raw`(?:^|[;\r\n])\s*${variable}\s*=\s*(?:'((?:''|[^'])*)'|"((?:\x60[\s\S]|[^"])*)")\s*(?=;|\r?\n|$)`, + 'g' + ); + let match; + while ((match = pattern.exec(input)) !== null) { + if (match[3] !== undefined && /(^|[^`])\$/.test(match[3])) continue; + const value = match[2] !== undefined + ? match[2].replace(/''/g, "'") + : match[3].replace(/`(.)/gs, '$1'); + state.staticScalars.set(match[1].toLowerCase(), value); + } + for (const [name, count] of assignmentCounts) { + if (count !== 1) state.staticScalars.delete(name); + } +} + +function recordInvocation(state, commandName) { + if (!commandName || state.invokedCommands.has(commandName)) return; + state.invokedCommands.add(commandName); + if (state.resolvingFunctions) state.pendingInvocations.push(commandName); +} + +function registerDeferredFunction(state, definition) { + const definitions = state.deferredFunctions.get(definition.functionName) || []; + definitions.push(definition); + state.deferredFunctions.set(definition.functionName, definitions); + if (state.resolvingFunctions && state.invokedCommands.has(definition.functionName)) { + state.pendingInvocations.push(definition.functionName); + } +} + +function addNestedScan(payload, depth, findings, analysis, options = {}, scanState = null) { + if (depth >= MAX_SCAN_DEPTH) { + findings.add(RULE_IDS.SCAN_DEPTH_EXCEEDED); + return; + } + scanPowerShell(payload, depth + 1, findings, analysis, options, scanState); +} + +function staticPipelineInput(tokens) { + if (!tokens || tokens.length === 0) return null; + if (tokens.length === 1) { + const value = String(tokens[0] || ''); + return value || null; + } + const command = commandBasename(tokens[0]); + if ((command === 'write-output' || command === 'echo') && tokens.length === 2) { + const value = String(tokens[1] || ''); + return tokens.quotedTokens?.[1] === true || /\s/.test(value) ? value : null; + } + return null; +} + +function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upstreamTokens = null) { + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + + if (isEncodedCommandFlag(token)) { + const decoded = decodeUtf16LeBase64(tokens[index + 1]); + if (decoded !== null) addNestedScan(decoded, depth, findings, analysis, {}, scanState); + return; + } + + if (isCommandFlag(token)) { + const payload = tokens.slice(index + 1).join(' '); + const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens) : null; + if (pipelinePayload || (payload && payload !== '-')) { + addNestedScan( + pipelinePayload || payload, + depth, + findings, + analysis, + { executeBareScriptBlocks: true }, + scanState + ); + } + return; + } + } +} + +function splitCmdSegments(payload) { + const segments = []; + let segment = ''; + let quote = false; + + for (let index = 0; index < payload.length; index += 1) { + const char = payload[index]; + if (char === '^' && index + 1 < payload.length) { + segment += payload[index + 1]; + index += 1; + continue; + } + if (char === '"') { + quote = !quote; + continue; + } + if (!quote && (char === '&' || char === '|')) { + if (segment.trim()) segments.push(segment.trim()); + segment = ''; + continue; + } + segment += char; + } + if (segment.trim()) segments.push(segment.trim()); + 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(/\)+$/, ''); + + while (words.length > 0 && /^\d*(?:>>?|<>?|< 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 + ); + } + 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 + ); + } + return; + } + if (firstCommand === 'call') { + scanCmdWords(words.slice(1), depth, findings, analysis, scanState, 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); + return; + } + + if (POWERSHELL_COMMANDS.has(firstCommand) || firstCommand === 'cmd') { + addNestedScan( + [firstCommand, ...words.slice(1)].join(' '), + depth, + findings, + analysis, + { executeBareScriptBlocks: true }, + scanState + ); + return; + } + if (CMD_DELETE_COMMANDS.has(firstCommand) && words.slice(1).some(word => /^[-/]s$/i.test(word))) { + findings.add(RULE_IDS.CMD_RECURSIVE_DELETE); + } +} + +function scanCmd(tokens, depth, findings, analysis, scanState) { + const flagIndex = tokens.findIndex((token, index) => index > 0 && /^\/[ck]$/i.test(token)); + if (flagIndex === -1) return; + + const payload = tokens + .slice(flagIndex + 1) + .filter(token => token !== '--%') + .join(' '); + for (const segment of splitCmdSegments(payload)) { + scanCmdWords( + segment.trim().split(/\s+/), + depth, + findings, + analysis, + scanState + ); + } +} + +function scanDeleteSegment(tokens, findings, quotedTokens = []) { + if (tokens.length === 0) return false; + + const command = commandBasename(tokens[0]); + if (!DELETE_COMMANDS.has(command)) return false; + + const usesLiteralPath = tokens.slice(1).some( + (token, index) => !quotedTokens[index + 1] && isParameterPrefix(token, 'literalpath') + ); + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!quotedTokens[index] && token.startsWith('@')) { + findings.add(RULE_IDS.REMOVE_SPLAT); + continue; + } + if (!quotedTokens[index] && isEnabledSwitch(token, 'recurse')) { + findings.add(RULE_IDS.REMOVE_RECURSE); + continue; + } + if (!quotedTokens[index] && isEnabledSwitch(token, 'force')) { + findings.add(RULE_IDS.REMOVE_FORCE); + continue; + } + if (!usesLiteralPath && !token.startsWith('-') && /[*?]/.test(token)) { + findings.add(RULE_IDS.REMOVE_WILDCARD); + } + } + + return true; +} + +function parameterValue(token) { + const separator = String(token || '').indexOf(':'); + return separator === -1 ? '' : String(token).slice(separator + 1); +} + +function startProcessParameterName(token) { + const raw = String(token || ''); + if (!raw.startsWith('-')) return null; + const name = raw.replace(/^-+/, '').split(':')[0].toLowerCase(); + if (name === 'args') return 'argumentlist'; + const candidates = [...START_PROCESS_VALUE_PARAMETERS, ...START_PROCESS_SWITCH_PARAMETERS] + .filter(parameter => parameter.startsWith(name)); + return candidates.length === 1 ? candidates[0] : null; +} + +function normalizeArgumentList(parts) { + let payload = parts.join(' ').trim(); + if (/^@?\(/.test(payload) && /\)$/.test(payload)) { + payload = payload.replace(/^@?\(\s*/, '').replace(/\s*\)$/, ''); + } + 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] && + /^@(?:(?: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 || []; + + 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; + } + + 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; + break; + } + if (parameter) { + if (START_PROCESS_VALUE_PARAMETERS.has(parameter) && !parameterValue(token)) index += 1; + 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; + } + } + + const nestedCommand = commandBasename(executable); + 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; + } + } + if ((POWERSHELL_COMMANDS.has(nestedCommand) || nestedCommand === 'cmd') && argumentList) { + addNestedScan( + `${executable} ${argumentList}`, + depth, + findings, + analysis, + { executeBareScriptBlocks: true }, + scanState + ); + } +} + +function isAssignmentTarget(value) { + const variable = String(value || ''); + const oneTarget = String.raw`(?:\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.[A-Za-z_][\w-]*)*)`; + return new RegExp(`^(?:\\[[^\\]]+\\])?${oneTarget}(?:,${oneTarget})*$`).test(variable); +} + +function executableSegment(tokens) { + if (!tokens || tokens.length === 0) { + return { firstTokenQuoted: false, quotedTokens: [], tokens: [] }; + } + const first = String(tokens[0] || ''); + const inlineAssignment = first.match(/^(.+?)(\+=|-=|\*=|\/=|%=|=)(.+)$/); + if (inlineAssignment && isAssignmentTarget(inlineAssignment[1])) { + return { + firstTokenQuoted: false, + quotedTokens: [false, ...(tokens.quotedTokens || []).slice(1)], + tokens: [inlineAssignment[3], ...tokens.slice(1)], + }; + } + if (tokens.length >= 2 && isAssignmentTarget(first) && /^(?:=|\+=|-=|\*=|\/=|%=)$/.test(tokens[1])) { + return { + firstTokenQuoted: tokens.quotedTokens?.[2] === true, + quotedTokens: (tokens.quotedTokens || []).slice(2), + tokens: tokens.slice(2), + }; + } + if (/^(?:return)$/i.test(first) && tokens.length > 1) { + return { + firstTokenQuoted: tokens.quotedTokens?.[1] === true, + quotedTokens: (tokens.quotedTokens || []).slice(1), + tokens: tokens.slice(1), + }; + } + return { + firstTokenQuoted: tokens.quotedTokens?.[0] === true, + quotedTokens: tokens.quotedTokens || [], + tokens, + }; +} + +function newObjectClassName(tokens, quotedTokens = []) { + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!quotedTokens[index] && isParameterPrefix(token, 'typename')) { + return parameterValue(token) || tokens[index + 1] || null; + } + if (!String(token).startsWith('-')) return token; + } + return null; +} + +function markPowerShellElevation(tokens, analysis) { + if (!analysis || analysis.elevated || tokens.length === 0) return; + const commandName = commandBasename(tokens[0]); + if (['set-acl', 'icacls', 'takeown', 'runas', 'sudo', 'chmod', 'chown'].includes(commandName)) { + analysis.elevated = true; + return; + } + if (!['start-process', 'saps', 'start'].includes(commandName)) return; + + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!isParameterPrefix(token, 'verb')) continue; + const inlineValue = String(token).split(':').slice(1).join(':'); + const value = inlineValue || tokens[index + 1] || ''; + if (/^runas$/i.test(value)) analysis.elevated = true; + return; + } +} + +function scanScriptBlockConsumer(tokens, quotedTokens, findings, state) { + const command = commandBasename(tokens[0]); + const consumers = new Set([ + 'foreach', + 'foreach-object', + 'icm', + 'invoke-command', + 'measure-command', + 'register-engineevent', + 'register-objectevent', + 'register-wmievent', + 'start-job', + 'sajb', + 'trace-command', + 'where', + 'where-object', + '%', + '?', + ]); + if (!consumers.has(command)) return; + + for (const token of tokens.slice(1)) { + const reference = variableReference(token); + if (reference && state.deferredFunctions.has(reference)) recordInvocation(state, reference); + } + + if (tokens.length === 2) { + const positionalReference = variableReference(tokens[1]); + if (positionalReference) { + recordInvocation(state, positionalReference); + if (!state.deferredFunctions.has(positionalReference)) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + } + return; + } + } + + const parameters = [ + 'action', + 'begin', + 'end', + 'expression', + 'filter', + 'initializationscript', + 'parallel', + 'process', + 'scriptblock', + ]; + for (let index = 1; index < tokens.length; index += 1) { + if (quotedTokens[index]) continue; + const parameter = parameters.find(name => isParameterPrefix(tokens[index], name)); + if (!parameter) continue; + const reference = variableReference(parameterValue(tokens[index]) || tokens[index + 1]); + if (!reference) continue; + recordInvocation(state, reference); + if (!state.deferredFunctions.has(reference)) findings.add(RULE_IDS.DYNAMIC_EXECUTION); + } +} + +function staticAliasDefinition(tokens, quotedTokens = []) { + let name = null; + let value = null; + const positional = []; + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!quotedTokens[index] && isParameterPrefix(token, 'name')) { + name = parameterValue(token) || tokens[++index] || null; + } else if (!quotedTokens[index] && isParameterPrefix(token, 'value')) { + value = parameterValue(token) || tokens[++index] || null; + } else if (!String(token).startsWith('-')) { + positional.push(token); + } + } + name ||= positional[0] || null; + value ||= positional[1] || null; + if (!/^[A-Za-z_][\w-]*$/.test(name || '') || !/^[A-Za-z_][\w./\\-]*$/.test(value || '')) { + return null; + } + return { name: name.toLowerCase(), value }; +} + +function scanInvokeScriptCalls(source, unquoted, depth, findings, analysis, state) { + const pattern = /\$executioncontext\.invokecommand\.invokescript\s*\(/gi; + while (pattern.exec(unquoted) !== null) { + const argumentSource = source.slice(pattern.lastIndex); + const literal = argumentSource.match(/^\s*(?:'(?:''|[^'])*'|"(?:`[\s\S]|[^"])*")/); + const payload = literal ? staticStringResult(literal[0].trim()) : null; + if (payload === null) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + } else { + addNestedScan( + payload, + depth, + findings, + analysis, + { executeBareScriptBlocks: true }, + state + ); + } + } +} + +function scanPowerShell(command, depth, findings, analysis = null, options = {}, scanState = null) { + const raw = normalizeSmartQuotes(command); + if (!raw.trim()) return; + + 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); + if (/\[\s*(?:system\.)?io\.directory\s*\]\s*::\s*delete\s*\(/i.test(unquoted)) { + findings.add(RULE_IDS.DOTNET_DIRECTORY_DELETE); + } + if (/\[\s*(?:system\.)?io\.file\s*\]\s*::\s*delete\s*\(/i.test(unquoted)) { + findings.add(RULE_IDS.DOTNET_FILE_DELETE); + } + const activatorPattern = /\[\s*(?:system\.)?activator\s*\]\s*::\s*createinstance\s*\(\s*\[([A-Za-z_][\w-]*)\]/gi; + let activatorMatch; + while ((activatorMatch = activatorPattern.exec(unquoted)) !== null) { + recordInvocation(state, `__class__:${activatorMatch[1].toLowerCase()}`); + } + for (const payload of hereStringExpressions) { + addNestedScan(payload, depth, findings, analysis, { executeBareScriptBlocks: true }, state); + } + + const { bodies, deferredFunctions, outer } = extractExecutableContainers(withoutComments, { + ...options, + staticScalars: state.staticScalars, + }); + 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()); + } + 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 (commandName === 'set-alias' || commandName === 'new-alias') { + const definition = staticAliasDefinition(tokens, executable.quotedTokens); + if (definition) 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); + } + } + +} + +function resolveDeferredFunctions(findings, analysis, state) { + state.pendingInvocations.push(...state.invokedCommands); + state.resolvingFunctions = true; + for (let cursor = 0; cursor < state.pendingInvocations.length; cursor += 1) { + const commandName = state.pendingInvocations[cursor]; + const definitions = state.deferredFunctions.get(commandName) || []; + for (const definition of definitions) { + if (state.scannedFunctions.has(definition)) continue; + state.scannedFunctions.add(definition); + const options = definition.functionName.startsWith('__class__:') + ? { executeBareScriptBlocks: true } + : {}; + addNestedScan(definition.body, definition.depth, findings, analysis, options, state); + } + } + state.resolvingFunctions = false; +} + +function classifyPowerShellDestructiveCommand(command) { + if (typeof command !== 'string' || !command.trim()) return []; + + const findings = new Set(); + const state = createScanState(); + scanPowerShell(command, 0, findings, null, {}, state); + resolveDeferredFunctions(findings, null, state); + return [...findings]; +} + +function isElevatedPowerShellCommand(command) { + if (typeof command !== 'string' || !command.trim()) return false; + + const analysis = { elevated: false }; + const state = createScanState(); + const findings = new Set(); + scanPowerShell(command, 0, findings, analysis, {}, state); + resolveDeferredFunctions(findings, analysis, state); + return analysis.elevated; +} + +module.exports = { + RULE_IDS, + classifyPowerShellDestructiveCommand, + isElevatedPowerShellCommand, +}; diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 54a19c0e04..f62b5c8032 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -105,6 +105,38 @@ function runBashHook(input, env = {}) { }; } +function runPowerShellHook(input, env = {}) { + const rawInput = typeof input === 'string' ? input : JSON.stringify(input); + const result = spawnSync( + 'node', + [ + runner, + 'pre:powershell:gateguard-fact-force', + 'scripts/hooks/gateguard-fact-force.js', + 'standard,strict' + ], + { + input: rawInput, + encoding: 'utf8', + env: { + ...process.env, + ECC_HOOK_PROFILE: 'standard', + GATEGUARD_STATE_DIR: stateDir, + CLAUDE_SESSION_ID: TEST_SESSION_ID, + ...env + }, + timeout: 15000, + stdio: ['pipe', 'pipe', 'pipe'] + } + ); + + return { + code: Number.isInteger(result.status) ? result.status : 1, + stdout: result.stdout || '', + stderr: result.stderr || '' + }; +} + function parseOutput(stdout) { try { return JSON.parse(stdout); @@ -2860,6 +2892,147 @@ function runTests() { passed++; else failed++; + // --- PowerShell tool consumer contract --- + if ( + test('normalizes PowerShell tool-name casing before destructive classification', () => { + for (const toolName of ['PowerShell', 'powershell', 'POWERSHELL']) { + clearState(); + const result = runPowerShellHook({ + tool_name: toolName, + tool_input: { command: 'Remove-Item -Force C:/tmp/demo' } + }); + assert.strictEqual(result.code, 0, `${toolName} hook should exit 0`); + const output = parseOutput(result.stdout); + assert.ok(output, `${toolName} should produce JSON output`); + assert.strictEqual( + output.hookSpecificOutput?.permissionDecision, + 'deny', + `${toolName} should be denied` + ); + assert.match( + output.hookSpecificOutput.permissionDecisionReason, + /Destructive command detected/ + ); + } + }) + ) + passed++; + else failed++; + + if ( + test('denies the first routine PowerShell command and allows its retry', () => { + clearState(); + const input = { + tool_name: 'PowerShell', + tool_input: { command: 'Get-Date' } + }; + + const first = runPowerShellHook(input); + assert.strictEqual(first.code, 0, 'first PowerShell hook should exit 0'); + const firstOutput = parseOutput(first.stdout); + assert.ok(firstOutput, 'first PowerShell attempt should produce JSON output'); + assert.strictEqual( + firstOutput.hookSpecificOutput?.permissionDecision, + 'deny', + 'first routine PowerShell command should be denied' + ); + assert.match( + firstOutput.hookSpecificOutput.permissionDecisionReason, + /pre:powershell:gateguard-fact-force/, + 'recovery guidance should name the independently configurable PowerShell hook ID' + ); + + const retry = runPowerShellHook(input); + assert.strictEqual(retry.code, 0, 'PowerShell retry should exit 0'); + const retryOutput = parseOutput(retry.stdout); + assert.ok(retryOutput, 'PowerShell retry should produce JSON output'); + if (retryOutput.hookSpecificOutput) { + assert.notStrictEqual( + retryOutput.hookSpecificOutput.permissionDecision, + 'deny', + 'routine PowerShell retry should be allowed' + ); + } else { + assert.strictEqual(retryOutput.tool_name, 'PowerShell'); + } + }) + ) + passed++; + else failed++; + + if ( + test('denies direct and nested destructive PowerShell commands', () => { + const commands = [ + 'Remove-Item -Recurse C:/tmp/demo', + 'Clear-Disk -Number 2 -RemoveData -Confirm:$false', + 'pwsh -Command "Remove-Item -Force C:/tmp/demo"', + 'Write-Output "$(Remove-Item -Force C:/tmp/demo)"', + '& { Remove-Item -Force C:/tmp/demo }', + 'if ($true) { Remove-Item -Force C:/tmp/demo }', + '@(Remove-Item -Force C:/tmp/demo)', + 'cmd /c "rd /s /q C:/tmp/demo"', + 'Remove-Item `\n-Force C:/tmp/demo', + '# (\nRemove-Item -Force C:/tmp/demo', + '<# ignored <# #> Remove-Item -Force C:/tmp/demo', + 'function cleanup { Remove-Item -Force C:/tmp/demo }; if ($true) { cleanup }', + 'cmd /c pwsh -Command "Remove-Item -Force C:/tmp/demo"', + '@"\n" # $(Remove-Item -Force C:/tmp/demo)\n"@', + '& ‘Remove-Item’ -Force C:/tmp/demo', + 'Invoke-Expression $runtimeValue' + ]; + + for (const command of commands) { + clearState(); + const result = runPowerShellHook({ + tool_name: 'PowerShell', + tool_input: { command } + }); + assert.strictEqual(result.code, 0, `${command} hook should exit 0`); + const output = parseOutput(result.stdout); + assert.ok(output, `${command} should produce JSON output`); + assert.strictEqual( + output.hookSpecificOutput?.permissionDecision, + 'deny', + `${command} should be denied` + ); + assert.match( + output.hookSpecificOutput.permissionDecisionReason, + /Destructive command detected/ + ); + } + }) + ) + passed++; + else failed++; + + if ( + test('allows benign PowerShell after the shared routine shell gate is satisfied', () => { + clearState(); + writeState({ checked: ['__bash_session__'], last_active: Date.now() }); + + for (const command of ['Get-ChildItem C:/tmp', 'Remove-Item C:/tmp/notes.txt']) { + const result = runPowerShellHook({ + tool_name: 'PowerShell', + tool_input: { command } + }); + assert.strictEqual(result.code, 0, `${command} hook should exit 0`); + const output = parseOutput(result.stdout); + assert.ok(output, `${command} should produce JSON output`); + if (output.hookSpecificOutput) { + assert.notStrictEqual( + output.hookSpecificOutput.permissionDecision, + 'deny', + `${command} should not receive a destructive denial` + ); + } else { + assert.strictEqual(output.tool_name, 'PowerShell'); + } + } + }) + ) + passed++; + else failed++; + // Cleanup only the temp directory created by this test file. try { if (fs.existsSync(stateDir)) { diff --git a/tests/hooks/governance-capture.test.js b/tests/hooks/governance-capture.test.js index df118594af..528c593e62 100644 --- a/tests/hooks/governance-capture.test.js +++ b/tests/hooks/governance-capture.test.js @@ -185,6 +185,219 @@ async function runTests() { assert.ok(/^[a-f0-9]{12}$/.test(securityEvent.payload.commandFingerprint), 'Expected short command fingerprint'); assert.ok(!Object.prototype.hasOwnProperty.call(securityEvent.payload, 'command'), 'Should not store raw command text'); })) passed += 1; else failed += 1; + + if (await test('PowerShell approval events contain exact destructive rule IDs without raw commands', async () => { + const encodedPayload = Buffer.from( + 'Remove-Item C:/private/encoded-command-sentinel/*', + 'utf16le' + ).toString('base64'); + const cases = [ + { + command: 'Remove-Item -Recurse -Force C:/private/remove-command-sentinel', + expectedRules: [ + 'powershell.remove-item.recurse', + 'powershell.remove-item.force', + ], + }, + { + command: 'Remove-Item C:/private/wildcard-command-sentinel/*', + expectedRules: ['powershell.remove-item.wildcard'], + }, + { + command: 'Remove-Item @deleteParams', + expectedRules: ['powershell.remove-item.splat'], + }, + { + command: 'Get-ChildItem C:/private/pipeline-command-sentinel -Recurse | Remove-Item', + expectedRules: ['powershell.remove-item.pipeline-recurse'], + }, + { + command: 'Clear-Content C:/private/clear-command-sentinel.txt', + expectedRules: ['powershell.clear-content'], + }, + { + command: 'Clear-Disk -Number 2 -RemoveData -Confirm:$false', + expectedRules: ['powershell.clear-disk'], + }, + { + command: 'Format-Volume -DriveLetter D -Force', + expectedRules: ['powershell.format-volume'], + }, + { + command: "[System.IO.Directory]::Delete('C:/private/dotnet-command-sentinel', $true)", + expectedRules: ['powershell.dotnet.directory-delete'], + }, + { + command: "[IO.File]::Delete('C:/private/file-command-sentinel.txt')", + expectedRules: ['powershell.dotnet.file-delete'], + }, + { + command: 'cmd /c rd /s /q C:/private/cmd-command-sentinel', + expectedRules: ['powershell.cmd.recursive-delete'], + }, + { + command: 'pwsh -Command "Remove-Item -Force C:/private/nested-command-sentinel"', + expectedRules: ['powershell.remove-item.force'], + }, + { + 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'], + }, + { + command: '<# ignored <# #> Remove-Item -Force C:/private/comment-command-sentinel', + expectedRules: ['powershell.remove-item.force'], + }, + { + command: 'function cleanup { Remove-Item -Force C:/private/function-command-sentinel }; $(cleanup)', + expectedRules: ['powershell.remove-item.force'], + }, + { + command: 'cmd /c pwsh -Command "Remove-Item -Force C:/private/cmd-pwsh-sentinel"', + expectedRules: ['powershell.remove-item.force'], + }, + { + command: 'Invoke-Expression $runtimeValue', + expectedRules: ['powershell.dynamic-execution'], + }, + { + command: 'git switch --discard-changes', + expectedRules: ['gateguard.bash-compatible-destructive'], + }, + ]; + + for (const { command, expectedRules } of cases) { + const events = analyzeForGovernanceEvents({ + tool_name: 'PowerShell', + tool_input: { command }, + }, { + hookPhase: 'pre', + }); + const approvalEvent = events.find(event => event.eventType === 'approval_requested'); + + assert.ok(approvalEvent, `${command} should raise approval_requested`); + assert.strictEqual(approvalEvent.payload.toolName, 'PowerShell'); + assert.deepStrictEqual( + [...approvalEvent.payload.matchedPatterns].sort(), + [...expectedRules].sort(), + `${command} should preserve exact classifier rule IDs` + ); + assert.ok( + /^[a-f0-9]{12}$/.test(approvalEvent.payload.commandFingerprint), + 'Expected short command fingerprint' + ); + assert.ok( + !Object.prototype.hasOwnProperty.call(approvalEvent.payload, 'command'), + 'Should not store raw command text' + ); + assert.ok( + !JSON.stringify(approvalEvent).includes(command), + 'Serialized governance evidence should not leak the raw command' + ); + } + })) passed += 1; else failed += 1; + + if (await test('PowerShell governance ignores literal and benign delete text', async () => { + const commands = [ + 'Get-ChildItem C:/tmp', + 'Get-Date', + 'Remove-Item C:/tmp/notes.txt', + "Write-Output '$(Remove-Item -Force C:/tmp/demo)'", + 'Write-Output "`$(Remove-Item -Force C:/tmp/demo)"', + ]; + + for (const command of commands) { + const events = analyzeForGovernanceEvents({ + tool_name: 'PowerShell', + tool_input: { command }, + }, { + hookPhase: 'pre', + }); + + assert.ok( + !events.some(event => event.eventType === 'approval_requested'), + `${command} should not raise approval_requested` + ); + } + })) passed += 1; else failed += 1; + + if (await test('PowerShell governance normalizes tool casing and redacts assignment prefixes', async () => { + const command = "$password='governance-secret-sentinel'; Remove-Item -Force C:/tmp/demo"; + for (const toolName of ['PowerShell', 'powershell', 'POWERSHELL']) { + const events = analyzeForGovernanceEvents({ + tool_name: toolName, + tool_input: { command }, + }, { + hookPhase: 'pre', + }); + const approvalEvent = events.find(event => event.eventType === 'approval_requested'); + assert.ok(approvalEvent, `${toolName} should raise approval_requested`); + assert.strictEqual(approvalEvent.payload.toolName, 'PowerShell'); + assert.strictEqual(approvalEvent.payload.commandName, null); + assert.ok(!JSON.stringify(events).includes('governance-secret-sentinel')); + } + })) passed += 1; else failed += 1; + + if (await test('PowerShell elevation events are captured without raw command leakage', async () => { + const commands = [ + 'Start-Process -Verb RunAs cmd -ArgumentList elevation-command-sentinel', + 'Start-Process –Verb RunAs cmd', + 'Start-Process -Verb $("RunAs") cmd', + 'Start-Process -Verb ("RunAs") cmd', + 'saps pwsh -Verb RunAs', + 'start pwsh -Verb RunAs', + 'runas.exe /user:Administrator cmd', + 'sudo chmod 600 C:/private/native-elevation-sentinel', + '$script:aclResult = Set-Acl -Path C:/private/scoped-assignment-sentinel -AclObject $acl', + 'Set-Acl -Path C:/private/acl-command-sentinel -AclObject $acl', + 'takeown /f C:/private/ownership-command-sentinel', + "& 'Set-Acl' -Path C:/private/call-operator-sentinel -AclObject $acl", + 'Microsoft.PowerShell.Security\\Set-Acl -Path C:/private/module-sentinel -AclObject $acl', + 'Set`-Acl -Path C:/private/backtick-sentinel -AclObject $acl', + 'Write-Output $(Set-Acl -Path C:/private/subexpression-sentinel -AclObject $acl)', + ]; + + for (const command of commands) { + const events = analyzeForGovernanceEvents({ + tool_name: 'PowerShell', + tool_input: { command }, + }, { + hookPhase: 'post', + }); + const securityEvent = events.find(event => event.eventType === 'security_finding'); + + assert.ok(securityEvent, `${command} should raise a security_finding`); + assert.strictEqual(securityEvent.payload.toolName, 'PowerShell'); + assert.strictEqual(securityEvent.payload.reason, 'elevated_privilege_command'); + assert.ok( + /^[a-f0-9]{12}$/.test(securityEvent.payload.commandFingerprint), + 'Expected short command fingerprint' + ); + assert.ok( + !Object.prototype.hasOwnProperty.call(securityEvent.payload, 'command'), + 'Should not store raw command text' + ); + assert.ok( + !JSON.stringify(securityEvent).includes(command), + 'Serialized governance evidence should not leak the raw command' + ); + } + + const literalEvents = analyzeForGovernanceEvents({ + tool_name: 'PowerShell', + tool_input: { command: "Write-Output 'Start-Process -Verb RunAs cmd'" }, + }, { + hookPhase: 'post', + }); + assert.ok( + !literalEvents.some(event => event.eventType === 'security_finding'), + 'quoted elevation prose should not raise a security finding' + ); + })) passed += 1; else failed += 1; + if (await test('analyzeForGovernanceEvents detects sensitive file access', async () => { const events = analyzeForGovernanceEvents({ tool_name: 'Edit', diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index ce3411b153..442cca64bf 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -2599,6 +2599,107 @@ async function runTests() { passed++; else failed++; + if ( + test('hooks.json gives PowerShell dedicated GateGuard and governance routes', () => { + const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); + const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); + const powerShellRoutes = hooks.hooks.PreToolUse.filter(entry => entry.matcher === 'PowerShell'); + const governanceRoute = hooks.hooks.PreToolUse.find(entry => entry.id === 'pre:governance-capture'); + + assert.strictEqual( + powerShellRoutes.length, + 1, + 'Should have exactly one dedicated PreToolUse PowerShell route' + ); + assert.strictEqual( + powerShellRoutes[0].id, + 'pre:powershell:gateguard-fact-force', + 'PowerShell should use its independently configurable GateGuard hook ID' + ); + assert.ok( + powerShellRoutes[0].hooks[0].command.includes('pre:powershell:gateguard-fact-force'), + 'Configured command should preserve the PowerShell GateGuard hook ID' + ); + assert.ok( + powerShellRoutes[0].hooks[0].command.includes('scripts/hooks/gateguard-fact-force.js'), + 'PowerShell route should invoke GateGuard without Bash-only preflight hooks' + ); + assert.ok(governanceRoute, 'PreToolUse governance route should exist'); + assert.ok( + governanceRoute.matcher.split('|').includes('PowerShell'), + 'PreToolUse governance matcher should include PowerShell' + ); + assert.ok( + hooks.hooks.PostToolUse.every(entry => entry.matcher === '.*'), + 'Top-level PostToolUse dispatchers should preserve current-main wildcard matchers' + ); + }) + ) + passed++; + else failed++; + + if ( + test('configured PowerShell routes enforce denial and emit redacted governance evidence', () => { + const root = path.join(__dirname, '..', '..'); + const hooks = JSON.parse(fs.readFileSync(path.join(root, 'hooks', 'hooks.json'), 'utf8')); + const gateRoute = hooks.hooks.PreToolUse.find(entry => entry.id === 'pre:powershell:gateguard-fact-force'); + const governanceRoute = hooks.hooks.PreToolUse.find(entry => entry.id === 'pre:governance-capture'); + const stateDir = createTestDir(); + const command = 'Remove-Item -Force C:/private/configured-route-sentinel'; + const payload = JSON.stringify({ + tool_name: 'PowerShell', + tool_input: { command } + }); + const env = { + ...process.env, + CLAUDE_PLUGIN_ROOT: root, + ECC_HOOK_PROFILE: 'standard', + GATEGUARD_STATE_DIR: stateDir, + CLAUDE_SESSION_ID: 'ecc039-configured-route-test' + }; + for (const key of ['ECC_GATEGUARD', 'GATEGUARD_DISABLED', 'GATEGUARD_BASH_ROUTINE_DISABLED', 'ECC_DISABLED_HOOKS']) { + delete env[key]; + } + + try { + const gated = spawnSync(gateRoute.hooks[0].command, { + cwd: root, + env, + input: payload, + encoding: 'utf8', + shell: true, + timeout: 15000 + }); + assert.strictEqual(gated.status, 0, gated.stderr); + assert.strictEqual( + JSON.parse(gated.stdout).hookSpecificOutput?.permissionDecision, + 'deny', + 'exact configured GateGuard command should deny destructive PowerShell' + ); + + const governed = spawnSync(governanceRoute.hooks[0].command, { + cwd: root, + env: { + ...env, + ECC_GOVERNANCE_CAPTURE: '1', + CLAUDE_HOOK_EVENT_NAME: 'PreToolUse' + }, + input: payload, + encoding: 'utf8', + shell: true, + timeout: 15000 + }); + assert.strictEqual(governed.status, 0, governed.stderr); + assert.ok(governed.stderr.includes('powershell.remove-item.force')); + assert.ok(!governed.stderr.includes(command), 'governance evidence should omit raw command text'); + } finally { + cleanupTestDir(stateDir); + } + }) + ) + passed++; + else failed++; + if ( test('all string hook matchers are valid regular expressions', () => { const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); diff --git a/tests/hooks/posttooluse-dispatcher.test.js b/tests/hooks/posttooluse-dispatcher.test.js index 0ce83581e4..c21f003f35 100644 --- a/tests/hooks/posttooluse-dispatcher.test.js +++ b/tests/hooks/posttooluse-dispatcher.test.js @@ -30,7 +30,9 @@ function runDispatcher(mode, toolName, env = {}) { const raw = JSON.stringify({ hook_event_name: 'PostToolUse', tool_name: toolName, - tool_input: toolName === 'Bash' ? { command: 'true' } : { file_path: path.join(os.tmpdir(), 'ecc-posttooluse-test.txt') }, + tool_input: ['Bash', 'PowerShell'].includes(toolName) + ? { command: 'true' } + : { file_path: path.join(os.tmpdir(), 'ecc-posttooluse-test.txt') }, tool_response: {} }); @@ -126,6 +128,16 @@ function runTests() { sync: ['post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'], async: ['post:bash:dispatcher', 'post:observe:continuous-learning'] }, + { + tool: 'PowerShell', + sync: ['post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'], + async: ['post:observe:continuous-learning'] + }, + { + tool: 'powershell', + sync: ['post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'], + async: ['post:observe:continuous-learning'] + }, { tool: 'Read', sync: ['post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'], diff --git a/tests/lib/powershell-destructive-command.test.js b/tests/lib/powershell-destructive-command.test.js new file mode 100644 index 0000000000..0589c0225f --- /dev/null +++ b/tests/lib/powershell-destructive-command.test.js @@ -0,0 +1,692 @@ +'use strict'; + +const assert = require('assert'); +const { + classifyPowerShellDestructiveCommand, +} = require('../../scripts/lib/powershell-destructive-command'); + +const RULES = Object.freeze({ + REMOVE_RECURSE: 'powershell.remove-item.recurse', + REMOVE_FORCE: 'powershell.remove-item.force', + REMOVE_WILDCARD: 'powershell.remove-item.wildcard', + REMOVE_SPLAT: 'powershell.remove-item.splat', + PIPELINE_RECURSE: 'powershell.remove-item.pipeline-recurse', + CLEAR_CONTENT: 'powershell.clear-content', + CLEAR_DISK: 'powershell.clear-disk', + FORMAT_VOLUME: 'powershell.format-volume', + DOTNET_DIRECTORY_DELETE: 'powershell.dotnet.directory-delete', + DOTNET_FILE_DELETE: 'powershell.dotnet.file-delete', + CMD_RECURSIVE_DELETE: 'powershell.cmd.recursive-delete', + DYNAMIC_EXECUTION: 'powershell.dynamic-execution', + SCAN_DEPTH_EXCEEDED: 'powershell.scan-depth-exceeded', +}); + +console.log('=== Testing powershell-destructive-command.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` PASS ${name}`); + passed += 1; + } catch (error) { + console.log(` FAIL ${name}`); + console.log(` ${error.message}`); + failed += 1; + } +} + +function classify(command) { + const findings = classifyPowerShellDestructiveCommand(command); + assert.ok(Array.isArray(findings), 'classifier must return an array'); + assert.ok( + findings.every(ruleId => typeof ruleId === 'string' && ruleId.length > 0), + 'every finding must be a non-empty rule-id string' + ); + assert.strictEqual( + new Set(findings).size, + findings.length, + `findings must be unique: ${JSON.stringify(findings)}` + ); + return findings; +} + +function expectRules(command, expected) { + const actual = classify(command); + assert.deepStrictEqual( + [...actual].sort(), + [...expected].sort(), + `unexpected findings for ${JSON.stringify(command)}` + ); +} + +function expectSafe(command) { + expectRules(command, []); +} + +console.log('Remove-Item forms:'); + +test('classifies recursive and force parameters independently', () => { + expectRules('Remove-Item -Recurse -Force C:/tmp/demo', [ + RULES.REMOVE_RECURSE, + RULES.REMOVE_FORCE, + ]); + expectRules('Remove-Item -Recurse C:/tmp/demo', [RULES.REMOVE_RECURSE]); + expectRules('Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); +}); + +test('classifies PowerShell parameter abbreviations case-insensitively', () => { + expectRules('REMOVE-ITEM -Rec -Fo C:/tmp/demo', [ + RULES.REMOVE_RECURSE, + RULES.REMOVE_FORCE, + ]); +}); + +test('normalizes every PowerShell command-parameter dash character', () => { + expectRules('Remove-Item –Force C:/tmp/demo', [RULES.REMOVE_FORCE]); + expectRules('Remove-Item —Recurse C:/tmp/demo', [RULES.REMOVE_RECURSE]); + expectRules('Remove-Item ―Force C:/tmp/demo', [RULES.REMOVE_FORCE]); +}); + +test('normalizes PowerShell backtick obfuscation after finding executable ranges', () => { + expectRules('Rem`ove-Item -Rec`urse C:/tmp/demo', [RULES.REMOVE_RECURSE]); +}); + +test('classifies recursive Remove-Item aliases', () => { + for (const alias of ['ri', 'rm', 'rmdir', 'rd', 'del', 'erase']) { + expectRules(`${alias} -Recurse C:/tmp/demo`, [RULES.REMOVE_RECURSE]); + } + expectRules('Remove-ItemProperty -Force HKCU:/Software/Demo -Name setting', [ + RULES.REMOVE_FORCE, + ]); +}); + +test('classifies wildcard targets, including quoted provider paths', () => { + expectRules('Remove-Item C:/build/*', [RULES.REMOVE_WILDCARD]); + expectRules('Remove-Item "C:/build/file?.tmp"', [RULES.REMOVE_WILDCARD]); +}); + +test('classifies splatted Remove-Item parameters', () => { + expectRules('Remove-Item @deleteParams', [RULES.REMOVE_SPLAT]); +}); + +test('returns deterministic, unique rule IDs when a rule matches repeatedly', () => { + const command = 'Remove-Item -Force C:/one; Remove-Item -Force C:/two'; + const first = classify(command); + const second = classify(command); + + assert.deepStrictEqual(first, second); + assert.deepStrictEqual(first, [RULES.REMOVE_FORCE]); +}); + +console.log('\nAdditional destructive APIs:'); + +test('classifies Clear-Content, Clear-Disk, and Format-Volume', () => { + expectRules('Clear-Content C:/tmp/log.txt', [RULES.CLEAR_CONTENT]); + expectRules('Clear-Disk -Number 2 -RemoveData -Confirm:$false', [RULES.CLEAR_DISK]); + expectRules('Format-Volume -DriveLetter D -Force', [RULES.FORMAT_VOLUME]); +}); + +test('classifies .NET directory and file deletion', () => { + expectRules("[System.IO.Directory]::Delete('C:/tmp/demo', $true)", [ + RULES.DOTNET_DIRECTORY_DELETE, + ]); + expectRules("[IO.File]::Delete('C:/tmp/demo.txt')", [ + RULES.DOTNET_FILE_DELETE, + ]); + expectRules("[IO.Fi`le]::Delete('C:/tmp/demo.txt')", [ + RULES.DOTNET_FILE_DELETE, + ]); +}); + +test('classifies recursive cmd.exe deletion reached through PowerShell', () => { + expectRules('cmd /c rd /s /q C:/tmp/demo', [RULES.CMD_RECURSIVE_DELETE]); + expectRules('cmd.exe /c del /s /q C:/tmp/demo/*', [ + RULES.CMD_RECURSIVE_DELETE, + ]); + expectRules('cmd /c "rd /s /q C:/tmp/demo"', [RULES.CMD_RECURSIVE_DELETE]); + expectRules('cmd /c @rd /s /q C:/tmp/demo', [RULES.CMD_RECURSIVE_DELETE]); + expectRules('cmd /c --% rd /s /q C:/tmp/demo', [RULES.CMD_RECURSIVE_DELETE]); + expectRules('cmd /c if exist C:/tmp/demo rd /s /q C:/tmp/demo', [ + RULES.CMD_RECURSIVE_DELETE, + ]); + expectRules('cmd /c "(rd /s /q C:/tmp/demo)"', [RULES.CMD_RECURSIVE_DELETE]); + expectRules('cmd /c (rd /s /q C:/tmp/demo)', [RULES.CMD_RECURSIVE_DELETE]); + expectRules('cmd /c if /i "x"=="x" rd /s /q C:/tmp/demo', [ + RULES.CMD_RECURSIVE_DELETE, + ]); + expectRules('cmd /c for %i in (1) do rd /s /q C:/tmp/demo', [ + RULES.CMD_RECURSIVE_DELETE, + ]); + expectRules('cmd /c call rd /s /q C:/tmp/demo', [RULES.CMD_RECURSIVE_DELETE]); + expectRules('cmd /c start /wait rd /s /q C:/tmp/demo', [ + RULES.CMD_RECURSIVE_DELETE, + ]); + expectRules('cmd /c if exist C:/never echo safe else rd /s /q C:/tmp/demo', [ + RULES.CMD_RECURSIVE_DELETE, + ]); + for (const command of [ + 'cmd /c if exist C:/never echo safe else if exist C:/never echo safe else rd /s /q C:/tmp/demo', + 'cmd /c for %i in (1) do if exist C:/never echo safe else rd /s /q C:/tmp/demo', + 'cmd /c call call rd /s /q C:/tmp/demo', + 'cmd /c start "job" /wait cmd /c rd /s /q C:/tmp/demo', + 'cmd /c >nul rd /s /q C:/tmp/demo', + 'cmd /c if /i "x" EQU "x" rd /s /q C:/tmp/demo', + 'cmd /c if 1 NEQ 2 rd /s /q C:/tmp/demo', + 'cmd /c if /i "x" EQU "x" if 1 NEQ 2 rd /s /q C:/tmp/demo', + ]) { + expectRules(command, [RULES.CMD_RECURSIVE_DELETE]); + } +}); + +test('classifies pipeline recursion evidence upstream of Remove-Item', () => { + expectRules('Get-ChildItem C:/tmp -Recurse | Remove-Item', [ + RULES.PIPELINE_RECURSE, + ]); +}); + +console.log('\nNested shell payloads:'); + +test('classifies powershell and pwsh command payloads recursively', () => { + expectRules( + 'powershell -Command "Remove-Item -Recurse C:/tmp/demo"', + [RULES.REMOVE_RECURSE] + ); + expectRules( + "pwsh -c 'Remove-Item -Force C:/tmp/demo'", + [RULES.REMOVE_FORCE] + ); + expectRules( + 'cmd /c pwsh -Command "Remove-Item -Force C:/tmp/demo"', + [RULES.REMOVE_FORCE] + ); + expectRules( + "'Remove-Item -Force C:/tmp/demo' | pwsh -Command -", + [RULES.REMOVE_FORCE] + ); + expectRules( + "Write-Output 'Remove-Item -Force C:/tmp/demo' | pwsh -Command -", + [RULES.REMOVE_FORCE] + ); + expectRules("@('Remove-Item -Force C:/tmp/demo') | pwsh -Command -", [ + RULES.REMOVE_FORCE, + ]); + expectRules("@'\nRemove-Item -Force C:/tmp/demo\n'@ | pwsh -Command -", [ + RULES.REMOVE_FORCE, + ]); + expectRules("@'\nRemove-Item -Force C:/tmp/demo\n'@ | pwsh -NoProfile -Command -", [ + RULES.REMOVE_FORCE, + ]); + expectRules( + "Write-Output \"[IO.File]::Delete('C:/tmp/demo')\" | pwsh -Command -", + [RULES.DOTNET_FILE_DELETE] + ); + expectRules('pwsh -CommandWithArgs "Remove-Item -Force C:/tmp/demo"', [ + RULES.REMOVE_FORCE, + ]); + expectRules('pwsh -cwa "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] + ); + for (const command of [ + "Start-Process pwsh -ArgumentList '-NoProfile','-Command','Remove-Item -Force C:/tmp/demo'", + "Start-Process -FilePath pwsh -ArgumentList '-NoProfile', '-Command', 'Remove-Item -Force C:/tmp/demo'", + "saps pwsh -ArgumentList '-NoProfile','-c','Remove-Item -Force C:/tmp/demo'", + "Start-Process pwsh -ArgumentList @('-NoProfile','-Command','Remove-Item -Force C:/tmp/demo')", + "Start-Process pwsh '-Command \"Remove-Item -Force C:/tmp/demo\"'", + "Start-Process pwsh -Args '-Command \"Remove-Item -Force C:/tmp/demo\"'", + "Start-Process -FilePath:pwsh -ArgumentList '-Command \"Remove-Item -Force C:/tmp/demo\"'", + "Start-Process pwsh -ArgumentList:'-Command \"Remove-Item -Force C:/tmp/demo\"'", + "Start-Process -Fi:pwsh -Arg:'-Command \"Remove-Item -Force C:/tmp/demo\"'", + "Start-Process -ArgumentList '-Command \"Remove-Item -Force C:/tmp/demo\"' -FilePath pwsh", + "Start-Process -WindowStyle Hidden pwsh -ArgumentList '-Command \"Remove-Item -Force C:/tmp/demo\"'", + "Start-Process -WorkingDirectory C:/tmp pwsh -ArgumentList '-Command \"Remove-Item -Force C:/tmp/demo\"'", + "Start-Process pwsh '-NoProfile','-Command','Remove-Item -Force C:/tmp/demo'", + "Start-Process pwsh -ArgumentList @('-NoProfile',('-Command'),('Remove-Item -Force C:/tmp/demo'))", + ]) { + expectRules(command, [RULES.REMOVE_FORCE]); + } + expectRules("Start-Process cmd -ArgumentList '/c rd /s /q C:/tmp/demo'", [ + RULES.CMD_RECURSIVE_DELETE, + ]); + expectRules( + "$params=@{FilePath='pwsh';ArgumentList='-Command \"Remove-Item -Force C:/tmp/demo\"'}; Start-Process @params", + [RULES.DYNAMIC_EXECUTION] + ); + expectRules( + "$global:params=@{FilePath='pwsh';ArgumentList='-Command \"Remove-Item -Force C:/tmp/demo\"'}; Start-Process @global:params", + [RULES.DYNAMIC_EXECUTION] + ); + expectRules( + "$shell='pwsh'; 'Remove-Item -Force C:/tmp/demo' | & $shell -Command -", + [RULES.REMOVE_FORCE] + ); + expectRules("@'\nRemove-Item -Force C:/tmp/demo\n'@ | & pwsh -Command -", [ + RULES.REMOVE_FORCE, + ]); +}); + +test('classifies UTF-16LE EncodedCommand payloads', () => { + const payload = Buffer.from( + 'Remove-Item C:/tmp/demo/*', + 'utf16le' + ).toString('base64'); + + expectRules(`pwsh -EncodedCommand ${payload}`, [RULES.REMOVE_WILDCARD]); +}); + +test('ignores an invalid EncodedCommand payload without throwing', () => { + assert.doesNotThrow(() => classify('pwsh -EncodedCommand %%%not-base64%%%')); + expectSafe('pwsh -EncodedCommand %%%not-base64%%%'); +}); + +test('bounds deeply nested encoded commands and reports conservative evidence', () => { + let command = 'Remove-Item -Recurse C:/tmp/demo'; + for (let depth = 0; depth < 8; depth += 1) { + const payload = Buffer.from(command, 'utf16le').toString('base64'); + command = `pwsh -EncodedCommand ${payload}`; + } + + expectRules(command, [RULES.SCAN_DEPTH_EXCEEDED]); +}); + +test('classifies destructive commands in executable PowerShell containers', () => { + const commands = [ + '& { Remove-Item -Force C:/tmp/demo }', + 'if ($true) { Remove-Item -Force C:/tmp/demo }', + 'ForEach-Object { Remove-Item -Force C:/tmp/demo }', + '@(Remove-Item -Force C:/tmp/demo)', + '(Remove-Item -Force C:/tmp/demo)', + 'pwsh -Command "& { Remove-Item -Force C:/tmp/demo }"', + 'pwsh -Command { Remove-Item -Force C:/tmp/demo }', + 'switch ($x) { default { Remove-Item -Force C:/tmp/demo } }', + "switch ($x) { 'match' { Remove-Item -Force C:/tmp/demo } }", + '& ({ Remove-Item -Force C:/tmp/demo })', + '& $( { Remove-Item -Force C:/tmp/demo } )', + 'Invoke-Command -ScriptBlock ({ Remove-Item -Force C:/tmp/demo })', + 'ForEach-Object -Process ({ Remove-Item -Force C:/tmp/demo })', + 'function cleanup { Remove-Item -Force C:/tmp/demo }; cleanup', + ]; + for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]); +}); + +test('preserves executable context through spacing and nested grouping', () => { + const commands = [ + `&${' '.repeat(300)}{ Remove-Item -Force C:/tmp/demo }`, + '& (({ Remove-Item -Force C:/tmp/demo }))', + 'pwsh -Command (({ Remove-Item -Force C:/tmp/demo }))', + '{ Remove-Item -Force C:/tmp/demo }.Invoke()', + '{ Remove-Item -Force C:/tmp/demo }.InvokeReturnAsIs()', + '{ Remove-Item -Force C:/tmp/demo }.Inv`oke()', + "{ Remove-Item -Force C:/tmp/demo }.'Invoke'()", + '{ Remove-Item -Force C:/tmp/demo }.InvokeWithContext($null, $null, @())', + '{ Remove-Item -Force C:/tmp/demo } `\n.Invoke()', + '{ Remove-Item -Force C:/tmp/demo }.GetNewClosure().Invoke()', + '{ Remove-Item -Force C:/tmp/demo }.GetNewClosure().GetNewClosure().Invoke()', + "{ Remove-Item -Force C:/tmp/demo }.'GetNewClosure'().Invoke()", + ]; + for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]); +}); + +test('classifies invoked functions and filters across executable containers', () => { + const commands = [ + 'function cleanup { Remove-Item -Force C:/tmp/demo }; if ($true) { cleanup }', + 'function cleanup { Remove-Item -Force C:/tmp/demo }; $(cleanup)', + 'filter cleanup { Remove-Item -Force C:/tmp/demo }; 1 | cleanup', + '1 | foreach { Remove-Item -Force C:/tmp/demo }', + '1 | where { Remove-Item -Force C:/tmp/demo; $true }', + '1 | Microsoft.PowerShell.Core\\ForEach-Object { Remove-Item -Force C:/tmp/demo }', + ]; + for (const command of commands) expectRules(command, [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', [ + RULES.REMOVE_FORCE, + ]); + expectRules('$cleanup = { Remove-Item -Force C:/tmp/demo }; $cleanup.Invoke()', [ + RULES.REMOVE_FORCE, + ]); + expectRules('${cleanup} = { Remove-Item -Force C:/tmp/demo }; & ${cleanup}', [ + RULES.REMOVE_FORCE, + ]); + for (const command of [ + '$cleanup = { Remove-Item -Force C:/tmp/demo }; Invoke-Command -ScriptBlock $cleanup', + '$cleanup = { Remove-Item -Force C:/tmp/demo }; 1 | ForEach-Object -Process $cleanup', + '$cleanup = { Remove-Item -Force C:/tmp/demo }; Start-Job -ScriptBlock $cleanup', + '$cleanup = { Remove-Item -Force C:/tmp/demo }; Measure-Command -Expression $cleanup', + '$cleanup = { Remove-Item -Force C:/tmp/demo }; Register-EngineEvent x -Action $cleanup', + '$cleanup = { Remove-Item -Force C:/tmp/demo }; Invoke-Command $cleanup', + '$cleanup = { Remove-Item -Force C:/tmp/demo }; 1 | ForEach-Object $cleanup', + '$cleanup = { Remove-Item -Force C:/tmp/demo }; Start-Job $cleanup', + '$cleanup = { Remove-Item -Force C:/tmp/demo }; Measure-Command $cleanup', + '$cleanup = { Remove-Item -Force C:/tmp/demo }; $cleanup.GetNewClosure().Invoke()', + '${cleanup} = { Remove-Item -Force C:/tmp/demo }; ${cleanup}.GetNewClosure().Invoke()', + '$cleanup = { Remove-Item -Force C:/tmp/demo }; icm -ScriptBlock $cleanup', + '$cleanup = { Remove-Item -Force C:/tmp/demo }; sajb -ScriptBlock $cleanup', + '$cleanup = { Remove-Item -Force C:/tmp/demo }; Trace-Command demo -Expression $cleanup', + '$cleanup = { Remove-Item -Force C:/tmp/demo }; Invoke-Command -NoNewScope $cleanup', + '$cleanup = { Remove-Item -Force C:/tmp/demo }; 1 | ForEach-Object -Begin {} $cleanup', + ]) { + expectRules(command, [RULES.REMOVE_FORCE]); + } +}); + +test('classifies static command results reached through the call operator', () => { + expectRules("& ('Remove-Item') -Force C:/tmp/demo", [RULES.REMOVE_FORCE]); + expectRules("& $('Remove-Item') -Force C:/tmp/demo", [RULES.REMOVE_FORCE]); + expectRules("& (('Remove-Item')) -Force C:/tmp/demo", [RULES.REMOVE_FORCE]); + expectRules("& $(( 'Remove-Item')) -Force C:/tmp/demo", [RULES.REMOVE_FORCE]); +}); + +test('classifies assignments, hashtables, and multiline executable blocks', () => { + const commands = [ + '$x = Remove-Item -Force C:/tmp/demo', + '$h = @{ x = $(Remove-Item -Force C:/tmp/demo) }', + 'if ($true)\n{ Remove-Item -Force C:/tmp/demo }', + 'switch ($x)\n{ default { Remove-Item -Force C:/tmp/demo } }', + 'function cleanup\n{ Remove-Item -Force C:/tmp/demo }; cleanup', + 'if ($true) `\n{ Remove-Item -Force C:/tmp/demo }', + ]; + for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]); + expectRules('$null = Clear-Disk -Number 2 -RemoveData -Confirm:$false', [ + RULES.CLEAR_DISK, + ]); +}); + +test('classifies compact, scoped, indexed, property, and return execution', () => { + const commands = [ + '$result=Remove-Item -Force C:/tmp/demo', + '[object]$result=Remove-Item -Force C:/tmp/demo', + '$script:x = Remove-Item -Force C:/tmp/demo', + '${x} = Remove-Item -Force C:/tmp/demo', + '$x[0] = Remove-Item -Force C:/tmp/demo', + '$x.Value = Remove-Item -Force C:/tmp/demo', + '$x,$y = Remove-Item -Force C:/tmp/demo', + 'return Remove-Item -Force C:/tmp/demo', + '$script:cleanup = { Remove-Item -Force C:/tmp/demo }; & $script:cleanup', + ]; + for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]); +}); + +test('classifies named function blocks and sibling consumer blocks', () => { + const commands = [ + 'function cleanup { begin { Remove-Item -Force C:/tmp/demo } }; cleanup', + 'function cleanup { process { Remove-Item -Force C:/tmp/demo } }; 1 | cleanup', + 'workflow cleanup { Remove-Item -Force C:/tmp/demo }; cleanup', + '1 | ForEach-Object { Write-Output safe } { Remove-Item -Force C:/tmp/demo }', + 'Trace-Command demo -Expression { Remove-Item -Force C:/tmp/demo }', + 'Register-EngineEvent demo -Action { Remove-Item -Force C:/tmp/demo }', + 'Register-EngineEvent demo -Action:{ Remove-Item -Force C:/tmp/demo }', + 'class Cleanup { static [void] Run() { Remove-Item -Force C:/tmp/demo } }; [Cleanup]::Run()', + 'class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; [Cleanup]::new()', + 'class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; New-Object -TypeName Cleanup', + 'class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; New-Object Cleanup', + 'class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; New-Object ([Cleanup])', + "class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; New-Object ('Cleanup')", + 'class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; [Activator]::CreateInstance([Cleanup])', + "$type = 'Cleanup'; class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; New-Object $type", + ]; + for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]); +}); + +test('classifies static execution primitives', () => { + expectRules("iex 'Remove-Item -Force C:/tmp/demo'", [RULES.REMOVE_FORCE]); + expectRules("Invoke-Expression 'Remove-Item -Force C:/tmp/demo'", [ + RULES.REMOVE_FORCE, + ]); + expectRules("& ([scriptblock]::Create('Remove-Item -Force C:/tmp/demo'))", [ + RULES.REMOVE_FORCE, + ]); + expectRules("Invoke-Expression @'\nRemove-Item -Force C:/tmp/demo\n'@", [ + RULES.REMOVE_FORCE, + ]); + expectRules("[scriptblock]::Create(@'\nRemove-Item -Force C:/tmp/demo\n'@).Invoke()", [ + RULES.REMOVE_FORCE, + ]); + expectRules("& (@'\nRemove-Item\n'@) -Force C:/tmp/demo", [RULES.REMOVE_FORCE]); + for (const command of [ + "$cmd = 'Remove-Item -Force C:/tmp/demo'; Invoke-Expression $cmd", + "$cmd='Remove-Item -Force C:/tmp/demo'; iex $cmd", + "$cmd = 'Remove-Item -Force C:/tmp/demo'; & ([scriptblock]::Create($cmd))", + "$name = 'Remove-Item'; & $name -Force C:/tmp/demo", + "$args = '-Command \"Remove-Item -Force C:/tmp/demo\"'; Start-Process pwsh -ArgumentList $args", + ]) { + expectRules(command, [RULES.REMOVE_FORCE]); + } + expectRules('Invoke-Expression $runtimeValue', [RULES.DYNAMIC_EXECUTION]); + expectRules('Start-Process pwsh -ArgumentList $runtimeArgs', [RULES.DYNAMIC_EXECUTION]); + expectRules("$cmd='Remove-'; $cmd+='Item'; & $cmd -Force C:/tmp/demo", [ + RULES.DYNAMIC_EXECUTION, + ]); + expectRules( + '$verb=\'Remove\'; $cmd="${verb}-Item"; & $cmd -Force C:/tmp/demo', + [RULES.DYNAMIC_EXECUTION] + ); + expectRules('& (Get-Command Remove-Item) -Force C:/tmp/demo', [ + RULES.DYNAMIC_EXECUTION, + ]); + expectRules("iex ('Remove-'+'Item -Force C:/tmp/demo')", [ + RULES.DYNAMIC_EXECUTION, + ]); + expectRules("iex ('{0}-Item -Force C:/tmp/demo' -f 'Remove')", [ + RULES.DYNAMIC_EXECUTION, + ]); + expectRules( + '$cleanup={ Remove-Item -Force C:/tmp/demo }; Invoke-Command -ScriptBlock (Get-Variable cleanup -ValueOnly)', + [RULES.DYNAMIC_EXECUTION] + ); + expectRules('Set-Alias zap Remove-Item; zap -Force C:/tmp/demo', [ + RULES.REMOVE_FORCE, + ]); + expectRules('New-Alias -Name zap -Value Remove-Item; zap -Force C:/tmp/demo', [ + RULES.REMOVE_FORCE, + ]); + expectRules( + "$ExecutionContext.InvokeCommand.InvokeScript('Remove-Item -Force C:/tmp/demo')", + [RULES.REMOVE_FORCE] + ); +}); + +test('classifies command names composed from static subexpression output', () => { + expectRules('Remove-$(Write-Output Item) -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); + expectRules('Clear-$(echo Disk) -Number 2', [RULES.CLEAR_DISK]); + expectRules('Format-$(echo Volume) -DriveLetter D', [RULES.FORMAT_VOLUME]); + expectRules('r$(echo m) -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); +}); + +test('classifies executable containers inside EncodedCommand payloads', () => { + const payload = Buffer.from( + '& { Remove-Item -Force C:/tmp/demo }', + 'utf16le' + ).toString('base64'); + expectRules(`pwsh -EncodedCommand ${payload}`, [RULES.REMOVE_FORCE]); +}); + +console.log('\nPowerShell subexpressions:'); + +test('classifies destructive commands in unquoted subexpressions', () => { + expectRules('Write-Output $(Remove-Item -Force C:/tmp/demo)', [ + RULES.REMOVE_FORCE, + ]); +}); + +test('classifies destructive commands in double-quoted subexpressions', () => { + expectRules('Write-Output "$(Remove-Item -Recurse C:/tmp/demo)"', [ + RULES.REMOVE_RECURSE, + ]); +}); + +test('classifies recursively nested subexpressions', () => { + expectRules( + 'Write-Output "$(Write-Output $(Remove-Item -Force C:/tmp/demo))"', + [RULES.REMOVE_FORCE] + ); +}); + +test('classifies sibling subexpressions without duplicating rule IDs', () => { + expectRules( + 'Write-Output $(Remove-Item -Force C:/one) $(Remove-Item -Force C:/two)', + [RULES.REMOVE_FORCE] + ); +}); + +test('keeps quoted delimiters inside subexpressions from splitting commands', () => { + expectRules( + 'Write-Output $(Write-Output "safe;|&"; Remove-Item -Force "C:/tmp/a;b/*")', + [RULES.REMOVE_FORCE, RULES.REMOVE_WILDCARD] + ); +}); + +test('keeps a quoted closing parenthesis inside a subexpression body', () => { + expectRules( + 'Write-Output $(Write-Output ")"; Remove-Item -Force C:/tmp/demo)', + [RULES.REMOVE_FORCE] + ); +}); + +test('keeps double-quoted apostrophes from suppressing executable subexpressions', () => { + expectRules( + 'Write-Output "it\'s $(Remove-Item -Force C:/tmp/demo)"', + [RULES.REMOVE_FORCE] + ); +}); + +test('treats subexpression text inside single quotes as literal', () => { + expectSafe("Write-Output '$(Remove-Item -Force C:/tmp/demo)'"); +}); + +test('treats a backtick-escaped subexpression inside double quotes as literal', () => { + expectSafe('Write-Output "`$(Remove-Item -Force C:/tmp/demo)"'); +}); + +test('respects literal and expandable PowerShell here-strings', () => { + expectSafe("@'\nliteral's Remove-Item -Force C:/tmp/demo\n'@"); + expectSafe('Write-Output "@\'\nRemove-Item -Force C:/tmp/demo\n\'@"'); + expectRules('@"\n$(Remove-Item -Force C:/tmp/demo)\n"@', [ + RULES.REMOVE_FORCE, + ]); + expectRules('@"\n" # $(Remove-Item -Force C:/tmp/demo)\n"@', [ + RULES.REMOVE_FORCE, + ]); + expectSafe('@"\n" # literal Remove-Item -Force C:/tmp/demo\n"@'); +}); + +test('normalizes PowerShell smart quotes before lexical analysis', () => { + expectRules('& ‘Remove-Item’ -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); + expectRules('& ‚Remove-Item‚ -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); + expectRules('& ‛Remove-Item‛ -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); + expectRules('& „Remove-Item„ -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); + expectSafe('Write-Output ‘$(Remove-Item -Force C:/tmp/demo)’'); + expectSafe('Write-Output ‚$(Remove-Item -Force C:/tmp/demo)‚'); +}); + +test('does not treat a backslash as an escape for an executable subexpression', () => { + expectRules('Write-Output \\$(Remove-Item -Force C:/tmp/demo)', [ + RULES.REMOVE_FORCE, + ]); +}); + +test('handles backtick line continuations before destructive parameters', () => { + expectRules('Remove-Item `\n-Force C:/tmp/demo', [RULES.REMOVE_FORCE]); + expectRules('Remove-Item `\r\n-Force C:/tmp/demo', [RULES.REMOVE_FORCE]); +}); + +test('ignores comment syntax without letting it poison following parser state', () => { + expectRules('# (\nRemove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); + expectRules('<# ( #>\nRemove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); + expectRules('<# ignored <# #> Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); + expectSafe('Write-Output safe # ; Remove-Item -Force C:/tmp/demo'); + expectSafe('Write-Output safe# | Remove-Item -Force C:/tmp/demo'); + expectSafe("# [IO.File]::Delete('C:/tmp/demo')"); + expectRules('# @"\nRemove-Item -Force C:/tmp/demo\n"@', [RULES.REMOVE_FORCE]); + expectRules('${a#b}=1; Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); + expectRules('${a<#b}=1; Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); +}); + +test('scans large comments and unmatched openers in bounded time', () => { + const started = Date.now(); + expectRules(`<# ${'$('.repeat(40000)} #>\nRemove-Item -Force C:/tmp/demo`, [ + RULES.REMOVE_FORCE, + ]); + expectSafe('$('.repeat(40000)); + expectSafe('()'.repeat(10000)); + assert.ok(Date.now() - started < 2000, 'large malformed input should remain bounded'); +}); + +test('resolves long invoked-function chains within the hook time budget', () => { + const definitions = []; + for (let index = 0; index < 20001; index += 1) { + const body = index === 20000 + ? 'Remove-Item -Force C:/tmp/demo' + : `f${index + 1}`; + definitions.push(`function f${index} { ${body} }`); + } + const started = Date.now(); + expectRules(`${definitions.join('; ')}; f0`, [RULES.REMOVE_FORCE]); + assert.ok(Date.now() - started < 4000, 'function resolution should remain below hook timeout'); +}); + +test('scans many sibling executable containers within the hook time budget', () => { + const command = Array.from( + { length: 40000 }, + (_, index) => index === 39999 + ? '$(Remove-Item -Force C:/tmp/demo)' + : '$(Write-Output safe)' + ).join(' '); + const started = Date.now(); + expectRules(command, [RULES.REMOVE_FORCE]); + assert.ok(Date.now() - started < 4000, 'sibling containers should remain bounded'); +}); + +console.log('\nBenign controls:'); + +test('allows plain non-recursive, non-forced, non-wildcard Remove-Item', () => { + expectSafe('Remove-Item C:/tmp/notes.txt'); +}); + +test('allows benign PowerShell and non-recursive cmd commands', () => { + expectSafe('Get-ChildItem C:/tmp'); + expectSafe('Get-Date'); + expectSafe('cmd /c del C:/tmp/notes.txt'); + expectSafe('cmd /c echo rd /s /q C:/tmp/demo'); + expectSafe('cmd /c "echo safe ^& rd /s /q C:/tmp/demo"'); + expectSafe("function cleanup { Remove-Item -Force C:/tmp/demo }; 'cleanup'"); + expectSafe('Write-Output safe`nRemove-Item -Force C:/tmp/demo'); +}); + +test('allows explicitly false destructive switches and inert script blocks', () => { + expectSafe('Remove-Item -Force:$false C:/tmp/demo'); + expectSafe('Remove-Item -Force:$null C:/tmp/demo'); + expectSafe('Remove-Item -Recurse:$false C:/tmp/demo'); + expectSafe("Remove-Item '-Force'"); + expectSafe("Remove-Item -LiteralPath 'C:/tmp/file*.txt'"); + expectSafe('{ Remove-Item -Force C:/tmp/demo }'); + expectSafe('function cleanup { Remove-Item -Force C:/tmp/demo }'); +}); + +test('treats backticks literally inside single-quoted strings', () => { + expectRules("Write-Output 'safe`'; Remove-Item -Force C:/tmp/demo", [ + RULES.REMOVE_FORCE, + ]); +}); + +test('handles empty and non-string commands', () => { + expectSafe(''); + expectSafe(null); + expectSafe(undefined); +}); + +test('handles a trailing backtick without throwing or inventing a finding', () => { + assert.doesNotThrow(() => classify('Write-Output safe`')); + expectSafe('Write-Output safe`'); +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +if (failed > 0) { + process.exit(1); +}