/ tools / PowerShellTool / powershellSecurity.ts
powershellSecurity.ts
   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  
  11  import {
  12    DANGEROUS_SCRIPT_BLOCK_CMDLETS,
  13    FILEPATH_EXECUTION_CMDLETS,
  14    MODULE_LOADING_CMDLETS,
  15  } from '../../utils/powershell/dangerousCmdlets.js'
  16  import type {
  17    ParsedCommandElement,
  18    ParsedPowerShellCommand,
  19  } from '../../utils/powershell/parser.js'
  20  import {
  21    COMMON_ALIASES,
  22    commandHasArgAbbreviation,
  23    deriveSecurityFlags,
  24    getAllCommands,
  25    getVariablesByScope,
  26    hasCommandNamed,
  27  } from '../../utils/powershell/parser.js'
  28  import { isClmAllowedType } from './clmTypes.js'
  29  
  30  type PowerShellSecurityResult = {
  31    behavior: 'passthrough' | 'ask' | 'allow'
  32    message?: string
  33  }
  34  
  35  const 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   */
  46  function 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   */
  67  const 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   */
  83  function 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   */
 106  function 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   */
 143  function 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   */
 166  function 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   */
 192  function 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   */
 216  const DOWNLOADER_NAMES = new Set([
 217    'invoke-webrequest',
 218    'iwr',
 219    'invoke-restmethod',
 220    'irm',
 221    'new-object',
 222    'start-bitstransfer', // MITRE T1197
 223  ])
 224  
 225  function isDownloader(name: string): boolean {
 226    return DOWNLOADER_NAMES.has(name.toLowerCase())
 227  }
 228  
 229  function isIex(name: string): boolean {
 230    const lower = name.toLowerCase()
 231    return lower === 'invoke-expression' || lower === 'iex'
 232  }
 233  
 234  function 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   */
 276  function 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   */
 321  function 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   */
 343  function 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  
 452  function 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   */
 499  function 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   */
 550  function 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   */
 639  const 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   */
 663  function 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   */
 714  function 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   */
 731  function 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   */
 746  function 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   */
 761  function 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   */
 776  function 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   */
 801  function 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   */
 822  function 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   */
 844  const SCHEDULED_TASK_CMDLETS = new Set([
 845    'register-scheduledtask',
 846    'new-scheduledtask',
 847    'new-scheduledtaskaction',
 848    'set-scheduledtask',
 849  ])
 850  
 851  function 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   */
 888  const 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  
 908  function 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  
 946  function 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   */
 971  const 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  
 982  function 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   */
1010  const WMI_SPAWN_CMDLETS = new Set([
1011    'invoke-wmimethod',
1012    'iwmi',
1013    'invoke-cimmethod',
1014  ])
1015  
1016  function 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   */
1042  export 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  }