feat(vuln): FIRST EPSS as fourth source, act/attend/track priority, CVSS vector; never cache a refused NVD answer

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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-29 11:07:31 +02:00
parent d3918cb740
commit a6184f165b
24 changed files with 1378 additions and 192 deletions
@@ -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<T>` 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
+1
View File
@@ -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
+4
View File
@@ -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`.
@@ -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<Dictionary<string, object>>
{
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<Dictionary<string, object>>
{
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()
{
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<Uri> Requests { get; } = new();
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) => _respond = respond;
protected override Task<HttpResponseMessage> 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<EpssApiClient>.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<NvdCveResponse>.ThrottledResult();
Assert.False(throttled.Succeeded);
Assert.True(throttled.Throttled);
Assert.NotNull(throttled.Data);
Assert.Empty(throttled.Data.Vulnerabilities);
var failed = NvdFetchResult<NvdCveResponse>.Failed();
Assert.False(failed.Succeeded);
Assert.False(failed.Throttled);
var ok = NvdFetchResult<NvdCveResponse>.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<CachedCve>
{
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<string, EpssScore>(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<CachedCve> { new() { CveId = "CVE-1", EpssScore = 0.3, EpssPercentile = 0.7, EpssDate = "2026-08-27" } };
var changed = VulnerabilityCorrelationService.ApplyEpssScores(cves, new Dictionary<string, EpssScore>());
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<CachedCve> { 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<CachedCve> { 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<List<CachedCve>>(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<string, object> { ["maxEpssScore"] = 0.9 /* stale value, must be recomputed */ };
var vulns = new List<object>
{
new Dictionary<string, object> { ["priority"] = CvePriority.Track, ["epssScore"] = 0.02 },
new Dictionary<string, object> { ["priority"] = CvePriority.Attend, ["epssScore"] = 0.4 },
new Dictionary<string, object> { ["priority"] = CvePriority.Track },
};
VulnerabilityCorrelationService.StampFindingPriority(finding, vulns);
Assert.Equal(CvePriority.Attend, finding["priority"]);
Assert.Equal(0.4, finding["maxEpssScore"]);
}
[Fact]
public void StampFindingPriority_NoScoredCves_OmitsMaxEpss()
{
var finding = new Dictionary<string, object>();
VulnerabilityCorrelationService.StampFindingPriority(finding, new List<object> { new Dictionary<string, object> { ["priority"] = CvePriority.Track } });
Assert.Equal(CvePriority.Track, finding["priority"]);
Assert.False(finding.ContainsKey("maxEpssScore"));
}
}
@@ -48,6 +48,25 @@ public class VulnerabilitySummaryAggregatorTests
Assert.Equal(1, r.DistinctCves);
}
[Fact]
public void TopCve_CarriesMaxEpssAndHighestPriority_NullWhenNothingScored()
{
var scored = Row("CVE-E", "s1", "Medium", 5.0); scored.EpssScore = 0.12; scored.Priority = "attend";
var scoredLower = Row("CVE-E", "s2", "Medium", 5.0); scoredLower.EpssScore = 0.03; scoredLower.Priority = "track";
var legacy = Row("CVE-E", "s3", "Medium", 5.0); // pre-EPSS row: null score, empty priority
var unscored = Row("CVE-U", "s1", "Low", 2.0);
var r = VulnerabilitySummaryAggregator.Aggregate(new[] { scored, scoredLower, legacy, unscored }, false, 20, 30, false, Now);
var e = r.TopCves.Single(c => c.CveId == "CVE-E");
Assert.Equal(0.12, e.EpssScore);
Assert.Equal("attend", e.Priority);
var u = r.TopCves.Single(c => c.CveId == "CVE-U");
Assert.Null(u.EpssScore);
Assert.Equal("", u.Priority);
}
[Fact]
public void BuildsSeverityBreakdownByDistinctCveAndCountsKev()
{
@@ -90,7 +90,17 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
continue;
}
var cpeResponse = await _nvdApi.SearchCpeAsync(keyword);
var cpeFetch = await _nvdApi.SearchCpeAsync(keyword);
if (!cpeFetch.Succeeded)
{
failed.Add(new AutoResolveFailedItem
{
SoftwareName = item.SoftwareName,
Reason = cpeFetch.Throttled ? "nvd_throttled" : "nvd_unavailable"
});
continue;
}
var cpeResponse = cpeFetch.Data;
var bestMatch = CpeMatchScorer.SelectBestMatch(
cpeResponse.Products,
item.NormalizedVendor ?? item.Publisher ?? "",
@@ -27,6 +27,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
private readonly KevDataService _kevService;
private readonly NvdApiClient _nvdApi;
private readonly EpssApiClient _epssApi;
private readonly MsrcCorrelationService _msrcCorrelation;
private readonly IVulnerabilityRepository _vulnRepo;
private readonly AdminConfigurationService _adminConfigService;
@@ -36,6 +37,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
public VulnerabilityDataSyncFunction(
KevDataService kevService,
NvdApiClient nvdApi,
EpssApiClient epssApi,
MsrcCorrelationService msrcCorrelation,
IVulnerabilityRepository vulnRepo,
AdminConfigurationService adminConfigService,
@@ -44,6 +46,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
_kevService = kevService;
_nvdApi = nvdApi;
_epssApi = epssApi;
_msrcCorrelation = msrcCorrelation;
_vulnRepo = vulnRepo;
_adminConfigService = adminConfigService;
@@ -113,6 +116,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
cveRefreshed = cveStats.Refreshed;
cveFailed = cveStats.Failed;
// 3b. Re-score EVERY cached CVE row with today's EPSS. Independent of the 7-day NVD
// TTL: EPSS is republished daily and the calls are cheap (one batch per 100 ids).
var epssStats = await RefreshEpssScoresAsync();
// 4. Update per-source sync timestamps. Only the sources that succeeded get
// bumped — a failed phase keeps its prior timestamp so the UI can surface
// "Last KEV/MSRC Sync" accurately.
@@ -156,6 +163,12 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
{ "cveStale", cveStale.ToString() },
{ "cveRefreshed", cveRefreshed.ToString() },
{ "cveFailed", cveFailed.ToString() },
{ "cveSkippedThrottled", cveStats.SkippedThrottled.ToString() },
{ "epssRows", epssStats.Rows.ToString() },
{ "epssCves", epssStats.Cves.ToString() },
{ "epssScored", epssStats.Scored.ToString() },
{ "epssRowsRewritten", epssStats.RowsRewritten.ToString() },
{ "epssError", epssStats.Error ?? "" },
{ "durationMs", durationMs.ToString("F0", System.Globalization.CultureInfo.InvariantCulture) }
});
}
@@ -165,6 +178,90 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
public int Stale;
public int Refreshed;
public int Failed;
/// <summary>Stale entries left untouched because NVD opened its throttle cooldown mid-run.</summary>
public int SkippedThrottled;
}
private struct EpssRefreshStats
{
public int Rows;
public int Cves;
public int Scored;
public int RowsRewritten;
public string? Error;
}
/// <summary>
/// Walks every <c>cve_*</c> cache row, batches all CVE ids through the EPSS API and rewrites
/// only the rows whose scores actually changed — with the row's ORIGINAL <c>CachedAt</c>, so
/// an EPSS rewrite never counts as a fresh NVD fetch for the 7-day TTL.
/// </summary>
private async Task<EpssRefreshStats> RefreshEpssScoresAsync()
{
var stats = new EpssRefreshStats();
try
{
var index = await _vulnRepo.GetStaleCveCacheEntriesAsync();
var rows = new List<(string CacheKey, string CpeUri, DateTime CachedAt, List<CachedCve> Cves)>();
foreach (var entry in index)
{
var pk = entry.ContainsKey("PartitionKey") ? entry["PartitionKey"]?.ToString() : null;
if (pk == null || !pk.StartsWith("cve_")) continue;
var full = await _vulnRepo.GetCveCacheEntryAsync(pk, "cache_result");
if (full == null) continue;
var cachedAtStr = full.ContainsKey("CachedAt") ? full["CachedAt"]?.ToString() : null;
if (!DateTime.TryParse(cachedAtStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var cachedAt))
continue;
var json = TableStorageChunking.ReassembleProperty(full, "CveDataJson", _logger, $"{pk}/cache_result") ?? "[]";
var cves = System.Text.Json.JsonSerializer.Deserialize<List<CachedCve>>(json, JsonOptions) ?? new List<CachedCve>();
if (cves.Count == 0) continue;
var cpeUri = full.ContainsKey("CpeUri") ? full["CpeUri"]?.ToString() ?? pk.Substring(4) : pk.Substring(4);
rows.Add((pk, cpeUri, cachedAt, cves));
}
stats.Rows = rows.Count;
var allIds = rows.SelectMany(r => r.Cves.Select(c => c.CveId)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
stats.Cves = allIds.Count;
if (allIds.Count == 0)
{
_logger.LogInformation("[VulnSync] EPSS refresh: no cached CVEs to score");
return stats;
}
var scores = await _epssApi.GetScoresAsync(allIds, CancellationToken.None);
stats.Scored = scores.Count;
foreach (var (cacheKey, cpeUri, cachedAt, cves) in rows)
{
var changed = VulnerabilityCorrelationService.ApplyEpssScores(cves, scores);
if (changed == 0) continue;
try
{
await _vulnRepo.UpsertCveCacheEntryAsync(cacheKey, "cache_result",
VulnerabilityCorrelationService.BuildCveCacheRow(cpeUri, cves, cachedAt));
stats.RowsRewritten++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[VulnSync] EPSS rewrite failed for {CpeUri}", cpeUri);
}
}
_logger.LogInformation(
"[VulnSync] EPSS refresh: {Scored}/{Cves} CVEs scored across {Rows} rows, {Rewritten} rows rewritten",
stats.Scored, stats.Cves, stats.Rows, stats.RowsRewritten);
}
catch (Exception ex)
{
stats.Error = ex.Message;
_logger.LogWarning(ex, "[VulnSync] EPSS refresh failed");
}
return stats;
}
/// <summary>
@@ -212,104 +309,59 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
int refreshed = 0;
int failed = 0;
int skippedThrottled = 0;
foreach (var (cacheKey, cpeUri, cachedAt, totalCves) in staleEntries)
for (int i = 0; i < staleEntries.Count; i++)
{
var (cacheKey, cpeUri, cachedAt, totalCves) = staleEntries[i];
try
{
_logger.LogInformation("[VulnSync] Refreshing CVE cache for {CpeUri} (cached: {CachedAt}, had {TotalCves} CVEs)",
cpeUri, cachedAt, totalCves);
// Query NVD API (rate-limited internally by NvdApiClient)
var cveResponse = await _nvdApi.SearchCvesByCpeAsync(cpeUri, CancellationToken.None);
var newCount = cveResponse?.Vulnerabilities?.Count ?? 0;
// Project NVD response → CachedCve list (same shape the read path expects).
var cves = new List<CachedCve>();
if (cveResponse?.Vulnerabilities != null)
var fetch = await _nvdApi.SearchCvesByCpeAsync(cpeUri, CancellationToken.None);
if (fetch.Throttled)
{
foreach (var vulnItem in cveResponse.Vulnerabilities)
{
var cve = vulnItem?.Cve;
if (cve == null) continue;
double cvssScore = 0;
string cvssSeverity = "UNKNOWN";
var cvssMetric = cve.Metrics?.CvssMetricV31?.FirstOrDefault()
?? cve.Metrics?.CvssMetricV30?.FirstOrDefault();
if (cvssMetric?.CvssData != null)
{
cvssScore = cvssMetric.CvssData.BaseScore;
cvssSeverity = cvssMetric.CvssData.BaseSeverity ?? "UNKNOWN";
}
var description = cve.Descriptions?.FirstOrDefault(d =>
string.Equals(d.Lang, "en", StringComparison.OrdinalIgnoreCase))?.Value ?? "";
if (description.Length > 500)
description = description.Substring(0, 497) + "...";
var affectedVersions = new List<CachedVersionRange>();
if (cve.Configurations != null)
{
foreach (var config in cve.Configurations)
{
if (config.Nodes == null) continue;
foreach (var node in config.Nodes)
{
if (node.CpeMatch == null) continue;
foreach (var match in node.CpeMatch)
{
if (!match.Vulnerable) continue;
affectedVersions.Add(new CachedVersionRange
{
Criteria = match.Criteria,
VersionStartIncluding = match.VersionStartIncluding,
VersionStartExcluding = match.VersionStartExcluding,
VersionEndIncluding = match.VersionEndIncluding,
VersionEndExcluding = match.VersionEndExcluding
});
}
}
}
}
cves.Add(new CachedCve
{
CveId = cve.Id ?? "",
CvssScore = cvssScore,
CvssSeverity = cvssSeverity,
Description = description,
PublishedDate = cve.Published ?? "",
AffectedVersions = affectedVersions
});
}
// NVD is refusing us; the stale rows keep serving (soft-stale reads
// still return data) and get another attempt tomorrow. Bail out of
// the loop instead of paying the cooldown skip per remaining entry.
skippedThrottled = staleEntries.Count - i;
_logger.LogWarning("[VulnSync] NVD throttled — leaving {Remaining} stale entries for the next run", skippedThrottled);
break;
}
if (!fetch.Succeeded)
{
failed++;
continue; // row untouched: never overwrite real data with a refusal
}
// Apply the same cache budget the live FetchAndCache path uses,
// so the nightly refresh can't reintroduce oversized rows.
// Project NVD response → CachedCve list (same shape the read path expects),
// under the same cache budget the live FetchAndCache path uses.
var cves = VulnerabilityCorrelationService.ProjectNvdResponse(fetch.Data);
var budget = CveCacheBudget.Apply(cves, DateTime.UtcNow);
cves = budget.Cves;
var cveDataJson = System.Text.Json.JsonSerializer.Serialize(cves, JsonOptions);
var data = new Dictionary<string, object>
// EPSS is re-applied for all rows in step 3b; scoring here keeps the row
// complete in case that step fails.
try
{
{ "CachedAt", DateTime.UtcNow.ToString("o") },
{ "CpeUri", cpeUri },
{ "TotalCves", cves.Count }
};
var scores = await _epssApi.GetScoresAsync(cves.Select(c => c.CveId), CancellationToken.None);
VulnerabilityCorrelationService.ApplyEpssScores(cves, scores);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[VulnSync] EPSS enrichment failed for {CpeUri}", cpeUri);
}
// Chunk CveDataJson across multiple properties if it exceeds 30K chars
foreach (var chunk in TableStorageChunking.ChunkProperty("CveDataJson", cveDataJson))
data[chunk.Key] = chunk.Value;
await _vulnRepo.UpsertCveCacheEntryAsync(cacheKey, "cache_result", data);
var row = VulnerabilityCorrelationService.BuildCveCacheRow(cpeUri, cves, DateTime.UtcNow);
await _vulnRepo.UpsertCveCacheEntryAsync(cacheKey, "cache_result", row);
refreshed++;
_logger.LogInformation(
"[VulnSync] Refreshed {CpeUri}: {OldCount} → {NewCount} CVEs (NVD={Total}, afterDate={AfterDate}, truncRanges={TruncCves}, bytes={Bytes})",
"[VulnSync] Refreshed {CpeUri}: {OldCount} → {NewCount} CVEs (NVD={Total}, afterDate={AfterDate}, truncRanges={TruncCves})",
cpeUri, totalCves, cves.Count,
budget.TotalIn, budget.KeptAfterDateFilter, budget.CvesWithTruncatedRanges, cveDataJson.Length);
budget.TotalIn, budget.KeptAfterDateFilter, budget.CvesWithTruncatedRanges);
}
catch (Exception ex)
{
@@ -318,11 +370,12 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
}
}
_logger.LogInformation("[VulnSync] CVE cache refresh complete: {Refreshed} refreshed, {Failed} failed out of {Total} stale entries",
refreshed, failed, staleEntries.Count);
_logger.LogInformation("[VulnSync] CVE cache refresh complete: {Refreshed} refreshed, {Failed} failed, {Skipped} skipped (throttled) out of {Total} stale entries",
refreshed, failed, skippedThrottled, staleEntries.Count);
stats.Refreshed = refreshed;
stats.Failed = failed;
stats.SkippedThrottled = skippedThrottled;
}
catch (Exception ex)
{
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Shared.Models.Vulnerability;
namespace AutopilotMonitor.Functions.Functions.Vulnerability
@@ -19,12 +20,18 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
/// <summary>Distinct-CVE counts grouped by their highest CVSS severity band.</summary>
public record SeverityBreakdown(int Critical, int High, int Medium, int Low);
/// <summary>A single CVE ranked by how many devices it affects in scope.</summary>
/// <summary>
/// A single CVE ranked by how many devices it affects in scope. <paramref name="EpssScore"/>
/// is the highest EPSS probability seen across the rows (null = unscored / legacy rows);
/// <paramref name="Priority"/> the highest act/attend/track band (empty on legacy rows).
/// </summary>
public record TopCve(
string CveId,
string CvssSeverity,
double CvssScore,
bool IsKev,
double? EpssScore,
string Priority,
int AffectedSessions,
IReadOnlyList<string> SampleSoftware);
@@ -76,6 +83,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
SeverityRank = SeverityRank(topSeverityRow.CvssSeverity),
CvssScore = g.Max(r => r.CvssScore),
IsKev = g.Any(r => r.IsKev),
// Max over a nullable ignores nulls and yields null when nothing is scored.
EpssScore = g.Max(r => r.EpssScore),
Priority = g.Select(r => r.Priority)
.Aggregate(string.Empty, (best, p) => CvePriority.Rank(p) > CvePriority.Rank(best) ? p : best),
AffectedSessions = g.Select(r => r.SessionId)
.Where(s => !string.IsNullOrEmpty(s))
.Distinct(StringComparer.OrdinalIgnoreCase).Count(),
@@ -93,7 +104,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
.ThenByDescending(c => c.CvssScore)
.ThenBy(c => c.CveId, StringComparer.OrdinalIgnoreCase)
.Take(topN)
.Select(c => new TopCve(c.CveId, c.Severity, c.CvssScore, c.IsKev, c.AffectedSessions, c.SampleSoftware))
.Select(c => new TopCve(c.CveId, c.Severity, c.CvssScore, c.IsKev, c.EpssScore, c.Priority, c.AffectedSessions, c.SampleSoftware))
.ToList();
var breakdown = new SeverityBreakdown(
@@ -407,6 +407,8 @@ builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.MsrcApiClient>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.EpssApiClient>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Vulnerability.MsrcCorrelationService>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelationService>();
// Hydrate MSRC + KEV in-memory caches from blob snapshots at app startup (fire-and-forget;
@@ -319,6 +319,9 @@ namespace AutopilotMonitor.Functions.Services
var cvssSeverity = vuln.TryGetValue("cvssSeverity", out var csvs) ? csvs?.ToString() ?? "" : "";
var isKev = vuln.TryGetValue("isKev", out var ik) && ik is bool ikb && ikb;
double? epssScore = null;
try { if (vuln.TryGetValue("epssScore", out var es) && es != null) epssScore = Convert.ToDouble(es); } catch { }
var priority = vuln.TryGetValue("priority", out var pr) ? pr?.ToString() ?? "" : "";
if (byCve.TryGetValue(cveId, out var existing))
{
@@ -329,12 +332,16 @@ namespace AutopilotMonitor.Functions.Services
existing["SoftwareName"] = softwareName;
}
existing["IsKev"] = existing.GetBoolean("IsKev") == true || isKev;
if (epssScore.HasValue && epssScore.Value > (existing.GetDouble("EpssScore") ?? -1))
existing["EpssScore"] = epssScore.Value;
if (Vulnerability.CvePriority.Rank(priority) > Vulnerability.CvePriority.Rank(existing.GetString("Priority")))
existing["Priority"] = priority;
if (RiskRank(overallRisk) > RiskRank(existing.GetString("OverallRisk")))
existing["OverallRisk"] = overallRisk;
continue;
}
byCve[cveId] = new TableEntity($"{tenantId}_{cveId}", sessionId)
var entity = new TableEntity($"{tenantId}_{cveId}", sessionId)
{
["SessionId"] = sessionId,
["TenantId"] = tenantId,
@@ -343,9 +350,14 @@ namespace AutopilotMonitor.Functions.Services
["CvssScore"] = cvssScore,
["CvssSeverity"] = cvssSeverity,
["IsKev"] = isKev,
["Priority"] = priority,
["OverallRisk"] = overallRisk,
["DetectedAt"] = now,
};
// Nullable double: write only when scored so a legacy reader sees "absent",
// not a fake 0.0 that would sort as "least likely exploited".
if (epssScore.HasValue) entity["EpssScore"] = epssScore.Value;
byCve[cveId] = entity;
}
}
@@ -1364,7 +1376,7 @@ namespace AutopilotMonitor.Functions.Services
var select = new[]
{
"SessionId", "TenantId", "CveId", "SoftwareName",
"CvssScore", "CvssSeverity", "IsKev", "OverallRisk", "DetectedAt",
"CvssScore", "CvssSeverity", "IsKev", "EpssScore", "Priority", "OverallRisk", "DetectedAt",
};
var rows = new List<CveExposureEntry>(Math.Min(maxRows, 2048));
@@ -1392,6 +1404,8 @@ namespace AutopilotMonitor.Functions.Services
CvssScore = e.GetDouble("CvssScore") ?? 0,
CvssSeverity = e.GetString("CvssSeverity") ?? string.Empty,
IsKev = e.GetBoolean("IsKev") ?? false,
EpssScore = e.GetDouble("EpssScore"),
Priority = e.GetString("Priority") ?? string.Empty,
OverallRisk = e.GetString("OverallRisk") ?? string.Empty,
DetectedAt = e.GetDateTime("DetectedAt"),
});
@@ -0,0 +1,48 @@
using System;
namespace AutopilotMonitor.Functions.Services.Vulnerability
{
/// <summary>
/// Remediation priority of one CVE, derived from the three signals we hold — deliberately a
/// three-band label with visible rules, not an opaque composite score:
/// <list type="bullet">
/// <item><c>act</c> — listed in CISA KEV: exploitation is confirmed, patch first.</item>
/// <item><c>attend</c> — EPSS ≥ <see cref="AttendEpssThreshold"/> (a real exploitation
/// likelihood; roughly the top decile of all scored CVEs) or CVSS ≥
/// <see cref="AttendCvssThreshold"/> (critical impact).</item>
/// <item><c>track</c> — everything else.</item>
/// </list>
/// This is orthogonal to <c>riskLevel</c>/<c>overall_risk</c> (CVSS + KEV severity bands), which
/// keep their meaning: severity says how bad, priority says how soon. A CVE without an EPSS
/// score is treated as "unknown likelihood" — it can still reach <c>attend</c> via CVSS, never
/// via a defaulted 0.
/// </summary>
public static class CvePriority
{
public const string Act = "act";
public const string Attend = "attend";
public const string Track = "track";
public const double AttendEpssThreshold = 0.1;
public const double AttendCvssThreshold = 9.0;
public static string Compute(bool isKev, double? epssScore, double cvssScore)
{
if (isKev) return Act;
if (epssScore.HasValue && epssScore.Value >= AttendEpssThreshold) return Attend;
if (cvssScore >= AttendCvssThreshold) return Attend;
return Track;
}
/// <summary>Rank for "highest priority wins" merges: act &gt; attend &gt; track &gt; unknown.</summary>
public static int Rank(string? priority) => priority?.ToLowerInvariant() switch
{
Act => 3,
Attend => 2,
Track => 1,
_ => 0,
};
public static string Max(string? a, string? b) => Rank(a) >= Rank(b) ? (a ?? Track) : (b ?? Track);
}
}
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services.Vulnerability
{
/// <summary>
/// One EPSS observation for a CVE: the probability (0..1) that the vulnerability is exploited
/// in the wild within the next 30 days, and the percentile of that score across all scored CVEs.
/// </summary>
public sealed class EpssScore
{
public string CveId { get; init; } = "";
public double Score { get; init; }
public double Percentile { get; init; }
/// <summary>Scoring date as published by FIRST (yyyy-MM-dd).</summary>
public string Date { get; init; } = "";
}
/// <summary>
/// Typed HttpClient for the FIRST.org EPSS API (https://api.first.org/data/v1/epss).
/// Public, unauthenticated, batched by CVE id — one request covers up to
/// <see cref="BatchSize"/> CVEs, which is more than the per-CPE cache budget, so a cache fill
/// costs exactly one EPSS call. Fail-soft: a failed batch is logged and skipped, the CVEs of
/// that batch simply stay without a score (the nightly sync fills them in later). The only
/// thing that leaves the backend is the list of public CVE identifiers held in the shared,
/// tenant-agnostic CPE cache — never anything about a tenant or a device.
/// </summary>
public class EpssApiClient
{
private readonly HttpClient _http;
private readonly ILogger<EpssApiClient> _logger;
internal const string EpssUrl = "https://api.first.org/data/v1/epss";
// The API accepts a comma-joined cve list of up to ~2000 characters (≈ 100 ids).
internal const int BatchSize = 100;
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
public EpssApiClient(HttpClient http, ILogger<EpssApiClient> logger)
{
_http = http ?? throw new ArgumentNullException(nameof(http));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_http.Timeout = TimeSpan.FromSeconds(15);
}
/// <summary>
/// Looks up EPSS scores for the given CVE ids. Returns a case-insensitive map keyed by CVE
/// id containing only the ids FIRST returned a score for (a CVE without a score is a normal
/// outcome — brand-new or rejected CVEs are not scored). Duplicates and blanks are ignored.
/// </summary>
public async Task<Dictionary<string, EpssScore>> GetScoresAsync(IEnumerable<string> cveIds, CancellationToken ct = default)
{
var result = new Dictionary<string, EpssScore>(StringComparer.OrdinalIgnoreCase);
var ids = cveIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim().ToUpperInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (ids.Count == 0) return result;
for (int i = 0; i < ids.Count; i += BatchSize)
{
ct.ThrowIfCancellationRequested();
var batch = ids.Skip(i).Take(BatchSize).ToList();
var url = $"{EpssUrl}?cve={Uri.EscapeDataString(string.Join(",", batch))}";
var started = DateTime.UtcNow;
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("User-Agent", "AutopilotMonitor/1.0");
using var response = await _http.SendAsync(request, ct);
var durationMs = (DateTime.UtcNow - started).TotalMilliseconds;
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"EPSS API returned {StatusCode} for a batch of {Count} CVEs after {DurationMs:F0}ms",
(int)response.StatusCode, batch.Count, durationMs);
continue;
}
var json = await response.Content.ReadAsStringAsync(ct);
var parsed = ParseResponse(json);
foreach (var score in parsed)
result[score.CveId] = score;
_logger.LogInformation(
"EPSS API OK: {Scored}/{Requested} CVEs scored in {DurationMs:F0}ms",
parsed.Count, batch.Count, durationMs);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "EPSS API request failed for a batch of {Count} CVEs", batch.Count);
}
}
return result;
}
/// <summary>
/// Parses the FIRST response envelope <c>{ "status": "OK", "data": [ { "cve", "epss",
/// "percentile", "date" } ] }</c>. <c>epss</c> and <c>percentile</c> are JSON strings,
/// parsed invariant. Entries that do not parse are dropped, never defaulted to 0 — a
/// missing score must stay "unknown", not "safe".
/// </summary>
internal static List<EpssScore> ParseResponse(string json)
{
var list = new List<EpssScore>();
var envelope = JsonSerializer.Deserialize<EpssEnvelope>(json, JsonOptions);
if (envelope?.Data == null) return list;
foreach (var row in envelope.Data)
{
if (string.IsNullOrWhiteSpace(row.Cve)) continue;
if (!double.TryParse(row.Epss, NumberStyles.Float, CultureInfo.InvariantCulture, out var score)) continue;
if (!double.TryParse(row.Percentile, NumberStyles.Float, CultureInfo.InvariantCulture, out var percentile)) continue;
if (score < 0 || score > 1 || percentile < 0 || percentile > 1) continue;
list.Add(new EpssScore
{
CveId = row.Cve.Trim().ToUpperInvariant(),
Score = score,
Percentile = percentile,
Date = row.Date ?? ""
});
}
return list;
}
private sealed class EpssEnvelope
{
public string? Status { get; set; }
public List<EpssRow>? Data { get; set; }
}
private sealed class EpssRow
{
public string? Cve { get; set; }
public string? Epss { get; set; }
public string? Percentile { get; set; }
public string? Date { get; set; }
}
}
}
@@ -9,9 +9,44 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services.Vulnerability
{
/// <summary>
/// Outcome of one NVD request. <see cref="Data"/> is never null — on failure it is an empty
/// response object so callers that only enumerate keep working — but <see cref="Succeeded"/>
/// is the ONLY signal that the empty list is a real answer. Callers that persist results
/// (CVE cache, negative auto-resolve cache) must check it: caching a throttled response as
/// "no CVEs" poisoned the 7-day cache for every product NVD happened to throttle.
/// </summary>
public sealed class NvdFetchResult<T> where T : class, new()
{
public T Data { get; }
public bool Succeeded { get; }
/// <summary>True when the failure was HTTP 429 (or a fail-fast during the resulting cooldown).</summary>
public bool Throttled { get; }
private NvdFetchResult(T data, bool succeeded, bool throttled)
{
Data = data;
Succeeded = succeeded;
Throttled = throttled;
}
public static NvdFetchResult<T> Ok(T data) => new(data, true, false);
public static NvdFetchResult<T> Failed() => new(new T(), false, false);
public static NvdFetchResult<T> ThrottledResult() => new(new T(), false, true);
}
/// <summary>
/// Typed HttpClient for the NVD API 2.0.
/// Handles rate limiting (5 req/30s without key, 50 req/30s with key).
/// <para>
/// Throttle contract: the HttpClient's <c>ExternalDataApi</c> Polly policy already retries a
/// 429 three times (1/2/4 s + jitter), so a 429 reaching this class means NVD is refusing us
/// for real. We then open a process-wide cooldown (<see cref="ThrottleCooldown"/>, or the
/// server's <c>Retry-After</c> capped at <see cref="MaxRetryAfter"/>) during which every
/// request fails fast with <see cref="NvdFetchResult{T}.Throttled"/> instead of burning
/// another retry chain per software item. Bounded worst case per cooldown window: ONE
/// retry chain (~7.5 s), never a stall of the whole correlation run.
/// </para>
/// </summary>
public class NvdApiClient
{
@@ -21,6 +56,34 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
private static readonly SemaphoreSlim _rateLimiter = new SemaphoreSlim(1, 1);
private static DateTime _lastRequestTime = DateTime.MinValue;
private static DateTime _throttledUntilUtc = DateTime.MinValue;
internal static readonly TimeSpan ThrottleCooldown = TimeSpan.FromSeconds(30);
internal static readonly TimeSpan MaxRetryAfter = TimeSpan.FromSeconds(120);
/// <summary>True while the process-wide 429 cooldown is active.</summary>
public static bool IsInCooldown => DateTime.UtcNow < _throttledUntilUtc;
/// <summary>
/// Cooldown length for a 429: the server's <c>Retry-After</c> (delta-seconds or HTTP date)
/// when present and sane, otherwise <see cref="ThrottleCooldown"/>; never above
/// <see cref="MaxRetryAfter"/> so a misconfigured header cannot park us for an hour.
/// </summary>
internal static TimeSpan ComputeCooldown(System.Net.Http.Headers.RetryConditionHeaderValue? retryAfter, DateTime nowUtc)
{
TimeSpan? fromHeader = null;
if (retryAfter?.Delta is TimeSpan delta)
fromHeader = delta;
else if (retryAfter?.Date is DateTimeOffset date)
fromHeader = date.UtcDateTime - nowUtc;
if (fromHeader is null || fromHeader.Value <= TimeSpan.Zero)
return ThrottleCooldown;
return fromHeader.Value > MaxRetryAfter ? MaxRetryAfter : fromHeader.Value;
}
/// <summary>Test seam: clears the cooldown so suites do not leak state into each other.</summary>
internal static void ResetThrottleState() => _throttledUntilUtc = DateTime.MinValue;
private const string NvdCveUrl = "https://services.nvd.nist.gov/rest/json/cves/2.0";
private const string NvdCpeUrl = "https://services.nvd.nist.gov/rest/json/cpes/2.0";
@@ -42,7 +105,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
/// <summary>
/// Search CVEs for a specific CPE name. Returns CVEs with version range configurations.
/// </summary>
public async Task<NvdCveResponse> SearchCvesByCpeAsync(string cpeName, CancellationToken ct = default)
public async Task<NvdFetchResult<NvdCveResponse>> SearchCvesByCpeAsync(string cpeName, CancellationToken ct = default)
{
// Use virtualMatchString for partial CPE matches (without version).
// cpeName requires a full CPE URI with version which we don't always have.
@@ -55,7 +118,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
/// Search CPE dictionary by keyword (vendor + product).
/// Used when seed mapping doesn't cover a product.
/// </summary>
public async Task<NvdCpeResponse> SearchCpeAsync(string keywordSearch, CancellationToken ct = default)
public async Task<NvdFetchResult<NvdCpeResponse>> SearchCpeAsync(string keywordSearch, CancellationToken ct = default)
{
var url = $"{NvdCpeUrl}?keywordSearch={Uri.EscapeDataString(keywordSearch)}&resultsPerPage=20";
return await ExecuteWithRateLimitAsync<NvdCpeResponse>(url, ct);
@@ -66,9 +129,19 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
private static long _totalSuccesses;
private static long _totalErrors;
private static long _total429s;
private static long _totalCooldownSkips;
private async Task<T> ExecuteWithRateLimitAsync<T>(string url, CancellationToken ct) where T : class, new()
private async Task<NvdFetchResult<T>> ExecuteWithRateLimitAsync<T>(string url, CancellationToken ct) where T : class, new()
{
if (IsInCooldown)
{
System.Threading.Interlocked.Increment(ref _totalCooldownSkips);
_logger.LogInformation(
"NVD throttle cooldown active until {Until:o}, skipping {Url} (cooldownSkips={Skips})",
_throttledUntilUtc, url, _totalCooldownSkips);
return NvdFetchResult<T>.ThrottledResult();
}
await _rateLimiter.WaitAsync(ct);
var requestStart = DateTime.UtcNow;
try
@@ -100,11 +173,15 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
if ((int)response.StatusCode == 429)
{
// Polly already retried this 3x — NVD is refusing us for real. Open the
// cooldown so the rest of the run fails fast instead of stalling per item.
System.Threading.Interlocked.Increment(ref _total429s);
var cooldown = ComputeCooldown(response.Headers.RetryAfter, DateTime.UtcNow);
_throttledUntilUtc = DateTime.UtcNow + cooldown;
_logger.LogWarning(
"NVD API rate-limited (429) for {Url} after {DurationMs:F0}ms (apiKey={HasKey}, total429s={Total429s})",
url, durationMs, hasApiKey, _total429s);
return new T();
"NVD API rate-limited (429) for {Url} after {DurationMs:F0}ms (apiKey={HasKey}, total429s={Total429s}); cooldown {CooldownSec:F0}s until {Until:o}",
url, durationMs, hasApiKey, _total429s, cooldown.TotalSeconds, _throttledUntilUtc);
return NvdFetchResult<T>.ThrottledResult();
}
if (!response.IsSuccessStatusCode)
@@ -113,7 +190,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
_logger.LogWarning(
"NVD API returned {StatusCode} for {Url} after {DurationMs:F0}ms (totalErrors={TotalErrors})",
(int)response.StatusCode, url, durationMs, _totalErrors);
return new T();
return NvdFetchResult<T>.Failed();
}
var json = await response.Content.ReadAsStringAsync();
@@ -124,7 +201,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
"NVD API OK: {Url} in {DurationMs:F0}ms, {ResponseBytes} bytes (apiKey={HasKey}, requests={Total}, ok={Successes}, err={Errors})",
url, durationMs, json.Length, hasApiKey, _totalRequests, _totalSuccesses, _totalErrors);
return result;
return NvdFetchResult<T>.Ok(result);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -133,7 +210,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
_logger.LogWarning(ex,
"NVD API request failed for {Url} after {DurationMs:F0}ms (totalErrors={TotalErrors})",
url, durationMs, _totalErrors);
return new T();
return NvdFetchResult<T>.Failed();
}
finally
{
@@ -22,6 +22,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
private readonly KevDataService _kevService;
private readonly MsrcCorrelationService _msrcCorrelation;
private readonly AdminConfigurationService _adminConfig;
private readonly EpssApiClient? _epssApi;
private readonly TelemetryClient? _telemetryClient;
private readonly ILogger<VulnerabilityCorrelationService> _logger;
@@ -47,7 +48,8 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
AdminConfigurationService adminConfig,
TelemetryClient? telemetryClient,
ILogger<VulnerabilityCorrelationService> logger,
IMaintenanceRepository? maintenanceRepo = null)
IMaintenanceRepository? maintenanceRepo = null,
EpssApiClient? epssApi = null)
{
_vulnRepo = vulnRepo;
_sessionRepo = sessionRepo;
@@ -56,6 +58,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
_kevService = kevService;
_msrcCorrelation = msrcCorrelation;
_adminConfig = adminConfig;
_epssApi = epssApi;
_telemetryClient = telemetryClient;
_logger = logger;
}
@@ -98,6 +101,8 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
int lowCves = 0;
int kevMatches = 0;
int msrcMatches = 0;
int actCves = 0;
int attendCves = 0;
string overallRisk = "none";
var cpeMappingsDict = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
@@ -178,6 +183,14 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
vuln["isKev"] = false;
}
// Priority needs the KEV verdict, so it is stamped here, not in FilterByVersion.
var epssForPriority = vuln.TryGetValue("epssScore", out var epssObj) && epssObj is double epssVal ? epssVal : (double?)null;
var cvssForPriority = vuln.TryGetValue("cvssScore", out var cvssObj) ? Convert.ToDouble(cvssObj) : 0;
var priority = CvePriority.Compute(kevEntry != null, epssForPriority, cvssForPriority);
vuln["priority"] = priority;
if (priority == CvePriority.Act) actCves++;
else if (priority == CvePriority.Attend) attendCves++;
// MSRC enrichment — only present if the CVE was published in a recent
// Patch Tuesday document. Augments source label and adds Microsoft-side
// metadata so the UI can show "confirmed by Microsoft" alongside NVD data.
@@ -223,7 +236,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
else if (maxCvss >= 4.0) riskLevel = "medium";
else if (maxCvss > 0) riskLevel = "low";
findings.Add(new Dictionary<string, object>
var finding = new Dictionary<string, object>
{
{ "softwareName", displayName },
{ "installedVersion", installedVersion },
@@ -231,7 +244,9 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
{ "matchConfidence", cpeMapping.MatchConfidence ?? "high" },
{ "riskLevel", riskLevel },
{ "vulnerabilities", vulns }
});
};
StampFindingPriority(finding, vulns);
findings.Add(finding);
}
// Determine overall risk
@@ -283,6 +298,8 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
{ "low", lowCves.ToString() },
{ "kev", kevMatches.ToString() },
{ "msrc", msrcMatches.ToString() },
{ "act", actCves.ToString() },
{ "attend", attendCves.ToString() },
{ "risk", overallRisk }
});
@@ -312,6 +329,8 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
{ "low_cves", lowCves },
{ "kev_matches", kevMatches },
{ "msrc_matches", msrcMatches },
{ "act_cves", actCves },
{ "attend_cves", attendCves },
{ "overall_risk", overallRisk },
{ "additional_findings_count", additionalCount }
}
@@ -451,7 +470,17 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
"CPE auto-resolve: searching NVD for '{Keyword}' (vendor={Vendor}, product={Name})",
keyword, normalizedVendor, normalizedName);
var cpeResponse = await _nvdApi.SearchCpeAsync(keyword, ct);
var cpeFetch = await _nvdApi.SearchCpeAsync(keyword, ct);
if (!cpeFetch.Succeeded)
{
// Throttled / failed lookup is NOT a miss: leave the negative cache alone so
// the product gets a real attempt on the next correlation run.
_logger.LogInformation(
"CPE auto-resolve SKIPPED for '{DisplayName}' (NVD {Reason}) — not cached as a miss",
displayName, cpeFetch.Throttled ? "throttled" : "unavailable");
return null;
}
var cpeResponse = cpeFetch.Data;
var candidateCount = cpeResponse.Products?.Count ?? 0;
var bestMatch = CpeMatchScorer.SelectBestMatch(
@@ -589,12 +618,26 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
if (cachedResult == null || cachedResult.IsHardExpired)
{
if (cachedResult?.IsHardExpired == true)
_logger.LogInformation("CVE cache hard-expired for {CpeUri} (age {AgeDays:F0}d), discarding stale data",
// Cache miss or hard-expired: synchronous fetch from NVD. A failed fetch
// (throttled / error) returns null and is NOT persisted — an empty answer
// cached for 7 days would hide every CVE of that product. Hard-expired data
// is then still better than nothing, so it stays in service until NVD answers.
var fresh = await FetchAndCacheCvesFromNvdAsync(cpeUri, cacheKey, ct);
if (fresh != null)
{
cachedResult = fresh;
}
else if (cachedResult != null)
{
_logger.LogInformation(
"NVD unavailable for {CpeUri}; serving hard-expired cache (age {AgeDays:F0}d) instead of nothing",
cpeUri, (DateTime.UtcNow - cachedResult.CachedAt).TotalDays);
// Cache miss or hard-expired: synchronous fetch from NVD
cachedResult = await FetchAndCacheCvesFromNvdAsync(cpeUri, cacheKey, ct);
}
else
{
_logger.LogInformation("NVD unavailable for {CpeUri} and no cache — product reported without CVE data this run", cpeUri);
return new List<Dictionary<string, object>>();
}
}
else if (cachedResult.IsStale)
{
@@ -652,77 +695,23 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
}
/// <summary>
/// Query NVD API for CVEs matching a CPE, then cache the results via the repository.
/// Returns the parsed CVE data regardless of whether caching succeeds.
/// Query NVD API for CVEs matching a CPE, enrich with EPSS, then cache the results via the
/// repository. Returns null when NVD did not answer (throttled / error) — nothing is
/// persisted in that case, so the cache never learns "no CVEs" from a refusal.
/// </summary>
private async Task<CachedCveResult> FetchAndCacheCvesFromNvdAsync(
private async Task<CachedCveResult?> FetchAndCacheCvesFromNvdAsync(
string cpeUri, string cacheKey, CancellationToken ct)
{
var cveResponse = await _nvdApi.SearchCvesByCpeAsync(cpeUri, ct);
var cves = new List<CachedCve>();
if (cveResponse?.Vulnerabilities != null)
var fetch = await _nvdApi.SearchCvesByCpeAsync(cpeUri, ct);
if (!fetch.Succeeded)
{
foreach (var vulnItem in cveResponse.Vulnerabilities)
{
var cve = vulnItem?.Cve;
if (cve == null) continue;
// Extract CVSS score (prefer v3.1, fallback to v3.0)
double cvssScore = 0;
string cvssSeverity = "UNKNOWN";
var cvssMetric = cve.Metrics?.CvssMetricV31?.FirstOrDefault()
?? cve.Metrics?.CvssMetricV30?.FirstOrDefault();
if (cvssMetric?.CvssData != null)
{
cvssScore = cvssMetric.CvssData.BaseScore;
cvssSeverity = cvssMetric.CvssData.BaseSeverity ?? "UNKNOWN";
}
// Extract English description
var description = cve.Descriptions?.FirstOrDefault(d =>
string.Equals(d.Lang, "en", StringComparison.OrdinalIgnoreCase))?.Value ?? "";
if (description.Length > 500)
description = description.Substring(0, 497) + "...";
// Collect all version ranges from configurations
var affectedVersions = new List<CachedVersionRange>();
if (cve.Configurations != null)
{
foreach (var config in cve.Configurations)
{
if (config.Nodes == null) continue;
foreach (var node in config.Nodes)
{
if (node.CpeMatch == null) continue;
foreach (var match in node.CpeMatch)
{
if (!match.Vulnerable) continue;
affectedVersions.Add(new CachedVersionRange
{
Criteria = match.Criteria,
VersionStartIncluding = match.VersionStartIncluding,
VersionStartExcluding = match.VersionStartExcluding,
VersionEndIncluding = match.VersionEndIncluding,
VersionEndExcluding = match.VersionEndExcluding
});
}
}
}
}
cves.Add(new CachedCve
{
CveId = cve.Id ?? "",
CvssScore = cvssScore,
CvssSeverity = cvssSeverity,
Description = description,
PublishedDate = cve.Published ?? "",
AffectedVersions = affectedVersions
});
}
_logger.LogInformation("CVE fetch for {CpeUri} not cached: NVD {Reason}",
cpeUri, fetch.Throttled ? "throttled" : "unavailable");
return null;
}
var cves = ProjectNvdResponse(fetch.Data);
// Apply cache budget: date filter + CVSS-sorted cap + per-CVE AffectedVersions cap.
// Protects against the 1 MB Azure Table Storage per-entity ceiling that
// property-chunking alone cannot solve.
@@ -735,6 +724,22 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
cpeUri, budget.TotalIn, budget.KeptAfterDateFilter, budget.KeptAfterCap, budget.CvesWithTruncatedRanges);
}
// One EPSS batch per CPE (≤ MaxCachedCves ids, one request). Best-effort: the
// nightly sync re-scores every cached row anyway.
if (_epssApi != null && cves.Count > 0)
{
try
{
var scores = await _epssApi.GetScoresAsync(cves.Select(c => c.CveId), ct);
ApplyEpssScores(cves, scores);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.LogWarning(ex, "EPSS enrichment failed for {CpeUri} (cached without scores)", cpeUri);
}
}
var result = new CachedCveResult
{
Cves = cves,
@@ -744,20 +749,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
// Persist to repository (best-effort, don't fail the request)
try
{
var cveDataJson = JsonSerializer.Serialize(cves, JsonOptions);
var data = new Dictionary<string, object>
{
{ "CachedAt", DateTime.UtcNow.ToString("o") },
{ "CpeUri", cpeUri },
{ "TotalCves", cves.Count }
};
// Chunk CveDataJson across multiple properties if it exceeds 30K chars
foreach (var chunk in TableStorageChunking.ChunkProperty("CveDataJson", cveDataJson))
data[chunk.Key] = chunk.Value;
await _vulnRepo.UpsertCveCacheEntryAsync(cacheKey, "cache_result", data);
await _vulnRepo.UpsertCveCacheEntryAsync(cacheKey, "cache_result", BuildCveCacheRow(cpeUri, cves, result.CachedAt));
_logger.LogDebug("Cached {Count} CVEs for {CpeUri}", cves.Count, cpeUri);
}
catch (Exception ex)
@@ -768,6 +760,117 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
return result;
}
/// <summary>
/// Projects an NVD 2.0 response onto the cached shape. Shared by the live fill and the
/// nightly refresh so both write byte-identical rows. CVSS prefers v3.1, falls back to v3.0;
/// the vector string rides along (attack vector / privileges matter for endpoint fleets).
/// </summary>
internal static List<CachedCve> ProjectNvdResponse(NvdCveResponse? response)
{
var cves = new List<CachedCve>();
if (response?.Vulnerabilities == null) return cves;
foreach (var vulnItem in response.Vulnerabilities)
{
var cve = vulnItem?.Cve;
if (cve == null) continue;
double cvssScore = 0;
string cvssSeverity = "UNKNOWN";
string? cvssVector = null;
var cvssMetric = cve.Metrics?.CvssMetricV31?.FirstOrDefault()
?? cve.Metrics?.CvssMetricV30?.FirstOrDefault();
if (cvssMetric?.CvssData != null)
{
cvssScore = cvssMetric.CvssData.BaseScore;
cvssSeverity = cvssMetric.CvssData.BaseSeverity ?? "UNKNOWN";
cvssVector = string.IsNullOrWhiteSpace(cvssMetric.CvssData.VectorString) ? null : cvssMetric.CvssData.VectorString;
}
var description = cve.Descriptions?.FirstOrDefault(d =>
string.Equals(d.Lang, "en", StringComparison.OrdinalIgnoreCase))?.Value ?? "";
if (description.Length > 500)
description = description.Substring(0, 497) + "...";
var affectedVersions = new List<CachedVersionRange>();
if (cve.Configurations != null)
{
foreach (var config in cve.Configurations)
{
if (config.Nodes == null) continue;
foreach (var node in config.Nodes)
{
if (node.CpeMatch == null) continue;
foreach (var match in node.CpeMatch)
{
if (!match.Vulnerable) continue;
affectedVersions.Add(new CachedVersionRange
{
Criteria = match.Criteria,
VersionStartIncluding = match.VersionStartIncluding,
VersionStartExcluding = match.VersionStartExcluding,
VersionEndIncluding = match.VersionEndIncluding,
VersionEndExcluding = match.VersionEndExcluding
});
}
}
}
}
cves.Add(new CachedCve
{
CveId = cve.Id ?? "",
CvssScore = cvssScore,
CvssSeverity = cvssSeverity,
CvssVector = cvssVector,
Description = description,
PublishedDate = cve.Published ?? "",
AffectedVersions = affectedVersions
});
}
return cves;
}
/// <summary>
/// Copies EPSS scores onto cached CVEs. Returns how many entries changed (new score, or a
/// different score/date than before) so the nightly refresh can skip unchanged rows.
/// A CVE absent from <paramref name="scores"/> keeps whatever it had — a failed batch must
/// not erase yesterday's score.
/// </summary>
internal static int ApplyEpssScores(List<CachedCve> cves, IReadOnlyDictionary<string, EpssScore> scores)
{
int changed = 0;
foreach (var cve in cves)
{
if (!scores.TryGetValue(cve.CveId, out var s)) continue;
if (cve.EpssScore == s.Score && cve.EpssPercentile == s.Percentile && cve.EpssDate == s.Date) continue;
cve.EpssScore = s.Score;
cve.EpssPercentile = s.Percentile;
cve.EpssDate = s.Date;
changed++;
}
return changed;
}
/// <summary>
/// The VulnerabilityCache row shape for one CPE, CveDataJson chunked at 30K chars.
/// <paramref name="cachedAt"/> is the NVD freshness stamp — an EPSS-only rewrite must pass
/// the existing value through so it does not masquerade as a fresh NVD fetch.
/// </summary>
internal static Dictionary<string, object> BuildCveCacheRow(string cpeUri, List<CachedCve> cves, DateTime cachedAt)
{
var cveDataJson = JsonSerializer.Serialize(cves, JsonOptions);
var data = new Dictionary<string, object>
{
{ "CachedAt", cachedAt.ToString("o") },
{ "CpeUri", cpeUri },
{ "TotalCves", cves.Count }
};
foreach (var chunk in TableStorageChunking.ChunkProperty("CveDataJson", cveDataJson))
data[chunk.Key] = chunk.Value;
return data;
}
// Platform keywords in CPE target_sw (field index 10) or target_hw (field index 11)
// that indicate the CVE does NOT apply to Windows.
internal static readonly HashSet<string> NonWindowsPlatforms = new(StringComparer.OrdinalIgnoreCase)
@@ -946,6 +1049,16 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
{ "knownRansomware", "" }
};
// Optional enrichment — absent keys mean "unknown", never a defaulted 0/empty.
if (!string.IsNullOrEmpty(cve.CvssVector))
entry["cvssVector"] = cve.CvssVector;
if (cve.EpssScore.HasValue)
{
entry["epssScore"] = cve.EpssScore.Value;
if (cve.EpssPercentile.HasValue) entry["epssPercentile"] = cve.EpssPercentile.Value;
if (!string.IsNullOrEmpty(cve.EpssDate)) entry["epssDate"] = cve.EpssDate;
}
if (isWildcardMatch)
{
entry["versionMatchConfidence"] = "low";
@@ -1142,8 +1255,15 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
public string CveId { get; set; } = "";
public double CvssScore { get; set; }
public string CvssSeverity { get; set; } = "UNKNOWN";
/// <summary>CVSS v3.x vector string (e.g. <c>CVSS:3.1/AV:N/AC:L/…</c>); null on legacy rows.</summary>
public string? CvssVector { get; set; }
public string Description { get; set; } = "";
public string PublishedDate { get; set; } = "";
/// <summary>FIRST EPSS probability (0..1); null = not scored / not yet fetched.</summary>
public double? EpssScore { get; set; }
public double? EpssPercentile { get; set; }
/// <summary>EPSS scoring date (yyyy-MM-dd) the score belongs to.</summary>
public string? EpssDate { get; set; }
public List<CachedVersionRange> AffectedVersions { get; set; } = new();
}
@@ -1244,6 +1364,27 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
{ "critical", 0 }, { "high", 1 }, { "medium", 2 }, { "low", 3 }, { "none", 4 }
};
/// <summary>
/// Finding-level roll-up of the per-CVE priority and EPSS: <c>priority</c> = highest band,
/// <c>maxEpssScore</c> = highest score (absent when no CVE of the finding is scored).
/// Used by the live correlation and by the Part-1/Part-2 merge so both agree.
/// </summary>
internal static void StampFindingPriority(Dictionary<string, object> finding, IEnumerable<object> vulns)
{
string priority = CvePriority.Track;
double? maxEpss = null;
foreach (var v in vulns)
{
if (v is not Dictionary<string, object> vuln) continue;
priority = CvePriority.Max(priority, vuln.GetValueOrDefault("priority")?.ToString());
if (vuln.GetValueOrDefault("epssScore") is double e && (!maxEpss.HasValue || e > maxEpss.Value))
maxEpss = e;
}
finding["priority"] = priority;
if (maxEpss.HasValue) finding["maxEpssScore"] = maxEpss.Value;
else finding.Remove("maxEpssScore");
}
/// <summary>
/// Merges two vulnerability reports (Part 1 = pre-provisioning, Part 2 = user enrollment).
/// Deduplicates findings by (softwareName, installedVersion, cveId).
@@ -1300,7 +1441,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
.ToList();
// Recompute scan_summary from merged findings
int totalCves = 0, criticalCves = 0, highCves = 0, mediumCves = 0, lowCves = 0, kevMatches = 0;
int totalCves = 0, criticalCves = 0, highCves = 0, mediumCves = 0, lowCves = 0, kevMatches = 0, actCves = 0, attendCves = 0;
foreach (var f in mergedFindings)
{
if (f.GetValueOrDefault("vulnerabilities") is IEnumerable<object> vulns)
@@ -1318,6 +1459,11 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
case "LOW": lowCves++; break;
}
if (vuln.GetValueOrDefault("isKev") is bool isKev && isKev) kevMatches++;
switch (vuln.GetValueOrDefault("priority")?.ToString())
{
case CvePriority.Act: actCves++; break;
case CvePriority.Attend: attendCves++; break;
}
}
}
}
@@ -1360,6 +1506,8 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
{ "medium_cves", mediumCves },
{ "low_cves", lowCves },
{ "kev_matches", kevMatches },
{ "act_cves", actCves },
{ "attend_cves", attendCves },
{ "overall_risk", overallRisk },
{ "additional_findings_count", additionalCount },
{ "phase_contributions", new Dictionary<string, object>
@@ -1424,6 +1572,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
else if (maxCvss >= 4.0) existing["riskLevel"] = "medium";
else if (maxCvss > 0) existing["riskLevel"] = "low";
else existing["riskLevel"] = "none";
StampFindingPriority(existing, mergedVulns.Values);
}
else
{
@@ -131,7 +131,9 @@ export function registerPrompts(server: McpServer, ga: boolean): void {
'1. Call search_sessions_by_cve with the cveId' +
(tenantId ? ' and tenantId' : '') +
'. Use pageSize=1000 and follow nextLink until it is absent — exposure audits must be complete, not sampled.\n' +
'2. Tally affected sessions/devices, and break them down by overallRisk and CVSS score.\n' +
'2. Tally affected sessions/devices, and break them down by overallRisk and CVSS score. ' +
'Call get_vulnerability_summary to pick up the CVE\'s epssScore (FIRST EPSS exploitation probability) and ' +
'priority (act = CISA KEV, attend = EPSS >= 0.1 or CVSS >= 9.0, track) — state both; a low-CVSS CVE with a high EPSS is still urgent.\n' +
'3. Use search_knowledge to look up remediation guidance for the affected software if a relevant rule exists.\n\n' +
'Report: total affected devices, severity breakdown, the most-exposed manufacturers/models if a pattern ' +
'stands out, and prioritized remediation. State explicitly if vulnerability scanning is disabled (empty result ≠ "not affected").',
@@ -1493,7 +1493,10 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
description:
'Get a fleet-wide vulnerability exposure summary aggregated from detected CVEs: total affected devices, ' +
'distinct CVE count, KEV (CISA Known-Exploited) count, a severity breakdown, and the top CVEs ranked by how ' +
'many devices they affect. ' +
'many devices they affect. Each top CVE carries cvssScore/cvssSeverity, isKev, epssScore (FIRST EPSS ' +
'probability of exploitation within 30 days, 0-1, null when unscored) and priority ("act" = in CISA KEV, ' +
'"attend" = EPSS >= 0.1 or CVSS >= 9.0, "track" = everything else) — rank remediation by priority, then EPSS, ' +
'not by CVSS alone. ' +
(ga ? 'Omit tenantId for a cross-tenant overview (Global Admin; also returns affected tenant count); pass tenantId to scope to one tenant. ' : '') +
'Use this to answer "how exposed is the fleet / this ' +
'tenant?" and "which CVEs affect the most devices?" — for the device list of a single CVE use search_sessions_by_cve. ' +
@@ -724,6 +724,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
(ga ? "Omit tenantId for cross-tenant search (Global Admin). " : "") +
"Requires vulnerability scanning to be enabled. " +
"Use this to answer: which devices are affected by CVE-2024-XXXX, show all critical vulnerability sessions. " +
"The per-session vulnerability report (get_session_summary / vulnerability_report event) lists each CVE with " +
"cvssScore, cvssVector, isKev, epssScore (FIRST EPSS, 0-1), epssPercentile and priority (act/attend/track). " +
"This endpoint is fully paginated — there is no truncation. The default pageSize=200 is tuned for typical " +
"interactive queries; raise it (up to 1000) for full exposure audits. For \"how many of my devices have CVE-X\" " +
"use pageSize=1000 and follow nextLink repeatedly until absent. Pass the whole nextLink string as " +
@@ -17,6 +17,10 @@ namespace AutopilotMonitor.Shared.Models.Vulnerability
public double CvssScore { get; set; }
public string CvssSeverity { get; set; } = string.Empty;
public bool IsKev { get; set; }
/// <summary>FIRST EPSS probability (0..1) at correlation time; null on legacy rows / unscored CVEs.</summary>
public double? EpssScore { get; set; }
/// <summary>Remediation priority (act / attend / track); empty on legacy rows.</summary>
public string Priority { get; set; } = string.Empty;
public string OverallRisk { get; set; } = string.Empty;
public DateTime? DetectedAt { get; set; }
}
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL } from "@/utils/config";
const LAST_UPDATED = "23 August 2026";
const LAST_UPDATED = "29 August 2026";
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
@@ -144,7 +144,8 @@ export default function PrivacyPage() {
delivers the welcome email when a tenant is activated and the farewell email after offboarding — it receives
an administrator&apos;s email address and the tenant domain, never enrollment telemetry; open and click
tracking is disabled. Vulnerability reference data is read inbound from NVD, the CISA KEV
catalog, and MSRC; nothing about your environment is sent to them.
catalog, MSRC, and the FIRST EPSS service; nothing about your environment is sent to them — the EPSS
lookup carries only public CVE identifiers from the shared reference cache.
</p>
<p className="text-gray-700">
Further connections exist only because you configure them: your own Azure storage account for diagnostics,
@@ -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<string, number> = { 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<number | null>((max, c) => (c.epss != null && (max == null || c.epss > max) ? c.epss : max), null);
const findingPriority = cves.reduce<string | undefined>((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({
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("cvss")}>
Max CVSS <SortIndicator column="cvss" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
</th>
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("epss")} title="Highest FIRST EPSS exploitation probability among the finding's CVEs">
EPSS <SortIndicator column="epss" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
@@ -607,7 +643,7 @@ export default function VulnerabilityReportSection({
})}
{filteredFindings.length === 0 && (
<tr>
<td colSpan={6} className="py-4 text-center text-gray-400">
<td colSpan={7} className="py-4 text-center text-gray-400">
No findings match your search.
</td>
</tr>
@@ -900,10 +936,17 @@ function FindingRow({ finding, isExpanded, onToggle }: {
<td className="py-2 px-3">
<CvssScore value={finding.maxCvss} />
</td>
<td className="py-2 px-3">
{finding.maxEpss != null ? (
<span className={CONTEXT_PILL_CLASS} title={epssTitle(finding.maxEpss)}>{formatEpss(finding.maxEpss)}</span>
) : (
<span className="text-gray-300">-</span>
)}
</td>
</tr>
{isExpanded && (
<tr>
<td colSpan={6} className="p-0">
<td colSpan={7} className="p-0">
<div className="bg-gray-50 border-t border-b border-gray-100 px-6 py-3">
<h4 className="text-xs font-medium text-gray-500 uppercase mb-2">
CVE Details ({finding.cves.length})
@@ -935,6 +978,16 @@ function FindingRow({ finding, isExpanded, onToggle }: {
<span>KEV</span>
</span>
)}
{cve.epss != null && (
<span className={`${CONTEXT_PILL_CLASS} cursor-help`} title={epssTitle(cve.epss, cve.epssPercentile)}>
{formatEpss(cve.epss)}
</span>
)}
{priorityLabel(cve.priority) && (
<span className={`${CONTEXT_PILL_CLASS} cursor-help`} title={PRIORITY_TITLE}>
{priorityLabel(cve.priority)}
</span>
)}
{cve.versionMatchConfidence === "low" && (
<span
className="px-2 py-0.5 rounded text-xs font-medium bg-amber-50 text-amber-700 cursor-help"
@@ -950,6 +1003,11 @@ function FindingRow({ finding, isExpanded, onToggle }: {
{cve.description && (
<p className="text-xs text-gray-600 mt-1 line-clamp-2">{cve.description}</p>
)}
{cve.cvssVector && (
<p className="text-[10px] font-mono text-gray-400 mt-1 break-all" title="CVSS base vector — AV (attack vector), AC (complexity), PR (privileges), UI (user interaction), S (scope), C/I/A (impact)">
{cve.cvssVector}
</p>
)}
</div>
</div>
))}
@@ -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() {
<a href="https://msrc.microsoft.com/" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">msrc.microsoft.com</a>.
</p>
</div>
<div className="p-4 border border-[var(--lp-line-soft)] rounded-lg">
<h3 className="font-semibold text-gray-900 mb-1">Exploit Prediction Scoring System (EPSS), FIRST.org</h3>
<p className="text-sm text-gray-500">
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{" "}
<a href="https://www.first.org/epss/" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">first.org/epss</a>.
</p>
</div>
</div>
</div>
@@ -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 && (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-semibold ${KEV_BADGE_CLASS}`}>KEV</span>
)}
{c.epssScore != null && (
<span className={CONTEXT_PILL_CLASS} title={epssTitle(c.epssScore)}>{formatEpss(c.epssScore)}</span>
)}
{priorityLabel(c.priority) && (
<span className={CONTEXT_PILL_CLASS} title={PRIORITY_TITLE}>{priorityLabel(c.priority)}</span>
)}
</div>
{c.sampleSoftware.length > 0 && (
<TruncatedLabel text={c.sampleSoftware.join(", ")} className="block text-[11px] text-gray-500 dark:text-gray-400 mt-0.5" />
@@ -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.";