source dump of claude code
at main 1804 lines 67 kB view raw
1import { execa } from 'execa' 2import { logForDebugging } from '../debug.js' 3import { memoizeWithLRU } from '../memoize.js' 4import { getCachedPowerShellPath } from '../shell/powershellDetection.js' 5import { jsonParse } from '../slowOperations.js' 6 7// --------------------------------------------------------------------------- 8// Public types describing the parsed output returned to callers. 9// These map to System.Management.Automation.Language AST classes. 10// Raw internal types (RawParsedOutput etc.) are defined further below. 11// --------------------------------------------------------------------------- 12 13/** 14 * The PowerShell AST element type for pipeline elements. 15 * Maps directly to CommandBaseAst derivatives in System.Management.Automation.Language. 16 */ 17type PipelineElementType = 18 | 'CommandAst' 19 | 'CommandExpressionAst' 20 | 'ParenExpressionAst' 21 22/** 23 * The AST node type for individual command elements (arguments, expressions). 24 * Used to classify each element during the AST walk so TypeScript can derive 25 * security flags without extra Find-AstNodes calls in PowerShell. 26 */ 27type CommandElementType = 28 | 'ScriptBlock' 29 | 'SubExpression' 30 | 'ExpandableString' 31 | 'MemberInvocation' 32 | 'Variable' 33 | 'StringConstant' 34 | 'Parameter' 35 | 'Other' 36 37/** 38 * A child node of a command element (one level deep). Populated for 39 * CommandParameterAst → .Argument (colon-bound parameters like 40 * `-InputObject:$env:SECRET`). Consumers check `child.type` to classify 41 * the bound value (Variable, StringConstant, Other) without parsing text. 42 */ 43export type CommandElementChild = { 44 type: CommandElementType 45 text: string 46} 47 48/** 49 * The PowerShell AST statement type. 50 * Maps directly to StatementAst derivatives in System.Management.Automation.Language. 51 */ 52type StatementType = 53 | 'PipelineAst' 54 | 'PipelineChainAst' 55 | 'AssignmentStatementAst' 56 | 'IfStatementAst' 57 | 'ForStatementAst' 58 | 'ForEachStatementAst' 59 | 'WhileStatementAst' 60 | 'DoWhileStatementAst' 61 | 'DoUntilStatementAst' 62 | 'SwitchStatementAst' 63 | 'TryStatementAst' 64 | 'TrapStatementAst' 65 | 'FunctionDefinitionAst' 66 | 'DataStatementAst' 67 | 'UnknownStatementAst' 68 69/** 70 * A command invocation within a pipeline segment. 71 */ 72export type ParsedCommandElement = { 73 /** The command/cmdlet name (e.g., "Get-ChildItem", "git") */ 74 name: string 75 /** The command name type: cmdlet, application (exe), or unknown */ 76 nameType: 'cmdlet' | 'application' | 'unknown' 77 /** The AST element type from PowerShell's parser */ 78 elementType: PipelineElementType 79 /** All arguments as strings (includes flags like "-Recurse") */ 80 args: string[] 81 /** The full text of this command element */ 82 text: string 83 /** AST node types for each element in this command (arguments, expressions, etc.) */ 84 elementTypes?: CommandElementType[] 85 /** 86 * Child nodes of each argument, aligned with `args[]` (so 87 * `children[i]` ↔ `args[i]` ↔ `elementTypes[i+1]`). Only populated for 88 * Parameter elements with a colon-bound argument. Undefined for elements 89 * with no children. Lets consumers check `children[i].some(c => c.type 90 * !== 'StringConstant')` instead of parsing the arg text for `:` + `$`. 91 */ 92 children?: (CommandElementChild[] | undefined)[] 93 /** Redirections on this command element (from nested commands in && / || chains) */ 94 redirections?: ParsedRedirection[] 95} 96 97/** 98 * A redirection found in the command. 99 */ 100type ParsedRedirection = { 101 /** The redirection operator */ 102 operator: '>' | '>>' | '2>' | '2>>' | '*>' | '*>>' | '2>&1' 103 /** The target (file path or stream number) */ 104 target: string 105 /** Whether this is a merging redirection like 2>&1 */ 106 isMerging: boolean 107} 108 109/** 110 * A parsed statement from PowerShell. 111 * Can be a pipeline, assignment, control flow statement, etc. 112 */ 113type ParsedStatement = { 114 /** The AST statement type from PowerShell's parser */ 115 statementType: StatementType 116 /** Individual commands in this statement (for pipelines) */ 117 commands: ParsedCommandElement[] 118 /** Redirections on this statement */ 119 redirections: ParsedRedirection[] 120 /** Full text of the statement */ 121 text: string 122 /** 123 * For control flow statements (if, for, foreach, while, try, etc.), 124 * commands found recursively inside the body blocks. 125 * Uses FindAll() to extract ALL nested CommandAst nodes at any depth. 126 */ 127 nestedCommands?: ParsedCommandElement[] 128 /** 129 * Security-relevant AST patterns found via FindAll() on the entire statement, 130 * regardless of statement type. This catches patterns that elementTypes may 131 * miss (e.g. member invocations inside assignments, subexpressions in 132 * non-pipeline statements). Computed in the PS1 script using instanceof 133 * checks against the PowerShell AST type system. 134 */ 135 securityPatterns?: { 136 hasMemberInvocations?: boolean 137 hasSubExpressions?: boolean 138 hasExpandableStrings?: boolean 139 hasScriptBlocks?: boolean 140 } 141} 142 143/** 144 * A variable reference found in the command. 145 */ 146type ParsedVariable = { 147 /** The variable path (e.g., "HOME", "env:PATH", "global:x") */ 148 path: string 149 /** Whether this variable uses splatting (@var instead of $var) */ 150 isSplatted: boolean 151} 152 153/** 154 * A parse error from PowerShell's parser. 155 */ 156type ParseError = { 157 message: string 158 errorId: string 159} 160 161/** 162 * The complete parsed result from the PowerShell AST parser. 163 */ 164export type ParsedPowerShellCommand = { 165 /** Whether the command parsed successfully (no syntax errors) */ 166 valid: boolean 167 /** Parse errors, if any */ 168 errors: ParseError[] 169 /** Top-level statements, separated by ; or newlines */ 170 statements: ParsedStatement[] 171 /** All variable references found */ 172 variables: ParsedVariable[] 173 /** Whether the token stream contains a stop-parsing (--%) token */ 174 hasStopParsing: boolean 175 /** The original command text */ 176 originalCommand: string 177 /** 178 * All .NET type literals found anywhere in the AST (TypeExpressionAst + 179 * TypeConstraintAst). TypeName.FullName — the literal text as written, NOT 180 * the resolved .NET type (e.g. [int] → "int", not "System.Int32"). 181 * Consumed by the CLM-allowlist check in powershellSecurity.ts. 182 */ 183 typeLiterals?: string[] 184 /** 185 * Whether the command contains `using module` or `using assembly` statements. 186 * These load external code (modules/assemblies) and execute their top-level 187 * script body or module initializers. The using statement is a sibling of 188 * the named blocks on ScriptBlockAst, not a child, so it is not visible 189 * to Process-BlockStatements or any downstream command walker. 190 */ 191 hasUsingStatements?: boolean 192 /** 193 * Whether the command contains `#Requires` directives (ScriptRequirements). 194 * `#Requires -Modules <name>` triggers module loading from PSModulePath. 195 */ 196 hasScriptRequirements?: boolean 197} 198 199// --------------------------------------------------------------------------- 200 201// Default 5s is fine for interactive use (warm pwsh spawn is ~450ms). Windows 202// CI under Defender/AMSI load can exceed 5s on consecutive spawns even after 203// CAN_SPAWN_PARSE_SCRIPT() warms the JIT (run 23574701241 windows-shard-5: 204// attackVectors F1 hit 2×5s timeout → valid:false → 'ask' instead of 'deny'). 205// Override via env for tests. Read inside parsePowerShellCommandImpl, not 206// top-level, per CLAUDE.md (globalSettings.env ordering). 207const DEFAULT_PARSE_TIMEOUT_MS = 5_000 208function getParseTimeoutMs(): number { 209 const env = process.env.CLAUDE_CODE_PWSH_PARSE_TIMEOUT_MS 210 if (env) { 211 const parsed = parseInt(env, 10) 212 if (!isNaN(parsed) && parsed > 0) return parsed 213 } 214 return DEFAULT_PARSE_TIMEOUT_MS 215} 216// MAX_COMMAND_LENGTH is derived from PARSE_SCRIPT_BODY.length below (after the 217// script body is defined) so it cannot go stale as the script grows. 218 219/** 220 * The PowerShell parse script inlined as a string constant. 221 * This avoids needing to read from disk at runtime (the file may not exist 222 * in bundled builds). The script uses the native PowerShell AST parser to 223 * analyze a command and output structured JSON. 224 */ 225// Raw types describing PS script JSON output (exported for testing) 226export type RawCommandElement = { 227 type: string // .GetType().Name e.g. "StringConstantExpressionAst" 228 text: string // .Extent.Text 229 value?: string // .Value if available (resolves backtick escapes) 230 expressionType?: string // .Expression.GetType().Name for CommandExpressionAst 231 children?: { type: string; text: string }[] // CommandParameterAst.Argument, one level 232} 233 234export type RawRedirection = { 235 type: string // "FileRedirectionAst" or "MergingRedirectionAst" 236 append?: boolean // .Append (FileRedirectionAst only) 237 fromStream?: string // .FromStream.ToString() e.g. "Output", "Error", "All" 238 locationText?: string // .Location.Extent.Text (FileRedirectionAst only) 239} 240 241export type RawPipelineElement = { 242 type: string // .GetType().Name e.g. "CommandAst", "CommandExpressionAst" 243 text: string // .Extent.Text 244 commandElements?: RawCommandElement[] 245 redirections?: RawRedirection[] 246 expressionType?: string // for CommandExpressionAst: .Expression.GetType().Name 247} 248 249export type RawStatement = { 250 type: string // .GetType().Name e.g. "PipelineAst", "IfStatementAst", "TrapStatementAst" 251 text: string // .Extent.Text 252 elements?: RawPipelineElement[] // for PipelineAst: the pipeline elements 253 nestedCommands?: RawPipelineElement[] // commands found via FindAll (all statement types) 254 redirections?: RawRedirection[] // FileRedirectionAst found via FindAll (non-PipelineAst only) 255 securityPatterns?: { 256 // Security-relevant AST node types found via FindAll on the statement 257 hasMemberInvocations?: boolean 258 hasSubExpressions?: boolean 259 hasExpandableStrings?: boolean 260 hasScriptBlocks?: boolean 261 } 262} 263 264type RawParsedOutput = { 265 valid: boolean 266 errors: { message: string; errorId: string }[] 267 statements: RawStatement[] 268 variables: { path: string; isSplatted: boolean }[] 269 hasStopParsing: boolean 270 originalCommand: string 271 typeLiterals?: string[] 272 hasUsingStatements?: boolean 273 hasScriptRequirements?: boolean 274} 275 276// This is the canonical copy of the parse script. There is no separate .ps1 file. 277/** 278 * The core parse logic. 279 * The command is passed via Base64-encoded $EncodedCommand variable 280 * to avoid here-string injection attacks. 281 * 282 * SECURITY — top-level ParamBlock: ScriptBlockAst.ParamBlock is a SIBLING of 283 * the named blocks (Begin/Process/End/Clean/DynamicParam), not nested inside 284 * them, so Process-BlockStatements never reaches it. Commands inside param() 285 * default-value expressions and attribute arguments (e.g. [ValidateScript({...})]) 286 * were invisible to every downstream check. PoC: 287 * param($x = (Remove-Item /)); Get-Process → only Get-Process surfaced 288 * param([ValidateScript({rm /;$true})]$x='t') → rm invisible, runs on bind 289 * Function-level param() IS covered: FindAll on the FunctionDefinitionAst 290 * statement recurses into its descendants. The gap was only the script-level 291 * ParamBlock. ParamBlockAst has .Parameters (not .Statements) so we FindAll 292 * on it directly rather than reusing Process-BlockStatements. We only emit a 293 * statement if there is something to report, to avoid noise for plain 294 * param($x) declarations. (Kept compact in-script to preserve argv budget.) 295 */ 296/** 297 * PS1 parse script. Comments live here (not inline) — every char inside the 298 * backticks eats into WINDOWS_MAX_COMMAND_LENGTH (argv budget). 299 * 300 * Structure: 301 * - Get-RawCommandElements: extract CommandAst element data (type, text, value, 302 * expressionType, children for colon-bound param .Argument) 303 * - Get-RawRedirections: extract FileRedirectionAst operator+target 304 * - Get-SecurityPatterns: FindAll for security flags (hasSubExpressions via 305 * Sub/Array/ParenExpressionAst, hasScriptBlocks, etc.) 306 * - Type literals: emit TypeExpressionAst names for CLM allowlist check 307 * - --% token: PS7 MinusMinus, PS5.1 Generic kind 308 * - CommandExpressionAst.Redirections: inherits from CommandBaseAst — 309 * `1 > /tmp/x` statement has FileRedirectionAst that element-iteration misses 310 * - Nested commands: FindAll for ALL statement types (if/for/foreach/while/ 311 * switch/try/function/assignment/PipelineChainAst) — skip direct pipeline 312 * elements already in the loop 313 */ 314// exported for testing 315export const PARSE_SCRIPT_BODY = ` 316if (-not $EncodedCommand) { 317 Write-Output '{"valid":false,"errors":[{"message":"No command provided","errorId":"NoInput"}],"statements":[],"variables":[],"hasStopParsing":false,"originalCommand":""}' 318 exit 0 319} 320 321$Command = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($EncodedCommand)) 322 323$tokens = $null 324$parseErrors = $null 325$ast = [System.Management.Automation.Language.Parser]::ParseInput( 326 $Command, 327 [ref]$tokens, 328 [ref]$parseErrors 329) 330 331$allVariables = [System.Collections.ArrayList]::new() 332 333function Get-RawCommandElements { 334 param([System.Management.Automation.Language.CommandAst]$CmdAst) 335 $elems = [System.Collections.ArrayList]::new() 336 foreach ($ce in $CmdAst.CommandElements) { 337 $ceData = @{ type = $ce.GetType().Name; text = $ce.Extent.Text } 338 if ($ce.PSObject.Properties['Value'] -and $null -ne $ce.Value -and $ce.Value -is [string]) { 339 $ceData.value = $ce.Value 340 } 341 if ($ce -is [System.Management.Automation.Language.CommandExpressionAst]) { 342 $ceData.expressionType = $ce.Expression.GetType().Name 343 } 344 $a=$ce.Argument;if($a){$ceData.children=@(@{type=$a.GetType().Name;text=$a.Extent.Text})} 345 [void]$elems.Add($ceData) 346 } 347 return $elems 348} 349 350function Get-RawRedirections { 351 param($Redirections) 352 $result = [System.Collections.ArrayList]::new() 353 foreach ($redir in $Redirections) { 354 $redirData = @{ type = $redir.GetType().Name } 355 if ($redir -is [System.Management.Automation.Language.FileRedirectionAst]) { 356 $redirData.append = [bool]$redir.Append 357 $redirData.fromStream = $redir.FromStream.ToString() 358 $redirData.locationText = $redir.Location.Extent.Text 359 } 360 [void]$result.Add($redirData) 361 } 362 return $result 363} 364 365function Get-SecurityPatterns($A) { 366 $p = @{} 367 foreach ($n in $A.FindAll({ param($x) 368 $x -is [System.Management.Automation.Language.MemberExpressionAst] -or 369 $x -is [System.Management.Automation.Language.SubExpressionAst] -or 370 $x -is [System.Management.Automation.Language.ArrayExpressionAst] -or 371 $x -is [System.Management.Automation.Language.ExpandableStringExpressionAst] -or 372 $x -is [System.Management.Automation.Language.ScriptBlockExpressionAst] -or 373 $x -is [System.Management.Automation.Language.ParenExpressionAst] 374 }, $true)) { switch ($n.GetType().Name) { 375 'InvokeMemberExpressionAst' { $p.hasMemberInvocations = $true } 376 'MemberExpressionAst' { $p.hasMemberInvocations = $true } 377 'SubExpressionAst' { $p.hasSubExpressions = $true } 378 'ArrayExpressionAst' { $p.hasSubExpressions = $true } 379 'ParenExpressionAst' { $p.hasSubExpressions = $true } 380 'ExpandableStringExpressionAst' { $p.hasExpandableStrings = $true } 381 'ScriptBlockExpressionAst' { $p.hasScriptBlocks = $true } 382 }} 383 if ($p.Count -gt 0) { return $p } 384 return $null 385} 386 387$varExprs = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.VariableExpressionAst] }, $true) 388foreach ($v in $varExprs) { 389 [void]$allVariables.Add(@{ 390 path = $v.VariablePath.ToString() 391 isSplatted = [bool]$v.Splatted 392 }) 393} 394 395$typeLiterals = [System.Collections.ArrayList]::new() 396foreach ($t in $ast.FindAll({ param($n) 397 $n -is [System.Management.Automation.Language.TypeExpressionAst] -or 398 $n -is [System.Management.Automation.Language.TypeConstraintAst] 399}, $true)) { [void]$typeLiterals.Add($t.TypeName.FullName) } 400 401$hasStopParsing = $false 402$tk = [System.Management.Automation.Language.TokenKind] 403foreach ($tok in $tokens) { 404 if ($tok.Kind -eq $tk::MinusMinus) { $hasStopParsing = $true; break } 405 if ($tok.Kind -eq $tk::Generic -and ($tok.Text -replace '[\u2013\u2014\u2015]','-') -eq '--%') { 406 $hasStopParsing = $true; break 407 } 408} 409 410$statements = [System.Collections.ArrayList]::new() 411 412function Process-BlockStatements { 413 param($Block) 414 if (-not $Block) { return } 415 416 foreach ($stmt in $Block.Statements) { 417 $statement = @{ 418 type = $stmt.GetType().Name 419 text = $stmt.Extent.Text 420 } 421 422 if ($stmt -is [System.Management.Automation.Language.PipelineAst]) { 423 $elements = [System.Collections.ArrayList]::new() 424 foreach ($element in $stmt.PipelineElements) { 425 $elemData = @{ 426 type = $element.GetType().Name 427 text = $element.Extent.Text 428 } 429 430 if ($element -is [System.Management.Automation.Language.CommandAst]) { 431 $elemData.commandElements = @(Get-RawCommandElements -CmdAst $element) 432 $elemData.redirections = @(Get-RawRedirections -Redirections $element.Redirections) 433 } elseif ($element -is [System.Management.Automation.Language.CommandExpressionAst]) { 434 $elemData.expressionType = $element.Expression.GetType().Name 435 $elemData.redirections = @(Get-RawRedirections -Redirections $element.Redirections) 436 } 437 438 [void]$elements.Add($elemData) 439 } 440 $statement.elements = @($elements) 441 442 $allNestedCmds = $stmt.FindAll( 443 { param($node) $node -is [System.Management.Automation.Language.CommandAst] }, 444 $true 445 ) 446 $nestedCmds = [System.Collections.ArrayList]::new() 447 foreach ($cmd in $allNestedCmds) { 448 if ($cmd.Parent -eq $stmt) { continue } 449 $nested = @{ 450 type = $cmd.GetType().Name 451 text = $cmd.Extent.Text 452 commandElements = @(Get-RawCommandElements -CmdAst $cmd) 453 redirections = @(Get-RawRedirections -Redirections $cmd.Redirections) 454 } 455 [void]$nestedCmds.Add($nested) 456 } 457 if ($nestedCmds.Count -gt 0) { 458 $statement.nestedCommands = @($nestedCmds) 459 } 460 $r = $stmt.FindAll({param($n) $n -is [System.Management.Automation.Language.FileRedirectionAst]}, $true) 461 if ($r.Count -gt 0) { 462 $rr = @(Get-RawRedirections -Redirections $r) 463 $statement.redirections = if ($statement.redirections) { @($statement.redirections) + $rr } else { $rr } 464 } 465 } else { 466 $nestedCmdAsts = $stmt.FindAll( 467 { param($node) $node -is [System.Management.Automation.Language.CommandAst] }, 468 $true 469 ) 470 $nested = [System.Collections.ArrayList]::new() 471 foreach ($cmd in $nestedCmdAsts) { 472 [void]$nested.Add(@{ 473 type = 'CommandAst' 474 text = $cmd.Extent.Text 475 commandElements = @(Get-RawCommandElements -CmdAst $cmd) 476 redirections = @(Get-RawRedirections -Redirections $cmd.Redirections) 477 }) 478 } 479 if ($nested.Count -gt 0) { 480 $statement.nestedCommands = @($nested) 481 } 482 $r = $stmt.FindAll({param($n) $n -is [System.Management.Automation.Language.FileRedirectionAst]}, $true) 483 if ($r.Count -gt 0) { $statement.redirections = @(Get-RawRedirections -Redirections $r) } 484 } 485 486 $sp = Get-SecurityPatterns $stmt 487 if ($sp) { $statement.securityPatterns = $sp } 488 489 [void]$statements.Add($statement) 490 } 491 492 if ($Block.Traps) { 493 foreach ($trap in $Block.Traps) { 494 $statement = @{ 495 type = 'TrapStatementAst' 496 text = $trap.Extent.Text 497 } 498 $nestedCmdAsts = $trap.FindAll( 499 { param($node) $node -is [System.Management.Automation.Language.CommandAst] }, 500 $true 501 ) 502 $nestedCmds = [System.Collections.ArrayList]::new() 503 foreach ($cmd in $nestedCmdAsts) { 504 $nested = @{ 505 type = $cmd.GetType().Name 506 text = $cmd.Extent.Text 507 commandElements = @(Get-RawCommandElements -CmdAst $cmd) 508 redirections = @(Get-RawRedirections -Redirections $cmd.Redirections) 509 } 510 [void]$nestedCmds.Add($nested) 511 } 512 if ($nestedCmds.Count -gt 0) { 513 $statement.nestedCommands = @($nestedCmds) 514 } 515 $r = $trap.FindAll({param($n) $n -is [System.Management.Automation.Language.FileRedirectionAst]}, $true) 516 if ($r.Count -gt 0) { $statement.redirections = @(Get-RawRedirections -Redirections $r) } 517 $sp = Get-SecurityPatterns $trap 518 if ($sp) { $statement.securityPatterns = $sp } 519 [void]$statements.Add($statement) 520 } 521 } 522} 523 524Process-BlockStatements -Block $ast.BeginBlock 525Process-BlockStatements -Block $ast.ProcessBlock 526Process-BlockStatements -Block $ast.EndBlock 527Process-BlockStatements -Block $ast.CleanBlock 528Process-BlockStatements -Block $ast.DynamicParamBlock 529 530if ($ast.ParamBlock) { 531 $pb = $ast.ParamBlock 532 $pn = [System.Collections.ArrayList]::new() 533 foreach ($c in $pb.FindAll({param($n) $n -is [System.Management.Automation.Language.CommandAst]}, $true)) { 534 [void]$pn.Add(@{type='CommandAst';text=$c.Extent.Text;commandElements=@(Get-RawCommandElements -CmdAst $c);redirections=@(Get-RawRedirections -Redirections $c.Redirections)}) 535 } 536 $pr = $pb.FindAll({param($n) $n -is [System.Management.Automation.Language.FileRedirectionAst]}, $true) 537 $ps = Get-SecurityPatterns $pb 538 if ($pn.Count -gt 0 -or $pr.Count -gt 0 -or $ps) { 539 $st = @{type='ParamBlockAst';text=$pb.Extent.Text} 540 if ($pn.Count -gt 0) { $st.nestedCommands = @($pn) } 541 if ($pr.Count -gt 0) { $st.redirections = @(Get-RawRedirections -Redirections $pr) } 542 if ($ps) { $st.securityPatterns = $ps } 543 [void]$statements.Add($st) 544 } 545} 546 547$hasUsingStatements = $ast.UsingStatements -and $ast.UsingStatements.Count -gt 0 548$hasScriptRequirements = $ast.ScriptRequirements -ne $null 549 550$output = @{ 551 valid = ($parseErrors.Count -eq 0) 552 errors = @($parseErrors | ForEach-Object { 553 @{ 554 message = $_.Message 555 errorId = $_.ErrorId 556 } 557 }) 558 statements = @($statements) 559 variables = @($allVariables) 560 hasStopParsing = $hasStopParsing 561 originalCommand = $Command 562 typeLiterals = @($typeLiterals) 563 hasUsingStatements = [bool]$hasUsingStatements 564 hasScriptRequirements = [bool]$hasScriptRequirements 565} 566 567$output | ConvertTo-Json -Depth 10 -Compress 568` 569 570// --------------------------------------------------------------------------- 571// Windows CreateProcess has a 32,767 char command-line limit. The encoding 572// chain is: 573// command (N UTF-8 bytes) → Base64 (~4N/3 chars) → $EncodedCommand = '...'\n 574// → full script (wrapper + PARSE_SCRIPT_BODY) → UTF-16LE (2× bytes) 575// → Base64 (4/3× chars) → -EncodedCommand argv 576// Final cmdline ≈ argv_overhead + (wrapper + 4N/3 + body) × 8/3 577// 578// Solving for N (UTF-8 bytes) with a 32,767 cap: 579// script_budget = (32767 - argv_overhead) × 3/8 580// cmd_b64_budget = script_budget - PARSE_SCRIPT_BODY.length - wrapper 581// N = cmd_b64_budget × 3/4 - safety_margin 582// 583// SECURITY: N is a UTF-8 BYTE budget, not a UTF-16 code-unit budget. The 584// length gate MUST measure Buffer.byteLength(command, 'utf8'), not 585// command.length. A BMP character in U+0800–U+FFFF (CJK ideographs, most 586// non-Latin scripts) is 1 UTF-16 code unit but 3 UTF-8 bytes. With 587// PARSE_SCRIPT_BODY ≈ 10.6K, N ≈ 1,092 bytes. Comparing against .length 588// permits a 1,092-code-unit pure-CJK command (≈3,276 UTF-8 bytes) → inner 589// base64 ≈ 4,368 chars → final argv ≈ 40K chars, overflowing 32,767 by 590// ~7.4K. CreateProcess fails → valid:false → parse-fail degradation (deny 591// rules silently downgrade to ask). Finding #36. 592// 593// COMPUTED from PARSE_SCRIPT_BODY.length so it cannot drift. The prior 594// hardcoded value (4,500) was derived from a ~6K body estimate; the body is 595// actually ~11K chars, so the real ceiling was ~1,850. Commands in the 596// 1,850–4,500 range passed this gate but then failed CreateProcess on 597// Windows, returning valid=false and skipping all AST-based security checks. 598// 599// Unix argv limits are typically 2MB+ (ARG_MAX) with ~128KB per-argument 600// limit (MAX_ARG_STRLEN on Linux; macOS has no per-arg limit below ARG_MAX). 601// At MAX=4,500 the -EncodedCommand argument is ~45KB — well under either. 602// Applying the Windows-derived limit on Unix would REGRESS: commands in the 603// ~1K–4.5K range previously parsed successfully and reached the sub-command 604// deny loop at powershellPermissions.ts; rejecting them pre-spawn degrades 605// user-configured deny rules from deny→ask for compound commands with a 606// denied cmdlet buried mid-script. So the Windows limit is platform-gated. 607// 608// If the Windows limit becomes too restrictive, switch to -File with a temp 609// file for large inputs. 610// --------------------------------------------------------------------------- 611const WINDOWS_ARGV_CAP = 32_767 612// pwsh path + " -NoProfile -NonInteractive -NoLogo -EncodedCommand " + 613// argv quoting. A long Windows pwsh path (C:\Program Files\PowerShell\7\ 614// pwsh.exe) + flags is ~95 chars; 200 leaves headroom for unusual installs. 615const FIXED_ARGV_OVERHEAD = 200 616// "$EncodedCommand = '" + "'\n" wrapper around the user command's base64 617const ENCODED_CMD_WRAPPER = `$EncodedCommand = ''\n`.length 618// Margin for base64 padding rounding (≤4 chars at each of 2 levels) and minor 619// estimation drift. Multibyte expansion is NOT absorbed here — the gate 620// measures actual UTF-8 bytes (Buffer.byteLength), not code units. 621const SAFETY_MARGIN = 100 622const SCRIPT_CHARS_BUDGET = ((WINDOWS_ARGV_CAP - FIXED_ARGV_OVERHEAD) * 3) / 8 623const CMD_B64_BUDGET = 624 SCRIPT_CHARS_BUDGET - PARSE_SCRIPT_BODY.length - ENCODED_CMD_WRAPPER 625// Exported for drift-guard tests (the drift-prone value is the Windows one). 626// Unit: UTF-8 BYTES. Compare against Buffer.byteLength, not .length. 627export const WINDOWS_MAX_COMMAND_LENGTH = Math.max( 628 0, 629 Math.floor((CMD_B64_BUDGET * 3) / 4) - SAFETY_MARGIN, 630) 631// Pre-existing value, known to work on Unix. See comment above re: why the 632// Windows derivation must NOT be applied here. Unit: UTF-8 BYTES — for ASCII 633// commands (the common case) bytes==chars so no regression; for multibyte 634// commands this is slightly tighter but still far below Unix ARG_MAX (~128KB 635// per-arg), so the argv spawn cannot overflow. 636const UNIX_MAX_COMMAND_LENGTH = 4_500 637// Unit: UTF-8 BYTES (see SECURITY note above). 638export const MAX_COMMAND_LENGTH = 639 process.platform === 'win32' 640 ? WINDOWS_MAX_COMMAND_LENGTH 641 : UNIX_MAX_COMMAND_LENGTH 642 643const INVALID_RESULT_BASE: Omit< 644 ParsedPowerShellCommand, 645 'errors' | 'originalCommand' 646> = { 647 valid: false, 648 statements: [], 649 variables: [], 650 hasStopParsing: false, 651} 652 653function makeInvalidResult( 654 command: string, 655 message: string, 656 errorId: string, 657): ParsedPowerShellCommand { 658 return { 659 ...INVALID_RESULT_BASE, 660 errors: [{ message, errorId }], 661 originalCommand: command, 662 } 663} 664 665/** 666 * Base64-encode a string as UTF-16LE, which is the encoding required by 667 * PowerShell's -EncodedCommand parameter. 668 */ 669function toUtf16LeBase64(text: string): string { 670 if (typeof Buffer !== 'undefined') { 671 return Buffer.from(text, 'utf16le').toString('base64') 672 } 673 // Fallback for non-Node environments 674 const bytes: number[] = [] 675 for (let i = 0; i < text.length; i++) { 676 const code = text.charCodeAt(i) 677 bytes.push(code & 0xff, (code >> 8) & 0xff) 678 } 679 return btoa(bytes.map(b => String.fromCharCode(b)).join('')) 680} 681 682/** 683 * Build the full PowerShell script that parses a command. 684 * The user command is Base64-encoded (UTF-8) and embedded in a variable 685 * to prevent injection attacks. 686 */ 687function buildParseScript(command: string): string { 688 const encoded = 689 typeof Buffer !== 'undefined' 690 ? Buffer.from(command, 'utf8').toString('base64') 691 : btoa( 692 new TextEncoder() 693 .encode(command) 694 .reduce((s, b) => s + String.fromCharCode(b), ''), 695 ) 696 return `$EncodedCommand = '${encoded}'\n${PARSE_SCRIPT_BODY}` 697} 698 699/** 700 * Ensure a value is an array. PowerShell 5.1's ConvertTo-Json may unwrap 701 * single-element arrays into plain objects. 702 */ 703function ensureArray<T>(value: T | T[] | undefined | null): T[] { 704 if (value === undefined || value === null) { 705 return [] 706 } 707 return Array.isArray(value) ? value : [value] 708} 709 710/** Map raw .NET AST type name to our StatementType union */ 711// exported for testing 712export function mapStatementType(rawType: string): StatementType { 713 switch (rawType) { 714 case 'PipelineAst': 715 return 'PipelineAst' 716 case 'PipelineChainAst': 717 return 'PipelineChainAst' 718 case 'AssignmentStatementAst': 719 return 'AssignmentStatementAst' 720 case 'IfStatementAst': 721 return 'IfStatementAst' 722 case 'ForStatementAst': 723 return 'ForStatementAst' 724 case 'ForEachStatementAst': 725 return 'ForEachStatementAst' 726 case 'WhileStatementAst': 727 return 'WhileStatementAst' 728 case 'DoWhileStatementAst': 729 return 'DoWhileStatementAst' 730 case 'DoUntilStatementAst': 731 return 'DoUntilStatementAst' 732 case 'SwitchStatementAst': 733 return 'SwitchStatementAst' 734 case 'TryStatementAst': 735 return 'TryStatementAst' 736 case 'TrapStatementAst': 737 return 'TrapStatementAst' 738 case 'FunctionDefinitionAst': 739 return 'FunctionDefinitionAst' 740 case 'DataStatementAst': 741 return 'DataStatementAst' 742 default: 743 return 'UnknownStatementAst' 744 } 745} 746 747/** Map raw .NET AST type name to our CommandElementType union */ 748// exported for testing 749export function mapElementType( 750 rawType: string, 751 expressionType?: string, 752): CommandElementType { 753 switch (rawType) { 754 case 'ScriptBlockExpressionAst': 755 return 'ScriptBlock' 756 case 'SubExpressionAst': 757 case 'ArrayExpressionAst': 758 // SECURITY: ArrayExpressionAst (@()) is a sibling of SubExpressionAst, 759 // not a subclass. Both evaluate arbitrary pipelines with side effects: 760 // Get-ChildItem @(Remove-Item ./data) runs Remove-Item inside @(). 761 // Map both to SubExpression so hasSubExpressions fires and isReadOnlyCommand 762 // rejects (it doesn't check nestedCommands, only pipeline.commands[]). 763 return 'SubExpression' 764 case 'ExpandableStringExpressionAst': 765 return 'ExpandableString' 766 case 'InvokeMemberExpressionAst': 767 case 'MemberExpressionAst': 768 return 'MemberInvocation' 769 case 'VariableExpressionAst': 770 return 'Variable' 771 case 'StringConstantExpressionAst': 772 case 'ConstantExpressionAst': 773 // ConstantExpressionAst covers numeric literals (5, 3.14). For 774 // permission purposes a numeric literal is as safe as a string 775 // literal — it's an inert value, not code. Without this mapping, 776 // `-Seconds:5` produced children[0].type='Other' and consumers 777 // checking `children.some(c => c.type !== 'StringConstant')` would 778 // false-positive ask on harmless numeric args. 779 return 'StringConstant' 780 case 'CommandParameterAst': 781 return 'Parameter' 782 case 'ParenExpressionAst': 783 return 'SubExpression' 784 case 'CommandExpressionAst': 785 // Delegate to the wrapped expression type so we catch SubExpressionAst, 786 // ExpandableStringExpressionAst, ScriptBlockExpressionAst, etc. 787 // without maintaining a manual list. Falls through to 'Other' if the 788 // inner type is unrecognised. 789 if (expressionType) { 790 return mapElementType(expressionType) 791 } 792 return 'Other' 793 default: 794 return 'Other' 795 } 796} 797 798/** Classify command name as cmdlet, application, or unknown */ 799// exported for testing 800export function classifyCommandName( 801 name: string, 802): 'cmdlet' | 'application' | 'unknown' { 803 if (/^[A-Za-z]+-[A-Za-z][A-Za-z0-9_]*$/.test(name)) { 804 return 'cmdlet' 805 } 806 if (/[.\\/]/.test(name)) { 807 return 'application' 808 } 809 return 'unknown' 810} 811 812/** Strip module prefix from command name (e.g. "Microsoft.PowerShell.Utility\\Invoke-Expression" -> "Invoke-Expression") */ 813// exported for testing 814export function stripModulePrefix(name: string): string { 815 const idx = name.lastIndexOf('\\') 816 if (idx < 0) return name 817 // Don't strip file paths: drive letters (C:\...), UNC paths (\\server\...), or relative paths (.\, ..\) 818 if ( 819 /^[A-Za-z]:/.test(name) || 820 name.startsWith('\\\\') || 821 name.startsWith('.\\') || 822 name.startsWith('..\\') 823 ) 824 return name 825 return name.substring(idx + 1) 826} 827 828/** Transform a raw CommandAst pipeline element into ParsedCommandElement */ 829// exported for testing 830export function transformCommandAst( 831 raw: RawPipelineElement, 832): ParsedCommandElement { 833 const cmdElements = ensureArray(raw.commandElements) 834 let name = '' 835 const args: string[] = [] 836 const elementTypes: CommandElementType[] = [] 837 const children: (CommandElementChild[] | undefined)[] = [] 838 let hasChildren = false 839 840 // SECURITY: nameType MUST be computed from the raw name (before 841 // stripModulePrefix). classifyCommandName('scripts\\Get-Process') returns 842 // 'application' (contains \\) — the correct answer, since PowerShell resolves 843 // this as a file path. After stripping it becomes 'Get-Process' which 844 // classifies as 'cmdlet' — wrong, and allowlist checks would trust it. 845 // Auto-allow paths gate on nameType !== 'application' to catch this. 846 // name (stripped) is still used for deny-rule matching symmetry, which is 847 // fail-safe: deny rules over-match (Module\\Remove-Item still hits a 848 // Remove-Item deny), allow rules are separately gated by nameType. 849 let nameType: 'cmdlet' | 'application' | 'unknown' = 'unknown' 850 if (cmdElements.length > 0) { 851 const first = cmdElements[0]! 852 // SECURITY: only trust .value for string-literal element types with a 853 // string-typed value. Numeric ConstantExpressionAst (e.g. `& 1`) emits an 854 // integer .value that crashes stripModulePrefix() → parser falls through 855 // to passthrough. For non-string-literal or non-string .value, use .text. 856 const isFirstStringLiteral = 857 first.type === 'StringConstantExpressionAst' || 858 first.type === 'ExpandableStringExpressionAst' 859 const rawNameUnstripped = 860 isFirstStringLiteral && typeof first.value === 'string' 861 ? first.value 862 : first.text 863 // SECURITY: strip surrounding quotes from the command name. When .value is 864 // unavailable (no StaticType on the raw node), .text preserves quotes — 865 // `& 'Invoke-Expression' 'x'` yields "'Invoke-Expression'". Stripping here 866 // at the source means every downstream reader of element.name (deny-rule 867 // matching, GIT_SAFETY_WRITE_CMDLETS lookup, resolveToCanonical, etc.) 868 // sees the bare cmdlet name. No-op when .value already stripped. 869 const rawName = rawNameUnstripped.replace(/^['"]|['"]$/g, '') 870 // SECURITY: PowerShell built-in cmdlet names are ASCII-only. Non-ASCII 871 // characters in cmdlet position are inherently suspicious — .NET 872 // OrdinalIgnoreCase folds U+017F (ſ) → S and U+0131 (ı) → I per 873 // UnicodeData.txt SimpleUppercaseMapping, so PowerShell resolves 874 // `ſtart-proceſſ` → Start-Process at runtime. JS .toLowerCase() does NOT 875 // fold these (ſ is already lowercase), so every downstream name 876 // comparison (NEVER_SUGGEST, deny-rule strEquals, resolveToCanonical, 877 // security validators) misses. Force 'application' to gate auto-allow 878 // (blocks at the nameType !== 'application' checks). Finding #31. 879 // Verified on Windows (pwsh 7.x, 2026-03): ſtart-proceſſ does NOT resolve. 880 // Retained as defense-in-depth against future .NET/PS behavior changes 881 // or module-provided command resolution hooks. 882 if (/[\u0080-\uFFFF]/.test(rawName)) { 883 nameType = 'application' 884 } else { 885 nameType = classifyCommandName(rawName) 886 } 887 name = stripModulePrefix(rawName) 888 elementTypes.push(mapElementType(first.type, first.expressionType)) 889 890 for (let i = 1; i < cmdElements.length; i++) { 891 const ce = cmdElements[i]! 892 // Use resolved .value for string constants (strips quotes, resolves 893 // backtick escapes like `n -> newline) but keep raw .text for parameters 894 // (where .value loses the dash prefix, e.g. '-Path' -> 'Path'), 895 // variables, and other non-string types. 896 const isStringLiteral = 897 ce.type === 'StringConstantExpressionAst' || 898 ce.type === 'ExpandableStringExpressionAst' 899 args.push(isStringLiteral && ce.value != null ? ce.value : ce.text) 900 elementTypes.push(mapElementType(ce.type, ce.expressionType)) 901 // Map raw children (CommandParameterAst.Argument) through 902 // mapElementType so consumers see 'Variable', 'StringConstant', etc. 903 const rawChildren = ensureArray(ce.children) 904 if (rawChildren.length > 0) { 905 hasChildren = true 906 children.push( 907 rawChildren.map(c => ({ 908 type: mapElementType(c.type), 909 text: c.text, 910 })), 911 ) 912 } else { 913 children.push(undefined) 914 } 915 } 916 } 917 918 const result: ParsedCommandElement = { 919 name, 920 nameType, 921 elementType: 'CommandAst', 922 args, 923 text: raw.text, 924 elementTypes, 925 ...(hasChildren ? { children } : {}), 926 } 927 928 // Preserve redirections from nested commands (e.g., in && / || chains) 929 const rawRedirs = ensureArray(raw.redirections) 930 if (rawRedirs.length > 0) { 931 result.redirections = rawRedirs.map(transformRedirection) 932 } 933 934 return result 935} 936 937/** Transform a non-CommandAst pipeline element into ParsedCommandElement */ 938// exported for testing 939export function transformExpressionElement( 940 raw: RawPipelineElement, 941): ParsedCommandElement { 942 const elementType: PipelineElementType = 943 raw.type === 'ParenExpressionAst' 944 ? 'ParenExpressionAst' 945 : 'CommandExpressionAst' 946 const elementTypes: CommandElementType[] = [ 947 mapElementType(raw.type, raw.expressionType), 948 ] 949 950 return { 951 name: raw.text, 952 nameType: 'unknown', 953 elementType, 954 args: [], 955 text: raw.text, 956 elementTypes, 957 } 958} 959 960/** Map raw redirection to ParsedRedirection */ 961// exported for testing 962export function transformRedirection(raw: RawRedirection): ParsedRedirection { 963 if (raw.type === 'MergingRedirectionAst') { 964 return { operator: '2>&1', target: '', isMerging: true } 965 } 966 967 const append = raw.append ?? false 968 const fromStream = raw.fromStream ?? 'Output' 969 970 let operator: ParsedRedirection['operator'] 971 if (append) { 972 switch (fromStream) { 973 case 'Error': 974 operator = '2>>' 975 break 976 case 'All': 977 operator = '*>>' 978 break 979 default: 980 operator = '>>' 981 break 982 } 983 } else { 984 switch (fromStream) { 985 case 'Error': 986 operator = '2>' 987 break 988 case 'All': 989 operator = '*>' 990 break 991 default: 992 operator = '>' 993 break 994 } 995 } 996 997 return { operator, target: raw.locationText ?? '', isMerging: false } 998} 999 1000/** Transform a raw statement into ParsedStatement */ 1001// exported for testing 1002export function transformStatement(raw: RawStatement): ParsedStatement { 1003 const statementType = mapStatementType(raw.type) 1004 const commands: ParsedCommandElement[] = [] 1005 const redirections: ParsedRedirection[] = [] 1006 1007 if (raw.elements) { 1008 // PipelineAst: walk pipeline elements 1009 for (const elem of ensureArray(raw.elements)) { 1010 if (elem.type === 'CommandAst') { 1011 commands.push(transformCommandAst(elem)) 1012 for (const redir of ensureArray(elem.redirections)) { 1013 redirections.push(transformRedirection(redir)) 1014 } 1015 } else { 1016 commands.push(transformExpressionElement(elem)) 1017 // SECURITY: CommandExpressionAst also carries .Redirections (inherited 1018 // from CommandBaseAst). `1 > /tmp/evil.txt` is a CommandExpressionAst 1019 // with a FileRedirectionAst. Must extract here or getFileRedirections() 1020 // misses it and compound commands like `Get-ChildItem; 1 > /tmp/x` 1021 // auto-allow at step 5 (only Get-ChildItem is checked). 1022 for (const redir of ensureArray(elem.redirections)) { 1023 redirections.push(transformRedirection(redir)) 1024 } 1025 } 1026 } 1027 // SECURITY: The PS1 PipelineAst branch does a deep FindAll for 1028 // FileRedirectionAst to catch redirections hidden inside: 1029 // - colon-bound ParenExpressionAst args: -Name:('payload' > file) 1030 // - hashtable value statements: @{k='payload' > ~/.bashrc} 1031 // Both are invisible at the element level — the redirection's parent 1032 // is a child of CommandParameterAst / CommandExpressionAst, not a 1033 // separate pipeline element. Merge into statement-level redirections. 1034 // 1035 // The FindAll ALSO re-discovers direct-element redirections already 1036 // captured in the per-element loop above. Dedupe by (operator, target) 1037 // so tests and consumers see the real count. 1038 const seen = new Set(redirections.map(r => `${r.operator}\0${r.target}`)) 1039 for (const redir of ensureArray(raw.redirections)) { 1040 const r = transformRedirection(redir) 1041 const key = `${r.operator}\0${r.target}` 1042 if (!seen.has(key)) { 1043 seen.add(key) 1044 redirections.push(r) 1045 } 1046 } 1047 } else { 1048 // Non-pipeline statement: add synthetic command entry with full text 1049 commands.push({ 1050 name: raw.text, 1051 nameType: 'unknown', 1052 elementType: 'CommandExpressionAst', 1053 args: [], 1054 text: raw.text, 1055 }) 1056 // SECURITY: The PS1 else-branch does a direct recursive FindAll on 1057 // FileRedirectionAst to catch expression redirections inside control flow 1058 // (if/for/foreach/while/switch/try/trap/&& and ||). The CommandAst FindAll 1059 // above CANNOT see these: in if ($x) { 1 > /tmp/evil }, the literal 1 with 1060 // its attached redirection is a CommandExpressionAst — a SIBLING of 1061 // CommandAst in the type hierarchy, not a subclass. So nestedCommands never 1062 // contains it, and without this hoist the redirection is invisible to 1063 // getFileRedirections → step 4.6 misses it → compound commands like 1064 // `Get-Process && 1 > /tmp/evil` auto-allow at step 5 (only Get-Process 1065 // is checked, allowlisted). 1066 // 1067 // Finding FileRedirectionAst DIRECTLY (rather than finding CommandExpressionAst 1068 // and extracting .Redirections) is both simpler and more robust: it catches 1069 // redirections on any node type, including ones we don't know about yet. 1070 // 1071 // Double-counts redirections already on nested CommandAst commands (those are 1072 // extracted at line ~395 into nestedCommands[i].redirections AND found again 1073 // here). Harmless: step 4.6 only checks fileRedirections.length > 0, not 1074 // the exact count. No code does arithmetic on redirection counts. 1075 // 1076 // PS1 SIZE NOTE: The full rationale lives here (TS), not in the PS1 script, 1077 // because PS1 comments bloat the -EncodedCommand payload and push the 1078 // Windows CreateProcess 32K limit. Keep PS1 comments terse; point them here. 1079 for (const redir of ensureArray(raw.redirections)) { 1080 redirections.push(transformRedirection(redir)) 1081 } 1082 } 1083 1084 let nestedCommands: ParsedCommandElement[] | undefined 1085 const rawNested = ensureArray(raw.nestedCommands) 1086 if (rawNested.length > 0) { 1087 nestedCommands = rawNested.map(transformCommandAst) 1088 } 1089 1090 const result: ParsedStatement = { 1091 statementType, 1092 commands, 1093 redirections, 1094 text: raw.text, 1095 nestedCommands, 1096 } 1097 1098 if (raw.securityPatterns) { 1099 result.securityPatterns = raw.securityPatterns 1100 } 1101 1102 return result 1103} 1104 1105/** Transform the complete raw PS output into ParsedPowerShellCommand */ 1106function transformRawOutput(raw: RawParsedOutput): ParsedPowerShellCommand { 1107 const result: ParsedPowerShellCommand = { 1108 valid: raw.valid, 1109 errors: ensureArray(raw.errors), 1110 statements: ensureArray(raw.statements).map(transformStatement), 1111 variables: ensureArray(raw.variables), 1112 hasStopParsing: raw.hasStopParsing, 1113 originalCommand: raw.originalCommand, 1114 } 1115 const tl = ensureArray(raw.typeLiterals) 1116 if (tl.length > 0) { 1117 result.typeLiterals = tl 1118 } 1119 if (raw.hasUsingStatements) { 1120 result.hasUsingStatements = true 1121 } 1122 if (raw.hasScriptRequirements) { 1123 result.hasScriptRequirements = true 1124 } 1125 return result 1126} 1127 1128/** 1129 * Parse a PowerShell command using the native AST parser. 1130 * Spawns pwsh to parse the command and returns structured results. 1131 * Results are memoized by command string. 1132 * 1133 * @param command - The PowerShell command to parse 1134 * @returns Parsed command structure, or a result with valid=false on failure 1135 */ 1136async function parsePowerShellCommandImpl( 1137 command: string, 1138): Promise<ParsedPowerShellCommand> { 1139 // SECURITY: MAX_COMMAND_LENGTH is a UTF-8 BYTE budget (see derivation at the 1140 // constant definition). command.length counts UTF-16 code units; a CJK 1141 // character is 1 code unit but 3 UTF-8 bytes, so .length under-reports by 1142 // up to 3× and allows argv overflow on Windows → CreateProcess fails → 1143 // valid:false → deny rules degrade to ask. Finding #36. 1144 const commandBytes = Buffer.byteLength(command, 'utf8') 1145 if (commandBytes > MAX_COMMAND_LENGTH) { 1146 logForDebugging( 1147 `PowerShell parser: command too long (${commandBytes} bytes, max ${MAX_COMMAND_LENGTH})`, 1148 ) 1149 return makeInvalidResult( 1150 command, 1151 `Command too long for parsing (${commandBytes} bytes). Maximum supported length is ${MAX_COMMAND_LENGTH} bytes.`, 1152 'CommandTooLong', 1153 ) 1154 } 1155 1156 const pwshPath = await getCachedPowerShellPath() 1157 if (!pwshPath) { 1158 return makeInvalidResult( 1159 command, 1160 'PowerShell is not available', 1161 'NoPowerShell', 1162 ) 1163 } 1164 1165 const script = buildParseScript(command) 1166 1167 // Pass the script to PowerShell via -EncodedCommand. 1168 // -EncodedCommand takes a Base64-encoded UTF-16LE string and executes it, 1169 // which avoids: (1) stdin interactive-mode issues where -File - produces 1170 // PS prompts and ANSI escapes in stdout, (2) command-line escaping issues, 1171 // (3) temp files. The script itself is large but well within OS arg limits 1172 // (Windows: 32K chars, Unix: typically 2MB+). 1173 const encodedScript = toUtf16LeBase64(script) 1174 const args = [ 1175 '-NoProfile', 1176 '-NonInteractive', 1177 '-NoLogo', 1178 '-EncodedCommand', 1179 encodedScript, 1180 ] 1181 1182 // Spawn pwsh with one retry on timeout. On loaded CI runners (Windows 1183 // especially), pwsh spawn + .NET JIT + ParseInput occasionally exceeds 5s 1184 // even after CAN_SPAWN_PARSE_SCRIPT() warms the JIT. execa kills the process 1185 // but exitCode is undefined, which the old code reported as the misleading 1186 // "pwsh exited with code 1:" with empty stderr. A single retry absorbs 1187 // transient load spikes; a double timeout is reported as PwshTimeout. 1188 const parseTimeoutMs = getParseTimeoutMs() 1189 let stdout = '' 1190 let stderr = '' 1191 let code: number | null = null 1192 let timedOut = false 1193 for (let attempt = 0; attempt < 2; attempt++) { 1194 try { 1195 const result = await execa(pwshPath, args, { 1196 timeout: parseTimeoutMs, 1197 reject: false, 1198 }) 1199 stdout = result.stdout 1200 stderr = result.stderr 1201 timedOut = result.timedOut 1202 code = result.failed ? (result.exitCode ?? 1) : 0 1203 } catch (e: unknown) { 1204 logForDebugging( 1205 `PowerShell parser: failed to spawn pwsh: ${e instanceof Error ? e.message : e}`, 1206 ) 1207 return makeInvalidResult( 1208 command, 1209 `Failed to spawn PowerShell: ${e instanceof Error ? e.message : e}`, 1210 'PwshSpawnError', 1211 ) 1212 } 1213 if (!timedOut) break 1214 logForDebugging( 1215 `PowerShell parser: pwsh timed out after ${parseTimeoutMs}ms (attempt ${attempt + 1})`, 1216 ) 1217 } 1218 1219 if (timedOut) { 1220 return makeInvalidResult( 1221 command, 1222 `pwsh timed out after ${parseTimeoutMs}ms (2 attempts)`, 1223 'PwshTimeout', 1224 ) 1225 } 1226 1227 if (code !== 0) { 1228 logForDebugging( 1229 `PowerShell parser: pwsh exited with code ${code}, stderr: ${stderr}`, 1230 ) 1231 return makeInvalidResult( 1232 command, 1233 `pwsh exited with code ${code}: ${stderr}`, 1234 'PwshError', 1235 ) 1236 } 1237 1238 const trimmed = stdout.trim() 1239 if (!trimmed) { 1240 logForDebugging('PowerShell parser: empty stdout from pwsh') 1241 return makeInvalidResult( 1242 command, 1243 'No output from PowerShell parser', 1244 'EmptyOutput', 1245 ) 1246 } 1247 1248 try { 1249 const raw = jsonParse(trimmed) as RawParsedOutput 1250 return transformRawOutput(raw) 1251 } catch { 1252 logForDebugging( 1253 `PowerShell parser: invalid JSON output: ${trimmed.slice(0, 200)}`, 1254 ) 1255 return makeInvalidResult( 1256 command, 1257 'Invalid JSON from PowerShell parser', 1258 'InvalidJson', 1259 ) 1260 } 1261} 1262 1263// Error IDs from makeInvalidResult that represent transient process failures. 1264// These should be evicted from the cache so subsequent calls can retry. 1265// Deterministic failures (CommandTooLong, syntax errors from successful parses) 1266// should stay cached since retrying would produce the same result. 1267const TRANSIENT_ERROR_IDS = new Set([ 1268 'PwshSpawnError', 1269 'PwshError', 1270 'PwshTimeout', 1271 'EmptyOutput', 1272 'InvalidJson', 1273]) 1274 1275const parsePowerShellCommandCached = memoizeWithLRU( 1276 (command: string) => { 1277 const promise = parsePowerShellCommandImpl(command) 1278 // Evict transient failures after resolution so they can be retried. 1279 // The current caller still receives the cached promise for this call, 1280 // ensuring concurrent callers share the same result. 1281 void promise.then(result => { 1282 if ( 1283 !result.valid && 1284 TRANSIENT_ERROR_IDS.has(result.errors[0]?.errorId ?? '') 1285 ) { 1286 parsePowerShellCommandCached.cache.delete(command) 1287 } 1288 }) 1289 return promise 1290 }, 1291 (command: string) => command, 1292 256, 1293) 1294export { parsePowerShellCommandCached as parsePowerShellCommand } 1295 1296// --------------------------------------------------------------------------- 1297// Analysis helpers — derived from the parsed AST structure. 1298// --------------------------------------------------------------------------- 1299 1300/** 1301 * Security-relevant flags derived from the parsed AST. 1302 */ 1303type SecurityFlags = { 1304 /** Contains $(...) subexpression */ 1305 hasSubExpressions: boolean 1306 /** Contains { ... } script block expressions */ 1307 hasScriptBlocks: boolean 1308 /** Contains @variable splatting */ 1309 hasSplatting: boolean 1310 /** Contains expandable strings with embedded expressions ("...$()...") */ 1311 hasExpandableStrings: boolean 1312 /** Contains .NET method invocations ([Type]::Method or $obj.Method()) */ 1313 hasMemberInvocations: boolean 1314 /** Contains variable assignments ($x = ...) */ 1315 hasAssignments: boolean 1316 /** Uses stop-parsing token (--%) */ 1317 hasStopParsing: boolean 1318} 1319 1320/** 1321 * Common PowerShell aliases mapped to their canonical cmdlet names. 1322 * Uses Object.create(null) to prevent prototype-chain pollution — attacker-controlled 1323 * command names like 'constructor' or '__proto__' must return undefined, not inherited 1324 * Object.prototype properties. 1325 */ 1326export const COMMON_ALIASES: Record<string, string> = Object.assign( 1327 Object.create(null) as Record<string, string>, 1328 { 1329 // Directory listing 1330 ls: 'Get-ChildItem', 1331 dir: 'Get-ChildItem', 1332 gci: 'Get-ChildItem', 1333 // Content 1334 cat: 'Get-Content', 1335 type: 'Get-Content', 1336 gc: 'Get-Content', 1337 // Navigation 1338 cd: 'Set-Location', 1339 sl: 'Set-Location', 1340 chdir: 'Set-Location', 1341 pushd: 'Push-Location', 1342 popd: 'Pop-Location', 1343 pwd: 'Get-Location', 1344 gl: 'Get-Location', 1345 // Items 1346 gi: 'Get-Item', 1347 gp: 'Get-ItemProperty', 1348 ni: 'New-Item', 1349 mkdir: 'New-Item', 1350 // `md` is PowerShell's built-in alias for `mkdir`. resolveToCanonical is 1351 // single-hop (no md→mkdir→New-Item chaining), so it needs its own entry 1352 // or `md /etc/x` falls through while `mkdir /etc/x` is caught. 1353 md: 'New-Item', 1354 ri: 'Remove-Item', 1355 del: 'Remove-Item', 1356 rd: 'Remove-Item', 1357 rmdir: 'Remove-Item', 1358 rm: 'Remove-Item', 1359 erase: 'Remove-Item', 1360 mi: 'Move-Item', 1361 mv: 'Move-Item', 1362 move: 'Move-Item', 1363 ci: 'Copy-Item', 1364 cp: 'Copy-Item', 1365 copy: 'Copy-Item', 1366 cpi: 'Copy-Item', 1367 si: 'Set-Item', 1368 rni: 'Rename-Item', 1369 ren: 'Rename-Item', 1370 // Process 1371 ps: 'Get-Process', 1372 gps: 'Get-Process', 1373 kill: 'Stop-Process', 1374 spps: 'Stop-Process', 1375 start: 'Start-Process', 1376 saps: 'Start-Process', 1377 sajb: 'Start-Job', 1378 ipmo: 'Import-Module', 1379 // Output 1380 echo: 'Write-Output', 1381 write: 'Write-Output', 1382 sleep: 'Start-Sleep', 1383 // Help 1384 help: 'Get-Help', 1385 man: 'Get-Help', 1386 gcm: 'Get-Command', 1387 // Service 1388 gsv: 'Get-Service', 1389 // Variables 1390 gv: 'Get-Variable', 1391 sv: 'Set-Variable', 1392 // History 1393 h: 'Get-History', 1394 history: 'Get-History', 1395 // Invoke 1396 iex: 'Invoke-Expression', 1397 iwr: 'Invoke-WebRequest', 1398 irm: 'Invoke-RestMethod', 1399 icm: 'Invoke-Command', 1400 ii: 'Invoke-Item', 1401 // PSSession — remote code execution surface 1402 nsn: 'New-PSSession', 1403 etsn: 'Enter-PSSession', 1404 exsn: 'Exit-PSSession', 1405 gsn: 'Get-PSSession', 1406 rsn: 'Remove-PSSession', 1407 // Misc 1408 cls: 'Clear-Host', 1409 clear: 'Clear-Host', 1410 select: 'Select-Object', 1411 where: 'Where-Object', 1412 foreach: 'ForEach-Object', 1413 '%': 'ForEach-Object', 1414 '?': 'Where-Object', 1415 measure: 'Measure-Object', 1416 ft: 'Format-Table', 1417 fl: 'Format-List', 1418 fw: 'Format-Wide', 1419 oh: 'Out-Host', 1420 ogv: 'Out-GridView', 1421 // SECURITY: The following aliases are deliberately omitted because PS Core 6+ 1422 // removed them (they collide with native executables). Our allowlist logic 1423 // resolves aliases BEFORE checking safety — if we map 'sort' → 'Sort-Object' 1424 // but PowerShell 7/Windows actually runs sort.exe, we'd auto-allow the wrong 1425 // program. 1426 // 'sc' → sc.exe (Service Controller) — e.g. `sc config Svc binpath= ...` 1427 // 'sort' → sort.exe — e.g. `sort /O C:\evil.txt` (arbitrary file write) 1428 // 'curl' → curl.exe (shipped with Windows 10 1803+) 1429 // 'wget' → wget.exe (if installed) 1430 // Prefer to leave ambiguous aliases unmapped — users can write the full name. 1431 // If adding aliases that resolve to SAFE_OUTPUT_CMDLETS or 1432 // ACCEPT_EDITS_ALLOWED_CMDLETS, verify no native .exe collision on PS Core. 1433 ac: 'Add-Content', 1434 clc: 'Clear-Content', 1435 // Write/export: tee-object/export-csv are in 1436 // CMDLET_PATH_CONFIG so path-level Edit denies fire on the full cmdlet name, 1437 // but PowerShell's built-in aliases fell through to ask-then-approve because 1438 // resolveToCanonical couldn't resolve them). Neither tee-object nor 1439 // export-csv is in SAFE_OUTPUT_CMDLETS or ACCEPT_EDITS_ALLOWED_CMDLETS, so 1440 // the native-exe collision warning above doesn't apply — on Linux PS Core 1441 // where `tee` runs /usr/bin/tee, that binary also writes to its positional 1442 // file arg and we correctly extract+check it. 1443 tee: 'Tee-Object', 1444 epcsv: 'Export-Csv', 1445 sp: 'Set-ItemProperty', 1446 rp: 'Remove-ItemProperty', 1447 cli: 'Clear-Item', 1448 epal: 'Export-Alias', 1449 // Text search 1450 sls: 'Select-String', 1451 }, 1452) 1453 1454const DIRECTORY_CHANGE_CMDLETS = new Set([ 1455 'set-location', 1456 'push-location', 1457 'pop-location', 1458]) 1459 1460const DIRECTORY_CHANGE_ALIASES = new Set(['cd', 'sl', 'chdir', 'pushd', 'popd']) 1461 1462/** 1463 * Get all command names across all statements, pipeline segments, and nested commands. 1464 * Returns lowercased names for case-insensitive comparison. 1465 */ 1466// exported for testing 1467export function getAllCommandNames(parsed: ParsedPowerShellCommand): string[] { 1468 const names: string[] = [] 1469 for (const statement of parsed.statements) { 1470 for (const cmd of statement.commands) { 1471 names.push(cmd.name.toLowerCase()) 1472 } 1473 if (statement.nestedCommands) { 1474 for (const cmd of statement.nestedCommands) { 1475 names.push(cmd.name.toLowerCase()) 1476 } 1477 } 1478 } 1479 return names 1480} 1481 1482/** 1483 * Get all pipeline segments as flat list of commands. 1484 * Useful for checking each command independently. 1485 */ 1486export function getAllCommands( 1487 parsed: ParsedPowerShellCommand, 1488): ParsedCommandElement[] { 1489 const commands: ParsedCommandElement[] = [] 1490 for (const statement of parsed.statements) { 1491 for (const cmd of statement.commands) { 1492 commands.push(cmd) 1493 } 1494 if (statement.nestedCommands) { 1495 for (const cmd of statement.nestedCommands) { 1496 commands.push(cmd) 1497 } 1498 } 1499 } 1500 return commands 1501} 1502 1503/** 1504 * Get all redirections across all statements. 1505 */ 1506// exported for testing 1507export function getAllRedirections( 1508 parsed: ParsedPowerShellCommand, 1509): ParsedRedirection[] { 1510 const redirections: ParsedRedirection[] = [] 1511 for (const statement of parsed.statements) { 1512 for (const redir of statement.redirections) { 1513 redirections.push(redir) 1514 } 1515 // Include redirections from nested commands (e.g., from && / || chains) 1516 if (statement.nestedCommands) { 1517 for (const cmd of statement.nestedCommands) { 1518 if (cmd.redirections) { 1519 for (const redir of cmd.redirections) { 1520 redirections.push(redir) 1521 } 1522 } 1523 } 1524 } 1525 } 1526 return redirections 1527} 1528 1529/** 1530 * Get all variables, optionally filtered by scope (e.g., 'env'). 1531 * Variable paths in PowerShell can have scopes like "env:PATH", "global:x". 1532 */ 1533export function getVariablesByScope( 1534 parsed: ParsedPowerShellCommand, 1535 scope: string, 1536): ParsedVariable[] { 1537 const prefix = scope.toLowerCase() + ':' 1538 return parsed.variables.filter(v => v.path.toLowerCase().startsWith(prefix)) 1539} 1540 1541/** 1542 * Check if any command in the parsed result matches a given name (case-insensitive). 1543 * Handles common aliases too. 1544 */ 1545export function hasCommandNamed( 1546 parsed: ParsedPowerShellCommand, 1547 name: string, 1548): boolean { 1549 const lowerName = name.toLowerCase() 1550 const canonicalFromAlias = COMMON_ALIASES[lowerName]?.toLowerCase() 1551 1552 for (const cmdName of getAllCommandNames(parsed)) { 1553 if (cmdName === lowerName) { 1554 return true 1555 } 1556 // Check if the command is an alias that resolves to the requested name 1557 const canonical = COMMON_ALIASES[cmdName]?.toLowerCase() 1558 if (canonical === lowerName) { 1559 return true 1560 } 1561 // Check if the requested name is an alias and the command is its canonical form 1562 if (canonicalFromAlias && cmdName === canonicalFromAlias) { 1563 return true 1564 } 1565 // Check if both resolve to the same canonical cmdlet (alias-to-alias match) 1566 if (canonical && canonicalFromAlias && canonical === canonicalFromAlias) { 1567 return true 1568 } 1569 } 1570 return false 1571} 1572 1573/** 1574 * Check if the command contains any directory-changing commands. 1575 * (Set-Location, cd, sl, chdir, Push-Location, pushd, Pop-Location, popd) 1576 */ 1577// exported for testing 1578export function hasDirectoryChange(parsed: ParsedPowerShellCommand): boolean { 1579 for (const cmdName of getAllCommandNames(parsed)) { 1580 if ( 1581 DIRECTORY_CHANGE_CMDLETS.has(cmdName) || 1582 DIRECTORY_CHANGE_ALIASES.has(cmdName) 1583 ) { 1584 return true 1585 } 1586 } 1587 return false 1588} 1589 1590/** 1591 * Check if the command is a single simple command (no pipes, no semicolons, no operators). 1592 */ 1593// exported for testing 1594export function isSingleCommand(parsed: ParsedPowerShellCommand): boolean { 1595 const stmt = parsed.statements[0] 1596 return ( 1597 parsed.statements.length === 1 && 1598 stmt !== undefined && 1599 stmt.commands.length === 1 && 1600 (!stmt.nestedCommands || stmt.nestedCommands.length === 0) 1601 ) 1602} 1603 1604/** 1605 * Check if a specific command has a given argument/flag (case-insensitive). 1606 * Useful for checking "-EncodedCommand", "-Recurse", etc. 1607 */ 1608export function commandHasArg( 1609 command: ParsedCommandElement, 1610 arg: string, 1611): boolean { 1612 const lowerArg = arg.toLowerCase() 1613 return command.args.some(a => a.toLowerCase() === lowerArg) 1614} 1615 1616/** 1617 * Tokenizer-level dash characters that PowerShell's parser accepts as 1618 * parameter prefixes. SpecialCharacters.IsDash (CharTraits.cs) accepts exactly 1619 * these four: ASCII hyphen-minus, en-dash, em-dash, horizontal bar. These are 1620 * tokenizer-level — they apply to ALL cmdlet parameters, not just argv to 1621 * powershell.exe (contrast with `/` which is an argv-parser quirk of 1622 * powershell.exe 5.1 only; see PS_ALT_PARAM_PREFIXES in powershellSecurity.ts). 1623 * 1624 * Extent.Text preserves the raw character; transformCommandAst uses ce.text 1625 * for CommandParameterAst elements, so these reach callers unchanged. 1626 */ 1627export const PS_TOKENIZER_DASH_CHARS = new Set([ 1628 '-', // U+002D hyphen-minus (ASCII) 1629 '\u2013', // en-dash 1630 '\u2014', // em-dash 1631 '\u2015', // horizontal bar 1632]) 1633 1634/** 1635 * Determines if an argument is a PowerShell parameter (flag), using the AST 1636 * element type as ground truth when available. 1637 * 1638 * The parser maps CommandParameterAst → 'Parameter' regardless of which dash 1639 * character the user typed — PowerShell's tokenizer handles that. So when 1640 * elementType is available, it's authoritative: 1641 * - 'Parameter' → true (covers `-Path`, `–Path`, `—Path`, `―Path`) 1642 * - anything else → false (a quoted "-Path" is StringConstant, not a param) 1643 * 1644 * When elementType is unavailable (backward compat / no AST detail), fall back 1645 * to a char check against PS_TOKENIZER_DASH_CHARS. 1646 */ 1647export function isPowerShellParameter( 1648 arg: string, 1649 elementType?: CommandElementType, 1650): boolean { 1651 if (elementType !== undefined) { 1652 return elementType === 'Parameter' 1653 } 1654 return arg.length > 0 && PS_TOKENIZER_DASH_CHARS.has(arg[0]!) 1655} 1656 1657/** 1658 * Check if any argument on a command is an unambiguous abbreviation of a PowerShell parameter. 1659 * PowerShell allows parameter abbreviation as long as the prefix is unambiguous. 1660 * The minPrefix is the shortest unambiguous prefix for the parameter. 1661 * For example, minPrefix '-en' for fullParam '-encodedcommand' matches '-en', '-enc', '-enco', etc. 1662 */ 1663export function commandHasArgAbbreviation( 1664 command: ParsedCommandElement, 1665 fullParam: string, 1666 minPrefix: string, 1667): boolean { 1668 const lowerFull = fullParam.toLowerCase() 1669 const lowerMin = minPrefix.toLowerCase() 1670 return command.args.some(a => { 1671 // Strip colon-bound value (e.g., -en:base64value -> -en) 1672 const colonIndex = a.indexOf(':', 1) 1673 const paramPart = colonIndex > 0 ? a.slice(0, colonIndex) : a 1674 // Strip backtick escapes — PowerShell resolves `-Member`Name` to 1675 // `-MemberName` but Extent.Text preserves the backtick, causing 1676 // prefix-comparison misses on the raw text. 1677 const lower = paramPart.replace(/`/g, '').toLowerCase() 1678 return ( 1679 lower.startsWith(lowerMin) && 1680 lowerFull.startsWith(lower) && 1681 lower.length <= lowerFull.length 1682 ) 1683 }) 1684} 1685 1686/** 1687 * Split a parsed command into its pipeline segments for per-segment permission checking. 1688 * Returns each pipeline's commands separately. 1689 */ 1690export function getPipelineSegments( 1691 parsed: ParsedPowerShellCommand, 1692): ParsedStatement[] { 1693 return parsed.statements 1694} 1695 1696/** 1697 * True if a redirection target is PowerShell's `$null` automatic variable. 1698 * `> $null` discards output (like /dev/null) — not a filesystem write. 1699 * `$null` cannot be reassigned, so this is safe to treat as a no-op sink. 1700 * `${null}` is the same automatic variable via curly-brace syntax. Spaces 1701 * inside the braces (`${ null }`) name a different variable, so no regex. 1702 */ 1703export function isNullRedirectionTarget(target: string): boolean { 1704 const t = target.trim().toLowerCase() 1705 return t === '$null' || t === '${null}' 1706} 1707 1708/** 1709 * Get output redirections (file redirections, not merging redirections). 1710 * Returns only redirections that write to files. 1711 */ 1712// exported for testing 1713export function getFileRedirections( 1714 parsed: ParsedPowerShellCommand, 1715): ParsedRedirection[] { 1716 return getAllRedirections(parsed).filter( 1717 r => !r.isMerging && !isNullRedirectionTarget(r.target), 1718 ) 1719} 1720 1721/** 1722 * Derive security-relevant flags from the parsed command structure. 1723 * This replaces the previous approach of computing flags in PowerShell via 1724 * separate Find-AstNodes calls. Instead, the PS1 script tags each element 1725 * with its AST node type, and this function walks those types. 1726 */ 1727// exported for testing 1728export function deriveSecurityFlags( 1729 parsed: ParsedPowerShellCommand, 1730): SecurityFlags { 1731 const flags: SecurityFlags = { 1732 hasSubExpressions: false, 1733 hasScriptBlocks: false, 1734 hasSplatting: false, 1735 hasExpandableStrings: false, 1736 hasMemberInvocations: false, 1737 hasAssignments: false, 1738 hasStopParsing: parsed.hasStopParsing, 1739 } 1740 1741 function checkElements(cmd: ParsedCommandElement): void { 1742 if (!cmd.elementTypes) { 1743 return 1744 } 1745 for (const et of cmd.elementTypes) { 1746 switch (et) { 1747 case 'ScriptBlock': 1748 flags.hasScriptBlocks = true 1749 break 1750 case 'SubExpression': 1751 flags.hasSubExpressions = true 1752 break 1753 case 'ExpandableString': 1754 flags.hasExpandableStrings = true 1755 break 1756 case 'MemberInvocation': 1757 flags.hasMemberInvocations = true 1758 break 1759 } 1760 } 1761 } 1762 1763 for (const stmt of parsed.statements) { 1764 if (stmt.statementType === 'AssignmentStatementAst') { 1765 flags.hasAssignments = true 1766 } 1767 for (const cmd of stmt.commands) { 1768 checkElements(cmd) 1769 } 1770 if (stmt.nestedCommands) { 1771 for (const cmd of stmt.nestedCommands) { 1772 checkElements(cmd) 1773 } 1774 } 1775 // securityPatterns provides a belt-and-suspenders check that catches 1776 // patterns elementTypes may miss (e.g. member invocations inside 1777 // assignments, subexpressions in non-pipeline statements). 1778 if (stmt.securityPatterns) { 1779 if (stmt.securityPatterns.hasMemberInvocations) { 1780 flags.hasMemberInvocations = true 1781 } 1782 if (stmt.securityPatterns.hasSubExpressions) { 1783 flags.hasSubExpressions = true 1784 } 1785 if (stmt.securityPatterns.hasExpandableStrings) { 1786 flags.hasExpandableStrings = true 1787 } 1788 if (stmt.securityPatterns.hasScriptBlocks) { 1789 flags.hasScriptBlocks = true 1790 } 1791 } 1792 } 1793 1794 for (const v of parsed.variables) { 1795 if (v.isSplatted) { 1796 flags.hasSplatting = true 1797 break 1798 } 1799 } 1800 1801 return flags 1802} 1803 1804// Raw types exported for testing (function exports are inline above)