From a6184f165bfb489fffb930eb7067f6af70ed52df Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 29 Aug 2026 11:07:31 +0200 Subject: [PATCH] feat(vuln): FIRST EPSS as fourth source, act/attend/track priority, CVSS vector; never cache a refused NVD answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EPSS: EpssApiClient (api.first.org, 100 ids per batch, unauthenticated, fail-soft). Score, percentile and date live on the cached CVE — one batch per CPE fill, every cve_* row re-scored by the nightly sync, only changed rows rewritten and with their original CachedAt. CveIndex carries EpssScore (max, absent when unscored) and Priority; TopCve exposes both. CvePriority: act = CISA KEV, attend = EPSS >= 0.1 or CVSS >= 9.0, track = rest — riskLevel/overall_risk semantics unchanged. NVD throttle: Polly already retried 429s; the defect was that a refused answer came back as an empty response and was cached as "0 CVEs" for seven days (also the negative auto-resolve cache). NvdFetchResult separates answered from refused; nothing persists a refusal; a 429 opens a process-wide cooldown (Retry-After capped at 120 s, else 30 s) so a run fails fast instead of paying a retry chain per software item; hard-expired rows keep serving meanwhile. The NVD->cache projection and the row builder are now single functions shared by the live fill and the sync. CVSS v3 vector string persisted and shown. Portal: EPSS column + pill, priority pill, vector line; exposure panel pills. MCP tool descriptions and the CVE-audit prompt name the fields. Terms attribution + privacy page updated in the same change. OKF doc backend/vulnerability-correlation-sources.md. Co-Authored-By: Claude Fable 5 --- .../vulnerability-correlation-sources.md | 143 +++++++ docs/index.md | 1 + docs/log.md | 4 + .../CveIndexEntityMergeTests.cs | 26 ++ .../EpssAndNvdThrottleTests.cs | 350 ++++++++++++++++++ .../VulnerabilitySummaryAggregatorTests.cs | 19 + .../AutoResolveCpeMappingFunction.cs | 12 +- .../VulnerabilityDataSyncFunction.cs | 213 +++++++---- .../VulnerabilitySummaryAggregator.cs | 15 +- .../AutopilotMonitor.Functions/Program.cs | 2 + .../Services/TableStorageService.AgentApi.cs | 18 +- .../Services/Vulnerability/CvePriority.cs | 48 +++ .../Services/Vulnerability/EpssApiClient.cs | 158 ++++++++ .../Services/Vulnerability/NvdApiClient.cs | 95 ++++- .../VulnerabilityCorrelationService.cs | 327 +++++++++++----- .../autopilot-monitor-mcp/src/prompts.ts | 4 +- .../autopilot-monitor-mcp/src/tools/admin.ts | 5 +- .../src/tools/sessions.ts | 2 + .../Models/Vulnerability/CveExposureEntry.cs | 4 + .../app/privacy/page.tsx | 5 +- .../components/VulnerabilityReportSection.tsx | 64 +++- .../autopilot-monitor-web/app/terms/page.tsx | 10 +- .../components/VulnerabilityExposurePanel.tsx | 12 +- .../lib/vulnerabilitySeverity.ts | 33 ++ 24 files changed, 1378 insertions(+), 192 deletions(-) create mode 100644 docs/backend/vulnerability-correlation-sources.md create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/EpssAndNvdThrottleTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CvePriority.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/EpssApiClient.cs diff --git a/docs/backend/vulnerability-correlation-sources.md b/docs/backend/vulnerability-correlation-sources.md new file mode 100644 index 00000000..1adaf8b6 --- /dev/null +++ b/docs/backend/vulnerability-correlation-sources.md @@ -0,0 +1,143 @@ +--- +type: Concept +title: Vulnerability Correlation Sources (NVD, KEV, MSRC, EPSS) +description: How the software inventory is turned into CVE findings - CPE mapping, the per-CPE NVD cache with its budget and TTLs, KEV/MSRC/EPSS enrichment, the throttle contract that keeps a refused NVD answer out of the cache, and the act/attend/track priority. +resource: src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs +tags: + - backend + - vulnerability + - nvd + - epss + - kev + - msrc + - cache +timestamp: 2026-08-29T18:00:00+02:00 +--- + +# Vulnerability Correlation Sources + +The Software Inventory & Vulnerability Analyzer (tenant opt-in) ships the installed-software +list with the session; the backend turns it into CVE findings entirely from cached public +reference data. Four sources, all trustworthy primaries, no aggregators: + +| Source | What it contributes | Fetch shape | Cache | +| --- | --- | --- | --- | +| NVD 2.0 (NIST) | CVE records per product: CVSS v3.1 (v3.0 fallback) score, severity, **vector**, description, affected version ranges; CPE dictionary for auto-mapping | `cves/2.0?virtualMatchString={cpe}` per product, `cpes/2.0?keywordSearch=` for unmapped titles; optional API key (`AdminConfiguration.NvdApiKey`) | Azure Table `VulnerabilityCache`, one row per CPE (`cve_{cpe}` / `cache_result`), `CveDataJson` chunked; soft TTL 7 d (serve + background refresh), hard 14 d | +| CISA KEV | "Exploited in the wild" flag, due date, required action, ransomware use | Whole catalog JSON | In-memory dictionary, 24 h, blob mirror `kev-catalog.json` | +| MSRC | Microsoft-side confirmation: document id/title, release date, Microsoft severity / score / vector | CVRF v3.0 index of the last six Patch Tuesdays | In-memory index, blob mirror `msrc-index.json` | +| FIRST EPSS | Probability (0..1) that the CVE is exploited within 30 days + percentile + scoring date | `api.first.org/data/v1/epss?cve=a,b,c` - 100 ids per request, unauthenticated | Stored **on the cached CVE** (no separate table): filled when a CPE row is (re)fetched, re-scored for every row by the nightly sync | + +Nothing about a tenant leaves the backend. The one outbound query that carries content is the +EPSS lookup, and its payload is the list of public CVE ids held in the shared, tenant-agnostic +CPE cache. Customer-facing wording: `autopilotmonitor-docs/trust/data-flows.md` and the +terms page attribution block. + +# Schema + +## Cached CVE (`CachedCve`, serialised into `CveDataJson`) + +``` +CveId, CvssScore, CvssSeverity, CvssVector?, Description (<=500), PublishedDate, +EpssScore?, EpssPercentile?, EpssDate?, AffectedVersions[] {Criteria, VersionStart/End Incl/Excl} +``` + +`JsonOptions` is `WhenWritingNull` + case-insensitive, so rows written before the vector / EPSS +properties existed deserialise with those as `null`, and a row is never rewritten just to add +nulls. `CveCacheBudget` bounds the row (24 months, or CVSS >= 7 within 60 months; max 100 CVEs +per CPE, 50 ranges per CVE) - which is also why one CPE fill is exactly one EPSS request. + +`ProjectNvdResponse` is the single NVD -> `CachedCve` projection; the live fill and the nightly +refresh both call it, so both write the same shape. `BuildCveCacheRow(cpe, cves, cachedAt)` is the +single row builder; `cachedAt` is the **NVD** freshness stamp, an EPSS-only rewrite passes the +existing value through so it does not look like a fresh NVD fetch to the 7-day TTL. + +## Per-finding output (report `findings[].vulnerabilities[]`) + +``` +cveId, cvssScore, cvssSeverity, cvssVector?, description, publishedDate, source ("NVD" | "NVD+MSRC"), +isKev, kevDueDate, kevRequiredAction, knownRansomware, +epssScore?, epssPercentile?, epssDate?, +priority ("act" | "attend" | "track"), +msrc* (when MSRC confirmed), versionMatchConfidence ("low" on wildcard CPE matches) +``` + +Finding level: `riskLevel` (unchanged CVSS+KEV bands), `priority` (highest band), `maxEpssScore?`. +`scan_summary` additionally carries `act_cves` / `attend_cves`. `MergeReports` (Part 1 + Part 2) +recomputes all of these. The `CveIndex` row (one per tenant/CVE/session) carries `EpssScore` +(max over occurrences, absent when unscored) and `Priority` (highest), which the fleet summary +(`TopCve.epssScore` / `priority`) reads. + +## Priority (`CvePriority.Compute`) + +* `act` - in CISA KEV. +* `attend` - EPSS >= 0.1 **or** CVSS >= 9.0. +* `track` - everything else. + +Three visible rules instead of a weighted composite: severity (`riskLevel`) keeps saying how bad, +priority says how soon. A CVE without an EPSS score is "unknown likelihood" - it can still reach +`attend` via CVSS, never via a defaulted 0. The thresholds are constants; changing them is a +product decision, not a tuning knob. + +# Throttle contract (NVD) + +The typed `HttpClient` runs under the `ExternalDataApi` Polly policy: 429/5xx/timeouts are retried +three times (1/2/4 s + jitter) with a circuit breaker. A 429 that reaches `NvdApiClient` therefore +means NVD is refusing us for real. Two things then happen, and one deliberately does not: + +1. `NvdFetchResult` reports `Succeeded = false`, `Throttled = true`. `Data` is an empty object + so enumerating callers keep working, but **nothing that persists may treat it as an answer**: + the CPE cache fill returns null and writes no row; the negative auto-resolve cache is not + touched; the admin auto-resolve reports `nvd_throttled`. Before this contract an empty response + was cached as "0 CVEs" for seven days - a product NVD happened to throttle disappeared from + every report for a week. +2. A process-wide cooldown opens: `Retry-After` when present (delta or date, capped at 120 s), + otherwise 30 s. During the cooldown every NVD call fails fast with `Throttled` - no wait, no + retry chain. Worst case added latency per cooldown window is one Polly chain (~7.5 s), never + a per-item stall; the nightly refresh breaks out of its loop on the first throttled answer and + reports the remainder as `cveSkippedThrottled`. +3. Not done on purpose: no in-process retry beyond Polly, no VulnCheck-style mirror, no separate + "throttled" cache state. Hard-expired cache rows keep serving while NVD is unavailable + (better than nothing, logged); a product with no row at all is reported without CVE data for + that run and gets a real attempt on the next correlation. + +# Nightly sync (`VulnerabilityDataSync`, 03:00 UTC) + +1. KEV catalog refresh (blob mirror updated). +2. MSRC index refresh (last six Patch Tuesdays). +3. NVD refresh of cache rows older than 7 days (sequential, breaks on throttle). +4. **EPSS re-score of every `cve_*` row** - all ids batched through FIRST, only rows whose score / + percentile / date changed are rewritten (with their original `CachedAt`). A missing id in the + response keeps yesterday's score: a failed batch never erases data. + +Telemetry event `VulnerabilityDataSyncCompleted` carries `epssRows / epssCves / epssScored / +epssRowsRewritten / epssError` next to the KEV / MSRC / CVE counters. + +# Examples + +* Chrome 126 finding: `CVE-2024-4947` CVSS 8.8 HIGH, `AV:N/AC:L/PR:N/UI:R`, KEV yes -> `priority = + act`, riskLevel `critical` (KEV) - unchanged severity semantics, priority now explicit. +* A 5.5 MEDIUM local-privilege CVE with EPSS 0.31 -> `attend`; the same CVE with EPSS 0.004 -> + `track`. Same severity band, different order of work. +* Nightly sync while NVD throttles: `[VulnSync] NVD throttled - leaving 37 stale entries for the + next run`, then `[VulnSync] EPSS refresh: 1412/1450 CVEs scored across 212 rows, 180 rows + rewritten` - EPSS is independent of NVD's mood. + +# Not done (recorded) + +* **ExploitDB CSV** as a "public exploit exists" flag (static file, daily, same shape as the KEV + mirror) - useful, cheap, deliberately not added: four primary sources are enough for now and + each additional outbound dependency is a trust-page line. Revisit if customers ask "is there a + public exploit?". +* GitHub / Nuclei PoC search, composite 0-100 risk scores, CWE -> ATT&CK mapping - noise for an + Intune admin, not adopted. + +# Citations + +* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/NvdApiClient.cs` - `NvdFetchResult`, cooldown, `ComputeCooldown` +* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/EpssApiClient.cs` - batching, `ParseResponse` +* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CvePriority.cs` +* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs` - `ProjectNvdResponse`, `ApplyEpssScores`, `BuildCveCacheRow`, `StampFindingPriority`, `FetchAndCacheCvesFromNvdAsync` +* `src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs` - `RefreshStaleCveCacheAsync`, `RefreshEpssScoresAsync` +* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs` - `BuildCveIndexEntities`, `ScanCveIndexAsync` +* `src/Backend/AutopilotMonitor.Functions.Tests/EpssAndNvdThrottleTests.cs` +* FIRST EPSS API: https://api.first.org/epss/ - CISA KEV: https://www.cisa.gov/known-exploited-vulnerabilities-catalog diff --git a/docs/index.md b/docs/index.md index 6ad3afb5..09ee1e90 100644 --- a/docs/index.md +++ b/docs/index.md @@ -79,6 +79,7 @@ https://docs.autopilotmonitor.com (separate repository). # Security & Identity * [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID. +* [Vulnerability Correlation Sources](backend/vulnerability-correlation-sources.md) - How the software inventory becomes CVE findings from four cached primaries (NVD per-CPE cache with budget and TTLs, CISA KEV, MSRC, FIRST EPSS stored on the cached CVE and re-scored nightly), the NVD throttle contract (a refused answer is never cached as "0 CVEs"; bounded process-wide cooldown honouring Retry-After), the persisted CVSS vector, and the act/attend/track priority that keeps severity and likelihood as two visible signals instead of one composite score. * [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, and the shadow-then-enforce rollout with its volume-controlled telemetry. # Conventions for this bundle diff --git a/docs/log.md b/docs/log.md index 8a86fce2..a21b1918 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-08-29 (2) + +* **New**: `backend/vulnerability-correlation-sources.md` - FIRST EPSS joins NVD / CISA KEV / MSRC as the fourth vulnerability source: the probability + percentile + date live on the cached CVE (one batch of <= 100 ids per CPE fill, every `cve_*` row re-scored by the nightly sync, only changed rows rewritten and with their original `CachedAt`), the CVSS vector is persisted instead of dropped, and each CVE carries an act / attend / track priority (KEV; EPSS >= 0.1 or CVSS >= 9.0; rest) next to the unchanged severity bands - two visible signals, no composite score. The comparison with an open-source CVE MCP server surfaced the real 429 defect: Polly already retried, but a refused NVD answer came back as an empty response and was cached as "0 CVEs" for seven days. `NvdFetchResult` now separates "answered" from "refused" (nothing persists a refusal - CPE cache, negative auto-resolve cache, admin auto-resolve), and a 429 opens a process-wide cooldown (`Retry-After` capped at 120 s, else 30 s) so the run fails fast instead of paying a retry chain per software item; hard-expired rows keep serving meanwhile. NVD -> cache projection and the row builder are now single functions shared by the live fill and the sync. CveIndex / fleet summary carry `epssScore` + `priority`; portal shows EPSS pill, priority pill, vector; MCP tool descriptions and the CVE-audit prompt name the fields. ExploitDB recorded as a deliberate not-now. Backend before web; trust pages (terms attribution, privacy, data-flows), settings and portal-guide docs updated in the same change. + ## 2026-08-29 * **New**: `mcp/protocol-eras-and-cimd.md` - the MCP server moved to the 2026-07-28 protocol revision (TypeScript SDK v2: `@modelcontextprotocol/server` + `/node`, zod 4; the monolithic `@modelcontextprotocol/sdk` is gone). One per-request factory (`mcp-server-factory.ts`) feeds both eras through `mcp-http.ts`: 2026 clients are served by the SDK entry (`createMcpHandler`, `legacy: 'reject'`, JSON response mode), 2025 clients keep the existing stateless transport with `enableJsonResponse` - routed on the SDK's own `isLegacyRequest` predicate, because the built-in stateless fallback would answer today's Claude/VS Code clients with SSE and lose the gzip win. Capabilities now declare `listChanged: false` (the SDK default advertised a notification a per-request server cannot send); `cacheHints` stamp `ttlMs`/`cacheScope: 'private'` on discover/list results (5 min) and resource reads (60 min) - never public, the catalog is role-dependent. Client registration: `client_id_metadata_document_supported: true`; an https `client_id` is a Client ID Metadata Document resolved by `cimd.ts` (SSRF gate on the resolved addresses before any request, no redirects, 5 s / 16 KB, exact `client_id` match, bounded `redirect_uris`, 10-60 min cache), the requested redirect_uri still has to pass the dynamic-registration allowlist; HMAC-signed DCR client_ids stay as the fallback. `mcp-oauth-flow.md` updated. Package version 1.5 -> 1.6. Pinned by `mcp-http.test.ts` (real v2 client over loopback HTTP, both eras, role parity), `cimd.test.ts`, `oauth-cimd.test.ts`. diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CveIndexEntityMergeTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CveIndexEntityMergeTests.cs index b46a9eac..eaf81799 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/CveIndexEntityMergeTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CveIndexEntityMergeTests.cs @@ -68,6 +68,32 @@ public class CveIndexEntityMergeTests Assert.Equal("critical", e.GetString("OverallRisk")); } + [Fact] + public void Merge_EpssMaxes_PriorityMaxes_AndUnscoredWritesNoEpssColumn() + { + var scoredLow = Vuln("CVE-1", 5.0, "MEDIUM"); scoredLow["epssScore"] = 0.02; scoredLow["priority"] = "track"; + var scoredHigh = Vuln("CVE-1", 4.0, "MEDIUM"); scoredHigh["epssScore"] = 0.35; scoredHigh["priority"] = "attend"; + var unscored = Vuln("CVE-1", 3.0, "LOW"); unscored["priority"] = "track"; + + var e = Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, new List> + { + Finding("App A", "medium", unscored), + Finding("App B", "medium", scoredLow), + Finding("App C", "medium", scoredHigh), + })); + + Assert.Equal(0.35, e.GetDouble("EpssScore")); + Assert.Equal("attend", e.GetString("Priority")); + + // A CVE nobody scored must not carry a fake 0.0 — the aggregate treats absent as "unknown". + var none = Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, new List> + { + Finding("App A", "low", Vuln("CVE-2", 3.0, "LOW")), + })); + Assert.False(none.ContainsKey("EpssScore")); + Assert.Equal("", none.GetString("Priority")); + } + [Fact] public void DistinctCves_StayDistinct_AndInvalidEntriesAreSkipped() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EpssAndNvdThrottleTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EpssAndNvdThrottleTests.cs new file mode 100644 index 00000000..2b50ac83 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/EpssAndNvdThrottleTests.cs @@ -0,0 +1,350 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Functions.Services.Vulnerability; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using static AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelationService; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pins the EPSS integration and the NVD throttle contract: +/// FIRST's envelope parses with invariant-culture string numbers, a 429 opens a bounded cooldown +/// (Retry-After honoured but capped), the NVD→cache projection carries the CVSS vector, EPSS +/// application never erases an existing score, and the act/attend/track priority is exactly the +/// three documented rules. +/// +public class EpssAndNvdThrottleTests +{ + // ----------------------------------------------------------------------- + // EpssApiClient + // ----------------------------------------------------------------------- + + private const string SampleEnvelope = """ + {"status":"OK","status-code":200,"version":"1.0","access":"public","total":2,"offset":0,"limit":100, + "data":[ + {"cve":"CVE-2024-21447","epss":"0.00383","percentile":"0.73461","date":"2026-08-28"}, + {"cve":"cve-2023-4863","epss":"0.94210","percentile":"0.99891","date":"2026-08-28"}, + {"cve":"CVE-2020-0001","epss":"not-a-number","percentile":"0.5","date":"2026-08-28"}, + {"cve":"","epss":"0.1","percentile":"0.5","date":"2026-08-28"} + ]} + """; + + [Fact] + public void ParseResponse_ParsesInvariantStrings_AndDropsUnparseableRows() + { + var scores = EpssApiClient.ParseResponse(SampleEnvelope); + + Assert.Equal(2, scores.Count); + var a = scores.Single(s => s.CveId == "CVE-2024-21447"); + Assert.Equal(0.00383, a.Score, 6); + Assert.Equal(0.73461, a.Percentile, 6); + Assert.Equal("2026-08-28", a.Date); + // Ids are normalised to upper case so cache lookups are stable. + Assert.Contains(scores, s => s.CveId == "CVE-2023-4863"); + } + + [Fact] + public void ParseResponse_EmptyOrMissingData_ReturnsEmpty() + { + Assert.Empty(EpssApiClient.ParseResponse("""{"status":"OK","data":[]}""")); + Assert.Empty(EpssApiClient.ParseResponse("""{"status":"OK"}""")); + } + + private sealed class StubHandler : HttpMessageHandler + { + public List Requests { get; } = new(); + private readonly Func _respond; + public StubHandler(Func respond) => _respond = respond; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Requests.Add(request.RequestUri!); + return Task.FromResult(_respond(request)); + } + } + + [Fact] + public async Task GetScoresAsync_BatchesByHundred_DedupesIds_AndSurvivesAFailedBatch() + { + int call = 0; + var handler = new StubHandler(req => + { + call++; + if (call == 2) + return new HttpResponseMessage(HttpStatusCode.InternalServerError); + // Echo the first id of the batch back as scored. + var query = Uri.UnescapeDataString(req.RequestUri!.Query); + var firstId = query.Substring("?cve=".Length).Split(',')[0]; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent($$"""{"status":"OK","data":[{"cve":"{{firstId}}","epss":"0.5","percentile":"0.9","date":"2026-08-28"}]}""") + }; + }); + var client = new EpssApiClient(new HttpClient(handler), NullLogger.Instance); + + // 150 distinct ids + 10 duplicates (case-varied) ⇒ exactly two requests. + var ids = Enumerable.Range(1, 150).Select(i => $"CVE-2024-{10000 + i}").ToList(); + ids.AddRange(ids.Take(10).Select(id => id.ToLowerInvariant())); + + var result = await client.GetScoresAsync(ids); + + Assert.Equal(2, handler.Requests.Count); + Assert.Single(result); // batch 1 scored one id, batch 2 failed soft + Assert.True(result.ContainsKey("cve-2024-10001")); // case-insensitive map + Assert.All(handler.Requests, u => Assert.StartsWith(EpssApiClient.EpssUrl, u.ToString())); + } + + // ----------------------------------------------------------------------- + // NVD throttle cooldown + // ----------------------------------------------------------------------- + + private static readonly DateTime Now = new(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void ComputeCooldown_NoHeader_UsesDefault() + { + Assert.Equal(NvdApiClient.ThrottleCooldown, NvdApiClient.ComputeCooldown(null, Now)); + } + + [Fact] + public void ComputeCooldown_RetryAfterDelta_IsHonoured() + { + var header = new RetryConditionHeaderValue(TimeSpan.FromSeconds(45)); + Assert.Equal(TimeSpan.FromSeconds(45), NvdApiClient.ComputeCooldown(header, Now)); + } + + [Fact] + public void ComputeCooldown_RetryAfterDate_IsRelativeToNow() + { + var header = new RetryConditionHeaderValue(new DateTimeOffset(Now.AddSeconds(20))); + Assert.Equal(TimeSpan.FromSeconds(20), NvdApiClient.ComputeCooldown(header, Now)); + } + + [Fact] + public void ComputeCooldown_IsCapped_SoAServerCannotParkUsForAnHour() + { + var header = new RetryConditionHeaderValue(TimeSpan.FromHours(1)); + Assert.Equal(NvdApiClient.MaxRetryAfter, NvdApiClient.ComputeCooldown(header, Now)); + } + + [Fact] + public void ComputeCooldown_PastDateOrZero_FallsBackToDefault() + { + Assert.Equal(NvdApiClient.ThrottleCooldown, + NvdApiClient.ComputeCooldown(new RetryConditionHeaderValue(new DateTimeOffset(Now.AddSeconds(-5))), Now)); + Assert.Equal(NvdApiClient.ThrottleCooldown, + NvdApiClient.ComputeCooldown(new RetryConditionHeaderValue(TimeSpan.Zero), Now)); + } + + [Fact] + public void NvdFetchResult_FailureShapes_CarryEmptyDataButNeverSucceeded() + { + var throttled = NvdFetchResult.ThrottledResult(); + Assert.False(throttled.Succeeded); + Assert.True(throttled.Throttled); + Assert.NotNull(throttled.Data); + Assert.Empty(throttled.Data.Vulnerabilities); + + var failed = NvdFetchResult.Failed(); + Assert.False(failed.Succeeded); + Assert.False(failed.Throttled); + + var ok = NvdFetchResult.Ok(new NvdCveResponse { TotalResults = 1 }); + Assert.True(ok.Succeeded); + Assert.Equal(1, ok.Data.TotalResults); + } + + // ----------------------------------------------------------------------- + // NVD → CachedCve projection + // ----------------------------------------------------------------------- + + [Fact] + public void ProjectNvdResponse_CarriesVector_PrefersV31_AndKeepsOnlyVulnerableRanges() + { + var response = new NvdCveResponse + { + Vulnerabilities = + { + new NvdVulnerabilityItem + { + Cve = new NvdCve + { + Id = "CVE-2024-1", + Published = "2026-01-01T00:00:00.000", + Descriptions = { new NvdDescription { Lang = "en", Value = "desc" } }, + Metrics = new NvdMetrics + { + CvssMetricV30 = { new NvdCvssMetricV31 { CvssData = new NvdCvssData { BaseScore = 5, BaseSeverity = "MEDIUM", VectorString = "CVSS:3.0/AV:L" } } }, + CvssMetricV31 = { new NvdCvssMetricV31 { CvssData = new NvdCvssData { BaseScore = 9.8, BaseSeverity = "CRITICAL", VectorString = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" } } }, + }, + Configurations = + { + new NvdConfiguration + { + Nodes = + { + new NvdNode + { + CpeMatch = + { + new NvdCpeMatch { Vulnerable = true, Criteria = "cpe:2.3:a:v:p:*:*:*:*:*:*:*:*", VersionEndExcluding = "2.0" }, + new NvdCpeMatch { Vulnerable = false, Criteria = "cpe:2.3:o:microsoft:windows:*:*:*:*:*:*:*:*" }, + } + } + } + } + } + } + }, + new NvdVulnerabilityItem + { + Cve = new NvdCve { Id = "CVE-2024-2", Descriptions = { new NvdDescription { Lang = "en", Value = "no metrics" } } } + }, + } + }; + + var cves = VulnerabilityCorrelationService.ProjectNvdResponse(response); + + Assert.Equal(2, cves.Count); + var first = cves[0]; + Assert.Equal(9.8, first.CvssScore); + Assert.Equal("CRITICAL", first.CvssSeverity); + Assert.Equal("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", first.CvssVector); + Assert.Single(first.AffectedVersions); + Assert.Equal("2.0", first.AffectedVersions[0].VersionEndExcluding); + Assert.Null(first.EpssScore); // never defaulted before enrichment + + var second = cves[1]; + Assert.Equal(0, second.CvssScore); + Assert.Equal("UNKNOWN", second.CvssSeverity); + Assert.Null(second.CvssVector); + } + + [Fact] + public void ProjectNvdResponse_Null_ReturnsEmpty() + { + Assert.Empty(VulnerabilityCorrelationService.ProjectNvdResponse(null)); + } + + // ----------------------------------------------------------------------- + // EPSS application onto cached CVEs + // ----------------------------------------------------------------------- + + [Fact] + public void ApplyEpssScores_SetsNewScores_KeepsUnscoredUntouched_AndReportsOnlyChanges() + { + var cves = new List + { + new() { CveId = "CVE-1", EpssScore = 0.2, EpssPercentile = 0.8, EpssDate = "2026-08-27" }, + new() { CveId = "CVE-2" }, + new() { CveId = "CVE-3", EpssScore = 0.5, EpssPercentile = 0.9, EpssDate = "2026-08-28" }, + }; + var scores = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["cve-1"] = new EpssScore { CveId = "CVE-1", Score = 0.25, Percentile = 0.85, Date = "2026-08-28" }, // changed + ["CVE-3"] = new EpssScore { CveId = "CVE-3", Score = 0.5, Percentile = 0.9, Date = "2026-08-28" }, // unchanged + // CVE-2 absent: FIRST did not score it (or its batch failed) ⇒ stays null + }; + + var changed = VulnerabilityCorrelationService.ApplyEpssScores(cves, scores); + + Assert.Equal(1, changed); + Assert.Equal(0.25, cves[0].EpssScore); + Assert.Equal("2026-08-28", cves[0].EpssDate); + Assert.Null(cves[1].EpssScore); + Assert.Equal(0.5, cves[2].EpssScore); + } + + [Fact] + public void ApplyEpssScores_NeverErasesYesterdaysScore_WhenTodayHasNone() + { + var cves = new List { new() { CveId = "CVE-1", EpssScore = 0.3, EpssPercentile = 0.7, EpssDate = "2026-08-27" } }; + var changed = VulnerabilityCorrelationService.ApplyEpssScores(cves, new Dictionary()); + Assert.Equal(0, changed); + Assert.Equal(0.3, cves[0].EpssScore); + } + + [Fact] + public void BuildCveCacheRow_PassesCachedAtThrough_SoEpssRewritesDoNotLookLikeNvdFetches() + { + var cachedAt = new DateTime(2026, 8, 20, 3, 0, 0, DateTimeKind.Utc); + var row = VulnerabilityCorrelationService.BuildCveCacheRow("cpe:2.3:a:v:p", new List { new() { CveId = "CVE-1" } }, cachedAt); + + Assert.Equal(cachedAt.ToString("o"), row["CachedAt"]); + Assert.Equal("cpe:2.3:a:v:p", row["CpeUri"]); + Assert.Equal(1, row["TotalCves"]); + Assert.True(row.ContainsKey("CveDataJson")); + } + + [Fact] + public void CachedCve_RoundTrips_WithoutWritingNulls_SoLegacyRowsStayReadable() + { + var json = System.Text.Json.JsonSerializer.Serialize(new List { new() { CveId = "CVE-1", CvssScore = 7 } }, JsonOptions); + Assert.DoesNotContain("EpssScore", json); + Assert.DoesNotContain("CvssVector", json); + + // A row written before this change (no EPSS/vector properties at all) still deserialises. + var legacy = """[{"CveId":"CVE-9","CvssScore":4.3,"CvssSeverity":"MEDIUM","Description":"","PublishedDate":"","AffectedVersions":[]}]"""; + var cves = System.Text.Json.JsonSerializer.Deserialize>(legacy, JsonOptions)!; + Assert.Null(cves[0].EpssScore); + Assert.Null(cves[0].CvssVector); + } + + // ----------------------------------------------------------------------- + // Priority + // ----------------------------------------------------------------------- + + [Theory] + [InlineData(true, null, 0.0, CvePriority.Act)] + [InlineData(true, 0.001, 2.0, CvePriority.Act)] + [InlineData(false, 0.1, 2.0, CvePriority.Attend)] + [InlineData(false, 0.0999, 2.0, CvePriority.Track)] + [InlineData(false, null, 9.0, CvePriority.Attend)] + [InlineData(false, 0.0, 9.8, CvePriority.Attend)] + [InlineData(false, null, 8.9, CvePriority.Track)] + [InlineData(false, 0.05, 7.5, CvePriority.Track)] + public void Compute_FollowsTheThreeRules(bool kev, double? epss, double cvss, string expected) + { + Assert.Equal(expected, CvePriority.Compute(kev, epss, cvss)); + } + + [Fact] + public void Max_PicksHigherBand_AndTreatsUnknownAsLowest() + { + Assert.Equal(CvePriority.Act, CvePriority.Max(CvePriority.Track, CvePriority.Act)); + Assert.Equal(CvePriority.Attend, CvePriority.Max(CvePriority.Attend, null)); + Assert.Equal(CvePriority.Track, CvePriority.Max(null, "")); + } + + [Fact] + public void StampFindingPriority_RollsUpHighestBand_AndMaxEpss() + { + var finding = new Dictionary { ["maxEpssScore"] = 0.9 /* stale value, must be recomputed */ }; + var vulns = new List + { + new Dictionary { ["priority"] = CvePriority.Track, ["epssScore"] = 0.02 }, + new Dictionary { ["priority"] = CvePriority.Attend, ["epssScore"] = 0.4 }, + new Dictionary { ["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(); + VulnerabilityCorrelationService.StampFindingPriority(finding, new List { new Dictionary { ["priority"] = CvePriority.Track } }); + Assert.Equal(CvePriority.Track, finding["priority"]); + Assert.False(finding.ContainsKey("maxEpssScore")); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilitySummaryAggregatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilitySummaryAggregatorTests.cs index a18f3380..02af53dc 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilitySummaryAggregatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilitySummaryAggregatorTests.cs @@ -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() { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/AutoResolveCpeMappingFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/AutoResolveCpeMappingFunction.cs index eaebc655..411b19f5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/AutoResolveCpeMappingFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/AutoResolveCpeMappingFunction.cs @@ -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 ?? "", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs index 0badc912..8e5fdd96 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs @@ -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; + /// Stale entries left untouched because NVD opened its throttle cooldown mid-run. + public int SkippedThrottled; + } + + private struct EpssRefreshStats + { + public int Rows; + public int Cves; + public int Scored; + public int RowsRewritten; + public string? Error; + } + + /// + /// Walks every cve_* cache row, batches all CVE ids through the EPSS API and rewrites + /// only the rows whose scores actually changed — with the row's ORIGINAL CachedAt, so + /// an EPSS rewrite never counts as a fresh NVD fetch for the 7-day TTL. + /// + private async Task RefreshEpssScoresAsync() + { + var stats = new EpssRefreshStats(); + try + { + var index = await _vulnRepo.GetStaleCveCacheEntriesAsync(); + var rows = new List<(string CacheKey, string CpeUri, DateTime CachedAt, List 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>(json, JsonOptions) ?? new List(); + 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; } /// @@ -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(); - 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(); - 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 + // 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) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryAggregator.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryAggregator.cs index 1ec91c8f..7eefd4eb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryAggregator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryAggregator.cs @@ -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 /// Distinct-CVE counts grouped by their highest CVSS severity band. public record SeverityBreakdown(int Critical, int High, int Medium, int Low); - /// A single CVE ranked by how many devices it affects in scope. + /// + /// A single CVE ranked by how many devices it affects in scope. + /// is the highest EPSS probability seen across the rows (null = unscored / legacy rows); + /// the highest act/attend/track band (empty on legacy rows). + /// public record TopCve( string CveId, string CvssSeverity, double CvssScore, bool IsKev, + double? EpssScore, + string Priority, int AffectedSessions, IReadOnlyList 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( diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 1dcf488a..3106e2d7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -407,6 +407,8 @@ builder.Services.AddHttpClient sp.GetRequiredService().ExternalDataApi); builder.Services.AddHttpClient() .AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi); +builder.Services.AddHttpClient() + .AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Hydrate MSRC + KEV in-memory caches from blob snapshots at app startup (fire-and-forget; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs index acbf7c95..4e474a34 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs @@ -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(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"), }); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CvePriority.cs b/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CvePriority.cs new file mode 100644 index 00000000..654bd812 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CvePriority.cs @@ -0,0 +1,48 @@ +using System; + +namespace AutopilotMonitor.Functions.Services.Vulnerability +{ + /// + /// 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: + /// + /// act — listed in CISA KEV: exploitation is confirmed, patch first. + /// attend — EPSS ≥ (a real exploitation + /// likelihood; roughly the top decile of all scored CVEs) or CVSS ≥ + /// (critical impact). + /// track — everything else. + /// + /// This is orthogonal to riskLevel/overall_risk (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 attend via CVSS, never + /// via a defaulted 0. + /// + 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; + } + + /// Rank for "highest priority wins" merges: act > attend > track > unknown. + 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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/EpssApiClient.cs b/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/EpssApiClient.cs new file mode 100644 index 00000000..8210af29 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/EpssApiClient.cs @@ -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 +{ + /// + /// 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. + /// + public sealed class EpssScore + { + public string CveId { get; init; } = ""; + public double Score { get; init; } + public double Percentile { get; init; } + /// Scoring date as published by FIRST (yyyy-MM-dd). + public string Date { get; init; } = ""; + } + + /// + /// 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 + /// 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. + /// + public class EpssApiClient + { + private readonly HttpClient _http; + private readonly ILogger _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 logger) + { + _http = http ?? throw new ArgumentNullException(nameof(http)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _http.Timeout = TimeSpan.FromSeconds(15); + } + + /// + /// 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. + /// + public async Task> GetScoresAsync(IEnumerable cveIds, CancellationToken ct = default) + { + var result = new Dictionary(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; + } + + /// + /// Parses the FIRST response envelope { "status": "OK", "data": [ { "cve", "epss", + /// "percentile", "date" } ] }. epss and percentile are JSON strings, + /// parsed invariant. Entries that do not parse are dropped, never defaulted to 0 — a + /// missing score must stay "unknown", not "safe". + /// + internal static List ParseResponse(string json) + { + var list = new List(); + var envelope = JsonSerializer.Deserialize(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? 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; } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/NvdApiClient.cs b/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/NvdApiClient.cs index ccc75b77..014c8a12 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/NvdApiClient.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/NvdApiClient.cs @@ -9,9 +9,44 @@ using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services.Vulnerability { + /// + /// Outcome of one NVD request. is never null — on failure it is an empty + /// response object so callers that only enumerate keep working — but + /// 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. + /// + public sealed class NvdFetchResult where T : class, new() + { + public T Data { get; } + public bool Succeeded { get; } + /// True when the failure was HTTP 429 (or a fail-fast during the resulting cooldown). + public bool Throttled { get; } + + private NvdFetchResult(T data, bool succeeded, bool throttled) + { + Data = data; + Succeeded = succeeded; + Throttled = throttled; + } + + public static NvdFetchResult Ok(T data) => new(data, true, false); + public static NvdFetchResult Failed() => new(new T(), false, false); + public static NvdFetchResult ThrottledResult() => new(new T(), false, true); + } + /// /// Typed HttpClient for the NVD API 2.0. /// Handles rate limiting (5 req/30s without key, 50 req/30s with key). + /// + /// Throttle contract: the HttpClient's ExternalDataApi 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 (, or the + /// server's Retry-After capped at ) during which every + /// request fails fast with 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. + /// /// 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); + + /// True while the process-wide 429 cooldown is active. + public static bool IsInCooldown => DateTime.UtcNow < _throttledUntilUtc; + + /// + /// Cooldown length for a 429: the server's Retry-After (delta-seconds or HTTP date) + /// when present and sane, otherwise ; never above + /// so a misconfigured header cannot park us for an hour. + /// + 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; + } + + /// Test seam: clears the cooldown so suites do not leak state into each other. + 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 /// /// Search CVEs for a specific CPE name. Returns CVEs with version range configurations. /// - public async Task SearchCvesByCpeAsync(string cpeName, CancellationToken ct = default) + public async Task> 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. /// - public async Task SearchCpeAsync(string keywordSearch, CancellationToken ct = default) + public async Task> SearchCpeAsync(string keywordSearch, CancellationToken ct = default) { var url = $"{NvdCpeUrl}?keywordSearch={Uri.EscapeDataString(keywordSearch)}&resultsPerPage=20"; return await ExecuteWithRateLimitAsync(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 ExecuteWithRateLimitAsync(string url, CancellationToken ct) where T : class, new() + private async Task> ExecuteWithRateLimitAsync(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.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.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.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.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.Failed(); } finally { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs index fc215a28..4f71e8ce 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs @@ -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 _logger; @@ -47,7 +48,8 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability AdminConfigurationService adminConfig, TelemetryClient? telemetryClient, ILogger 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(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 + var finding = new Dictionary { { "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>(); + } } else if (cachedResult.IsStale) { @@ -652,77 +695,23 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability } /// - /// 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. /// - private async Task FetchAndCacheCvesFromNvdAsync( + private async Task FetchAndCacheCvesFromNvdAsync( string cpeUri, string cacheKey, CancellationToken ct) { - var cveResponse = await _nvdApi.SearchCvesByCpeAsync(cpeUri, ct); - var cves = new List(); - - 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(); - 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 - { - { "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; } + /// + /// 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). + /// + internal static List ProjectNvdResponse(NvdCveResponse? response) + { + var cves = new List(); + 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(); + 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; + } + + /// + /// 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 keeps whatever it had — a failed batch must + /// not erase yesterday's score. + /// + internal static int ApplyEpssScores(List cves, IReadOnlyDictionary 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; + } + + /// + /// The VulnerabilityCache row shape for one CPE, CveDataJson chunked at 30K chars. + /// 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. + /// + internal static Dictionary BuildCveCacheRow(string cpeUri, List cves, DateTime cachedAt) + { + var cveDataJson = JsonSerializer.Serialize(cves, JsonOptions); + var data = new Dictionary + { + { "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 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"; + /// CVSS v3.x vector string (e.g. CVSS:3.1/AV:N/AC:L/…); null on legacy rows. + public string? CvssVector { get; set; } public string Description { get; set; } = ""; public string PublishedDate { get; set; } = ""; + /// FIRST EPSS probability (0..1); null = not scored / not yet fetched. + public double? EpssScore { get; set; } + public double? EpssPercentile { get; set; } + /// EPSS scoring date (yyyy-MM-dd) the score belongs to. + public string? EpssDate { get; set; } public List AffectedVersions { get; set; } = new(); } @@ -1244,6 +1364,27 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability { "critical", 0 }, { "high", 1 }, { "medium", 2 }, { "low", 3 }, { "none", 4 } }; + /// + /// Finding-level roll-up of the per-CVE priority and EPSS: priority = highest band, + /// maxEpssScore = 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. + /// + internal static void StampFindingPriority(Dictionary finding, IEnumerable vulns) + { + string priority = CvePriority.Track; + double? maxEpss = null; + foreach (var v in vulns) + { + if (v is not Dictionary 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"); + } + /// /// 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 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 @@ -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 { diff --git a/src/McpServer/autopilot-monitor-mcp/src/prompts.ts b/src/McpServer/autopilot-monitor-mcp/src/prompts.ts index 377c7580..6fce3234 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/prompts.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/prompts.ts @@ -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").', diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 9a1d074f..ae1f4d5e 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -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. ' + diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index 7b7aa1ef..11a029c4 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -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 " + diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Vulnerability/CveExposureEntry.cs b/src/Shared/AutopilotMonitor.Shared/Models/Vulnerability/CveExposureEntry.cs index 36b52270..be274d22 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Vulnerability/CveExposureEntry.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Vulnerability/CveExposureEntry.cs @@ -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; } + /// FIRST EPSS probability (0..1) at correlation time; null on legacy rows / unscored CVEs. + public double? EpssScore { get; set; } + /// Remediation priority (act / attend / track); empty on legacy rows. + public string Priority { get; set; } = string.Empty; public string OverallRisk { get; set; } = string.Empty; public DateTime? DetectedAt { get; set; } } diff --git a/src/Web/autopilot-monitor-web/app/privacy/page.tsx b/src/Web/autopilot-monitor-web/app/privacy/page.tsx index 40bbd8e2..802c81b1 100644 --- a/src/Web/autopilot-monitor-web/app/privacy/page.tsx +++ b/src/Web/autopilot-monitor-web/app/privacy/page.tsx @@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar"; import { SiteFooter } from "../../components/SiteFooter"; import { DOCS_URL } from "@/utils/config"; -const LAST_UPDATED = "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'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.

