Construire un outil d'analyse PowerShell en PowerShell — Lire les scripts avec l'AST, pas avec des expressions régulières
· Mis à jour le: · Go Komura · PowerShell, AST, Analyse statique, Outils de développement
Supposons que vous vouliez passer en revue un ensemble de scripts PowerShell et lister chaque endroit qui appelle Write-Host. Chercher le mot est facile, mais les trois lignes suivantes correspondent toutes.
$source = @'
# Write-Host sert à afficher du texte à l'écran
$message = 'Write-Host'
Write-Host 'Bonjour'
'@
Seule la ligne 3 est ce que vous cherchez. La ligne 1 est un commentaire et la ligne 2 une chaîne affectée à une variable : aucune des deux n’appelle Write-Host.
PowerShell lui-même fait cette distinction en exécutant le code. Vous pouvez aussi récupérer vous-même le résultat de cette lecture. Cet article commence par analyser le code et examiner les objets qui en sortent. Aucun module supplémentaire n’est utilisé.1
Le @' ... '@ ci-dessus est une chaîne here-string entre guillemets simples. Elle place ces trois lignes dans $source sous forme de chaîne qui n’est pas exécutée pour l’instant et dans laquelle $message n’est pas développée.2
1. Afficher ce que renvoie l’analyseur
Passez d’abord $source à 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
Ce qui a atterri dans $ast n’est ni une chaîne ni le résultat de l’exécution du script : c’est un objet de type ScriptBlockAst. Il représente l’ensemble du code que vous avez fourni. Outre le renvoi de cet objet, ParseInput restitue les jetons et les erreurs de syntaxe via les variables passées avec [ref].1
AST est l’abréviation d’« arbre syntaxique abstrait ». Le nom peut évoquer une structure de données particulière, mais du point de vue de PowerShell c’est avant tout un objet doté de propriétés et de méthodes. Suivez ces propriétés et vous atteignez d’autres objets qui représentent des affectations et des appels de commandes.3
Dans le diagramme, un trait continu marque une relation qui vaut toujours et un trait pointillé une relation conditionnelle (les conditions figurent dans l’explication de chaque relation sur la page de détail). La liste complète des relations (5 au total, avec preuve et niveau de certitude) et les définitions des concepts principaux sont rassemblées sur la page de détail de la carte des connaissances (en japonais). Données : JSON-LD / Turtle
2. En quels objets ces trois lignes se sont-elles transformées ?
Dans un code comme celui-ci, qui n’explicite pas begin, process et end, les instructions ordinaires se trouvent dans EndBlock.Statements. Alignons leurs types en regard du code d’origine.4
$statements = $ast.EndBlock.Statements
$statements | ForEach-Object {
[pscustomobject]@{
Type = $_.GetType().Name
Text = $_.Extent.Text
}
}
Type Text
---- ----
AssignmentStatementAst $message = 'Write-Host'
PipelineAst Write-Host 'Bonjour'
Deux entrées. La ligne de commentaire n’apparaît pas dans cette liste d’instructions. Si vous avez besoin des commentaires eux-mêmes, vous pouvez les obtenir depuis les $tokens reçus à l’instant.3
La ligne 2 est devenue un AssignmentStatementAst, qui représente une affectation. La ligne 3 est un PipelineAst : même sans écrire de |, elle est représentée comme un pipeline à une seule commande. Extent.Text est le code d’origine correspondant à l’objet. Chaque fois que le nom de type seul vous laisse dans le doute, cela vous indique de quelle partie de la source il s’agit.56
flowchart TB
accTitle: Comment le code d'origine correspond aux objets syntaxiques
accDescr: Le EndBlock de l'ensemble du script contient l'affectation de la ligne 2 et le pipeline de la ligne 3, et ce dernier contient l'appel de commande.
R["ScriptBlockAst"] --> E["EndBlock"]
E --> A["Affectation : ligne 2"]
E --> P["Pipeline : ligne 3"]
P --> C["CommandAst"]
Figure 1 : L’appel de la ligne 3 se trouve à l’intérieur du pipeline.
Les indices de tableau commencent à 0, donc $statements[1] est le pipeline de la ligne 3. Prenez-en le premier élément.
$pipeline = $statements[1]
$call = $pipeline.PipelineElements[0]
$call.GetType().Name
$call.Extent.Text
$call.GetCommandName()
CommandAst
Write-Host 'Bonjour'
Write-Host
Voilà le CommandAst. Extent.Text, qui renvoie le code d’origine, et GetCommandName(), qui renvoie le nom de l’appel, sont tous deux utilisés sur ce même objet.7
Le détail incluant les arguments se trouve dans CommandElements.
$call.CommandElements | ForEach-Object {
[pscustomobject]@{
Type = $_.GetType().Name
Text = $_.Extent.Text
}
}
Type Text
---- ----
StringConstantExpressionAst Write-Host
StringConstantExpressionAst 'Bonjour'
Le nom et l’argument sont tous deux des nœuds représentant des chaînes. Pourtant, ce que renvoie GetCommandName() est Write-Host. Il ne choisit pas d’après l’orthographe de la chaîne seule : il regarde l’élément qui tient lieu de nom au sein d’un appel de commande. Le 'Write-Host' de la ligne 2 est à droite d’une affectation et n’appartient pas du tout à cet appel.78
Voilà la différence entre une recherche textuelle et une analyse syntaxique. Même pour un mot identique, examiner la structure dans laquelle il s’inscrit permet de distinguer ses rôles.
3. Chercher CommandAst plutôt que de parcourir par indices
Nous avons vérifié le contenu. Dans un vrai script, toutefois, vous ne pouvez pas coder en dur « le premier élément de la deuxième instruction ». Les appels apparaissent dans des fonctions, dans des blocs if et au milieu d’un pipeline.
La méthode de recherche prévue pour cela est 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 'Bonjour'
FindAll parcourt l’arbre syntaxique et remet chaque nœud au test contenu dans { ... }. Vérifiez le type du $node reçu avec -is et renvoyez $true lorsqu’il s’agit d’un CommandAst. Ce nœud reste dans les résultats. Le $true final indique de chercher aussi dans les fonctions et les blocs de script imbriqués.9
Avec function Show-Message { Write-Host 'hello' }, par exemple, il descend dans le corps de la fonction et récupère l’appel. Il n’est pas nécessaire d’exécuter la fonction.
flowchart TB
accTitle: Rechercher par type jusque dans une fonction
accDescr: La recherche descend dans le corps d'une fonction contenue dans le script et inclut dans les résultats l'appel correspondant à CommandAst.
R["Script"] --> F["Définition de fonction"]
F --> B["Corps de la fonction"]
B --> C["CommandAst"]
C --> M["Type correspondant : conservé dans les résultats"]
Figure 2 : Vous obtenez les nœuds correspondant au critère de recherche sans compter vous-même la profondeur d’imbrication.
Dans les trois lignes du début, il a trouvé le même appel unique que celui atteint par indice. Extrayez le nom et l’emplacement, et vous obtenez quelque chose d’exploitable comme résultat de recherche.
$commands | ForEach-Object {
[pscustomobject]@{
Name = $_.GetCommandName()
Line = $_.Extent.StartLineNumber
Column = $_.Extent.StartColumnNumber
}
}
Name Line Column
---- ---- ------
Write-Host 3 1
Les lignes et les colonnes commencent à 1. Comme le même nœud porte à la fois le nom et l’emplacement, vous n’avez pas à retrouver la ligne par une recherche textuelle distincte.6
4. À quoi ressemble un appel via une variable
Modifions légèrement la cible de l’analyse. Outre le nom écrit directement, incluez des cas qui passent une chaîne ou une variable à l’opérateur d’appel &.
$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'
La ligne 4 a elle aussi été trouvée comme CommandAst. La valeur de retour de GetCommandName() est toutefois $null.
Une personne qui lit ces quatre lignes peut déduire la valeur de $command de l’affectation juste au-dessus. Cette méthode ne remonte pas les affectations à une variable pour en calculer la valeur. Contrairement au cas où le nom est écrit directement dans le code, cette API seule ne peut pas extraire le nom.7
flowchart TB
accTitle: Le même nœud d'appel, des résultats différents pour l'obtention du nom
accDescr: Pour un appel dont le nom est une chaîne, Write-Host peut être obtenu ; pour un appel via une variable le nom est null, mais les deux portent l'emplacement de l'appel.
C["CommandAst"] --> L["L'élément de nom est une chaîne"]
C --> V["L'élément de nom est une variable"]
L --> N["Nom : Write-Host"]
V --> U["Nom : null"]
Figure 3 : Même avec un nom vide, on voit encore qu’un appel est écrit à la ligne 4.
Dans un outil qui passe en revue des fichiers, consignez cette différence dans une colonne NameKind. Les lignes dont la chaîne de nom a pu être obtenue sont Static ; celles où elle n’a pas pu l’être sont Unresolved. Conserver les lignes sans nom, c’est ne pas perdre de vue les endroits qu’une personne devrait vérifier.
5. Extraire d’un fichier les emplacements de Write-Host
Quand vous lisez un .ps1 au lieu d’une chaîne, utilisez ParseFile à la place de ParseInput. La façon de lire l’AST ne change pas.10
Get-ScriptCommand, qui rassemble tout ce qui précède, figure dans le code complet à la fin de cet article et dans l’archive d’exemples. Enregistrez le code complet sous Get-ScriptCommand.ps1 et les quatre lignes contenues dans la here-string de la section 4 sous demo.ps1 dans le même dossier. L’archive d’exemples contient ces deux fichiers.
Exécutez-le dans ce dossier.
. .\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
Vous obtenez ainsi les emplacements des appels dont le nom est Write-Host. L’affectation de la ligne 3 n’y figure pas. Cela évite le problème du départ, où commentaires et chaînes ordinaires se mêlaient aux résultats de recherche.
La ligne 4, en revanche, sort de ce filtre. Non pas parce qu’il n’y a pas d’appel, mais parce que le nom est indéterminé. Vérifiez les lignes indéterminées séparément.
$calls |
Where-Object NameKind -eq 'Unresolved' |
Format-Table Line, Column, NameKind, Name -AutoSize
Line Column NameKind Name
---- ------ -------- ----
4 1 Unresolved
Static est une marque signifiant « la chaîne du nom a pu être obtenue ». Ce n’est pas une garantie que la commande existe ni que vous sachiez quelle implémentation sera invoquée. echo, par exemple, revient tel quel, et Microsoft.PowerShell.Utility\Write-Host revient sous son nom qualifié : aucun des deux n’apparaît donc dans la recherche par correspondance exacte ci-dessus. Si les alias et les appels qualifiés entrent aussi dans le périmètre de votre inventaire, le critère de recherche doit s’y adapter.11
Notez que ce qui a été inclus par dot-sourcing, c’est l’outil d’analyse. demo.ps1 est seulement lu avec ParseFile ; il n’est jamais démarré.
6. Les résultats de recherche ne sont pas un historique d’exécution
Cette recherche examine la façon dont quelque chose est écrit dans le code. Des appels figurent aussi dans des fonctions jamais utilisées et dans if ($false) { Write-Host ... } : ils apparaissent donc dans la liste. Elle ne vous dit rien sur l’ordre d’exécution ni sur le nombre de fois.
Les chaînes sont également traitées différemment : 'Write-Host' et "Today: $(Get-Date)" ne sont pas la même chose. La seconde contient une expression incorporée, si bien que le Get-Date qui s’y trouve est détecté. Du code situé dans une chaîne ordinaire, au contraire, n’est jamais réanalysé comme un script distinct.2
Un appel de méthode .NET tel que [Console]::WriteLine(...) est un type de nœud différent de CommandAst. Cette liste ne couvre pas toutes les opérations et ne prouve rien quant à la sécurité. C’est un outil pour passer en revue des scripts que vous gérez vous-même. Confirmer les alias au moment de l’exécution ou des fonctions homonymes exige en outre l’environnement dans lequel le script s’exécute.1211
Au cœur de ce que nous avons utilisé se trouvent les objets confirmés avec GetType() et Extent.Text. Chaque fois que vous voudrez examiner une autre construction syntaxique, vous pourrez commencer de la même manière : passer un court fragment de code à ParseInput et mettre ces deux éléments côte à côte. Si votre objectif est le contrôle qualité au regard de règles existantes, PSScriptAnalyzer est le meilleur choix.
Le code complet pour parcourir des fichiers
Voici l’intégralité de Get-ScriptCommand utilisé à la section 5. La partie qui lit la syntaxe est la même qu’à la section 3 ; autour se trouvent la récupération du fichier, le traitement des erreurs de syntaxe et l’entrée de plusieurs fichiers.
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
}
}
}
}
}
Il récupère le fichier réel avec Get-Item -LiteralPath puis passe FullName à ParseFile. Un nom comme draft[1].ps1 n’est pas traité comme un motif générique. Les répertoires et les fichiers inexistants déclenchent une erreur.
En cas d’erreur de syntaxe, il s’arrête avant d’émettre le moindre résultat pour ce fichier. Un AST partiellement renvoyé ne compte pas comme une analyse réussie. L’idée est de garder distincts « lu correctement et zéro trouvé » et « n’a pas pu être lu ».
La valeur de retour n’est pas mise en forme sous forme de tableau : c’est un objet avec Path, Line, Column, NameKind et Name. Outre le filtrage de la section 5, vous pouvez enregistrer en CSV les résultats de plusieurs fichiers. Comme il accepte aussi une entrée via une propriété nommée FullName, vous pouvez lui passer directement les objets FileInfo renvoyés par Get-ChildItem.
Get-ChildItem -LiteralPath .\scripts -Filter *.ps1 -File -Recurse |
Get-ScriptCommand |
Export-Csv -LiteralPath .\commands.csv -NoTypeInformation -Encoding UTF8 -NoClobber
-NoClobber empêche d’écraser un CSV existant. Si un fichier échoue en cours de route, les résultats antérieurs peuvent déjà être partis dans le CSV. Ne prenez pas l’existence du fichier pour un signe que chaque entrée a réussi : vérifiez aussi les erreurs.
La grammaire utilisée pour l’analyse suit la version de PowerShell qui exécute l’outil. Une analyse réussie sous 7.x ne signifie pas que le script s’exécutera sous 5.1. Si vous lisez aussi sous 5.1 des fichiers contenant du japonais, tenez compte de l’encodage des caractères, par exemple UTF-8 avec BOM.13
Exemples et vérification
L’outil d’analyse, les exemples et les tests (ZIP) contiennent la fonction finalisée, des exemples à analyser et des tests Pester. La fonction elle-même est identique au code présenté ici ; la version distribuée ajoute des commentaires d’aide. Si vous ne pouvez pas récupérer le ZIP, vous pouvez toujours enregistrer et utiliser le code complet ci-dessus.
La fonction finalisée et les 26 cas Pester existants sont inchangés par rapport à la révision précédente. Pour cette révision, les 12 blocs de code PowerShell extraits du corps du texte ont été exécutés sous Windows PowerShell 5.1 et PowerShell 7.x, et les noms de types, le code d’origine, les emplacements d’appels et les résultats filtrés ont été confrontés les uns aux autres. Les versions exactes et le périmètre de vérification sont documentés dans le README de l’archive d’exemples.
Articles liés
- Protéger la qualité des scripts PowerShell avec PSScriptAnalyzer
- Tester PowerShell avec Pester
- Conception des paramètres et modularisation pour PowerShell
Liens de référence
-
Microsoft Learn, Parser.ParseInput Method. Sur l’API qui renvoie un AST à partir d’une chaîne et restitue jetons et erreurs de syntaxe via des arguments de sortie. ↩ ↩2
-
Microsoft Learn, about_Quoting_Rules. Sur les here-strings entre guillemets simples et les sous-expressions dans les chaînes développables. ↩ ↩2
-
Microsoft PowerShell Team, Using abstract syntax trees (ASTs) with ISE to make scripting more productive. Sur l’accès à l’arbre syntaxique depuis PowerShell et la recherche de nœuds tels que les définitions de fonctions. ↩ ↩2
-
Microsoft Learn, NamedBlockAst Class. Sur les blocs dont le nom n’est pas explicité et sur Statements, qui contient les instructions. ↩
-
Microsoft Learn, PipelineAst.PipelineElements Property. Sur les éléments qui composent un pipeline. ↩
-
Microsoft Learn, IScriptExtent Interface. Sur l’étendue dans la source, la position de départ et le fait que lignes et colonnes commencent à 1. ↩ ↩2
-
Microsoft Learn, CommandAst.GetCommandName Method. Sur le renvoi de null pour les appels dont le nom ne peut pas être obtenu de façon statique. ↩ ↩2 ↩3
-
Microsoft Learn, CommandAst.CommandElements Property. Sur les éléments syntaxiques tels que le nom de l’appel et les arguments. ↩
-
Microsoft Learn, Ast.FindAll Method. Sur le parcours des nœuds correspondant à une condition et sur l’option permettant de chercher dans les fonctions et blocs de script imbriqués. ↩
-
Microsoft Learn, Parser.ParseFile Method. Sur l’API qui analyse un fichier et produit l’AST, les jetons et les erreurs de syntaxe. ↩
-
Microsoft Learn, about_Command_Precedence. Sur la priorité à l’exécution des commandes homonymes, alias, fonctions et assimilés. ↩ ↩2
-
Microsoft Learn, InvokeMemberExpressionAst Constructors. Sur les nœuds qui représentent les appels de méthodes d’instance et statiques. ↩
-
Microsoft Learn, about_Character_Encoding. Sur la façon dont Windows PowerShell lit les scripts et sur le traitement du BOM UTF-8. ↩
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
Protéger la qualité des scripts PowerShell avec PSScriptAnalyzer ── choix des règles et intégration en CI
Un guide pratique pour déployer PSScriptAnalyzer, l'outil d'analyse statique de PowerShell, sur les scripts internes de l'entreprise. Il ...
Le réseau fonctionne mais Windows affiche « Pas d'Internet » — Isoler NCSI, DNS, proxy et VPN sous Windows
Pourquoi Windows affiche « Pas d'Internet » alors que le réseau fonctionne, en partant du verdict NCSI. Isoler DNS, proxy, VPN et portail...
Disque à 100 % : que faut-il vraiment arrêter ? — Distinguer SysMain, Windows Search et Defender
Isoler une utilisation disque Windows à 100 % d'après le débit, le temps de réponse et les fichiers. Arrêter SysMain en toute sécurité, r...
L'ordre de la résolution de noms sous Windows — hosts, le cache DNS, LLMNR/mDNS et DoH
Que la réponse vienne de hosts, du cache DNS, du serveur DNS ou de LLMNR/mDNS change le résultat, et explique pourquoi certains PC échoue...
Ce que le démarrage rapide fait vraiment — pourquoi « Arrêter » sous Windows n'est pas un redémarrage
Un arrêt Windows est par défaut un arrêt hybride, qui enregistre le noyau et les pilotes dans hiberfil.sys. Pourquoi seul un redémarrage ...
Sujets associés
Ces pages replacent le sujet dans un contexte plus large de services et de décisions.
Thèmes techniques Windows
Portail des sujets sur le développement Windows, l'analyse des incidents et la valorisation des actifs existants.
Questions fréquentes
Questions souvent posées lors d’une consultation sur le sujet de cet article.
- Qu'est-ce que l'AST de PowerShell ?
- C'est un arbre syntaxique abstrait qui représente le code sous forme de nœuds pour chaque construction syntaxique, telle qu'une affectation, un appel de commande ou une définition de fonction. Il permet de distinguer les mêmes caractères écrits dans un commentaire ou une chaîne de ces mêmes caractères écrits comme nom d'un appel.
- Dois-je exécuter les fichiers .ps1 que j'analyse ?
- L'outil de cet article les lit avec Parser.ParseFile ; il ne lance pas la cible et ne l'inclut pas par dot-sourcing. Ce n'est toutefois pas un bac à sable garantissant la sécurité : il sert à faire l'inventaire de scripts que vous gérez vous-même. Ne trouver aucun appel n'est pas non plus une preuve de sécurité.
- Puis-je obtenir le nom d'une commande appelée via une variable ?
- Lorsque GetCommandName ne peut pas obtenir le nom de façon statique, il renvoie null. L'outil de cet article ne jette pas cette ligne : il la conserve avec un NameKind à Unresolved. Il ne suit pas les affectations de variables et n'évalue pas les expressions pour deviner le nom.
- Cela fonctionne-t-il sous Windows PowerShell 5.1 ?
- L'outil présenté ici vise 5.1 et la série 7.x. La grammaire utilisée pour l'analyse est cependant celle du PowerShell qui exécute l'outil. Une analyse réussie sous 7.x ne prouve pas la compatibilité avec 5.1. Si vous lisez aussi sous 5.1 des fichiers contenant du japonais, tenez compte également de l'encodage des caractères, par exemple UTF-8 avec BOM.
- Comment cela se compare-t-il à PSScriptAnalyzer ?
- L'outil de cet article existe pour lister les noms et les emplacements des appels. Quand vous voulez contrôler la qualité au regard de règles existantes et gérer les avertissements, utilisez PSScriptAnalyzer. La façon de lire l'AST montrée ici est une porte d'entrée pour comprendre les résultats d'analyse statique et le fonctionnement des règles personnalisées.
Profil de l’auteur
Page de présentation de l’auteur de l’article.
Go Komura
Représentant de KomuraSoft LLC
Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.