Construir una herramienta de análisis de PowerShell en PowerShell — Leer scripts con el AST, no con expresiones regulares

· Actualizado el: · · PowerShell, AST, Análisis estático, Herramientas de desarrollo

Suponga que quiere revisar un conjunto de scripts de PowerShell y listar cada lugar que llama a Write-Host. Buscar la palabra es fácil, pero las tres líneas siguientes coinciden todas.

$source = @'
# Write-Host sirve para mostrar texto en pantalla
$message = 'Write-Host'
Write-Host 'Hola'
'@

Solo la línea 3 es lo que busca. La línea 1 es un comentario y la línea 2 una cadena asignada a una variable, así que ninguna de las dos llama a Write-Host.

El propio PowerShell hace esa distinción mientras ejecuta el código. Usted también puede extraer por su cuenta el resultado de esa lectura. Este artículo empieza por analizar el código y examinar los objetos que salen de él. No se usan módulos adicionales.1

El @' ... '@ de arriba es una here-string entre comillas simples. Coloca esas tres líneas en $source como una cadena que de momento no se ejecuta y en la que $message no se expande.2

1. Mostrar lo que devuelve el analizador

Pase primero $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

Lo que ha aterrizado en $ast no es ni una cadena ni el resultado de ejecutar el script: es un objeto de tipo ScriptBlockAst. Representa la totalidad del código que usted proporcionó. Además de devolver este objeto, ParseInput devuelve los tokens y los errores de sintaxis a través de las variables pasadas con [ref].1

AST son las siglas de «árbol de sintaxis abstracta». El nombre puede sugerir alguna estructura de datos especial, pero desde el punto de vista de PowerShell es ante todo un objeto con propiedades y métodos. Siga esas propiedades y llegará a otros objetos que representan asignaciones y llamadas a comandos.3

En el diagrama, una línea continua marca una relación que siempre se cumple y una línea discontinua una relación condicional (las condiciones están en la explicación de cada relación en la página de detalle). La lista completa de relaciones (5 en total, con evidencia y grado de certeza) y las definiciones de los conceptos principales están reunidas en la página de detalle del mapa de conocimiento (en japonés). Datos: JSON-LD / Turtle

2. ¿En qué objetos se convirtieron esas tres líneas?

En código como este, que no explicita begin, process y end, las instrucciones corrientes están en EndBlock.Statements. Pongamos sus tipos junto al código original.4

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

Dos entradas. La línea de comentario no aparece en esta lista de instrucciones. Si necesita los comentarios en sí, puede obtenerlos de los $tokens que recibió hace un momento.3

La línea 2 se convirtió en un AssignmentStatementAst, que representa una asignación. La línea 3 es un PipelineAst: incluso sin escribir un |, se representa como una canalización con un solo comando. Extent.Text es el código original correspondiente al objeto. Siempre que el nombre del tipo por sí solo le deje con dudas, esto le dice de qué parte del origen se habla.56

Cómo se corresponde el código original con los objetos sintácticosEl EndBlock del script completo contiene la asignación de la línea 2 y la canalización de la línea 3, y esta última contiene la llamada al comando.ScriptBlockAstEndBlockAsignación: línea 2Canalización: línea 3CommandAst

Figura 1: La llamada de la línea 3 está dentro de la canalización.

Los índices de los arreglos empiezan en 0, así que $statements[1] es la canalización de la línea 3. De ahí tome el primer elemento.

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

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

Ahí está el CommandAst. Extent.Text, que devuelve el código original, y GetCommandName(), que devuelve el nombre de la llamada, se usan ambos sobre ese mismo objeto.7

El desglose incluidos los argumentos está en CommandElements.

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

El nombre y el argumento son ambos nodos que representan cadenas. Aun así, lo que devuelve GetCommandName() es Write-Host. No elige por la grafía de la cadena por sí sola: mira el elemento que hace de nombre dentro de una llamada a un comando. El 'Write-Host' de la línea 2 está a la derecha de una asignación y no pertenece en absoluto a esta llamada.78

Esta es la diferencia entre una búsqueda de texto y el análisis sintáctico. Aun tratándose de la misma palabra, examinar la estructura en la que se halla permite distinguir sus papeles.

