We build a catalogue of Windows and Android software, and part of the pipeline checks the Authenticode signature on every binary we ingest. It works by shelling out to Get-AuthenticodeSignature and mapping the result onto our own verdict enum.
On windows-latest, every single binary came back INVALID. Signed ones, deliberately tampered ones, and files that were not executables at all. The whole job finished in 4.3 seconds.
Four of the five self-test cases failed loudly. The fifth passed. That fifth one is the interesting part, and it is the reason this took three CI round trips instead of one.
The cause: two PowerShells, one module path
GitHub's Windows runners execute every step under PowerShell 7:
shell: C:\Program Files\PowerShell\7\pwsh.EXE -command ". '{0}'"`
PowerShell 7's PSModulePath begins with C:\Program Files\PowerShell\7\Modules, and that directory contains its own copy of Microsoft.PowerShell.Security.
Our script spawns powershell.exe — Windows PowerShell 5.1, because that is where the Authenticode cmdlets have always lived. The child inherits the parent's environment, including PSModulePath. It searches that path first, finds PowerShell 7's Microsoft.PowerShell.Security manifest, and cannot load it, because a 7.x module is not loadable by 5.1:
The 'Get-AuthenticodeSignature' command was found in the module
'Microsoft.PowerShell.Security', but the module could not be loaded.
The cmdlet never resolved. Not once, on any file.
Why it produced INVALID rather than an error
Our verdict mapping fails closed, which is normally correct — an unverifiable binary should never be treated as verified. But every branch fails closed, so an unresolved cmdlet became INVALID for every input.
That distinction matters more than it sounds:
- A verifier that rejects everything is strict.
- A verifier that cannot run rejects everything too. From the outside these are identical. Both produce a wall of INVALID. Only one of them is doing any work, and ours was the other one — it would have failed closed on every real release, forever, while looking like conservative security policy.
What made it worse: Microsoft Defender checks in the same job passed fine. Defender goes through MpCmdRun.exe and Get-MpThreatDetection, a different module that loads without issue. So the scan cases were green and only the signature cases were red, which made the failure look narrow and specific when it was total.
The fix
Strip PSModulePath from the child environment entirely:
function windowsPowerShellEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.toLowerCase() === 'psmodulepath') continue;
env[key] = value;
}
return env;
}
Two deliberate details.
Unset it, don't pin it. The tempting fix is to set PSModulePath to 5.1's own module directory. Don't — that hard-codes a layout a future runner image can move. If the variable is absent, PowerShell computes its own default for whichever version is being launched, and the fix keeps working.
Every casing. Windows environment variable names are case-insensitive, but Object.entries(process.env) in Node gives you whatever casing the parent used. Comparing against the exact string 'PSModulePath' will miss PSModulePATH.
Reproducing it locally
This one is genuinely awkward to reproduce, because the obvious approach does not work. Setting PSModulePath to a nonexistent directory does not trigger it — 5.1 finds nothing there and falls back to its built-in module location, which works fine.
You have to make it find something it cannot load. Place a stub manifest named Microsoft.PowerShell.Security.psd1 declaring a version floor 5.1 cannot satisfy:
@{
ModuleVersion = '7.0.0.0'
PowerShellVersion = '7.0'
GUID = 'a94c8c7e-9810-47c0-b8af-65089c13a35a'
CmdletsToExport = @('Get-AuthenticodeSignature')
}
Put its directory ahead of the system modules in PSModulePath, and you get the exact error message above. Unset the variable and the same file returns Valid.
The test that passed for the wrong reason
Case 3 of our self-test takes a text file, renames it .exe, and asserts the verifier does not call it valid. It passed throughout.
Of course it did. "Status is not VALID" is satisfied perfectly well by a tool that never started. The assertion was true for a reason that had nothing to do with the behaviour it was supposed to be testing — and because it passed, the failure looked narrower than it was.
If you assert that something was rejected, also assert that the check ran. Bare negatives — "status is not VALID", "no results returned", "exit code is non-zero" — are all satisfiable by a crash.
Finding the field that distinguishes ran and said no from never ran took some care. The status code itself could not do it: our PowerShell wrapper initialises psStatus to UnknownError before calling out, and UnknownError is also a real answer Windows gives for a malformed file. Same value, two opposite meanings.
The discriminator was the accompanying message:
function signatureCheckRan(verified) {
const raw = verified.raw;
if (raw?.ok === false || !raw?.signature) return false;
const sig = raw.signature;
if (sig.error) return false;
return sig.psStatus !== 'UnknownError' || Boolean(sig.statusMessage);
}
A completed call has either a status other than UnknownError, or UnknownError carrying Windows' own explanatory message. A call that threw has the untouched placeholder, no message, and an exception string. Now case 3 asserts both that the file was rejected and that something was there to reject it.
A second trap in the same PR: catalog vs embedded signing
Worth knowing if you write tamper-detection tests. Windows OS binaries — notepad.exe, cmd.exe, powershell.exe — are catalog-signed, not embedded-signed. Catalog entries are keyed by the original file's hash, so a byte-identical copy verifies Valid and your happy-path test passes. But a tampered copy is in no catalog and comes back NotSigned, never HashMismatch. Your tamper test then fails, and it reads exactly like broken tamper detection.
Pick a reference binary by reading embedded-ness out of the bytes rather than asking PowerShell. The certificate table is data directory 4 of the PE optional header; if its offset and size are both non-zero, the signature is embedded. Measured on one dev box: notepad gives offset 0, size 0 — catalog-signed. node gives offset 92263424, size 15688 — embedded. Git for Windows is the reliable choice on a runner, since actions/checkout cannot run without it and its binaries are embedded-signed.
One more: our PowerShell wrapper writes its JSON output beside the target file. Run it against something under C:\Program Files and you get a hard failure from an Access-Denied write that has nothing to do with the signature. Copy to a temp directory and verify the copy.
Takeaways
- Any powershell.exe spawned from a pwsh parent needs PSModulePath stripped. On GitHub's Windows runners, that is every step by default.
- A verifier where every branch fails closed can't tell you it broke. Make "couldn't check" a distinct outcome from "checked and failed".
- Assert that the check executed, not just that it returned the answer you wanted.
- Two green cases in a red suite are worth more attention than the red ones — they may be green vacuously. We hit all of this building the ingestion pipeline for FileCobra, where the same checks run over every binary in the catalogue. If you are doing Authenticode verification in CI, the PowerShell version mismatch will find you eventually — it is not specific to our setup.
Top comments (0)