
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?
# fleet-cve-scanner 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](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/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](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/docs/known-limitations.md) before trusting it — it is written to be honest rather than flattering. ## Quick start You need [PowerShell 7](#requirements) (`pwsh`). Two ways to run: ### A. Live NinjaOne scan ```bash 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: ```bash 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](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/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 put `sqlite3.exe` on `PATH` or beside the script. Without it the scan still runs; `first_seen` falls 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_seen` tracking. 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 Program `cvelistV5`) — 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.](https://nvd.nist.gov/developers/request-an-api-key) ## Configuration reference Config is a JSON file (default `config.json` beside the script; override with `-ConfigPath`). Start from [`config.example.json`](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/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](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/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 (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. - **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:** ```bat 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):** ```cron 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/`](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/dashboard-auth) directory ships a minimal ASP.NET host with Microsoft Entra (Azure AD) authentication; see its [README](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/dashboard-auth/README.md) and [RUNBOOK](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/dashboard-auth/RUNBOOK.md). ## 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 `-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](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/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 anywhere `pwsh` exists — including the scan server — with nothing to install. CI runs them all on Windows and Ubuntu. ## Documentation - [docs/how-it-works.md](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/docs/how-it-works.md) — the scan pipeline stage by stage, verdict logic, cache discipline, and the fail-soft contract. - [docs/known-limitations.md](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/docs/known-limitations.md) — every known way the scanner can be wrong, and why. - [docs/rate-limiting.md](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/docs/rate-limiting.md) — the NVD rate-limit lessons as an operator guide and tuning table. - [docs/rmm-adapters.md](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/docs/rmm-adapters.md) — the input contract, the two built-in inventory paths, and how to add a native adapter. - [docs/HISTORY.md](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/docs/HISTORY.md) — condensed, sanitized version history and the engineering lessons behind each release. ## Contributing PRs welcome — RMM adapters most of all. See [CONTRIBUTING.md](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/CONTRIBUTING.md) for the ground rules and the safe-direction requirement (status logic may only err toward *more* visibility, never silently toward PATCHED). ## 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/> ## License Apache-2.0 — see [LICENSE](https://github.com/boostedchaos/fleet-cve-scanner/blob/main/LICENSE).