Further connections exist only because you configure them: your own Azure storage account for diagnostics, diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/VulnerabilityReportSection.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/VulnerabilityReportSection.tsx index cc01da97..2d34ff58 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/VulnerabilityReportSection.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/VulnerabilityReportSection.tsx @@ -2,6 +2,7 @@ import { useState, useMemo } from "react"; import type { EnrollmentEvent } from "@/types"; +import { CONTEXT_PILL_CLASS, formatEpss, epssTitle, priorityLabel, PRIORITY_TITLE } from "@/lib/vulnerabilitySeverity"; interface VulnerabilityReportSectionProps { events: EnrollmentEvent[]; @@ -22,6 +23,13 @@ interface CveDetail { publishedDate?: string; references?: string[]; versionMatchConfidence?: string; + /** CVSS v3.x vector string; absent on reports written before it was persisted. */ + cvssVector?: string; + /** FIRST EPSS probability (0..1); undefined = unscored / pre-EPSS report. */ + epss?: number; + epssPercentile?: number; + /** act / attend / track; undefined on pre-priority reports. */ + priority?: string; } interface Finding { @@ -31,6 +39,17 @@ interface Finding { maxCvss: number; maxSeverity: string; kevCount: number; + /** Highest EPSS across the finding's CVEs; null when none is scored. */ + maxEpss: number | null; + priority?: string; +} + +const PRIORITY_ORDER: Record = { act: 0, attend: 1, track: 2 }; + +function maxPriority(a?: string, b?: string): string | undefined { + if (!a) return b; + if (!b) return a; + return (PRIORITY_ORDER[a] ?? 3) <= (PRIORITY_ORDER[b] ?? 3) ? a : b; } interface InventoryItem { @@ -94,7 +113,7 @@ function confidenceTooltip(confidence?: string): string { return "Unknown identification confidence"; } -type FindingsSortKey = "severity" | "software" | "cves" | "cvss"; +type FindingsSortKey = "severity" | "software" | "cves" | "cvss" | "epss"; // Module-level so React keeps a stable component identity across renders // (react-hooks/static-components: an inline component type remounts on every render). @@ -154,9 +173,15 @@ export default function VulnerabilityReportSection({ publishedDate: (c.publishedDate || c.published_date || "") as string, references: (c.references || []) as string[], versionMatchConfidence: (c.versionMatchConfidence || c.version_match_confidence) as string | undefined, + cvssVector: (c.cvssVector as string | undefined) || undefined, + epss: typeof c.epssScore === "number" ? c.epssScore : undefined, + epssPercentile: typeof c.epssPercentile === "number" ? c.epssPercentile : undefined, + priority: (c.priority as string | undefined) || undefined, })); const maxCvss = cves.reduce((max: number, c: CveDetail) => Math.max(max, c.cvss ?? 0), 0); + const maxEpss = cves.reduce((max, c) => (c.epss != null && (max == null || c.epss > max) ? c.epss : max), null); + const findingPriority = cves.reduce((best, c) => maxPriority(best, c.priority), undefined); const maxSev = cves.reduce((worst: string, c: CveDetail) => { const cSev = c.severity || severityFromCvss(c.cvss ?? 0); return (SEVERITY_ORDER[cSev] ?? 4) < (SEVERITY_ORDER[worst] ?? 4) ? cSev : worst; @@ -175,6 +200,8 @@ export default function VulnerabilityReportSection({ existing.maxCvss = Math.max(existing.maxCvss, maxCvss); existing.maxSeverity = (SEVERITY_ORDER[maxSev] ?? 4) < (SEVERITY_ORDER[existing.maxSeverity] ?? 4) ? maxSev : existing.maxSeverity; existing.kevCount += cveKevCount; + if (maxEpss != null && (existing.maxEpss == null || maxEpss > existing.maxEpss)) existing.maxEpss = maxEpss; + existing.priority = maxPriority(existing.priority, findingPriority); } else { findingsMap.set(key, { softwareName: f.softwareName || f.software || "Unknown", @@ -183,6 +210,8 @@ export default function VulnerabilityReportSection({ maxCvss, maxSeverity: maxSev, kevCount: cveKevCount, + maxEpss, + priority: findingPriority, }); } mapped.add((f.softwareName || f.software || "").toLowerCase().trim()); @@ -370,6 +399,10 @@ export default function VulnerabilityReportSection({ case "cvss": cmp = a.maxCvss - b.maxCvss; break; + case "epss": + // Unscored sorts below every scored value in both directions. + cmp = (a.maxEpss ?? -1) - (b.maxEpss ?? -1); + break; } return findingsSortAsc ? cmp : -cmp; }); @@ -590,6 +623,9 @@ export default function VulnerabilityReportSection({ toggleFindingsSort("cvss")}> Max CVSS + toggleFindingsSort("epss")} title="Highest FIRST EPSS exploitation probability among the finding's CVEs"> + EPSS + @@ -607,7 +643,7 @@ export default function VulnerabilityReportSection({ })} {filteredFindings.length === 0 && ( - + No findings match your search. @@ -900,10 +936,17 @@ function FindingRow({ finding, isExpanded, onToggle }: { + + {finding.maxEpss != null ? ( + {formatEpss(finding.maxEpss)} + ) : ( + - + )} + {isExpanded && ( - +

CVE Details ({finding.cves.length}) @@ -935,6 +978,16 @@ function FindingRow({ finding, isExpanded, onToggle }: { KEV )} + {cve.epss != null && ( + + {formatEpss(cve.epss)} + + )} + {priorityLabel(cve.priority) && ( + + {priorityLabel(cve.priority)} + + )} {cve.versionMatchConfidence === "low" && ( {cve.description}

)} + {cve.cvssVector && ( +

+ {cve.cvssVector} +

+ )}

))} diff --git a/src/Web/autopilot-monitor-web/app/terms/page.tsx b/src/Web/autopilot-monitor-web/app/terms/page.tsx index f0ee2ace..341ac89f 100644 --- a/src/Web/autopilot-monitor-web/app/terms/page.tsx +++ b/src/Web/autopilot-monitor-web/app/terms/page.tsx @@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar"; import { SiteFooter } from "../../components/SiteFooter"; import { DOCS_URL } from "@/utils/config"; -const LAST_UPDATED = "31 July 2026"; +const LAST_UPDATED = "29 August 2026"; const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`; const DOCS_PLANS = `${DOCS_URL}/plans`; const GITHUB_ADVISORY = "https://github.com/okieselbach/AutopilotMonitor/security/advisories/new"; @@ -199,6 +199,14 @@ export default function TermsPage() { msrc.microsoft.com.

