Welcome back! In the previous chapter, Chapter 1: AST-Based Parsing Bridge, we learned how to ask PowerShell to parse a command and return a raw JSON AST (Abstract Syntax Tree).
While getting that AST was a huge victory, we have a new problem. The raw AST is verbose and noisy. It describes the code exactly as the .NET engine sees it, which includes a lot of implementation details we don't need.
This chapter covers AST Transformation & Normalization. We will build a logic layer that acts as a refinery, turning raw ore (complex .NET objects) into gold bars (clean, predictable TypeScript interfaces).
Let's look at a real example. A user runs this command:
Microsoft.PowerShell.Management\Get-Process -Name "node"
The raw AST JSON from Chapter 1 looks something like this (simplified):
{
"type": "CommandAst",
"commandElements": [
{ "type": "StringConstantExpressionAst", "value": "Microsoft.PowerShell.Management\\Get-Process" },
{ "type": "CommandParameterAst", "parameterName": "Name" },
{ "type": "StringConstantExpressionAst", "value": "node" }
]
}
Why this is hard to use:
Microsoft.PowerShell.Management\). If we have a security rule for Get-Process, this string won't match!ps (an alias for Get-Process), the AST would say ps. We need to know they mean the same thing.commandElements and check types. We just want a simple list of arguments.We need a transformation layer. We want to convert that complex JSON above into this clean TypeScript object:
const cleanCommand = {
name: "Get-Process", // Cleaned!
nameType: "cmdlet", // Classified!
args: ["-Name", "node"] // Simplified!
}
This process allows the rest of our application to ignore the weird quirks of PowerShell syntax and focus on what the command does.
PowerShell allows you to be very specific about where a command comes from. But for security checks, we usually care about the command, not the module.
We use a helper function to strip these prefixes.
// parser.ts
export function stripModulePrefix(name: string): string {
// Find the last backslash
const idx = name.lastIndexOf('\\')
// If no backslash, return the name as is
if (idx < 0) return name
// Return everything AFTER the backslash
return name.substring(idx + 1)
}
Explanation: This logic converts
Microsoft.PowerShell.Management\Get-Processinto simplyGet-Process. It normalizes the input so our security rules don't need to guess every possible module path.
Is the user running a built-in PowerShell tool (Cmdlet) or an external program (like git or node)? This distinction matters for security.
We classify commands based on their naming convention.
// parser.ts
export function classifyCommandName(name: string): 'cmdlet' | 'application' | 'unknown' {
// Cmdlets usually look like Verb-Noun (e.g., Get-Process)
if (/^[A-Za-z]+-[A-Za-z]+$/.test(name)) {
return 'cmdlet'
}
// Applications usually have extensions or paths (e.g., ./script.ps1)
if (/[.\\/]/.test(name)) {
return 'application'
}
return 'unknown'
}
Explanation:
* Cmdlet: Matches the
Verb-Nounpattern.
* Application: Contains dots or slashes (indicating a file path or extension).
* Unknown: Everything else (like aliases
ls,dir).
PowerShell users love aliases. dir, ls, and gci all mean Get-ChildItem.
If we deny Get-ChildItem but allow ls, we have a security hole. We solve this by maintaining a dictionary of "Common Aliases".
// parser.ts
export const COMMON_ALIASES: Record<string, string> = {
'ls': 'Get-ChildItem',
'dir': 'Get-ChildItem',
'echo': 'Write-Output',
'cat': 'Get-Content',
'ps': 'Get-Process',
'curl': 'Invoke-WebRequest',
// ... many more
}
Later, when checking permissions, we look up the command name in this list. If name is ls, we treat it exactly as if it were Get-ChildItem.
Now let's put it together. We iterate over the raw AST nodes and "transform" them into our clean interfaces.
This function takes a raw RawPipelineElement (from the JSON) and returns a clean ParsedCommandElement.
// parser.ts (Simplified)
export function transformCommandAst(raw: RawPipelineElement): ParsedCommandElement {
const args: string[] = []
// 1. Get the raw name (usually the first element)
const rawName = raw.commandElements[0].text
// 2. Normalize the name
const name = stripModulePrefix(rawName)
const nameType = classifyCommandName(name)
// 3. Flatten arguments
// Start at index 1 (skip the command name itself)
for (let i = 1; i < raw.commandElements.length; i++) {
args.push(raw.commandElements[i].text)
}
// 4. Return the clean object
return { name, nameType, args, text: raw.text, elementType: 'CommandAst' }
}
Explanation:
1. We extract the command name from the first element.
2. We clean it using the helper functions we wrote earlier.
3. We loop through the rest of the elements and simply push their text into an
argsarray.
4. We return a strictly typed object that is easy to use.
The output of this transformation is a ParsedPowerShellCommand. This is the interface our application actually uses.
// parser.ts
export type ParsedCommandElement = {
name: string
nameType: 'cmdlet' | 'application' | 'unknown'
args: string[]
// ...
}
export type ParsedPowerShellCommand = {
valid: boolean
statements: ParsedStatement[]
// ...
}
Imagine we want to block the user from stopping a process.
Without Normalization:
We have to check if the command equals Stop-Process, OR Microsoft.PowerShell.Management\Stop-Process, OR kill, OR spps (another alias).
With Normalization:
Microsoft...\Stop-Process becomes Stop-Process.kill becomes Stop-Process.name === "Stop-Process".By normalizing the data before we analyze it, we make our security logic significantly simpler and more robust.
We have successfully bridged the gap between the chaotic world of raw PowerShell ASTs and the structured world of TypeScript application logic. We can now:
However, knowing the names of the commands isn't enough. PowerShell is a dynamic language. Users can hide malicious intent inside strings like $(expression) or script blocks { ... }.
In the next chapter, we will learn how to detect these hidden dangers.
Next: Chapter 3 - Security Pattern Detection
Generated by Code IQ