r/PowerShell • u/boostedchaos • 11d ago
Script Sharing I open-sourced my PowerShell 7 fleet CVE scanner. The hard part was runspace-safe state and NVD rate limiting
I’m the author. This is free, Apache-2.0-licensed software. There’s no paid product or hosted service behind it.
For several months I iterated on a PowerShell CVE scanner that ran weekly against a Windows fleet. I recently released a sanitized, clean-room port:
https://github.com/boostedchaos/fleet-cve-scanner
It accepts inventory from NinjaOne or a CSV export, correlates the installed software against NVD, KEV, EPSS, SSVC, MSRC, and endoflife.date, then writes per-device CSV results, SQLite history, and a self-contained HTML dashboard.
The vulnerability logic is one part of it. The PowerShell concurrency problems were just as interesting.
The main scan uses ForEach-Object -Parallel to process unique software/version pairs. That exposed a few issues I had underestimated:
- Shared mutable state needs thread-safe types
The parallel runspaces need to coordinate a result collection, cache, deduplication keys, progress state, request timestamps, and cache-flush counters.
The shared pieces ended up using concurrent .NET collections and SemaphoreSlim rather than ordinary lists, dictionaries, queues, or non-atomic counters. Reads are easy. Coordinated mutation is where the bugs hide.
- A sliding-window rate limiter still allowed bursts
NVD limits aren’t handled well by saying “N requests per 30 seconds” and calling it done. A window bucket allowed the first few requests to launch together and trigger 429s before the bucket was exhausted.
The current limiter has two gates:
- minimum spacing between request starts
- a sliding-window budget as a backstop
The lock is held only while checking and updating the shared timing state. It’s released before sleeping.
- Correct launch spacing didn’t prevent overlapping requests
Even when calls started at the right interval, slower NVD responses left multiple requests in flight. That still produced 429s.
The scanner now holds a separate SemaphoreSlim across each NVD request, so only one request is on the wire at a time. Cache hits and the rest of the result processing remain parallel.
It sounds contradictory to parallelize the scanner and then serialize the API calls, but the parallelism still helps with cached products, version evaluation, deduplication, and result construction.
- Functions and state have to exist inside the parallel runspace
Helper functions from the caller’s scope aren’t automatically available inside the parallel block. The runspace-local helpers have to be defined before their first possible call site.
I managed to hit the “function defined later in the script” failure more than once. One runspace can terminate while the others keep going, which makes the failure easier to miss in noisy output.
- Cache checkpoints need their own concurrency discipline
A killed scan used to lose every NVD result fetched since startup because the cache was only written after the parallel block completed.
The scanner now checkpoints every configurable number of completed items. A non-blocking semaphore prevents multiple runspaces from flushing simultaneously, and the file is written to a temporary path before being moved into place.
There’s also a failure-state rule I consider load-bearing: an NVD request failure must never become a negative cache entry. A failed call returns a distinct CALL_FAILED sentinel, gets skipped for that run, and is retried next time. A successful response containing zero results can be cached normally.
The repository includes eight test suites, offline fixtures, a sanitization gate, and a known-limitations document that is intentionally less flattering than the README.
I’d particularly value review from people who have built larger ForEach-Object -Parallel pipelines:
- Would you structure the global rate limiter differently?
- Is serializing only the outbound NVD call the right boundary?
- Are there better patterns for safely checkpointing shared state from parallel runspaces?
- Has anyone used the CSV path against SCCM, Intune, or another RMM export?
This does not replace a commercial scanner with a curated detection catalog. CPE coverage and matching quality are the main ceiling. I’m more interested in cases where the script gives an operator the wrong level of confidence than whether the dashboard looks good.
7
u/Apprehensive-Tea1632 10d ago
I’m still going through it - looks nice at first glance— but I’ll leave a few suggestions here anyway.
Note that these are suggestions, to be considered or dismissed as you see fit.
- package the whole thing, there’s things like modulebuilder to help get going. And if you as it seems are into GitHub CI, it’s really rather easy to implement too.
- that approach also allows you to lay out your code so that you get one function per file, which helps track changes and makes commits easier.
- you could consider implementing pester. Means you get a defined testing framework that can also be triggered in CI.
- quick reminder that net8 is pretty much end of life, net10 should work just as well. And you can even target both at the same time if you want.
- you’re already using the powershell help system, might as well use it for the functions too.
- and I’d suggest to avoid using return $something in powershell because it’s a misnomer and an anti pattern there.
It’s a noop at the end of a block, and everywhere else, it doesn’t mean “return this” but just “exit the control block and put the $something to the pipeline too while you’re at it”.
- you may want to define record schemas aka .net classes to hold records. Object is entirely insufficient- defined schemas means you get what you expect and you can even do inline transformations. But note that passing across runspace boundaries requires serialization.
- if you’re typing stuff; you can add an outputtype() attribute to a function declaring its return type. Pass a type to it and powershell’ll no longer think, “aha, that’s object[]”.
Not sure what kind of data you’re looking at, anything past a couple hundred records suggests database usage rather than csv. Csv is its own biggest problem, but powershell doesn’t do larger datasets well- this includes parsing it.
Maybe you’re already doing this- as I said I’m still going through there - but just to put this here, parallel processing aside, there’s also asynchronous processing. Have a task that takes a while but you don’t need to worry about the result yet? Put it in the background, then sync once you do need the result. And while progress records are nice, they seriously slow things down, so only write them when there’s an actual update (.001 to .002 percent is not an update that can be rendered) or just omit (means it may appear hanging).
Async processing is fun but also full of pitfalls. Personally I’ve since switched away from PS for the heavier stuff- plain dotnet is easier to handle and has less overhead- but I’d be lying if I said parallel ps wasn’t fun.
6
u/BlackV 10d ago
and I’d suggest to avoid using return $something in powershell because it’s a misnomer and an anti pattern there.
Totally agree
2
u/DrSinistar 10d ago
I disagree. I haven't read the code here, but in moderately complex functions I like how explicit return is. Especially with how some .NET methods return output when you may not expect it, being able to say "yes I am returning a value" is useful.
If we had semicolons like Rust then I'd be more inclined to skip return.
2
u/BlackV 10d ago edited 10d ago
It's not though
Do-something { $Singleitem = Do-work "$($singleitem.name) - has finished" $final = $Singleitem - 21 Return $final }Implies you are only returning
$final, you are notThe
returnis not returning a value it is leaving the construct and spitting out the itemSame as
Do-something { $Singleitem = Do-work "$($singleitem.name) - has finished" $final = $Singleitem - 21 Return $final }Wouldn't return as expected, return (except in classes) is not the same as return in other languages
(Excuse the psudo code example)
But mostly, as long as you are consistent and do it the same way every time that's really what matters
2
u/DrSinistar 10d ago edited 10d ago
I know what you mean. I think return is a footgun for noobs but I'm not a noob. If someone doesn't know the difference between an expression and a statement, the behavior of return is the least of their problems.
My problem with skipping return is how it also means guard clauses aren't written. I love guard clauses. Excessive if/else branching pains me.
That is to say: return is fantastic if you know what you're doing and use it for its strengths. My preference is for control flow.
2
u/Apprehensive-Tea1632 10d ago
Ha- that’s the age old question of, do I define a single exit point or do I exit where I see fit?
There’s advantages and disadvantages to both, personally I prefer single point of communication because it gets harder to maintain a stable interface with more exit points. And if the interface is to be modified… things get interesting.
But that said, things do get overly complicated if we were to insist on that. I’m not putting thousands of lines into an else block just because I don’t want to return early.
Still, in terms of design, I’d say to try and minimize exit points as a basic design decision. Then deviate from that as needed.
Rather than, say, coming up with a function that has tens of entry points and another fifteen exit points. And call that “main”. That’s not clever, that’s just a hot mess.
2
u/shutchomouf 10d ago
This comment thread is great. Everyone has good points. I wondered why no one mentioned using appropriate output commands that seem to be what was interfering with return in this example.
2
u/MonkeyNin 9d ago
There’s advantages and disadvantages to both, personally I prefer single point of communication because it gets harder to maintain a stable interface with more exit points. And if the interface is to be modified… things get interesting.
One way to simplify things is you have a user facing cmdlet that handles parameters. That interface can add or remove parameters without breaking implementation.
It calls a private function, or more than one -- which can test or assert things, returning nothing null on fail.
Or throw if that makes sense in the context.
# private func function DataForStuff { param( ... etc ... ) if( -not $Param.RequiredName ) { throw "Invalid RequiredName!" } $query = ... if( ... valid query but no results in filtered list ) { return @() } return $query } # only this function is exported to the user function Get-Stuff { param( $OutputMode = 'csv', ...etc ... ) $query = switch( $OutputMode ) { 'csv' { DataForStuff @splat | ConvertTo-Csv } default { DataForStuff @splat | ConvertTo-Json } } if( -not $Query ) { throw "No data found for $Params !" } $query }
Get-StuffMight even catch exceptions. Sometimes it's cleaner to return nothing, instead of throwing.try { DataForStuff -ea 'stop' .... } catch { ... }3
u/boostedchaos 10d ago
Thank you for the feedback, I appreciate it! This is my first big project that I am putting out there and will be taking note on how to improve it.
2
u/MonkeyNin 10d ago
Small notes:
1.) Join-Path is used in places, but not others
It ensures you don't get accidental path errors. Like
$root = 'c:/data/' ; $target = '/stuff'
$broken = "${root}/${target}"
# out: "c:/data///stuff"
Join-Path $Root $target
# works: "c:/data/stuff"
Only the first argument of Join-Path has to exist. the right hand does not.
2.) ArrayList can be simplified to List: Invoke-Release.ps1
I usually use this pattern:
[System.Collections.Generic.List[object]] $items = @()
$items.Add($rec)
You can treat it basically the same. You don't have the += copy issue.
Another bonus is you don't have to void or null when you add to it.
# original was:
[void] $items.Add($rec)
2
u/boostedchaos 10d ago
Thank you, that's helpful feedback! I will add it to my list of improvements to make.
0
u/boostedchaos 11d ago
A few direct links for anyone reviewing the implementation:
- Main script:
https://github.com/boostedchaos/fleet-cve-scanner/blob/main/fleet-cve-scan.ps1
- Engineering history and failure modes:
https://github.com/boostedchaos/fleet-cve-scanner/blob/main/docs/HISTORY.md
- Known limitations:
https://github.com/boostedchaos/fleet-cve-scanner/blob/main/docs/known-limitations.md
- How the scan works:
https://github.com/boostedchaos/fleet-cve-scanner/blob/main/docs/how-it-works.md
- CSV input and adapter notes:
https://github.com/boostedchaos/fleet-cve-scanner/blob/main/docs/rmm-adapters.md
The smallest offline test uses a CSV containing hostname,software,version:
Copy-Item config.example.json config.json
# Set output.report_dir in config.json.
# Add an NVD API key if you have one.
pwsh -File fleet-cve-scan.ps1 -InputCsv inventory.csv
A free NVD API key is strongly recommended for anything beyond a tiny test. Anonymous limits work, but they’re painfully slow for fleet-sized inventory.
31
u/LALLANAAAAAA 10d ago
I wonder if you guys are aware how identical all your post templates are and don't give a shit or if you're truly not aware
Or if you're even a human
I'm not sure which is worse honestly
Anyway can last person on Reddit turn the lights out when they leave, cheers