feat(vuln): manual MSRC sync trigger + per-source last-sync timestamps

Adds a separate "Sync MSRC" admin button next to "Sync KEV" so MSRC can be
refreshed on demand instead of waiting for the daily 03:00 UTC timer. KEV
and MSRC now have independent "Last Sync" timestamps surfaced in the UI,
and the daily timer only bumps the timestamp of phases that actually
succeeded — a partial failure is now observable via a stale timestamp
rather than being masked by a single combined value.

Backend:
- New POST /api/vulnerability/sync-msrc → MsrcCorrelationService.RefreshAsync,
  GlobalAdminOnly, returns msrcCves/msrcDocs/msrcDocsFailed/durationMs
- AdminConfiguration.MsrcLastSyncUtc + Store/Map round-trip
- VulnerabilityDataSync writes KEV/MSRC timestamps independently

Web:
- Sync MSRC button (blue) + two timestamps in Data Sync Status card
- handleSyncMsrc + msrcLastSyncUtc round-trip via existing AdminConfig spread

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-28 15:23:20 +02:00
parent 86044b4916
commit 412da59764
8 changed files with 209 additions and 9 deletions
@@ -546,6 +546,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "VulnerabilityCorrelationEnabled", config.VulnerabilityCorrelationEnabled },
{ "EnableIndexDualWrite", config.EnableIndexDualWrite },
{ "VulnerabilityDataLastSyncUtc", config.VulnerabilityDataLastSyncUtc },
{ "MsrcLastSyncUtc", config.MsrcLastSyncUtc },
{ "MaxDiagnosticsDownloadSizeMB", config.MaxDiagnosticsDownloadSizeMB },
{ "DiagnosticsDownloadTimeoutSeconds", config.DiagnosticsDownloadTimeoutSeconds },
{ "OpsEventRetentionDays", config.OpsEventRetentionDays },
@@ -605,6 +606,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
VulnerabilityCorrelationEnabled = entity.GetBoolean("VulnerabilityCorrelationEnabled") ?? true,
EnableIndexDualWrite = entity.GetBoolean("EnableIndexDualWrite") ?? false,
VulnerabilityDataLastSyncUtc = entity.GetString("VulnerabilityDataLastSyncUtc"),
MsrcLastSyncUtc = entity.GetString("MsrcLastSyncUtc"),
MaxDiagnosticsDownloadSizeMB = entity.GetInt32("MaxDiagnosticsDownloadSizeMB") ?? 500,
DiagnosticsDownloadTimeoutSeconds = entity.GetInt32("DiagnosticsDownloadTimeoutSeconds") ?? 120,
OpsEventRetentionDays = entity.GetInt32("OpsEventRetentionDays") ?? 90,
@@ -0,0 +1,79 @@
using System;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Vulnerability;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
/// <summary>
/// HTTP-triggered manual refresh of the MSRC CVRF index. Mirrors the MSRC phase of
/// VulnerabilityDataSyncFunction (the daily timer) but runs on demand from the admin UI.
/// Updates AdminConfiguration.MsrcLastSyncUtc only on success so a partial failure is
/// observable in the "Last MSRC Sync" timestamp. Global Admin only.
/// </summary>
public class TriggerMsrcSyncFunction
{
private readonly MsrcCorrelationService _msrcCorrelation;
private readonly AdminConfigurationService _adminConfigService;
private readonly ILogger<TriggerMsrcSyncFunction> _logger;
public TriggerMsrcSyncFunction(
MsrcCorrelationService msrcCorrelation,
AdminConfigurationService adminConfigService,
ILogger<TriggerMsrcSyncFunction> logger)
{
_msrcCorrelation = msrcCorrelation;
_adminConfigService = adminConfigService;
_logger = logger;
}
[Function("TriggerMsrcSync")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "vulnerability/sync-msrc")] HttpRequestData req)
{
var startedAt = DateTime.UtcNow;
try
{
var msrc = await _msrcCorrelation.RefreshAsync();
if (string.IsNullOrEmpty(msrc.Error))
{
var config = await _adminConfigService.GetConfigurationAsync();
if (config != null)
{
config.MsrcLastSyncUtc = DateTime.UtcNow.ToString("o");
await _adminConfigService.SaveConfigurationAsync(config);
}
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = string.IsNullOrEmpty(msrc.Error),
message = string.IsNullOrEmpty(msrc.Error)
? "MSRC index refreshed"
: $"MSRC sync failed: {msrc.Error}",
msrcCves = msrc.IndexedCves,
msrcDocs = msrc.DocumentsFetched,
msrcDocsFailed = msrc.DocumentsFailed,
msrcSkipped = msrc.Skipped,
msrcError = msrc.Error,
durationMs = (int)(DateTime.UtcNow - startedAt).TotalMilliseconds,
syncedAt = DateTime.UtcNow.ToString("o")
});
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Manual MSRC sync failed");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "MSRC sync failed: " + ex.Message });
return errorResponse;
}
}
}
}
@@ -69,11 +69,15 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
int msrcCves = 0;
string? msrcError = null;
string? kevSyncedAt = null;
string? msrcSyncedAt = null;
// 1. Refresh CISA KEV catalog
try
{
await _kevService.RefreshAsync();
kevCount = _kevService.CatalogSize;
kevSyncedAt = DateTime.UtcNow.ToString("o");
_logger.LogInformation("[VulnSync] KEV catalog refreshed: {Count} entries", kevCount);
}
catch (Exception ex)
@@ -90,6 +94,8 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
msrcCves = msrc.IndexedCves;
if (!string.IsNullOrEmpty(msrc.Error))
msrcError = msrc.Error;
else
msrcSyncedAt = DateTime.UtcNow.ToString("o");
_logger.LogInformation(
"[VulnSync] MSRC index refreshed: {Cves} CVEs across {Docs} CVRF documents (failed={Failed}, ms={Ms:F0})",
msrc.IndexedCves, msrc.DocumentsFetched, msrc.DocumentsFailed, msrc.DurationMs);
@@ -106,14 +112,27 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
cveRefreshed = cveStats.Refreshed;
cveFailed = cveStats.Failed;
// 4. Update sync timestamp
// 4. Update per-source sync timestamps. Only the sources that succeeded get
// bumped — a failed phase keeps its prior timestamp so the UI can surface
// "Last KEV/MSRC Sync" accurately.
try
{
var config = await _adminConfigService.GetConfigurationAsync();
if (config != null)
{
config.VulnerabilityDataLastSyncUtc = DateTime.UtcNow.ToString("o");
await _adminConfigService.SaveConfigurationAsync(config);
bool dirty = false;
if (kevSyncedAt != null)
{
config.VulnerabilityDataLastSyncUtc = kevSyncedAt;
dirty = true;
}
if (msrcSyncedAt != null)
{
config.MsrcLastSyncUtc = msrcSyncedAt;
dirty = true;
}
if (dirty)
await _adminConfigService.SaveConfigurationAsync(config);
}
}
catch (Exception ex)
@@ -265,6 +265,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "vulnerability/unmatched-software", EndpointPolicy.GlobalAdminOnly),
new("GET", "vulnerability/software-inventory", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/sync", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/sync-msrc", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/cpe-mapping", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/cpe-mapping/auto-resolve", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "vulnerability/cpe-mapping", EndpointPolicy.GlobalAdminOnly),
@@ -388,11 +388,21 @@ namespace AutopilotMonitor.Shared.Models
public bool EnableIndexDualWrite { get; set; } = false;
/// <summary>
/// Last successful vulnerability data sync timestamp (UTC ISO 8601).
/// Updated by VulnerabilityDataSyncFunction.
/// Last successful CISA KEV catalog sync timestamp (UTC ISO 8601).
/// Updated by VulnerabilityDataSyncFunction (daily timer) and TriggerVulnerabilityDataSyncFunction
/// (manual /api/vulnerability/sync). Pre-existing field — semantically means "last KEV sync"
/// since KEV is the only live data refresh that ran via the manual endpoint historically.
/// </summary>
public string VulnerabilityDataLastSyncUtc { get; set; } = default!;
/// <summary>
/// Last successful MSRC CVRF index refresh timestamp (UTC ISO 8601).
/// Updated by VulnerabilityDataSyncFunction (daily timer) and TriggerMsrcSyncFunction
/// (manual /api/vulnerability/sync-msrc). Empty/null means MSRC has never refreshed
/// successfully since this field was introduced.
/// </summary>
public string MsrcLastSyncUtc { get; set; } = default!;
/// <summary>
/// Creates default configuration
/// </summary>
@@ -33,10 +33,13 @@ export function VulnerabilityDataSection({
// Sync state
const [syncing, setSyncing] = useState(false);
const [syncingMsrc, setSyncingMsrc] = useState(false);
const [syncResult, setSyncResult] = useState<{
success: boolean;
message: string;
kevCatalogEntries?: number;
msrcCves?: number;
msrcDocs?: number;
syncedAt?: string;
} | null>(null);
@@ -149,6 +152,54 @@ export function VulnerabilityDataSection({
}
}, [getAccessToken, setError, onAdminConfigUpdated]);
const handleSyncMsrc = useCallback(async () => {
setSyncingMsrc(true);
setSyncResult(null);
setError(null);
try {
const response = await authenticatedFetch(
api.vulnerability.syncMsrc(),
getAccessToken,
{ method: "POST" }
);
if (!response.ok) {
throw new Error(`MSRC sync failed: ${response.statusText}`);
}
const data = await response.json();
setSyncResult({
success: !!data.success,
message: data.message || (data.success ? "MSRC index refreshed" : "MSRC sync failed"),
msrcCves: data.msrcCves,
msrcDocs: data.msrcDocs,
syncedAt: data.syncedAt,
});
try {
const configResponse = await authenticatedFetch(api.globalConfig.get(), getAccessToken);
if (configResponse.ok) {
const updatedConfig = await configResponse.json();
onAdminConfigUpdated(updatedConfig);
}
} catch {
// Non-critical: config refresh after sync
}
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired during MSRC sync");
}
setSyncResult({
success: false,
message: err instanceof Error ? err.message : "MSRC sync failed",
});
setError(err instanceof Error ? err.message : "MSRC sync failed");
} finally {
setSyncingMsrc(false);
}
}, [getAccessToken, setError, onAdminConfigUpdated]);
const handleReseed = useCallback(async (type: "seed" | "community") => {
const isSeed = type === "seed";
if (isSeed) setReseedingSeed(true); else setReseedingCommunity(true);
@@ -314,11 +365,17 @@ export function VulnerabilityDataSection({
<h3 className="text-sm font-medium text-emerald-900 dark:text-emerald-100 mb-3">Data Sync Status</h3>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-emerald-800 dark:text-gray-300">Last Sync</span>
<span className="text-emerald-800 dark:text-gray-300">Last KEV Sync</span>
<span className="text-gray-900 dark:text-gray-100 font-mono text-xs">
{formatTimestamp(adminConfig?.vulnerabilityDataLastSyncUtc)}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-emerald-800 dark:text-gray-300">Last MSRC Sync</span>
<span className="text-gray-900 dark:text-gray-100 font-mono text-xs">
{formatTimestamp(adminConfig?.msrcLastSyncUtc)}
</span>
</div>
{syncResult?.kevCatalogEntries !== undefined && (
<div className="flex items-center justify-between text-sm">
<span className="text-emerald-800 dark:text-gray-300">KEV Catalog Entries</span>
@@ -327,6 +384,16 @@ export function VulnerabilityDataSection({
</span>
</div>
)}
{syncResult?.msrcCves !== undefined && (
<div className="flex items-center justify-between text-sm">
<span className="text-emerald-800 dark:text-gray-300">
MSRC Indexed CVEs{syncResult.msrcDocs !== undefined ? ` (across ${syncResult.msrcDocs} CVRF docs)` : ""}
</span>
<span className="text-gray-900 dark:text-gray-100 font-semibold">
{syncResult.msrcCves.toLocaleString()}
</span>
</div>
)}
{/* Sync Result Message */}
{syncResult && (
@@ -359,7 +426,7 @@ export function VulnerabilityDataSection({
<div className="pt-2 flex flex-wrap items-center gap-2">
<button
onClick={handleSync}
disabled={syncing || reseedingSeed || reseedingCommunity}
disabled={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 ? (
@@ -376,9 +443,29 @@ export function VulnerabilityDataSection({
</>
)}
</button>
<button
onClick={handleSyncMsrc}
disabled={syncingMsrc || syncing || reseedingSeed || reseedingCommunity}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-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={() => handleReseed("seed")}
disabled={reseedingSeed || reseedingCommunity || syncing}
disabled={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"
>
@@ -398,7 +485,7 @@ export function VulnerabilityDataSection({
</button>
<button
onClick={() => handleReseed("community")}
disabled={reseedingCommunity || reseedingSeed || syncing}
disabled={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)"
>
+1
View File
@@ -301,6 +301,7 @@ export const api = {
// ── Vulnerability ─────────────────────────────────────────────────────────
vulnerability: {
sync: () => `${API_BASE_URL}/api/vulnerability/sync`,
syncMsrc: () => `${API_BASE_URL}/api/vulnerability/sync-msrc`,
syncReseed: (type: string) =>
`${API_BASE_URL}/api/vulnerability/sync${qs({ reseed: type })}`,
cpeMappings: () => `${API_BASE_URL}/api/vulnerability/cpe-mappings`,
@@ -15,6 +15,7 @@ export interface AdminConfiguration {
nvdApiKey?: string;
vulnerabilityCorrelationEnabled?: boolean;
vulnerabilityDataLastSyncUtc?: string;
msrcLastSyncUtc?: string;
opsAlertRulesJson?: string;
opsAlertTelegramEnabled?: boolean;
opsAlertTelegramChatId?: string;