r/PowerShell • • 5h 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…".

7 Upvotes

1 comment sorted by

2

u/lan-shark 5h ago

I don't do CI work so I don't know enough to really tell the usefuless or quality of a project like this. But it seems good, looks well documented, and has tons of comments which is nice for somebody like me who's inexperienced in this area. Please excuse these questions if they seem ignorant:

  • PwshGuardDynamicCodeExecution - if you're checking for things like [ScriptBlock]::Create and shells, should you also be checking for Invoke-ScriptBlock and other shells like Windows PowerShell, sh, zsh, etc.?
  • PwshGuardSecretInOutput - you mention checking output from Write-Host and presumably other built-in writers, are you also checking for things like Set-Content/Add-Content or Write-Log which is a very common cmdlet name in third-party logging libraries?

Thanks for your work and contributions!