mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-14 05:20:11 +00:00
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:
@@ -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));
|
||||
}
|
||||
|
||||
+77
@@ -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]
|
||||
|
||||
+9
-3
@@ -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;
|
||||
}
|
||||
|
||||
+99
@@ -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),
|
||||
|
||||
+24
-2
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user