feat store vulnerability reports in own table, changed design margin on public sites, fixed security event sequence order

This commit is contained in:
Oliver Kieselbach
2026-03-20 21:47:19 +01:00
parent 945df7c816
commit 1dc38ecb6d
20 changed files with 699 additions and 167 deletions
@@ -89,6 +89,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
// UnrestrictedMode audit: tracks whether the first config apply has happened
private bool _isFirstConfigApply = true;
private int? _deferredSecurityAuditConfigVersion; // deferred until after agent_started
public MonitoringService(AgentConfiguration configuration, AgentLogger logger, string agentVersion)
{
@@ -214,6 +215,28 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
ImmediateUpload = true
});
// Emit deferred security_audit (initial UnrestrictedMode) now that agent_started has its sequence
if (_deferredSecurityAuditConfigVersion.HasValue)
{
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "security_audit",
Severity = EventSeverity.Info,
Source = "MonitoringService",
Message = "UnrestrictedMode enabled (initial config)",
Data = new Dictionary<string, object>
{
["unrestrictedMode"] = true,
["source"] = "initial_config",
["configVersion"] = _deferredSecurityAuditConfigVersion.Value
},
ImmediateUpload = true
});
_deferredSecurityAuditConfigVersion = null;
}
// WhiteGlove Part 2 detection: if the previous boot completed pre-provisioning,
// emit a whiteglove_resumed event so the backend transitions Pending → InProgress.
if (_sessionPersistence.IsWhiteGloveResume())
@@ -431,22 +454,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
_isFirstConfigApply = false;
if (config.UnrestrictedMode)
{
_logger.Info("UnrestrictedMode enabled via tenant configuration (initial config)");
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
EventType = "security_audit",
Severity = EventSeverity.Info,
Source = "MonitoringService",
Message = "UnrestrictedMode enabled (initial config)",
Data = new Dictionary<string, object>
{
["unrestrictedMode"] = true,
["source"] = "initial_config",
["configVersion"] = config.ConfigVersion
},
ImmediateUpload = true
});
// Defer initial security_audit until after agent_started so sequence numbers are correct
_logger.Info("UnrestrictedMode enabled via tenant configuration (initial config) — audit deferred until after agent_started");
_deferredSecurityAuditConfigVersion = config.ConfigVersion;
}
}
else if (config.UnrestrictedMode != previous)
@@ -1 +1 @@
544
551
@@ -224,8 +224,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
// Vulnerability Correlation — fire-and-forget when shutdown inventory arrives.
// Runs asynchronously so it never blocks the HTTP response to the agent.
// The vulnerability_report event is stored in Table Storage; the UI picks it up
// on the next poll or page refresh (no SignalR broadcast for async results).
// The report is stored in the VulnerabilityCache table (not Events) because
// it is a server-side analysis result, not an agent event.
var shutdownInventoryDetected = storedEvents.Any(e =>
e.EventType == AutopilotMonitor.Shared.Constants.EventTypes.SoftwareInventoryAnalysis &&
e.Data != null &&
@@ -275,21 +275,18 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
return;
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
var vulnReport = await _vulnerabilityCorrelation.CorrelateAsync(
var reportData = await _vulnerabilityCorrelation.CorrelateAsync(
capturedSessionId, capturedTenantId, allInventoryItems, cts.Token);
if (vulnReport != null)
if (reportData != null)
{
vulnReport.SessionId = capturedSessionId;
vulnReport.TenantId = capturedTenantId;
vulnReport.ReceivedAt = DateTime.UtcNow;
await _storageService.StoreEventsBatchAsync(new List<EnrollmentEvent> { vulnReport });
_logger.LogInformation("{Prefix} Vulnerability correlation complete (async): {Message}",
capturedPrefix, vulnReport.Message);
await _storageService.StoreVulnerabilityReportAsync(
capturedTenantId, capturedSessionId, reportData);
_logger.LogInformation("{Prefix} Vulnerability correlation complete (async)", capturedPrefix);
// Push SignalR notification so the UI fetches the report immediately
var overallRisk = vulnReport.Data?.ContainsKey("scan_summary") == true
&& vulnReport.Data["scan_summary"] is Dictionary<string, object> summary
var overallRisk = reportData.ContainsKey("scan_summary")
&& reportData["scan_summary"] is Dictionary<string, object> summary
&& summary.ContainsKey("overall_risk")
? summary["overall_risk"]?.ToString() ?? "unknown"
: "unknown";
@@ -51,22 +51,22 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
return unauthorized;
}
// Scan recent vulnerability_report events for unmatched_software lists
// Scan vulnerability reports in VulnerabilityCache table for unmatched_software lists
var unmatchedAgg = new Dictionary<string, UnmatchedSoftwareAgg>(StringComparer.OrdinalIgnoreCase);
var tableClient = _storageService.GetTableClient(Constants.TableNames.Events);
var recentEvents = tableClient.QueryAsync<TableEntity>(
filter: $"EventType eq 'vulnerability_report'",
var tableClient = _storageService.GetTableClient(Constants.TableNames.VulnerabilityCache);
var reportEntities = tableClient.QueryAsync<TableEntity>(
filter: $"RowKey eq 'vulnerability_report'",
maxPerPage: 200);
await foreach (var entity in recentEvents)
await foreach (var entity in reportEntities)
{
var dataJson = entity.GetString("Data");
if (string.IsNullOrEmpty(dataJson)) continue;
var reportJson = entity.GetString("ReportJson");
if (string.IsNullOrEmpty(reportJson)) continue;
try
{
var data = JsonSerializer.Deserialize<Dictionary<string, object>>(dataJson,
var data = JsonSerializer.Deserialize<Dictionary<string, object>>(reportJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (data == null || !data.ContainsKey("unmatched_software")) continue;
@@ -79,6 +79,8 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
if (unmatchedList == null) continue;
var sessionId = entity.GetString("SessionId") ?? "";
foreach (var item in unmatchedList)
{
var softwareName = item.GetValueOrDefault("softwareName", "");
@@ -93,7 +95,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
Publisher = item.GetValueOrDefault("publisher", ""),
Frequency = 0,
LastSeenAt = entity.GetDateTimeOffset("Timestamp")?.UtcDateTime ?? DateTime.UtcNow,
ExampleSessionId = entity.PartitionKey?.Split('_').LastOrDefault() ?? ""
ExampleSessionId = sessionId
};
}
@@ -102,11 +104,11 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
if (ts > unmatchedAgg[key].LastSeenAt)
{
unmatchedAgg[key].LastSeenAt = ts;
unmatchedAgg[key].ExampleSessionId = entity.PartitionKey?.Split('_').LastOrDefault() ?? "";
unmatchedAgg[key].ExampleSessionId = sessionId;
}
}
}
catch { /* skip malformed events */ }
catch { /* skip malformed reports */ }
}
var result = unmatchedAgg.Values
@@ -0,0 +1,81 @@
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
/// <summary>
/// Returns the vulnerability correlation report for a session.
/// The report is stored in the VulnerabilityCache table (not the Events table)
/// because it is a server-side analysis result, not an agent event.
/// Galactic admins can request reports for any tenant by passing ?tenantId=...
/// </summary>
public class GetVulnerabilityReportFunction
{
private readonly ILogger<GetVulnerabilityReportFunction> _logger;
private readonly TableStorageService _storageService;
private readonly GalacticAdminService _galacticAdminService;
public GetVulnerabilityReportFunction(
ILogger<GetVulnerabilityReportFunction> logger,
TableStorageService storageService,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_storageService = storageService;
_galacticAdminService = galacticAdminService;
}
[Function("GetVulnerabilityReport")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/vulnerability-report")] HttpRequestData req,
string sessionId)
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var userTenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
// Resolve effective tenant ID: use query param if provided, fall back to JWT tenant
var query = HttpUtility.ParseQueryString(req.Url.Query);
var requestedTenantId = query["tenantId"];
var effectiveTenantId = string.IsNullOrEmpty(requestedTenantId) ? userTenantId : requestedTenantId;
// Cross-tenant access requires Galactic Admin
if (effectiveTenantId != userTenantId)
{
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userIdentifier);
if (!isGalacticAdmin)
{
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { success = false, message = "Access denied. You can only view vulnerability reports for your own tenant." });
return forbidden;
}
_logger.LogInformation("Galactic Admin {User} accessing cross-tenant vulnerability report (tenant: {TenantId})",
userIdentifier, effectiveTenantId);
}
var report = await _storageService.GetVulnerabilityReportAsync(effectiveTenantId, sessionId);
if (report == null)
{
var notFound = req.CreateResponse(HttpStatusCode.OK);
await notFound.WriteAsJsonAsync(new { success = true, sessionId, report = (object?)null });
return notFound;
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
sessionId,
report
});
return response;
}
}
}
@@ -1,7 +1,11 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Shared;
using Azure.Data.Tables;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
@@ -9,22 +13,31 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
/// <summary>
/// Timer-triggered function that refreshes vulnerability data caches daily.
/// - CISA KEV catalog (full refresh)
/// - Stale NVD CVE cache entries (older than 7 days)
/// - MSRC security updates (latest months)
/// - CISA KEV catalog (full in-memory refresh)
/// - Stale NVD CVE cache entries in VulnerabilityCache table (older than 7 days)
///
/// All operations are logged to ILogger → visible in Application Insights under
/// "traces" with customDimensions.Category = "VulnerabilityDataSyncFunction".
/// Query: traces | where customDimensions.Category contains "VulnerabilityDataSync"
/// </summary>
public class VulnerabilityDataSyncFunction
{
private readonly KevDataService _kevService;
private readonly NvdApiClient _nvdApi;
private readonly TableStorageService _storageService;
private readonly AdminConfigurationService _adminConfigService;
private readonly ILogger<VulnerabilityDataSyncFunction> _logger;
public VulnerabilityDataSyncFunction(
KevDataService kevService,
NvdApiClient nvdApi,
TableStorageService storageService,
AdminConfigurationService adminConfigService,
ILogger<VulnerabilityDataSyncFunction> logger)
{
_kevService = kevService;
_nvdApi = nvdApi;
_storageService = storageService;
_adminConfigService = adminConfigService;
_logger = logger;
}
@@ -36,20 +49,23 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
public async Task Run(
[TimerTrigger("0 0 3 * * *")] TimerInfo timer)
{
_logger.LogInformation("Starting vulnerability data sync...");
_logger.LogInformation("[VulnSync] Starting vulnerability data sync...");
// 1. Refresh CISA KEV catalog
try
{
// 1. Refresh CISA KEV catalog (small JSON, fast)
await _kevService.RefreshAsync();
_logger.LogInformation("KEV catalog refreshed: {Count} entries", _kevService.CatalogSize);
_logger.LogInformation("[VulnSync] KEV catalog refreshed: {Count} entries", _kevService.CatalogSize);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "KEV catalog refresh failed");
_logger.LogWarning(ex, "[VulnSync] KEV catalog refresh failed");
}
// 2. Update sync timestamp in AdminConfiguration
// 2. Refresh stale CVE cache entries
await RefreshStaleCveCacheAsync();
// 3. Update sync timestamp
try
{
var config = await _adminConfigService.GetConfigurationAsync();
@@ -61,10 +77,168 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update vulnerability sync timestamp");
_logger.LogWarning(ex, "[VulnSync] Failed to update sync timestamp");
}
_logger.LogInformation("Vulnerability data sync completed");
_logger.LogInformation("[VulnSync] Vulnerability data sync completed");
}
/// <summary>
/// Scans the VulnerabilityCache table for CVE cache entries older than 7 days
/// and refreshes them by re-querying the NVD API. Processes sequentially with
/// rate limiting to avoid NVD API throttling.
/// </summary>
private async Task RefreshStaleCveCacheAsync()
{
try
{
var tableClient = _storageService.GetTableClient(Constants.TableNames.VulnerabilityCache);
var staleCutoff = DateTime.UtcNow.AddDays(-7).ToString("o");
// Find all CVE cache entries (PK starts with "cve_")
var cacheEntries = tableClient.QueryAsync<TableEntity>(
filter: "RowKey eq 'cache_result'",
select: new[] { "PartitionKey", "RowKey", "CachedAt", "CpeUri", "TotalCves" });
var staleEntries = new System.Collections.Generic.List<(string CacheKey, string CpeUri, string CachedAt, int TotalCves)>();
await foreach (var entity in cacheEntries)
{
var pk = entity.PartitionKey;
if (pk == null || !pk.StartsWith("cve_")) continue;
var cachedAt = entity.GetString("CachedAt") ?? "";
if (string.Compare(cachedAt, staleCutoff, StringComparison.OrdinalIgnoreCase) < 0)
{
staleEntries.Add((
pk,
entity.GetString("CpeUri") ?? pk.Substring(4), // Fallback: strip "cve_" prefix
cachedAt,
entity.GetInt32("TotalCves") ?? 0
));
}
}
if (staleEntries.Count == 0)
{
_logger.LogInformation("[VulnSync] CVE cache: no stale entries found");
return;
}
_logger.LogInformation("[VulnSync] CVE cache: {StaleCount} stale entries to refresh", staleEntries.Count);
int refreshed = 0;
int failed = 0;
foreach (var (cacheKey, cpeUri, cachedAt, totalCves) in staleEntries)
{
try
{
_logger.LogInformation("[VulnSync] Refreshing CVE cache for {CpeUri} (cached: {CachedAt}, had {TotalCves} CVEs)",
cpeUri, cachedAt, totalCves);
// Query NVD API (rate-limited internally by NvdApiClient)
var cveResponse = await _nvdApi.SearchCvesByCpeAsync(cpeUri, CancellationToken.None);
var newCount = cveResponse?.Vulnerabilities?.Count ?? 0;
// Re-serialize and store (reuse the same cache key)
var cves = new System.Collections.Generic.List<object>();
if (cveResponse?.Vulnerabilities != null)
{
foreach (var vulnItem in cveResponse.Vulnerabilities.Take(200))
{
var cve = vulnItem?.Cve;
if (cve == null) continue;
double cvssScore = 0;
string cvssSeverity = "UNKNOWN";
var cvssMetric = cve.Metrics?.CvssMetricV31?.FirstOrDefault()
?? cve.Metrics?.CvssMetricV30?.FirstOrDefault();
if (cvssMetric?.CvssData != null)
{
cvssScore = cvssMetric.CvssData.BaseScore;
cvssSeverity = cvssMetric.CvssData.BaseSeverity ?? "UNKNOWN";
}
var description = cve.Descriptions?.FirstOrDefault(d =>
string.Equals(d.Lang, "en", StringComparison.OrdinalIgnoreCase))?.Value ?? "";
if (description.Length > 500)
description = description.Substring(0, 497) + "...";
var affectedVersions = new System.Collections.Generic.List<object>();
if (cve.Configurations != null)
{
foreach (var config in cve.Configurations)
{
if (config.Nodes == null) continue;
foreach (var node in config.Nodes)
{
if (node.CpeMatch == null) continue;
foreach (var match in node.CpeMatch)
{
if (!match.Vulnerable) continue;
affectedVersions.Add(new
{
Criteria = match.Criteria,
VersionStartIncluding = match.VersionStartIncluding,
VersionStartExcluding = match.VersionStartExcluding,
VersionEndIncluding = match.VersionEndIncluding,
VersionEndExcluding = match.VersionEndExcluding
});
}
}
}
}
cves.Add(new
{
CveId = cve.Id ?? "",
CvssScore = cvssScore,
CvssSeverity = cvssSeverity,
Description = description,
PublishedDate = cve.Published ?? "",
AffectedVersions = affectedVersions
});
}
}
var cveDataJson = System.Text.Json.JsonSerializer.Serialize(cves);
// Guard 64KB limit
if (cveDataJson.Length > 60_000)
{
var reduced = cves.Take(cves.Count / 2).ToList();
cveDataJson = System.Text.Json.JsonSerializer.Serialize(reduced);
}
var updatedEntity = new TableEntity(cacheKey, "cache_result")
{
{ "CveDataJson", cveDataJson },
{ "CachedAt", DateTime.UtcNow.ToString("o") },
{ "CpeUri", cpeUri },
{ "TotalCves", cves.Count }
};
await tableClient.UpsertEntityAsync(updatedEntity);
refreshed++;
_logger.LogInformation("[VulnSync] Refreshed {CpeUri}: {OldCount} → {NewCount} CVEs",
cpeUri, totalCves, cves.Count);
}
catch (Exception ex)
{
failed++;
_logger.LogWarning(ex, "[VulnSync] Failed to refresh CVE cache for {CpeUri}", cpeUri);
}
}
_logger.LogInformation("[VulnSync] CVE cache refresh complete: {Refreshed} refreshed, {Failed} failed out of {Total} stale entries",
refreshed, failed, staleEntries.Count);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[VulnSync] CVE cache refresh scan failed");
}
}
}
}
@@ -105,6 +105,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "sessions/{sessionId}", EndpointPolicy.MemberRead),
new("GET", "sessions/{sessionId}/events", EndpointPolicy.MemberRead),
new("GET", "sessions/{sessionId}/analysis", EndpointPolicy.MemberRead),
new("GET", "sessions/{sessionId}/vulnerability-report", EndpointPolicy.MemberRead),
new("GET", "metrics/app", EndpointPolicy.MemberRead),
new("GET", "metrics/usage", EndpointPolicy.MemberRead),
new("GET", "metrics/geographic", EndpointPolicy.MemberRead),
@@ -508,5 +508,71 @@ namespace AutopilotMonitor.Functions.Services
IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false
};
}
// ===== VULNERABILITY REPORT METHODS =====
/// <summary>
/// Stores a vulnerability correlation report for a session.
/// Uses the existing VulnerabilityCache table with PK = report_{tenantId}_{sessionId}, RK = vulnerability_report.
/// </summary>
public async Task StoreVulnerabilityReportAsync(string tenantId, string sessionId, Dictionary<string, object> reportData)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.VulnerabilityCache);
var partitionKey = $"report_{tenantId}_{sessionId}";
var scanSummary = reportData.ContainsKey("scan_summary")
? reportData["scan_summary"] as Dictionary<string, object>
: null;
var entity = new TableEntity(partitionKey, "vulnerability_report")
{
["SessionId"] = sessionId,
["TenantId"] = tenantId,
["ReportJson"] = JsonConvert.SerializeObject(reportData),
["CreatedAt"] = DateTime.UtcNow.ToString("o"),
["OverallRisk"] = scanSummary?.GetValueOrDefault("overall_risk")?.ToString() ?? "none",
["TotalCvesFound"] = Convert.ToInt32(scanSummary?.GetValueOrDefault("total_cves_found") ?? 0),
["CriticalCves"] = Convert.ToInt32(scanSummary?.GetValueOrDefault("critical_cves") ?? 0),
["HighCves"] = Convert.ToInt32(scanSummary?.GetValueOrDefault("high_cves") ?? 0),
["KevMatches"] = Convert.ToInt32(scanSummary?.GetValueOrDefault("kev_matches") ?? 0),
["TotalScanned"] = Convert.ToInt32(scanSummary?.GetValueOrDefault("total_software_scanned") ?? 0),
["TotalMatched"] = Convert.ToInt32(scanSummary?.GetValueOrDefault("matched_to_cpe") ?? 0),
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogInformation("Stored vulnerability report for session {SessionId} (risk={Risk})", sessionId,
scanSummary?.GetValueOrDefault("overall_risk")?.ToString() ?? "none");
}
/// <summary>
/// Gets the vulnerability correlation report for a session.
/// Returns the deserialized report data, or null if no report exists.
/// </summary>
public async Task<Dictionary<string, object>?> GetVulnerabilityReportAsync(string tenantId, string sessionId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.VulnerabilityCache);
var partitionKey = $"report_{tenantId}_{sessionId}";
var response = await tableClient.GetEntityAsync<TableEntity>(partitionKey, "vulnerability_report");
var entity = response?.Value;
if (entity == null) return null;
var reportJson = entity.GetString("ReportJson");
if (string.IsNullOrEmpty(reportJson)) return null;
return JsonConvert.DeserializeObject<Dictionary<string, object>>(reportJson);
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
}
}
}
@@ -7,7 +7,6 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Shared.Models;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging;
@@ -58,7 +57,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
_seedLoaded = false;
}
public async Task<EnrollmentEvent> CorrelateAsync(
public async Task<Dictionary<string, object>?> CorrelateAsync(
string sessionId,
string tenantId,
List<Dictionary<string, object>> inventoryItems,
@@ -197,12 +196,6 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
findings = findings.Take(50).ToList();
}
// Determine event severity
EventSeverity eventSeverity;
if (overallRisk == "critical" || kevMatches > 0) eventSeverity = EventSeverity.Error;
else if (overallRisk == "high" || criticalCves > 0) eventSeverity = EventSeverity.Warning;
else eventSeverity = EventSeverity.Info;
var data = new Dictionary<string, object>
{
{ "scan_summary", new Dictionary<string, object>
@@ -226,28 +219,12 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
{
{ "cpe_cache_entries", _cpeMappings?.Count ?? 0 },
{ "kev_catalog_entries", _kevService.CatalogSize },
{ "kev_last_sync", _kevService.LastRefresh.ToString("o") },
{ "nvd_api_key_configured", !string.IsNullOrWhiteSpace((await _adminConfig.GetConfigurationAsync())?.NvdApiKey) }
{ "kev_last_sync", _kevService.LastRefresh.ToString("o") }
}
}
};
var message = totalCves > 0
? $"Vulnerability scan: {criticalCves} critical, {highCves} high across {matchedCount} scanned products"
: $"Vulnerability scan complete: {matchedCount} products scanned, no known vulnerabilities found";
return new EnrollmentEvent
{
SessionId = sessionId,
TenantId = tenantId,
Timestamp = DateTime.UtcNow,
EventType = "vulnerability_report",
Severity = eventSeverity,
Source = "VulnerabilityCorrelation",
Phase = EnrollmentPhase.Complete,
Message = message,
Data = data
};
return data;
}
// -----------------------------------------------------------------------
@@ -299,72 +276,104 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
}
// -----------------------------------------------------------------------
// CVE Lookup
// CVE Lookup (with Table Storage caching)
// -----------------------------------------------------------------------
private const int CacheTtlDays = 7;
private const int MaxCachedCves = 200;
private async Task<List<Dictionary<string, object>>> GetVulnerabilitiesForCpeAsync(
string cpeUri, string installedVersion, CancellationToken ct)
{
var results = new List<Dictionary<string, object>>();
try
{
// Query NVD API for CVEs matching this CPE
var cveResponse = await _nvdApi.SearchCvesByCpeAsync(cpeUri, ct);
// 1. Try cache first
var cacheKey = SanitizeTableKey($"cve_{cpeUri}");
var cachedResult = await GetCachedCvesAsync(cacheKey);
if (cveResponse?.Vulnerabilities == null)
return results;
if (cachedResult == null)
{
// Cache miss: query NVD, cache result
cachedResult = await FetchAndCacheCvesFromNvdAsync(cpeUri, cacheKey, ct);
}
else if (cachedResult.IsStale)
{
// Serve stale, refresh in background (fire-and-forget)
_logger.LogDebug("CVE cache stale for {CpeUri}, serving cached and refreshing in background", cpeUri);
_ = Task.Run(async () =>
{
try { await FetchAndCacheCvesFromNvdAsync(cpeUri, cacheKey, CancellationToken.None); }
catch (Exception ex) { _logger.LogWarning(ex, "Background CVE cache refresh failed for {CpeUri}", cpeUri); }
});
}
// 2. Filter cached CVEs by installed version
return FilterByVersion(cachedResult.Cves, cpeUri, installedVersion);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to look up CVEs for CPE {CpeUri}", cpeUri);
return new List<Dictionary<string, object>>();
}
}
/// <summary>
/// Read cached CVE data from the VulnerabilityCache table.
/// Returns null on cache miss or read errors.
/// </summary>
private async Task<CachedCveResult?> GetCachedCvesAsync(string cacheKey)
{
try
{
var tableClient = _storage.GetTableClient(AutopilotMonitor.Shared.Constants.TableNames.VulnerabilityCache);
var response = await tableClient.GetEntityAsync<TableEntity>(cacheKey, "cache_result");
var entity = response?.Value;
if (entity == null) return null;
var cachedAtStr = entity.GetString("CachedAt");
if (string.IsNullOrWhiteSpace(cachedAtStr)) return null;
var cveDataJson = entity.GetString("CveDataJson") ?? "[]";
var cves = JsonSerializer.Deserialize<List<CachedCve>>(cveDataJson, JsonOptions) ?? new List<CachedCve>();
return new CachedCveResult
{
Cves = cves,
CachedAt = DateTime.Parse(cachedAtStr, null, System.Globalization.DateTimeStyles.RoundtripKind)
};
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
return null; // Cache miss
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to read CVE cache for key {CacheKey}", cacheKey);
return null;
}
}
/// <summary>
/// Query NVD API for CVEs matching a CPE, then cache the results in table storage.
/// Returns the parsed CVE data regardless of whether caching succeeds.
/// </summary>
private async Task<CachedCveResult> FetchAndCacheCvesFromNvdAsync(
string cpeUri, string cacheKey, CancellationToken ct)
{
var cveResponse = await _nvdApi.SearchCvesByCpeAsync(cpeUri, ct);
var cves = new List<CachedCve>();
if (cveResponse?.Vulnerabilities != null)
{
foreach (var vulnItem in cveResponse.Vulnerabilities)
{
var cve = vulnItem?.Cve;
if (cve == null) continue;
// Check if installed version is affected
bool isAffected = false;
if (cve.Configurations != null)
{
foreach (var config in cve.Configurations)
{
if (config.Nodes == null) continue;
foreach (var node in config.Nodes)
{
if (node.CpeMatch == null) continue;
foreach (var match in node.CpeMatch)
{
if (!match.Vulnerable) continue;
// If no version constraints, consider affected
if (string.IsNullOrWhiteSpace(match.VersionStartIncluding) &&
string.IsNullOrWhiteSpace(match.VersionStartExcluding) &&
string.IsNullOrWhiteSpace(match.VersionEndIncluding) &&
string.IsNullOrWhiteSpace(match.VersionEndExcluding))
{
// Wildcard match -- only if CPE criteria matches our product
if (match.Criteria != null && match.Criteria.StartsWith(cpeUri, StringComparison.OrdinalIgnoreCase))
isAffected = true;
}
else if (!string.IsNullOrWhiteSpace(installedVersion))
{
isAffected = VersionComparer.IsVersionAffected(
installedVersion,
match.VersionStartIncluding,
match.VersionStartExcluding,
match.VersionEndIncluding,
match.VersionEndExcluding);
}
if (isAffected) break;
}
if (isAffected) break;
}
if (isAffected) break;
}
}
if (!isAffected)
continue;
// Extract CVSS score (prefer v3.1, fallback to v3.0)
double cvssScore = 0;
string cvssSeverity = "UNKNOWN";
@@ -379,37 +388,204 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
// Extract English description
var description = cve.Descriptions?.FirstOrDefault(d =>
string.Equals(d.Lang, "en", StringComparison.OrdinalIgnoreCase))?.Value ?? "";
// Truncate long descriptions
if (description.Length > 500)
description = description.Substring(0, 497) + "...";
results.Add(new Dictionary<string, object>
// Collect all version ranges from configurations
var affectedVersions = new List<CachedVersionRange>();
if (cve.Configurations != null)
{
{ "cveId", cve.Id ?? "" },
{ "cvssScore", cvssScore },
{ "cvssSeverity", cvssSeverity },
{ "description", description },
{ "publishedDate", cve.Published ?? "" },
{ "source", "NVD" },
{ "isKev", false },
{ "kevDueDate", "" },
{ "kevRequiredAction", "" },
{ "knownRansomware", "" }
foreach (var config in cve.Configurations)
{
if (config.Nodes == null) continue;
foreach (var node in config.Nodes)
{
if (node.CpeMatch == null) continue;
foreach (var match in node.CpeMatch)
{
if (!match.Vulnerable) continue;
affectedVersions.Add(new CachedVersionRange
{
Criteria = match.Criteria,
VersionStartIncluding = match.VersionStartIncluding,
VersionStartExcluding = match.VersionStartExcluding,
VersionEndIncluding = match.VersionEndIncluding,
VersionEndExcluding = match.VersionEndExcluding
});
}
}
}
}
cves.Add(new CachedCve
{
CveId = cve.Id ?? "",
CvssScore = cvssScore,
CvssSeverity = cvssSeverity,
Description = description,
PublishedDate = cve.Published ?? "",
AffectedVersions = affectedVersions
});
}
}
catch (OperationCanceledException)
// Truncate to most severe CVEs if too many (Table Storage 64KB property limit)
if (cves.Count > MaxCachedCves)
{
throw;
cves = cves
.OrderByDescending(c => c.CvssScore)
.ThenByDescending(c => c.PublishedDate)
.Take(MaxCachedCves)
.ToList();
_logger.LogInformation("Truncated CVE cache for {CpeUri} from {Total} to {Max} entries",
cpeUri, cveResponse?.Vulnerabilities?.Count ?? 0, MaxCachedCves);
}
var result = new CachedCveResult
{
Cves = cves,
CachedAt = DateTime.UtcNow
};
// Persist to table storage (best-effort, don't fail the request)
try
{
var cveDataJson = JsonSerializer.Serialize(cves, JsonOptions);
// Guard against exceeding Table Storage 64KB single-property limit
if (cveDataJson.Length > 60_000)
{
// Further reduce until it fits
var reduced = cves
.OrderByDescending(c => c.CvssScore)
.ThenByDescending(c => c.PublishedDate)
.Take(cves.Count / 2)
.ToList();
cveDataJson = JsonSerializer.Serialize(reduced, JsonOptions);
_logger.LogInformation("Further reduced CVE cache for {CpeUri} to {Count} entries to fit 64KB limit",
cpeUri, reduced.Count);
}
var entity = new TableEntity(cacheKey, "cache_result")
{
{ "CveDataJson", cveDataJson },
{ "CachedAt", DateTime.UtcNow.ToString("o") },
{ "CpeUri", cpeUri },
{ "TotalCves", cves.Count }
};
var tableClient = _storage.GetTableClient(AutopilotMonitor.Shared.Constants.TableNames.VulnerabilityCache);
await tableClient.UpsertEntityAsync(entity);
_logger.LogDebug("Cached {Count} CVEs for {CpeUri}", cves.Count, cpeUri);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to look up CVEs for CPE {CpeUri}", cpeUri);
_logger.LogWarning(ex, "Failed to cache CVE results for {CpeUri}", cpeUri);
}
return result;
}
/// <summary>
/// Filter cached CVEs by installed version. Applies the same version-range
/// logic that was previously done inline during NVD response parsing.
/// </summary>
private List<Dictionary<string, object>> FilterByVersion(
List<CachedCve> cves, string cpeUri, string installedVersion)
{
var results = new List<Dictionary<string, object>>();
foreach (var cve in cves)
{
bool isAffected = false;
foreach (var range in cve.AffectedVersions)
{
// If no version constraints, wildcard match only if CPE criteria matches our product
if (string.IsNullOrWhiteSpace(range.VersionStartIncluding) &&
string.IsNullOrWhiteSpace(range.VersionStartExcluding) &&
string.IsNullOrWhiteSpace(range.VersionEndIncluding) &&
string.IsNullOrWhiteSpace(range.VersionEndExcluding))
{
if (range.Criteria != null && range.Criteria.StartsWith(cpeUri, StringComparison.OrdinalIgnoreCase))
isAffected = true;
}
else if (!string.IsNullOrWhiteSpace(installedVersion))
{
isAffected = VersionComparer.IsVersionAffected(
installedVersion,
range.VersionStartIncluding,
range.VersionStartExcluding,
range.VersionEndIncluding,
range.VersionEndExcluding);
}
if (isAffected) break;
}
if (!isAffected)
continue;
results.Add(new Dictionary<string, object>
{
{ "cveId", cve.CveId },
{ "cvssScore", cve.CvssScore },
{ "cvssSeverity", cve.CvssSeverity },
{ "description", cve.Description },
{ "publishedDate", cve.PublishedDate },
{ "source", "NVD" },
{ "isKev", false },
{ "kevDueDate", "" },
{ "kevRequiredAction", "" },
{ "knownRansomware", "" }
});
}
return results;
}
/// <summary>
/// Replace characters not allowed in Azure Table Storage keys (/, \, #, ?).
/// </summary>
private static string SanitizeTableKey(string key)
{
return key
.Replace("/", "_")
.Replace("\\", "_")
.Replace("#", "_")
.Replace("?", "_");
}
// -----------------------------------------------------------------------
// CVE Cache Models
// -----------------------------------------------------------------------
private class CachedCveResult
{
public List<CachedCve> Cves { get; set; } = new();
public DateTime CachedAt { get; set; }
public bool IsStale => (DateTime.UtcNow - CachedAt).TotalDays > CacheTtlDays;
}
private class CachedCve
{
public string CveId { get; set; } = "";
public double CvssScore { get; set; }
public string CvssSeverity { get; set; } = "UNKNOWN";
public string Description { get; set; } = "";
public string PublishedDate { get; set; } = "";
public List<CachedVersionRange> AffectedVersions { get; set; } = new();
}
private class CachedVersionRange
{
public string? Criteria { get; set; }
public string? VersionStartIncluding { get; set; }
public string? VersionStartExcluding { get; set; }
public string? VersionEndIncluding { get; set; }
public string? VersionEndExcluding { get; set; }
}
// -----------------------------------------------------------------------
// Seed Loading
// -----------------------------------------------------------------------
@@ -60,7 +60,7 @@ export default function AboutPage() {
<div className="min-h-screen bg-gray-50">
<PublicPageHeader title="About Autopilot Monitor" />
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-10">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-10">
{/* What is Autopilot Monitor */}
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
@@ -295,7 +295,7 @@ export default function AdminConfigurationPage() {
</header>
{/* Main Content */}
<main className="px-4 sm:px-6 lg:px-8 py-8">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="space-y-6">
{/* Success Message */}
{successMessage && (
@@ -6,7 +6,7 @@ export default function ChangelogPage() {
return (
<div className="min-h-screen bg-gray-50">
<PublicPageHeader title="Changelog" />
<main className="px-4 sm:px-6 lg:px-8 py-8">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8 sm:p-10">
{/* Intro */}
@@ -152,7 +152,7 @@ export default function HealthCheckPage() {
</div>
</header>
<div className="py-6 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{/* Overall Status Bar */}
{healthResult && overallColors && (
<div className={`rounded-lg border mb-6 p-4 flex items-center justify-between ${overallColors.bg} ${overallColors.border}`}>
@@ -4,7 +4,7 @@ export default function PrivacyPage() {
return (
<div className="min-h-screen bg-gray-50">
<PublicPageHeader title="Privacy Policy" />
<main className="px-4 sm:px-6 lg:px-8 py-8 space-y-6">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
<div className="bg-white rounded-lg shadow p-6 space-y-4">
<h2 className="text-xl font-semibold text-gray-900">Data Collection</h2>
<p className="text-gray-700">
@@ -4,7 +4,7 @@ export default function RoadmapPage() {
return (
<div className="min-h-screen bg-gray-50">
<PublicPageHeader title="Roadmap" />
<main className="px-4 sm:px-6 lg:px-8 py-8">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8 sm:p-10">
<p className="text-gray-600">
Planned features for upcoming releases.
@@ -4,6 +4,7 @@ import { useState, useMemo } from "react";
interface VulnerabilityReportSectionProps {
events: any[];
vulnerabilityReport: any;
expanded: boolean;
setExpanded: (v: boolean) => void;
}
@@ -92,6 +93,7 @@ function confidenceTooltip(confidence?: string): string {
export default function VulnerabilityReportSection({
events,
vulnerabilityReport,
expanded,
setExpanded,
}: VulnerabilityReportSectionProps) {
@@ -103,17 +105,18 @@ export default function VulnerabilityReportSection({
const [inventorySearch, setInventorySearch] = useState("");
const [unmatchedExpanded, setUnmatchedExpanded] = useState(false);
// Filter relevant events
const vulnEvents = useMemo(() => {
try { return events.filter(e => e.eventType === "vulnerability_report"); }
catch (e) { console.error("[VulnerabilityReportSection] Failed to filter vulnEvents", e); return []; }
}, [events]);
// Vulnerability report now comes from dedicated storage (not events).
// Wrap in an array-like structure for backwards-compatible parsing below.
const vulnReportData = useMemo(() => {
try { return vulnerabilityReport ? [{ data: vulnerabilityReport }] : []; }
catch (e) { console.error("[VulnerabilityReportSection] Failed to wrap vulnReport", e); return []; }
}, [vulnerabilityReport]);
const inventoryEvents = useMemo(() => {
try { return events.filter(e => e.eventType === "software_inventory_analysis"); }
catch (e) { console.error("[VulnerabilityReportSection] Failed to filter inventoryEvents", e); return []; }
}, [events]);
// --- Findings data (wrapped in try/catch to prevent page crash on malformed event data) ---
// --- Findings data (wrapped in try/catch to prevent page crash on malformed report data) ---
const { findings, unmatchedSoftware, totalSoftware, kevTotal, dataFreshness } = useMemo(() => { try {
const findingsMap = new Map<string, Finding>();
let unmatched: string[] = [];
@@ -121,7 +124,7 @@ export default function VulnerabilityReportSection({
let freshness = "";
let kevCount = 0;
for (const evt of vulnEvents) {
for (const evt of vulnReportData) {
const d = evt.data || {};
if (d.findings && Array.isArray(d.findings)) {
for (const f of d.findings) {
@@ -188,7 +191,7 @@ export default function VulnerabilityReportSection({
dataFreshness: freshness,
};
} catch (e) { console.error("[VulnerabilityReportSection] Failed to parse findings", e); return { findings: [], unmatchedSoftware: [], totalSoftware: 0, kevTotal: 0, dataFreshness: "" }; }
}, [vulnEvents]);
}, [vulnReportData]);
// Severity counts for header badges
const severityCounts = useMemo(() => {
@@ -244,8 +247,8 @@ export default function VulnerabilityReportSection({
// --- Delta data (new installs during enrollment) ---
const deltaItems = useMemo(() => { try {
const items: InventoryItem[] = [];
// Look in vulnerability_report events for new_installs_during_enrollment in shutdown data
for (const evt of [...vulnEvents, ...inventoryEvents]) {
// Look in vulnerability report and inventory events for new_installs_during_enrollment
for (const evt of [...vulnReportData, ...inventoryEvents]) {
const d = evt.data || {};
const newInstalls = d.new_installs_during_enrollment || d.newInstallsDuringEnrollment || [];
if (Array.isArray(newInstalls)) {
@@ -286,7 +289,7 @@ export default function VulnerabilityReportSection({
}
return items;
} catch (e) { console.error("[VulnerabilityReportSection] Failed to parse delta", e); return []; }
}, [vulnEvents, inventoryEvents, events]);
}, [vulnReportData, inventoryEvents, events]);
// --- Findings: sort & filter ---
const filteredFindings = useMemo(() => {
@@ -355,8 +358,8 @@ export default function VulnerabilityReportSection({
return <span className="ml-1">{findingsSortAsc ? "\u25B2" : "\u25BC"}</span>;
};
// Guard: no relevant events → render nothing (placed after all hooks to satisfy Rules of Hooks)
if (vulnEvents.length === 0 && inventoryEvents.length === 0) return null;
// Guard: no relevant data → render nothing (placed after all hooks to satisfy Rules of Hooks)
if (vulnReportData.length === 0 && inventoryEvents.length === 0) return null;
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
@@ -53,6 +53,7 @@ export default function SessionDetailPage() {
const [analysisResults, setAnalysisResults] = useState<RuleResult[]>([]);
const [loadingAnalysis, setLoadingAnalysis] = useState(false);
const [analysisExpanded, setAnalysisExpanded] = useState(true);
const [vulnerabilityReport, setVulnerabilityReport] = useState<any>(null);
const [vulnerabilityReportExpanded, setVulnerabilityReportExpanded] = useState(false);
const [phaseTimelineExpanded, setPhaseTimelineExpanded] = useState(true);
const [perfExpanded, setPerfExpanded] = useState(true);
@@ -187,11 +188,12 @@ export default function SessionDetailPage() {
// fetchEvents will be called after sessionTenantId is set
}, [sessionId, tenantId, galacticAdminMode, user]);
// Fetch events and analysis when we have the session's tenant ID
// Fetch events, analysis, and vulnerability report when we have the session's tenant ID
useEffect(() => {
if (sessionTenantId && sessionId) {
fetchEvents();
fetchAnalysisResults();
fetchVulnerabilityReport();
// Fetch showScriptOutput setting (best-effort)
(async () => {
try {
@@ -295,7 +297,7 @@ export default function SessionDetailPage() {
const handleVulnerabilityReportReady = (data: { sessionId: string; tenantId: string; overallRisk: string }) => {
if (data.sessionId !== sessionIdRef.current) return;
console.info(`[SessionDetail] vulnerabilityReportReady signal: risk=${data.overallRisk}`);
scheduleFetchEvents(); // vulnerability_report is stored as an event
fetchVulnerabilityReport();
};
on('eventStream', handleEventStream);
@@ -455,6 +457,25 @@ export default function SessionDetailPage() {
}
};
const fetchVulnerabilityReport = async () => {
const effectiveTenantId = resolveEffectiveTenantId();
if (!effectiveTenantId || !GUID_REGEX.test(effectiveTenantId)) return;
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/sessions/${sessionId}/vulnerability-report?tenantId=${effectiveTenantId}`,
getAccessToken
);
if (response.ok) {
const data = await response.json();
if (data.report) {
setVulnerabilityReport(data.report);
}
}
} catch (error) {
console.error("Failed to fetch vulnerability report:", error);
}
};
const markAsFailed = () => {
setShowMarkFailedConfirm(true);
};
@@ -1023,6 +1044,7 @@ export default function SessionDetailPage() {
<div id="section-vulnerability-report">
<VulnerabilityReportSection
events={events}
vulnerabilityReport={vulnerabilityReport}
expanded={vulnerabilityReportExpanded}
setExpanded={setVulnerabilityReportExpanded}
/>
@@ -904,7 +904,7 @@ export default function SettingsPage() {
</header>
{/* Main Content */}
<main className="px-4 sm:px-6 lg:px-8 py-8">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{loading ? (
<div className="bg-white rounded-lg shadow p-8 text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
@@ -4,7 +4,7 @@ export default function TermsPage() {
return (
<div className="min-h-screen bg-gray-50">
<PublicPageHeader title="Terms of Use" />
<main className="px-4 sm:px-6 lg:px-8 py-8 space-y-6">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
<div className="bg-white rounded-lg shadow p-6 space-y-4">
<p className="text-gray-700">
By using this service, you agree to the following terms:
File diff suppressed because one or more lines are too long