September 26th, 2012

Learn How IT Pros Can Use the PowerShell AST

Doctor Scripto
Scripter

Summary: Guest blogger, Microsoft PowerShell MVP, Bartek Bielawski, shows how to use the Windows PowerShell AST.

Microsoft Scripting Guy, Ed Wilson, is here. Not only do we have a special guest today as our guest blogger, it is a special day in the Scripting House because it is the Scripting Wife’s birthday. So Happy Birthday, Scripting Wife!

Guest Blogger Week continues today with Bartek Bielawski as our guest. For more from Bartek, read his other Hey, Scripting Guy! guest blogs.

Now for Bartek…

If you haven’t noticed yet, I’m interested in Windows PowerShell language features. I love to write Windows PowerShell script (for fun!) and investigate the elements that are available to us in this awesome scripting language. That’s one of the reasons why I’m super excited about Windows PowerShell 3.0. Previous versions were already a blast, so I did not expect much could be done to improve it. But as usual, the Windows PowerShell team not only met, but they exceeded, my expectations.

One thing I was super happy about in Windows PowerShell 2.0 was the Windows PowerShell parser. It allows me to parse script and find information about used language elements; and if I do not like them, I can change them. It was perfect as a cleanup function that would replace aliases with full cmdlet names in your script. Let’s leave fixing script aside because there are plenty of functions out there, including an awesome ISE add-in from Ed Wilson himself. For more information, read Modify the PowerShell ISE to Remove Aliases from Scripts.

For now, we will focus on spotting the issue. Here is our Bad script:

ls *.ps1 | ? {

    $_.Name -match ‘Bad’

} | % {

    $_.BaseName

}

Write-Host ?

Write-Host “I’m fine!”

 

“$(echo foo | % { $_ + ‘o’ })”

You can hardly find any way to put more aliases there (Write-Host is there as a good guy to see if our script actually works), and some of them are as cryptic as possible. Let’s see how the Windows PowerShell 2.0 parser handles them. First, our script.

$code = Get-Content .\BadScript.ps1

[System.Management.Automation.PSParser]::Tokenize($code,[ref]$null) |

    ForEach-Object {

        if ($_.Type -eq ‘Command’) {

            $Content = $_.Content

            if ($Alias = Get-Alias | where { $_.Name -eq $Content }) {

                New-Object PSObject -Property @{

                    Alias = $Alias.Name

                    Definition = $Alias.Definition

                    Start = $_.Start

                    Length = $_.Length

                }

            }

        }

    } | Format-Table -AutoSize Alias, Definition, Start, Length

And our results:

Image of command output

As you can see, it was not able to analyze the code within the subexpression. This is where only RegEx can help. But then again, it might be hard to write RegEx in a way that would not replace each and every question mark within the strings present in the script. That’s where the Abstract Syntax Trees (ASTs) that are available in Windows PowerShell 3.0 come in handy.

ASTs give us the ability to look at our script even closer than PSParser in Windows PowerShell 2.0. If you really want to look only at script, you may want to use the module that I wrote to aid me (and others) when looking at script structure. For more information, see AST Module in Microsoft TechNet.

With ASTs we can easily find language elements—not only use flat tokens as we did in Windows PowerShell 2.0, but also understand script structure and analyze it with parent-child relationships. It’s probably better explained by developers, and I’m not developer—so let’s take a look how we can check our BadScript with ASTs in Windows PowerShell 3.0 rather than using PSParser in Windows PowerShell 2.0. This script looks different, and it shows an ability that we did not have in Windows PowerShell 2.0. We can look for specific types of language elements.

$AST = [System.Management.Automation.Language.Parser]::ParseFile(

    ‘.\BadScript.ps1’,

    [ref]$null,

    [ref]$Null

)

 

$AST.FindAll({

    $args[0] -is [System.Management.Automation.Language.CommandAst]}

    , $true) | foreach {

    $Command = $_.CommandElements[0]

    if ($Alias = Get-Alias | where { $_.Name -eq $Command }) {

        [PSCustomObject]@{

            Alias = $Alias.Name

            Definition = $Alias.Definition

            Start = $Command.Extent.StartOffset

            End = $Command.Extent.EndOffset

           

        }

    }   

} | Format-Table -AutoSize

Our result includes aliases used within the subexpression.

Image of command output

