Costruire uno strumento di analisi PowerShell in PowerShell — Leggere gli script con l'AST, non con le espressioni regolari

· Aggiornato il: · · PowerShell, AST, Analisi statica, Strumenti di sviluppo

Supponiamo che vogliate passare in rassegna una serie di script PowerShell ed elencare ogni punto che chiama Write-Host. Cercare la parola è facile, ma tutte e tre le righe seguenti corrispondono.

$source = @'
# Write-Host serve a mostrare testo sullo schermo
$message = 'Write-Host'
Write-Host 'Ciao'
'@

Solo la riga 3 è ciò che cercate. La riga 1 è un commento e la riga 2 una stringa assegnata a una variabile, quindi nessuna delle due chiama Write-Host.

PowerShell stesso distingue questi casi mentre esegue il codice. Potete anche estrarre voi il risultato di quella lettura. Questo articolo inizia analizzando il codice ed esaminando gli oggetti che ne escono. Non si usano moduli aggiuntivi.1

Il @' ... '@ qui sopra è una here-string tra apici singoli. Mette quelle tre righe in $source come stringa che per ora non viene eseguita e in cui $message non viene espansa.2

1. Mostrare che cosa restituisce il parser

Passate prima $source a ParseInput.

$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseInput(
    $source, [ref] $tokens, [ref] $parseErrors)

if ($parseErrors.Count -gt 0) {
    throw $parseErrors[0].Message
}

$ast.GetType().Name
ScriptBlockAst

Ciò che è finito in $ast non è né una stringa né il risultato dell’esecuzione dello script: è un oggetto di tipo ScriptBlockAst. Rappresenta l’intero codice che avete fornito. Oltre a restituire questo oggetto, ParseInput restituisce i token e gli errori di sintassi tramite le variabili passate con [ref].1

AST sta per «albero sintattico astratto». Il nome può far pensare a una struttura dati particolare, ma dal punto di vista di PowerShell è anzitutto un oggetto con proprietà e metodi. Seguendo quelle proprietà si raggiungono altri oggetti che rappresentano assegnazioni e chiamate di comando.3

Nel diagramma, una linea continua indica una relazione che vale sempre e una linea tratteggiata indica una relazione condizionale (le condizioni sono nella spiegazione di ciascuna relazione nella pagina di dettaglio). L’elenco completo delle relazioni (in totale 5, con evidenza e livello di certezza) e le definizioni dei concetti principali sono raccolti nella pagina di dettaglio della mappa della conoscenza (in giapponese). Dati: JSON-LD / Turtle

2. In quali oggetti si sono trasformate quelle tre righe?

In un codice come questo, che non esplicita begin, process ed end, le istruzioni ordinarie stanno in EndBlock.Statements. Mettiamo i loro tipi accanto al codice originale.4

$statements = $ast.EndBlock.Statements
$statements | ForEach-Object {
    [pscustomobject]@{
        Type = $_.GetType().Name
        Text = $_.Extent.Text
    }
}
Type                   Text
----                   ----
AssignmentStatementAst $message = 'Write-Host'
PipelineAst            Write-Host 'Ciao'

Due voci. La riga di commento non compare in questo elenco di istruzioni. Se vi servono i commenti stessi, potete ottenerli dai $tokens ricevuti poco fa.3

La riga 2 è diventata un AssignmentStatementAst, che rappresenta un’assegnazione. La riga 3 è un PipelineAst: anche senza scrivere un |, viene rappresentata come pipeline con un solo comando. Extent.Text è il codice originale corrispondente all’oggetto. Ogni volta che il solo nome del tipo vi lascia nel dubbio, questo vi dice di quale parte della sorgente si stia parlando.56

Come il codice originale corrisponde agli oggetti sintatticiL'EndBlock dell'intero script contiene l'assegnazione della riga 2 e la pipeline della riga 3, e quest'ultima contiene la chiamata di comando.ScriptBlockAstEndBlockAssegnazione: riga 2Pipeline: riga 3CommandAst

Figura 1: La chiamata della riga 3 si trova dentro la pipeline.

Gli indici degli array partono da 0, quindi $statements[1] è la pipeline della riga 3. Da lì prendete il primo elemento.

$pipeline = $statements[1]
$call = $pipeline.PipelineElements[0]

$call.GetType().Name
$call.Extent.Text
$call.GetCommandName()
CommandAst
Write-Host 'Ciao'
Write-Host

