From a6184f165bfb489fffb930eb7067f6af70ed52df Mon Sep 17 00:00:00 2001
From: Oliver Kieselbach
Date: Sat, 29 Aug 2026 11:07:31 +0200
Subject: [PATCH] feat(vuln): FIRST EPSS as fourth source, act/attend/track
priority, CVSS vector; never cache a refused NVD answer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
EPSS: EpssApiClient (api.first.org, 100 ids per batch, unauthenticated, fail-soft). Score, percentile
and date live on the cached CVE — one batch per CPE fill, every cve_* row re-scored by the nightly
sync, only changed rows rewritten and with their original CachedAt. CveIndex carries EpssScore (max,
absent when unscored) and Priority; TopCve exposes both. CvePriority: act = CISA KEV, attend =
EPSS >= 0.1 or CVSS >= 9.0, track = rest — riskLevel/overall_risk semantics unchanged.
NVD throttle: Polly already retried 429s; the defect was that a refused answer came back as an
empty response and was cached as "0 CVEs" for seven days (also the negative auto-resolve cache).
NvdFetchResult separates answered from refused; nothing persists a refusal; a 429 opens a
process-wide cooldown (Retry-After capped at 120 s, else 30 s) so a run fails fast instead of
paying a retry chain per software item; hard-expired rows keep serving meanwhile. The NVD->cache
projection and the row builder are now single functions shared by the live fill and the sync.
CVSS v3 vector string persisted and shown. Portal: EPSS column + pill, priority pill, vector line;
exposure panel pills. MCP tool descriptions and the CVE-audit prompt name the fields. Terms
attribution + privacy page updated in the same change. OKF doc backend/vulnerability-correlation-sources.md.
Co-Authored-By: Claude Fable 5
---
.../vulnerability-correlation-sources.md | 143 +++++++
docs/index.md | 1 +
docs/log.md | 4 +
.../CveIndexEntityMergeTests.cs | 26 ++
.../EpssAndNvdThrottleTests.cs | 350 ++++++++++++++++++
.../VulnerabilitySummaryAggregatorTests.cs | 19 +
.../AutoResolveCpeMappingFunction.cs | 12 +-
.../VulnerabilityDataSyncFunction.cs | 213 +++++++----
.../VulnerabilitySummaryAggregator.cs | 15 +-
.../AutopilotMonitor.Functions/Program.cs | 2 +
.../Services/TableStorageService.AgentApi.cs | 18 +-
.../Services/Vulnerability/CvePriority.cs | 48 +++
.../Services/Vulnerability/EpssApiClient.cs | 158 ++++++++
.../Services/Vulnerability/NvdApiClient.cs | 95 ++++-
.../VulnerabilityCorrelationService.cs | 327 +++++++++++-----
.../autopilot-monitor-mcp/src/prompts.ts | 4 +-
.../autopilot-monitor-mcp/src/tools/admin.ts | 5 +-
.../src/tools/sessions.ts | 2 +
.../Models/Vulnerability/CveExposureEntry.cs | 4 +
.../app/privacy/page.tsx | 5 +-
.../components/VulnerabilityReportSection.tsx | 64 +++-
.../autopilot-monitor-web/app/terms/page.tsx | 10 +-
.../components/VulnerabilityExposurePanel.tsx | 12 +-
.../lib/vulnerabilitySeverity.ts | 33 ++
24 files changed, 1378 insertions(+), 192 deletions(-)
create mode 100644 docs/backend/vulnerability-correlation-sources.md
create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/EpssAndNvdThrottleTests.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CvePriority.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/EpssApiClient.cs
diff --git a/docs/backend/vulnerability-correlation-sources.md b/docs/backend/vulnerability-correlation-sources.md
new file mode 100644
index 00000000..1adaf8b6
--- /dev/null
+++ b/docs/backend/vulnerability-correlation-sources.md
@@ -0,0 +1,143 @@
+---
+type: Concept
+title: Vulnerability Correlation Sources (NVD, KEV, MSRC, EPSS)
+description: How the software inventory is turned into CVE findings - CPE mapping, the per-CPE NVD cache with its budget and TTLs, KEV/MSRC/EPSS enrichment, the throttle contract that keeps a refused NVD answer out of the cache, and the act/attend/track priority.
+resource: src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs
+tags:
+ - backend
+ - vulnerability
+ - nvd
+ - epss
+ - kev
+ - msrc
+ - cache
+timestamp: 2026-08-29T18:00:00+02:00
+---
+
+# Vulnerability Correlation Sources
+
+The Software Inventory & Vulnerability Analyzer (tenant opt-in) ships the installed-software
+list with the session; the backend turns it into CVE findings entirely from cached public
+reference data. Four sources, all trustworthy primaries, no aggregators:
+
+| Source | What it contributes | Fetch shape | Cache |
+| --- | --- | --- | --- |
+| NVD 2.0 (NIST) | CVE records per product: CVSS v3.1 (v3.0 fallback) score, severity, **vector**, description, affected version ranges; CPE dictionary for auto-mapping | `cves/2.0?virtualMatchString={cpe}` per product, `cpes/2.0?keywordSearch=` for unmapped titles; optional API key (`AdminConfiguration.NvdApiKey`) | Azure Table `VulnerabilityCache`, one row per CPE (`cve_{cpe}` / `cache_result`), `CveDataJson` chunked; soft TTL 7 d (serve + background refresh), hard 14 d |
+| CISA KEV | "Exploited in the wild" flag, due date, required action, ransomware use | Whole catalog JSON | In-memory dictionary, 24 h, blob mirror `kev-catalog.json` |
+| MSRC | Microsoft-side confirmation: document id/title, release date, Microsoft severity / score / vector | CVRF v3.0 index of the last six Patch Tuesdays | In-memory index, blob mirror `msrc-index.json` |
+| FIRST EPSS | Probability (0..1) that the CVE is exploited within 30 days + percentile + scoring date | `api.first.org/data/v1/epss?cve=a,b,c` - 100 ids per request, unauthenticated | Stored **on the cached CVE** (no separate table): filled when a CPE row is (re)fetched, re-scored for every row by the nightly sync |
+
+Nothing about a tenant leaves the backend. The one outbound query that carries content is the
+EPSS lookup, and its payload is the list of public CVE ids held in the shared, tenant-agnostic
+CPE cache. Customer-facing wording: `autopilotmonitor-docs/trust/data-flows.md` and the
+terms page attribution block.
+
+# Schema
+
+## Cached CVE (`CachedCve`, serialised into `CveDataJson`)
+
+```
+CveId, CvssScore, CvssSeverity, CvssVector?, Description (<=500), PublishedDate,
+EpssScore?, EpssPercentile?, EpssDate?, AffectedVersions[] {Criteria, VersionStart/End Incl/Excl}
+```
+
+`JsonOptions` is `WhenWritingNull` + case-insensitive, so rows written before the vector / EPSS
+properties existed deserialise with those as `null`, and a row is never rewritten just to add
+nulls. `CveCacheBudget` bounds the row (24 months, or CVSS >= 7 within 60 months; max 100 CVEs
+per CPE, 50 ranges per CVE) - which is also why one CPE fill is exactly one EPSS request.
+
+`ProjectNvdResponse` is the single NVD -> `CachedCve` projection; the live fill and the nightly
+refresh both call it, so both write the same shape. `BuildCveCacheRow(cpe, cves, cachedAt)` is the
+single row builder; `cachedAt` is the **NVD** freshness stamp, an EPSS-only rewrite passes the
+existing value through so it does not look like a fresh NVD fetch to the 7-day TTL.
+
+## Per-finding output (report `findings[].vulnerabilities[]`)
+
+```
+cveId, cvssScore, cvssSeverity, cvssVector?, description, publishedDate, source ("NVD" | "NVD+MSRC"),
+isKev, kevDueDate, kevRequiredAction, knownRansomware,
+epssScore?, epssPercentile?, epssDate?,
+priority ("act" | "attend" | "track"),
+msrc* (when MSRC confirmed), versionMatchConfidence ("low" on wildcard CPE matches)
+```
+
+Finding level: `riskLevel` (unchanged CVSS+KEV bands), `priority` (highest band), `maxEpssScore?`.
+`scan_summary` additionally carries `act_cves` / `attend_cves`. `MergeReports` (Part 1 + Part 2)
+recomputes all of these. The `CveIndex` row (one per tenant/CVE/session) carries `EpssScore`
+(max over occurrences, absent when unscored) and `Priority` (highest), which the fleet summary
+(`TopCve.epssScore` / `priority`) reads.
+
+## Priority (`CvePriority.Compute`)
+
+* `act` - in CISA KEV.
+* `attend` - EPSS >= 0.1 **or** CVSS >= 9.0.
+* `track` - everything else.
+
+Three visible rules instead of a weighted composite: severity (`riskLevel`) keeps saying how bad,
+priority says how soon. A CVE without an EPSS score is "unknown likelihood" - it can still reach
+`attend` via CVSS, never via a defaulted 0. The thresholds are constants; changing them is a
+product decision, not a tuning knob.
+
+# Throttle contract (NVD)
+
+The typed `HttpClient` runs under the `ExternalDataApi` Polly policy: 429/5xx/timeouts are retried
+three times (1/2/4 s + jitter) with a circuit breaker. A 429 that reaches `NvdApiClient` therefore
+means NVD is refusing us for real. Two things then happen, and one deliberately does not:
+
+1. `NvdFetchResult` reports `Succeeded = false`, `Throttled = true`. `Data` is an empty object
+ so enumerating callers keep working, but **nothing that persists may treat it as an answer**:
+ the CPE cache fill returns null and writes no row; the negative auto-resolve cache is not
+ touched; the admin auto-resolve reports `nvd_throttled`. Before this contract an empty response
+ was cached as "0 CVEs" for seven days - a product NVD happened to throttle disappeared from
+ every report for a week.
+2. A process-wide cooldown opens: `Retry-After` when present (delta or date, capped at 120 s),
+ otherwise 30 s. During the cooldown every NVD call fails fast with `Throttled` - no wait, no
+ retry chain. Worst case added latency per cooldown window is one Polly chain (~7.5 s), never
+ a per-item stall; the nightly refresh breaks out of its loop on the first throttled answer and
+ reports the remainder as `cveSkippedThrottled`.
+3. Not done on purpose: no in-process retry beyond Polly, no VulnCheck-style mirror, no separate
+ "throttled" cache state. Hard-expired cache rows keep serving while NVD is unavailable
+ (better than nothing, logged); a product with no row at all is reported without CVE data for
+ that run and gets a real attempt on the next correlation.
+
+# Nightly sync (`VulnerabilityDataSync`, 03:00 UTC)
+
+1. KEV catalog refresh (blob mirror updated).
+2. MSRC index refresh (last six Patch Tuesdays).
+3. NVD refresh of cache rows older than 7 days (sequential, breaks on throttle).
+4. **EPSS re-score of every `cve_*` row** - all ids batched through FIRST, only rows whose score /
+ percentile / date changed are rewritten (with their original `CachedAt`). A missing id in the
+ response keeps yesterday's score: a failed batch never erases data.
+
+Telemetry event `VulnerabilityDataSyncCompleted` carries `epssRows / epssCves / epssScored /
+epssRowsRewritten / epssError` next to the KEV / MSRC / CVE counters.
+
+# Examples
+
+* Chrome 126 finding: `CVE-2024-4947` CVSS 8.8 HIGH, `AV:N/AC:L/PR:N/UI:R`, KEV yes -> `priority =
+ act`, riskLevel `critical` (KEV) - unchanged severity semantics, priority now explicit.
+* A 5.5 MEDIUM local-privilege CVE with EPSS 0.31 -> `attend`; the same CVE with EPSS 0.004 ->
+ `track`. Same severity band, different order of work.
+* Nightly sync while NVD throttles: `[VulnSync] NVD throttled - leaving 37 stale entries for the
+ next run`, then `[VulnSync] EPSS refresh: 1412/1450 CVEs scored across 212 rows, 180 rows
+ rewritten` - EPSS is independent of NVD's mood.
+
+# Not done (recorded)
+
+* **ExploitDB CSV** as a "public exploit exists" flag (static file, daily, same shape as the KEV
+ mirror) - useful, cheap, deliberately not added: four primary sources are enough for now and
+ each additional outbound dependency is a trust-page line. Revisit if customers ask "is there a
+ public exploit?".
+* GitHub / Nuclei PoC search, composite 0-100 risk scores, CWE -> ATT&CK mapping - noise for an
+ Intune admin, not adopted.
+
+# Citations
+
+* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/NvdApiClient.cs` - `NvdFetchResult`, cooldown, `ComputeCooldown`
+* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/EpssApiClient.cs` - batching, `ParseResponse`
+* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CvePriority.cs`
+* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs` - `ProjectNvdResponse`, `ApplyEpssScores`, `BuildCveCacheRow`, `StampFindingPriority`, `FetchAndCacheCvesFromNvdAsync`
+* `src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs` - `RefreshStaleCveCacheAsync`, `RefreshEpssScoresAsync`
+* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs` - `BuildCveIndexEntities`, `ScanCveIndexAsync`
+* `src/Backend/AutopilotMonitor.Functions.Tests/EpssAndNvdThrottleTests.cs`
+* FIRST EPSS API: https://api.first.org/epss/ - CISA KEV: https://www.cisa.gov/known-exploited-vulnerabilities-catalog
diff --git a/docs/index.md b/docs/index.md
index 6ad3afb5..09ee1e90 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -79,6 +79,7 @@ https://docs.autopilotmonitor.com (separate repository).
# Security & Identity
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
+* [Vulnerability Correlation Sources](backend/vulnerability-correlation-sources.md) - How the software inventory becomes CVE findings from four cached primaries (NVD per-CPE cache with budget and TTLs, CISA KEV, MSRC, FIRST EPSS stored on the cached CVE and re-scored nightly), the NVD throttle contract (a refused answer is never cached as "0 CVEs"; bounded process-wide cooldown honouring Retry-After), the persisted CVSS vector, and the act/attend/track priority that keeps severity and likelihood as two visible signals instead of one composite score.
* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, and the shadow-then-enforce rollout with its volume-controlled telemetry.
# Conventions for this bundle
diff --git a/docs/log.md b/docs/log.md
index 8a86fce2..a21b1918 100644
--- a/docs/log.md
+++ b/docs/log.md
@@ -1,5 +1,9 @@
# Log
+## 2026-08-29 (2)
+
+* **New**: `backend/vulnerability-correlation-sources.md` - FIRST EPSS joins NVD / CISA KEV / MSRC as the fourth vulnerability source: the probability + percentile + date live on the cached CVE (one batch of <= 100 ids per CPE fill, every `cve_*` row re-scored by the nightly sync, only changed rows rewritten and with their original `CachedAt`), the CVSS vector is persisted instead of dropped, and each CVE carries an act / attend / track priority (KEV; EPSS >= 0.1 or CVSS >= 9.0; rest) next to the unchanged severity bands - two visible signals, no composite score. The comparison with an open-source CVE MCP server surfaced the real 429 defect: Polly already retried, but a refused NVD answer came back as an empty response and was cached as "0 CVEs" for seven days. `NvdFetchResult` now separates "answered" from "refused" (nothing persists a refusal - CPE cache, negative auto-resolve cache, admin auto-resolve), and a 429 opens a process-wide cooldown (`Retry-After` capped at 120 s, else 30 s) so the run fails fast instead of paying a retry chain per software item; hard-expired rows keep serving meanwhile. NVD -> cache projection and the row builder are now single functions shared by the live fill and the sync. CveIndex / fleet summary carry `epssScore` + `priority`; portal shows EPSS pill, priority pill, vector; MCP tool descriptions and the CVE-audit prompt name the fields. ExploitDB recorded as a deliberate not-now. Backend before web; trust pages (terms attribution, privacy, data-flows), settings and portal-guide docs updated in the same change.
+
## 2026-08-29
* **New**: `mcp/protocol-eras-and-cimd.md` - the MCP server moved to the 2026-07-28 protocol revision (TypeScript SDK v2: `@modelcontextprotocol/server` + `/node`, zod 4; the monolithic `@modelcontextprotocol/sdk` is gone). One per-request factory (`mcp-server-factory.ts`) feeds both eras through `mcp-http.ts`: 2026 clients are served by the SDK entry (`createMcpHandler`, `legacy: 'reject'`, JSON response mode), 2025 clients keep the existing stateless transport with `enableJsonResponse` - routed on the SDK's own `isLegacyRequest` predicate, because the built-in stateless fallback would answer today's Claude/VS Code clients with SSE and lose the gzip win. Capabilities now declare `listChanged: false` (the SDK default advertised a notification a per-request server cannot send); `cacheHints` stamp `ttlMs`/`cacheScope: 'private'` on discover/list results (5 min) and resource reads (60 min) - never public, the catalog is role-dependent. Client registration: `client_id_metadata_document_supported: true`; an https `client_id` is a Client ID Metadata Document resolved by `cimd.ts` (SSRF gate on the resolved addresses before any request, no redirects, 5 s / 16 KB, exact `client_id` match, bounded `redirect_uris`, 10-60 min cache), the requested redirect_uri still has to pass the dynamic-registration allowlist; HMAC-signed DCR client_ids stay as the fallback. `mcp-oauth-flow.md` updated. Package version 1.5 -> 1.6. Pinned by `mcp-http.test.ts` (real v2 client over loopback HTTP, both eras, role parity), `cimd.test.ts`, `oauth-cimd.test.ts`.
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CveIndexEntityMergeTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CveIndexEntityMergeTests.cs
index b46a9eac..eaf81799 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/CveIndexEntityMergeTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/CveIndexEntityMergeTests.cs
@@ -68,6 +68,32 @@ public class CveIndexEntityMergeTests
Assert.Equal("critical", e.GetString("OverallRisk"));
}
+ [Fact]
+ public void Merge_EpssMaxes_PriorityMaxes_AndUnscoredWritesNoEpssColumn()
+ {
+ var scoredLow = Vuln("CVE-1", 5.0, "MEDIUM"); scoredLow["epssScore"] = 0.02; scoredLow["priority"] = "track";
+ var scoredHigh = Vuln("CVE-1", 4.0, "MEDIUM"); scoredHigh["epssScore"] = 0.35; scoredHigh["priority"] = "attend";
+ var unscored = Vuln("CVE-1", 3.0, "LOW"); unscored["priority"] = "track";
+
+ var e = Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, new List>
+ {
+ Finding("App A", "medium", unscored),
+ Finding("App B", "medium", scoredLow),
+ Finding("App C", "medium", scoredHigh),
+ }));
+
+ Assert.Equal(0.35, e.GetDouble("EpssScore"));
+ Assert.Equal("attend", e.GetString("Priority"));
+
+ // A CVE nobody scored must not carry a fake 0.0 — the aggregate treats absent as "unknown".
+ var none = Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, new List>
+ {
+ Finding("App A", "low", Vuln("CVE-2", 3.0, "LOW")),
+ }));
+ Assert.False(none.ContainsKey("EpssScore"));
+ Assert.Equal("", none.GetString("Priority"));
+ }
+
[Fact]
public void DistinctCves_StayDistinct_AndInvalidEntriesAreSkipped()
{
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EpssAndNvdThrottleTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EpssAndNvdThrottleTests.cs
new file mode 100644
index 00000000..2b50ac83
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/EpssAndNvdThrottleTests.cs
@@ -0,0 +1,350 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Services.Vulnerability;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+using static AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelationService;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Pins the EPSS integration and the NVD throttle contract:
+/// FIRST's envelope parses with invariant-culture string numbers, a 429 opens a bounded cooldown
+/// (Retry-After honoured but capped), the NVD→cache projection carries the CVSS vector, EPSS
+/// application never erases an existing score, and the act/attend/track priority is exactly the
+/// three documented rules.
+///
+public class EpssAndNvdThrottleTests
+{
+ // -----------------------------------------------------------------------
+ // EpssApiClient
+ // -----------------------------------------------------------------------
+
+ private const string SampleEnvelope = """
+ {"status":"OK","status-code":200,"version":"1.0","access":"public","total":2,"offset":0,"limit":100,
+ "data":[
+ {"cve":"CVE-2024-21447","epss":"0.00383","percentile":"0.73461","date":"2026-08-28"},
+ {"cve":"cve-2023-4863","epss":"0.94210","percentile":"0.99891","date":"2026-08-28"},
+ {"cve":"CVE-2020-0001","epss":"not-a-number","percentile":"0.5","date":"2026-08-28"},
+ {"cve":"","epss":"0.1","percentile":"0.5","date":"2026-08-28"}
+ ]}
+ """;
+
+ [Fact]
+ public void ParseResponse_ParsesInvariantStrings_AndDropsUnparseableRows()
+ {
+ var scores = EpssApiClient.ParseResponse(SampleEnvelope);
+
+ Assert.Equal(2, scores.Count);
+ var a = scores.Single(s => s.CveId == "CVE-2024-21447");
+ Assert.Equal(0.00383, a.Score, 6);
+ Assert.Equal(0.73461, a.Percentile, 6);
+ Assert.Equal("2026-08-28", a.Date);
+ // Ids are normalised to upper case so cache lookups are stable.
+ Assert.Contains(scores, s => s.CveId == "CVE-2023-4863");
+ }
+
+ [Fact]
+ public void ParseResponse_EmptyOrMissingData_ReturnsEmpty()
+ {
+ Assert.Empty(EpssApiClient.ParseResponse("""{"status":"OK","data":[]}"""));
+ Assert.Empty(EpssApiClient.ParseResponse("""{"status":"OK"}"""));
+ }
+
+ private sealed class StubHandler : HttpMessageHandler
+ {
+ public List Requests { get; } = new();
+ private readonly Func _respond;
+ public StubHandler(Func respond) => _respond = respond;
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ Requests.Add(request.RequestUri!);
+ return Task.FromResult(_respond(request));
+ }
+ }
+
+ [Fact]
+ public async Task GetScoresAsync_BatchesByHundred_DedupesIds_AndSurvivesAFailedBatch()
+ {
+ int call = 0;
+ var handler = new StubHandler(req =>
+ {
+ call++;
+ if (call == 2)
+ return new HttpResponseMessage(HttpStatusCode.InternalServerError);
+ // Echo the first id of the batch back as scored.
+ var query = Uri.UnescapeDataString(req.RequestUri!.Query);
+ var firstId = query.Substring("?cve=".Length).Split(',')[0];
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent($$"""{"status":"OK","data":[{"cve":"{{firstId}}","epss":"0.5","percentile":"0.9","date":"2026-08-28"}]}""")
+ };
+ });
+ var client = new EpssApiClient(new HttpClient(handler), NullLogger.Instance);
+
+ // 150 distinct ids + 10 duplicates (case-varied) ⇒ exactly two requests.
+ var ids = Enumerable.Range(1, 150).Select(i => $"CVE-2024-{10000 + i}").ToList();
+ ids.AddRange(ids.Take(10).Select(id => id.ToLowerInvariant()));
+
+ var result = await client.GetScoresAsync(ids);
+
+ Assert.Equal(2, handler.Requests.Count);
+ Assert.Single(result); // batch 1 scored one id, batch 2 failed soft
+ Assert.True(result.ContainsKey("cve-2024-10001")); // case-insensitive map
+ Assert.All(handler.Requests, u => Assert.StartsWith(EpssApiClient.EpssUrl, u.ToString()));
+ }
+
+ // -----------------------------------------------------------------------
+ // NVD throttle cooldown
+ // -----------------------------------------------------------------------
+
+ private static readonly DateTime Now = new(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void ComputeCooldown_NoHeader_UsesDefault()
+ {
+ Assert.Equal(NvdApiClient.ThrottleCooldown, NvdApiClient.ComputeCooldown(null, Now));
+ }
+
+ [Fact]
+ public void ComputeCooldown_RetryAfterDelta_IsHonoured()
+ {
+ var header = new RetryConditionHeaderValue(TimeSpan.FromSeconds(45));
+ Assert.Equal(TimeSpan.FromSeconds(45), NvdApiClient.ComputeCooldown(header, Now));
+ }
+
+ [Fact]
+ public void ComputeCooldown_RetryAfterDate_IsRelativeToNow()
+ {
+ var header = new RetryConditionHeaderValue(new DateTimeOffset(Now.AddSeconds(20)));
+ Assert.Equal(TimeSpan.FromSeconds(20), NvdApiClient.ComputeCooldown(header, Now));
+ }
+
+ [Fact]
+ public void ComputeCooldown_IsCapped_SoAServerCannotParkUsForAnHour()
+ {
+ var header = new RetryConditionHeaderValue(TimeSpan.FromHours(1));
+ Assert.Equal(NvdApiClient.MaxRetryAfter, NvdApiClient.ComputeCooldown(header, Now));
+ }
+
+ [Fact]
+ public void ComputeCooldown_PastDateOrZero_FallsBackToDefault()
+ {
+ Assert.Equal(NvdApiClient.ThrottleCooldown,
+ NvdApiClient.ComputeCooldown(new RetryConditionHeaderValue(new DateTimeOffset(Now.AddSeconds(-5))), Now));
+ Assert.Equal(NvdApiClient.ThrottleCooldown,
+ NvdApiClient.ComputeCooldown(new RetryConditionHeaderValue(TimeSpan.Zero), Now));
+ }
+
+ [Fact]
+ public void NvdFetchResult_FailureShapes_CarryEmptyDataButNeverSucceeded()
+ {
+ var throttled = NvdFetchResult.ThrottledResult();
+ Assert.False(throttled.Succeeded);
+ Assert.True(throttled.Throttled);
+ Assert.NotNull(throttled.Data);
+ Assert.Empty(throttled.Data.Vulnerabilities);
+
+ var failed = NvdFetchResult.Failed();
+ Assert.False(failed.Succeeded);
+ Assert.False(failed.Throttled);
+
+ var ok = NvdFetchResult.Ok(new NvdCveResponse { TotalResults = 1 });
+ Assert.True(ok.Succeeded);
+ Assert.Equal(1, ok.Data.TotalResults);
+ }
+
+ // -----------------------------------------------------------------------
+ // NVD → CachedCve projection
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void ProjectNvdResponse_CarriesVector_PrefersV31_AndKeepsOnlyVulnerableRanges()
+ {
+ var response = new NvdCveResponse
+ {
+ Vulnerabilities =
+ {
+ new NvdVulnerabilityItem
+ {
+ Cve = new NvdCve
+ {
+ Id = "CVE-2024-1",
+ Published = "2026-01-01T00:00:00.000",
+ Descriptions = { new NvdDescription { Lang = "en", Value = "desc" } },
+ Metrics = new NvdMetrics
+ {
+ CvssMetricV30 = { new NvdCvssMetricV31 { CvssData = new NvdCvssData { BaseScore = 5, BaseSeverity = "MEDIUM", VectorString = "CVSS:3.0/AV:L" } } },
+ CvssMetricV31 = { new NvdCvssMetricV31 { CvssData = new NvdCvssData { BaseScore = 9.8, BaseSeverity = "CRITICAL", VectorString = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" } } },
+ },
+ Configurations =
+ {
+ new NvdConfiguration
+ {
+ Nodes =
+ {
+ new NvdNode
+ {
+ CpeMatch =
+ {
+ new NvdCpeMatch { Vulnerable = true, Criteria = "cpe:2.3:a:v:p:*:*:*:*:*:*:*:*", VersionEndExcluding = "2.0" },
+ new NvdCpeMatch { Vulnerable = false, Criteria = "cpe:2.3:o:microsoft:windows:*:*:*:*:*:*:*:*" },
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ new NvdVulnerabilityItem
+ {
+ Cve = new NvdCve { Id = "CVE-2024-2", Descriptions = { new NvdDescription { Lang = "en", Value = "no metrics" } } }
+ },
+ }
+ };
+
+ var cves = VulnerabilityCorrelationService.ProjectNvdResponse(response);
+
+ Assert.Equal(2, cves.Count);
+ var first = cves[0];
+ Assert.Equal(9.8, first.CvssScore);
+ Assert.Equal("CRITICAL", first.CvssSeverity);
+ Assert.Equal("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", first.CvssVector);
+ Assert.Single(first.AffectedVersions);
+ Assert.Equal("2.0", first.AffectedVersions[0].VersionEndExcluding);
+ Assert.Null(first.EpssScore); // never defaulted before enrichment
+
+ var second = cves[1];
+ Assert.Equal(0, second.CvssScore);
+ Assert.Equal("UNKNOWN", second.CvssSeverity);
+ Assert.Null(second.CvssVector);
+ }
+
+ [Fact]
+ public void ProjectNvdResponse_Null_ReturnsEmpty()
+ {
+ Assert.Empty(VulnerabilityCorrelationService.ProjectNvdResponse(null));
+ }
+
+ // -----------------------------------------------------------------------
+ // EPSS application onto cached CVEs
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void ApplyEpssScores_SetsNewScores_KeepsUnscoredUntouched_AndReportsOnlyChanges()
+ {
+ var cves = new List
+ {
+ new() { CveId = "CVE-1", EpssScore = 0.2, EpssPercentile = 0.8, EpssDate = "2026-08-27" },
+ new() { CveId = "CVE-2" },
+ new() { CveId = "CVE-3", EpssScore = 0.5, EpssPercentile = 0.9, EpssDate = "2026-08-28" },
+ };
+ var scores = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["cve-1"] = new EpssScore { CveId = "CVE-1", Score = 0.25, Percentile = 0.85, Date = "2026-08-28" }, // changed
+ ["CVE-3"] = new EpssScore { CveId = "CVE-3", Score = 0.5, Percentile = 0.9, Date = "2026-08-28" }, // unchanged
+ // CVE-2 absent: FIRST did not score it (or its batch failed) ⇒ stays null
+ };
+
+ var changed = VulnerabilityCorrelationService.ApplyEpssScores(cves, scores);
+
+ Assert.Equal(1, changed);
+ Assert.Equal(0.25, cves[0].EpssScore);
+ Assert.Equal("2026-08-28", cves[0].EpssDate);
+ Assert.Null(cves[1].EpssScore);
+ Assert.Equal(0.5, cves[2].EpssScore);
+ }
+
+ [Fact]
+ public void ApplyEpssScores_NeverErasesYesterdaysScore_WhenTodayHasNone()
+ {
+ var cves = new List { new() { CveId = "CVE-1", EpssScore = 0.3, EpssPercentile = 0.7, EpssDate = "2026-08-27" } };
+ var changed = VulnerabilityCorrelationService.ApplyEpssScores(cves, new Dictionary());
+ Assert.Equal(0, changed);
+ Assert.Equal(0.3, cves[0].EpssScore);
+ }
+
+ [Fact]
+ public void BuildCveCacheRow_PassesCachedAtThrough_SoEpssRewritesDoNotLookLikeNvdFetches()
+ {
+ var cachedAt = new DateTime(2026, 8, 20, 3, 0, 0, DateTimeKind.Utc);
+ var row = VulnerabilityCorrelationService.BuildCveCacheRow("cpe:2.3:a:v:p", new List { new() { CveId = "CVE-1" } }, cachedAt);
+
+ Assert.Equal(cachedAt.ToString("o"), row["CachedAt"]);
+ Assert.Equal("cpe:2.3:a:v:p", row["CpeUri"]);
+ Assert.Equal(1, row["TotalCves"]);
+ Assert.True(row.ContainsKey("CveDataJson"));
+ }
+
+ [Fact]
+ public void CachedCve_RoundTrips_WithoutWritingNulls_SoLegacyRowsStayReadable()
+ {
+ var json = System.Text.Json.JsonSerializer.Serialize(new List { new() { CveId = "CVE-1", CvssScore = 7 } }, JsonOptions);
+ Assert.DoesNotContain("EpssScore", json);
+ Assert.DoesNotContain("CvssVector", json);
+
+ // A row written before this change (no EPSS/vector properties at all) still deserialises.
+ var legacy = """[{"CveId":"CVE-9","CvssScore":4.3,"CvssSeverity":"MEDIUM","Description":"","PublishedDate":"","AffectedVersions":[]}]""";
+ var cves = System.Text.Json.JsonSerializer.Deserialize>(legacy, JsonOptions)!;
+ Assert.Null(cves[0].EpssScore);
+ Assert.Null(cves[0].CvssVector);
+ }
+
+ // -----------------------------------------------------------------------
+ // Priority
+ // -----------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(true, null, 0.0, CvePriority.Act)]
+ [InlineData(true, 0.001, 2.0, CvePriority.Act)]
+ [InlineData(false, 0.1, 2.0, CvePriority.Attend)]
+ [InlineData(false, 0.0999, 2.0, CvePriority.Track)]
+ [InlineData(false, null, 9.0, CvePriority.Attend)]
+ [InlineData(false, 0.0, 9.8, CvePriority.Attend)]
+ [InlineData(false, null, 8.9, CvePriority.Track)]
+ [InlineData(false, 0.05, 7.5, CvePriority.Track)]
+ public void Compute_FollowsTheThreeRules(bool kev, double? epss, double cvss, string expected)
+ {
+ Assert.Equal(expected, CvePriority.Compute(kev, epss, cvss));
+ }
+
+ [Fact]
+ public void Max_PicksHigherBand_AndTreatsUnknownAsLowest()
+ {
+ Assert.Equal(CvePriority.Act, CvePriority.Max(CvePriority.Track, CvePriority.Act));
+ Assert.Equal(CvePriority.Attend, CvePriority.Max(CvePriority.Attend, null));
+ Assert.Equal(CvePriority.Track, CvePriority.Max(null, ""));
+ }
+
+ [Fact]
+ public void StampFindingPriority_RollsUpHighestBand_AndMaxEpss()
+ {
+ var finding = new Dictionary { ["maxEpssScore"] = 0.9 /* stale value, must be recomputed */ };
+ var vulns = new List
Further connections exist only because you configure them: your own Azure storage account for diagnostics,
diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/VulnerabilityReportSection.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/VulnerabilityReportSection.tsx
index cc01da97..2d34ff58 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/components/VulnerabilityReportSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/components/VulnerabilityReportSection.tsx
@@ -2,6 +2,7 @@
import { useState, useMemo } from "react";
import type { EnrollmentEvent } from "@/types";
+import { CONTEXT_PILL_CLASS, formatEpss, epssTitle, priorityLabel, PRIORITY_TITLE } from "@/lib/vulnerabilitySeverity";
interface VulnerabilityReportSectionProps {
events: EnrollmentEvent[];
@@ -22,6 +23,13 @@ interface CveDetail {
publishedDate?: string;
references?: string[];
versionMatchConfidence?: string;
+ /** CVSS v3.x vector string; absent on reports written before it was persisted. */
+ cvssVector?: string;
+ /** FIRST EPSS probability (0..1); undefined = unscored / pre-EPSS report. */
+ epss?: number;
+ epssPercentile?: number;
+ /** act / attend / track; undefined on pre-priority reports. */
+ priority?: string;
}
interface Finding {
@@ -31,6 +39,17 @@ interface Finding {
maxCvss: number;
maxSeverity: string;
kevCount: number;
+ /** Highest EPSS across the finding's CVEs; null when none is scored. */
+ maxEpss: number | null;
+ priority?: string;
+}
+
+const PRIORITY_ORDER: Record = { act: 0, attend: 1, track: 2 };
+
+function maxPriority(a?: string, b?: string): string | undefined {
+ if (!a) return b;
+ if (!b) return a;
+ return (PRIORITY_ORDER[a] ?? 3) <= (PRIORITY_ORDER[b] ?? 3) ? a : b;
}
interface InventoryItem {
@@ -94,7 +113,7 @@ function confidenceTooltip(confidence?: string): string {
return "Unknown identification confidence";
}
-type FindingsSortKey = "severity" | "software" | "cves" | "cvss";
+type FindingsSortKey = "severity" | "software" | "cves" | "cvss" | "epss";
// Module-level so React keeps a stable component identity across renders
// (react-hooks/static-components: an inline component type remounts on every render).
@@ -154,9 +173,15 @@ export default function VulnerabilityReportSection({
publishedDate: (c.publishedDate || c.published_date || "") as string,
references: (c.references || []) as string[],
versionMatchConfidence: (c.versionMatchConfidence || c.version_match_confidence) as string | undefined,
+ cvssVector: (c.cvssVector as string | undefined) || undefined,
+ epss: typeof c.epssScore === "number" ? c.epssScore : undefined,
+ epssPercentile: typeof c.epssPercentile === "number" ? c.epssPercentile : undefined,
+ priority: (c.priority as string | undefined) || undefined,
}));
const maxCvss = cves.reduce((max: number, c: CveDetail) => Math.max(max, c.cvss ?? 0), 0);
+ const maxEpss = cves.reduce((max, c) => (c.epss != null && (max == null || c.epss > max) ? c.epss : max), null);
+ const findingPriority = cves.reduce((best, c) => maxPriority(best, c.priority), undefined);
const maxSev = cves.reduce((worst: string, c: CveDetail) => {
const cSev = c.severity || severityFromCvss(c.cvss ?? 0);
return (SEVERITY_ORDER[cSev] ?? 4) < (SEVERITY_ORDER[worst] ?? 4) ? cSev : worst;
@@ -175,6 +200,8 @@ export default function VulnerabilityReportSection({
existing.maxCvss = Math.max(existing.maxCvss, maxCvss);
existing.maxSeverity = (SEVERITY_ORDER[maxSev] ?? 4) < (SEVERITY_ORDER[existing.maxSeverity] ?? 4) ? maxSev : existing.maxSeverity;
existing.kevCount += cveKevCount;
+ if (maxEpss != null && (existing.maxEpss == null || maxEpss > existing.maxEpss)) existing.maxEpss = maxEpss;
+ existing.priority = maxPriority(existing.priority, findingPriority);
} else {
findingsMap.set(key, {
softwareName: f.softwareName || f.software || "Unknown",
@@ -183,6 +210,8 @@ export default function VulnerabilityReportSection({
maxCvss,
maxSeverity: maxSev,
kevCount: cveKevCount,
+ maxEpss,
+ priority: findingPriority,
});
}
mapped.add((f.softwareName || f.software || "").toLowerCase().trim());
@@ -370,6 +399,10 @@ export default function VulnerabilityReportSection({
case "cvss":
cmp = a.maxCvss - b.maxCvss;
break;
+ case "epss":
+ // Unscored sorts below every scored value in both directions.
+ cmp = (a.maxEpss ?? -1) - (b.maxEpss ?? -1);
+ break;
}
return findingsSortAsc ? cmp : -cmp;
});
@@ -590,6 +623,9 @@ export default function VulnerabilityReportSection({
toggleFindingsSort("cvss")}>
Max CVSS
+
toggleFindingsSort("epss")} title="Highest FIRST EPSS exploitation probability among the finding's CVEs">
+ EPSS
+
))}
diff --git a/src/Web/autopilot-monitor-web/app/terms/page.tsx b/src/Web/autopilot-monitor-web/app/terms/page.tsx
index f0ee2ace..341ac89f 100644
--- a/src/Web/autopilot-monitor-web/app/terms/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/terms/page.tsx
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL } from "@/utils/config";
-const LAST_UPDATED = "31 July 2026";
+const LAST_UPDATED = "29 August 2026";
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
const DOCS_PLANS = `${DOCS_URL}/plans`;
const GITHUB_ADVISORY = "https://github.com/okieselbach/AutopilotMonitor/security/advisories/new";
@@ -199,6 +199,14 @@ export default function TermsPage() {
msrc.microsoft.com.
+
+
Exploit Prediction Scoring System (EPSS), FIRST.org
+
+ Exploitation-probability scores (EPSS) are provided by the Forum of Incident Response and Security Teams (FIRST). The lookup
+ sends only public CVE identifiers from the shared reference cache — never tenant or device data. For more information, visit{" "}
+ first.org/epss.
+
+
diff --git a/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx b/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx
index a341b754..11c35254 100644
--- a/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx
+++ b/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx
@@ -1,6 +1,6 @@
"use client";
-import { severityChipClass, KEV_BADGE_CLASS } from "@/lib/vulnerabilitySeverity";
+import { severityChipClass, KEV_BADGE_CLASS, CONTEXT_PILL_CLASS, formatEpss, epssTitle, priorityLabel, PRIORITY_TITLE } from "@/lib/vulnerabilitySeverity";
import TruncatedLabel from "@/components/TruncatedLabel";
export interface VulnerabilityTopCve {
@@ -8,6 +8,10 @@ export interface VulnerabilityTopCve {
cvssSeverity: string;
cvssScore: number;
isKev: boolean;
+ /** FIRST EPSS probability (0..1); null/absent when unscored or for rows written before EPSS. */
+ epssScore?: number | null;
+ /** act / attend / track; empty for rows written before priorities existed. */
+ priority?: string;
affectedSessions: number;
sampleSoftware: string[];
}
@@ -135,6 +139,12 @@ export default function VulnerabilityExposurePanel({
{c.isKev && (
KEV
)}
+ {c.epssScore != null && (
+ {formatEpss(c.epssScore)}
+ )}
+ {priorityLabel(c.priority) && (
+ {priorityLabel(c.priority)}
+ )}
{c.sampleSoftware.length > 0 && (
diff --git a/src/Web/autopilot-monitor-web/lib/vulnerabilitySeverity.ts b/src/Web/autopilot-monitor-web/lib/vulnerabilitySeverity.ts
index 77be9d8c..b1f05ad4 100644
--- a/src/Web/autopilot-monitor-web/lib/vulnerabilitySeverity.ts
+++ b/src/Web/autopilot-monitor-web/lib/vulnerabilitySeverity.ts
@@ -21,3 +21,36 @@ export function severityChipClass(severity: string): string {
/** Tailwind classes for the CISA Known-Exploited (KEV) badge. */
export const KEV_BADGE_CLASS =
"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 border-red-300 dark:border-red-700";
+
+/**
+ * Context-label pill (gray outline — no new color family): used for the EPSS score and the
+ * act/attend/track priority so they read as information next to the severity chip, not as
+ * a second severity scale.
+ */
+export const CONTEXT_PILL_CLASS =
+ "inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-medium border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-300";
+
+/** "EPSS 12.3%" — one decimal, or two when the score is below 1% so small scores don't read as 0. */
+export function formatEpss(score: number): string {
+ const pct = score * 100;
+ return `EPSS ${pct < 1 ? pct.toFixed(2) : pct.toFixed(1)}%`;
+}
+
+/** Tooltip for the EPSS pill; percentile optional (0..1). */
+export function epssTitle(score: number, percentile?: number | null): string {
+ const base = `EPSS (FIRST.org): ${(score * 100).toFixed(2)}% estimated probability of exploitation in the wild within the next 30 days`;
+ return percentile != null ? `${base} — higher than ${(percentile * 100).toFixed(1)}% of all scored CVEs` : base;
+}
+
+/** Human label for the remediation priority band. */
+export function priorityLabel(priority?: string | null): string | null {
+ switch ((priority || "").toLowerCase()) {
+ case "act": return "Act";
+ case "attend": return "Attend";
+ case "track": return "Track";
+ default: return null;
+ }
+}
+
+export const PRIORITY_TITLE =
+ "Remediation priority — Act: listed in CISA KEV (exploitation confirmed). Attend: EPSS ≥ 10% or CVSS ≥ 9.0. Track: everything else.";