source dump of claude code
at main 1090 lines 38 kB view raw
1/** 2 * PowerShell-specific security analysis for command validation. 3 * 4 * Detects dangerous patterns: code injection, download cradles, privilege 5 * escalation, dynamic command names, COM objects, etc. 6 * 7 * All checks are AST-based. If parsing failed (valid=false), none of the 8 * individual checks match and powershellCommandIsSafe returns 'ask'. 9 */ 10 11import { 12 DANGEROUS_SCRIPT_BLOCK_CMDLETS, 13 FILEPATH_EXECUTION_CMDLETS, 14 MODULE_LOADING_CMDLETS, 15} from '../../utils/powershell/dangerousCmdlets.js' 16import type { 17 ParsedCommandElement, 18 ParsedPowerShellCommand, 19} from '../../utils/powershell/parser.js' 20import { 21 COMMON_ALIASES, 22 commandHasArgAbbreviation, 23 deriveSecurityFlags, 24 getAllCommands, 25 getVariablesByScope, 26 hasCommandNamed, 27} from '../../utils/powershell/parser.js' 28import { isClmAllowedType } from './clmTypes.js' 29 30type PowerShellSecurityResult = { 31 behavior: 'passthrough' | 'ask' | 'allow' 32 message?: string 33} 34 35const POWERSHELL_EXECUTABLES = new Set([ 36 'pwsh', 37 'pwsh.exe', 38 'powershell', 39 'powershell.exe', 40]) 41 42/** 43 * Extracts the base executable name from a command, handling full paths 44 * like /usr/bin/pwsh, C:\Windows\...\powershell.exe, or .\pwsh. 45 */ 46function isPowerShellExecutable(name: string): boolean { 47 const lower = name.toLowerCase() 48 if (POWERSHELL_EXECUTABLES.has(lower)) { 49 return true 50 } 51 // Extract basename from paths (both / and \ separators) 52 const lastSep = Math.max(lower.lastIndexOf('/'), lower.lastIndexOf('\\')) 53 if (lastSep >= 0) { 54 return POWERSHELL_EXECUTABLES.has(lower.slice(lastSep + 1)) 55 } 56 return false 57} 58 59/** 60 * Alternative parameter-prefix characters that PowerShell accepts as equivalent 61 * to ASCII hyphen-minus (U+002D). PowerShell's tokenizer (SpecialCharacters.IsDash) 62 * and powershell.exe's CommandLineParameterParser both accept all four dash 63 * characters plus Windows PowerShell 5.1's `/` parameter delimiter. 64 * Extent.Text preserves the raw character; transformCommandAst uses ce.text for 65 * CommandParameterAst elements, so these reach us unchanged. 66 */ 67const PS_ALT_PARAM_PREFIXES = new Set([ 68 '/', // Windows PowerShell 5.1 (powershell.exe, not pwsh 7+) 69 '\u2013', // en-dash 70 '\u2014', // em-dash 71 '\u2015', // horizontal bar 72]) 73 74/** 75 * Wrapper around commandHasArgAbbreviation that also matches alternative 76 * parameter prefixes (`/`, en-dash, em-dash, horizontal-bar). PowerShell's 77 * tokenizer (SpecialCharacters.IsDash) accepts these for both powershell.exe 78 * args AND cmdlet parameters, so use this for ALL PS param checks — not just 79 * pwsh.exe invocations. Previously checkComObject/checkStartProcess/ 80 * checkDangerousFilePathExecution/checkForEachMemberName used bare 81 * commandHasArgAbbreviation, so `Start-Process foo –Verb RunAs` bypassed. 82 */ 83function psExeHasParamAbbreviation( 84 cmd: ParsedCommandElement, 85 fullParam: string, 86 minPrefix: string, 87): boolean { 88 if (commandHasArgAbbreviation(cmd, fullParam, minPrefix)) { 89 return true 90 } 91 // Normalize alternative prefixes to `-` and re-check. Build a synthetic cmd 92 // with normalized args; commandHasArgAbbreviation handles colon-value split. 93 const normalized: ParsedCommandElement = { 94 ...cmd, 95 args: cmd.args.map(a => 96 a.length > 0 && PS_ALT_PARAM_PREFIXES.has(a[0]!) ? '-' + a.slice(1) : a, 97 ), 98 } 99 return commandHasArgAbbreviation(normalized, fullParam, minPrefix) 100} 101 102/** 103 * Checks if a PowerShell command uses Invoke-Expression or its alias (iex). 104 * These are equivalent to eval and can execute arbitrary code. 105 */ 106function checkInvokeExpression( 107 parsed: ParsedPowerShellCommand, 108): PowerShellSecurityResult { 109 if (hasCommandNamed(parsed, 'Invoke-Expression')) { 110 return { 111 behavior: 'ask', 112 message: 113 'Command uses Invoke-Expression which can execute arbitrary code', 114 } 115 } 116 return { behavior: 'passthrough' } 117} 118 119/** 120 * Checks for dynamic command invocation where the command name itself is an 121 * expression that cannot be statically resolved. 122 * 123 * PoCs: 124 * & ${function:Invoke-Expression} 'payload' — VariableExpressionAst 125 * & ('iex','x')[0] 'payload' — IndexExpressionAst → 'Other' 126 * & ('i'+'ex') 'payload' — BinaryExpressionAst → 'Other' 127 * 128 * In all cases cmd.name is the literal extent text (e.g. "('iex','x')[0]"), 129 * which doesn't match hasCommandNamed('Invoke-Expression'). At runtime 130 * PowerShell evaluates the expression to a command name and invokes it. 131 * 132 * Legitimate command names are ALWAYS StringConstantExpressionAst (mapped to 133 * 'StringConstant'): `Get-Process`, `git`, `ls`. Any other element type in 134 * name position is dynamic. Rather than denylisting dynamic types (fragile — 135 * mapElementType's default case maps unknown AST types to 'Other', which a 136 * `=== 'Variable'` check misses), we allowlist 'StringConstant'. 137 * 138 * elementTypes[0] is the command-name element (transformCommandAst pushes it 139 * first, before arg elements). The `!== undefined` guard preserves fail-open 140 * when elementTypes is absent (parse-detail unavailable — if parsing failed 141 * entirely, valid=false already returns 'ask' earlier in the chain). 142 */ 143function checkDynamicCommandName( 144 parsed: ParsedPowerShellCommand, 145): PowerShellSecurityResult { 146 for (const cmd of getAllCommands(parsed)) { 147 if (cmd.elementType !== 'CommandAst') { 148 continue 149 } 150 const nameElementType = cmd.elementTypes?.[0] 151 if (nameElementType !== undefined && nameElementType !== 'StringConstant') { 152 return { 153 behavior: 'ask', 154 message: 155 'Command name is a dynamic expression which cannot be statically validated', 156 } 157 } 158 } 159 return { behavior: 'passthrough' } 160} 161 162/** 163 * Checks for encoded command parameters which obscure intent. 164 * These are commonly used in malware to bypass security tools. 165 */ 166function checkEncodedCommand( 167 parsed: ParsedPowerShellCommand, 168): PowerShellSecurityResult { 169 for (const cmd of getAllCommands(parsed)) { 170 if (isPowerShellExecutable(cmd.name)) { 171 if (psExeHasParamAbbreviation(cmd, '-encodedcommand', '-e')) { 172 return { 173 behavior: 'ask', 174 message: 'Command uses encoded parameters which obscure intent', 175 } 176 } 177 } 178 } 179 return { behavior: 'passthrough' } 180} 181 182/** 183 * Checks for PowerShell re-invocation (nested pwsh/powershell process). 184 * 185 * Any PowerShell executable in command position is flagged — not just 186 * -Command/-File. Bare `pwsh` receiving stdin (`Get-Content x | pwsh`) or 187 * a positional script path executes arbitrary code with none of the explicit 188 * flags present. Same unvalidatable-nested-process reasoning as 189 * checkStartProcess vector 2: we cannot statically analyze what the child 190 * process will run. 191 */ 192function checkPwshCommandOrFile( 193 parsed: ParsedPowerShellCommand, 194): PowerShellSecurityResult { 195 for (const cmd of getAllCommands(parsed)) { 196 if (isPowerShellExecutable(cmd.name)) { 197 return { 198 behavior: 'ask', 199 message: 200 'Command spawns a nested PowerShell process which cannot be validated', 201 } 202 } 203 } 204 return { behavior: 'passthrough' } 205} 206 207/** 208 * Checks for download cradle patterns - common malware techniques 209 * that download and execute remote code. 210 * 211 * Per-statement: catches piped cradles (`IWR ... | IEX`). 212 * Cross-statement: catches split cradles (`$r = IWR ...; IEX $r.Content`). 213 * The cross-statement case is already blocked by checkInvokeExpression (which 214 * scans all statements), but this check improves the warning message. 215 */ 216const DOWNLOADER_NAMES = new Set([ 217 'invoke-webrequest', 218 'iwr', 219 'invoke-restmethod', 220 'irm', 221 'new-object', 222 'start-bitstransfer', // MITRE T1197 223]) 224 225function isDownloader(name: string): boolean { 226 return DOWNLOADER_NAMES.has(name.toLowerCase()) 227} 228 229function isIex(name: string): boolean { 230 const lower = name.toLowerCase() 231 return lower === 'invoke-expression' || lower === 'iex' 232} 233 234function checkDownloadCradles( 235 parsed: ParsedPowerShellCommand, 236): PowerShellSecurityResult { 237 // Per-statement: piped cradle (IWR ... | IEX) 238 for (const statement of parsed.statements) { 239 const cmds = statement.commands 240 if (cmds.length < 2) { 241 continue 242 } 243 const hasDownloader = cmds.some(cmd => isDownloader(cmd.name)) 244 const hasIex = cmds.some(cmd => isIex(cmd.name)) 245 if (hasDownloader && hasIex) { 246 return { 247 behavior: 'ask', 248 message: 'Command downloads and executes remote code', 249 } 250 } 251 } 252 253 // Cross-statement: split cradle ($r = IWR ...; IEX $r.Content). 254 // No new false positives: if IEX is present, checkInvokeExpression already asks. 255 const all = getAllCommands(parsed) 256 if (all.some(c => isDownloader(c.name)) && all.some(c => isIex(c.name))) { 257 return { 258 behavior: 'ask', 259 message: 'Command downloads and executes remote code', 260 } 261 } 262 263 return { behavior: 'passthrough' } 264} 265 266/** 267 * Checks for standalone download utilities — LOLBAS tools commonly used to 268 * fetch payloads. Unlike checkDownloadCradles (which requires download + IEX 269 * in-pipeline), this flags the download operation itself. 270 * 271 * Start-BitsTransfer: always a file transfer (MITRE T1197). 272 * certutil -urlcache: classic LOLBAS download. Only flagged with -urlcache; 273 * bare `certutil` has many legitimate cert-management uses. 274 * bitsadmin /transfer: legacy BITS download (pre-PowerShell). 275 */ 276function checkDownloadUtilities( 277 parsed: ParsedPowerShellCommand, 278): PowerShellSecurityResult { 279 for (const cmd of getAllCommands(parsed)) { 280 const lower = cmd.name.toLowerCase() 281 // Start-BitsTransfer is purpose-built for file transfer — no safe variant. 282 if (lower === 'start-bitstransfer') { 283 return { 284 behavior: 'ask', 285 message: 'Command downloads files via BITS transfer', 286 } 287 } 288 // certutil / certutil.exe — only when -urlcache is present. certutil has 289 // many non-download uses (cert store queries, encoding, etc.). 290 // certutil.exe accepts both -urlcache and /urlcache per standard Windows 291 // utility convention — check both forms (bitsadmin below does the same). 292 if (lower === 'certutil' || lower === 'certutil.exe') { 293 const hasUrlcache = cmd.args.some(a => { 294 const la = a.toLowerCase() 295 return la === '-urlcache' || la === '/urlcache' 296 }) 297 if (hasUrlcache) { 298 return { 299 behavior: 'ask', 300 message: 'Command uses certutil to download from a URL', 301 } 302 } 303 } 304 // bitsadmin /transfer — legacy BITS CLI, same threat as Start-BitsTransfer. 305 if (lower === 'bitsadmin' || lower === 'bitsadmin.exe') { 306 if (cmd.args.some(a => a.toLowerCase() === '/transfer')) { 307 return { 308 behavior: 'ask', 309 message: 'Command downloads files via BITS transfer', 310 } 311 } 312 } 313 } 314 return { behavior: 'passthrough' } 315} 316 317/** 318 * Checks for Add-Type usage which compiles and loads .NET code at runtime. 319 * This can be used to execute arbitrary compiled code. 320 */ 321function checkAddType( 322 parsed: ParsedPowerShellCommand, 323): PowerShellSecurityResult { 324 if (hasCommandNamed(parsed, 'Add-Type')) { 325 return { 326 behavior: 'ask', 327 message: 'Command compiles and loads .NET code', 328 } 329 } 330 return { behavior: 'passthrough' } 331} 332 333/** 334 * Checks for New-Object -ComObject. COM objects like WScript.Shell, 335 * Shell.Application, MMC20.Application, Schedule.Service, Msxml2.XMLHTTP 336 * have their own execution/download capabilities — no IEX required. 337 * 338 * We can't enumerate all dangerous ProgIDs, so flag any -ComObject. Object 339 * creation alone is inert, but the prompt should warn the user that COM 340 * instantiation is an execution primitive. Method invocation on the result 341 * (.Run(), .Exec()) is separately caught by checkMemberInvocations. 342 */ 343function checkComObject( 344 parsed: ParsedPowerShellCommand, 345): PowerShellSecurityResult { 346 for (const cmd of getAllCommands(parsed)) { 347 if (cmd.name.toLowerCase() !== 'new-object') { 348 continue 349 } 350 // -ComObject min abbrev is -com (New-Object params: -TypeName, -ComObject, 351 // -ArgumentList, -Property, -Strict; -co is ambiguous in PS5.1 due to 352 // common params like -Confirm, so use -com). 353 if (psExeHasParamAbbreviation(cmd, '-comobject', '-com')) { 354 return { 355 behavior: 'ask', 356 message: 357 'Command instantiates a COM object which may have execution capabilities', 358 } 359 } 360 // SECURITY: checkTypeLiterals only sees [bracket] syntax from 361 // parsed.typeLiterals. `New-Object System.Net.WebClient` passes the type 362 // as a STRING ARG (StringConstantExpressionAst), not a TypeExpressionAst, 363 // so CLM never fires. Extract -TypeName (named, colon-bound, or 364 // positional-0) and run through isClmAllowedType. Closes attackVectors D4. 365 let typeName: string | undefined 366 for (let i = 0; i < cmd.args.length; i++) { 367 const a = cmd.args[i]! 368 const lower = a.toLowerCase() 369 // -TypeName abbrev: -t is unambiguous (no other New-Object -t* params). 370 // Handle colon-bound form first: -TypeName:Foo.Bar 371 if (lower.startsWith('-t') && lower.includes(':')) { 372 const colonIdx = a.indexOf(':') 373 const paramPart = lower.slice(0, colonIdx) 374 if ('-typename'.startsWith(paramPart)) { 375 typeName = a.slice(colonIdx + 1) 376 break 377 } 378 } 379 // Space-separated form: -TypeName Foo.Bar 380 if ( 381 lower.startsWith('-t') && 382 '-typename'.startsWith(lower) && 383 cmd.args[i + 1] !== undefined 384 ) { 385 typeName = cmd.args[i + 1] 386 break 387 } 388 } 389 // Positional-0 binds to -TypeName (NetParameterSet default). Named params 390 // (-Strict, -ArgumentList, -Property, -ComObject) may appear before the 391 // positional TypeName, so scan past them to find the first non-consumed arg. 392 if (typeName === undefined) { 393 // New-Object named params that consume a following value argument 394 const VALUE_PARAMS = new Set(['-argumentlist', '-comobject', '-property']) 395 // Switch params (no value argument) 396 const SWITCH_PARAMS = new Set(['-strict']) 397 for (let i = 0; i < cmd.args.length; i++) { 398 const a = cmd.args[i]! 399 if (a.startsWith('-')) { 400 const lower = a.toLowerCase() 401 // Skip -TypeName variants (already handled by named-param loop above) 402 if (lower.startsWith('-t') && '-typename'.startsWith(lower)) { 403 i++ // skip value 404 continue 405 } 406 // Colon-bound form: -Param:Value (single token, no skip needed) 407 if (lower.includes(':')) continue 408 if (SWITCH_PARAMS.has(lower)) continue 409 if (VALUE_PARAMS.has(lower)) { 410 i++ // skip value 411 continue 412 } 413 // Unknown param — skip conservatively 414 continue 415 } 416 // First non-dash arg is the positional TypeName 417 typeName = a 418 break 419 } 420 } 421 if (typeName !== undefined && !isClmAllowedType(typeName)) { 422 return { 423 behavior: 'ask', 424 message: `New-Object instantiates .NET type '${typeName}' outside the ConstrainedLanguage allowlist`, 425 } 426 } 427 } 428 return { behavior: 'passthrough' } 429} 430 431/** 432 * Checks for DANGEROUS_SCRIPT_BLOCK_CMDLETS invoked with -FilePath (or 433 * -LiteralPath). These run a script file — arbitrary code execution with no 434 * ScriptBlockAst in the tree. 435 * 436 * checkScriptBlockInjection only fires when hasScriptBlocks is true. With 437 * -FilePath there is no ScriptBlockAst, so DANGEROUS_SCRIPT_BLOCK_CMDLETS is 438 * never consulted. This check closes that gap for the -FilePath vector. 439 * 440 * Cmdlets in DANGEROUS_SCRIPT_BLOCK_CMDLETS that accept -FilePath: 441 * Invoke-Command -FilePath (icm alias via COMMON_ALIASES) 442 * Start-Job -FilePath, -LiteralPath 443 * Start-ThreadJob -FilePath 444 * Register-ScheduledJob -FilePath 445 * The *-PSSession and Register-*Event entries do not accept -FilePath. 446 * 447 * -f is unambiguous for -FilePath on all four (no other -f* params). 448 * -l is unambiguous for -LiteralPath on Start-Job; harmless no-op on the 449 * others (no -l* params to collide with). 450 */ 451 452function checkDangerousFilePathExecution( 453 parsed: ParsedPowerShellCommand, 454): PowerShellSecurityResult { 455 for (const cmd of getAllCommands(parsed)) { 456 const lower = cmd.name.toLowerCase() 457 const resolved = COMMON_ALIASES[lower]?.toLowerCase() ?? lower 458 if (!FILEPATH_EXECUTION_CMDLETS.has(resolved)) { 459 continue 460 } 461 if ( 462 psExeHasParamAbbreviation(cmd, '-filepath', '-f') || 463 psExeHasParamAbbreviation(cmd, '-literalpath', '-l') 464 ) { 465 return { 466 behavior: 'ask', 467 message: `${cmd.name} -FilePath executes an arbitrary script file`, 468 } 469 } 470 // Positional binding: `Start-Job script.ps1` binds position-0 to 471 // -FilePath via FilePathParameterSet resolution (ScriptBlock args select 472 // ScriptBlockParameterSet instead). Same pattern as checkForEachMemberName: 473 // any non-dash StringConstant is a potential -FilePath. Over-flagging 474 // (e.g., `Start-Job -Name foo` where `foo` is StringConstant) is fail-safe. 475 for (let i = 0; i < cmd.args.length; i++) { 476 const argType = cmd.elementTypes?.[i + 1] 477 const arg = cmd.args[i] 478 if (argType === 'StringConstant' && arg && !arg.startsWith('-')) { 479 return { 480 behavior: 'ask', 481 message: `${cmd.name} with positional string argument binds to -FilePath and executes a script file`, 482 } 483 } 484 } 485 } 486 return { behavior: 'passthrough' } 487} 488 489/** 490 * Checks for ForEach-Object -MemberName. Invokes a method by string name on 491 * every piped object — semantically equivalent to `| % { $_.Method() }` but 492 * without any ScriptBlockAst or InvokeMemberExpressionAst in the tree. 493 * 494 * PoC: `Get-Process | ForEach-Object -MemberName Kill` → kills all processes. 495 * checkScriptBlockInjection misses it (no script block); checkMemberInvocations 496 * misses it (no .Method() syntax). Aliases `%` and `foreach` resolve via 497 * COMMON_ALIASES. 498 */ 499function checkForEachMemberName( 500 parsed: ParsedPowerShellCommand, 501): PowerShellSecurityResult { 502 for (const cmd of getAllCommands(parsed)) { 503 const lower = cmd.name.toLowerCase() 504 const resolved = COMMON_ALIASES[lower]?.toLowerCase() ?? lower 505 if (resolved !== 'foreach-object') { 506 continue 507 } 508 // ForEach-Object params starting with -m: only -MemberName. -m is unambiguous. 509 if (psExeHasParamAbbreviation(cmd, '-membername', '-m')) { 510 return { 511 behavior: 'ask', 512 message: 513 'ForEach-Object -MemberName invokes methods by string name which cannot be validated', 514 } 515 } 516 // PS7+: `ForEach-Object Kill` binds a positional string arg to 517 // -MemberName via MemberSet parameter-set resolution (ScriptBlock args 518 // select ScriptBlockSet instead). Scan ALL args — `-Verbose Kill` or 519 // `-ErrorAction Stop Kill` still binds Kill positionally. Any non-dash 520 // StringConstant is a potential -MemberName; over-flagging is fail-safe. 521 for (let i = 0; i < cmd.args.length; i++) { 522 const argType = cmd.elementTypes?.[i + 1] 523 const arg = cmd.args[i] 524 if (argType === 'StringConstant' && arg && !arg.startsWith('-')) { 525 return { 526 behavior: 'ask', 527 message: 528 'ForEach-Object with positional string argument binds to -MemberName and invokes methods by name', 529 } 530 } 531 } 532 } 533 return { behavior: 'passthrough' } 534} 535 536/** 537 * Checks for dangerous Start-Process patterns. 538 * 539 * Two vectors: 540 * 1. `-Verb RunAs` — privilege escalation (UAC prompt). 541 * 2. Launching a PowerShell executable — nested invocation. 542 * `Start-Process pwsh -ArgumentList "-e <b64>"` evades 543 * checkEncodedCommand/checkPwshCommandOrFile because cmd.name is 544 * `Start-Process`, not `pwsh`. The `-e` lives inside the -ArgumentList 545 * string value and is never parsed as a param on the outer command. 546 * Rather than parse -ArgumentList contents (fragile — it's an opaque 547 * string or array), flag any Start-Process whose target is a PS 548 * executable: the nested invocation is unvalidatable by construction. 549 */ 550function checkStartProcess( 551 parsed: ParsedPowerShellCommand, 552): PowerShellSecurityResult { 553 for (const cmd of getAllCommands(parsed)) { 554 const lower = cmd.name.toLowerCase() 555 if (lower !== 'start-process' && lower !== 'saps' && lower !== 'start') { 556 continue 557 } 558 // Vector 1: -Verb RunAs (space or colon syntax). 559 // Space syntax: psExeHasParamAbbreviation finds -Verb/-v, then scan args 560 // for a bare 'runas' token. 561 if ( 562 psExeHasParamAbbreviation(cmd, '-Verb', '-v') && 563 cmd.args.some(a => a.toLowerCase() === 'runas') 564 ) { 565 return { 566 behavior: 'ask', 567 message: 'Command requests elevated privileges', 568 } 569 } 570 // Colon syntax — two layers: 571 // (a) Structural: PR #23554 added children[] for colon-bound param args. 572 // children[i] = [{type, text}] for the bound value. Check if any 573 // -v*-prefixed param has a child whose text normalizes (strip 574 // quotes/backtick/whitespace) to 'runas'. Robust against arbitrary 575 // quoting the regex can't anticipate. 576 // (b) Regex fallback: for parsed output without children[] or as 577 // defense-in-depth. -Verb:'RunAs', -Verb:"RunAs", -Verb:`runas all 578 // bypassed the old /...:runas$/ pattern because the quote/tick broke 579 // the match. 580 if (cmd.children) { 581 for (let i = 0; i < cmd.args.length; i++) { 582 // Strip backticks before matching param name (bug #14): -V`erb:RunAs 583 const argClean = cmd.args[i]!.replace(/`/g, '') 584 if (!/^[-\u2013\u2014\u2015/]v[a-z]*:/i.test(argClean)) continue 585 const kids = cmd.children[i] 586 if (!kids) continue 587 for (const child of kids) { 588 if (child.text.replace(/['"`\s]/g, '').toLowerCase() === 'runas') { 589 return { 590 behavior: 'ask', 591 message: 'Command requests elevated privileges', 592 } 593 } 594 } 595 } 596 } 597 if ( 598 cmd.args.some(a => { 599 // Strip backticks before matching (bug #14 / review nit #2) 600 const clean = a.replace(/`/g, '') 601 return /^[-\u2013\u2014\u2015/]v[a-z]*:['"` ]*runas['"` ]*$/i.test( 602 clean, 603 ) 604 }) 605 ) { 606 return { 607 behavior: 'ask', 608 message: 'Command requests elevated privileges', 609 } 610 } 611 // Vector 2: Start-Process targeting a PowerShell executable. 612 // Target is either the first positional arg or the value after -FilePath. 613 // Scan all args — any PS-executable token present is treated as the launch 614 // target. Known false-positive: path-valued params (-WorkingDirectory, 615 // -RedirectStandard*) whose basename is pwsh/powershell — 616 // isPowerShellExecutable extracts basenames from paths, so 617 // `-WorkingDirectory C:\projects\pwsh` triggers. Accepted trade-off: 618 // Start-Process is not in CMDLET_ALLOWLIST (always prompts regardless), 619 // result is ask not reject, and correctly parsing Start-Process parameter 620 // binding is fragile. Strip quotes the parser may have preserved. 621 for (const arg of cmd.args) { 622 const stripped = arg.replace(/^['"]|['"]$/g, '') 623 if (isPowerShellExecutable(stripped)) { 624 return { 625 behavior: 'ask', 626 message: 627 'Start-Process launches a nested PowerShell process which cannot be validated', 628 } 629 } 630 } 631 } 632 return { behavior: 'passthrough' } 633} 634 635/** 636 * Cmdlets where script blocks are safe (filtering/output cmdlets). 637 * Script blocks piped to these are just predicates or projections, not arbitrary execution. 638 */ 639const SAFE_SCRIPT_BLOCK_CMDLETS = new Set([ 640 'where-object', 641 'sort-object', 642 'select-object', 643 'group-object', 644 'format-table', 645 'format-list', 646 'format-wide', 647 'format-custom', 648 // NOT foreach-object — its block is arbitrary script, not a predicate. 649 // getAllCommands recurses so commands inside the block ARE checked, but 650 // non-command AST nodes (AssignmentStatementAst etc.) are invisible to it. 651 // See powershellPermissions.ts step-5 hasScriptBlocks guard. 652]) 653 654/** 655 * Checks for script block injection patterns where script blocks 656 * appear in suspicious contexts that could execute arbitrary code. 657 * 658 * Script blocks used with safe filtering/output cmdlets (Where-Object, 659 * Sort-Object, Select-Object, Group-Object) are allowed. 660 * Script blocks used with dangerous cmdlets (Invoke-Command, Invoke-Expression, 661 * Start-Job, etc.) are flagged. 662 */ 663function checkScriptBlockInjection( 664 parsed: ParsedPowerShellCommand, 665): PowerShellSecurityResult { 666 const security = deriveSecurityFlags(parsed) 667 if (!security.hasScriptBlocks) { 668 return { behavior: 'passthrough' } 669 } 670 671 // Check all commands in the parsed result. If any command is in the 672 // dangerous set, flag it. If all commands with script blocks are in 673 // the safe set (or the allowlist), allow it. 674 for (const cmd of getAllCommands(parsed)) { 675 const lower = cmd.name.toLowerCase() 676 if (DANGEROUS_SCRIPT_BLOCK_CMDLETS.has(lower)) { 677 return { 678 behavior: 'ask', 679 message: 680 'Command contains script block with dangerous cmdlet that may execute arbitrary code', 681 } 682 } 683 } 684 685 // Check if all commands are either safe script block consumers or don't use script blocks 686 const allCommandsSafe = getAllCommands(parsed).every(cmd => { 687 const lower = cmd.name.toLowerCase() 688 // Safe filtering/output cmdlets 689 if (SAFE_SCRIPT_BLOCK_CMDLETS.has(lower)) { 690 return true 691 } 692 // Resolve aliases 693 const alias = COMMON_ALIASES[lower] 694 if (alias && SAFE_SCRIPT_BLOCK_CMDLETS.has(alias.toLowerCase())) { 695 return true 696 } 697 // Unknown command with script blocks present — flag as potentially dangerous 698 return false 699 }) 700 701 if (allCommandsSafe) { 702 return { behavior: 'passthrough' } 703 } 704 705 return { 706 behavior: 'ask', 707 message: 'Command contains script block that may execute arbitrary code', 708 } 709} 710 711/** 712 * AST-only check: Detects subexpressions $() which can hide command execution. 713 */ 714function checkSubExpressions( 715 parsed: ParsedPowerShellCommand, 716): PowerShellSecurityResult { 717 if (deriveSecurityFlags(parsed).hasSubExpressions) { 718 return { 719 behavior: 'ask', 720 message: 'Command contains subexpressions $()', 721 } 722 } 723 return { behavior: 'passthrough' } 724} 725 726/** 727 * AST-only check: Detects expandable strings (double-quoted) with embedded 728 * expressions like "$env:PATH" or "$(dangerous-command)". These can hide 729 * command execution or variable interpolation inside string literals. 730 */ 731function checkExpandableStrings( 732 parsed: ParsedPowerShellCommand, 733): PowerShellSecurityResult { 734 if (deriveSecurityFlags(parsed).hasExpandableStrings) { 735 return { 736 behavior: 'ask', 737 message: 'Command contains expandable strings with embedded expressions', 738 } 739 } 740 return { behavior: 'passthrough' } 741} 742 743/** 744 * AST-only check: Detects splatting (@variable) which can obscure arguments. 745 */ 746function checkSplatting( 747 parsed: ParsedPowerShellCommand, 748): PowerShellSecurityResult { 749 if (deriveSecurityFlags(parsed).hasSplatting) { 750 return { 751 behavior: 'ask', 752 message: 'Command uses splatting (@variable)', 753 } 754 } 755 return { behavior: 'passthrough' } 756} 757 758/** 759 * AST-only check: Detects stop-parsing token (--%) which prevents further parsing. 760 */ 761function checkStopParsing( 762 parsed: ParsedPowerShellCommand, 763): PowerShellSecurityResult { 764 if (deriveSecurityFlags(parsed).hasStopParsing) { 765 return { 766 behavior: 'ask', 767 message: 'Command uses stop-parsing token (--%)', 768 } 769 } 770 return { behavior: 'passthrough' } 771} 772 773/** 774 * AST-only check: Detects .NET method invocations which can access system APIs. 775 */ 776function checkMemberInvocations( 777 parsed: ParsedPowerShellCommand, 778): PowerShellSecurityResult { 779 if (deriveSecurityFlags(parsed).hasMemberInvocations) { 780 return { 781 behavior: 'ask', 782 message: 'Command invokes .NET methods', 783 } 784 } 785 return { behavior: 'passthrough' } 786} 787 788/** 789 * AST-only check: type literals outside Microsoft's ConstrainedLanguage 790 * allowlist. CLM blocks all .NET type access except ~90 primitives/attributes 791 * Microsoft considers safe for untrusted code. We trust that list as the 792 * "safe" boundary — anything outside it (Reflection.Assembly, IO.Pipes, 793 * Diagnostics.Process, InteropServices.Marshal, etc.) can access system APIs 794 * that compromise the permission model. 795 * 796 * Runs AFTER checkMemberInvocations: that broadly flags any ::Method / .Method() 797 * call; this check is the more specific "which types" signal. Both fire on 798 * [Reflection.Assembly]::Load; CLM gives the precise message. Pure type casts 799 * like [int]$x have no member invocation and only hit this check. 800 */ 801function checkTypeLiterals( 802 parsed: ParsedPowerShellCommand, 803): PowerShellSecurityResult { 804 for (const t of parsed.typeLiterals ?? []) { 805 if (!isClmAllowedType(t)) { 806 return { 807 behavior: 'ask', 808 message: `Command uses .NET type [${t}] outside the ConstrainedLanguage allowlist`, 809 } 810 } 811 } 812 return { behavior: 'passthrough' } 813} 814 815/** 816 * Invoke-Item (alias ii) opens a file with its default handler (ShellExecute 817 * on Windows, open/xdg-open on Unix). On an .exe/.ps1/.bat/.cmd this is RCE. 818 * Bug 008: ii is in no blocklist; passthrough prompt doesn't explain the 819 * exec hazard. Always ask — there is no safe variant (even opening .txt may 820 * invoke a user-configured handler that accepts arguments). 821 */ 822function checkInvokeItem( 823 parsed: ParsedPowerShellCommand, 824): PowerShellSecurityResult { 825 for (const cmd of getAllCommands(parsed)) { 826 const lower = cmd.name.toLowerCase() 827 if (lower === 'invoke-item' || lower === 'ii') { 828 return { 829 behavior: 'ask', 830 message: 831 'Invoke-Item opens files with the default handler (ShellExecute). On executable files this runs arbitrary code.', 832 } 833 } 834 } 835 return { behavior: 'passthrough' } 836} 837 838/** 839 * Scheduled-task persistence primitives. Register-ScheduledJob was blocked 840 * (DANGEROUS_SCRIPT_BLOCK_CMDLETS); the newer Register-ScheduledTask cmdlet 841 * and legacy schtasks.exe /create were not. Persistence that survives the 842 * session with no explanatory prompt. 843 */ 844const SCHEDULED_TASK_CMDLETS = new Set([ 845 'register-scheduledtask', 846 'new-scheduledtask', 847 'new-scheduledtaskaction', 848 'set-scheduledtask', 849]) 850 851function checkScheduledTask( 852 parsed: ParsedPowerShellCommand, 853): PowerShellSecurityResult { 854 for (const cmd of getAllCommands(parsed)) { 855 const lower = cmd.name.toLowerCase() 856 if (SCHEDULED_TASK_CMDLETS.has(lower)) { 857 return { 858 behavior: 'ask', 859 message: `${cmd.name} creates or modifies a scheduled task (persistence primitive)`, 860 } 861 } 862 if (lower === 'schtasks' || lower === 'schtasks.exe') { 863 if ( 864 cmd.args.some(a => { 865 const la = a.toLowerCase() 866 return ( 867 la === '/create' || 868 la === '/change' || 869 la === '-create' || 870 la === '-change' 871 ) 872 }) 873 ) { 874 return { 875 behavior: 'ask', 876 message: 877 'schtasks with create/change modifies scheduled tasks (persistence primitive)', 878 } 879 } 880 } 881 } 882 return { behavior: 'passthrough' } 883} 884 885/** 886 * AST-only check: Detects environment variable manipulation via Set-Item/New-Item on env: scope. 887 */ 888const ENV_WRITE_CMDLETS = new Set([ 889 'set-item', 890 'si', 891 'new-item', 892 'ni', 893 'remove-item', 894 'ri', 895 'del', 896 'rm', 897 'rd', 898 'rmdir', 899 'erase', 900 'clear-item', 901 'cli', 902 'set-content', 903 // 'sc' omitted — collides with sc.exe on PS Core 7+, see COMMON_ALIASES note 904 'add-content', 905 'ac', 906]) 907 908function checkEnvVarManipulation( 909 parsed: ParsedPowerShellCommand, 910): PowerShellSecurityResult { 911 const envVars = getVariablesByScope(parsed, 'env') 912 if (envVars.length === 0) { 913 return { behavior: 'passthrough' } 914 } 915 // Check if any command is a write cmdlet 916 for (const cmd of getAllCommands(parsed)) { 917 if (ENV_WRITE_CMDLETS.has(cmd.name.toLowerCase())) { 918 return { 919 behavior: 'ask', 920 message: 'Command modifies environment variables', 921 } 922 } 923 } 924 // Also flag if there are assignments involving env vars 925 if (deriveSecurityFlags(parsed).hasAssignments && envVars.length > 0) { 926 return { 927 behavior: 'ask', 928 message: 'Command modifies environment variables', 929 } 930 } 931 return { behavior: 'passthrough' } 932} 933 934/** 935 * Module-loading cmdlets execute a .psm1's top-level script body (Import-Module) 936 * or download from arbitrary repositories (Install-Module, Save-Module). A 937 * wildcard allow rule like `Import-Module:*` would let an attacker-supplied 938 * .psm1 execute with the user's privileges — same risk as Invoke-Expression. 939 * 940 * NEVER_SUGGEST (dangerousCmdlets.ts) derives from this list so the UI 941 * never offers these as wildcard suggestions, but users can still manually 942 * write allow rules. This check ensures the permission engine independently 943 * gates these cmdlets. 944 */ 945 946function checkModuleLoading( 947 parsed: ParsedPowerShellCommand, 948): PowerShellSecurityResult { 949 for (const cmd of getAllCommands(parsed)) { 950 const lower = cmd.name.toLowerCase() 951 if (MODULE_LOADING_CMDLETS.has(lower)) { 952 return { 953 behavior: 'ask', 954 message: 955 'Command loads, installs, or downloads a PowerShell module or script, which can execute arbitrary code', 956 } 957 } 958 } 959 return { behavior: 'passthrough' } 960} 961 962/** 963 * Set-Alias/New-Alias can hijack future command resolution: after 964 * `Set-Alias Get-Content Invoke-Expression`, any later `Get-Content $x` 965 * executes arbitrary code. Set-Variable/New-Variable can poison 966 * `$PSDefaultParameterValues` (e.g., `Set-Variable PSDefaultParameterValues 967 * @{'*:Path'='/etc/passwd'}`) which alters every subsequent cmdlet's behavior. 968 * Neither effect can be validated statically — we'd need to track all future 969 * command resolutions in the session. Always ask. 970 */ 971const RUNTIME_STATE_CMDLETS = new Set([ 972 'set-alias', 973 'sal', 974 'new-alias', 975 'nal', 976 'set-variable', 977 'sv', 978 'new-variable', 979 'nv', 980]) 981 982function checkRuntimeStateManipulation( 983 parsed: ParsedPowerShellCommand, 984): PowerShellSecurityResult { 985 for (const cmd of getAllCommands(parsed)) { 986 // Strip module qualifier: `Microsoft.PowerShell.Utility\Set-Alias` → `set-alias` 987 const raw = cmd.name.toLowerCase() 988 const lower = raw.includes('\\') 989 ? raw.slice(raw.lastIndexOf('\\') + 1) 990 : raw 991 if (RUNTIME_STATE_CMDLETS.has(lower)) { 992 return { 993 behavior: 'ask', 994 message: 995 'Command creates or modifies an alias or variable that can affect future command resolution', 996 } 997 } 998 } 999 return { behavior: 'passthrough' } 1000} 1001 1002/** 1003 * Invoke-WmiMethod / Invoke-CimMethod are Start-Process equivalents via WMI. 1004 * `Invoke-WmiMethod -Class Win32_Process -Name Create -ArgumentList "cmd /c ..."` 1005 * spawns an arbitrary process, bypassing checkStartProcess entirely. No narrow 1006 * safe usage exists — -Class and -MethodName accept arbitrary strings, so 1007 * gating on Win32_Process specifically would miss -Class $x or other process- 1008 * spawning WMI classes. Returns ask on any invocation. (security finding #34) 1009 */ 1010const WMI_SPAWN_CMDLETS = new Set([ 1011 'invoke-wmimethod', 1012 'iwmi', 1013 'invoke-cimmethod', 1014]) 1015 1016function checkWmiProcessSpawn( 1017 parsed: ParsedPowerShellCommand, 1018): PowerShellSecurityResult { 1019 for (const cmd of getAllCommands(parsed)) { 1020 const lower = cmd.name.toLowerCase() 1021 if (WMI_SPAWN_CMDLETS.has(lower)) { 1022 return { 1023 behavior: 'ask', 1024 message: `${cmd.name} can spawn arbitrary processes via WMI/CIM (Win32_Process Create)`, 1025 } 1026 } 1027 } 1028 return { behavior: 'passthrough' } 1029} 1030 1031/** 1032 * Main entry point for PowerShell security validation. 1033 * Checks a PowerShell command against known dangerous patterns. 1034 * 1035 * All checks are AST-based. If the AST parse failed (parsed.valid === false), 1036 * none of the individual checks will match and we return 'ask' as a safe default. 1037 * 1038 * @param command - The PowerShell command to validate (unused, kept for API compat) 1039 * @param parsed - Parsed AST from PowerShell's native parser (required) 1040 * @returns Security result indicating whether the command is safe 1041 */ 1042export function powershellCommandIsSafe( 1043 _command: string, 1044 parsed: ParsedPowerShellCommand, 1045): PowerShellSecurityResult { 1046 // If the AST parse failed, we cannot determine safety -- ask the user 1047 if (!parsed.valid) { 1048 return { 1049 behavior: 'ask', 1050 message: 'Could not parse command for security analysis', 1051 } 1052 } 1053 1054 const validators = [ 1055 checkInvokeExpression, 1056 checkDynamicCommandName, 1057 checkEncodedCommand, 1058 checkPwshCommandOrFile, 1059 checkDownloadCradles, 1060 checkDownloadUtilities, 1061 checkAddType, 1062 checkComObject, 1063 checkDangerousFilePathExecution, 1064 checkInvokeItem, 1065 checkScheduledTask, 1066 checkForEachMemberName, 1067 checkStartProcess, 1068 checkScriptBlockInjection, 1069 checkSubExpressions, 1070 checkExpandableStrings, 1071 checkSplatting, 1072 checkStopParsing, 1073 checkMemberInvocations, 1074 checkTypeLiterals, 1075 checkEnvVarManipulation, 1076 checkModuleLoading, 1077 checkRuntimeStateManipulation, 1078 checkWmiProcessSpawn, 1079 ] 1080 1081 for (const validator of validators) { 1082 const result = validator(parsed) 1083 if (result.behavior === 'ask') { 1084 return result 1085 } 1086 } 1087 1088 // All checks passed 1089 return { behavior: 'passthrough' } 1090}