mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
feat(vuln): FIRST EPSS as fourth source, act/attend/track priority, CVSS vector; never cache a refused NVD answer
EPSS: EpssApiClient (api.first.org, 100 ids per batch, unauthenticated, fail-soft). Score, percentile and date live on the cached CVE — one batch per CPE fill, every cve_* row re-scored by the nightly sync, only changed rows rewritten and with their original CachedAt. CveIndex carries EpssScore (max, absent when unscored) and Priority; TopCve exposes both. CvePriority: act = CISA KEV, attend = EPSS >= 0.1 or CVSS >= 9.0, track = rest — riskLevel/overall_risk semantics unchanged. NVD throttle: Polly already retried 429s; the defect was that a refused answer came back as an empty response and was cached as "0 CVEs" for seven days (also the negative auto-resolve cache). NvdFetchResult separates answered from refused; nothing persists a refusal; a 429 opens a process-wide cooldown (Retry-After capped at 120 s, else 30 s) so a run fails fast instead of paying a retry chain per software item; hard-expired rows keep serving meanwhile. The NVD->cache projection and the row builder are now single functions shared by the live fill and the sync. CVSS v3 vector string persisted and shown. Portal: EPSS column + pill, priority pill, vector line; exposure panel pills. MCP tool descriptions and the CVE-audit prompt name the fields. Terms attribution + privacy page updated in the same change. OKF doc backend/vulnerability-correlation-sources.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Vulnerability Correlation Sources (NVD, KEV, MSRC, EPSS)
|
||||
description: How the software inventory is turned into CVE findings - CPE mapping, the per-CPE NVD cache with its budget and TTLs, KEV/MSRC/EPSS enrichment, the throttle contract that keeps a refused NVD answer out of the cache, and the act/attend/track priority.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs
|
||||
tags:
|
||||
- backend
|
||||
- vulnerability
|
||||
- nvd
|
||||
- epss
|
||||
- kev
|
||||
- msrc
|
||||
- cache
|
||||
timestamp: 2026-08-29T18:00:00+02:00
|
||||
---
|
||||
|
||||
# Vulnerability Correlation Sources
|
||||
|
||||
The Software Inventory & Vulnerability Analyzer (tenant opt-in) ships the installed-software
|
||||
list with the session; the backend turns it into CVE findings entirely from cached public
|
||||
reference data. Four sources, all trustworthy primaries, no aggregators:
|
||||
|
||||
| Source | What it contributes | Fetch shape | Cache |
|
||||
| --- | --- | --- | --- |
|
||||
| NVD 2.0 (NIST) | CVE records per product: CVSS v3.1 (v3.0 fallback) score, severity, **vector**, description, affected version ranges; CPE dictionary for auto-mapping | `cves/2.0?virtualMatchString={cpe}` per product, `cpes/2.0?keywordSearch=` for unmapped titles; optional API key (`AdminConfiguration.NvdApiKey`) | Azure Table `VulnerabilityCache`, one row per CPE (`cve_{cpe}` / `cache_result`), `CveDataJson` chunked; soft TTL 7 d (serve + background refresh), hard 14 d |
|
||||
| CISA KEV | "Exploited in the wild" flag, due date, required action, ransomware use | Whole catalog JSON | In-memory dictionary, 24 h, blob mirror `kev-catalog.json` |
|
||||
| MSRC | Microsoft-side confirmation: document id/title, release date, Microsoft severity / score / vector | CVRF v3.0 index of the last six Patch Tuesdays | In-memory index, blob mirror `msrc-index.json` |
|
||||
| FIRST EPSS | Probability (0..1) that the CVE is exploited within 30 days + percentile + scoring date | `api.first.org/data/v1/epss?cve=a,b,c` - 100 ids per request, unauthenticated | Stored **on the cached CVE** (no separate table): filled when a CPE row is (re)fetched, re-scored for every row by the nightly sync |
|
||||
|
||||
Nothing about a tenant leaves the backend. The one outbound query that carries content is the
|
||||
EPSS lookup, and its payload is the list of public CVE ids held in the shared, tenant-agnostic
|
||||
CPE cache. Customer-facing wording: `autopilotmonitor-docs/trust/data-flows.md` and the
|
||||
terms page attribution block.
|
||||
|
||||
# Schema
|
||||
|
||||
## Cached CVE (`CachedCve`, serialised into `CveDataJson`)
|
||||
|
||||
```
|
||||
CveId, CvssScore, CvssSeverity, CvssVector?, Description (<=500), PublishedDate,
|
||||
EpssScore?, EpssPercentile?, EpssDate?, AffectedVersions[] {Criteria, VersionStart/End Incl/Excl}
|
||||
```
|
||||
|
||||
`JsonOptions` is `WhenWritingNull` + case-insensitive, so rows written before the vector / EPSS
|
||||
properties existed deserialise with those as `null`, and a row is never rewritten just to add
|
||||
nulls. `CveCacheBudget` bounds the row (24 months, or CVSS >= 7 within 60 months; max 100 CVEs
|
||||
per CPE, 50 ranges per CVE) - which is also why one CPE fill is exactly one EPSS request.
|
||||
|
||||
`ProjectNvdResponse` is the single NVD -> `CachedCve` projection; the live fill and the nightly
|
||||
refresh both call it, so both write the same shape. `BuildCveCacheRow(cpe, cves, cachedAt)` is the
|
||||
single row builder; `cachedAt` is the **NVD** freshness stamp, an EPSS-only rewrite passes the
|
||||
existing value through so it does not look like a fresh NVD fetch to the 7-day TTL.
|
||||
|
||||
## Per-finding output (report `findings[].vulnerabilities[]`)
|
||||
|
||||
```
|
||||
cveId, cvssScore, cvssSeverity, cvssVector?, description, publishedDate, source ("NVD" | "NVD+MSRC"),
|
||||
isKev, kevDueDate, kevRequiredAction, knownRansomware,
|
||||
epssScore?, epssPercentile?, epssDate?,
|
||||
priority ("act" | "attend" | "track"),
|
||||
msrc* (when MSRC confirmed), versionMatchConfidence ("low" on wildcard CPE matches)
|
||||
```
|
||||
|
||||
Finding level: `riskLevel` (unchanged CVSS+KEV bands), `priority` (highest band), `maxEpssScore?`.
|
||||
`scan_summary` additionally carries `act_cves` / `attend_cves`. `MergeReports` (Part 1 + Part 2)
|
||||
recomputes all of these. The `CveIndex` row (one per tenant/CVE/session) carries `EpssScore`
|
||||
(max over occurrences, absent when unscored) and `Priority` (highest), which the fleet summary
|
||||
(`TopCve.epssScore` / `priority`) reads.
|
||||
|
||||
## Priority (`CvePriority.Compute`)
|
||||
|
||||
* `act` - in CISA KEV.
|
||||
* `attend` - EPSS >= 0.1 **or** CVSS >= 9.0.
|
||||
* `track` - everything else.
|
||||
|
||||
Three visible rules instead of a weighted composite: severity (`riskLevel`) keeps saying how bad,
|
||||
priority says how soon. A CVE without an EPSS score is "unknown likelihood" - it can still reach
|
||||
`attend` via CVSS, never via a defaulted 0. The thresholds are constants; changing them is a
|
||||
product decision, not a tuning knob.
|
||||
|
||||
# Throttle contract (NVD)
|
||||
|
||||
The typed `HttpClient` runs under the `ExternalDataApi` Polly policy: 429/5xx/timeouts are retried
|
||||
three times (1/2/4 s + jitter) with a circuit breaker. A 429 that reaches `NvdApiClient` therefore
|
||||
means NVD is refusing us for real. Two things then happen, and one deliberately does not:
|
||||
|
||||
1. `NvdFetchResult<T>` reports `Succeeded = false`, `Throttled = true`. `Data` is an empty object
|
||||
so enumerating callers keep working, but **nothing that persists may treat it as an answer**:
|
||||
the CPE cache fill returns null and writes no row; the negative auto-resolve cache is not
|
||||
touched; the admin auto-resolve reports `nvd_throttled`. Before this contract an empty response
|
||||
was cached as "0 CVEs" for seven days - a product NVD happened to throttle disappeared from
|
||||
every report for a week.
|
||||
2. A process-wide cooldown opens: `Retry-After` when present (delta or date, capped at 120 s),
|
||||
otherwise 30 s. During the cooldown every NVD call fails fast with `Throttled` - no wait, no
|
||||
retry chain. Worst case added latency per cooldown window is one Polly chain (~7.5 s), never
|
||||
a per-item stall; the nightly refresh breaks out of its loop on the first throttled answer and
|
||||
reports the remainder as `cveSkippedThrottled`.
|
||||
3. Not done on purpose: no in-process retry beyond Polly, no VulnCheck-style mirror, no separate
|
||||
"throttled" cache state. Hard-expired cache rows keep serving while NVD is unavailable
|
||||
(better than nothing, logged); a product with no row at all is reported without CVE data for
|
||||
that run and gets a real attempt on the next correlation.
|
||||
|
||||
# Nightly sync (`VulnerabilityDataSync`, 03:00 UTC)
|
||||
|
||||
1. KEV catalog refresh (blob mirror updated).
|
||||
2. MSRC index refresh (last six Patch Tuesdays).
|
||||
3. NVD refresh of cache rows older than 7 days (sequential, breaks on throttle).
|
||||
4. **EPSS re-score of every `cve_*` row** - all ids batched through FIRST, only rows whose score /
|
||||
percentile / date changed are rewritten (with their original `CachedAt`). A missing id in the
|
||||
response keeps yesterday's score: a failed batch never erases data.
|
||||
|
||||
Telemetry event `VulnerabilityDataSyncCompleted` carries `epssRows / epssCves / epssScored /
|
||||
epssRowsRewritten / epssError` next to the KEV / MSRC / CVE counters.
|
||||
|
||||
# Examples
|
||||
|
||||
* Chrome 126 finding: `CVE-2024-4947` CVSS 8.8 HIGH, `AV:N/AC:L/PR:N/UI:R`, KEV yes -> `priority =
|
||||
act`, riskLevel `critical` (KEV) - unchanged severity semantics, priority now explicit.
|
||||
* A 5.5 MEDIUM local-privilege CVE with EPSS 0.31 -> `attend`; the same CVE with EPSS 0.004 ->
|
||||
`track`. Same severity band, different order of work.
|
||||
* Nightly sync while NVD throttles: `[VulnSync] NVD throttled - leaving 37 stale entries for the
|
||||
next run`, then `[VulnSync] EPSS refresh: 1412/1450 CVEs scored across 212 rows, 180 rows
|
||||
rewritten` - EPSS is independent of NVD's mood.
|
||||
|
||||
# Not done (recorded)
|
||||
|
||||
* **ExploitDB CSV** as a "public exploit exists" flag (static file, daily, same shape as the KEV
|
||||
mirror) - useful, cheap, deliberately not added: four primary sources are enough for now and
|
||||
each additional outbound dependency is a trust-page line. Revisit if customers ask "is there a
|
||||
public exploit?".
|
||||
* GitHub / Nuclei PoC search, composite 0-100 risk scores, CWE -> ATT&CK mapping - noise for an
|
||||
Intune admin, not adopted.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/NvdApiClient.cs` - `NvdFetchResult`, cooldown, `ComputeCooldown`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/EpssApiClient.cs` - batching, `ParseResponse`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CvePriority.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs` - `ProjectNvdResponse`, `ApplyEpssScores`, `BuildCveCacheRow`, `StampFindingPriority`, `FetchAndCacheCvesFromNvdAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs` - `RefreshStaleCveCacheAsync`, `RefreshEpssScoresAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs` - `BuildCveIndexEntities`, `ScanCveIndexAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/EpssAndNvdThrottleTests.cs`
|
||||
* FIRST EPSS API: https://api.first.org/epss/ - CISA KEV: https://www.cisa.gov/known-exploited-vulnerabilities-catalog
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-29 (2)
|
||||
|
||||
* **New**: `backend/vulnerability-correlation-sources.md` - FIRST EPSS joins NVD / CISA KEV / MSRC as the fourth vulnerability source: the probability + percentile + date live on the cached CVE (one batch of <= 100 ids per CPE fill, every `cve_*` row re-scored by the nightly sync, only changed rows rewritten and with their original `CachedAt`), the CVSS vector is persisted instead of dropped, and each CVE carries an act / attend / track priority (KEV; EPSS >= 0.1 or CVSS >= 9.0; rest) next to the unchanged severity bands - two visible signals, no composite score. The comparison with an open-source CVE MCP server surfaced the real 429 defect: Polly already retried, but a refused NVD answer came back as an empty response and was cached as "0 CVEs" for seven days. `NvdFetchResult` now separates "answered" from "refused" (nothing persists a refusal - CPE cache, negative auto-resolve cache, admin auto-resolve), and a 429 opens a process-wide cooldown (`Retry-After` capped at 120 s, else 30 s) so the run fails fast instead of paying a retry chain per software item; hard-expired rows keep serving meanwhile. NVD -> cache projection and the row builder are now single functions shared by the live fill and the sync. CveIndex / fleet summary carry `epssScore` + `priority`; portal shows EPSS pill, priority pill, vector; MCP tool descriptions and the CVE-audit prompt name the fields. ExploitDB recorded as a deliberate not-now. Backend before web; trust pages (terms attribution, privacy, data-flows), settings and portal-guide docs updated in the same change.
|
||||
|
||||
## 2026-08-29
|
||||
|
||||
* **New**: `mcp/protocol-eras-and-cimd.md` - the MCP server moved to the 2026-07-28 protocol revision (TypeScript SDK v2: `@modelcontextprotocol/server` + `/node`, zod 4; the monolithic `@modelcontextprotocol/sdk` is gone). One per-request factory (`mcp-server-factory.ts`) feeds both eras through `mcp-http.ts`: 2026 clients are served by the SDK entry (`createMcpHandler`, `legacy: 'reject'`, JSON response mode), 2025 clients keep the existing stateless transport with `enableJsonResponse` - routed on the SDK's own `isLegacyRequest` predicate, because the built-in stateless fallback would answer today's Claude/VS Code clients with SSE and lose the gzip win. Capabilities now declare `listChanged: false` (the SDK default advertised a notification a per-request server cannot send); `cacheHints` stamp `ttlMs`/`cacheScope: 'private'` on discover/list results (5 min) and resource reads (60 min) - never public, the catalog is role-dependent. Client registration: `client_id_metadata_document_supported: true`; an https `client_id` is a Client ID Metadata Document resolved by `cimd.ts` (SSRF gate on the resolved addresses before any request, no redirects, 5 s / 16 KB, exact `client_id` match, bounded `redirect_uris`, 10-60 min cache), the requested redirect_uri still has to pass the dynamic-registration allowlist; HMAC-signed DCR client_ids stay as the fallback. `mcp-oauth-flow.md` updated. Package version 1.5 -> 1.6. Pinned by `mcp-http.test.ts` (real v2 client over loopback HTTP, both eras, role parity), `cimd.test.ts`, `oauth-cimd.test.ts`.
|
||||
|
||||
@@ -68,6 +68,32 @@ public class CveIndexEntityMergeTests
|
||||
Assert.Equal("critical", e.GetString("OverallRisk"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_EpssMaxes_PriorityMaxes_AndUnscoredWritesNoEpssColumn()
|
||||
{
|
||||
var scoredLow = Vuln("CVE-1", 5.0, "MEDIUM"); scoredLow["epssScore"] = 0.02; scoredLow["priority"] = "track";
|
||||
var scoredHigh = Vuln("CVE-1", 4.0, "MEDIUM"); scoredHigh["epssScore"] = 0.35; scoredHigh["priority"] = "attend";
|
||||
var unscored = Vuln("CVE-1", 3.0, "LOW"); unscored["priority"] = "track";
|
||||
|
||||
var e = Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, new List<Dictionary<string, object>>
|
||||
{
|
||||
Finding("App A", "medium", unscored),
|
||||
Finding("App B", "medium", scoredLow),
|
||||
Finding("App C", "medium", scoredHigh),
|
||||
}));
|
||||
|
||||
Assert.Equal(0.35, e.GetDouble("EpssScore"));
|
||||
Assert.Equal("attend", e.GetString("Priority"));
|
||||
|
||||
// A CVE nobody scored must not carry a fake 0.0 — the aggregate treats absent as "unknown".
|
||||
var none = Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, new List<Dictionary<string, object>>
|
||||
{
|
||||
Finding("App A", "low", Vuln("CVE-2", 3.0, "LOW")),
|
||||
}));
|
||||
Assert.False(none.ContainsKey("EpssScore"));
|
||||
Assert.Equal("", none.GetString("Priority"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistinctCves_StayDistinct_AndInvalidEntriesAreSkipped()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelationService;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the EPSS integration and the NVD throttle contract:
|
||||
/// FIRST's envelope parses with invariant-culture string numbers, a 429 opens a bounded cooldown
|
||||
/// (Retry-After honoured but capped), the NVD→cache projection carries the CVSS vector, EPSS
|
||||
/// application never erases an existing score, and the act/attend/track priority is exactly the
|
||||
/// three documented rules.
|
||||
/// </summary>
|
||||
public class EpssAndNvdThrottleTests
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// EpssApiClient
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private const string SampleEnvelope = """
|
||||
{"status":"OK","status-code":200,"version":"1.0","access":"public","total":2,"offset":0,"limit":100,
|
||||
"data":[
|
||||
{"cve":"CVE-2024-21447","epss":"0.00383","percentile":"0.73461","date":"2026-08-28"},
|
||||
{"cve":"cve-2023-4863","epss":"0.94210","percentile":"0.99891","date":"2026-08-28"},
|
||||
{"cve":"CVE-2020-0001","epss":"not-a-number","percentile":"0.5","date":"2026-08-28"},
|
||||
{"cve":"","epss":"0.1","percentile":"0.5","date":"2026-08-28"}
|
||||
]}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void ParseResponse_ParsesInvariantStrings_AndDropsUnparseableRows()
|
||||
{
|
||||
var scores = EpssApiClient.ParseResponse(SampleEnvelope);
|
||||
|
||||
Assert.Equal(2, scores.Count);
|
||||
var a = scores.Single(s => s.CveId == "CVE-2024-21447");
|
||||
Assert.Equal(0.00383, a.Score, 6);
|
||||
Assert.Equal(0.73461, a.Percentile, 6);
|
||||
Assert.Equal("2026-08-28", a.Date);
|
||||
// Ids are normalised to upper case so cache lookups are stable.
|
||||
Assert.Contains(scores, s => s.CveId == "CVE-2023-4863");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseResponse_EmptyOrMissingData_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(EpssApiClient.ParseResponse("""{"status":"OK","data":[]}"""));
|
||||
Assert.Empty(EpssApiClient.ParseResponse("""{"status":"OK"}"""));
|
||||
}
|
||||
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
public List<Uri> Requests { get; } = new();
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
|
||||
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) => _respond = respond;
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
Requests.Add(request.RequestUri!);
|
||||
return Task.FromResult(_respond(request));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScoresAsync_BatchesByHundred_DedupesIds_AndSurvivesAFailedBatch()
|
||||
{
|
||||
int call = 0;
|
||||
var handler = new StubHandler(req =>
|
||||
{
|
||||
call++;
|
||||
if (call == 2)
|
||||
return new HttpResponseMessage(HttpStatusCode.InternalServerError);
|
||||
// Echo the first id of the batch back as scored.
|
||||
var query = Uri.UnescapeDataString(req.RequestUri!.Query);
|
||||
var firstId = query.Substring("?cve=".Length).Split(',')[0];
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent($$"""{"status":"OK","data":[{"cve":"{{firstId}}","epss":"0.5","percentile":"0.9","date":"2026-08-28"}]}""")
|
||||
};
|
||||
});
|
||||
var client = new EpssApiClient(new HttpClient(handler), NullLogger<EpssApiClient>.Instance);
|
||||
|
||||
// 150 distinct ids + 10 duplicates (case-varied) ⇒ exactly two requests.
|
||||
var ids = Enumerable.Range(1, 150).Select(i => $"CVE-2024-{10000 + i}").ToList();
|
||||
ids.AddRange(ids.Take(10).Select(id => id.ToLowerInvariant()));
|
||||
|
||||
var result = await client.GetScoresAsync(ids);
|
||||
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
Assert.Single(result); // batch 1 scored one id, batch 2 failed soft
|
||||
Assert.True(result.ContainsKey("cve-2024-10001")); // case-insensitive map
|
||||
Assert.All(handler.Requests, u => Assert.StartsWith(EpssApiClient.EpssUrl, u.ToString()));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// NVD throttle cooldown
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static readonly DateTime Now = new(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void ComputeCooldown_NoHeader_UsesDefault()
|
||||
{
|
||||
Assert.Equal(NvdApiClient.ThrottleCooldown, NvdApiClient.ComputeCooldown(null, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeCooldown_RetryAfterDelta_IsHonoured()
|
||||
{
|
||||
var header = new RetryConditionHeaderValue(TimeSpan.FromSeconds(45));
|
||||
Assert.Equal(TimeSpan.FromSeconds(45), NvdApiClient.ComputeCooldown(header, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeCooldown_RetryAfterDate_IsRelativeToNow()
|
||||
{
|
||||
var header = new RetryConditionHeaderValue(new DateTimeOffset(Now.AddSeconds(20)));
|
||||
Assert.Equal(TimeSpan.FromSeconds(20), NvdApiClient.ComputeCooldown(header, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeCooldown_IsCapped_SoAServerCannotParkUsForAnHour()
|
||||
{
|
||||
var header = new RetryConditionHeaderValue(TimeSpan.FromHours(1));
|
||||
Assert.Equal(NvdApiClient.MaxRetryAfter, NvdApiClient.ComputeCooldown(header, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeCooldown_PastDateOrZero_FallsBackToDefault()
|
||||
{
|
||||
Assert.Equal(NvdApiClient.ThrottleCooldown,
|
||||
NvdApiClient.ComputeCooldown(new RetryConditionHeaderValue(new DateTimeOffset(Now.AddSeconds(-5))), Now));
|
||||
Assert.Equal(NvdApiClient.ThrottleCooldown,
|
||||
NvdApiClient.ComputeCooldown(new RetryConditionHeaderValue(TimeSpan.Zero), Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NvdFetchResult_FailureShapes_CarryEmptyDataButNeverSucceeded()
|
||||
{
|
||||
var throttled = NvdFetchResult<NvdCveResponse>.ThrottledResult();
|
||||
Assert.False(throttled.Succeeded);
|
||||
Assert.True(throttled.Throttled);
|
||||
Assert.NotNull(throttled.Data);
|
||||
Assert.Empty(throttled.Data.Vulnerabilities);
|
||||
|
||||
var failed = NvdFetchResult<NvdCveResponse>.Failed();
|
||||
Assert.False(failed.Succeeded);
|
||||
Assert.False(failed.Throttled);
|
||||
|
||||
var ok = NvdFetchResult<NvdCveResponse>.Ok(new NvdCveResponse { TotalResults = 1 });
|
||||
Assert.True(ok.Succeeded);
|
||||
Assert.Equal(1, ok.Data.TotalResults);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// NVD → CachedCve projection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ProjectNvdResponse_CarriesVector_PrefersV31_AndKeepsOnlyVulnerableRanges()
|
||||
{
|
||||
var response = new NvdCveResponse
|
||||
{
|
||||
Vulnerabilities =
|
||||
{
|
||||
new NvdVulnerabilityItem
|
||||
{
|
||||
Cve = new NvdCve
|
||||
{
|
||||
Id = "CVE-2024-1",
|
||||
Published = "2026-01-01T00:00:00.000",
|
||||
Descriptions = { new NvdDescription { Lang = "en", Value = "desc" } },
|
||||
Metrics = new NvdMetrics
|
||||
{
|
||||
CvssMetricV30 = { new NvdCvssMetricV31 { CvssData = new NvdCvssData { BaseScore = 5, BaseSeverity = "MEDIUM", VectorString = "CVSS:3.0/AV:L" } } },
|
||||
CvssMetricV31 = { new NvdCvssMetricV31 { CvssData = new NvdCvssData { BaseScore = 9.8, BaseSeverity = "CRITICAL", VectorString = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" } } },
|
||||
},
|
||||
Configurations =
|
||||
{
|
||||
new NvdConfiguration
|
||||
{
|
||||
Nodes =
|
||||
{
|
||||
new NvdNode
|
||||
{
|
||||
CpeMatch =
|
||||
{
|
||||
new NvdCpeMatch { Vulnerable = true, Criteria = "cpe:2.3:a:v:p:*:*:*:*:*:*:*:*", VersionEndExcluding = "2.0" },
|
||||
new NvdCpeMatch { Vulnerable = false, Criteria = "cpe:2.3:o:microsoft:windows:*:*:*:*:*:*:*:*" },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
new NvdVulnerabilityItem
|
||||
{
|
||||
Cve = new NvdCve { Id = "CVE-2024-2", Descriptions = { new NvdDescription { Lang = "en", Value = "no metrics" } } }
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
var cves = VulnerabilityCorrelationService.ProjectNvdResponse(response);
|
||||
|
||||
Assert.Equal(2, cves.Count);
|
||||
var first = cves[0];
|
||||
Assert.Equal(9.8, first.CvssScore);
|
||||
Assert.Equal("CRITICAL", first.CvssSeverity);
|
||||
Assert.Equal("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", first.CvssVector);
|
||||
Assert.Single(first.AffectedVersions);
|
||||
Assert.Equal("2.0", first.AffectedVersions[0].VersionEndExcluding);
|
||||
Assert.Null(first.EpssScore); // never defaulted before enrichment
|
||||
|
||||
var second = cves[1];
|
||||
Assert.Equal(0, second.CvssScore);
|
||||
Assert.Equal("UNKNOWN", second.CvssSeverity);
|
||||
Assert.Null(second.CvssVector);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectNvdResponse_Null_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(VulnerabilityCorrelationService.ProjectNvdResponse(null));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// EPSS application onto cached CVEs
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ApplyEpssScores_SetsNewScores_KeepsUnscoredUntouched_AndReportsOnlyChanges()
|
||||
{
|
||||
var cves = new List<CachedCve>
|
||||
{
|
||||
new() { CveId = "CVE-1", EpssScore = 0.2, EpssPercentile = 0.8, EpssDate = "2026-08-27" },
|
||||
new() { CveId = "CVE-2" },
|
||||
new() { CveId = "CVE-3", EpssScore = 0.5, EpssPercentile = 0.9, EpssDate = "2026-08-28" },
|
||||
};
|
||||
var scores = new Dictionary<string, EpssScore>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["cve-1"] = new EpssScore { CveId = "CVE-1", Score = 0.25, Percentile = 0.85, Date = "2026-08-28" }, // changed
|
||||
["CVE-3"] = new EpssScore { CveId = "CVE-3", Score = 0.5, Percentile = 0.9, Date = "2026-08-28" }, // unchanged
|
||||
// CVE-2 absent: FIRST did not score it (or its batch failed) ⇒ stays null
|
||||
};
|
||||
|
||||
var changed = VulnerabilityCorrelationService.ApplyEpssScores(cves, scores);
|
||||
|
||||
Assert.Equal(1, changed);
|
||||
Assert.Equal(0.25, cves[0].EpssScore);
|
||||
Assert.Equal("2026-08-28", cves[0].EpssDate);
|
||||
Assert.Null(cves[1].EpssScore);
|
||||
Assert.Equal(0.5, cves[2].EpssScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyEpssScores_NeverErasesYesterdaysScore_WhenTodayHasNone()
|
||||
{
|
||||
var cves = new List<CachedCve> { new() { CveId = "CVE-1", EpssScore = 0.3, EpssPercentile = 0.7, EpssDate = "2026-08-27" } };
|
||||
var changed = VulnerabilityCorrelationService.ApplyEpssScores(cves, new Dictionary<string, EpssScore>());
|
||||
Assert.Equal(0, changed);
|
||||
Assert.Equal(0.3, cves[0].EpssScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCveCacheRow_PassesCachedAtThrough_SoEpssRewritesDoNotLookLikeNvdFetches()
|
||||
{
|
||||
var cachedAt = new DateTime(2026, 8, 20, 3, 0, 0, DateTimeKind.Utc);
|
||||
var row = VulnerabilityCorrelationService.BuildCveCacheRow("cpe:2.3:a:v:p", new List<CachedCve> { new() { CveId = "CVE-1" } }, cachedAt);
|
||||
|
||||
Assert.Equal(cachedAt.ToString("o"), row["CachedAt"]);
|
||||
Assert.Equal("cpe:2.3:a:v:p", row["CpeUri"]);
|
||||
Assert.Equal(1, row["TotalCves"]);
|
||||
Assert.True(row.ContainsKey("CveDataJson"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CachedCve_RoundTrips_WithoutWritingNulls_SoLegacyRowsStayReadable()
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(new List<CachedCve> { new() { CveId = "CVE-1", CvssScore = 7 } }, JsonOptions);
|
||||
Assert.DoesNotContain("EpssScore", json);
|
||||
Assert.DoesNotContain("CvssVector", json);
|
||||
|
||||
// A row written before this change (no EPSS/vector properties at all) still deserialises.
|
||||
var legacy = """[{"CveId":"CVE-9","CvssScore":4.3,"CvssSeverity":"MEDIUM","Description":"","PublishedDate":"","AffectedVersions":[]}]""";
|
||||
var cves = System.Text.Json.JsonSerializer.Deserialize<List<CachedCve>>(legacy, JsonOptions)!;
|
||||
Assert.Null(cves[0].EpssScore);
|
||||
Assert.Null(cves[0].CvssVector);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Priority
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, null, 0.0, CvePriority.Act)]
|
||||
[InlineData(true, 0.001, 2.0, CvePriority.Act)]
|
||||
[InlineData(false, 0.1, 2.0, CvePriority.Attend)]
|
||||
[InlineData(false, 0.0999, 2.0, CvePriority.Track)]
|
||||
[InlineData(false, null, 9.0, CvePriority.Attend)]
|
||||
[InlineData(false, 0.0, 9.8, CvePriority.Attend)]
|
||||
[InlineData(false, null, 8.9, CvePriority.Track)]
|
||||
[InlineData(false, 0.05, 7.5, CvePriority.Track)]
|
||||
public void Compute_FollowsTheThreeRules(bool kev, double? epss, double cvss, string expected)
|
||||
{
|
||||
Assert.Equal(expected, CvePriority.Compute(kev, epss, cvss));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Max_PicksHigherBand_AndTreatsUnknownAsLowest()
|
||||
{
|
||||
Assert.Equal(CvePriority.Act, CvePriority.Max(CvePriority.Track, CvePriority.Act));
|
||||
Assert.Equal(CvePriority.Attend, CvePriority.Max(CvePriority.Attend, null));
|
||||
Assert.Equal(CvePriority.Track, CvePriority.Max(null, ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StampFindingPriority_RollsUpHighestBand_AndMaxEpss()
|
||||
{
|
||||
var finding = new Dictionary<string, object> { ["maxEpssScore"] = 0.9 /* stale value, must be recomputed */ };
|
||||
var vulns = new List<object>
|
||||
{
|
||||
new Dictionary<string, object> { ["priority"] = CvePriority.Track, ["epssScore"] = 0.02 },
|
||||
new Dictionary<string, object> { ["priority"] = CvePriority.Attend, ["epssScore"] = 0.4 },
|
||||
new Dictionary<string, object> { ["priority"] = CvePriority.Track },
|
||||
};
|
||||
|
||||
VulnerabilityCorrelationService.StampFindingPriority(finding, vulns);
|
||||
|
||||
Assert.Equal(CvePriority.Attend, finding["priority"]);
|
||||
Assert.Equal(0.4, finding["maxEpssScore"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StampFindingPriority_NoScoredCves_OmitsMaxEpss()
|
||||
{
|
||||
var finding = new Dictionary<string, object>();
|
||||
VulnerabilityCorrelationService.StampFindingPriority(finding, new List<object> { new Dictionary<string, object> { ["priority"] = CvePriority.Track } });
|
||||
Assert.Equal(CvePriority.Track, finding["priority"]);
|
||||
Assert.False(finding.ContainsKey("maxEpssScore"));
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,25 @@ public class VulnerabilitySummaryAggregatorTests
|
||||
Assert.Equal(1, r.DistinctCves);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopCve_CarriesMaxEpssAndHighestPriority_NullWhenNothingScored()
|
||||
{
|
||||
var scored = Row("CVE-E", "s1", "Medium", 5.0); scored.EpssScore = 0.12; scored.Priority = "attend";
|
||||
var scoredLower = Row("CVE-E", "s2", "Medium", 5.0); scoredLower.EpssScore = 0.03; scoredLower.Priority = "track";
|
||||
var legacy = Row("CVE-E", "s3", "Medium", 5.0); // pre-EPSS row: null score, empty priority
|
||||
var unscored = Row("CVE-U", "s1", "Low", 2.0);
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(new[] { scored, scoredLower, legacy, unscored }, false, 20, 30, false, Now);
|
||||
|
||||
var e = r.TopCves.Single(c => c.CveId == "CVE-E");
|
||||
Assert.Equal(0.12, e.EpssScore);
|
||||
Assert.Equal("attend", e.Priority);
|
||||
|
||||
var u = r.TopCves.Single(c => c.CveId == "CVE-U");
|
||||
Assert.Null(u.EpssScore);
|
||||
Assert.Equal("", u.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildsSeverityBreakdownByDistinctCveAndCountsKev()
|
||||
{
|
||||
|
||||
+11
-1
@@ -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 ?? "",
|
||||
|
||||
+133
-80
@@ -27,6 +27,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
{
|
||||
private readonly KevDataService _kevService;
|
||||
private readonly NvdApiClient _nvdApi;
|
||||
private readonly EpssApiClient _epssApi;
|
||||
private readonly MsrcCorrelationService _msrcCorrelation;
|
||||
private readonly IVulnerabilityRepository _vulnRepo;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
@@ -36,6 +37,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
public VulnerabilityDataSyncFunction(
|
||||
KevDataService kevService,
|
||||
NvdApiClient nvdApi,
|
||||
EpssApiClient epssApi,
|
||||
MsrcCorrelationService msrcCorrelation,
|
||||
IVulnerabilityRepository vulnRepo,
|
||||
AdminConfigurationService adminConfigService,
|
||||
@@ -44,6 +46,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
{
|
||||
_kevService = kevService;
|
||||
_nvdApi = nvdApi;
|
||||
_epssApi = epssApi;
|
||||
_msrcCorrelation = msrcCorrelation;
|
||||
_vulnRepo = vulnRepo;
|
||||
_adminConfigService = adminConfigService;
|
||||
@@ -113,6 +116,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
cveRefreshed = cveStats.Refreshed;
|
||||
cveFailed = cveStats.Failed;
|
||||
|
||||
// 3b. Re-score EVERY cached CVE row with today's EPSS. Independent of the 7-day NVD
|
||||
// TTL: EPSS is republished daily and the calls are cheap (one batch per 100 ids).
|
||||
var epssStats = await RefreshEpssScoresAsync();
|
||||
|
||||
// 4. Update per-source sync timestamps. Only the sources that succeeded get
|
||||
// bumped — a failed phase keeps its prior timestamp so the UI can surface
|
||||
// "Last KEV/MSRC Sync" accurately.
|
||||
@@ -156,6 +163,12 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
{ "cveStale", cveStale.ToString() },
|
||||
{ "cveRefreshed", cveRefreshed.ToString() },
|
||||
{ "cveFailed", cveFailed.ToString() },
|
||||
{ "cveSkippedThrottled", cveStats.SkippedThrottled.ToString() },
|
||||
{ "epssRows", epssStats.Rows.ToString() },
|
||||
{ "epssCves", epssStats.Cves.ToString() },
|
||||
{ "epssScored", epssStats.Scored.ToString() },
|
||||
{ "epssRowsRewritten", epssStats.RowsRewritten.ToString() },
|
||||
{ "epssError", epssStats.Error ?? "" },
|
||||
{ "durationMs", durationMs.ToString("F0", System.Globalization.CultureInfo.InvariantCulture) }
|
||||
});
|
||||
}
|
||||
@@ -165,6 +178,90 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
public int Stale;
|
||||
public int Refreshed;
|
||||
public int Failed;
|
||||
/// <summary>Stale entries left untouched because NVD opened its throttle cooldown mid-run.</summary>
|
||||
public int SkippedThrottled;
|
||||
}
|
||||
|
||||
private struct EpssRefreshStats
|
||||
{
|
||||
public int Rows;
|
||||
public int Cves;
|
||||
public int Scored;
|
||||
public int RowsRewritten;
|
||||
public string? Error;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks every <c>cve_*</c> cache row, batches all CVE ids through the EPSS API and rewrites
|
||||
/// only the rows whose scores actually changed — with the row's ORIGINAL <c>CachedAt</c>, so
|
||||
/// an EPSS rewrite never counts as a fresh NVD fetch for the 7-day TTL.
|
||||
/// </summary>
|
||||
private async Task<EpssRefreshStats> RefreshEpssScoresAsync()
|
||||
{
|
||||
var stats = new EpssRefreshStats();
|
||||
try
|
||||
{
|
||||
var index = await _vulnRepo.GetStaleCveCacheEntriesAsync();
|
||||
var rows = new List<(string CacheKey, string CpeUri, DateTime CachedAt, List<CachedCve> Cves)>();
|
||||
|
||||
foreach (var entry in index)
|
||||
{
|
||||
var pk = entry.ContainsKey("PartitionKey") ? entry["PartitionKey"]?.ToString() : null;
|
||||
if (pk == null || !pk.StartsWith("cve_")) continue;
|
||||
|
||||
var full = await _vulnRepo.GetCveCacheEntryAsync(pk, "cache_result");
|
||||
if (full == null) continue;
|
||||
|
||||
var cachedAtStr = full.ContainsKey("CachedAt") ? full["CachedAt"]?.ToString() : null;
|
||||
if (!DateTime.TryParse(cachedAtStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var cachedAt))
|
||||
continue;
|
||||
|
||||
var json = TableStorageChunking.ReassembleProperty(full, "CveDataJson", _logger, $"{pk}/cache_result") ?? "[]";
|
||||
var cves = System.Text.Json.JsonSerializer.Deserialize<List<CachedCve>>(json, JsonOptions) ?? new List<CachedCve>();
|
||||
if (cves.Count == 0) continue;
|
||||
|
||||
var cpeUri = full.ContainsKey("CpeUri") ? full["CpeUri"]?.ToString() ?? pk.Substring(4) : pk.Substring(4);
|
||||
rows.Add((pk, cpeUri, cachedAt, cves));
|
||||
}
|
||||
|
||||
stats.Rows = rows.Count;
|
||||
var allIds = rows.SelectMany(r => r.Cves.Select(c => c.CveId)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
stats.Cves = allIds.Count;
|
||||
if (allIds.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("[VulnSync] EPSS refresh: no cached CVEs to score");
|
||||
return stats;
|
||||
}
|
||||
|
||||
var scores = await _epssApi.GetScoresAsync(allIds, CancellationToken.None);
|
||||
stats.Scored = scores.Count;
|
||||
|
||||
foreach (var (cacheKey, cpeUri, cachedAt, cves) in rows)
|
||||
{
|
||||
var changed = VulnerabilityCorrelationService.ApplyEpssScores(cves, scores);
|
||||
if (changed == 0) continue;
|
||||
try
|
||||
{
|
||||
await _vulnRepo.UpsertCveCacheEntryAsync(cacheKey, "cache_result",
|
||||
VulnerabilityCorrelationService.BuildCveCacheRow(cpeUri, cves, cachedAt));
|
||||
stats.RowsRewritten++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[VulnSync] EPSS rewrite failed for {CpeUri}", cpeUri);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[VulnSync] EPSS refresh: {Scored}/{Cves} CVEs scored across {Rows} rows, {Rewritten} rows rewritten",
|
||||
stats.Scored, stats.Cves, stats.Rows, stats.RowsRewritten);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stats.Error = ex.Message;
|
||||
_logger.LogWarning(ex, "[VulnSync] EPSS refresh failed");
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -212,104 +309,59 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
|
||||
int refreshed = 0;
|
||||
int failed = 0;
|
||||
int skippedThrottled = 0;
|
||||
|
||||
foreach (var (cacheKey, cpeUri, cachedAt, totalCves) in staleEntries)
|
||||
for (int i = 0; i < staleEntries.Count; i++)
|
||||
{
|
||||
var (cacheKey, cpeUri, cachedAt, totalCves) = staleEntries[i];
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("[VulnSync] Refreshing CVE cache for {CpeUri} (cached: {CachedAt}, had {TotalCves} CVEs)",
|
||||
cpeUri, cachedAt, totalCves);
|
||||
|
||||
// Query NVD API (rate-limited internally by NvdApiClient)
|
||||
var cveResponse = await _nvdApi.SearchCvesByCpeAsync(cpeUri, CancellationToken.None);
|
||||
var newCount = cveResponse?.Vulnerabilities?.Count ?? 0;
|
||||
|
||||
// Project NVD response → CachedCve list (same shape the read path expects).
|
||||
var cves = new List<CachedCve>();
|
||||
if (cveResponse?.Vulnerabilities != null)
|
||||
var fetch = await _nvdApi.SearchCvesByCpeAsync(cpeUri, CancellationToken.None);
|
||||
if (fetch.Throttled)
|
||||
{
|
||||
foreach (var vulnItem in cveResponse.Vulnerabilities)
|
||||
{
|
||||
var cve = vulnItem?.Cve;
|
||||
if (cve == null) continue;
|
||||
|
||||
double cvssScore = 0;
|
||||
string cvssSeverity = "UNKNOWN";
|
||||
var cvssMetric = cve.Metrics?.CvssMetricV31?.FirstOrDefault()
|
||||
?? cve.Metrics?.CvssMetricV30?.FirstOrDefault();
|
||||
if (cvssMetric?.CvssData != null)
|
||||
{
|
||||
cvssScore = cvssMetric.CvssData.BaseScore;
|
||||
cvssSeverity = cvssMetric.CvssData.BaseSeverity ?? "UNKNOWN";
|
||||
}
|
||||
|
||||
var description = cve.Descriptions?.FirstOrDefault(d =>
|
||||
string.Equals(d.Lang, "en", StringComparison.OrdinalIgnoreCase))?.Value ?? "";
|
||||
if (description.Length > 500)
|
||||
description = description.Substring(0, 497) + "...";
|
||||
|
||||
var affectedVersions = new List<CachedVersionRange>();
|
||||
if (cve.Configurations != null)
|
||||
{
|
||||
foreach (var config in cve.Configurations)
|
||||
{
|
||||
if (config.Nodes == null) continue;
|
||||
foreach (var node in config.Nodes)
|
||||
{
|
||||
if (node.CpeMatch == null) continue;
|
||||
foreach (var match in node.CpeMatch)
|
||||
{
|
||||
if (!match.Vulnerable) continue;
|
||||
affectedVersions.Add(new CachedVersionRange
|
||||
{
|
||||
Criteria = match.Criteria,
|
||||
VersionStartIncluding = match.VersionStartIncluding,
|
||||
VersionStartExcluding = match.VersionStartExcluding,
|
||||
VersionEndIncluding = match.VersionEndIncluding,
|
||||
VersionEndExcluding = match.VersionEndExcluding
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cves.Add(new CachedCve
|
||||
{
|
||||
CveId = cve.Id ?? "",
|
||||
CvssScore = cvssScore,
|
||||
CvssSeverity = cvssSeverity,
|
||||
Description = description,
|
||||
PublishedDate = cve.Published ?? "",
|
||||
AffectedVersions = affectedVersions
|
||||
});
|
||||
}
|
||||
// NVD is refusing us; the stale rows keep serving (soft-stale reads
|
||||
// still return data) and get another attempt tomorrow. Bail out of
|
||||
// the loop instead of paying the cooldown skip per remaining entry.
|
||||
skippedThrottled = staleEntries.Count - i;
|
||||
_logger.LogWarning("[VulnSync] NVD throttled — leaving {Remaining} stale entries for the next run", skippedThrottled);
|
||||
break;
|
||||
}
|
||||
if (!fetch.Succeeded)
|
||||
{
|
||||
failed++;
|
||||
continue; // row untouched: never overwrite real data with a refusal
|
||||
}
|
||||
|
||||
// Apply the same cache budget the live FetchAndCache path uses,
|
||||
// so the nightly refresh can't reintroduce oversized rows.
|
||||
// Project NVD response → CachedCve list (same shape the read path expects),
|
||||
// under the same cache budget the live FetchAndCache path uses.
|
||||
var cves = VulnerabilityCorrelationService.ProjectNvdResponse(fetch.Data);
|
||||
var budget = CveCacheBudget.Apply(cves, DateTime.UtcNow);
|
||||
cves = budget.Cves;
|
||||
|
||||
var cveDataJson = System.Text.Json.JsonSerializer.Serialize(cves, JsonOptions);
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
// EPSS is re-applied for all rows in step 3b; scoring here keeps the row
|
||||
// complete in case that step fails.
|
||||
try
|
||||
{
|
||||
{ "CachedAt", DateTime.UtcNow.ToString("o") },
|
||||
{ "CpeUri", cpeUri },
|
||||
{ "TotalCves", cves.Count }
|
||||
};
|
||||
var scores = await _epssApi.GetScoresAsync(cves.Select(c => c.CveId), CancellationToken.None);
|
||||
VulnerabilityCorrelationService.ApplyEpssScores(cves, scores);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[VulnSync] EPSS enrichment failed for {CpeUri}", cpeUri);
|
||||
}
|
||||
|
||||
// Chunk CveDataJson across multiple properties if it exceeds 30K chars
|
||||
foreach (var chunk in TableStorageChunking.ChunkProperty("CveDataJson", cveDataJson))
|
||||
data[chunk.Key] = chunk.Value;
|
||||
|
||||
await _vulnRepo.UpsertCveCacheEntryAsync(cacheKey, "cache_result", data);
|
||||
var row = VulnerabilityCorrelationService.BuildCveCacheRow(cpeUri, cves, DateTime.UtcNow);
|
||||
await _vulnRepo.UpsertCveCacheEntryAsync(cacheKey, "cache_result", row);
|
||||
refreshed++;
|
||||
|
||||
_logger.LogInformation(
|
||||
"[VulnSync] Refreshed {CpeUri}: {OldCount} → {NewCount} CVEs (NVD={Total}, afterDate={AfterDate}, truncRanges={TruncCves}, bytes={Bytes})",
|
||||
"[VulnSync] Refreshed {CpeUri}: {OldCount} → {NewCount} CVEs (NVD={Total}, afterDate={AfterDate}, truncRanges={TruncCves})",
|
||||
cpeUri, totalCves, cves.Count,
|
||||
budget.TotalIn, budget.KeptAfterDateFilter, budget.CvesWithTruncatedRanges, cveDataJson.Length);
|
||||
budget.TotalIn, budget.KeptAfterDateFilter, budget.CvesWithTruncatedRanges);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -318,11 +370,12 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("[VulnSync] CVE cache refresh complete: {Refreshed} refreshed, {Failed} failed out of {Total} stale entries",
|
||||
refreshed, failed, staleEntries.Count);
|
||||
_logger.LogInformation("[VulnSync] CVE cache refresh complete: {Refreshed} refreshed, {Failed} failed, {Skipped} skipped (throttled) out of {Total} stale entries",
|
||||
refreshed, failed, skippedThrottled, staleEntries.Count);
|
||||
|
||||
stats.Refreshed = refreshed;
|
||||
stats.Failed = failed;
|
||||
stats.SkippedThrottled = skippedThrottled;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+13
-2
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared.Models.Vulnerability;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
@@ -19,12 +20,18 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
/// <summary>Distinct-CVE counts grouped by their highest CVSS severity band.</summary>
|
||||
public record SeverityBreakdown(int Critical, int High, int Medium, int Low);
|
||||
|
||||
/// <summary>A single CVE ranked by how many devices it affects in scope.</summary>
|
||||
/// <summary>
|
||||
/// A single CVE ranked by how many devices it affects in scope. <paramref name="EpssScore"/>
|
||||
/// is the highest EPSS probability seen across the rows (null = unscored / legacy rows);
|
||||
/// <paramref name="Priority"/> the highest act/attend/track band (empty on legacy rows).
|
||||
/// </summary>
|
||||
public record TopCve(
|
||||
string CveId,
|
||||
string CvssSeverity,
|
||||
double CvssScore,
|
||||
bool IsKev,
|
||||
double? EpssScore,
|
||||
string Priority,
|
||||
int AffectedSessions,
|
||||
IReadOnlyList<string> SampleSoftware);
|
||||
|
||||
@@ -76,6 +83,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
SeverityRank = SeverityRank(topSeverityRow.CvssSeverity),
|
||||
CvssScore = g.Max(r => r.CvssScore),
|
||||
IsKev = g.Any(r => r.IsKev),
|
||||
// Max over a nullable ignores nulls and yields null when nothing is scored.
|
||||
EpssScore = g.Max(r => r.EpssScore),
|
||||
Priority = g.Select(r => r.Priority)
|
||||
.Aggregate(string.Empty, (best, p) => CvePriority.Rank(p) > CvePriority.Rank(best) ? p : best),
|
||||
AffectedSessions = g.Select(r => r.SessionId)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||
@@ -93,7 +104,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
.ThenByDescending(c => c.CvssScore)
|
||||
.ThenBy(c => c.CveId, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(topN)
|
||||
.Select(c => new TopCve(c.CveId, c.Severity, c.CvssScore, c.IsKev, c.AffectedSessions, c.SampleSoftware))
|
||||
.Select(c => new TopCve(c.CveId, c.Severity, c.CvssScore, c.IsKev, c.EpssScore, c.Priority, c.AffectedSessions, c.SampleSoftware))
|
||||
.ToList();
|
||||
|
||||
var breakdown = new SeverityBreakdown(
|
||||
|
||||
@@ -407,6 +407,8 @@ builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
|
||||
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.MsrcApiClient>()
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
|
||||
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.EpssApiClient>()
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Vulnerability.MsrcCorrelationService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelationService>();
|
||||
// Hydrate MSRC + KEV in-memory caches from blob snapshots at app startup (fire-and-forget;
|
||||
|
||||
@@ -319,6 +319,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var cvssSeverity = vuln.TryGetValue("cvssSeverity", out var csvs) ? csvs?.ToString() ?? "" : "";
|
||||
var isKev = vuln.TryGetValue("isKev", out var ik) && ik is bool ikb && ikb;
|
||||
double? epssScore = null;
|
||||
try { if (vuln.TryGetValue("epssScore", out var es) && es != null) epssScore = Convert.ToDouble(es); } catch { }
|
||||
var priority = vuln.TryGetValue("priority", out var pr) ? pr?.ToString() ?? "" : "";
|
||||
|
||||
if (byCve.TryGetValue(cveId, out var existing))
|
||||
{
|
||||
@@ -329,12 +332,16 @@ namespace AutopilotMonitor.Functions.Services
|
||||
existing["SoftwareName"] = softwareName;
|
||||
}
|
||||
existing["IsKev"] = existing.GetBoolean("IsKev") == true || isKev;
|
||||
if (epssScore.HasValue && epssScore.Value > (existing.GetDouble("EpssScore") ?? -1))
|
||||
existing["EpssScore"] = epssScore.Value;
|
||||
if (Vulnerability.CvePriority.Rank(priority) > Vulnerability.CvePriority.Rank(existing.GetString("Priority")))
|
||||
existing["Priority"] = priority;
|
||||
if (RiskRank(overallRisk) > RiskRank(existing.GetString("OverallRisk")))
|
||||
existing["OverallRisk"] = overallRisk;
|
||||
continue;
|
||||
}
|
||||
|
||||
byCve[cveId] = new TableEntity($"{tenantId}_{cveId}", sessionId)
|
||||
var entity = new TableEntity($"{tenantId}_{cveId}", sessionId)
|
||||
{
|
||||
["SessionId"] = sessionId,
|
||||
["TenantId"] = tenantId,
|
||||
@@ -343,9 +350,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
["CvssScore"] = cvssScore,
|
||||
["CvssSeverity"] = cvssSeverity,
|
||||
["IsKev"] = isKev,
|
||||
["Priority"] = priority,
|
||||
["OverallRisk"] = overallRisk,
|
||||
["DetectedAt"] = now,
|
||||
};
|
||||
// Nullable double: write only when scored so a legacy reader sees "absent",
|
||||
// not a fake 0.0 that would sort as "least likely exploited".
|
||||
if (epssScore.HasValue) entity["EpssScore"] = epssScore.Value;
|
||||
byCve[cveId] = entity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1364,7 +1376,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var select = new[]
|
||||
{
|
||||
"SessionId", "TenantId", "CveId", "SoftwareName",
|
||||
"CvssScore", "CvssSeverity", "IsKev", "OverallRisk", "DetectedAt",
|
||||
"CvssScore", "CvssSeverity", "IsKev", "EpssScore", "Priority", "OverallRisk", "DetectedAt",
|
||||
};
|
||||
|
||||
var rows = new List<CveExposureEntry>(Math.Min(maxRows, 2048));
|
||||
@@ -1392,6 +1404,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
CvssScore = e.GetDouble("CvssScore") ?? 0,
|
||||
CvssSeverity = e.GetString("CvssSeverity") ?? string.Empty,
|
||||
IsKev = e.GetBoolean("IsKev") ?? false,
|
||||
EpssScore = e.GetDouble("EpssScore"),
|
||||
Priority = e.GetString("Priority") ?? string.Empty,
|
||||
OverallRisk = e.GetString("OverallRisk") ?? string.Empty,
|
||||
DetectedAt = e.GetDateTime("DetectedAt"),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
{
|
||||
/// <summary>
|
||||
/// Remediation priority of one CVE, derived from the three signals we hold — deliberately a
|
||||
/// three-band label with visible rules, not an opaque composite score:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>act</c> — listed in CISA KEV: exploitation is confirmed, patch first.</item>
|
||||
/// <item><c>attend</c> — EPSS ≥ <see cref="AttendEpssThreshold"/> (a real exploitation
|
||||
/// likelihood; roughly the top decile of all scored CVEs) or CVSS ≥
|
||||
/// <see cref="AttendCvssThreshold"/> (critical impact).</item>
|
||||
/// <item><c>track</c> — everything else.</item>
|
||||
/// </list>
|
||||
/// This is orthogonal to <c>riskLevel</c>/<c>overall_risk</c> (CVSS + KEV severity bands), which
|
||||
/// keep their meaning: severity says how bad, priority says how soon. A CVE without an EPSS
|
||||
/// score is treated as "unknown likelihood" — it can still reach <c>attend</c> via CVSS, never
|
||||
/// via a defaulted 0.
|
||||
/// </summary>
|
||||
public static class CvePriority
|
||||
{
|
||||
public const string Act = "act";
|
||||
public const string Attend = "attend";
|
||||
public const string Track = "track";
|
||||
|
||||
public const double AttendEpssThreshold = 0.1;
|
||||
public const double AttendCvssThreshold = 9.0;
|
||||
|
||||
public static string Compute(bool isKev, double? epssScore, double cvssScore)
|
||||
{
|
||||
if (isKev) return Act;
|
||||
if (epssScore.HasValue && epssScore.Value >= AttendEpssThreshold) return Attend;
|
||||
if (cvssScore >= AttendCvssThreshold) return Attend;
|
||||
return Track;
|
||||
}
|
||||
|
||||
/// <summary>Rank for "highest priority wins" merges: act > attend > track > unknown.</summary>
|
||||
public static int Rank(string? priority) => priority?.ToLowerInvariant() switch
|
||||
{
|
||||
Act => 3,
|
||||
Attend => 2,
|
||||
Track => 1,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
public static string Max(string? a, string? b) => Rank(a) >= Rank(b) ? (a ?? Track) : (b ?? Track);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
{
|
||||
/// <summary>
|
||||
/// One EPSS observation for a CVE: the probability (0..1) that the vulnerability is exploited
|
||||
/// in the wild within the next 30 days, and the percentile of that score across all scored CVEs.
|
||||
/// </summary>
|
||||
public sealed class EpssScore
|
||||
{
|
||||
public string CveId { get; init; } = "";
|
||||
public double Score { get; init; }
|
||||
public double Percentile { get; init; }
|
||||
/// <summary>Scoring date as published by FIRST (yyyy-MM-dd).</summary>
|
||||
public string Date { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed HttpClient for the FIRST.org EPSS API (https://api.first.org/data/v1/epss).
|
||||
/// Public, unauthenticated, batched by CVE id — one request covers up to
|
||||
/// <see cref="BatchSize"/> CVEs, which is more than the per-CPE cache budget, so a cache fill
|
||||
/// costs exactly one EPSS call. Fail-soft: a failed batch is logged and skipped, the CVEs of
|
||||
/// that batch simply stay without a score (the nightly sync fills them in later). The only
|
||||
/// thing that leaves the backend is the list of public CVE identifiers held in the shared,
|
||||
/// tenant-agnostic CPE cache — never anything about a tenant or a device.
|
||||
/// </summary>
|
||||
public class EpssApiClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<EpssApiClient> _logger;
|
||||
|
||||
internal const string EpssUrl = "https://api.first.org/data/v1/epss";
|
||||
// The API accepts a comma-joined cve list of up to ~2000 characters (≈ 100 ids).
|
||||
internal const int BatchSize = 100;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public EpssApiClient(HttpClient http, ILogger<EpssApiClient> logger)
|
||||
{
|
||||
_http = http ?? throw new ArgumentNullException(nameof(http));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_http.Timeout = TimeSpan.FromSeconds(15);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up EPSS scores for the given CVE ids. Returns a case-insensitive map keyed by CVE
|
||||
/// id containing only the ids FIRST returned a score for (a CVE without a score is a normal
|
||||
/// outcome — brand-new or rejected CVEs are not scored). Duplicates and blanks are ignored.
|
||||
/// </summary>
|
||||
public async Task<Dictionary<string, EpssScore>> GetScoresAsync(IEnumerable<string> cveIds, CancellationToken ct = default)
|
||||
{
|
||||
var result = new Dictionary<string, EpssScore>(StringComparer.OrdinalIgnoreCase);
|
||||
var ids = cveIds
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id.Trim().ToUpperInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (ids.Count == 0) return result;
|
||||
|
||||
for (int i = 0; i < ids.Count; i += BatchSize)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var batch = ids.Skip(i).Take(BatchSize).ToList();
|
||||
var url = $"{EpssUrl}?cve={Uri.EscapeDataString(string.Join(",", batch))}";
|
||||
var started = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("User-Agent", "AutopilotMonitor/1.0");
|
||||
using var response = await _http.SendAsync(request, ct);
|
||||
var durationMs = (DateTime.UtcNow - started).TotalMilliseconds;
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"EPSS API returned {StatusCode} for a batch of {Count} CVEs after {DurationMs:F0}ms",
|
||||
(int)response.StatusCode, batch.Count, durationMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var parsed = ParseResponse(json);
|
||||
foreach (var score in parsed)
|
||||
result[score.CveId] = score;
|
||||
|
||||
_logger.LogInformation(
|
||||
"EPSS API OK: {Scored}/{Requested} CVEs scored in {DurationMs:F0}ms",
|
||||
parsed.Count, batch.Count, durationMs);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "EPSS API request failed for a batch of {Count} CVEs", batch.Count);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the FIRST response envelope <c>{ "status": "OK", "data": [ { "cve", "epss",
|
||||
/// "percentile", "date" } ] }</c>. <c>epss</c> and <c>percentile</c> are JSON strings,
|
||||
/// parsed invariant. Entries that do not parse are dropped, never defaulted to 0 — a
|
||||
/// missing score must stay "unknown", not "safe".
|
||||
/// </summary>
|
||||
internal static List<EpssScore> ParseResponse(string json)
|
||||
{
|
||||
var list = new List<EpssScore>();
|
||||
var envelope = JsonSerializer.Deserialize<EpssEnvelope>(json, JsonOptions);
|
||||
if (envelope?.Data == null) return list;
|
||||
|
||||
foreach (var row in envelope.Data)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Cve)) continue;
|
||||
if (!double.TryParse(row.Epss, NumberStyles.Float, CultureInfo.InvariantCulture, out var score)) continue;
|
||||
if (!double.TryParse(row.Percentile, NumberStyles.Float, CultureInfo.InvariantCulture, out var percentile)) continue;
|
||||
if (score < 0 || score > 1 || percentile < 0 || percentile > 1) continue;
|
||||
|
||||
list.Add(new EpssScore
|
||||
{
|
||||
CveId = row.Cve.Trim().ToUpperInvariant(),
|
||||
Score = score,
|
||||
Percentile = percentile,
|
||||
Date = row.Date ?? ""
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private sealed class EpssEnvelope
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
public List<EpssRow>? Data { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EpssRow
|
||||
{
|
||||
public string? Cve { get; set; }
|
||||
public string? Epss { get; set; }
|
||||
public string? Percentile { get; set; }
|
||||
public string? Date { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,44 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
{
|
||||
/// <summary>
|
||||
/// Outcome of one NVD request. <see cref="Data"/> is never null — on failure it is an empty
|
||||
/// response object so callers that only enumerate keep working — but <see cref="Succeeded"/>
|
||||
/// is the ONLY signal that the empty list is a real answer. Callers that persist results
|
||||
/// (CVE cache, negative auto-resolve cache) must check it: caching a throttled response as
|
||||
/// "no CVEs" poisoned the 7-day cache for every product NVD happened to throttle.
|
||||
/// </summary>
|
||||
public sealed class NvdFetchResult<T> where T : class, new()
|
||||
{
|
||||
public T Data { get; }
|
||||
public bool Succeeded { get; }
|
||||
/// <summary>True when the failure was HTTP 429 (or a fail-fast during the resulting cooldown).</summary>
|
||||
public bool Throttled { get; }
|
||||
|
||||
private NvdFetchResult(T data, bool succeeded, bool throttled)
|
||||
{
|
||||
Data = data;
|
||||
Succeeded = succeeded;
|
||||
Throttled = throttled;
|
||||
}
|
||||
|
||||
public static NvdFetchResult<T> Ok(T data) => new(data, true, false);
|
||||
public static NvdFetchResult<T> Failed() => new(new T(), false, false);
|
||||
public static NvdFetchResult<T> ThrottledResult() => new(new T(), false, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed HttpClient for the NVD API 2.0.
|
||||
/// Handles rate limiting (5 req/30s without key, 50 req/30s with key).
|
||||
/// <para>
|
||||
/// Throttle contract: the HttpClient's <c>ExternalDataApi</c> Polly policy already retries a
|
||||
/// 429 three times (1/2/4 s + jitter), so a 429 reaching this class means NVD is refusing us
|
||||
/// for real. We then open a process-wide cooldown (<see cref="ThrottleCooldown"/>, or the
|
||||
/// server's <c>Retry-After</c> capped at <see cref="MaxRetryAfter"/>) during which every
|
||||
/// request fails fast with <see cref="NvdFetchResult{T}.Throttled"/> instead of burning
|
||||
/// another retry chain per software item. Bounded worst case per cooldown window: ONE
|
||||
/// retry chain (~7.5 s), never a stall of the whole correlation run.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class NvdApiClient
|
||||
{
|
||||
@@ -21,6 +56,34 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
|
||||
private static readonly SemaphoreSlim _rateLimiter = new SemaphoreSlim(1, 1);
|
||||
private static DateTime _lastRequestTime = DateTime.MinValue;
|
||||
private static DateTime _throttledUntilUtc = DateTime.MinValue;
|
||||
|
||||
internal static readonly TimeSpan ThrottleCooldown = TimeSpan.FromSeconds(30);
|
||||
internal static readonly TimeSpan MaxRetryAfter = TimeSpan.FromSeconds(120);
|
||||
|
||||
/// <summary>True while the process-wide 429 cooldown is active.</summary>
|
||||
public static bool IsInCooldown => DateTime.UtcNow < _throttledUntilUtc;
|
||||
|
||||
/// <summary>
|
||||
/// Cooldown length for a 429: the server's <c>Retry-After</c> (delta-seconds or HTTP date)
|
||||
/// when present and sane, otherwise <see cref="ThrottleCooldown"/>; never above
|
||||
/// <see cref="MaxRetryAfter"/> so a misconfigured header cannot park us for an hour.
|
||||
/// </summary>
|
||||
internal static TimeSpan ComputeCooldown(System.Net.Http.Headers.RetryConditionHeaderValue? retryAfter, DateTime nowUtc)
|
||||
{
|
||||
TimeSpan? fromHeader = null;
|
||||
if (retryAfter?.Delta is TimeSpan delta)
|
||||
fromHeader = delta;
|
||||
else if (retryAfter?.Date is DateTimeOffset date)
|
||||
fromHeader = date.UtcDateTime - nowUtc;
|
||||
|
||||
if (fromHeader is null || fromHeader.Value <= TimeSpan.Zero)
|
||||
return ThrottleCooldown;
|
||||
return fromHeader.Value > MaxRetryAfter ? MaxRetryAfter : fromHeader.Value;
|
||||
}
|
||||
|
||||
/// <summary>Test seam: clears the cooldown so suites do not leak state into each other.</summary>
|
||||
internal static void ResetThrottleState() => _throttledUntilUtc = DateTime.MinValue;
|
||||
|
||||
private const string NvdCveUrl = "https://services.nvd.nist.gov/rest/json/cves/2.0";
|
||||
private const string NvdCpeUrl = "https://services.nvd.nist.gov/rest/json/cpes/2.0";
|
||||
@@ -42,7 +105,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
/// <summary>
|
||||
/// Search CVEs for a specific CPE name. Returns CVEs with version range configurations.
|
||||
/// </summary>
|
||||
public async Task<NvdCveResponse> SearchCvesByCpeAsync(string cpeName, CancellationToken ct = default)
|
||||
public async Task<NvdFetchResult<NvdCveResponse>> SearchCvesByCpeAsync(string cpeName, CancellationToken ct = default)
|
||||
{
|
||||
// Use virtualMatchString for partial CPE matches (without version).
|
||||
// cpeName requires a full CPE URI with version which we don't always have.
|
||||
@@ -55,7 +118,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
/// Search CPE dictionary by keyword (vendor + product).
|
||||
/// Used when seed mapping doesn't cover a product.
|
||||
/// </summary>
|
||||
public async Task<NvdCpeResponse> SearchCpeAsync(string keywordSearch, CancellationToken ct = default)
|
||||
public async Task<NvdFetchResult<NvdCpeResponse>> SearchCpeAsync(string keywordSearch, CancellationToken ct = default)
|
||||
{
|
||||
var url = $"{NvdCpeUrl}?keywordSearch={Uri.EscapeDataString(keywordSearch)}&resultsPerPage=20";
|
||||
return await ExecuteWithRateLimitAsync<NvdCpeResponse>(url, ct);
|
||||
@@ -66,9 +129,19 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
private static long _totalSuccesses;
|
||||
private static long _totalErrors;
|
||||
private static long _total429s;
|
||||
private static long _totalCooldownSkips;
|
||||
|
||||
private async Task<T> ExecuteWithRateLimitAsync<T>(string url, CancellationToken ct) where T : class, new()
|
||||
private async Task<NvdFetchResult<T>> ExecuteWithRateLimitAsync<T>(string url, CancellationToken ct) where T : class, new()
|
||||
{
|
||||
if (IsInCooldown)
|
||||
{
|
||||
System.Threading.Interlocked.Increment(ref _totalCooldownSkips);
|
||||
_logger.LogInformation(
|
||||
"NVD throttle cooldown active until {Until:o}, skipping {Url} (cooldownSkips={Skips})",
|
||||
_throttledUntilUtc, url, _totalCooldownSkips);
|
||||
return NvdFetchResult<T>.ThrottledResult();
|
||||
}
|
||||
|
||||
await _rateLimiter.WaitAsync(ct);
|
||||
var requestStart = DateTime.UtcNow;
|
||||
try
|
||||
@@ -100,11 +173,15 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
|
||||
if ((int)response.StatusCode == 429)
|
||||
{
|
||||
// Polly already retried this 3x — NVD is refusing us for real. Open the
|
||||
// cooldown so the rest of the run fails fast instead of stalling per item.
|
||||
System.Threading.Interlocked.Increment(ref _total429s);
|
||||
var cooldown = ComputeCooldown(response.Headers.RetryAfter, DateTime.UtcNow);
|
||||
_throttledUntilUtc = DateTime.UtcNow + cooldown;
|
||||
_logger.LogWarning(
|
||||
"NVD API rate-limited (429) for {Url} after {DurationMs:F0}ms (apiKey={HasKey}, total429s={Total429s})",
|
||||
url, durationMs, hasApiKey, _total429s);
|
||||
return new T();
|
||||
"NVD API rate-limited (429) for {Url} after {DurationMs:F0}ms (apiKey={HasKey}, total429s={Total429s}); cooldown {CooldownSec:F0}s until {Until:o}",
|
||||
url, durationMs, hasApiKey, _total429s, cooldown.TotalSeconds, _throttledUntilUtc);
|
||||
return NvdFetchResult<T>.ThrottledResult();
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
@@ -113,7 +190,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
_logger.LogWarning(
|
||||
"NVD API returned {StatusCode} for {Url} after {DurationMs:F0}ms (totalErrors={TotalErrors})",
|
||||
(int)response.StatusCode, url, durationMs, _totalErrors);
|
||||
return new T();
|
||||
return NvdFetchResult<T>.Failed();
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
@@ -124,7 +201,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
"NVD API OK: {Url} in {DurationMs:F0}ms, {ResponseBytes} bytes (apiKey={HasKey}, requests={Total}, ok={Successes}, err={Errors})",
|
||||
url, durationMs, json.Length, hasApiKey, _totalRequests, _totalSuccesses, _totalErrors);
|
||||
|
||||
return result;
|
||||
return NvdFetchResult<T>.Ok(result);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
@@ -133,7 +210,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
_logger.LogWarning(ex,
|
||||
"NVD API request failed for {Url} after {DurationMs:F0}ms (totalErrors={TotalErrors})",
|
||||
url, durationMs, _totalErrors);
|
||||
return new T();
|
||||
return NvdFetchResult<T>.Failed();
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
+238
-89
@@ -22,6 +22,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
private readonly KevDataService _kevService;
|
||||
private readonly MsrcCorrelationService _msrcCorrelation;
|
||||
private readonly AdminConfigurationService _adminConfig;
|
||||
private readonly EpssApiClient? _epssApi;
|
||||
private readonly TelemetryClient? _telemetryClient;
|
||||
private readonly ILogger<VulnerabilityCorrelationService> _logger;
|
||||
|
||||
@@ -47,7 +48,8 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
AdminConfigurationService adminConfig,
|
||||
TelemetryClient? telemetryClient,
|
||||
ILogger<VulnerabilityCorrelationService> logger,
|
||||
IMaintenanceRepository? maintenanceRepo = null)
|
||||
IMaintenanceRepository? maintenanceRepo = null,
|
||||
EpssApiClient? epssApi = null)
|
||||
{
|
||||
_vulnRepo = vulnRepo;
|
||||
_sessionRepo = sessionRepo;
|
||||
@@ -56,6 +58,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
_kevService = kevService;
|
||||
_msrcCorrelation = msrcCorrelation;
|
||||
_adminConfig = adminConfig;
|
||||
_epssApi = epssApi;
|
||||
_telemetryClient = telemetryClient;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -98,6 +101,8 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
int lowCves = 0;
|
||||
int kevMatches = 0;
|
||||
int msrcMatches = 0;
|
||||
int actCves = 0;
|
||||
int attendCves = 0;
|
||||
string overallRisk = "none";
|
||||
|
||||
var cpeMappingsDict = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -178,6 +183,14 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
vuln["isKev"] = false;
|
||||
}
|
||||
|
||||
// Priority needs the KEV verdict, so it is stamped here, not in FilterByVersion.
|
||||
var epssForPriority = vuln.TryGetValue("epssScore", out var epssObj) && epssObj is double epssVal ? epssVal : (double?)null;
|
||||
var cvssForPriority = vuln.TryGetValue("cvssScore", out var cvssObj) ? Convert.ToDouble(cvssObj) : 0;
|
||||
var priority = CvePriority.Compute(kevEntry != null, epssForPriority, cvssForPriority);
|
||||
vuln["priority"] = priority;
|
||||
if (priority == CvePriority.Act) actCves++;
|
||||
else if (priority == CvePriority.Attend) attendCves++;
|
||||
|
||||
// MSRC enrichment — only present if the CVE was published in a recent
|
||||
// Patch Tuesday document. Augments source label and adds Microsoft-side
|
||||
// metadata so the UI can show "confirmed by Microsoft" alongside NVD data.
|
||||
@@ -223,7 +236,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
else if (maxCvss >= 4.0) riskLevel = "medium";
|
||||
else if (maxCvss > 0) riskLevel = "low";
|
||||
|
||||
findings.Add(new Dictionary<string, object>
|
||||
var finding = new Dictionary<string, object>
|
||||
{
|
||||
{ "softwareName", displayName },
|
||||
{ "installedVersion", installedVersion },
|
||||
@@ -231,7 +244,9 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
{ "matchConfidence", cpeMapping.MatchConfidence ?? "high" },
|
||||
{ "riskLevel", riskLevel },
|
||||
{ "vulnerabilities", vulns }
|
||||
});
|
||||
};
|
||||
StampFindingPriority(finding, vulns);
|
||||
findings.Add(finding);
|
||||
}
|
||||
|
||||
// Determine overall risk
|
||||
@@ -283,6 +298,8 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
{ "low", lowCves.ToString() },
|
||||
{ "kev", kevMatches.ToString() },
|
||||
{ "msrc", msrcMatches.ToString() },
|
||||
{ "act", actCves.ToString() },
|
||||
{ "attend", attendCves.ToString() },
|
||||
{ "risk", overallRisk }
|
||||
});
|
||||
|
||||
@@ -312,6 +329,8 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
{ "low_cves", lowCves },
|
||||
{ "kev_matches", kevMatches },
|
||||
{ "msrc_matches", msrcMatches },
|
||||
{ "act_cves", actCves },
|
||||
{ "attend_cves", attendCves },
|
||||
{ "overall_risk", overallRisk },
|
||||
{ "additional_findings_count", additionalCount }
|
||||
}
|
||||
@@ -451,7 +470,17 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
"CPE auto-resolve: searching NVD for '{Keyword}' (vendor={Vendor}, product={Name})",
|
||||
keyword, normalizedVendor, normalizedName);
|
||||
|
||||
var cpeResponse = await _nvdApi.SearchCpeAsync(keyword, ct);
|
||||
var cpeFetch = await _nvdApi.SearchCpeAsync(keyword, ct);
|
||||
if (!cpeFetch.Succeeded)
|
||||
{
|
||||
// Throttled / failed lookup is NOT a miss: leave the negative cache alone so
|
||||
// the product gets a real attempt on the next correlation run.
|
||||
_logger.LogInformation(
|
||||
"CPE auto-resolve SKIPPED for '{DisplayName}' (NVD {Reason}) — not cached as a miss",
|
||||
displayName, cpeFetch.Throttled ? "throttled" : "unavailable");
|
||||
return null;
|
||||
}
|
||||
var cpeResponse = cpeFetch.Data;
|
||||
var candidateCount = cpeResponse.Products?.Count ?? 0;
|
||||
|
||||
var bestMatch = CpeMatchScorer.SelectBestMatch(
|
||||
@@ -589,12 +618,26 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
|
||||
if (cachedResult == null || cachedResult.IsHardExpired)
|
||||
{
|
||||
if (cachedResult?.IsHardExpired == true)
|
||||
_logger.LogInformation("CVE cache hard-expired for {CpeUri} (age {AgeDays:F0}d), discarding stale data",
|
||||
// Cache miss or hard-expired: synchronous fetch from NVD. A failed fetch
|
||||
// (throttled / error) returns null and is NOT persisted — an empty answer
|
||||
// cached for 7 days would hide every CVE of that product. Hard-expired data
|
||||
// is then still better than nothing, so it stays in service until NVD answers.
|
||||
var fresh = await FetchAndCacheCvesFromNvdAsync(cpeUri, cacheKey, ct);
|
||||
if (fresh != null)
|
||||
{
|
||||
cachedResult = fresh;
|
||||
}
|
||||
else if (cachedResult != null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"NVD unavailable for {CpeUri}; serving hard-expired cache (age {AgeDays:F0}d) instead of nothing",
|
||||
cpeUri, (DateTime.UtcNow - cachedResult.CachedAt).TotalDays);
|
||||
|
||||
// Cache miss or hard-expired: synchronous fetch from NVD
|
||||
cachedResult = await FetchAndCacheCvesFromNvdAsync(cpeUri, cacheKey, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("NVD unavailable for {CpeUri} and no cache — product reported without CVE data this run", cpeUri);
|
||||
return new List<Dictionary<string, object>>();
|
||||
}
|
||||
}
|
||||
else if (cachedResult.IsStale)
|
||||
{
|
||||
@@ -652,77 +695,23 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query NVD API for CVEs matching a CPE, then cache the results via the repository.
|
||||
/// Returns the parsed CVE data regardless of whether caching succeeds.
|
||||
/// Query NVD API for CVEs matching a CPE, enrich with EPSS, then cache the results via the
|
||||
/// repository. Returns null when NVD did not answer (throttled / error) — nothing is
|
||||
/// persisted in that case, so the cache never learns "no CVEs" from a refusal.
|
||||
/// </summary>
|
||||
private async Task<CachedCveResult> FetchAndCacheCvesFromNvdAsync(
|
||||
private async Task<CachedCveResult?> FetchAndCacheCvesFromNvdAsync(
|
||||
string cpeUri, string cacheKey, CancellationToken ct)
|
||||
{
|
||||
var cveResponse = await _nvdApi.SearchCvesByCpeAsync(cpeUri, ct);
|
||||
var cves = new List<CachedCve>();
|
||||
|
||||
if (cveResponse?.Vulnerabilities != null)
|
||||
var fetch = await _nvdApi.SearchCvesByCpeAsync(cpeUri, ct);
|
||||
if (!fetch.Succeeded)
|
||||
{
|
||||
foreach (var vulnItem in cveResponse.Vulnerabilities)
|
||||
{
|
||||
var cve = vulnItem?.Cve;
|
||||
if (cve == null) continue;
|
||||
|
||||
// Extract CVSS score (prefer v3.1, fallback to v3.0)
|
||||
double cvssScore = 0;
|
||||
string cvssSeverity = "UNKNOWN";
|
||||
var cvssMetric = cve.Metrics?.CvssMetricV31?.FirstOrDefault()
|
||||
?? cve.Metrics?.CvssMetricV30?.FirstOrDefault();
|
||||
if (cvssMetric?.CvssData != null)
|
||||
{
|
||||
cvssScore = cvssMetric.CvssData.BaseScore;
|
||||
cvssSeverity = cvssMetric.CvssData.BaseSeverity ?? "UNKNOWN";
|
||||
}
|
||||
|
||||
// Extract English description
|
||||
var description = cve.Descriptions?.FirstOrDefault(d =>
|
||||
string.Equals(d.Lang, "en", StringComparison.OrdinalIgnoreCase))?.Value ?? "";
|
||||
if (description.Length > 500)
|
||||
description = description.Substring(0, 497) + "...";
|
||||
|
||||
// Collect all version ranges from configurations
|
||||
var affectedVersions = new List<CachedVersionRange>();
|
||||
if (cve.Configurations != null)
|
||||
{
|
||||
foreach (var config in cve.Configurations)
|
||||
{
|
||||
if (config.Nodes == null) continue;
|
||||
foreach (var node in config.Nodes)
|
||||
{
|
||||
if (node.CpeMatch == null) continue;
|
||||
foreach (var match in node.CpeMatch)
|
||||
{
|
||||
if (!match.Vulnerable) continue;
|
||||
affectedVersions.Add(new CachedVersionRange
|
||||
{
|
||||
Criteria = match.Criteria,
|
||||
VersionStartIncluding = match.VersionStartIncluding,
|
||||
VersionStartExcluding = match.VersionStartExcluding,
|
||||
VersionEndIncluding = match.VersionEndIncluding,
|
||||
VersionEndExcluding = match.VersionEndExcluding
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cves.Add(new CachedCve
|
||||
{
|
||||
CveId = cve.Id ?? "",
|
||||
CvssScore = cvssScore,
|
||||
CvssSeverity = cvssSeverity,
|
||||
Description = description,
|
||||
PublishedDate = cve.Published ?? "",
|
||||
AffectedVersions = affectedVersions
|
||||
});
|
||||
}
|
||||
_logger.LogInformation("CVE fetch for {CpeUri} not cached: NVD {Reason}",
|
||||
cpeUri, fetch.Throttled ? "throttled" : "unavailable");
|
||||
return null;
|
||||
}
|
||||
|
||||
var cves = ProjectNvdResponse(fetch.Data);
|
||||
|
||||
// Apply cache budget: date filter + CVSS-sorted cap + per-CVE AffectedVersions cap.
|
||||
// Protects against the 1 MB Azure Table Storage per-entity ceiling that
|
||||
// property-chunking alone cannot solve.
|
||||
@@ -735,6 +724,22 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
cpeUri, budget.TotalIn, budget.KeptAfterDateFilter, budget.KeptAfterCap, budget.CvesWithTruncatedRanges);
|
||||
}
|
||||
|
||||
// One EPSS batch per CPE (≤ MaxCachedCves ids, one request). Best-effort: the
|
||||
// nightly sync re-scores every cached row anyway.
|
||||
if (_epssApi != null && cves.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scores = await _epssApi.GetScoresAsync(cves.Select(c => c.CveId), ct);
|
||||
ApplyEpssScores(cves, scores);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "EPSS enrichment failed for {CpeUri} (cached without scores)", cpeUri);
|
||||
}
|
||||
}
|
||||
|
||||
var result = new CachedCveResult
|
||||
{
|
||||
Cves = cves,
|
||||
@@ -744,20 +749,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
// Persist to repository (best-effort, don't fail the request)
|
||||
try
|
||||
{
|
||||
var cveDataJson = JsonSerializer.Serialize(cves, JsonOptions);
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "CachedAt", DateTime.UtcNow.ToString("o") },
|
||||
{ "CpeUri", cpeUri },
|
||||
{ "TotalCves", cves.Count }
|
||||
};
|
||||
|
||||
// Chunk CveDataJson across multiple properties if it exceeds 30K chars
|
||||
foreach (var chunk in TableStorageChunking.ChunkProperty("CveDataJson", cveDataJson))
|
||||
data[chunk.Key] = chunk.Value;
|
||||
|
||||
await _vulnRepo.UpsertCveCacheEntryAsync(cacheKey, "cache_result", data);
|
||||
await _vulnRepo.UpsertCveCacheEntryAsync(cacheKey, "cache_result", BuildCveCacheRow(cpeUri, cves, result.CachedAt));
|
||||
_logger.LogDebug("Cached {Count} CVEs for {CpeUri}", cves.Count, cpeUri);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -768,6 +760,117 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects an NVD 2.0 response onto the cached shape. Shared by the live fill and the
|
||||
/// nightly refresh so both write byte-identical rows. CVSS prefers v3.1, falls back to v3.0;
|
||||
/// the vector string rides along (attack vector / privileges matter for endpoint fleets).
|
||||
/// </summary>
|
||||
internal static List<CachedCve> ProjectNvdResponse(NvdCveResponse? response)
|
||||
{
|
||||
var cves = new List<CachedCve>();
|
||||
if (response?.Vulnerabilities == null) return cves;
|
||||
|
||||
foreach (var vulnItem in response.Vulnerabilities)
|
||||
{
|
||||
var cve = vulnItem?.Cve;
|
||||
if (cve == null) continue;
|
||||
|
||||
double cvssScore = 0;
|
||||
string cvssSeverity = "UNKNOWN";
|
||||
string? cvssVector = null;
|
||||
var cvssMetric = cve.Metrics?.CvssMetricV31?.FirstOrDefault()
|
||||
?? cve.Metrics?.CvssMetricV30?.FirstOrDefault();
|
||||
if (cvssMetric?.CvssData != null)
|
||||
{
|
||||
cvssScore = cvssMetric.CvssData.BaseScore;
|
||||
cvssSeverity = cvssMetric.CvssData.BaseSeverity ?? "UNKNOWN";
|
||||
cvssVector = string.IsNullOrWhiteSpace(cvssMetric.CvssData.VectorString) ? null : cvssMetric.CvssData.VectorString;
|
||||
}
|
||||
|
||||
var description = cve.Descriptions?.FirstOrDefault(d =>
|
||||
string.Equals(d.Lang, "en", StringComparison.OrdinalIgnoreCase))?.Value ?? "";
|
||||
if (description.Length > 500)
|
||||
description = description.Substring(0, 497) + "...";
|
||||
|
||||
var affectedVersions = new List<CachedVersionRange>();
|
||||
if (cve.Configurations != null)
|
||||
{
|
||||
foreach (var config in cve.Configurations)
|
||||
{
|
||||
if (config.Nodes == null) continue;
|
||||
foreach (var node in config.Nodes)
|
||||
{
|
||||
if (node.CpeMatch == null) continue;
|
||||
foreach (var match in node.CpeMatch)
|
||||
{
|
||||
if (!match.Vulnerable) continue;
|
||||
affectedVersions.Add(new CachedVersionRange
|
||||
{
|
||||
Criteria = match.Criteria,
|
||||
VersionStartIncluding = match.VersionStartIncluding,
|
||||
VersionStartExcluding = match.VersionStartExcluding,
|
||||
VersionEndIncluding = match.VersionEndIncluding,
|
||||
VersionEndExcluding = match.VersionEndExcluding
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cves.Add(new CachedCve
|
||||
{
|
||||
CveId = cve.Id ?? "",
|
||||
CvssScore = cvssScore,
|
||||
CvssSeverity = cvssSeverity,
|
||||
CvssVector = cvssVector,
|
||||
Description = description,
|
||||
PublishedDate = cve.Published ?? "",
|
||||
AffectedVersions = affectedVersions
|
||||
});
|
||||
}
|
||||
return cves;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies EPSS scores onto cached CVEs. Returns how many entries changed (new score, or a
|
||||
/// different score/date than before) so the nightly refresh can skip unchanged rows.
|
||||
/// A CVE absent from <paramref name="scores"/> keeps whatever it had — a failed batch must
|
||||
/// not erase yesterday's score.
|
||||
/// </summary>
|
||||
internal static int ApplyEpssScores(List<CachedCve> cves, IReadOnlyDictionary<string, EpssScore> scores)
|
||||
{
|
||||
int changed = 0;
|
||||
foreach (var cve in cves)
|
||||
{
|
||||
if (!scores.TryGetValue(cve.CveId, out var s)) continue;
|
||||
if (cve.EpssScore == s.Score && cve.EpssPercentile == s.Percentile && cve.EpssDate == s.Date) continue;
|
||||
cve.EpssScore = s.Score;
|
||||
cve.EpssPercentile = s.Percentile;
|
||||
cve.EpssDate = s.Date;
|
||||
changed++;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The VulnerabilityCache row shape for one CPE, CveDataJson chunked at 30K chars.
|
||||
/// <paramref name="cachedAt"/> is the NVD freshness stamp — an EPSS-only rewrite must pass
|
||||
/// the existing value through so it does not masquerade as a fresh NVD fetch.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, object> BuildCveCacheRow(string cpeUri, List<CachedCve> cves, DateTime cachedAt)
|
||||
{
|
||||
var cveDataJson = JsonSerializer.Serialize(cves, JsonOptions);
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "CachedAt", cachedAt.ToString("o") },
|
||||
{ "CpeUri", cpeUri },
|
||||
{ "TotalCves", cves.Count }
|
||||
};
|
||||
foreach (var chunk in TableStorageChunking.ChunkProperty("CveDataJson", cveDataJson))
|
||||
data[chunk.Key] = chunk.Value;
|
||||
return data;
|
||||
}
|
||||
|
||||
// Platform keywords in CPE target_sw (field index 10) or target_hw (field index 11)
|
||||
// that indicate the CVE does NOT apply to Windows.
|
||||
internal static readonly HashSet<string> NonWindowsPlatforms = new(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -946,6 +1049,16 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
{ "knownRansomware", "" }
|
||||
};
|
||||
|
||||
// Optional enrichment — absent keys mean "unknown", never a defaulted 0/empty.
|
||||
if (!string.IsNullOrEmpty(cve.CvssVector))
|
||||
entry["cvssVector"] = cve.CvssVector;
|
||||
if (cve.EpssScore.HasValue)
|
||||
{
|
||||
entry["epssScore"] = cve.EpssScore.Value;
|
||||
if (cve.EpssPercentile.HasValue) entry["epssPercentile"] = cve.EpssPercentile.Value;
|
||||
if (!string.IsNullOrEmpty(cve.EpssDate)) entry["epssDate"] = cve.EpssDate;
|
||||
}
|
||||
|
||||
if (isWildcardMatch)
|
||||
{
|
||||
entry["versionMatchConfidence"] = "low";
|
||||
@@ -1142,8 +1255,15 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
public string CveId { get; set; } = "";
|
||||
public double CvssScore { get; set; }
|
||||
public string CvssSeverity { get; set; } = "UNKNOWN";
|
||||
/// <summary>CVSS v3.x vector string (e.g. <c>CVSS:3.1/AV:N/AC:L/…</c>); null on legacy rows.</summary>
|
||||
public string? CvssVector { get; set; }
|
||||
public string Description { get; set; } = "";
|
||||
public string PublishedDate { get; set; } = "";
|
||||
/// <summary>FIRST EPSS probability (0..1); null = not scored / not yet fetched.</summary>
|
||||
public double? EpssScore { get; set; }
|
||||
public double? EpssPercentile { get; set; }
|
||||
/// <summary>EPSS scoring date (yyyy-MM-dd) the score belongs to.</summary>
|
||||
public string? EpssDate { get; set; }
|
||||
public List<CachedVersionRange> AffectedVersions { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -1244,6 +1364,27 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
{ "critical", 0 }, { "high", 1 }, { "medium", 2 }, { "low", 3 }, { "none", 4 }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Finding-level roll-up of the per-CVE priority and EPSS: <c>priority</c> = highest band,
|
||||
/// <c>maxEpssScore</c> = highest score (absent when no CVE of the finding is scored).
|
||||
/// Used by the live correlation and by the Part-1/Part-2 merge so both agree.
|
||||
/// </summary>
|
||||
internal static void StampFindingPriority(Dictionary<string, object> finding, IEnumerable<object> vulns)
|
||||
{
|
||||
string priority = CvePriority.Track;
|
||||
double? maxEpss = null;
|
||||
foreach (var v in vulns)
|
||||
{
|
||||
if (v is not Dictionary<string, object> vuln) continue;
|
||||
priority = CvePriority.Max(priority, vuln.GetValueOrDefault("priority")?.ToString());
|
||||
if (vuln.GetValueOrDefault("epssScore") is double e && (!maxEpss.HasValue || e > maxEpss.Value))
|
||||
maxEpss = e;
|
||||
}
|
||||
finding["priority"] = priority;
|
||||
if (maxEpss.HasValue) finding["maxEpssScore"] = maxEpss.Value;
|
||||
else finding.Remove("maxEpssScore");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two vulnerability reports (Part 1 = pre-provisioning, Part 2 = user enrollment).
|
||||
/// Deduplicates findings by (softwareName, installedVersion, cveId).
|
||||
@@ -1300,7 +1441,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
.ToList();
|
||||
|
||||
// Recompute scan_summary from merged findings
|
||||
int totalCves = 0, criticalCves = 0, highCves = 0, mediumCves = 0, lowCves = 0, kevMatches = 0;
|
||||
int totalCves = 0, criticalCves = 0, highCves = 0, mediumCves = 0, lowCves = 0, kevMatches = 0, actCves = 0, attendCves = 0;
|
||||
foreach (var f in mergedFindings)
|
||||
{
|
||||
if (f.GetValueOrDefault("vulnerabilities") is IEnumerable<object> vulns)
|
||||
@@ -1318,6 +1459,11 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
case "LOW": lowCves++; break;
|
||||
}
|
||||
if (vuln.GetValueOrDefault("isKev") is bool isKev && isKev) kevMatches++;
|
||||
switch (vuln.GetValueOrDefault("priority")?.ToString())
|
||||
{
|
||||
case CvePriority.Act: actCves++; break;
|
||||
case CvePriority.Attend: attendCves++; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1360,6 +1506,8 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
{ "medium_cves", mediumCves },
|
||||
{ "low_cves", lowCves },
|
||||
{ "kev_matches", kevMatches },
|
||||
{ "act_cves", actCves },
|
||||
{ "attend_cves", attendCves },
|
||||
{ "overall_risk", overallRisk },
|
||||
{ "additional_findings_count", additionalCount },
|
||||
{ "phase_contributions", new Dictionary<string, object>
|
||||
@@ -1424,6 +1572,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
else if (maxCvss >= 4.0) existing["riskLevel"] = "medium";
|
||||
else if (maxCvss > 0) existing["riskLevel"] = "low";
|
||||
else existing["riskLevel"] = "none";
|
||||
StampFindingPriority(existing, mergedVulns.Values);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -131,7 +131,9 @@ export function registerPrompts(server: McpServer, ga: boolean): void {
|
||||
'1. Call search_sessions_by_cve with the cveId' +
|
||||
(tenantId ? ' and tenantId' : '') +
|
||||
'. Use pageSize=1000 and follow nextLink until it is absent — exposure audits must be complete, not sampled.\n' +
|
||||
'2. Tally affected sessions/devices, and break them down by overallRisk and CVSS score.\n' +
|
||||
'2. Tally affected sessions/devices, and break them down by overallRisk and CVSS score. ' +
|
||||
'Call get_vulnerability_summary to pick up the CVE\'s epssScore (FIRST EPSS exploitation probability) and ' +
|
||||
'priority (act = CISA KEV, attend = EPSS >= 0.1 or CVSS >= 9.0, track) — state both; a low-CVSS CVE with a high EPSS is still urgent.\n' +
|
||||
'3. Use search_knowledge to look up remediation guidance for the affected software if a relevant rule exists.\n\n' +
|
||||
'Report: total affected devices, severity breakdown, the most-exposed manufacturers/models if a pattern ' +
|
||||
'stands out, and prioritized remediation. State explicitly if vulnerability scanning is disabled (empty result ≠ "not affected").',
|
||||
|
||||
@@ -1493,7 +1493,10 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
description:
|
||||
'Get a fleet-wide vulnerability exposure summary aggregated from detected CVEs: total affected devices, ' +
|
||||
'distinct CVE count, KEV (CISA Known-Exploited) count, a severity breakdown, and the top CVEs ranked by how ' +
|
||||
'many devices they affect. ' +
|
||||
'many devices they affect. Each top CVE carries cvssScore/cvssSeverity, isKev, epssScore (FIRST EPSS ' +
|
||||
'probability of exploitation within 30 days, 0-1, null when unscored) and priority ("act" = in CISA KEV, ' +
|
||||
'"attend" = EPSS >= 0.1 or CVSS >= 9.0, "track" = everything else) — rank remediation by priority, then EPSS, ' +
|
||||
'not by CVSS alone. ' +
|
||||
(ga ? 'Omit tenantId for a cross-tenant overview (Global Admin; also returns affected tenant count); pass tenantId to scope to one tenant. ' : '') +
|
||||
'Use this to answer "how exposed is the fleet / this ' +
|
||||
'tenant?" and "which CVEs affect the most devices?" — for the device list of a single CVE use search_sessions_by_cve. ' +
|
||||
|
||||
@@ -724,6 +724,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
(ga ? "Omit tenantId for cross-tenant search (Global Admin). " : "") +
|
||||
"Requires vulnerability scanning to be enabled. " +
|
||||
"Use this to answer: which devices are affected by CVE-2024-XXXX, show all critical vulnerability sessions. " +
|
||||
"The per-session vulnerability report (get_session_summary / vulnerability_report event) lists each CVE with " +
|
||||
"cvssScore, cvssVector, isKev, epssScore (FIRST EPSS, 0-1), epssPercentile and priority (act/attend/track). " +
|
||||
"This endpoint is fully paginated — there is no truncation. The default pageSize=200 is tuned for typical " +
|
||||
"interactive queries; raise it (up to 1000) for full exposure audits. For \"how many of my devices have CVE-X\" " +
|
||||
"use pageSize=1000 and follow nextLink repeatedly until absent. Pass the whole nextLink string as " +
|
||||
|
||||
@@ -17,6 +17,10 @@ namespace AutopilotMonitor.Shared.Models.Vulnerability
|
||||
public double CvssScore { get; set; }
|
||||
public string CvssSeverity { get; set; } = string.Empty;
|
||||
public bool IsKev { get; set; }
|
||||
/// <summary>FIRST EPSS probability (0..1) at correlation time; null on legacy rows / unscored CVEs.</summary>
|
||||
public double? EpssScore { get; set; }
|
||||
/// <summary>Remediation priority (act / attend / track); empty on legacy rows.</summary>
|
||||
public string Priority { get; set; } = string.Empty;
|
||||
public string OverallRisk { get; set; } = string.Empty;
|
||||
public DateTime? DetectedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
const LAST_UPDATED = "23 August 2026";
|
||||
const LAST_UPDATED = "29 August 2026";
|
||||
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
|
||||
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
|
||||
|
||||
@@ -144,7 +144,8 @@ export default function PrivacyPage() {
|
||||
delivers the welcome email when a tenant is activated and the farewell email after offboarding — it receives
|
||||
an administrator's email address and the tenant domain, never enrollment telemetry; open and click
|
||||
tracking is disabled. Vulnerability reference data is read inbound from NVD, the CISA KEV
|
||||
catalog, and MSRC; nothing about your environment is sent to them.
|
||||
catalog, MSRC, and the FIRST EPSS service; nothing about your environment is sent to them — the EPSS
|
||||
lookup carries only public CVE identifiers from the shared reference cache.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Further connections exist only because you configure them: your own Azure storage account for diagnostics,
|
||||
|
||||
+61
-3
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import type { EnrollmentEvent } from "@/types";
|
||||
import { CONTEXT_PILL_CLASS, formatEpss, epssTitle, priorityLabel, PRIORITY_TITLE } from "@/lib/vulnerabilitySeverity";
|
||||
|
||||
interface VulnerabilityReportSectionProps {
|
||||
events: EnrollmentEvent[];
|
||||
@@ -22,6 +23,13 @@ interface CveDetail {
|
||||
publishedDate?: string;
|
||||
references?: string[];
|
||||
versionMatchConfidence?: string;
|
||||
/** CVSS v3.x vector string; absent on reports written before it was persisted. */
|
||||
cvssVector?: string;
|
||||
/** FIRST EPSS probability (0..1); undefined = unscored / pre-EPSS report. */
|
||||
epss?: number;
|
||||
epssPercentile?: number;
|
||||
/** act / attend / track; undefined on pre-priority reports. */
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
interface Finding {
|
||||
@@ -31,6 +39,17 @@ interface Finding {
|
||||
maxCvss: number;
|
||||
maxSeverity: string;
|
||||
kevCount: number;
|
||||
/** Highest EPSS across the finding's CVEs; null when none is scored. */
|
||||
maxEpss: number | null;
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
const PRIORITY_ORDER: Record<string, number> = { act: 0, attend: 1, track: 2 };
|
||||
|
||||
function maxPriority(a?: string, b?: string): string | undefined {
|
||||
if (!a) return b;
|
||||
if (!b) return a;
|
||||
return (PRIORITY_ORDER[a] ?? 3) <= (PRIORITY_ORDER[b] ?? 3) ? a : b;
|
||||
}
|
||||
|
||||
interface InventoryItem {
|
||||
@@ -94,7 +113,7 @@ function confidenceTooltip(confidence?: string): string {
|
||||
return "Unknown identification confidence";
|
||||
}
|
||||
|
||||
type FindingsSortKey = "severity" | "software" | "cves" | "cvss";
|
||||
type FindingsSortKey = "severity" | "software" | "cves" | "cvss" | "epss";
|
||||
|
||||
// Module-level so React keeps a stable component identity across renders
|
||||
// (react-hooks/static-components: an inline component type remounts on every render).
|
||||
@@ -154,9 +173,15 @@ export default function VulnerabilityReportSection({
|
||||
publishedDate: (c.publishedDate || c.published_date || "") as string,
|
||||
references: (c.references || []) as string[],
|
||||
versionMatchConfidence: (c.versionMatchConfidence || c.version_match_confidence) as string | undefined,
|
||||
cvssVector: (c.cvssVector as string | undefined) || undefined,
|
||||
epss: typeof c.epssScore === "number" ? c.epssScore : undefined,
|
||||
epssPercentile: typeof c.epssPercentile === "number" ? c.epssPercentile : undefined,
|
||||
priority: (c.priority as string | undefined) || undefined,
|
||||
}));
|
||||
|
||||
const maxCvss = cves.reduce((max: number, c: CveDetail) => Math.max(max, c.cvss ?? 0), 0);
|
||||
const maxEpss = cves.reduce<number | null>((max, c) => (c.epss != null && (max == null || c.epss > max) ? c.epss : max), null);
|
||||
const findingPriority = cves.reduce<string | undefined>((best, c) => maxPriority(best, c.priority), undefined);
|
||||
const maxSev = cves.reduce((worst: string, c: CveDetail) => {
|
||||
const cSev = c.severity || severityFromCvss(c.cvss ?? 0);
|
||||
return (SEVERITY_ORDER[cSev] ?? 4) < (SEVERITY_ORDER[worst] ?? 4) ? cSev : worst;
|
||||
@@ -175,6 +200,8 @@ export default function VulnerabilityReportSection({
|
||||
existing.maxCvss = Math.max(existing.maxCvss, maxCvss);
|
||||
existing.maxSeverity = (SEVERITY_ORDER[maxSev] ?? 4) < (SEVERITY_ORDER[existing.maxSeverity] ?? 4) ? maxSev : existing.maxSeverity;
|
||||
existing.kevCount += cveKevCount;
|
||||
if (maxEpss != null && (existing.maxEpss == null || maxEpss > existing.maxEpss)) existing.maxEpss = maxEpss;
|
||||
existing.priority = maxPriority(existing.priority, findingPriority);
|
||||
} else {
|
||||
findingsMap.set(key, {
|
||||
softwareName: f.softwareName || f.software || "Unknown",
|
||||
@@ -183,6 +210,8 @@ export default function VulnerabilityReportSection({
|
||||
maxCvss,
|
||||
maxSeverity: maxSev,
|
||||
kevCount: cveKevCount,
|
||||
maxEpss,
|
||||
priority: findingPriority,
|
||||
});
|
||||
}
|
||||
mapped.add((f.softwareName || f.software || "").toLowerCase().trim());
|
||||
@@ -370,6 +399,10 @@ export default function VulnerabilityReportSection({
|
||||
case "cvss":
|
||||
cmp = a.maxCvss - b.maxCvss;
|
||||
break;
|
||||
case "epss":
|
||||
// Unscored sorts below every scored value in both directions.
|
||||
cmp = (a.maxEpss ?? -1) - (b.maxEpss ?? -1);
|
||||
break;
|
||||
}
|
||||
return findingsSortAsc ? cmp : -cmp;
|
||||
});
|
||||
@@ -590,6 +623,9 @@ export default function VulnerabilityReportSection({
|
||||
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("cvss")}>
|
||||
Max CVSS <SortIndicator column="cvss" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
|
||||
</th>
|
||||
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("epss")} title="Highest FIRST EPSS exploitation probability among the finding's CVEs">
|
||||
EPSS <SortIndicator column="epss" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
@@ -607,7 +643,7 @@ export default function VulnerabilityReportSection({
|
||||
})}
|
||||
{filteredFindings.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-4 text-center text-gray-400">
|
||||
<td colSpan={7} className="py-4 text-center text-gray-400">
|
||||
No findings match your search.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -900,10 +936,17 @@ function FindingRow({ finding, isExpanded, onToggle }: {
|
||||
<td className="py-2 px-3">
|
||||
<CvssScore value={finding.maxCvss} />
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{finding.maxEpss != null ? (
|
||||
<span className={CONTEXT_PILL_CLASS} title={epssTitle(finding.maxEpss)}>{formatEpss(finding.maxEpss)}</span>
|
||||
) : (
|
||||
<span className="text-gray-300">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-0">
|
||||
<td colSpan={7} className="p-0">
|
||||
<div className="bg-gray-50 border-t border-b border-gray-100 px-6 py-3">
|
||||
<h4 className="text-xs font-medium text-gray-500 uppercase mb-2">
|
||||
CVE Details ({finding.cves.length})
|
||||
@@ -935,6 +978,16 @@ function FindingRow({ finding, isExpanded, onToggle }: {
|
||||
<span>KEV</span>
|
||||
</span>
|
||||
)}
|
||||
{cve.epss != null && (
|
||||
<span className={`${CONTEXT_PILL_CLASS} cursor-help`} title={epssTitle(cve.epss, cve.epssPercentile)}>
|
||||
{formatEpss(cve.epss)}
|
||||
</span>
|
||||
)}
|
||||
{priorityLabel(cve.priority) && (
|
||||
<span className={`${CONTEXT_PILL_CLASS} cursor-help`} title={PRIORITY_TITLE}>
|
||||
{priorityLabel(cve.priority)}
|
||||
</span>
|
||||
)}
|
||||
{cve.versionMatchConfidence === "low" && (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs font-medium bg-amber-50 text-amber-700 cursor-help"
|
||||
@@ -950,6 +1003,11 @@ function FindingRow({ finding, isExpanded, onToggle }: {
|
||||
{cve.description && (
|
||||
<p className="text-xs text-gray-600 mt-1 line-clamp-2">{cve.description}</p>
|
||||
)}
|
||||
{cve.cvssVector && (
|
||||
<p className="text-[10px] font-mono text-gray-400 mt-1 break-all" title="CVSS base vector — AV (attack vector), AC (complexity), PR (privileges), UI (user interaction), S (scope), C/I/A (impact)">
|
||||
{cve.cvssVector}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
const LAST_UPDATED = "31 July 2026";
|
||||
const LAST_UPDATED = "29 August 2026";
|
||||
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
|
||||
const DOCS_PLANS = `${DOCS_URL}/plans`;
|
||||
const GITHUB_ADVISORY = "https://github.com/okieselbach/AutopilotMonitor/security/advisories/new";
|
||||
@@ -199,6 +199,14 @@ export default function TermsPage() {
|
||||
<a href="https://msrc.microsoft.com/" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">msrc.microsoft.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-[var(--lp-line-soft)] rounded-lg">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">Exploit Prediction Scoring System (EPSS), FIRST.org</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Exploitation-probability scores (EPSS) are provided by the Forum of Incident Response and Security Teams (FIRST). The lookup
|
||||
sends only public CVE identifiers from the shared reference cache — never tenant or device data. For more information, visit{" "}
|
||||
<a href="https://www.first.org/epss/" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">first.org/epss</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { severityChipClass, KEV_BADGE_CLASS } from "@/lib/vulnerabilitySeverity";
|
||||
import { severityChipClass, KEV_BADGE_CLASS, CONTEXT_PILL_CLASS, formatEpss, epssTitle, priorityLabel, PRIORITY_TITLE } from "@/lib/vulnerabilitySeverity";
|
||||
import TruncatedLabel from "@/components/TruncatedLabel";
|
||||
|
||||
export interface VulnerabilityTopCve {
|
||||
@@ -8,6 +8,10 @@ export interface VulnerabilityTopCve {
|
||||
cvssSeverity: string;
|
||||
cvssScore: number;
|
||||
isKev: boolean;
|
||||
/** FIRST EPSS probability (0..1); null/absent when unscored or for rows written before EPSS. */
|
||||
epssScore?: number | null;
|
||||
/** act / attend / track; empty for rows written before priorities existed. */
|
||||
priority?: string;
|
||||
affectedSessions: number;
|
||||
sampleSoftware: string[];
|
||||
}
|
||||
@@ -135,6 +139,12 @@ export default function VulnerabilityExposurePanel({
|
||||
{c.isKev && (
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-semibold ${KEV_BADGE_CLASS}`}>KEV</span>
|
||||
)}
|
||||
{c.epssScore != null && (
|
||||
<span className={CONTEXT_PILL_CLASS} title={epssTitle(c.epssScore)}>{formatEpss(c.epssScore)}</span>
|
||||
)}
|
||||
{priorityLabel(c.priority) && (
|
||||
<span className={CONTEXT_PILL_CLASS} title={PRIORITY_TITLE}>{priorityLabel(c.priority)}</span>
|
||||
)}
|
||||
</div>
|
||||
{c.sampleSoftware.length > 0 && (
|
||||
<TruncatedLabel text={c.sampleSoftware.join(", ")} className="block text-[11px] text-gray-500 dark:text-gray-400 mt-0.5" />
|
||||
|
||||
@@ -21,3 +21,36 @@ export function severityChipClass(severity: string): string {
|
||||
/** Tailwind classes for the CISA Known-Exploited (KEV) badge. */
|
||||
export const KEV_BADGE_CLASS =
|
||||
"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 border-red-300 dark:border-red-700";
|
||||
|
||||
/**
|
||||
* Context-label pill (gray outline — no new color family): used for the EPSS score and the
|
||||
* act/attend/track priority so they read as information next to the severity chip, not as
|
||||
* a second severity scale.
|
||||
*/
|
||||
export const CONTEXT_PILL_CLASS =
|
||||
"inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-medium border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-300";
|
||||
|
||||
/** "EPSS 12.3%" — one decimal, or two when the score is below 1% so small scores don't read as 0. */
|
||||
export function formatEpss(score: number): string {
|
||||
const pct = score * 100;
|
||||
return `EPSS ${pct < 1 ? pct.toFixed(2) : pct.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** Tooltip for the EPSS pill; percentile optional (0..1). */
|
||||
export function epssTitle(score: number, percentile?: number | null): string {
|
||||
const base = `EPSS (FIRST.org): ${(score * 100).toFixed(2)}% estimated probability of exploitation in the wild within the next 30 days`;
|
||||
return percentile != null ? `${base} — higher than ${(percentile * 100).toFixed(1)}% of all scored CVEs` : base;
|
||||
}
|
||||
|
||||
/** Human label for the remediation priority band. */
|
||||
export function priorityLabel(priority?: string | null): string | null {
|
||||
switch ((priority || "").toLowerCase()) {
|
||||
case "act": return "Act";
|
||||
case "attend": return "Attend";
|
||||
case "track": return "Track";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const PRIORITY_TITLE =
|
||||
"Remediation priority — Act: listed in CISA KEV (exploitation confirmed). Attend: EPSS ≥ 10% or CVSS ≥ 9.0. Track: everything else.";
|
||||
|
||||
Reference in New Issue
Block a user