But that’s just the tip of the iceberg. Another MVP, Ravikanth Chaganti, used ASTs to create a great ISE add-on (and very short one, too), which gives us the ability to see all the functions that are defined in our scripts. For more information, see PowerShell ISE Add-on: ISE Function Explorer Using the PowerShell 3.0 Parser.

With that in mind, we can do something different. We can analyze a profile script, hunt any function there, and build a module based on it. Then we could put it on a file share so that we can use it on different machines as a module rather than re-create the profile everywhere.

$Module = (mkdir $profile\..\Modules\Profile -Force).FullName

$AST = [System.Management.Automation.Language.Parser]::ParseFile(

    $profile,

    [ref]$null,

    [ref]$Null

)

 

“# Module generated from profile on: {0}” -f (Get-Date) |

    Out-File $Module\Profile.psm1

 

$AST.FindAll({

    $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst]}

    , $true) | foreach {

    $_.Extent.Text | Out-File -Append $Module\Profile.psm1

}

Obviously, you can do the same thing with any script that in the past required dot sourcing.

ASTs are not only available when we work with “static” commands in scripts. In fact, any script block that we create will have an AST property that lets us translate and test on-the-fly. The following script uses almost the same logic, but this time it is used against something dynamic.

function Test-Code {

param (

    [ScriptBlock]$Script

)

    $Warnings = 0

    “Analyzing code: {0}” -f $Script

    $Script.Ast.FindAll({

    $args[0] -is [System.Management.Automation.Language.CommandAst]}

    , $true) | foreach {

    $Command = $_.CommandElements[0]

    if ($Alias = Get-Alias | where { $_.Name -eq $Command }) {

        Write-Warning (“You used alias: ‘{0}’! Argh!” -f $Command)

        $Warnings++

    } elseif (‘Write-Host’ -eq $Command) {

        Write-Warning “You just killed a kitten! Bad scripter!”

        $Warnings++

    }

 

    if (!$Warnings) {

        “There… Perfect script!”

    }

}

}

So, how did our tests go for the two script blocks we tried?

Image of command output

This may be a rather silly use case, but if you are creating a function that will run script blocks that are passed to users, you may want to analyze them first so that you can warn users that they may want to reconsider running the script.

function Test-Harm {

[CmdletBinding()]

param (

    [ScriptBlock]$Script

)

    $PotentialyHarmful = $false

    “Analyzing code: {0}” -f $Script

    $Script.Ast.FindAll({

    $args[0] -is [System.Management.Automation.Language.CommandAst]}

    , $true) | foreach {

        $Command = $_.CommandElements[0]

        if ($Alias = Get-Alias | where { $_.Name -eq $Command }) {

            if ($Alias.Definition -match ‘^Remove-‘) {

                $PotentialyHarmful = $true

            }

        } elseif ($Command.Extent.Text -match ‘^Remove-‘) {

            $PotentialyHarmful = $true

        }

    }

    if (!$PotentialyHarmful -or $PSCmdlet.ShouldContinue(

        “Script contains Remove-* cmdlet – run it?”,”Script”)) {

        & $Script

    }

}

This script will warn users even if the script that causes deletion of Foo is well hidden in the subexpression and is an alias.

Image of command output

As you can see, it’s not easy to fool our function. It’s able to tell if you used a Remove-* cmdlet, no matter how hard you try to hide it. If it actually runs, it will be noticed. If it is in a comment or plain string, users won’t be notified.

Image of command output

Using RegEx to achieve the same would be hard to say the least. I encourage you to take a look at ASTs. I think they can be really helpful for anybody who wants to analyze script—either their own or script provided by others.

See the full downloadable script in the Script Repository: Code Samples for Using AST in PowerShell 3.0.

~ Bartek

Thank you, Bartek, for a very interesting and useful guest blog.

Join me tomorrow for more cool Windows PowerShell stuff.

I invite you to follow me on Twitter and Facebook. If you have any questions, send email to me at scripter@microsoft.com, or post your questions on the Official Scripting Guys Forum. See you tomorrow. Until then, peace.

Ed Wilson, Microsoft Scripting Guy 

Author

The "Scripting Guys" is a historical title passed from scripter to scripter. The current revision has morphed into our good friend Doctor Scripto who has been with us since the very beginning.

0 comments

Discussion are closed.