Создаём инструмент анализа PowerShell на самом PowerShell — читаем скрипты через AST, а не регулярными выражениями

· Обновлено: · · PowerShell, AST, Статический анализ, Инструменты разработки

Допустим, вы хотите просмотреть набор скриптов PowerShell и свести в список все места, где вызывается Write-Host. Найти слово несложно, но под поиск попадут все три строки ниже.

$source = @'
# Write-Host выводит текст на экран
$message = 'Write-Host'
Write-Host 'Привет'
'@

Нужна только третья строка. Первая — комментарий, вторая — строка, которая присваивается переменной, поэтому ни одна из них не вызывает Write-Host.

Сам PowerShell различает их, когда выполняет код. Результат этого разбора можно извлечь и наружу. В этой статье мы начнём с того, что разберём код по синтаксису и рассмотрим получившиеся объекты. Дополнительные модули не используются.1

Приведённая выше конструкция @' ... '@ — это here-string в одинарных кавычках. Он помещает три строки в $source как строку, которая сейчас не выполняется и в которой $message не раскрывается.2

1. Посмотреть, что возвращает парсер

Сначала передадим $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

В $ast попал не строка и не результат выполнения скрипта, а объект типа ScriptBlockAst. Он представляет весь введённый код. Кроме возврата этого объекта, ParseInput через переменные, переданные с [ref], возвращает токены и ошибки разбора.1

AST — сокращение от «абстрактное синтаксическое дерево». Имя может навести на мысль о какой-то особой структуре данных, но с точки зрения PowerShell это прежде всего объект со свойствами и методами. Если идти по этим свойствам, мы дойдём до других объектов, которые представляют присваивания и вызовы команд.3

2. В какие объекты превратились три строки кода

В таком коде, где begin, process и end не указаны явно, обычные операторы находятся в EndBlock.Statements. Сопоставим тип и исходный код.4

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

Записей две. Строка с комментарием в этот список операторов не попадает. Если нужны сами комментарии, их можно получить из $tokens, который мы получили чуть раньше.3

Строка 2 стала AssignmentStatementAst, то есть присваиванием. Строка 3 — PipelineAst: даже без | она представлена как конвейер с одной командой. Extent.Text — исходный код, соответствующий объекту. Когда одного имени типа не хватает, по нему можно проверить, о каком фрагменте идёт речь.56

Соответствие исходного кода и синтаксических объектовВ EndBlock всего скрипта попадают присваивание из строки 2 и конвейер из строки 3, а в конвейере содержится вызов команды.ScriptBlockAstEndBlockПрисваивание: строка 2Конвейер: строка 3CommandAst

Рис. 1: Вызов из строки 3 находится внутри конвейера.

Индексы массива начинаются с 0, поэтому $statements[1] — это конвейер из строки 3. Из него возьмём первый элемент.

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

$call.GetType().Name
$call.Extent.Text
$call.GetCommandName()
CommandAst
Write-Host 'Привет'
Write-Host

CommandAst найден. И Extent.Text, возвращающий исходный код, и GetCommandName(), возвращающий имя вызова, применяются к одному и тому же объекту.7

Разбор с учётом аргументов находится в CommandElements.

$call.CommandElements | ForEach-Object {
    [pscustomobject]@{
        Type = $_.GetType().Name
        Text = $_.Extent.Text
    }
}
Type                        Text
----                        ----
StringConstantExpressionAst Write-Host
StringConstantExpressionAst 'Привет'

И имя, и аргумент — узлы, представляющие строку. И всё же GetCommandName() возвращает именно Write-Host. Он выбирает не по написанию строки, а смотрит на элемент, который внутри вызова команды играет роль имени. Строка 'Write-Host' из строки 2 находится в правой части присваивания и к этому вызову вообще не относится.78

В этом и разница между поиском по строкам и разбором синтаксиса. Даже для одного и того же слова можно различить назначение, если посмотреть на структуру, в которой оно находится.

3. Искать CommandAst вместо обхода по индексам

Содержимое мы проверили. Но в реальном скрипте нельзя заранее считать, что нужен «первый элемент второго оператора». Вызовы встречаются и внутри функций, и внутри if, и в середине конвейера.

Метод поиска для этого — 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 'Привет'

FindAll обходит синтаксическое дерево и передаёт каждый узел в проверку { ... }. Тип полученного $node проверяется через -is, и если это CommandAst, возвращается $true. Тогда узел остаётся в результатах. Последний $true — указание искать также внутри вложенных функций и блоков скрипта.9

Например, для function Show-Message { Write-Host 'hello' } он спускается внутрь функции и находит вызов. Запускать функцию не требуется.

Поиск по типу со спуском внутрь функцииПоиск спускается в тело функции, содержащейся в скрипте, и включает в результаты вызов, соответствующий CommandAst.СкриптОпределение функцииТело функцииCommandAstТип совпал: остаётся в результатах

Рис. 2: Узлы, подходящие под условие поиска, можно получить, не считая глубину вложенности вручную.

В трёх строках из начала статьи найден один вызов — тот же, к которому мы пришли по индексу. Если извлечь имя и позицию, результат можно использовать как результат поиска.

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