+
+

Exploit Prediction Scoring System (EPSS), FIRST.org

+

+ Exploitation-probability scores (EPSS) are provided by the Forum of Incident Response and Security Teams (FIRST). The lookup + sends only public CVE identifiers from the shared reference cache — never tenant or device data. For more information, visit{" "} + first.org/epss. +

+
diff --git a/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx b/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx index a341b754..11c35254 100644 --- a/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx +++ b/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { severityChipClass, KEV_BADGE_CLASS } from "@/lib/vulnerabilitySeverity"; +import { severityChipClass, KEV_BADGE_CLASS, CONTEXT_PILL_CLASS, formatEpss, epssTitle, priorityLabel, PRIORITY_TITLE } from "@/lib/vulnerabilitySeverity"; import TruncatedLabel from "@/components/TruncatedLabel"; export interface VulnerabilityTopCve { @@ -8,6 +8,10 @@ export interface VulnerabilityTopCve { cvssSeverity: string; cvssScore: number; isKev: boolean; + /** FIRST EPSS probability (0..1); null/absent when unscored or for rows written before EPSS. */ + epssScore?: number | null; + /** act / attend / track; empty for rows written before priorities existed. */ + priority?: string; affectedSessions: number; sampleSoftware: string[]; } @@ -135,6 +139,12 @@ export default function VulnerabilityExposurePanel({ {c.isKev && ( KEV )} + {c.epssScore != null && ( + {formatEpss(c.epssScore)} + )} + {priorityLabel(c.priority) && ( + {priorityLabel(c.priority)} + )} {c.sampleSoftware.length > 0 && ( diff --git a/src/Web/autopilot-monitor-web/lib/vulnerabilitySeverity.ts b/src/Web/autopilot-monitor-web/lib/vulnerabilitySeverity.ts index 77be9d8c..b1f05ad4 100644 --- a/src/Web/autopilot-monitor-web/lib/vulnerabilitySeverity.ts +++ b/src/Web/autopilot-monitor-web/lib/vulnerabilitySeverity.ts @@ -21,3 +21,36 @@ export function severityChipClass(severity: string): string { /** Tailwind classes for the CISA Known-Exploited (KEV) badge. */ export const KEV_BADGE_CLASS = "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 border-red-300 dark:border-red-700"; + +/** + * Context-label pill (gray outline — no new color family): used for the EPSS score and the + * act/attend/track priority so they read as information next to the severity chip, not as + * a second severity scale. + */ +export const CONTEXT_PILL_CLASS = + "inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-medium border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-300"; + +/** "EPSS 12.3%" — one decimal, or two when the score is below 1% so small scores don't read as 0. */ +export function formatEpss(score: number): string { + const pct = score * 100; + return `EPSS ${pct < 1 ? pct.toFixed(2) : pct.toFixed(1)}%`; +} + +/** Tooltip for the EPSS pill; percentile optional (0..1). */ +export function epssTitle(score: number, percentile?: number | null): string { + const base = `EPSS (FIRST.org): ${(score * 100).toFixed(2)}% estimated probability of exploitation in the wild within the next 30 days`; + return percentile != null ? `${base} — higher than ${(percentile * 100).toFixed(1)}% of all scored CVEs` : base; +} + +/** Human label for the remediation priority band. */ +export function priorityLabel(priority?: string | null): string | null { + switch ((priority || "").toLowerCase()) { + case "act": return "Act"; + case "attend": return "Attend"; + case "track": return "Track"; + default: return null; + } +} + +export const PRIORITY_TITLE = + "Remediation priority — Act: listed in CISA KEV (exploitation confirmed). Attend: EPSS ≥ 10% or CVSS ≥ 9.0. Track: everything else.";