/** * PowerShell-specific security analysis for command validation. * * Detects dangerous patterns: code injection, download cradles, privilege * escalation, dynamic command names, COM objects, etc. * * All checks are AST-based. If parsing failed (valid=false), none of the * individual checks match and powershellCommandIsSafe returns 'ask'. */ import { DANGEROUS_SCRIPT_BLOCK_CMDLETS, FILEPATH_EXECUTION_CMDLETS, MODULE_LOADING_CMDLETS, } from '../../utils/powershell/dangerousCmdlets.js' import type { ParsedCommandElement, ParsedPowerShellCommand, } from '../../utils/powershell/parser.js' import { COMMON_ALIASES, commandHasArgAbbreviation, deriveSecurityFlags, getAllCommands, getVariablesByScope, hasCommandNamed, } from '../../utils/powershell/parser.js' import { isClmAllowedType } from './clmTypes.js' type PowerShellSecurityResult = { behavior: 'passthrough' | 'ask' | 'allow' message?: string } const POWERSHELL_EXECUTABLES = new Set([ 'pwsh', 'pwsh.exe', 'powershell', 'powershell.exe', ]) /** * Extracts the base executable name from a command, handling full paths * like /usr/bin/pwsh, C:\Windows\...\powershell.exe, or .\pwsh. */ function isPowerShellExecutable(name: string): boolean { const lower = name.toLowerCase() if (POWERSHELL_EXECUTABLES.has(lower)) { return true } // Extract basename from paths (both / and \ separators) const lastSep = Math.max(lower.lastIndexOf('/'), lower.lastIndexOf('\\')) if (lastSep >= 0) { return POWERSHELL_EXECUTABLES.has(lower.slice(lastSep + 1)) } return false } /** * Alternative parameter-prefix characters that PowerShell accepts as equivalent * to ASCII hyphen-minus (U+002D). PowerShell's tokenizer (SpecialCharacters.IsDash) * and powershell.exe's CommandLineParameterParser both accept all four dash * characters plus Windows PowerShell 5.1's `/` parameter delimiter. * Extent.Text preserves the raw character; transformCommandAst uses ce.text for * CommandParameterAst elements, so these reach us unchanged. */ const PS_ALT_PARAM_PREFIXES = new Set([ '/', // Windows PowerShell 5.1 (powershell.exe, not pwsh 7+) '\u2013', // en-dash '\u2014', // em-dash '\u2015', // horizontal bar ]) /** * Wrapper around commandHasArgAbbreviation that also matches alternative * parameter prefixes (`/`, en-dash, em-dash, horizontal-bar). PowerShell's * tokenizer (SpecialCharacters.IsDash) accepts these for both powershell.exe * args AND cmdlet parameters, so use this for ALL PS param checks — not just * pwsh.exe invocations. Previously checkComObject/checkStartProcess/ * checkDangerousFilePathExecution/checkForEachMemberName used bare * commandHasArgAbbreviation, so `Start-Process foo –Verb RunAs` bypassed. */ function psExeHasParamAbbreviation( cmd: ParsedCommandElement, fullParam: string, minPrefix: string, ): boolean { if (commandHasArgAbbreviation(cmd, fullParam, minPrefix)) { return true } // Normalize alternative prefixes to `-` and re-check. Build a synthetic cmd // with normalized args; commandHasArgAbbreviation handles colon-value split. const normalized: ParsedCommandElement = { ...cmd, args: cmd.args.map(a => a.length > 0 && PS_ALT_PARAM_PREFIXES.has(a[0]!) ? '-' + a.slice(1) : a, ), } return commandHasArgAbbreviation(normalized, fullParam, minPrefix) } /** * Checks if a PowerShell command uses Invoke-Expression or its alias (iex). * These are equivalent to eval and can execute arbitrary code. */ function checkInvokeExpression( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { if (hasCommandNamed(parsed, 'Invoke-Expression')) { return { behavior: 'ask', message: 'Command uses Invoke-Expression which can execute arbitrary code', } } return { behavior: 'passthrough' } } /** * Checks for dynamic command invocation where the command name itself is an * expression that cannot be statically resolved. * * PoCs: * & ${function:Invoke-Expression} 'payload' — VariableExpressionAst * & ('iex','x')[0] 'payload' — IndexExpressionAst → 'Other' * & ('i'+'ex') 'payload' — BinaryExpressionAst → 'Other' * * In all cases cmd.name is the literal extent text (e.g. "('iex','x')[0]"), * which doesn't match hasCommandNamed('Invoke-Expression'). At runtime * PowerShell evaluates the expression to a command name and invokes it. * * Legitimate command names are ALWAYS StringConstantExpressionAst (mapped to * 'StringConstant'): `Get-Process`, `git`, `ls`. Any other element type in * name position is dynamic. Rather than denylisting dynamic types (fragile — * mapElementType's default case maps unknown AST types to 'Other', which a * `=== 'Variable'` check misses), we allowlist 'StringConstant'. * * elementTypes[0] is the command-name element (transformCommandAst pushes it * first, before arg elements). The `!== undefined` guard preserves fail-open * when elementTypes is absent (parse-detail unavailable — if parsing failed * entirely, valid=false already returns 'ask' earlier in the chain). */ function checkDynamicCommandName( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { for (const cmd of getAllCommands(parsed)) { if (cmd.elementType !== 'CommandAst') { continue } const nameElementType = cmd.elementTypes?.[0] if (nameElementType !== undefined && nameElementType !== 'StringConstant') { return { behavior: 'ask', message: 'Command name is a dynamic expression which cannot be statically validated', } } } return { behavior: 'passthrough' } } /** * Checks for encoded command parameters which obscure intent. * These are commonly used in malware to bypass security tools. */ function checkEncodedCommand( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { for (const cmd of getAllCommands(parsed)) { if (isPowerShellExecutable(cmd.name)) { if (psExeHasParamAbbreviation(cmd, '-encodedcommand', '-e')) { return { behavior: 'ask', message: 'Command uses encoded parameters which obscure intent', } } } } return { behavior: 'passthrough' } } /** * Checks for PowerShell re-invocation (nested pwsh/powershell process). * * Any PowerShell executable in command position is flagged — not just * -Command/-File. Bare `pwsh` receiving stdin (`Get-Content x | pwsh`) or * a positional script path executes arbitrary code with none of the explicit * flags present. Same unvalidatable-nested-process reasoning as * checkStartProcess vector 2: we cannot statically analyze what the child * process will run. */ function checkPwshCommandOrFile( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { for (const cmd of getAllCommands(parsed)) { if (isPowerShellExecutable(cmd.name)) { return { behavior: 'ask', message: 'Command spawns a nested PowerShell process which cannot be validated', } } } return { behavior: 'passthrough' } } /** * Checks for download cradle patterns - common malware techniques * that download and execute remote code. * * Per-statement: catches piped cradles (`IWR ... | IEX`). * Cross-statement: catches split cradles (`$r = IWR ...; IEX $r.Content`). * The cross-statement case is already blocked by checkInvokeExpression (which * scans all statements), but this check improves the warning message. */ const DOWNLOADER_NAMES = new Set([ 'invoke-webrequest', 'iwr', 'invoke-restmethod', 'irm', 'new-object', 'start-bitstransfer', // MITRE T1197 ]) function isDownloader(name: string): boolean { return DOWNLOADER_NAMES.has(name.toLowerCase()) } function isIex(name: string): boolean { const lower = name.toLowerCase() return lower === 'invoke-expression' || lower === 'iex' } function checkDownloadCradles( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { // Per-statement: piped cradle (IWR ... | IEX) for (const statement of parsed.statements) { const cmds = statement.commands if (cmds.length < 2) { continue } const hasDownloader = cmds.some(cmd => isDownloader(cmd.name)) const hasIex = cmds.some(cmd => isIex(cmd.name)) if (hasDownloader && hasIex) { return { behavior: 'ask', message: 'Command downloads and executes remote code', } } } // Cross-statement: split cradle ($r = IWR ...; IEX $r.Content). // No new false positives: if IEX is present, checkInvokeExpression already asks. const all = getAllCommands(parsed) if (all.some(c => isDownloader(c.name)) && all.some(c => isIex(c.name))) { return { behavior: 'ask', message: 'Command downloads and executes remote code', } } return { behavior: 'passthrough' } } /** * Checks for standalone download utilities — LOLBAS tools commonly used to * fetch payloads. Unlike checkDownloadCradles (which requires download + IEX * in-pipeline), this flags the download operation itself. * * Start-BitsTransfer: always a file transfer (MITRE T1197). * certutil -urlcache: classic LOLBAS download. Only flagged with -urlcache; * bare `certutil` has many legitimate cert-management uses. * bitsadmin /transfer: legacy BITS download (pre-PowerShell). */ function checkDownloadUtilities( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { for (const cmd of getAllCommands(parsed)) { const lower = cmd.name.toLowerCase() // Start-BitsTransfer is purpose-built for file transfer — no safe variant. if (lower === 'start-bitstransfer') { return { behavior: 'ask', message: 'Command downloads files via BITS transfer', } } // certutil / certutil.exe — only when -urlcache is present. certutil has // many non-download uses (cert store queries, encoding, etc.). // certutil.exe accepts both -urlcache and /urlcache per standard Windows // utility convention — check both forms (bitsadmin below does the same). if (lower === 'certutil' || lower === 'certutil.exe') { const hasUrlcache = cmd.args.some(a => { const la = a.toLowerCase() return la === '-urlcache' || la === '/urlcache' }) if (hasUrlcache) { return { behavior: 'ask', message: 'Command uses certutil to download from a URL', } } } // bitsadmin /transfer — legacy BITS CLI, same threat as Start-BitsTransfer. if (lower === 'bitsadmin' || lower === 'bitsadmin.exe') { if (cmd.args.some(a => a.toLowerCase() === '/transfer')) { return { behavior: 'ask', message: 'Command downloads files via BITS transfer', } } } } return { behavior: 'passthrough' } } /** * Checks for Add-Type usage which compiles and loads .NET code at runtime. * This can be used to execute arbitrary compiled code. */ function checkAddType( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { if (hasCommandNamed(parsed, 'Add-Type')) { return { behavior: 'ask', message: 'Command compiles and loads .NET code', } } return { behavior: 'passthrough' } } /** * Checks for New-Object -ComObject. COM objects like WScript.Shell, * Shell.Application, MMC20.Application, Schedule.Service, Msxml2.XMLHTTP * have their own execution/download capabilities — no IEX required. * * We can't enumerate all dangerous ProgIDs, so flag any -ComObject. Object * creation alone is inert, but the prompt should warn the user that COM * instantiation is an execution primitive. Method invocation on the result * (.Run(), .Exec()) is separately caught by checkMemberInvocations. */ function checkComObject( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { for (const cmd of getAllCommands(parsed)) { if (cmd.name.toLowerCase() !== 'new-object') { continue } // -ComObject min abbrev is -com (New-Object params: -TypeName, -ComObject, // -ArgumentList, -Property, -Strict; -co is ambiguous in PS5.1 due to // common params like -Confirm, so use -com). if (psExeHasParamAbbreviation(cmd, '-comobject', '-com')) { return { behavior: 'ask', message: 'Command instantiates a COM object which may have execution capabilities', } } // SECURITY: checkTypeLiterals only sees [bracket] syntax from // parsed.typeLiterals. `New-Object System.Net.WebClient` passes the type // as a STRING ARG (StringConstantExpressionAst), not a TypeExpressionAst, // so CLM never fires. Extract -TypeName (named, colon-bound, or // positional-0) and run through isClmAllowedType. Closes attackVectors D4. let typeName: string | undefined for (let i = 0; i < cmd.args.length; i++) { const a = cmd.args[i]! const lower = a.toLowerCase() // -TypeName abbrev: -t is unambiguous (no other New-Object -t* params). // Handle colon-bound form first: -TypeName:Foo.Bar if (lower.startsWith('-t') && lower.includes(':')) { const colonIdx = a.indexOf(':') const paramPart = lower.slice(0, colonIdx) if ('-typename'.startsWith(paramPart)) { typeName = a.slice(colonIdx + 1) break } } // Space-separated form: -TypeName Foo.Bar if ( lower.startsWith('-t') && '-typename'.startsWith(lower) && cmd.args[i + 1] !== undefined ) { typeName = cmd.args[i + 1] break } } // Positional-0 binds to -TypeName (NetParameterSet default). Named params // (-Strict, -ArgumentList, -Property, -ComObject) may appear before the // positional TypeName, so scan past them to find the first non-consumed arg. if (typeName === undefined) { // New-Object named params that consume a following value argument const VALUE_PARAMS = new Set(['-argumentlist', '-comobject', '-property']) // Switch params (no value argument) const SWITCH_PARAMS = new Set(['-strict']) for (let i = 0; i < cmd.args.length; i++) { const a = cmd.args[i]! if (a.startsWith('-')) { const lower = a.toLowerCase() // Skip -TypeName variants (already handled by named-param loop above) if (lower.startsWith('-t') && '-typename'.startsWith(lower)) { i++ // skip value continue } // Colon-bound form: -Param:Value (single token, no skip needed) if (lower.includes(':')) continue if (SWITCH_PARAMS.has(lower)) continue if (VALUE_PARAMS.has(lower)) { i++ // skip value continue } // Unknown param — skip conservatively continue } // First non-dash arg is the positional TypeName typeName = a break } } if (typeName !== undefined && !isClmAllowedType(typeName)) { return { behavior: 'ask', message: `New-Object instantiates .NET type '${typeName}' outside the ConstrainedLanguage allowlist`, } } } return { behavior: 'passthrough' } } /** * Checks for DANGEROUS_SCRIPT_BLOCK_CMDLETS invoked with -FilePath (or * -LiteralPath). These run a script file — arbitrary code execution with no * ScriptBlockAst in the tree. * * checkScriptBlockInjection only fires when hasScriptBlocks is true. With * -FilePath there is no ScriptBlockAst, so DANGEROUS_SCRIPT_BLOCK_CMDLETS is * never consulted. This check closes that gap for the -FilePath vector. * * Cmdlets in DANGEROUS_SCRIPT_BLOCK_CMDLETS that accept -FilePath: * Invoke-Command -FilePath (icm alias via COMMON_ALIASES) * Start-Job -FilePath, -LiteralPath * Start-ThreadJob -FilePath * Register-ScheduledJob -FilePath * The *-PSSession and Register-*Event entries do not accept -FilePath. * * -f is unambiguous for -FilePath on all four (no other -f* params). * -l is unambiguous for -LiteralPath on Start-Job; harmless no-op on the * others (no -l* params to collide with). */ function checkDangerousFilePathExecution( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { for (const cmd of getAllCommands(parsed)) { const lower = cmd.name.toLowerCase() const resolved = COMMON_ALIASES[lower]?.toLowerCase() ?? lower if (!FILEPATH_EXECUTION_CMDLETS.has(resolved)) { continue } if ( psExeHasParamAbbreviation(cmd, '-filepath', '-f') || psExeHasParamAbbreviation(cmd, '-literalpath', '-l') ) { return { behavior: 'ask', message: `${cmd.name} -FilePath executes an arbitrary script file`, } } // Positional binding: `Start-Job script.ps1` binds position-0 to // -FilePath via FilePathParameterSet resolution (ScriptBlock args select // ScriptBlockParameterSet instead). Same pattern as checkForEachMemberName: // any non-dash StringConstant is a potential -FilePath. Over-flagging // (e.g., `Start-Job -Name foo` where `foo` is StringConstant) is fail-safe. for (let i = 0; i < cmd.args.length; i++) { const argType = cmd.elementTypes?.[i + 1] const arg = cmd.args[i] if (argType === 'StringConstant' && arg && !arg.startsWith('-')) { return { behavior: 'ask', message: `${cmd.name} with positional string argument binds to -FilePath and executes a script file`, } } } } return { behavior: 'passthrough' } } /** * Checks for ForEach-Object -MemberName. Invokes a method by string name on * every piped object — semantically equivalent to `| % { $_.Method() }` but * without any ScriptBlockAst or InvokeMemberExpressionAst in the tree. * * PoC: `Get-Process | ForEach-Object -MemberName Kill` → kills all processes. * checkScriptBlockInjection misses it (no script block); checkMemberInvocations * misses it (no .Method() syntax). Aliases `%` and `foreach` resolve via * COMMON_ALIASES. */ function checkForEachMemberName( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { for (const cmd of getAllCommands(parsed)) { const lower = cmd.name.toLowerCase() const resolved = COMMON_ALIASES[lower]?.toLowerCase() ?? lower if (resolved !== 'foreach-object') { continue } // ForEach-Object params starting with -m: only -MemberName. -m is unambiguous. if (psExeHasParamAbbreviation(cmd, '-membername', '-m')) { return { behavior: 'ask', message: 'ForEach-Object -MemberName invokes methods by string name which cannot be validated', } } // PS7+: `ForEach-Object Kill` binds a positional string arg to // -MemberName via MemberSet parameter-set resolution (ScriptBlock args // select ScriptBlockSet instead). Scan ALL args — `-Verbose Kill` or // `-ErrorAction Stop Kill` still binds Kill positionally. Any non-dash // StringConstant is a potential -MemberName; over-flagging is fail-safe. for (let i = 0; i < cmd.args.length; i++) { const argType = cmd.elementTypes?.[i + 1] const arg = cmd.args[i] if (argType === 'StringConstant' && arg && !arg.startsWith('-')) { return { behavior: 'ask', message: 'ForEach-Object with positional string argument binds to -MemberName and invokes methods by name', } } } } return { behavior: 'passthrough' } } /** * Checks for dangerous Start-Process patterns. * * Two vectors: * 1. `-Verb RunAs` — privilege escalation (UAC prompt). * 2. Launching a PowerShell executable — nested invocation. * `Start-Process pwsh -ArgumentList "-e "` evades * checkEncodedCommand/checkPwshCommandOrFile because cmd.name is * `Start-Process`, not `pwsh`. The `-e` lives inside the -ArgumentList * string value and is never parsed as a param on the outer command. * Rather than parse -ArgumentList contents (fragile — it's an opaque * string or array), flag any Start-Process whose target is a PS * executable: the nested invocation is unvalidatable by construction. */ function checkStartProcess( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { for (const cmd of getAllCommands(parsed)) { const lower = cmd.name.toLowerCase() if (lower !== 'start-process' && lower !== 'saps' && lower !== 'start') { continue } // Vector 1: -Verb RunAs (space or colon syntax). // Space syntax: psExeHasParamAbbreviation finds -Verb/-v, then scan args // for a bare 'runas' token. if ( psExeHasParamAbbreviation(cmd, '-Verb', '-v') && cmd.args.some(a => a.toLowerCase() === 'runas') ) { return { behavior: 'ask', message: 'Command requests elevated privileges', } } // Colon syntax — two layers: // (a) Structural: PR #23554 added children[] for colon-bound param args. // children[i] = [{type, text}] for the bound value. Check if any // -v*-prefixed param has a child whose text normalizes (strip // quotes/backtick/whitespace) to 'runas'. Robust against arbitrary // quoting the regex can't anticipate. // (b) Regex fallback: for parsed output without children[] or as // defense-in-depth. -Verb:'RunAs', -Verb:"RunAs", -Verb:`runas all // bypassed the old /...:runas$/ pattern because the quote/tick broke // the match. if (cmd.children) { for (let i = 0; i < cmd.args.length; i++) { // Strip backticks before matching param name (bug #14): -V`erb:RunAs const argClean = cmd.args[i]!.replace(/`/g, '') if (!/^[-\u2013\u2014\u2015/]v[a-z]*:/i.test(argClean)) continue const kids = cmd.children[i] if (!kids) continue for (const child of kids) { if (child.text.replace(/['"`\s]/g, '').toLowerCase() === 'runas') { return { behavior: 'ask', message: 'Command requests elevated privileges', } } } } } if ( cmd.args.some(a => { // Strip backticks before matching (bug #14 / review nit #2) const clean = a.replace(/`/g, '') return /^[-\u2013\u2014\u2015/]v[a-z]*:['"` ]*runas['"` ]*$/i.test( clean, ) }) ) { return { behavior: 'ask', message: 'Command requests elevated privileges', } } // Vector 2: Start-Process targeting a PowerShell executable. // Target is either the first positional arg or the value after -FilePath. // Scan all args — any PS-executable token present is treated as the launch // target. Known false-positive: path-valued params (-WorkingDirectory, // -RedirectStandard*) whose basename is pwsh/powershell — // isPowerShellExecutable extracts basenames from paths, so // `-WorkingDirectory C:\projects\pwsh` triggers. Accepted trade-off: // Start-Process is not in CMDLET_ALLOWLIST (always prompts regardless), // result is ask not reject, and correctly parsing Start-Process parameter // binding is fragile. Strip quotes the parser may have preserved. for (const arg of cmd.args) { const stripped = arg.replace(/^['"]|['"]$/g, '') if (isPowerShellExecutable(stripped)) { return { behavior: 'ask', message: 'Start-Process launches a nested PowerShell process which cannot be validated', } } } } return { behavior: 'passthrough' } } /** * Cmdlets where script blocks are safe (filtering/output cmdlets). * Script blocks piped to these are just predicates or projections, not arbitrary execution. */ const SAFE_SCRIPT_BLOCK_CMDLETS = new Set([ 'where-object', 'sort-object', 'select-object', 'group-object', 'format-table', 'format-list', 'format-wide', 'format-custom', // NOT foreach-object — its block is arbitrary script, not a predicate. // getAllCommands recurses so commands inside the block ARE checked, but // non-command AST nodes (AssignmentStatementAst etc.) are invisible to it. // See powershellPermissions.ts step-5 hasScriptBlocks guard. ]) /** * Checks for script block injection patterns where script blocks * appear in suspicious contexts that could execute arbitrary code. * * Script blocks used with safe filtering/output cmdlets (Where-Object, * Sort-Object, Select-Object, Group-Object) are allowed. * Script blocks used with dangerous cmdlets (Invoke-Command, Invoke-Expression, * Start-Job, etc.) are flagged. */ function checkScriptBlockInjection( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { const security = deriveSecurityFlags(parsed) if (!security.hasScriptBlocks) { return { behavior: 'passthrough' } } // Check all commands in the parsed result. If any command is in the // dangerous set, flag it. If all commands with script blocks are in // the safe set (or the allowlist), allow it. for (const cmd of getAllCommands(parsed)) { const lower = cmd.name.toLowerCase() if (DANGEROUS_SCRIPT_BLOCK_CMDLETS.has(lower)) { return { behavior: 'ask', message: 'Command contains script block with dangerous cmdlet that may execute arbitrary code', } } } // Check if all commands are either safe script block consumers or don't use script blocks const allCommandsSafe = getAllCommands(parsed).every(cmd => { const lower = cmd.name.toLowerCase() // Safe filtering/output cmdlets if (SAFE_SCRIPT_BLOCK_CMDLETS.has(lower)) { return true } // Resolve aliases const alias = COMMON_ALIASES[lower] if (alias && SAFE_SCRIPT_BLOCK_CMDLETS.has(alias.toLowerCase())) { return true } // Unknown command with script blocks present — flag as potentially dangerous return false }) if (allCommandsSafe) { return { behavior: 'passthrough' } } return { behavior: 'ask', message: 'Command contains script block that may execute arbitrary code', } } /** * AST-only check: Detects subexpressions $() which can hide command execution. */ function checkSubExpressions( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { if (deriveSecurityFlags(parsed).hasSubExpressions) { return { behavior: 'ask', message: 'Command contains subexpressions $()', } } return { behavior: 'passthrough' } } /** * AST-only check: Detects expandable strings (double-quoted) with embedded * expressions like "$env:PATH" or "$(dangerous-command)". These can hide * command execution or variable interpolation inside string literals. */ function checkExpandableStrings( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { if (deriveSecurityFlags(parsed).hasExpandableStrings) { return { behavior: 'ask', message: 'Command contains expandable strings with embedded expressions', } } return { behavior: 'passthrough' } } /** * AST-only check: Detects splatting (@variable) which can obscure arguments. */ function checkSplatting( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { if (deriveSecurityFlags(parsed).hasSplatting) { return { behavior: 'ask', message: 'Command uses splatting (@variable)', } } return { behavior: 'passthrough' } } /** * AST-only check: Detects stop-parsing token (--%) which prevents further parsing. */ function checkStopParsing( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { if (deriveSecurityFlags(parsed).hasStopParsing) { return { behavior: 'ask', message: 'Command uses stop-parsing token (--%)', } } return { behavior: 'passthrough' } } /** * AST-only check: Detects .NET method invocations which can access system APIs. */ function checkMemberInvocations( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { if (deriveSecurityFlags(parsed).hasMemberInvocations) { return { behavior: 'ask', message: 'Command invokes .NET methods', } } return { behavior: 'passthrough' } } /** * AST-only check: type literals outside Microsoft's ConstrainedLanguage * allowlist. CLM blocks all .NET type access except ~90 primitives/attributes * Microsoft considers safe for untrusted code. We trust that list as the * "safe" boundary — anything outside it (Reflection.Assembly, IO.Pipes, * Diagnostics.Process, InteropServices.Marshal, etc.) can access system APIs * that compromise the permission model. * * Runs AFTER checkMemberInvocations: that broadly flags any ::Method / .Method() * call; this check is the more specific "which types" signal. Both fire on * [Reflection.Assembly]::Load; CLM gives the precise message. Pure type casts * like [int]$x have no member invocation and only hit this check. */ function checkTypeLiterals( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { for (const t of parsed.typeLiterals ?? []) { if (!isClmAllowedType(t)) { return { behavior: 'ask', message: `Command uses .NET type [${t}] outside the ConstrainedLanguage allowlist`, } } } return { behavior: 'passthrough' } } /** * Invoke-Item (alias ii) opens a file with its default handler (ShellExecute * on Windows, open/xdg-open on Unix). On an .exe/.ps1/.bat/.cmd this is RCE. * Bug 008: ii is in no blocklist; passthrough prompt doesn't explain the * exec hazard. Always ask — there is no safe variant (even opening .txt may * invoke a user-configured handler that accepts arguments). */ function checkInvokeItem( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { for (const cmd of getAllCommands(parsed)) { const lower = cmd.name.toLowerCase() if (lower === 'invoke-item' || lower === 'ii') { return { behavior: 'ask', message: 'Invoke-Item opens files with the default handler (ShellExecute). On executable files this runs arbitrary code.', } } } return { behavior: 'passthrough' } } /** * Scheduled-task persistence primitives. Register-ScheduledJob was blocked * (DANGEROUS_SCRIPT_BLOCK_CMDLETS); the newer Register-ScheduledTask cmdlet * and legacy schtasks.exe /create were not. Persistence that survives the * session with no explanatory prompt. */ const SCHEDULED_TASK_CMDLETS = new Set([ 'register-scheduledtask', 'new-scheduledtask', 'new-scheduledtaskaction', 'set-scheduledtask', ]) function checkScheduledTask( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { for (const cmd of getAllCommands(parsed)) { const lower = cmd.name.toLowerCase() if (SCHEDULED_TASK_CMDLETS.has(lower)) { return { behavior: 'ask', message: `${cmd.name} creates or modifies a scheduled task (persistence primitive)`, } } if (lower === 'schtasks' || lower === 'schtasks.exe') { if ( cmd.args.some(a => { const la = a.toLowerCase() return ( la === '/create' || la === '/change' || la === '-create' || la === '-change' ) }) ) { return { behavior: 'ask', message: 'schtasks with create/change modifies scheduled tasks (persistence primitive)', } } } } return { behavior: 'passthrough' } } /** * AST-only check: Detects environment variable manipulation via Set-Item/New-Item on env: scope. */ const ENV_WRITE_CMDLETS = new Set([ 'set-item', 'si', 'new-item', 'ni', 'remove-item', 'ri', 'del', 'rm', 'rd', 'rmdir', 'erase', 'clear-item', 'cli', 'set-content', // 'sc' omitted — collides with sc.exe on PS Core 7+, see COMMON_ALIASES note 'add-content', 'ac', ]) function checkEnvVarManipulation( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { const envVars = getVariablesByScope(parsed, 'env') if (envVars.length === 0) { return { behavior: 'passthrough' } } // Check if any command is a write cmdlet for (const cmd of getAllCommands(parsed)) { if (ENV_WRITE_CMDLETS.has(cmd.name.toLowerCase())) { return { behavior: 'ask', message: 'Command modifies environment variables', } } } // Also flag if there are assignments involving env vars if (deriveSecurityFlags(parsed).hasAssignments && envVars.length > 0) { return { behavior: 'ask', message: 'Command modifies environment variables', } } return { behavior: 'passthrough' } } /** * Module-loading cmdlets execute a .psm1's top-level script body (Import-Module) * or download from arbitrary repositories (Install-Module, Save-Module). A * wildcard allow rule like `Import-Module:*` would let an attacker-supplied * .psm1 execute with the user's privileges — same risk as Invoke-Expression. * * NEVER_SUGGEST (dangerousCmdlets.ts) derives from this list so the UI * never offers these as wildcard suggestions, but users can still manually * write allow rules. This check ensures the permission engine independently * gates these cmdlets. */ function checkModuleLoading( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { for (const cmd of getAllCommands(parsed)) { const lower = cmd.name.toLowerCase() if (MODULE_LOADING_CMDLETS.has(lower)) { return { behavior: 'ask', message: 'Command loads, installs, or downloads a PowerShell module or script, which can execute arbitrary code', } } } return { behavior: 'passthrough' } } /** * Set-Alias/New-Alias can hijack future command resolution: after * `Set-Alias Get-Content Invoke-Expression`, any later `Get-Content $x` * executes arbitrary code. Set-Variable/New-Variable can poison * `$PSDefaultParameterValues` (e.g., `Set-Variable PSDefaultParameterValues * @{'*:Path'='/etc/passwd'}`) which alters every subsequent cmdlet's behavior. * Neither effect can be validated statically — we'd need to track all future * command resolutions in the session. Always ask. */ const RUNTIME_STATE_CMDLETS = new Set([ 'set-alias', 'sal', 'new-alias', 'nal', 'set-variable', 'sv', 'new-variable', 'nv', ]) function checkRuntimeStateManipulation( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { for (const cmd of getAllCommands(parsed)) { // Strip module qualifier: `Microsoft.PowerShell.Utility\Set-Alias` → `set-alias` const raw = cmd.name.toLowerCase() const lower = raw.includes('\\') ? raw.slice(raw.lastIndexOf('\\') + 1) : raw if (RUNTIME_STATE_CMDLETS.has(lower)) { return { behavior: 'ask', message: 'Command creates or modifies an alias or variable that can affect future command resolution', } } } return { behavior: 'passthrough' } } /** * Invoke-WmiMethod / Invoke-CimMethod are Start-Process equivalents via WMI. * `Invoke-WmiMethod -Class Win32_Process -Name Create -ArgumentList "cmd /c ..."` * spawns an arbitrary process, bypassing checkStartProcess entirely. No narrow * safe usage exists — -Class and -MethodName accept arbitrary strings, so * gating on Win32_Process specifically would miss -Class $x or other process- * spawning WMI classes. Returns ask on any invocation. (security finding #34) */ const WMI_SPAWN_CMDLETS = new Set([ 'invoke-wmimethod', 'iwmi', 'invoke-cimmethod', ]) function checkWmiProcessSpawn( parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { for (const cmd of getAllCommands(parsed)) { const lower = cmd.name.toLowerCase() if (WMI_SPAWN_CMDLETS.has(lower)) { return { behavior: 'ask', message: `${cmd.name} can spawn arbitrary processes via WMI/CIM (Win32_Process Create)`, } } } return { behavior: 'passthrough' } } /** * Main entry point for PowerShell security validation. * Checks a PowerShell command against known dangerous patterns. * * All checks are AST-based. If the AST parse failed (parsed.valid === false), * none of the individual checks will match and we return 'ask' as a safe default. * * @param command - The PowerShell command to validate (unused, kept for API compat) * @param parsed - Parsed AST from PowerShell's native parser (required) * @returns Security result indicating whether the command is safe */ export function powershellCommandIsSafe( _command: string, parsed: ParsedPowerShellCommand, ): PowerShellSecurityResult { // If the AST parse failed, we cannot determine safety -- ask the user if (!parsed.valid) { return { behavior: 'ask', message: 'Could not parse command for security analysis', } } const validators = [ checkInvokeExpression, checkDynamicCommandName, checkEncodedCommand, checkPwshCommandOrFile, checkDownloadCradles, checkDownloadUtilities, checkAddType, checkComObject, checkDangerousFilePathExecution, checkInvokeItem, checkScheduledTask, checkForEachMemberName, checkStartProcess, checkScriptBlockInjection, checkSubExpressions, checkExpandableStrings, checkSplatting, checkStopParsing, checkMemberInvocations, checkTypeLiterals, checkEnvVarManipulation, checkModuleLoading, checkRuntimeStateManipulation, checkWmiProcessSpawn, ] for (const validator of validators) { const result = validator(parsed) if (result.behavior === 'ask') { return result } } // All checks passed return { behavior: 'passthrough' } }