Protecting PowerShell Script Quality with PSScriptAnalyzer — Rule Selection and CI Integration
· Go Komura · PowerShell, Static Analysis, CI/CD, GitHub Actions, Quality Management, Maintainability, Operational Improvement, Scripting
Once the number of PowerShell scripts inside a company starts to grow, you inevitably run into the problem of inconsistent quality. Scripts so full of aliases they cannot be read. Scripts with plain-text passwords in them. Scripts where a mistyped variable name has sat unnoticed by anyone. The problem surfaces in a particular shape: once the person who wrote it has moved on, whoever has to read it is in trouble.
A substantial share of these problems can be detected mechanically by a static analysis tool. PowerShell has an official static analysis module, PSScriptAnalyzer, which analyses a body of scripts with a single command. Its biggest advantage is that it delivers results from day one without you writing a single line of test code.
This article sets out the practical steps for introducing PSScriptAnalyzer to your in-house script assets, in the order of “the rules to enable first”, “phased rollout across existing assets”, and “automated checking in CI”. For quality assurance through testing, see “Testing PowerShell with Pester” alongside this.
1. The Bottom Line First
- PSScriptAnalyzer is PowerShell’s official static analysis module.
Invoke-ScriptAnalyzeranalyses scripts and modules and reports rule violations.1 - Findings carry a severity. There are three levels — Error / Warning / Information — and driving Error alone to zero is the realistic starting point.1
- Consolidate configuration in
PSScriptAnalyzerSettings.psd1. PutSeverity,IncludeRules,ExcludeRulesandRulesin the repository so that everyone analyses against the same baseline.2 - Individual suppression means
SuppressMessageAttributeplus a written reason. Before excluding a whole rule, consider whether a narrowly scoped suppression is enough.3 - Some findings can be fixed automatically with
-Fix. Formatting is handled byInvoke-Formatter.14 - The PowerShell extension for VS Code has PSScriptAnalyzer built in. Warnings appear as you edit, so it takes effect earlier than CI does.5
- Make the CI failure condition “severity Error plus named critical rules”. Severity is fixed per rule, and detection of plain-text passwords (
PSAvoidUsingPlainTextForPassword) is a Warning. If you condition only on Error, it slips straight through.6 - Roll out to existing assets in phases. The order is “drive Error to zero” → “strict on changed files only” → “widen the scope”.
2. Getting Started — One Command to Begin
Install-Module -Name PSScriptAnalyzer -Scope CurrentUser
# Analyse everything under a folder in one go
Invoke-ScriptAnalyzer -Path 'D:\Scripts' -Recurse |
Sort-Object Severity, RuleName |
Format-Table Severity, RuleName, ScriptName, Line, Message -AutoSize
# Get the counts by severity (the first step of taking inventory)
Invoke-ScriptAnalyzer -Path 'D:\Scripts' -Recurse |
Group-Object Severity | Select-Object Name, Count
Start by running these two and getting a numerical picture of the state of your own assets. There is no need to be alarmed if hundreds of findings come back. That is how it starts at most sites.
You can review the list of available rules and their descriptions with Get-ScriptAnalyzerRule.1
Get-ScriptAnalyzerRule | Select-Object Severity, RuleName, CommonName | Sort-Object Severity
Get-ScriptAnalyzerRule -Name PSAvoidUsingWriteHost | Format-List * # description of an individual rule
3. The Findings That Pay Off First — Practical Priorities
Of the dozens of rules available, here are the ones that bear most directly on the quality of in-house scripts, in priority order.
| Rule | What it detects | Why it matters |
|---|---|---|
PSAvoidUsingPlainTextForPassword |
A parameter accepting a plain-text password | Holding credentials in plain text gets flagged in audits too (severity is Warning)6 |
PSAvoidUsingConvertToSecureStringWithPlainText |
Building a SecureString from plain text | Same root cause as above. It defeats the point of the encryption |
PSUseDeclaredVarsMoreThanAssignments |
Variables that are assigned but never used | Catches typos in variable names. Effectively bug detection |
PSAvoidUsingInvokeExpression |
Use of Invoke-Expression |
Executing a string as code is a breeding ground for injection |
PSUseShouldProcessForStateChangingFunctions |
State-changing functions with no -WhatIf |
Detects designs where dangerous operations cannot be checked in advance |
PSAvoidUsingCmdletAliases |
Aliases such as ls, %, ? |
Handy interactively, but harmful to readability in a script |
PSUseApprovedVerbs |
Function names using unapproved verbs | If it does not follow conventions such as Get-/Set-, it is hard to discover |
PSAvoidGlobalVars |
Use of global variables | Side effects become unreadable. You cannot write tests either |
PSUseSingularNouns |
Plural nouns (Get-Users and similar) |
PowerShell’s naming convention. Give things names other people can guess |
PSUseDeclaredVarsMoreThanAssignments in particular offers a very good cost-benefit ratio. When you meant to assign to $fileName but reference $fileNmae further down, it picks that up as “a variable that was assigned but never used”, so it genuinely catches bugs that only static analysis will find. (Note that PowerShell variable names are case-insensitive, so $fileName and $filename are the same variable. What this kind of detection catches is cases where the spelling itself differs.)
4. Fixing the Team Baseline in a Settings File
There is no point if everyone analyses against a different baseline. Put PSScriptAnalyzerSettings.psd1 in the repository so that every person and CI uses the same settings.2
# PSScriptAnalyzerSettings.psd1
@{
# Use the default rule set
IncludeDefaultRules = $true
# In the first phase of the rollout, limit to Error and Warning
Severity = @('Error', 'Warning')
# Rules we are deferring as company policy (leave the reason in a comment)
ExcludeRules = @(
'PSAvoidUsingWriteHost' # we have many interactive tools; tolerated for now
'PSUseSingularNouns' # we cannot rename all existing functions at once
)
# Per-rule detailed settings
Rules = @{
PSUseCompatibleSyntax = @{
# Check the set of scripts that must run on both 5.1 and 7.
# TargetVersions only accepts versions for which the rule has a syntax
# definition (check with Get-ScriptAnalyzerRule). Writing an unsupported
# value causes an error when the settings are loaded, so take care
Enable = $true
TargetVersions = @('5.1', '7.0')
}
PSPlaceOpenBrace = @{
Enable = $true
OnSameLine = $true
NewLineAfter = $true
IgnoreOneLineBlock = $true
}
PSUseConsistentIndentation = @{
Enable = $true
IndentationSize = 4
Kind = 'space'
}
}
}
Invoke-ScriptAnalyzer -Path . -Recurse -Settings .\PSScriptAnalyzerSettings.psd1
PSUseCompatibleSyntax is especially useful in environments where 5.1 and 7 coexist. It detects, before execution, the accident of writing 7-only syntax (the ternary operator, pipeline chain operators and so on) into a script intended for 5.1. For migration policy itself, see “The Differences Between Windows PowerShell 5.1 and PowerShell 7”.
5. Leave Exceptions in Place, With a Reason
Where you genuinely cannot comply with a finding, suppress it in that one place rather than disabling the whole rule.3
function Show-KsBanner {
# Write-Host is used deliberately, for decorative display in an interactive tool
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSAvoidUsingWriteHost', '',
Justification = 'Display function for interactive use only; by design it returns no value')]
[CmdletBinding()]
param([string] $Title)
Write-Host ('=' * 60) -ForegroundColor Cyan
Write-Host $Title -ForegroundColor Cyan
}
The key point is always writing the Justification. A suppression with no reason is indistinguishable, to the next person reading, from someone who simply silenced a warning. This is a sort of ADR (decision record) embedded in the code, and the thinking carries over from “Using ADRs (Architecture Decision Records) on a Small Team”.
6. Phased Rollout Across Existing Assets
Faced with hundreds of warnings, if you decide to “fix everything and then adopt it”, you will stall before you begin. Split it into phases.
Phase 1: stop the bleeding (one day)
Put only Severity = 'Error' into CI and drive that to zero. What needs care here is that severity is fixed per rule and does not always match intuition. For example, the severity of PSAvoidUsingPlainTextForPassword is Warning, so if you make Error the only failure condition it will not be caught.6 For rules you want to fail on regardless of severity, such as those around credentials, add them to the failure condition by rule name as follows.
# Make the CI failure condition: severity Error + individually named critical rules
$mustFix = @(
'PSAvoidUsingPlainTextForPassword'
'PSAvoidUsingConvertToSecureStringWithPlainText'
'PSAvoidUsingUsernameAndPasswordParams'
)
$blocking = $issues | Where-Object { $_.Severity -eq 'Error' -or $_.RuleName -in $mustFix }
Phase 2: protect new and changed code (one week) Analyse only the files that changed. The existing debt can stay as it is, and you stop new problems from accumulating.
When you run this in CI, the commit you compare against must already have been fetched. actions/checkout fetches only a single commit by default, so specify fetch-depth: 0 or fetch the base branch explicitly (without it, you get a failure with unknown revision).
One more point: do not hard-code main as the comparison target. git diff A...HEAD means “the diff from the common ancestor of A and HEAD”, so if you use origin/main...HEAD on a PR targeting develop or a release branch, changes that PR never touched end up in scope and CI fails on pre-existing findings in unrelated files. In GitHub Actions the PR’s target branch is available in GITHUB_BASE_REF, so use that.7
Further, make sure you always detect a git failure. By default PowerShell does not turn a non-zero exit code from an external command into a terminating error.8 As a result, when the base ref has not been fetched, git diff fails and simply produces empty output; the code that follows interprets that as “no changed files”, and CI goes green without analysing a single file. That is the most dangerous way a diff-based check can break. Check $LASTEXITCODE immediately after the call and fail explicitly (on PowerShell 7.3 and later, you can also set $PSNativeCommandUseErrorActionPreference = $true).8
- uses: actions/checkout@v4
with:
fetch-depth: 0 # history is required in order to take a diff
# Analyse only the changed ps1/psm1 files (diff-based check in CI)
# Do not hard-code main as the comparison target. On a PR targeting develop or a
# release branch, the diff against main includes unrelated changes and you fail on
# files you never touched.
# The PR's target branch is available from GITHUB_BASE_REF (empty on push)
$base = if ($env:GITHUB_BASE_REF) { "origin/$($env:GITHUB_BASE_REF)" } else { 'origin/main' }
# Without -c core.quotePath=false, paths containing non-ASCII characters come back
# quoted with octal escapes, like "scripts/\346...", and slip past the extension test
$diff = git -c core.quotePath=false diff --name-only "$base...HEAD"
# A native command failure is not a terminating error by default. If the base ref has
# not been fetched, git fails, the output is empty, and it sails through as
# "no changes = nothing to analyse = pass".
# Inspect $LASTEXITCODE immediately afterwards and fail explicitly
if ($LASTEXITCODE -ne 0) {
throw "git diff failed (exit $LASTEXITCODE). The base branch $base may not have been fetched"
}
$changed = $diff |
Where-Object { $_ -match '\.ps(m|d)?1$' } | # target .ps1 / .psm1 / .psd1
Where-Object { Test-Path $_ }
# -Path is a parameter that takes a single path, so passing an array straight in
# fails at parameter binding. Analyse one file at a time and aggregate the results
$issues = foreach ($file in $changed) {
Invoke-ScriptAnalyzer -Path $file -Settings .\PSScriptAnalyzerSettings.psd1
}
Phase 3: widen the scope (ongoing)
Remove entries from ExcludeRules one at a time, tightening as far as you have dealt with. Fix existing files as refactoring opportunities arise, and work the debt down.
Findings that can be fixed automatically can be handled in bulk with -Fix (always review the diff before applying).1 For formatting alone, Invoke-Formatter is available.4
Invoke-ScriptAnalyzer -Path .\Scripts -Recurse -Fix -Settings .\PSScriptAnalyzerSettings.psd1
git diff # always eyeball what changed
7. Automating It in CI
With GitHub Actions it is a few lines on a Windows runner. The key is to fail on Error and merely display Warnings.
name: powershell-lint
on:
pull_request:
paths: ['**/*.ps1', '**/*.psm1', '**/*.psd1']
jobs:
analyze:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required if you switch to diff-based analysis (Section 6)
- name: Install PSScriptAnalyzer
shell: pwsh
run: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module PSScriptAnalyzer -Scope CurrentUser -Force
- name: Analyze
shell: pwsh
run: |
$issues = Invoke-ScriptAnalyzer -Path . -Recurse `
-Settings ./PSScriptAnalyzerSettings.psd1
# Log everything (so the warnings are visible too)
$issues | Sort-Object Severity, ScriptName, Line |
Format-Table Severity, RuleName, ScriptName, Line, Message -AutoSize |
Out-String -Width 200 | Write-Host
# Failure condition = severity Error + rules we do not tolerate at any severity
$mustFix = @(
'PSAvoidUsingPlainTextForPassword'
'PSAvoidUsingConvertToSecureStringWithPlainText'
'PSAvoidUsingUsernameAndPasswordParams'
)
$blocking = @($issues | Where-Object { $_.Severity -eq 'Error' -or $_.RuleName -in $mustFix })
$warns = @($issues | Where-Object Severity -eq 'Warning')
Write-Host "Blocking: $($blocking.Count) / Warning: $($warns.Count)"
# Once the rollout has progressed, add Warning to the condition as well
if ($blocking.Count -gt 0) {
throw "$($blocking.Count) finding(s) must be fixed"
}
If you put this in the same workflow as your Pester tests, you get the flow “lint passes → tests pass → it can be merged”. For how to build CI/CD for Windows applications generally, see “CI/CD for WinForms / WPF Apps in Practice”.
Even in environments with no CI, running Invoke-ScriptAnalyzer monthly and keeping the results in a CSV is enough to make the state of your assets adequately visible.
Invoke-ScriptAnalyzer -Path '\\fileserver\scripts' -Recurse |
Select-Object Severity, RuleName, ScriptName, Line, Message |
Export-Csv "D:\Inventory\lint_$(Get-Date -f yyyyMM).csv" -Encoding utf8BOM -NoTypeInformation
8. Practical Rules of Thumb (Decision Table)
| Question | Options | Guidance |
|---|---|---|
| Order of adoption | Pester first / PSScriptAnalyzer first | Static analysis works from day one without writing tests |
| Initial scope | All rules / Severity=Error + credential rules named explicitly | If you condition on everything, nobody passes. Note that severity is fixed per rule6 |
| A large backlog of existing warnings | Fix them all / Strict on changed files only | Stop the growth first, then reduce as opportunities arise |
| Individual exceptions | ExcludeRules / SuppressMessageAttribute + Justification |
Minimise the blast radius. Always leave the reason3 |
| Sharing the configuration | Everyone’s own settings / A .psd1 in the repository |
Align the baseline between CI and developers2 |
| A mix of 5.1 and 7 | Run it and see / PSUseCompatibleSyntax |
Detect syntax-level incompatibility before execution |
| Automatic fixes | By hand / -Fix + review the diff |
Always look at git diff after applying1 |
| Feedback while editing | CI only / The VS Code extension | Being able to fix it on the spot is the cheapest option5 |
9. Summary
- PSScriptAnalyzer is the official static analysis module. You can adopt it without writing tests, and it delivers results from day one.
- The realistic rollout is phased: drive Error to zero first, then check only changed files strictly. Because severity is fixed per rule, rules you want to fail on — such as plain-text passwords (Warning) — must be added to the failure condition by rule name.
- Some rules, such as
PSUseDeclaredVarsMoreThanAssignments, catch real bugs in the form of mistyped variable names. - Consolidate the configuration in
PSScriptAnalyzerSettings.psd1and keep it in the repository, so that developers and CI share a baseline. - Record exceptions with a reason written into
SuppressMessageAttribute. Excluding a whole rule is the last resort. - In CI, fail on Error and make Warnings visible. Put it in the same workflow as Pester and the quality gate lives in one place.
Download the Sample Code
The code covered in this article is distributed as a package you can run as-is. It contains the settings file, the CI pass/fail script, and the GitHub Actions example.
Download the sample code (zip)
The samples for this article have been actually executed and verified on PowerShell 7.6 (14 Pester tests). Run the Invoke-SampleTests.ps1 included in the zip to reproduce the same verification on your own machine.
# Syntax parsing + static analysis + Pester tests
./Invoke-SampleTests.ps1
The configuration values (paths, server names, tenant IDs and so on) are examples. Do not run them against production as-is — adapt them to your own environment.
Related Articles
- Testing PowerShell with Pester — A Practical Approach to Making Operations Scripts Harder to Break
- Parameter Design and Modularisation in PowerShell — From “a Script That Works” to “a Script You Can Hand Over”
- Handling Credentials Safely in PowerShell — Banishing Plain-Text Passwords From Your Scripts
- The Differences Between Windows PowerShell 5.1 and PowerShell 7 — A Practical Guide to Migrating In-House Scripts
- CI/CD for WinForms / WPF Apps in Practice — Automating from Build to Signing and Distribution with GitHub Actions
- An Introduction to ADRs (Architecture Decision Records) — The Minimal Way to Record ‘Why We Designed It This Way’ on a Small Team
Related Consulting Areas
KomuraSoft LLC handles taking inventory of in-house script assets and establishing quality baselines, introducing static analysis and testing into CI, and improving the maintainability of operations scripts that have become dependent on one person.
- Technical Consulting & Design Review
- Migration & Reuse of Existing Assets
- Maintenance & Modernization of Existing Windows Software
- Contact Us
References
-
Microsoft Learn, PSScriptAnalyzer module overview. On PSScriptAnalyzer being a static analysis tool for PowerShell scripts and modules; analysis via Invoke-ScriptAnalyzer and parameters such as -Path / -Recurse / -Settings / -Fix / -ExcludeRule; retrieving the rule list with Get-ScriptAnalyzerRule; and diagnostic results carrying a severity (Error / Warning / Information). ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, PSScriptAnalyzer settings file. On being able to specify Severity, IncludeRules, ExcludeRules, IncludeDefaultRules and Rules in a settings file (.psd1); passing a settings file with the -Settings parameter; and per-rule detailed settings (TargetVersions for PSUseCompatibleSyntax, and options for the formatting rules). ↩ ↩2 ↩3
-
Microsoft Learn, Suppressing rules in PSScriptAnalyzer. On being able to suppress diagnostics per rule and per target using System.Diagnostics.CodeAnalysis.SuppressMessageAttribute, and on its RuleName, Target and Justification arguments. ↩ ↩2 ↩3
-
Microsoft Learn, Invoke-Formatter. On formatting script text according to settings, and on being able to specify the formatting rules (indentation, position of the opening brace, handling of whitespace and so on) in a settings file. ↩ ↩2
-
Microsoft Learn, Using Visual Studio Code for PowerShell development. On the PowerShell extension using PSScriptAnalyzer to display warnings while editing, and on it providing formatting functionality. ↩ ↩2
-
Microsoft Learn, AvoidUsingPlainTextForPassword. On passwords and secrets not being accepted via plain-text string parameters but via SecureString or PSCredential, and on this rule’s severity level being Warning and it being enabled at all times. See also the related rule AvoidUsingConvertToSecureStringWithPlainText (on secrets not being protected when a SecureString is generated from plain text). ↩ ↩2 ↩3 ↩4
-
GitHub Docs, Variables reference — Default environment variables. On GITHUB_BASE_REF containing the PR’s target branch name for pull_request events (and being empty for other events). For the meaning of the three-dot notation (the diff from the merge base of the two named refs), see Git’s official git diff. ↩
-
Microsoft Learn, about_Preference_Variables — $PSNativeCommandUseErrorActionPreference. On non-zero exit codes from native commands not becoming terminating errors by default; on setting this preference (introduced in PowerShell 7.3) to $true causing them to become terminating errors in accordance with $ErrorActionPreference; and on retrieving the exit code of the most recent external command via $LASTEXITCODE. ↩ ↩2
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Distributing and Updating PowerShell Modules In-House — PSResourceGet and an Internal Repository
How to move on from copying and reusing ps1 files in a shared folder. Covers how to write a module manifest, versioning, building an inte...
Stop Using Write-Host — PowerShell Output Streams and Log Design
How to choose between PowerShell's six output streams, the problems with Write-Host and where it genuinely belongs, why function return v...
Automating PC Provisioning With winget + PowerShell — Making the Runbook Executable
How to make new-hire PC setup reproducible. Covers installing applications with winget and export/import, declarative configuration with ...
Hardening PowerShell — Logging, AMSI, Language Modes, and JEA
A practical guide to using PowerShell safely instead of banning it. Covers enabling script block logging and transcription, AMSI and disa...
Investigating Event Logs in Practice with Get-WinEvent — Filtering Speed Decides How Long the Investigation Takes
How to make Windows event log investigation efficient with PowerShell. Covers why filtering with Where-Object is slow, when to use Filter...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Frequently Asked Questions
Common questions about the topic of this article.
- I ran PSScriptAnalyzer against our existing scripts and got hundreds of warnings. Where should I start?
- Do not try to fix everything at once. The practical approach is to target only the findings with severity Error first, and drive those to zero. Note that severity is fixed per rule — for example, PSAvoidUsingPlainTextForPassword, which detects plain-text passwords, is a Warning. If there are rules you want to fail the build on regardless of severity, such as the credential-related ones, add them to the CI failure condition by rule name explicitly. Next, add a rule to CI that analyses only the files you are about to change, which stops new problems from appearing. For the existing warnings, decide that you will tolerate them for now, exclude them in the settings file, and reduce them one at a time as you refactor. That is the realistic path.
- I want to suppress a warning in one specific place. How do I do that?
- Attach a SuppressMessageAttribute to that function or script. Specify the rule name on System.Diagnostics.CodeAnalysis.SuppressMessageAttribute and write the reason in Justification. Writing the reason matters, because it lets whoever reads the code later judge why this is an exception. If you want to disable a rule entirely, you put it in ExcludeRules in the settings file, but that has a much wider blast radius, so consider whether a targeted suppression is enough first.
- I get a warning when I use Write-Host. Am I not allowed to use it?
- PSAvoidUsingWriteHost is a design-level observation: if you use Write-Host where you should be returning a value, the output cannot be captured. If the purpose is decorative display in an interactive tool, suppressing it with a SuppressMessageAttribute and a written reason is entirely reasonable. On the other hand, if an unattended script uses nothing but Write-Host, the warning is worth acting on. Do not follow rules mechanically — understand the intent behind the finding and decide.
- Which should we introduce first, Pester or PSScriptAnalyzer?
- Introducing PSScriptAnalyzer first gives you more return for the cost. You can analyse every script with a single command without writing a line of test code, and you see results on day one. Pester takes longer to get going because you have to write the tests, but tests are the only thing that protects the correctness of your logic. As an order of operations, we recommend putting static analysis into CI first to stop the obvious problems, then adding Pester tests starting with the processing you would most regret breaking.
- Is there any point in adopting this on a small team with no CI server?
- Yes. Even without Git or CI, simply running Invoke-ScriptAnalyzer -Path . -Recurse against the set of scripts in your shared folder amounts to an inventory. Exporting the results to CSV and reviewing how many severity Error findings there are each month is enough to make the state of your assets visible. On top of that, the PowerShell extension for VS Code has PSScriptAnalyzer built in, so warnings appear as you edit. That alone steadily improves coding habits.
Author Profile
Profile page for the article author.
Go Komura
Representative of KomuraSoft LLC
Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.
Public links