Строки и столбцы начинаются с 1. Имя и позицию несёт один и тот же узел, поэтому позже не нужно заново искать строку отдельным поиском по тексту.6

4. Как выглядит вызов через переменную

Здесь немного изменим объект анализа. Кроме случая, когда имя написано напрямую, добавим случаи, когда в оператор вызова & передаётся строка или переменная.

$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'

Строка 4 тоже найдена как CommandAst. Но возвращаемое значение GetCommandName()$null.

Человек, читающий эти четыре строки, может вывести значение $command из присваивания прямо над ними. Однако этот метод не проходит назад по присваиваниям переменной и не вычисляет значение. В отличие от случая, когда имя написано в коде напрямую, одного этого API, чтобы извлечь имя, недостаточно.7

Один и тот же узел вызова, но разные результаты получения имениДля вызова, имя которого задано строкой, удаётся получить Write-Host, а для вызова через переменную имя равно null, хотя позицию вызова несут оба.CommandAstЭлемент имени — строкаЭлемент имени — переменнаяИмя: Write-HostИмя: null

Рис. 3: Даже когда имя пустое, видно, что в строке 4 написан вызов.

В инструменте, который обходит файлы, это различие сохраняется в столбце NameKind. Строки, для которых строку имени получить удалось, — Static; строки, для которых не удалось, — Unresolved. Если оставить и строки без имени, мы не потеряем места, которые человеку следует проверить.

5. Извлечь позиции Write-Host из файла

Когда читается .ps1, а не строка, вместо ParseInput используется ParseFile. Способ чтения AST не меняется.10

Функция Get-ScriptCommand, собравшая всё сделанное выше, приведена в полном коде в конце статьи и в примере для скачивания. Сохраните полный код как Get-ScriptCommand.ps1, а четыре строки внутри here-string из раздела 4 — как demo.ps1 в той же папке. В примере для скачивания есть оба файла.

Запустим это в той же папке.

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

Так мы получили позиции вызовов, имя которых — Write-Host. Присваивание из строки 3 сюда не попадает. Проблема из начала статьи, когда комментарии и обычные строки смешиваются с результатами поиска, обойдена.

С другой стороны, строка 4 из этого фильтра выпадает. Не потому, что там нет вызова, а потому, что имя не определено. Неопределённые строки проверяются отдельно.

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

Static — это признак того, что строку имени получить удалось, а не гарантия того, что команда существует или что известно, какая реализация будет вызвана. Например, echo возвращается как echo, а Microsoft.PowerShell.Utility\Write-Host — как имя с квалификатором, поэтому ни то, ни другое в поиск по точному совпадению выше не попадает. Если в область проверки входят и псевдонимы, и вызовы с квалификатором, условие поиска нужно подстроить под это.11

Отметим: в текущую область видимости через точку был подключён сам инструмент анализа. demo.ps1 только читается через ParseFile и не запускается.

6. Результаты поиска — не история выполнения

Этот поиск проверяет, как что-то написано в коде. И в неиспользуемых функциях, и внутри if ($false) { Write-Host ... } вызовы тоже написаны, поэтому они попадают в список. Порядок и число выполнений по нему неизвестны.

Со строками обращение тоже различается: 'Write-Host' и "Today: $(Get-Date)" — не одно и то же. Во второй встроено выражение, поэтому Get-Date внутри неё находится. А код внутри обычной строки заново как отдельный скрипт не разбирается.2

Вызов метода .NET вроде [Console]::WriteLine(...) — узел другого типа, не CommandAst. Этот список не охватывает все операции и ничего не доказывает о безопасности. Это инструмент для проверки скриптов, которыми вы управляете сами. Чтобы убедиться в псевдонимах времени выполнения или в одноимённых функциях, нужна ещё и среда, в которой работает скрипт.1211

В центре использованного нами механизма — объекты, которые проверяются через GetType(), и Extent.Text. Когда захочется разобрать другой синтаксис, можно начать так же: передать короткий код в ParseInput и сопоставить эти два. Если цель — проверка качества по существующим правилам, лучше воспользоваться PSScriptAnalyzer.

Полный код для поиска по файлам

Ниже — вся функция Get-ScriptCommand из раздела 5. Часть, которая читает синтаксис, та же, что в разделе 3; вокруг неё добавлены получение файла, обработка ошибок разбора и приём нескольких файлов.

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

Файл берётся через Get-Item -LiteralPath, а затем его FullName передаётся в ParseFile. Имя вроде draft[1].ps1 не рассматривается как подстановочный знак. Папки и несуществующие файлы дают ошибку.

При ошибке разбора функция останавливается до вывода результатов по этому файлу. Частично возвращённый AST не считается успешным анализом. Смысл в том, чтобы разделить «прочитано корректно, найдено ноль» и «прочитать не удалось».

Возвращаемое значение не форматируется в таблицу: это объект с Path, Line, Column, NameKind и Name. Кроме фильтрации, как в разделе 5, результаты по нескольким файлам можно сохранить в CSV. Вход принимается и через свойство с именем FullName, поэтому объекты FileInfo, которые возвращает Get-ChildItem, можно передавать напрямую.

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

