
An open-source, single-script CVE scanner for RMM-managed fleets. Pure PowerShell 7 — joins your RMM software inventory against NVD, CISA KEV, EPSS and SSVC to answer: is this version vulnerable, and how urgent is it?
An open-source, single-script CVE scanner for RMM-managed fleets. Pure PowerShell 7, no agents, no appliances, no license fees.
It answers one question for every piece of software on every endpoint you manage: is the installed version known to be vulnerable, and how urgent is it? — by joining your RMM's software inventory against free, authoritative security feeds:
Outputs: a per-device CSV report, a SQLite history with SLA clocks and week-over-week change events, a self-contained HTML dashboard, and an optional markdown exposure report.
v1.0.0. The engine was ported clean-room from a scanner that ran weekly in production against a Windows fleet (see docs/HISTORY.md). Before this release the two were run against the same live fleet and compared row-for-row: across tens of thousands of findings where both saw an identical (device, software, version), every computed field — status, CVSS, severity, KEV flag, SSVC decision, fix version — matched exactly.
That comparison covers the verdict logic. It did not exercise the history/SLA layer, and it is not a substitute for validating the tool in your own environment. Read docs/known-limitations.md before trusting it — it is written to be honest rather than flattering.
You need PowerShell 7 (pwsh). Two ways to run:
cp config.example.json config.json
# edit config.json: fill ninjaone.client_id / client_secret / base_url,
# set output.report_dir, and (recommended) nvd.api_key
pwsh -File fleet-cve-scan.ps1
Export your inventory to a CSV with columns hostname, software, version
(optional device_id, os), then:
cp config.example.json config.json # only output.report_dir is needed here
pwsh -File fleet-cve-scan.ps1 -InputCsv inventory.csv
-InputCsv makes zero NinjaOne calls — no OAuth, no API. It still queries
NVD and the other public feeds, so you still want an nvd.api_key in
config.json and a valid output.report_dir. See
docs/rmm-adapters.md for the input contract and
per-RMM export recipes.
brew install powershell; Windows:
winget install Microsoft.PowerShell; Debian/Ubuntu: install from the
Microsoft package repo (packages.microsoft.com).brew install sqlite; Debian/Ubuntu: apt-get install sqlite3; Windows:
download the SQLite "tools" bundle from sqlite.org and put sqlite3.exe on
PATH or beside the script. Without it the scan still runs; first_seen
falls back to the run date.first_seen tracking. Absent = those features skip, scan still completes.epss.empiricalsecurity.com
(the EPSS bulk-score host — FIRST moved EPSS data hosting there), MSRC,
endoflife.date, and GitHub raw (CVE Program cvelistV5) — plus the NinjaOne
API in live mode.Config is a JSON file (default config.json beside the script; override with
-ConfigPath). Start from config.example.json. Every
key the scanner reads, its default when omitted, and what it does:
ninjaone (live mode)| Key | Default | Purpose |
|---|---|---|
ninjaone.client_id | "" | NinjaOne API OAuth2 client id. Required for live scans; leave blank for -InputCsv. |
ninjaone.client_secret | "" | NinjaOne API OAuth2 client secret. |
ninjaone.base_url | — (example ships https://app.ninjarmm.com) | NinjaOne API base URL; required for live scans, no built-in fallback. Use your region's host (e.g. eu.ninjarmm.com, oc.ninjarmm.com). |
nvd| Key | Default | Purpose |
|---|---|---|
nvd.api_key | "" | NVD API key, sent as the apiKey header. Blank runs unauthenticated, which drops the defaults for nvd_rate_limit and nvd_min_spacing_ms to NVD's anonymous ceiling (4/30s, 6500ms) instead of the authenticated one (48/30s, 700ms). Free, and ~10x faster. |
| Key | Default | Purpose |
|---|---|---|
cvss_threshold | 7.0 | Minimum CVSS base score for a scored CVE to be reported VULNERABLE. KEV-listed CVEs bypass this floor. |
ssvc_mission_prevalence | high | SSVC Mission Prevalence stakeholder input (low / medium / high). |
ssvc_public_wellbeing | high | SSVC Public Well-being stakeholder input (low / medium / high). |
| Key | Default | Purpose |
|---|---|---|
nvd_cache_ttl_days | 7 | Days an NVD result (per software name) stays cached before re-query. |
nvd_cache_flush_every | 500 | Checkpoint the NVD cache to disk every N completed items, so a killed scan keeps its fetches. 0 disables (end-of-scan save still runs). |
nvd_min_spacing_ms | 700 with an API key, 6500 without | Minimum milliseconds between NVD calls. The binding rate constraint — tune this first. |
nvd_rate_limit | 48 with an API key, 4 without | Sliding-window cap on NVD calls per 30s (a backstop). Floor of 1. |
parallel_throttle | 20 | ForEach-Object -Parallel runspace count. NVD calls are serialized regardless; this only bounds cache-hit concurrency. Floor of 1. |
kev_cache_ttl_hours | 24 | TTL for the cached CISA KEV catalog. |
epss_cache_ttl_hours | 24 | TTL for the cached FIRST.org EPSS score file. |
eol_cache_ttl_days | 7 | TTL for cached endoflife.date lookups. |
msrc_cache_ttl_days | 30 | TTL for cached MSRC CVRF data. |
cvelist_cache_ttl_days | 7 | TTL for cached CVE Program (cvelistV5) / CISA-ADP SSVC data. |
cvelist_fetch_budget | 300 | Max CVE-record fetches per run for SSVC/vulnrichment enrichment; the rest defer to the next run. |
nvd_min_spacing_ms, nvd_rate_limit, and parallel_throttle are read by the
scanner but not present in config.example.json — add them only if you need to
tune. See docs/rate-limiting.md.
| Key | Default | Purpose |
|---|---|---|
epss_spike_threshold | 0.10 | Minimum rise in a finding's EPSS score between scans to emit an EPSS_SPIKE change event. |
sla_days_critical | 14 | SLA window (days from first_seen) for CRITICAL findings. |
sla_days_high | 30 | SLA window for other scored severities. |
denied_software_csv | "" | Path to an optional denied-software CSV cross-check. Empty disables the check. |
output| Key | Default | Purpose |
|---|---|---|
output.report_dir | (required) | Directory for all outputs — CSV, history DB, dashboard, and caches. |
output.wiki_security_page | "" | Optional path to a markdown exposure page. Written only when set. |
output.dashboard_share | "" | Optional path/share that receives a copy of the dashboard each run. Empty = local-only. |
Everything below lands in output.report_dir.
vuln-report-YYYY-MM-DD.csv — the per-finding report. 28 columns
(schema below).vuln-history.db — SQLite history (requires sqlite3): runs (per-run
aggregates), findings (per (device, software, cve) open-finding history
with first_seen / last_seen / resolved_date / epss_score), and
changes (typed week-over-week events).cve-dashboard.html — self-contained HTML dashboard (charting library
inlined, no external requests). Optionally copied to output.dashboard_share.eol-report-YYYY-MM-DD.csv — written when end-of-life families are
detected.denied-report-YYYY-MM-DD.csv — written when denied_software_csv is
configured and matches.nvd-cache.json, kev-cache.json, epss-cache.json,
eol-cache.json, msrc-cache.json, cvelist-cache.json (all gitignored).output.wiki_security_page, only when set.The column set is fixed and gate-enforced. In order:
| # | Column | Meaning |
|---|---|---|
| 1 | device_id | RMM device id (or a deterministic synthetic id in -InputCsv mode). |
| 2 | hostname | Device hostname. |
| 3 | os | Operating system string (may be blank). |
| 4 | software | Product name as inventoried. |
| 5 | installed_version | Version string as inventoried. |
| 6 | cve_id | CVE identifier. |
| 7 | cvss_score | CVSS base score (0 if none). |
| 8 | severity | CVSS severity label. |
| 9 | cvss_version | Which CVSS standard produced the score (4.0/3.1/3.0/2.0/blank). |
| 10 | epss_score | FIRST.org EPSS probability (blank if the CVE is absent from the catalog). |
| 11 | epss_percentile | EPSS percentile (blank if absent). |
| 12 | ssvc_exploitation | Raw CISA SSVC exploitation option (none/poc/active; blank without ADP data). |
| 13 | ssvc_decision | SSVC decision Act/Attend/Track (blank without ADP data). |
| 14 | status | VULNERABLE / PATCHED / UNKNOWN. |
| 15 | published_date | CVE published date. |
| 16 | unknown_reason | Why a row is UNKNOWN (SUSPECT_VERSION / / ; blank otherwise). |
Run-WeeklyScan.ps1 is a wrapper for scheduled runs: it runs the scanner from
its own directory, captures all output streams to a dated log under
<script dir>/logs/ (override with -LogDir), and propagates the scanner's
exit code so the scheduler sees failures.
Windows Task Scheduler:
schtasks /Create /TN "CVE-Scan-Weekly" ^
/TR "pwsh -NoProfile -File C:\path\to\Run-WeeklyScan.ps1" ^
/SC WEEKLY /D SUN /ST 22:00 /RU SYSTEM
cron (Linux/macOS):
0 22 * * 0 pwsh -File /path/to/Run-WeeklyScan.ps1
cve-dashboard.html is fully self-contained — open it directly, drop it on any
static host or file share, or copy it automatically each run via
output.dashboard_share. For access-controlled hosting, the
dashboard-auth/ directory ships a minimal ASP.NET host
with Microsoft Entra (Azure AD) authentication; see its
README and RUNBOOK.
-InputCsv mode lets any
RMM/SCCM/Intune export feed it, but only the NinjaOne adapter has been run
in production.The full, unflinching list — the CPE skip rate, dedup-key masking, page-1
totalResults, MSRC supersedence gaps, and more — is in
docs/known-limitations.md.
A few structural choices that look unconventional are deliberate:
ForEach-Object -Parallel, and those runspaces cannot see outer-scope
functions — which is why a handful of helpers are intentionally duplicated
inside the parallel block. A one-function-per-file module layout would not
remove that duplication (the parallel body would still need the source
injected per runspace); it would just add a build step and break the
copy-one-file deployment model. If the project ever outgrows single-file,
packaging is the v2-shaped change, not a patch.[PSCustomObject] records, not classes. Finding records are produced
inside parallel runspaces and marshaled back to the main runspace.
PowerShell class instances do not reliably survive that boundary;
[PSCustomObject] does, and the CSV column contract is enforced by the
release gate instead of a type system.tests/ are
dependency-free scripts with exit-code assertions, runnable anywhere
pwsh exists — including the scan server — with nothing to install. CI
runs them all on Windows and Ubuntu.PRs welcome — RMM adapters most of all. See CONTRIBUTING.md for the ground rules and the safe-direction requirement (status logic may only err toward more visibility, never silently toward PATCHED).
This tool is only as useful as the free, authoritative feeds it joins against. Each of the following is queried at scan time; please honor their terms if you build on this.
cvelistV5 — CVE records plus the SSVC / vulnrichment data
contributed by the CISA ADP. CVE® is a registered trademark of The MITRE
Corporation. https://github.com/CVEProject/cvelistV5Apache-2.0 — see LICENSE.
NO_VERSION_BOUNDVERSION_PARSE_ERROR| 17 | is_kev | True when the CVE is on the CISA KEV catalog. |
| 18 | kev_due_date | CISA KEV remediation due date. |
| 19 | kev_ransomware_use | KEV ransomware-campaign use flag. |
| 20 | fix_version | Computed fix-boundary version. |
| 21 | fix_boundary_inclusive | Whether the fix boundary is inclusive. |
| 22 | advisory_url | Vendor advisory or NVD detail URL. |
| 23 | patch_evidence | Evidence when a Microsoft UNKNOWN was flipped to PATCHED. |
| 24 | triage_status | Triage disposition from triage.json (blank if none). |
| 25 | triage_note | Triage note. |
| 26 | first_seen | Date the finding was first observed (from the history DB). |
| 27 | sla_due | SLA due date. |
| 28 | sla_breach | Whether the SLA is breached. |