mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
feat store vulnerability reports in own table, changed design margin on public sites, fixed security event sequence order
This commit is contained in:
@@ -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
|
||||
|
||||
+9
-12
@@ -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";
|
||||
|
||||
+13
-11
@@ -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
|
||||
|
||||
+81
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+269
-93
@@ -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.
|
||||
|
||||
+16
-13
@@ -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
Reference in New Issue
Block a user