3. Buscar CommandAst en lugar de recorrer por índices

Hemos confirmado el contenido. En un script real, sin embargo, no puede fijar «el primer elemento de la segunda instrucción». Las llamadas aparecen dentro de funciones, dentro de bloques if y a mitad de una canalización.

El método de búsqueda previsto para eso es 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 'Hola'

FindAll recorre el árbol de sintaxis y entrega cada nodo a la prueba contenida en { ... }. Compruebe con -is el tipo del $node recibido y devuelva $true cuando sea un CommandAst. Ese nodo permanece en los resultados. El $true final indica que se busque también dentro de funciones y bloques de script anidados.9

Con function Show-Message { Write-Host 'hello' }, por ejemplo, desciende al cuerpo de la función y recoge la llamada. No hace falta ejecutar la función.

Buscar por tipo hasta dentro de una funciónLa búsqueda desciende al cuerpo de una función contenida en el script e incluye en los resultados la llamada que coincide con CommandAst.ScriptDefinición de funciónCuerpo de la funciónCommandAstEl tipo coincide: se mantiene en los resultados

Figura 2: Obtiene los nodos que cumplen el criterio de búsqueda sin contar usted mismo la profundidad de anidamiento.

En las tres líneas del principio encontró la misma única llamada a la que llegamos por índice. Extraiga el nombre y la ubicación y tendrá algo utilizable como resultado de búsqueda.

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

Las líneas y las columnas empiezan en 1. Como el mismo nodo lleva tanto el nombre como la ubicación, no tiene que volver a buscar la línea con una búsqueda de texto aparte.6

4. Qué aspecto tiene una llamada a través de una variable

Cambiemos un poco el objetivo del análisis. Además del nombre escrito directamente, incluya casos que pasan una cadena o una variable al operador de llamada &.

$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 línea 4 también se encontró como CommandAst. El valor devuelto por GetCommandName(), sin embargo, es $null.

Una persona que lea estas cuatro líneas puede deducir el valor de $command de la asignación inmediatamente anterior. Este método no retrocede por las asignaciones a una variable para calcular su valor. A diferencia del caso en que el nombre está escrito directamente en el código, esta API por sí sola no puede extraer el nombre.7

El mismo nodo de llamada, resultados distintos al obtener el nombrePara una llamada cuyo nombre es una cadena se puede obtener Write-Host; para una llamada a través de una variable el nombre es null, pero ambas llevan la ubicación de la llamada.CommandAstEl elemento del nombre es una cadenaEl elemento del nombre es una variableNombre: Write-HostNombre: null

Figura 3: Aun con el nombre vacío, se sigue viendo que en la línea 4 hay escrita una llamada.

En una herramienta que revisa archivos, recoja esta diferencia en una columna NameKind. Las líneas cuya cadena de nombre pudo obtenerse son Static; aquellas en las que no fue posible, Unresolved. Conservar las líneas sin nombre significa no perder de vista los lugares que una persona debería comprobar.

5. Extraer de un archivo las ubicaciones de Write-Host

Cuando lea un .ps1 en lugar de una cadena, use ParseFile en vez de ParseInput. La forma de leer el AST no cambia.10

Get-ScriptCommand, que reúne todo lo anterior, está en el código completo al final de este artículo y en el paquete de ejemplos. Guarde el código completo como Get-ScriptCommand.ps1 y las cuatro líneas contenidas en la here-string de la sección 4 como demo.ps1 en la misma carpeta. El paquete de ejemplos contiene ambos archivos.

Ejecútelo en esa carpeta.

. .\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

Con eso tiene las ubicaciones de las llamadas cuyo nombre es Write-Host. La asignación de la línea 3 no se incluye. Así se evita el problema del principio, en el que comentarios y cadenas corrientes acababan mezclados en los resultados de búsqueda.

La línea 4, en cambio, queda fuera de este filtro. No porque allí no haya una llamada, sino porque el nombre está indeterminado. Compruebe por separado las líneas indeterminadas.

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

Static es una marca que significa «la cadena del nombre pudo obtenerse». No garantiza que el comando exista ni que sepa qué implementación se invocará. echo, por ejemplo, vuelve como echo, y Microsoft.PowerShell.Utility\Write-Host vuelve con el nombre cualificado, de modo que ninguno de los dos aparece en la búsqueda por coincidencia exacta anterior. Si los alias y las llamadas cualificadas también entran en el alcance de su revisión, el criterio de búsqueda tiene que ajustarse a ello.11

