diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 95f5c033..4a7eaf55 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -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, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/TriggerMsrcSyncFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/TriggerMsrcSyncFunction.cs new file mode 100644 index 00000000..12126e29 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/TriggerMsrcSyncFunction.cs @@ -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 +{ + /// + /// 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. + /// + public class TriggerMsrcSyncFunction + { + private readonly MsrcCorrelationService _msrcCorrelation; + private readonly AdminConfigurationService _adminConfigService; + private readonly ILogger _logger; + + public TriggerMsrcSyncFunction( + MsrcCorrelationService msrcCorrelation, + AdminConfigurationService adminConfigService, + ILogger logger) + { + _msrcCorrelation = msrcCorrelation; + _adminConfigService = adminConfigService; + _logger = logger; + } + + [Function("TriggerMsrcSync")] + public async Task 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; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs index 87d3a77a..a2679892 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs @@ -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) diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 1d83dcac..f50a16ef 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -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), diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs index ade64861..8d884d92 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs @@ -388,11 +388,21 @@ namespace AutopilotMonitor.Shared.Models public bool EnableIndexDualWrite { get; set; } = false; /// - /// 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. /// public string VulnerabilityDataLastSyncUtc { get; set; } = default!; + /// + /// 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. + /// + public string MsrcLastSyncUtc { get; set; } = default!; + /// /// Creates default configuration /// diff --git a/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx index 15c51684..3bdfa1bb 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx @@ -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({

Data Sync Status

- Last Sync + Last KEV Sync {formatTimestamp(adminConfig?.vulnerabilityDataLastSyncUtc)}
+
+ Last MSRC Sync + + {formatTimestamp(adminConfig?.msrcLastSyncUtc)} + +
{syncResult?.kevCatalogEntries !== undefined && (
KEV Catalog Entries @@ -327,6 +384,16 @@ export function VulnerabilityDataSection({
)} + {syncResult?.msrcCves !== undefined && ( +
+ + MSRC Indexed CVEs{syncResult.msrcDocs !== undefined ? ` (across ${syncResult.msrcDocs} CVRF docs)` : ""} + + + {syncResult.msrcCves.toLocaleString()} + +
+ )} {/* Sync Result Message */} {syncResult && ( @@ -359,7 +426,7 @@ export function VulnerabilityDataSection({
+