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