mono/packages/kbot/ref/tools/PowerShellTool/powershellSecurity.ts
2026-04-01 01:05:48 +02:00

1091 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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' }
}