Tenga en cuenta que lo que se incorporó mediante dot-sourcing fue la herramienta de análisis. demo.ps1 solo se lee con ParseFile; nunca se inicia.

6. Los resultados de la búsqueda no son un historial de ejecución

Esta búsqueda examina cómo está escrito algo en el código. También hay llamadas escritas dentro de funciones que nunca se usan y dentro de if ($false) { Write-Host ... }, de modo que aparecen en la lista. No le dice nada sobre el orden de ejecución ni sobre cuántas veces se ejecuta algo.

Las cadenas también se tratan de forma distinta: 'Write-Host' y "Today: $(Get-Date)" no son lo mismo. En la segunda hay una expresión incrustada, así que el Get-Date de su interior se encuentra. El código que está dentro de una cadena corriente, en cambio, nunca se vuelve a analizar como un script aparte.2

Una llamada a un método de .NET como [Console]::WriteLine(...) es un tipo de nodo distinto de CommandAst. Esta lista ni cubre todas las operaciones ni demuestra nada sobre seguridad. Es una herramienta para revisar scripts que usted mismo gestiona. Confirmar alias en tiempo de ejecución o funciones homónimas exige además el entorno en el que el script se ejecuta.1211

En el centro de lo que hemos usado aquí están los objetos confirmados con GetType() y Extent.Text. Siempre que quiera examinar otra construcción sintáctica, puede empezar igual: pasar un fragmento corto de código a ParseInput y poner esos dos elementos uno junto al otro. Si su objetivo es inspeccionar la calidad frente a reglas existentes, PSScriptAnalyzer es la mejor opción.

El código completo para revisar archivos

Abajo está el Get-ScriptCommand completo usado en la sección 5. La parte que lee la sintaxis es la misma que en la sección 3; a su alrededor están la obtención del archivo, el tratamiento de errores de sintaxis y la entrada de varios archivos.

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
                }
            }
        }
    }
}

Obtiene el archivo real con Get-Item -LiteralPath y después pasa FullName a ParseFile. Un nombre como draft[1].ps1 no se trata como comodín. Los directorios y los archivos inexistentes provocan un error.

Si hay un error de sintaxis, se detiene antes de emitir ningún resultado para ese archivo. Un AST devuelto parcialmente no cuenta como análisis correcto. La idea es mantener separados «se leyó bien y se encontraron cero» y «no se pudo leer».

El valor devuelto no se da formato de tabla: es un objeto con Path, Line, Column, NameKind y Name. Además de filtrar como en la sección 5, puede guardar en CSV los resultados de varios archivos. Como también acepta entrada mediante una propiedad llamada FullName, puede pasarle directamente los objetos FileInfo que devuelve Get-ChildItem.

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

-NoClobber impide sobrescribir un CSV existente. Si un archivo falla a mitad de camino, los resultados anteriores pueden haber ido ya al CSV. No tome la existencia del archivo como señal de que todas las entradas tuvieron éxito: compruebe también los errores.

La gramática usada para el análisis sigue la versión de PowerShell que ejecuta la herramienta. Analizar con éxito bajo 7.x no significa que el script vaya a ejecutarse bajo 5.1. Si en 5.1 también lee archivos con texto en japonés, tenga en cuenta la codificación de caracteres, por ejemplo UTF-8 con BOM.13

Ejemplos y verificación

La herramienta de análisis, los ejemplos y las pruebas (ZIP) contienen la función terminada, ejemplos que analizar y pruebas de Pester. La función en sí es idéntica al código aquí mostrado; la versión distribuida añade comentarios de ayuda. Si no puede obtener el ZIP, todavía puede guardar y usar el código completo de arriba.

La función terminada y los 26 casos de Pester existentes no han cambiado respecto a la revisión anterior. Para esta revisión, los 12 bloques de código de PowerShell extraídos del cuerpo del texto se ejecutaron bajo Windows PowerShell 5.1 y PowerShell 7.x, y se contrastaron entre sí los nombres de tipo, el código original, las ubicaciones de las llamadas y los resultados filtrados. Las versiones exactas y el alcance de la verificación están documentados en el README del paquete de ejemplos.