Ecco il CommandAst. Extent.Text, che restituisce il codice originale, e GetCommandName(), che restituisce il nome della chiamata, sono usati entrambi sullo stesso oggetto.7

Il dettaglio comprensivo degli argomenti si trova in CommandElements.

$call.CommandElements | ForEach-Object {
    [pscustomobject]@{
        Type = $_.GetType().Name
        Text = $_.Extent.Text
    }
}
Type                        Text
----                        ----
StringConstantExpressionAst Write-Host
StringConstantExpressionAst 'Ciao'

Nome e argomento sono entrambi nodi che rappresentano stringhe. Eppure ciò che GetCommandName() restituisce è Write-Host. Non sceglie in base alla sola grafia della stringa: guarda l’elemento che funge da nome all’interno di una chiamata di comando. Il 'Write-Host' della riga 2 sta alla destra di un’assegnazione e non appartiene affatto a questa chiamata.78

Questa è la differenza fra una ricerca testuale e l’analisi sintattica. Anche a parità di parola, esaminare la struttura in cui si trova permette di distinguerne i ruoli.

3. Cercare CommandAst invece di percorrere per indici

Il contenuto l’abbiamo verificato. In uno script reale, però, non potete fissare «il primo elemento della seconda istruzione». Le chiamate compaiono dentro funzioni, dentro blocchi if e a metà di una pipeline.

Il metodo di ricerca previsto per questo è FindAll.

$commands = @($ast.FindAll({
    param($node)
    $node -is [System.Management.Automation.Language.CommandAst]
}, $true))

$commands.Count
$commands[0].GetType().Name
$commands[0].Extent.Text
1
CommandAst
Write-Host 'Ciao'

FindAll percorre l’albero sintattico e consegna ogni nodo al test contenuto in { ... }. Verificate con -is il tipo del $node ricevuto e restituite $true quando è un CommandAst. Quel nodo resta fra i risultati. Il $true finale indica di cercare anche dentro funzioni e blocchi di script annidati.9

Con function Show-Message { Write-Host 'hello' }, per esempio, scende nel corpo della funzione e raccoglie la chiamata. Non è necessario eseguire la funzione.

Cercare per tipo fin dentro una funzioneLa ricerca scende nel corpo di una funzione contenuta nello script e include fra i risultati la chiamata corrispondente a CommandAst.ScriptDefinizione di funzioneCorpo della funzioneCommandAstIl tipo corrisponde: resta nei risultati

Figura 2: Ottenete i nodi che soddisfano il criterio di ricerca senza contare voi la profondità di annidamento.

Nelle tre righe iniziali ha trovato la stessa unica chiamata raggiunta tramite l’indice. Estraete nome e posizione e avrete qualcosa di utilizzabile come risultato di ricerca.

$commands | ForEach-Object {
    [pscustomobject]@{
        Name   = $_.GetCommandName()
        Line   = $_.Extent.StartLineNumber
        Column = $_.Extent.StartColumnNumber
    }
}
Name       Line Column
----       ---- ------
Write-Host    3      1

Righe e colonne partono da 1. Poiché lo stesso nodo porta sia il nome sia la posizione, non dovete ritrovare la riga con una ricerca testuale separata.6

4. Come appare una chiamata tramite una variabile

Cambiamo un poco l’obiettivo dell’analisi. Oltre al nome scritto direttamente, includete casi che passano una stringa o una variabile all’operatore di chiamata &.

$source = @'
Write-Host 'direct'
& 'Write-Host' 'quoted'
$command = 'Write-Host'
& $command 'variable'
'@

$ast = [System.Management.Automation.Language.Parser]::ParseInput(
    $source, [ref] $tokens, [ref] $parseErrors)
if ($parseErrors.Count -gt 0) { throw $parseErrors[0].Message }

$commands = @($ast.FindAll({
    param($node)
    $node -is [System.Management.Automation.Language.CommandAst]
}, $true))

$commands | ForEach-Object {
    [pscustomobject]@{
        Line = $_.Extent.StartLineNumber
        Name = $_.GetCommandName()
        Text = $_.Extent.Text
    }
}
Line Name       Text
---- ----       ----
   1 Write-Host Write-Host 'direct'
   2 Write-Host & 'Write-Host' 'quoted'
   4            & $command 'variable'

Anche la riga 4 è stata trovata come CommandAst. Il valore restituito da GetCommandName() è però $null.

Una persona che legge queste quattro righe può dedurre il valore di $command dall’assegnazione immediatamente sopra. Questo metodo non risale alle assegnazioni a una variabile per calcolarne il valore. A differenza del caso in cui il nome è scritto direttamente nel codice, questa sola API non può estrarre il nome.7

Lo stesso nodo di chiamata, risultati diversi nell'ottenere il nomePer una chiamata il cui nome è una stringa si può ottenere Write-Host; per una chiamata tramite variabile il nome è null, ma entrambe portano la posizione della chiamata.CommandAstL'elemento del nome è una stringaL'elemento del nome è una variabileNome: Write-HostNome: null

Figura 3: Anche con il nome vuoto si vede comunque che alla riga 4 è scritta una chiamata.

In uno strumento che passa in rassegna file, riportate questa differenza in una colonna NameKind. Le righe di cui si è potuta ottenere la stringa del nome sono Static; quelle per cui non è stato possibile sono Unresolved. Conservare le righe senza nome significa non perdere di vista i punti che una persona dovrebbe controllare.

5. Estrarre da un file le posizioni di Write-Host

Quando leggete un .ps1 invece di una stringa, usate ParseFile al posto di ParseInput. Il modo di leggere l’AST non cambia.10

Get-ScriptCommand, che raccoglie tutto quanto visto finora, si trova nel codice completo in fondo a questo articolo e nell’archivio di esempi. Salvate il codice completo come Get-ScriptCommand.ps1 e le quattro righe contenute nella here-string della sezione 4 come demo.ps1 nella stessa cartella. L’archivio di esempi contiene entrambi i file.

Eseguitelo in quella cartella.

. .\Get-ScriptCommand.ps1
$calls = @(Get-ScriptCommand -LiteralPath .\demo.ps1)

$calls |
    Where-Object { $_.NameKind -eq 'Static' -and $_.Name -eq 'Write-Host' } |
    Format-Table Line, Column, NameKind, Name -AutoSize
Line Column NameKind Name
---- ------ -------- ----
   1      1 Static   Write-Host
   2      1 Static   Write-Host

Ecco così le posizioni delle chiamate il cui nome è Write-Host. L’assegnazione della riga 3 non è inclusa. Si evita il problema da cui siamo partiti, in cui commenti e stringhe ordinarie finivano fra i risultati della ricerca.

La riga 4, invece, cade fuori da questo filtro. Non perché lì non ci sia una chiamata, ma perché il nome è indeterminato. Controllate separatamente le righe indeterminate.

$calls |
    Where-Object NameKind -eq 'Unresolved' |
    Format-Table Line, Column, NameKind, Name -AutoSize
Line Column NameKind   Name
---- ------ --------   ----
   4      1 Unresolved

Static è un contrassegno che significa «la stringa del nome è stata ottenuta». Non garantisce che il comando esista né che sappiate quale implementazione verrà invocata. echo, per esempio, torna come echo, e Microsoft.PowerShell.Utility\Write-Host torna con il nome qualificato, quindi nessuno dei due compare nella ricerca per corrispondenza esatta vista sopra. Se anche alias e chiamate qualificate rientrano nella vostra rassegna, il criterio di ricerca deve adeguarsi.11

Notate che ciò che è stato incluso con il dot-sourcing è lo strumento di analisi. demo.ps1 viene solo letto con ParseFile e non viene mai avviato.

6. I risultati della ricerca non sono una cronologia di esecuzione

Questa ricerca esamina come qualcosa sia scritto nel codice. Le chiamate sono scritte anche dentro funzioni mai usate e dentro if ($false) { Write-Host ... }, quindi compaiono nell’elenco. Non vi dice nulla sull’ordine di esecuzione né su quante volte qualcosa venga eseguito.

Anche le stringhe vengono trattate in modo diverso: 'Write-Host' e "Today: $(Get-Date)" non sono la stessa cosa. Nella seconda è incorporata un’espressione, quindi il Get-Date al suo interno viene trovato. Il codice che sta dentro una stringa ordinaria, invece, non viene mai rianalizzato come script a sé.2

Una chiamata di metodo .NET come [Console]::WriteLine(...) è un tipo di nodo diverso da CommandAst. Questo elenco non copre ogni operazione né dimostra alcunché sulla sicurezza. È uno strumento per passare in rassegna script che gestite voi. Confermare alias a runtime o funzioni omonime richiede inoltre l’ambiente in cui lo script viene eseguito.1211

Al centro di ciò che abbiamo usato qui ci sono gli oggetti confermati con GetType() ed Extent.Text. Ogni volta che vorrete esaminare un altro costrutto sintattico, potrete cominciare allo stesso modo: passare un breve frammento di codice a ParseInput e mettere questi due elementi fianco a fianco. Se il vostro scopo è il controllo di qualità rispetto a regole esistenti, PSScriptAnalyzer è la scelta migliore.

Il codice completo per esaminare i file

Qui sotto c’è l’intero Get-ScriptCommand usato nella sezione 5. La parte che legge la sintassi è la stessa della sezione 3; attorno ci sono il recupero del file, la gestione degli errori di sintassi e l’ingresso di più file.

function Get-ScriptCommand {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('FullName')]
        [ValidateNotNullOrEmpty()]
        [string[]] $LiteralPath
    )

    process {
        foreach ($path in $LiteralPath) {
            $file = Get-Item -LiteralPath $path -Force -ErrorAction Stop
            if ($file -isnot [System.IO.FileInfo]) {
                throw "A file is required: $path"
            }

            $tokens = $null
            $parseErrors = $null
            $ast = [System.Management.Automation.Language.Parser]::ParseFile(
                $file.FullName, [ref] $tokens, [ref] $parseErrors)
            if ($parseErrors.Count -gt 0) {
                $first = $parseErrors[0]
                throw ('Parse error: {0}:{1}:{2} ({3})' -f $file.FullName,
                    $first.Extent.StartLineNumber,
                    $first.Extent.StartColumnNumber, $first.ErrorId)
            }

            $commands = $ast.FindAll({
                    param($node)
                    $node -is [System.Management.Automation.Language.CommandAst]
                }, $true)
            foreach ($command in ($commands | Sort-Object { $_.Extent.StartOffset })) {
                $name = $command.GetCommandName()
                $kind = if ($null -eq $name) { 'Unresolved' } else { 'Static' }
                [pscustomobject]@{
                    Path     = $file.FullName
                    Line     = $command.Extent.StartLineNumber
                    Column   = $command.Extent.StartColumnNumber
                    NameKind = $kind
                    Name     = $name
                }
            }
        }
    }
}

Recupera il file effettivo con Get-Item -LiteralPath e poi passa FullName a ParseFile. Un nome come draft[1].ps1 non viene trattato come carattere jolly. Directory e file inesistenti generano un errore.

Se c’è un errore di sintassi, si ferma prima di emettere qualsiasi risultato per quel file. Un AST restituito parzialmente non conta come analisi riuscita. Il punto è tenere distinti «letto correttamente e trovati zero» e «non è stato possibile leggerlo».

Il valore restituito non viene formattato in tabella: è un oggetto con Path, Line, Column, NameKind e Name. Oltre a filtrare come nella sezione 5, potete salvare in CSV i risultati di più file. Poiché accetta input anche tramite una proprietà chiamata FullName, potete passargli direttamente gli oggetti FileInfo restituiti da Get-ChildItem.

Get-ChildItem -LiteralPath .\scripts -Filter *.ps1 -File -Recurse |
    Get-ScriptCommand |
    Export-Csv -LiteralPath .\commands.csv -NoTypeInformation -Encoding UTF8 -NoClobber

-NoClobber impedisce di sovrascrivere un CSV esistente. Se un file fallisce a metà percorso, i risultati precedenti potrebbero essere già finiti nel CSV. Non prendete l’esistenza del file come segno che ogni voce sia riuscita: controllate anche gli errori.

La grammatica usata per l’analisi segue la versione di PowerShell che esegue lo strumento. Analizzare con successo sotto 7.x non significa che lo script funzionerà sotto 5.1. Se sotto 5.1 leggete anche file contenenti giapponese, tenete conto della codifica dei caratteri, per esempio UTF-8 con BOM.13

Esempi e verifica

Lo strumento di analisi, gli esempi e i test (ZIP) contengono la funzione finita, esempi da analizzare e test Pester. La funzione in sé è identica al codice qui mostrato; la versione distribuita aggiunge commenti di aiuto. Se non riuscite a ottenere lo ZIP, potete comunque salvare e usare il codice completo qui sopra.

La funzione finita e i 26 casi Pester esistenti sono invariati rispetto alla revisione precedente. Per questa revisione i 12 blocchi di codice PowerShell estratti dal corpo del testo sono stati eseguiti sotto Windows PowerShell 5.1 e PowerShell 7.x, e nomi dei tipi, codice originale, posizioni delle chiamate e risultati filtrati sono stati confrontati fra loro. Le versioni esatte e l’ambito della verifica sono documentati nel README dell’archivio di esempi.

Articoli correlati

  1. Microsoft Learn, Parser.ParseInput Method. Sull’API che restituisce un AST da una stringa e restituisce token ed errori di sintassi tramite argomenti di output.  2

  2. Microsoft Learn, about_Quoting_Rules. Sulle here-string tra apici singoli e sulle sottoespressioni nelle stringhe espandibili.  2

  3. Microsoft PowerShell Team, Using abstract syntax trees (ASTs) with ISE to make scripting more productive. Sull’accesso all’albero sintattico da PowerShell e sulla ricerca di nodi come le definizioni di funzione.  2

  4. Microsoft Learn, NamedBlockAst Class. Sui blocchi il cui nome non è esplicitato e su Statements, che contiene le istruzioni. 

  5. Microsoft Learn, PipelineAst.PipelineElements Property. Sugli elementi che compongono una pipeline. 

  6. Microsoft Learn, IScriptExtent Interface. Sull’estensione nella sorgente, sulla posizione iniziale e sul fatto che righe e colonne partono da 1.  2

  7. Microsoft Learn, CommandAst.GetCommandName Method. Sul fatto che viene restituito null per le chiamate il cui nome non può essere ottenuto staticamente.  2 3

  8. Microsoft Learn, CommandAst.CommandElements Property. Sugli elementi sintattici come il nome della chiamata e gli argomenti. 

  9. Microsoft Learn, Ast.FindAll Method. Sul percorrere i nodi che soddisfano una condizione e sull’opzione per cercare in funzioni e blocchi di script annidati. 

  10. Microsoft Learn, Parser.ParseFile Method. Sull’API che analizza un file e produce AST, token ed errori di sintassi. 

  11. Microsoft Learn, about_Command_Precedence. Sulla precedenza a runtime di comandi omonimi, alias, funzioni e simili.  2

  12. Microsoft Learn, InvokeMemberExpressionAst Constructors. Sui nodi che rappresentano le chiamate di metodo di istanza e statiche. 

  13. Microsoft Learn, about_Character_Encoding. Su come Windows PowerShell legge gli script e sulla gestione del BOM UTF-8. 

Articoli recenti con gli stessi tag per approfondire argomenti vicini.

Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.

Domande frequenti

Domande che ricorrono nelle consulenze sull’argomento dell’articolo.

Che cos'è l'AST di PowerShell?
È un albero sintattico astratto che rappresenta il codice come nodi per ciascun costrutto sintattico, per esempio un'assegnazione, una chiamata di comando o una definizione di funzione. Permette di distinguere gli stessi caratteri scritti in un commento o in una stringa dagli stessi caratteri scritti come nome di una chiamata.
Devo eseguire i file .ps1 che sto analizzando?
Lo strumento di questo articolo li legge con Parser.ParseFile; non avvia la destinazione né la include con il dot-sourcing. Non è però una sandbox che garantisce la sicurezza: serve a fare l'inventario di script che gestite voi. Anche non trovare alcuna chiamata non è una prova di sicurezza.
Posso ottenere il nome di un comando chiamato tramite una variabile?
Quando GetCommandName non riesce a ottenere il nome staticamente, restituisce null. Lo strumento di questo articolo non scarta quella riga: la conserva con NameKind impostato a Unresolved. Non segue le assegnazioni di variabili e non valuta espressioni per indovinare il nome.
Funziona su Windows PowerShell 5.1?
Lo strumento qui presentato è rivolto alla 5.1 e alla serie 7.x. La grammatica usata per l'analisi è però quella del PowerShell che esegue lo strumento. Analizzare con successo sotto 7.x non è una prova di compatibilità con la 5.1. Se sotto 5.1 leggete anche file contenenti giapponese, tenete conto pure della codifica dei caratteri, per esempio UTF-8 con BOM.
Come si confronta con PSScriptAnalyzer?
Lo strumento di questo articolo esiste per elencare nomi e posizioni delle chiamate. Quando volete controllare la qualità rispetto a regole esistenti e gestire gli avvisi, usate PSScriptAnalyzer. Il modo di leggere l'AST mostrato qui è una porta d'ingresso per capire i risultati dell'analisi statica e il funzionamento delle regole personalizzate.

Profilo dell’autore

Pagina di presentazione dell’autore dell’articolo.

Go Komura

Rappresentante di KomuraSoft LLC

Specializzato nello sviluppo di software Windows, nella consulenza tecnica e nell’analisi dei malfunzionamenti, soprattutto nei progetti con sistemi esistenti e guasti difficili da riprodurre.

Torna al blog