-NoClobber — это указание, запрещающее перезапись существующего CSV. Если файл в середине списка не удался, результаты по предыдущим файлам могли уже уйти в CSV. Не делайте вывод, что всё прошло успешно, только по факту создания файла; проверяйте и ошибки.

Грамматика разбора определяется версией PowerShell, в которой запущен инструмент. Успешный разбор в 7.x не гарантирует, что скрипт будет работать в 5.1. Если вы читаете в 5.1 файлы, содержащие японский текст, учитывайте и кодировку символов, например UTF-8 с BOM.13

Примеры и проверка

Инструмент анализа, примеры и тесты (ZIP) содержит готовую функцию, примеры для анализа и тесты Pester. Тело функции совпадает с приведённым здесь кодом, а в распространяемой версии добавлены комментарии справки. Если получить ZIP не удаётся, можно сохранить и использовать полный код выше.

Готовая функция и существующие 26 случаев Pester не менялись с предыдущей версии. При этом обновлении 12 блоков кода PowerShell, извлечённых из статьи, были выполнены в Windows PowerShell 5.1 и в PowerShell 7.x, а имена типов, исходный код, позиции вызовов и результаты фильтрации сверены между собой. Точные версии и объём проверки описаны в README примера для скачивания.

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 5, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

Похожие статьи

Ссылки

  1. Microsoft Learn, Parser.ParseInput Method. Об API, который возвращает AST из строки, а токены и ошибки разбора — через выходные аргументы.  2

  2. Microsoft Learn, about_Quoting_Rules. О here-string в одинарных кавычках и о подвыражениях в раскрываемых строках.  2

  3. Microsoft PowerShell Team, Using abstract syntax trees (ASTs) with ISE to make scripting more productive. О доступе к синтаксическому дереву из PowerShell и о поиске узлов, например определений функций.  2

  4. Microsoft Learn, NamedBlockAst Class. О блоках, имя которых не указано явно, и о Statements, который содержит операторы. 

  5. Microsoft Learn, PipelineAst.PipelineElements Property. Об элементах, из которых состоит конвейер. 

  6. Microsoft Learn, IScriptExtent Interface. О диапазоне в исходном коде, о начальной позиции и о том, что строки и столбцы начинаются с 1.  2

  7. Microsoft Learn, CommandAst.GetCommandName Method. О том, что для вызовов, имя которых нельзя получить статически, возвращается null.  2 3

  8. Microsoft Learn, CommandAst.CommandElements Property. О синтаксических элементах: имени вызова, аргументах и подобных. 

  9. Microsoft Learn, Ast.FindAll Method. Об обходе узлов, подходящих под условие, и об указании искать внутри вложенных функций и блоков скрипта. 

  10. Microsoft Learn, Parser.ParseFile Method. Об API, который разбирает файл и возвращает AST, токены и ошибки разбора. 

  11. Microsoft Learn, about_Command_Precedence. О приоритете во время выполнения между одноимёнными командами, псевдонимами, функциями и подобными объектами.  2

  12. Microsoft Learn, InvokeMemberExpressionAst Constructors. Об узлах, представляющих вызовы методов экземпляра и статических методов. 

  13. Microsoft Learn, about_Character_Encoding. О том, как Windows PowerShell читает скрипты, и об обработке UTF-8 BOM. 

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Что такое AST в PowerShell?
Это абстрактное синтаксическое дерево, в котором код представлен узлами по каждому синтаксическому элементу: присваиванию, вызову команды, определению функции. Оно позволяет отличить те же символы, написанные внутри комментария или строки, от тех же символов, написанных как имя вызова.
Нужно ли запускать анализируемый файл .ps1?
Инструмент из этой статьи читает файлы через Parser.ParseFile: он не запускает цель и не выполняет её в текущей области видимости. При этом это не песочница, гарантирующая безопасность, а инструмент для инвентаризации скриптов, которыми вы управляете сами. И отсутствие найденных вызовов тоже не доказывает безопасность.
Можно ли узнать имя команды, которая вызывается через переменную?
Когда GetCommandName не может получить имя статически, возвращается null. Инструмент из этой статьи не отбрасывает такую строку, а оставляет её с NameKind = Unresolved. Он не отслеживает присваивания переменных и не вычисляет выражения, чтобы угадать имя.
Работает ли это в Windows PowerShell 5.1?
Приведённый инструмент рассчитан на 5.1 и на ветку 7.x. Но грамматика, по которой выполняется разбор, — это грамматика той версии PowerShell, в которой запущен инструмент. Успешный разбор в 7.x не доказывает совместимость с 5.1. Если вы читаете в 5.1 файлы, содержащие японский текст, учитывайте и кодировку символов, например UTF-8 с BOM.
Как разделить задачи этого инструмента и PSScriptAnalyzer?
Инструмент из этой статьи нужен, чтобы свести в список имена и позиции вызовов. Когда требуется проверить качество по существующим правилам и управлять предупреждениями, используется PSScriptAnalyzer. Показанный здесь способ чтения AST — точка входа в понимание результатов статического анализа и устройства собственных правил.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог