mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
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:
+2
@@ -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,
|
||||
|
||||
+79
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-3
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user