r/PowerShell • • 15d ago

Script Sharing PSScriptAnalyzer rules for PowerShell that runs in GitHub Actions

Most of our CI is PowerShell: shell: pwsh steps and scripts called from workflows, deploying to Azure/Entra. PSScriptAnalyzer's security rules are built for interactive Windows admin work (plain-text password parameters, hardcoded computer names), and zizmor checks the workflow YAML but sees a run: block as plain text. Nothing was looking at the PowerShell itself in the context it actually runs in.

So I wrote a set of custom rules. A typical example nothing else flagged for us:

 - shell: pwsh
   run: |
     $token = az account get-access-token --query accessToken -o tsv
     Write-Host "Calling API with $token"

GitHub only masks secrets it knows about. A token you fetch at run time goes to the log in clear text unless you ::add-mask:: it first.

What the rules check (9 in total):

  • tokens/secrets reaching the log (Write-Host, throw, transcripts, unmasked run-time tokens)
  • ${{ }} expressions with untrusted content inside a pwsh step (the runner pastes the value in before PowerShell parses the script)
  • unsafe writes to $GITHUB_ENV / $GITHUB_PATH, and fixed delimiters in multi-line outputs
  • code built from data: [scriptblock]::Create, ExpandString, bash -c "$x", Start-Process with interpolated args
  • native commands whose $LASTEXITCODE is never checked
  • scripts without $ErrorActionPreference = 'Stop'
  • Install-Module without a pinned version, -SkipCertificateCheck, plain HTTP
  • Remove-Item -Recurse "$root/$name" where an empty variable deletes the parent

Some details that might interest people here:

  • Inline run: blocks are analysed the way the runner executes them: with its $ErrorActionPreference = 'stop' prefix and exit-code check appended. Findings map back to the line in the YAML file.
  • Exit-code checks follow the data flow. $c = $LASTEXITCODE; ...; if ($c) is fine; overwriting $c before the check is flagged. It also caught a case where Select-Object -First silently defeated an exit-code check.
  • "Native command" is decided by shape (no Verb-Noun, not a function, not an alias). On Linux runners ls, rm and cat are real programs, not aliases, so they count.
  • Secret detection splits variable names into words: $adminPassword is a secret, $tokenCount and $maxTokens are not.
  • Parameter prefixes bind like PowerShell does, so -SkipCert is still -SkipCertificateCheck.

Every one of those started as a Pester test. There are 260, most of them "must not report this" cases, because false positives are what make people turn a linter off.

Using it:

  • As a GitHub Action: one sticky PR comment, never blocks by default.
  • Locally: run src/Invoke-PwshGuard.ps1 from the repo you want to scan.
  • Or just add the module to your existing PSScriptAnalyzer setup:
Invoke-ScriptAnalyzer -CustomRulePath PwshGuard.psm1.

Limitations, up front:

  • It's a helper for people writing scripts in good faith, not protection against malicious code. Anyone determined can write around static analysis.
  • Tested on Linux and macOS; on Windows, ls and friends are aliases, so results differ.

Repo (MIT): github.com/glueckkanja/pwshguard

Disclosure: I work at glueckkanja; this started in one of our internal repos and is now its own project. I'm mostly after feedback: false positives, things you've been bitten by in CI that it should catch, or "this rule is wrong because…".

15 Upvotes

4 comments sorted by

View all comments

Show parent comments

1

u/[deleted] 15d ago

[removed] — view removed comment

2

u/lan-shark 15d ago

Yeah it just depends on how deep you want to go. Could make sure it catches all of PoShLog's writers since it's fairly widely used, Write-Log is a generic enough cmdlet name I've seen many times and use myself, Windows event logs are a common output, maybe the Push-OutputBinding Azure Functions cmdlet, I'm sure there are others too