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:
- NIST NVD — known CVEs + affected version ranges
- CISA KEV — actively exploited in the wild (bypasses the score threshold)
- EPSS — probability of exploitation in the next 30 days
- CISA SSVC — act / attend / track prioritization decisions
- MSRC — proof a Microsoft patch already covers the finding
- endoflife.date — software that will never be patched again
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.
Status
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.
Quick start
You need PowerShell 7 (pwsh). Two ways to run:
A. Live NinjaOne scan
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
B. Offline — any RMM, SCCM, or Intune (no API access)
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.
Installing prerequisites
- PowerShell 7 — macOS:
brew install powershell; Windows:winget install Microsoft.PowerShell; Debian/Ubuntu: install from the Microsoft package repo (packages.microsoft.com). - sqlite3 (optional, powers history/trending) — macOS: preinstalled or
brew install sqlite; Debian/Ubuntu:apt-get install sqlite3; Windows: download the SQLite "tools" bundle from sqlite.org and putsqlite3.exeonPATHor beside the script. Without it the scan still runs;first_seenfalls back to the run date.
Requirements
- PowerShell 7 (7.4+ recommended). The parallel scan requires 7.0+.
- sqlite3 — optional; enables the history DB, trending, and SLA
first_seentracking. Absent = those features skip, scan still completes. - Network egress to NVD, CISA (KEV),
epss.empiricalsecurity.com(the EPSS bulk-score host — FIRST moved EPSS data hosting there), MSRC, endoflife.date, and GitHub raw (CVE ProgramcvelistV5) — plus the NinjaOne API in live mode. - Credentials — live mode needs NinjaOne OAuth2 client id/secret; offline mode needs only an inventory CSV. An NVD API key is technically optional but effectively required at fleet scale: NVD allows 50 requests/30s with a key and only 5/30s without one. The scanner detects which you have and throttles to match, so a keyless run is correct but roughly 10x slower — fine for a handful of products, impractical for a real fleet. Keys are free.
Configuration reference
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. |
Scan & scoring
| 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). |
Caching & rate limiting
| 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.
Enrichment & SLA
| 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. |
Outputs
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 (requiressqlite3):runs(per-run aggregates),findings(per(device, software, cve)open-finding history withfirst_seen/last_seen/resolved_date/epss_score), andchanges(typed week-over-week events).cve-dashboard.html— self-contained HTML dashboard (charting library inlined, no external requests). Optionally copied tooutput.dashboard_share.eol-report-YYYY-MM-DD.csv— written when end-of-life families are detected.denied-report-YYYY-MM-DD.csv— written whendenied_software_csvis configured and matches.- Cache files —
nvd-cache.json,kev-cache.json,epss-cache.json,eol-cache.json,msrc-cache.json,cvelist-cache.json(all gitignored). - Markdown exposure page — at
output.wiki_security_page, only when set.
CSV schema (28 columns)
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 / NO_VERSION_BOUND / VERSION_PARSE_ERROR; blank otherwise). |
| 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. |
Scheduling
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
Dashboard hosting
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.
Honest limitations (read before trusting it)
- CPE matching is fuzzy by nature. 40–60% of typical inventory has no NVD entry at all and is skipped (logged, never silently). Name-based CPE selection can pick the wrong product; a suppression workflow exists because it has to. This is the ceiling of the free-NVD approach — a paid scanner with a curated detection catalog will be more accurate. This tool's value is being free, transparent, and auditable.
- UNKNOWN is a first-class verdict. When the scanner can't prove vulnerable-or-patched, it says so instead of guessing.
- Verified against NinjaOne only. The scan engine is RMM-agnostic (input
is just device + software + version); the
-InputCsvmode 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.
Design notes
A few structural choices that look unconventional are deliberate:
- Single file, not a module. The scan hot path runs inside
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.- Plain PS7 test scripts, not Pester. The suites in
tests/are dependency-free scripts with exit-code assertions, runnable anywherepwshexists — including the scan server — with nothing to install. CI runs them all on Windows and Ubuntu.
Documentation
- docs/how-it-works.md — the scan pipeline stage by stage, verdict logic, cache discipline, and the fail-soft contract.
- docs/known-limitations.md — every known way the scanner can be wrong, and why.
- docs/rate-limiting.md — the NVD rate-limit lessons as an operator guide and tuning table.
- docs/rmm-adapters.md — the input contract, the two built-in inventory paths, and how to add a native adapter.
- docs/HISTORY.md — condensed, sanitized version history and the engineering lessons behind each release.
Data sources & attribution
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.
- NIST National Vulnerability Database (NVD) — CVE records and affected version ranges, via the NVD API. This product uses the NVD API but is not endorsed or certified by the NVD. https://nvd.nist.gov/
- CISA Known Exploited Vulnerabilities (KEV) Catalog — the authoritative list of CVEs known to be exploited in the wild. https://www.cisa.gov/known-exploited-vulnerabilities-catalog
- CVE Program —
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/cvelistV5 - SSVC (Stakeholder-Specific Vulnerability Categorization) — the act / attend / track decision logic in this tool is derived from CERT/CC SSVC (Carnegie Mellon University Software Engineering Institute) and the CISA SSVC Coordinator decision tree, version 2.0.3. Credit to CERT/CC and CISA is required. https://www.cisa.gov/ssvc · https://github.com/CERTCC/SSVC
- FIRST.org EPSS (Exploit Prediction Scoring System) — probability of exploitation in the next 30 days. EPSS data is provided by FIRST. See Jay Jacobs, Sasha Romanosky, Benjamin Edwards, Michael Roytman, Idris Adjerid (2021), Exploit Prediction Scoring System, Digital Threats: Research and Practice, 2(3). https://www.first.org/epss/
- Microsoft Security Response Center (MSRC) — the CVRF API, used to confirm when a Microsoft security update already covers a finding. © Microsoft Corporation. https://api.msrc.microsoft.com/ · https://msrc.microsoft.com/
- endoflife.date — end-of-life and end-of-support dates for software that will never be patched again. Data courtesy of the endoflife.date project. https://endoflife.date/
Comments