Artículos relacionados

Enlaces de referencia

  1. Microsoft Learn, Parser.ParseInput Method. Sobre la API que devuelve un AST a partir de una cadena y devuelve tokens y errores de sintaxis mediante argumentos de salida.  2

  2. Microsoft Learn, about_Quoting_Rules. Sobre las here-strings entre comillas simples y las subexpresiones en cadenas expandibles.  2

  3. Microsoft PowerShell Team, Using abstract syntax trees (ASTs) with ISE to make scripting more productive. Sobre el acceso al árbol de sintaxis desde PowerShell y la búsqueda de nodos como las definiciones de función.  2

  4. Microsoft Learn, NamedBlockAst Class. Sobre los bloques cuyo nombre no se explicita y sobre Statements, que contiene las instrucciones. 

  5. Microsoft Learn, PipelineAst.PipelineElements Property. Sobre los elementos que componen una canalización. 

  6. Microsoft Learn, IScriptExtent Interface. Sobre la extensión en el origen, la posición inicial y el hecho de que líneas y columnas empiezan en 1.  2

  7. Microsoft Learn, CommandAst.GetCommandName Method. Sobre la devolución de null en las llamadas cuyo nombre no puede obtenerse de forma estática.  2 3

  8. Microsoft Learn, CommandAst.CommandElements Property. Sobre elementos sintácticos como el nombre de la llamada y los argumentos. 

  9. Microsoft Learn, Ast.FindAll Method. Sobre el recorrido de los nodos que cumplen una condición y la opción de buscar en funciones y bloques de script anidados. 

  10. Microsoft Learn, Parser.ParseFile Method. Sobre la API que analiza un archivo y produce el AST, los tokens y los errores de sintaxis. 

  11. Microsoft Learn, about_Command_Precedence. Sobre la precedencia en tiempo de ejecución de comandos homónimos, alias, funciones y similares.  2

  12. Microsoft Learn, InvokeMemberExpressionAst Constructors. Sobre los nodos que representan llamadas a métodos de instancia y estáticos. 

  13. Microsoft Learn, about_Character_Encoding. Sobre cómo lee los scripts Windows PowerShell y sobre el tratamiento del BOM de UTF-8. 

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Qué es el AST de PowerShell?
Es un árbol de sintaxis abstracta que representa el código como nodos para cada construcción sintáctica, como una asignación, una llamada a un comando o una definición de función. Permite distinguir los mismos caracteres escritos dentro de un comentario o una cadena de esos mismos caracteres escritos como nombre de una llamada.
¿Tengo que ejecutar los archivos .ps1 que analizo?
La herramienta de este artículo los lee con Parser.ParseFile; no inicia el destino ni lo incorpora mediante dot-sourcing. No es, sin embargo, un entorno aislado que garantice la seguridad: sirve para inventariar scripts que usted mismo gestiona. Tampoco no encontrar ninguna llamada es prueba de seguridad.
¿Puedo obtener el nombre de un comando llamado a través de una variable?
Cuando GetCommandName no puede obtener el nombre de forma estática, devuelve null. La herramienta de este artículo no descarta esa línea: la conserva con NameKind en Unresolved. No sigue las asignaciones de variables ni evalúa expresiones para adivinar el nombre.
¿Funciona en Windows PowerShell 5.1?
La herramienta que aquí se presenta va dirigida a 5.1 y a la serie 7.x. La gramática usada para el análisis, no obstante, es la del PowerShell que ejecuta la herramienta. Analizar con éxito bajo 7.x no prueba la compatibilidad con 5.1. Si en 5.1 también lee archivos con texto en japonés, tenga en cuenta además la codificación de caracteres, por ejemplo UTF-8 con BOM.
¿Cómo se compara con PSScriptAnalyzer?
La herramienta de este artículo existe para listar nombres y ubicaciones de las llamadas. Cuando quiera inspeccionar la calidad frente a reglas existentes y gestionar los avisos, use PSScriptAnalyzer. La forma de leer el AST que se muestra aquí es una puerta de entrada para entender los resultados del análisis estático y cómo funcionan las reglas propias.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog