Merge pull request #217 from okieselbach/agents/vuln-dictionary-button

Vulnerability data: the NVD product dictionary in the sync status, with a button to load it
This commit is contained in:
Oliver Kieselbach
2026-11-13 01:09:17 +01:00
committed by GitHub
14 changed files with 812 additions and 136 deletions
@@ -6,7 +6,10 @@ using System.Reflection;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Vulnerability;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Shared;
using Microsoft.Extensions.Logging.Abstractions;
namespace AutopilotMonitor.Functions.Tests;
@@ -822,4 +825,200 @@ public class CpeDictionaryServiceTests
Assert.Same(held, served);
}
// -----------------------------------------------------------------------
// One refresh at a time
// -----------------------------------------------------------------------
/// <summary>A feed whose archive is held back until the test lets it through.</summary>
private sealed class HeldDownload : IDisposable
{
private readonly ManualResetEventSlim _asked = new(false);
private readonly ManualResetEventSlim _released = new(false);
public HeldDownload()
{
Feed = new FeedStub
{
Archive = () =>
{
_asked.Set();
Assert.True(_released.Wait(TimeSpan.FromSeconds(30)), "The test never released the download.");
return Bytes(FullSizeArchive.Value);
},
};
Service = CpeDictionaryFixture.Service(Feed, Blob);
// The stub answers on the caller's thread, so the held refresh needs one of its own.
Running = Task.Run(() => Service.RefreshAsync());
Assert.True(_asked.Wait(TimeSpan.FromSeconds(30)), "The refresh never asked for the archive.");
}
public FeedStub Feed { get; }
public StoredBlob Blob { get; } = new();
public CpeDictionaryService Service { get; }
public Task<CpeDictionaryRefreshResult> Running { get; }
public Task<CpeDictionaryRefreshResult> ReleaseAsync()
{
_released.Set();
return Running;
}
public void Dispose() => _released.Set();
}
[Fact]
public async Task Refresh_WhileOneRuns_AndNotWaiting_DoesNothingAndSaysSo()
{
using var held = new HeldDownload();
var second = await held.Service.RefreshAsync(CancellationToken.None, waitForRunningRefresh: false);
Assert.True(second.AlreadyRunning);
Assert.False(second.Changed);
Assert.Null(second.Error);
// The second call asked NVD for nothing: neither the version nor the archive.
Assert.Equal(new[] { CpeDictionaryService.MetaUrl, CpeDictionaryService.FeedUrl }, held.Feed.Requests);
var first = await held.ReleaseAsync();
Assert.True(first.Changed);
Assert.False(first.AlreadyRunning);
Assert.Equal(1, held.Blob.Uploads);
// The lock is free again.
var third = await held.Service.RefreshAsync(CancellationToken.None, waitForRunningRefresh: false);
Assert.False(third.AlreadyRunning);
Assert.False(third.Changed);
Assert.Equal(FullSize, third.Products);
}
[Fact]
public async Task Refresh_WhileOneRuns_AndWaiting_FindsTheStoredCopyCurrentInsteadOfDownloadingAgain()
{
using var held = new HeldDownload();
// The nightly run: it must not skip the dictionary because the button was a moment faster.
var waiting = held.Service.RefreshAsync();
await Task.Delay(150);
Assert.False(waiting.IsCompleted);
var first = await held.ReleaseAsync();
var second = await waiting;
Assert.True(first.Changed);
Assert.False(second.AlreadyRunning);
Assert.False(second.Changed);
Assert.Null(second.Error);
Assert.Equal(FullSize, second.Products);
Assert.Equal(
new[] { CpeDictionaryService.MetaUrl, CpeDictionaryService.FeedUrl, CpeDictionaryService.MetaUrl }, held.Feed.Requests);
Assert.Equal(1, held.Blob.Uploads);
}
// -----------------------------------------------------------------------
// The operator's button (POST vulnerability/sync-dictionary)
// -----------------------------------------------------------------------
private static TriggerCpeDictionarySyncFunction Button(CpeDictionaryService service) =>
new(service, telemetryClient: null, NullLogger<TriggerCpeDictionarySyncFunction>.Instance);
private static async Task<JsonElement> PressAsync(CpeDictionaryService service)
{
var response = await Button(service).Run(OperatorCall.Request());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return OperatorCall.BodyOf(response);
}
[Fact]
public async Task Button_NoDictionaryYet_DownloadsIt_AndSaysHowManyProductsItHolds()
{
var blob = new StoredBlob();
var service = CpeDictionaryFixture.Service(new FeedStub { Archive = () => Bytes(FullSizeArchive.Value) }, blob);
var body = await PressAsync(service);
Assert.True(body.GetProperty("success").GetBoolean());
Assert.True(body.GetProperty("changed").GetBoolean());
Assert.Equal(FullSize, body.GetProperty("products").GetInt32());
Assert.Equal("NVD product dictionary loaded (10,005 products)", body.GetProperty("message").GetString());
Assert.False(body.TryGetProperty("error", out _));
Assert.True(DateTime.TryParse(body.GetProperty("syncedAt").GetString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out _));
Assert.Equal(1, blob.Uploads);
// The instance that downloaded it serves it at once, which is what Auto-Map needs next.
Assert.Equal(FullSize, (await service.GetAsync())!.Count);
}
[Fact]
public async Task Button_NvdPublishedNothingNew_SaysTheStoredCopyIsCurrent()
{
var blob = new StoredBlob(CpeDictionaryFixture.SnapshotJson(Products(CpeDictionaryService.MinProducts), FeedVersion));
var feed = new FeedStub { Archive = () => Bytes(FullSizeArchive.Value) };
var body = await PressAsync(CpeDictionaryFixture.Service(feed, blob));
Assert.True(body.GetProperty("success").GetBoolean());
Assert.False(body.GetProperty("changed").GetBoolean());
Assert.Equal(CpeDictionaryService.MinProducts, body.GetProperty("products").GetInt32());
Assert.Equal("NVD product dictionary is current (10,000 products)", body.GetProperty("message").GetString());
Assert.Equal(new[] { CpeDictionaryService.MetaUrl }, feed.Requests);
Assert.Equal(0, blob.Uploads);
}
[Fact]
public async Task Button_NvdDoesNotAnswer_SaysWhy_AndThatTheStoredCopyStays()
{
var blob = new StoredBlob(CpeDictionaryFixture.SnapshotJson(Products(CpeDictionaryService.MinProducts), FeedVersion));
var feed = new FeedStub { Meta = () => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) };
var body = await PressAsync(CpeDictionaryFixture.Service(feed, blob));
// Not a server error: the request did its work, the answer says that NVD did not.
Assert.False(body.GetProperty("success").GetBoolean());
Assert.False(body.GetProperty("changed").GetBoolean());
Assert.Equal(CpeDictionaryService.MinProducts, body.GetProperty("products").GetInt32());
var message = body.GetProperty("message").GetString()!;
Assert.StartsWith("NVD product dictionary sync failed: ", message);
Assert.EndsWith(". The stored copy (10,000 products) stays in service.", message);
Assert.DoesNotContain("..", message);
Assert.False(string.IsNullOrEmpty(body.GetProperty("error").GetString()));
Assert.Equal(0, blob.Uploads);
}
[Fact]
public async Task Button_NvdDoesNotAnswer_AndNothingIsStored_NamesNoStoredCopy()
{
var feed = new FeedStub { Meta = () => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) };
var body = await PressAsync(CpeDictionaryFixture.Service(feed, new StoredBlob()));
Assert.False(body.GetProperty("success").GetBoolean());
Assert.Equal(0, body.GetProperty("products").GetInt32());
var message = body.GetProperty("message").GetString()!;
Assert.StartsWith("NVD product dictionary sync failed: ", message);
Assert.DoesNotContain("stored copy", message);
}
[Fact]
public async Task Button_WhileADownloadRuns_IsAConflict_AndStartsNoSecondOne()
{
using var held = new HeldDownload();
var response = await Button(held.Service).Run(OperatorCall.Request());
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
Assert.Equal(Constants.ApiErrorCodes.Conflict, EndpointHarness.ErrorCode(response));
Assert.Equal(new[] { CpeDictionaryService.MetaUrl, CpeDictionaryService.FeedUrl }, held.Feed.Requests);
Assert.True((await held.ReleaseAsync()).Changed);
}
[Theory]
[InlineData(true, null, 34859, "NVD product dictionary loaded (34,859 products)")]
[InlineData(false, null, 34859, "NVD product dictionary is current (34,859 products)")]
[InlineData(false, "The reduced dictionary holds only 12 products, too few for a full feed.", 34859,
"NVD product dictionary sync failed: The reduced dictionary holds only 12 products, too few for a full feed. The stored copy (34,859 products) stays in service.")]
[InlineData(false, "A task was canceled", 0, "NVD product dictionary sync failed: A task was canceled.")]
public void Button_Message_SaysWhatHappenedInOneSentenceEach(bool changed, string? error, int products, string expected) =>
Assert.Equal(expected, TriggerCpeDictionarySyncFunction.MessageFor(
new CpeDictionaryRefreshResult { Changed = changed, Error = error }, products));
}
@@ -0,0 +1,77 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Vulnerability;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// GET vulnerability/sync-status keeps two stores apart that are easily taken for one: the CVE
/// cache (the products CVE data is held for) and the NVD product dictionary (the names programs
/// are matched against). A dictionary that was never stored is absent from the answer, not zero.
/// </summary>
public class GetVulnerabilitySyncStatusFunctionTests : IDisposable
{
public GetVulnerabilitySyncStatusFunctionTests() => NvdTestState.Reset();
public void Dispose() => NvdTestState.Reset();
private static async Task<JsonElement> StatusAsync(CpeDictionaryService dictionary, params string[] cachedProducts)
{
var scan = await VulnerabilityScanRig.CreateAsync();
foreach (var product in cachedProducts)
scan.Cache(VulnerabilityCacheRows.Filled($"cpe:2.3:a:contoso:{product}", Array.Empty<CachedCve>(), DateTime.UtcNow));
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var refresh = new VulnerabilityCacheRefreshService(
scan.Repo.Object,
scan.Filler,
new EpssApiClient(new HttpClient(new ReferenceDataFixture.FailingHandler()), NullLogger<EpssApiClient>.Instance),
scan.MappingStore,
adminConfig.Object,
NullLogger<VulnerabilityCacheRefreshService>.Instance);
var function = new GetVulnerabilitySyncStatusFunction(
ReferenceDataFixture.Kev(new ReferenceDataFixture.FailingHandler()),
ReferenceDataFixture.Msrc(),
refresh,
dictionary,
adminConfig.Object,
NullLogger<GetVulnerabilitySyncStatusFunction>.Instance);
var response = await function.Run(OperatorCall.Request());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return OperatorCall.BodyOf(response);
}
[Fact]
public async Task Status_WithAStoredDictionary_NamesItsSizeAndAge_NextToTheCveCache()
{
var dictionary = CpeDictionaryFixture.Stored(
("contoso:widget", "Contoso Widget"), ("fabrikam:gadget", "Fabrikam Gadget"), ("northwind:tool", "Northwind Tool"));
var status = await StatusAsync(dictionary, "widget");
Assert.Equal(3, status.GetProperty("cpeDictionaryProducts").GetInt32());
Assert.Equal("2026-11-11T03:00:00.0000000Z", status.GetProperty("cpeDictionaryLastRefreshUtc").GetString());
// The cache counts the one product CVE data is held for, whatever the dictionary lists.
Assert.Equal(1, status.GetProperty("nvdCacheEntries").GetInt32());
}
[Fact]
public async Task Status_WithoutADictionary_LeavesBothSlotsOut()
{
var status = await StatusAsync(CpeDictionaryFixture.Stored(), "widget", "gadget");
Assert.False(status.TryGetProperty("cpeDictionaryProducts", out _));
Assert.False(status.TryGetProperty("cpeDictionaryLastRefreshUtc", out _));
Assert.Equal(2, status.GetProperty("nvdCacheEntries").GetInt32());
}
}
@@ -48,7 +48,7 @@ public class VulnerabilityDataSyncFunctionTests
[Theory]
[InlineData("HTTP 503", null, null, null, "KEV catalog not refreshed")]
[InlineData(null, "2 of 6 documents failed", null, null, "MSRC index not refreshed")]
[InlineData(null, null, "The reduced dictionary holds only 12 products; the stored copy is kept.", null, "NVD product dictionary not refreshed")]
[InlineData(null, null, "The reduced dictionary holds only 12 products, too few for a full feed.", null, "NVD product dictionary not refreshed")]
[InlineData(null, null, null, "CPE mappings could not be loaded and no earlier copy is held.", "automatic mappings or inventory reconcile failed")]
public void SourceOrMappingStepThatFailed_IsNamed(
string? kevError, string? msrcError, string? dictionaryError, string? mappingError, string expected)
@@ -239,6 +239,8 @@ public class VulnerabilityWireParityTests
rowsRewritten = 1201, error = (string?)"EPSS batch 3 timed out",
startedUtc = "2026-08-30T04:10:30.0000000Z", finishedUtc = (string?)"2026-08-30T04:11:00.0000000Z",
},
cpeDictionaryProducts = (int?)34859,
cpeDictionaryLastRefreshUtc = (string?)"2026-11-13T00:40:11.0000000Z",
},
new GetVulnerabilitySyncStatusResponse
{
@@ -264,6 +266,8 @@ public class VulnerabilityWireParityTests
RowsRewritten = 1201, Error = "EPSS batch 3 timed out",
StartedUtc = "2026-08-30T04:10:30.0000000Z", FinishedUtc = "2026-08-30T04:11:00.0000000Z",
},
CpeDictionaryProducts = 34859,
CpeDictionaryLastRefreshUtc = "2026-11-13T00:40:11.0000000Z",
});
}
@@ -287,6 +291,9 @@ public class VulnerabilityWireParityTests
epssLastSyncUtc = (string?)null,
epssRefreshRunning = false,
epssLastRun = (object?)null,
// No dictionary was ever stored: both slots vanish, they are not 0 and "".
cpeDictionaryProducts = (int?)null,
cpeDictionaryLastRefreshUtc = (string?)null,
},
new GetVulnerabilitySyncStatusResponse
{
@@ -498,6 +505,65 @@ public class VulnerabilityWireParityTests
new GetUnmatchedSoftwareResponse { Software = new List<UnmatchedSoftwareItem> { item }, Total = 1, Skip = 0, Take = 50 });
}
// ---- TriggerCpeDictionarySync (dual-purpose success) ---------------------------------
[Fact]
public void TriggerCpeDictionarySyncResponse_matches_the_failed_download_shape()
{
string? error = "Response status code does not indicate success: 503 (Service Unavailable)";
var syncedAt = "2026-11-13T00:40:11.0000000Z";
AssertParity(
new
{
success = false,
message = "NVD product dictionary sync failed: 503. The stored copy (34,859 products) stays in service.",
changed = false,
products = 34859,
error,
durationMs = 812,
syncedAt
},
new TriggerCpeDictionarySyncResponse
{
Success = false,
Message = "NVD product dictionary sync failed: 503. The stored copy (34,859 products) stays in service.",
Changed = false,
Products = 34859,
Error = error,
DurationMs = 812,
SyncedAt = syncedAt
});
}
[Fact]
public void TriggerCpeDictionarySyncResponse_omits_a_null_error_on_success()
{
var syncedAt = "2026-11-13T00:40:11.0000000Z";
AssertParity(
new
{
success = true,
message = "NVD product dictionary loaded (34,859 products)",
changed = true,
products = 34859,
error = (string?)null,
durationMs = 13480,
syncedAt
},
new TriggerCpeDictionarySyncResponse
{
Success = true,
Message = "NVD product dictionary loaded (34,859 products)",
Changed = true,
Products = 34859,
Error = null,
DurationMs = 13480,
SyncedAt = syncedAt
});
}
// ---- TriggerEpssSync (dual-purpose success) ------------------------------------------
[Fact]
@@ -13,15 +13,16 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
/// <summary>
/// Read-only status endpoint that surfaces live KEV + MSRC catalog sizes plus the
/// persisted last-sync timestamps from AdminConfiguration. Used by the admin UI
/// "Data Sync Status" panel so operators can see how many entries each cache holds
/// in addition to when it was last refreshed. Global Admin only.
/// persisted last-sync timestamps from AdminConfiguration, and the size and age of the stored
/// NVD product dictionary. Used by the admin UI "Data Sync Status" panel so operators can see
/// how many entries each cache holds in addition to when it was last refreshed.
/// </summary>
public class GetVulnerabilitySyncStatusFunction
{
private readonly KevDataService _kevService;
private readonly MsrcCorrelationService _msrcService;
private readonly VulnerabilityCacheRefreshService _refresh;
private readonly CpeDictionaryService _dictionary;
private readonly AdminConfigurationService _adminConfigService;
private readonly ILogger<GetVulnerabilitySyncStatusFunction> _logger;
@@ -29,12 +30,14 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
KevDataService kevService,
MsrcCorrelationService msrcService,
VulnerabilityCacheRefreshService refresh,
CpeDictionaryService dictionary,
AdminConfigurationService adminConfigService,
ILogger<GetVulnerabilitySyncStatusFunction> logger)
{
_kevService = kevService;
_msrcService = msrcService;
_refresh = refresh;
_dictionary = dictionary;
_adminConfigService = adminConfigService;
_logger = logger;
}
@@ -54,6 +57,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
var nvdCacheEntries = await _refresh.CountCveCacheRowsAsync();
var nvd = _refresh.LastNvdRefresh;
var epss = _refresh.LastEpssRefresh;
var dictionary = await _dictionary.GetAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new GetVulnerabilitySyncStatusResponse
@@ -82,6 +86,8 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
RowsRewritten = epss.RowsRewritten, Error = epss.Error,
StartedUtc = epss.StartedUtc.ToString("o"), FinishedUtc = epss.FinishedUtc?.ToString("o"),
},
CpeDictionaryProducts = dictionary?.Count,
CpeDictionaryLastRefreshUtc = dictionary?.LastRefresh,
});
return response;
}
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Functions.Telemetry;
using AutopilotMonitor.Shared.Models;
using Microsoft.ApplicationInsights;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
/// <summary>
/// Downloads the NVD product dictionary now instead of waiting for the nightly run, which is
/// otherwise the only thing that loads it: on a new installation, and after a night whose
/// download failed, the operator's Auto-Map has no dictionary until 03:00 UTC. Synchronous
/// (a download takes about a quarter of a minute); 409 while a refresh runs on this instance.
/// Global Admin only.
/// </summary>
public class TriggerCpeDictionarySyncFunction
{
private readonly CpeDictionaryService _dictionary;
private readonly TelemetryClient? _telemetryClient;
private readonly ILogger<TriggerCpeDictionarySyncFunction> _logger;
public TriggerCpeDictionarySyncFunction(
CpeDictionaryService dictionary,
TelemetryClient? telemetryClient,
ILogger<TriggerCpeDictionarySyncFunction> logger)
{
_dictionary = dictionary;
_telemetryClient = telemetryClient;
_logger = logger;
}
[Function("TriggerCpeDictionarySync")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "vulnerability/sync-dictionary")] HttpRequestData req)
{
try
{
// Not the caller's cancellation: a closed tab must not cut the download off halfway.
var refresh = await _dictionary.RefreshAsync(CancellationToken.None, waitForRunningRefresh: false);
if (refresh.AlreadyRunning)
return await req.ConflictAsync("An NVD product dictionary download is already running");
// After a failed download the stored copy stays in service; its size is what the operator has.
var failed = !string.IsNullOrEmpty(refresh.Error);
var products = failed ? (await _dictionary.GetAsync(CancellationToken.None))?.Count ?? 0 : refresh.Products;
_telemetryClient.TrackEventUnsampled("ManualCpeDictionarySyncCompleted", new Dictionary<string, string>
{
{ "changed", refresh.Changed.ToString() },
{ "products", products.ToString() },
{ "feedVersion", refresh.FeedVersion ?? "" },
{ "error", refresh.Error ?? "" },
{ "durationMs", refresh.DurationMs.ToString() },
});
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new TriggerCpeDictionarySyncResponse
{
Success = !failed,
Message = MessageFor(refresh, products),
Changed = refresh.Changed,
Products = products,
Error = refresh.Error,
DurationMs = (int)refresh.DurationMs,
SyncedAt = DateTime.UtcNow.ToString("o"),
});
return response;
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "TriggerCpeDictionarySync");
}
}
internal static string MessageFor(CpeDictionaryRefreshResult refresh, int products)
{
var count = products.ToString("N0", CultureInfo.InvariantCulture);
if (!string.IsNullOrEmpty(refresh.Error))
{
var reason = refresh.Error!.TrimEnd('.', ' ');
return products > 0
? $"NVD product dictionary sync failed: {reason}. The stored copy ({count} products) stays in service."
: $"NVD product dictionary sync failed: {reason}.";
}
return refresh.Changed
? $"NVD product dictionary loaded ({count} products)"
: $"NVD product dictionary is current ({count} products)";
}
}
}
@@ -686,6 +686,7 @@ public static class EndpointAccessPolicyCatalog
new("POST", "vulnerability/sync-msrc", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/sync-nvd", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/sync-epss", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/sync-dictionary", EndpointPolicy.GlobalAdminOnly),
new("GET", "vulnerability/sync-status", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "vulnerability/cpe-mapping", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/cpe-mapping/auto-resolve", EndpointPolicy.GlobalAdminOnly),
@@ -29,6 +29,9 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
public string? FeedVersion { get; set; }
public string? Error { get; set; }
public long DurationMs { get; set; }
/// <summary>True when another refresh was running on this instance and this call did nothing.</summary>
public bool AlreadyRunning { get; set; }
}
/// <summary>
@@ -162,6 +165,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
private readonly BlobBackedReferenceCache<CpeDictionaryProduct> _blob;
private readonly ILogger<CpeDictionaryService> _logger;
private readonly SemaphoreSlim _loadLock = new(1, 1);
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private CpeDictionary? _loaded;
private DateTime _loadedAtUtc;
@@ -197,8 +201,26 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
/// <summary>
/// Downloads the feed when NVD published a new one and stores the reduced copy. Never throws:
/// a failure is reported in the result and the stored copy stays in service.
/// One refresh at a time on an instance: the operator's button and the nightly run would
/// otherwise pull the same 85 MB twice. The nightly run waits for a refresh in flight and
/// then finds the stored copy current; the button (<paramref name="waitForRunningRefresh"/>
/// false) is told at once that one is running.
/// </summary>
internal async Task<CpeDictionaryRefreshResult> RefreshAsync(CancellationToken ct = default)
internal async Task<CpeDictionaryRefreshResult> RefreshAsync(CancellationToken ct = default, bool waitForRunningRefresh = true)
{
if (!await _refreshLock.WaitAsync(waitForRunningRefresh ? Timeout.InfiniteTimeSpan : TimeSpan.Zero, ct).ConfigureAwait(false))
return new CpeDictionaryRefreshResult { AlreadyRunning = true };
try
{
return await RefreshCoreAsync(ct).ConfigureAwait(false);
}
finally
{
_refreshLock.Release();
}
}
private async Task<CpeDictionaryRefreshResult> RefreshCoreAsync(CancellationToken ct)
{
var elapsed = Stopwatch.StartNew();
var result = new CpeDictionaryRefreshResult();
@@ -219,7 +241,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
var products = await DownloadAndReduceAsync(budget.Token).ConfigureAwait(false);
if (products.Count < MinProducts)
throw new InvalidOperationException($"The reduced dictionary holds only {products.Count} products; the stored copy is kept.");
throw new InvalidOperationException($"The reduced dictionary holds only {products.Count} products, too few for a full feed.");
var snapshot = new BlobBackedReferenceCache<CpeDictionaryProduct>.Snapshot
{
@@ -2635,6 +2635,10 @@ export interface GetVulnerabilitySyncStatusResponse {
epssRefreshRunning: boolean;
/** Last run on THIS instance (in-memory; null / key omitted after a cold start). */
epssLastRun?: EpssRefreshRunWire;
/** Products in the stored NVD product dictionary, which program names are matched against; null (key omitted) while none was ever stored. Not the CVE cache: NvdCacheEntries counts the products CVE data is held for. */
cpeDictionaryProducts?: number;
/** When the stored dictionary was last replaced (ISO-8601); null (key omitted) while none was ever stored. */
cpeDictionaryLastRefreshUtc?: string;
}
/** One Global Admin/Reader row on the wire. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them). */
@@ -6383,6 +6387,21 @@ export interface TpmPssUnsupportedResponse {
dataQualityNotice: string;
}
/** Response of POST vulnerability/sync-dictionary (dual-purpose: success reflects whether the NVD product dictionary was downloaded or found current). */
export interface TriggerCpeDictionarySyncResponse {
success: boolean;
message: string;
/** True when a new copy of the feed was downloaded and stored; false when the stored copy is current. */
changed: boolean;
/** Products in the stored dictionary after this call; 0 when there is none. */
products: number;
/** Error text of a failed download; null (key omitted) on success. */
error?: string;
durationMs: number;
/** Preformatted ISO-8601 timestamp of this response. */
syncedAt: string;
}
/** Response of POST vulnerability/sync-epss (dual-purpose: success reflects whether the synchronous EPSS re-score finished without error). */
export interface TriggerEpssSyncResponse {
success: boolean;
@@ -225,6 +225,16 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>Last run on THIS instance (in-memory; null / key omitted after a cold start).</summary>
public EpssRefreshRunWire? EpssLastRun { get; set; }
/// <summary>
/// Products in the stored NVD product dictionary, which program names are matched against;
/// null (key omitted) while none was ever stored. Not the CVE cache: NvdCacheEntries counts
/// the products CVE data is held for.
/// </summary>
public int? CpeDictionaryProducts { get; set; }
/// <summary>When the stored dictionary was last replaced (ISO-8601); null (key omitted) while none was ever stored.</summary>
public string? CpeDictionaryLastRefreshUtc { get; set; }
}
/// <summary>One entry of the persistent software ignore list.</summary>
@@ -310,6 +320,31 @@ namespace AutopilotMonitor.Shared.Models
public string SyncedAt { get; set; } = default!;
}
/// <summary>
/// Response of POST vulnerability/sync-dictionary (dual-purpose: success reflects whether the
/// NVD product dictionary was downloaded or found current).
/// </summary>
// Declaration order == wire order.
public class TriggerCpeDictionarySyncResponse : IApiResponse
{
public bool Success { get; set; }
public string Message { get; set; } = default!;
/// <summary>True when a new copy of the feed was downloaded and stored; false when the stored copy is current.</summary>
public bool Changed { get; set; }
/// <summary>Products in the stored dictionary after this call; 0 when there is none.</summary>
public int Products { get; set; }
/// <summary>Error text of a failed download; null (key omitted) on success.</summary>
public string? Error { get; set; }
public int DurationMs { get; set; }
/// <summary>Preformatted ISO-8601 timestamp of this response.</summary>
public string SyncedAt { get; set; } = default!;
}
/// <summary>
/// Response of POST vulnerability/sync-msrc (dual-purpose: success reflects whether the
/// MSRC index refresh finished without error).
@@ -14,12 +14,20 @@ import type {
AdminConfiguration as WireAdminConfiguration,
CveExposureSummary,
GetVulnerabilitySyncStatusResponse,
TriggerCpeDictionarySyncResponse,
TriggerEpssSyncResponse,
TriggerMsrcSyncResponse,
TriggerNvdCacheRefreshResponse,
TriggerVulnerabilityDataSyncResponse,
} from "@/lib/generated/wire-types.generated";
// The syncs that answer with a message and a timestamp: POST, show the message, reload the status.
const CACHE_SYNCS = {
nvd: { label: "NVD cache refresh", url: api.vulnerability.syncNvd },
epss: { label: "EPSS sync", url: api.vulnerability.syncEpss },
dictionary: { label: "NVD dictionary sync", url: api.vulnerability.syncDictionary },
} as const;
interface VulnerabilityDataSectionProps {
adminConfig: AdminConfiguration | null;
loadingConfig: boolean;
@@ -62,6 +70,7 @@ export function VulnerabilityDataSection({
const [syncingNvd, setSyncingNvd] = useState(false);
const [syncingEpss, setSyncingEpss] = useState(false);
const [syncingDictionary, setSyncingDictionary] = useState(false);
// Live catalog/index sizes (read-only; fetched from /api/vulnerability/sync-status)
const [syncStatus, setSyncStatus] = useState<{
@@ -73,6 +82,9 @@ export function VulnerabilityDataSection({
nvdLastRun?: { stale: number; refreshed: number; failed: number; skippedThrottled: number; skippedUnavailable: number; error?: string | null; finishedUtc?: string | null } | null;
epssRefreshRunning?: boolean;
epssLastRun?: { cves: number; scored: number; rowsRewritten: number; error?: string | null } | null;
/** The stored NVD product dictionary; both absent while none was ever loaded. */
cpeDictionaryProducts?: number | null;
cpeDictionaryLastRefreshUtc?: string | null;
} | null>(null);
// Fleet exposure overview (read-only; cross-tenant via /api/global/metrics/vulnerability)
@@ -97,6 +109,8 @@ export function VulnerabilityDataSection({
nvdLastRun: data.nvdLastRun,
epssRefreshRunning: data.epssRefreshRunning,
epssLastRun: data.epssLastRun,
cpeDictionaryProducts: data.cpeDictionaryProducts,
cpeDictionaryLastRefreshUtc: data.cpeDictionaryLastRefreshUtc,
});
} catch {
// Non-critical: counts are auxiliary; timestamp still renders from admin config
@@ -254,19 +268,19 @@ export function VulnerabilityDataSection({
}
}, [getAccessToken, setError, refreshAdminConfig, refreshSyncStatus]);
// NVD cache refresh + EPSS re-score share one shape: POST, show the backend's message,
// pull the admin config (timestamps) and the sync status (counts) afterwards.
// NVD cache refresh, EPSS re-score and dictionary download share one shape: POST, show the
// backend's message, pull the admin config (timestamps) and the sync status (counts) afterwards.
const runCacheSync = useCallback(async (
kind: "nvd" | "epss",
kind: keyof typeof CACHE_SYNCS,
setBusy: (b: boolean) => void,
) => {
const label = kind === "nvd" ? "NVD cache refresh" : "EPSS sync";
const { label, url } = CACHE_SYNCS[kind];
setBusy(true);
setSyncResult(null);
setError(null);
try {
const data = await fetchJson<TriggerNvdCacheRefreshResponse | TriggerEpssSyncResponse>(
kind === "nvd" ? api.vulnerability.syncNvd() : api.vulnerability.syncEpss(),
const data = await fetchJson<TriggerNvdCacheRefreshResponse | TriggerEpssSyncResponse | TriggerCpeDictionarySyncResponse>(
url(),
getAccessToken,
{ method: "POST" }
);
@@ -289,6 +303,7 @@ export function VulnerabilityDataSection({
const handleSyncNvd = useCallback(() => runCacheSync("nvd", setSyncingNvd), [runCacheSync]);
const handleSyncEpss = useCallback(() => runCacheSync("epss", setSyncingEpss), [runCacheSync]);
const handleSyncDictionary = useCallback(() => runCacheSync("dictionary", setSyncingDictionary), [runCacheSync]);
const handleReseed = useCallback(async (type: "seed" | "community") => {
const isSeed = type === "seed";
@@ -400,7 +415,7 @@ export function VulnerabilityDataSection({
{/* Vulnerability Correlation Enabled Toggle */}
<div>
<div className="flex items-center justify-between">
<div className="flex items-start justify-between">
<div className="flex-1 mr-4">
<span className="text-emerald-900 dark:text-emerald-100 font-medium">Enable Vulnerability Correlation</span>
<p className="text-sm text-emerald-800 dark:text-gray-300 mt-1">
@@ -462,8 +477,8 @@ export function VulnerabilityDataSection({
<span className="text-gray-900 dark:text-gray-100 font-mono text-xs">
{formatTimestamp(adminConfig?.nvdCacheLastRefreshUtc)}
{syncStatus?.nvdCacheEntries !== undefined && (
<span className="ml-2 text-emerald-700 dark:text-emerald-300 font-semibold">
· {syncStatus.nvdCacheEntries.toLocaleString()} products
<span className="ml-2 text-emerald-700 dark:text-emerald-300 font-semibold" title="Products CVE data is held for: the ones a mapping names. The product dictionary below is a different store.">
· CVE data for {syncStatus.nvdCacheEntries.toLocaleString()} products
</span>
)}
{syncStatus?.nvdRefreshRunning && (
@@ -479,6 +494,21 @@ export function VulnerabilityDataSection({
)}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-emerald-800 dark:text-gray-300" title="NVD's list of product names. Program names without a mapping are matched against it (Auto-Map, and the nightly automatic mappings). Loaded every night and with Sync NVD Dictionary.">NVD Product Dictionary</span>
<span className="text-gray-900 dark:text-gray-100 font-mono text-xs">
{syncStatus?.cpeDictionaryProducts != null ? (
<>
{formatTimestamp(syncStatus.cpeDictionaryLastRefreshUtc ?? undefined)}
<span className="ml-2 text-emerald-700 dark:text-emerald-300 font-semibold">
· {syncStatus.cpeDictionaryProducts.toLocaleString()} products
</span>
</>
) : syncStatus ? (
"not loaded yet"
) : null}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-emerald-800 dark:text-gray-300" title="Last FIRST EPSS re-score of every cached CVE">Last EPSS Sync</span>
<span className="text-gray-900 dark:text-gray-100 font-mono text-xs">
@@ -518,127 +548,151 @@ export function VulnerabilityDataSection({
</div>
)}
{/* Sync + Seed Buttons */}
<div className="pt-2 flex flex-wrap items-center gap-2">
<button
onClick={handleSync}
disabled={!canMutate || syncing || syncingMsrc || reseedingSeed || reseedingCommunity}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
>
{syncing ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
<span>Syncing...</span>
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.996 19.632h-4.991" />
</svg>
<span>Sync KEV</span>
</>
)}
</button>
<button
onClick={handleSyncMsrc}
disabled={!canMutate || syncingMsrc || syncing || reseedingSeed || reseedingCommunity}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
title="Refreshes the MSRC CVRF index (last 6 Patch Tuesdays from api.msrc.microsoft.com)"
>
{syncingMsrc ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
<span>Syncing...</span>
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
<span>Sync MSRC</span>
</>
)}
</button>
<button
onClick={handleSyncNvd}
disabled={!canMutate || syncingNvd || nvdRunning || syncing || syncingMsrc || syncingEpss || reseedingSeed || reseedingCommunity}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-emerald-700 hover:bg-emerald-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
title="Re-queries NVD for every cached product older than 7 days. Runs in the background (sequential, NVD rate-limited) — the status line above updates while it runs"
>
{syncingNvd || nvdRunning ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
<span>Refreshing...</span>
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
<span>Refresh NVD Cache</span>
</>
)}
</button>
<button
onClick={handleSyncEpss}
disabled={!canMutate || syncingEpss || syncing || syncingMsrc || syncingNvd || reseedingSeed || reseedingCommunity}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
title="Re-scores every cached CVE with today's FIRST EPSS exploitation probability (api.first.org)"
>
{syncingEpss ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
<span>Syncing...</span>
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
<span>Sync EPSS</span>
</>
)}
</button>
<button
onClick={() => handleReseed("seed")}
disabled={!canMutate || reseedingSeed || reseedingCommunity || syncing || syncingMsrc}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-amber-700 dark:text-amber-300 bg-transparent border border-amber-400 dark:border-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Re-imports CPE mapping seed from GitHub"
>
{reseedingSeed ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-amber-600 mr-2"></div>
<span>Seeding...</span>
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
<span>Seed CPE Mappings</span>
</>
)}
</button>
<button
onClick={() => handleReseed("community")}
disabled={!canMutate || reseedingCommunity || reseedingSeed || syncing || syncingMsrc}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-indigo-700 dark:text-indigo-300 bg-transparent border border-indigo-400 dark:border-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Re-imports community CPE mappings from GitHub (if available)"
>
{reseedingCommunity ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600 mr-2"></div>
<span>Seeding...</span>
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<span>Seed Community Mappings</span>
</>
)}
</button>
{/* The syncs in one row, the two list imports in a second */}
<div className="pt-2 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<button
onClick={handleSync}
disabled={!canMutate || syncing || syncingMsrc || reseedingSeed || reseedingCommunity}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
>
{syncing ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
<span>Syncing...</span>
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.996 19.632h-4.991" />
</svg>
<span>Sync KEV</span>
</>
)}
</button>
<button
onClick={handleSyncMsrc}
disabled={!canMutate || syncingMsrc || syncing || reseedingSeed || reseedingCommunity}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
title="Refreshes the MSRC CVRF index (last 6 Patch Tuesdays from api.msrc.microsoft.com)"
>
{syncingMsrc ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
<span>Syncing...</span>
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
<span>Sync MSRC</span>
</>
)}
</button>
<button
onClick={handleSyncNvd}
disabled={!canMutate || syncingNvd || nvdRunning || syncing || syncingMsrc || syncingEpss || reseedingSeed || reseedingCommunity}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-emerald-700 hover:bg-emerald-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
title="Re-queries NVD for every cached product older than 7 days. Runs in the background (sequential, NVD rate-limited) — the status line above updates while it runs"
>
{syncingNvd || nvdRunning ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
<span>Refreshing...</span>
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
<span>Refresh NVD Cache</span>
</>
)}
</button>
<button
onClick={handleSyncDictionary}
disabled={!canMutate || syncingDictionary || syncing || syncingMsrc || reseedingSeed || reseedingCommunity}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-teal-700 hover:bg-teal-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
title="Downloads the NVD product dictionary now instead of waiting for the nightly run (about 15 seconds). Auto-Map needs it."
>
{syncingDictionary ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
<span>Syncing...</span>
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
<span>Sync NVD Dictionary</span>
</>
)}
</button>
<button
onClick={handleSyncEpss}
disabled={!canMutate || syncingEpss || syncing || syncingMsrc || syncingNvd || reseedingSeed || reseedingCommunity}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
title="Re-scores every cached CVE with today's FIRST EPSS exploitation probability (api.first.org)"
>
{syncingEpss ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
<span>Syncing...</span>
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
<span>Sync EPSS</span>
</>
)}
</button>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => handleReseed("seed")}
disabled={!canMutate || reseedingSeed || reseedingCommunity || syncing || syncingMsrc}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-amber-700 dark:text-amber-300 bg-transparent border border-amber-400 dark:border-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Re-imports CPE mapping seed from GitHub"
>
{reseedingSeed ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-amber-600 mr-2"></div>
<span>Seeding...</span>
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
<span>Seed CPE Mappings</span>
</>
)}
</button>
<button
onClick={() => handleReseed("community")}
disabled={!canMutate || reseedingCommunity || reseedingSeed || syncing || syncingMsrc}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-indigo-700 dark:text-indigo-300 bg-transparent border border-indigo-400 dark:border-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Re-imports community CPE mappings from GitHub (if available)"
>
{reseedingCommunity ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600 mr-2"></div>
<span>Seeding...</span>
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
<span>Seed Community Mappings</span>
</>
)}
</button>
</div>
</div>
</div>
</div>
+1
View File
@@ -731,6 +731,7 @@ export const api = {
syncMsrc: () => `${API_BASE_URL}/api/vulnerability/sync-msrc`,
syncNvd: () => `${API_BASE_URL}/api/vulnerability/sync-nvd`,
syncEpss: () => `${API_BASE_URL}/api/vulnerability/sync-epss`,
syncDictionary: () => `${API_BASE_URL}/api/vulnerability/sync-dictionary`,
syncStatus: () => `${API_BASE_URL}/api/vulnerability/sync-status`,
syncReseed: (type: string) =>
`${API_BASE_URL}/api/vulnerability/sync${qs({ reseed: type })}`,
@@ -12630,6 +12630,24 @@
},
"optional": true,
"doc": "Last run on THIS instance (in-memory; null / key omitted after a cold start)."
},
{
"name": "cpeDictionaryProducts",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true,
"doc": "Products in the stored NVD product dictionary, which program names are matched against; null (key omitted) while none was ever stored. Not the CVE cache: NvdCacheEntries counts the products CVE data is held for."
},
{
"name": "cpeDictionaryLastRefreshUtc",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "When the stored dictionary was last replaced (ISO-8601); null (key omitted) while none was ever stored."
}
]
},
@@ -29081,6 +29099,66 @@
}
]
},
"TriggerCpeDictionarySyncResponse": {
"kind": "object",
"doc": "Response of POST vulnerability/sync-dictionary (dual-purpose: success reflects whether the NVD product dictionary was downloaded or found current).",
"fields": [
{
"name": "success",
"type": {
"kind": "primitive",
"name": "boolean"
}
},
{
"name": "message",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "changed",
"type": {
"kind": "primitive",
"name": "boolean"
},
"doc": "True when a new copy of the feed was downloaded and stored; false when the stored copy is current."
},
{
"name": "products",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Products in the stored dictionary after this call; 0 when there is none."
},
{
"name": "error",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "Error text of a failed download; null (key omitted) on success."
},
{
"name": "durationMs",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "syncedAt",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "Preformatted ISO-8601 timestamp of this response."
}
]
},
"TriggerEpssSyncResponse": {
"kind": "object",
"doc": "Response of POST vulnerability/sync-epss (dual-purpose: success reflects whether the synchronous EPSS re-score finished without error).",
@@ -2635,6 +2635,10 @@ export interface GetVulnerabilitySyncStatusResponse {
epssRefreshRunning: boolean;
/** Last run on THIS instance (in-memory; null / key omitted after a cold start). */
epssLastRun?: EpssRefreshRunWire;
/** Products in the stored NVD product dictionary, which program names are matched against; null (key omitted) while none was ever stored. Not the CVE cache: NvdCacheEntries counts the products CVE data is held for. */
cpeDictionaryProducts?: number;
/** When the stored dictionary was last replaced (ISO-8601); null (key omitted) while none was ever stored. */
cpeDictionaryLastRefreshUtc?: string;
}
/** One Global Admin/Reader row on the wire. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them). */
@@ -6383,6 +6387,21 @@ export interface TpmPssUnsupportedResponse {
dataQualityNotice: string;
}
/** Response of POST vulnerability/sync-dictionary (dual-purpose: success reflects whether the NVD product dictionary was downloaded or found current). */
export interface TriggerCpeDictionarySyncResponse {
success: boolean;
message: string;
/** True when a new copy of the feed was downloaded and stored; false when the stored copy is current. */
changed: boolean;
/** Products in the stored dictionary after this call; 0 when there is none. */
products: number;
/** Error text of a failed download; null (key omitted) on success. */
error?: string;
durationMs: number;
/** Preformatted ISO-8601 timestamp of this response. */
syncedAt: string;
}
/** Response of POST vulnerability/sync-epss (dual-purpose: success reflects whether the synchronous EPSS re-score finished without error). */
export interface TriggerEpssSyncResponse {
success: boolean;