feat: Software Inventory Analyzer with Vulnerability Correlation

This commit is contained in:
Oliver Kieselbach
2026-03-20 14:43:58 +01:00
parent 3384c216ca
commit 26e5925672
33 changed files with 5395 additions and 30 deletions
+1
View File
@@ -109,6 +109,7 @@ tmpclaude-*-cwd
.claude/commands/ux-insights.md
.claude/commands/diag-agent-log.md
.claude/commands/diag-ime-log.md
.claude/commands/update-cpe-seed.md
.claude/scripts/query-appinsights.sh
.claude/scripts/query-table.sh
.claude/scripts/fetch-diag-zip.sh
+76
View File
@@ -0,0 +1,76 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-ID-003",
"title": "Critical Vulnerability Detected",
"description": "Fires when the vulnerability correlation finds critical-severity CVEs or CISA KEV entries in installed software. This indicates the device has software with known, actively exploited vulnerabilities.",
"severity": "critical",
"category": "vulnerability",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "single",
"baseConfidence": 90,
"conditions": [
{
"signal": "critical_vulnerabilities",
"source": "event_type",
"eventType": "vulnerability_report",
"dataField": "scan_summary.critical_cves",
"operator": "gte",
"value": "1",
"required": false
},
{
"signal": "kev_matches",
"source": "event_type",
"eventType": "vulnerability_report",
"dataField": "scan_summary.kev_matches",
"operator": "gte",
"value": "1",
"required": false
}
],
"confidenceFactors": [
{
"signal": "kev_confirmed",
"source": "event_data",
"eventType": "vulnerability_report",
"dataField": "scan_summary.kev_matches",
"operator": "gte",
"value": "1",
"weight": 10
}
],
"confidenceThreshold": 80,
"explanation": "The device has installed software with critical vulnerabilities. Software with CVEs rated CRITICAL (CVSS >= 9.0) or listed in the CISA Known Exploited Vulnerabilities catalog was detected. These vulnerabilities are actively targeted by attackers and should be remediated immediately.",
"remediation": [
{
"title": "Review vulnerability findings",
"steps": [
"Open the Vulnerability Report section in the session detail",
"Identify all software with critical or KEV-flagged vulnerabilities",
"Check if newer versions are available that address these CVEs",
"Update the affected software in your Intune app deployment"
]
},
{
"title": "Update deployment packages",
"steps": [
"Check Intune for outdated app package versions",
"Update the deployment package to the latest patched version",
"Re-deploy to affected devices"
]
}
],
"relatedDocs": [
{
"title": "CISA KEV Catalog",
"url": "https://www.cisa.gov/known-exploited-vulnerabilities-catalog"
},
{
"title": "NVD - National Vulnerability Database",
"url": "https://nvd.nist.gov/"
}
],
"tags": ["vulnerability", "cve", "kev", "critical", "security"]
}
+81
View File
@@ -1110,6 +1110,87 @@
"local-admin",
"bypass-detection"
]
},
{
"ruleId": "ANALYZE-ID-003",
"title": "Critical Vulnerability Detected",
"description": "Fires when the vulnerability correlation finds critical-severity CVEs or CISA KEV entries in installed software.",
"severity": "critical",
"category": "vulnerability",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "single",
"baseConfidence": 90,
"conditions": [
{
"signal": "critical_vulnerabilities",
"source": "event_type",
"eventType": "vulnerability_report",
"dataField": "scan_summary.critical_cves",
"operator": "gte",
"value": "1",
"required": false
},
{
"signal": "kev_matches",
"source": "event_type",
"eventType": "vulnerability_report",
"dataField": "scan_summary.kev_matches",
"operator": "gte",
"value": "1",
"required": false
}
],
"confidenceFactors": [
{
"signal": "kev_confirmed",
"source": "event_data",
"eventType": "vulnerability_report",
"dataField": "scan_summary.kev_matches",
"operator": "gte",
"value": "1",
"weight": 10
}
],
"confidenceThreshold": 80,
"explanation": "The device has installed software with critical vulnerabilities. Software with CVEs rated CRITICAL (CVSS >= 9.0) or listed in the CISA Known Exploited Vulnerabilities catalog was detected. These vulnerabilities are actively targeted by attackers and should be remediated immediately.",
"remediation": [
{
"title": "Review vulnerability findings",
"steps": [
"Open the Vulnerability Report section in the session detail",
"Identify all software with critical or KEV-flagged vulnerabilities",
"Check if newer versions are available that address these CVEs",
"Update the affected software in your Intune app deployment"
]
},
{
"title": "Update deployment packages",
"steps": [
"Check Intune for outdated app package versions",
"Update the deployment package to the latest patched version",
"Re-deploy to affected devices"
]
}
],
"relatedDocs": [
{
"title": "CISA KEV Catalog",
"url": "https://www.cisa.gov/known-exploited-vulnerabilities-catalog"
},
{
"title": "NVD - National Vulnerability Database",
"url": "https://nvd.nist.gov/"
}
],
"tags": [
"vulnerability",
"cve",
"kev",
"critical",
"security"
]
}
]
}
@@ -0,0 +1,546 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.Monitoring.Analyzers
{
/// <summary>
/// Collects installed software inventory from the Windows registry, normalizes
/// vendor/product/version strings, and emits structured events for vulnerability correlation.
///
/// Lifecycle:
/// AnalyzeAtStartup() — baseline inventory snapshot (before enrollment installs)
/// AnalyzeAtShutdown() — final inventory + delta (what was installed during enrollment)
///
/// The actual CVE/KEV correlation happens server-side after the events are ingested.
/// </summary>
public class SoftwareInventoryAnalyzer : IAgentAnalyzer
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly AgentLogger _logger;
// Captured at startup for delta detection at shutdown
private List<SoftwareEntry> _startupInventory;
public string Name => "SoftwareInventoryAnalyzer";
// Maximum items per event chunk to stay within Table Storage property size limits (~64 KB)
private const int ChunkSize = 75;
// -----------------------------------------------------------------------
// Registry paths for installed software
// -----------------------------------------------------------------------
private static readonly string[] UninstallRegistryPaths = new[]
{
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
};
private const string UninstallRegistryPathHkcu =
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
// -----------------------------------------------------------------------
// Publisher normalization map
// -----------------------------------------------------------------------
private static readonly Dictionary<string, string> PublisherMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "microsoft corporation", "microsoft" },
{ "microsoft", "microsoft" },
{ "google llc", "google" },
{ "google inc", "google" },
{ "google inc.", "google" },
{ "adobe inc.", "adobe" },
{ "adobe systems incorporated", "adobe" },
{ "adobe systems, incorporated", "adobe" },
{ "mozilla corporation", "mozilla" },
{ "mozilla", "mozilla" },
{ "oracle corporation", "oracle" },
{ "oracle", "oracle" },
{ "apple inc.", "apple" },
{ "apple inc", "apple" },
{ "dell inc.", "dell" },
{ "dell inc", "dell" },
{ "dell technologies", "dell" },
{ "hewlett-packard", "hp" },
{ "hewlett packard enterprise", "hp" },
{ "hp inc.", "hp" },
{ "hp inc", "hp" },
{ "lenovo", "lenovo" },
{ "lenovo group limited", "lenovo" },
{ "cisco systems, inc.", "cisco" },
{ "cisco systems", "cisco" },
{ "vmware, inc.", "vmware" },
{ "vmware", "vmware" },
{ "citrix systems, inc.", "citrix" },
{ "citrix", "citrix" },
{ "zoom video communications, inc.", "zoom" },
{ "slack technologies, inc.", "slack" },
{ "slack technologies, llc", "slack" },
{ "7-zip", "7-zip" },
{ "igor pavlov", "7-zip" },
{ "the 7-zip developers", "7-zip" },
{ "notepad++ team", "notepad++" },
{ "don ho", "notepad++" },
{ "putty", "putty" },
{ "simon tatham", "putty" },
{ "winscp", "winscp" },
{ "martin prikryl", "winscp" },
{ "teamviewer", "teamviewer" },
{ "teamviewer germany gmbh", "teamviewer" },
{ "fortinet technologies (canada) inc.", "fortinet" },
{ "fortinet inc", "fortinet" },
{ "palo alto networks", "paloaltonetworks" },
{ "crowdstrike, inc.", "crowdstrike" },
{ "crowdstrike inc.", "crowdstrike" },
{ "git", "git" },
{ "the git development community", "git" },
{ "python software foundation", "python" },
{ "node.js", "nodejs" },
{ "node.js foundation", "nodejs" },
{ "openjs foundation", "nodejs" },
};
// -----------------------------------------------------------------------
// Publisher suffix stripping
// -----------------------------------------------------------------------
private static readonly string[] PublisherSuffixes = new[]
{
", inc.", " inc.", " inc", ", llc", " llc", ", ltd", " ltd", " ltd.",
" corporation", " corp.", " corp", " gmbh", " ag", " se",
" co.", " co", " group", " limited"
};
// -----------------------------------------------------------------------
// Exclude patterns for filtering noise
// -----------------------------------------------------------------------
private static readonly Regex KbUpdatePattern = new Regex(@"^KB\d{6,}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly string[] ExcludeContains = new[]
{
"Language Pack",
"MUI Pack",
"Spell Checking",
"Proofing Tools",
"Microsoft .NET Host",
"Microsoft .NET Runtime",
"Microsoft .NET AppHost",
"Microsoft ASP.NET",
"Microsoft Windows Desktop Runtime",
"Additional Runtime",
"Minimum Runtime",
"Microsoft Update Health Tools",
"Update for Windows",
"Security Update for",
"Hotfix for",
};
private static readonly string[] ExcludeStartsWith = new[]
{
"vs_", // Visual Studio installer bootstrapper components
"MSVC", // MSVC redistributable sub-components (the parent is kept)
};
// -----------------------------------------------------------------------
// Regex patterns for normalization
// -----------------------------------------------------------------------
private static readonly Regex ArchitecturePattern = new Regex(
@"\s*[\(\-]\s*(?:x64|x86|64-bit|32-bit|amd64|arm64)\s*[\)]?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex TrailingVersionPattern = new Regex(
@"\s+v?\d+(?:\.\d+)*\s*$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex VersionExtractPattern = new Regex(
@"(\d+(?:\.\d+){0,3})",
RegexOptions.Compiled);
public SoftwareInventoryAnalyzer(
string sessionId,
string tenantId,
Action<EnrollmentEvent> emitEvent,
AgentLogger logger)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void AnalyzeAtStartup()
{
_logger.Info($"{Name}: Running startup analysis (baseline inventory)");
try
{
_startupInventory = CollectAndNormalize();
EmitInventoryEvents("startup", EnrollmentPhase.Unknown, _startupInventory, null);
}
catch (Exception ex)
{
_logger.Error($"{Name}: Startup analysis failed", ex);
}
}
public void AnalyzeAtShutdown()
{
_logger.Info($"{Name}: Running shutdown analysis (delta detection)");
try
{
var currentInventory = CollectAndNormalize();
var newInstalls = ComputeDelta(_startupInventory ?? new List<SoftwareEntry>(), currentInventory);
EmitInventoryEvents("shutdown", EnrollmentPhase.Complete, currentInventory, newInstalls);
}
catch (Exception ex)
{
_logger.Error($"{Name}: Shutdown analysis failed", ex);
}
}
// -----------------------------------------------------------------------
// Collection
// -----------------------------------------------------------------------
private List<SoftwareEntry> CollectAndNormalize()
{
var entries = new List<SoftwareEntry>();
// HKLM 64-bit and WOW6432Node (32-bit)
foreach (var path in UninstallRegistryPaths)
{
var source = path.Contains("WOW6432Node") ? "HKLM_32" : "HKLM_64";
CollectFromKey(Registry.LocalMachine, path, source, entries);
}
// HKCU (may be empty during OOBE when running as SYSTEM)
try
{
CollectFromKey(Registry.CurrentUser, UninstallRegistryPathHkcu, "HKCU", entries);
}
catch (Exception ex)
{
_logger.Debug($"{Name}: HKCU read skipped (expected during OOBE): {ex.Message}");
}
// Normalize all entries
foreach (var entry in entries)
{
NormalizeEntry(entry);
}
_logger.Info($"{Name}: Collected {entries.Count} software entries");
return entries;
}
private void CollectFromKey(RegistryKey rootKey, string subKeyPath, string source, List<SoftwareEntry> results)
{
try
{
using (var key = rootKey.OpenSubKey(subKeyPath, writable: false))
{
if (key == null)
{
_logger.Debug($"{Name}: Registry key not found: {source}\\{subKeyPath}");
return;
}
var subKeyNames = key.GetSubKeyNames();
foreach (var subKeyName in subKeyNames)
{
try
{
using (var subKey = key.OpenSubKey(subKeyName, writable: false))
{
if (subKey == null)
continue;
var displayName = subKey.GetValue("DisplayName")?.ToString();
var systemComponent = subKey.GetValue("SystemComponent");
var parentKeyName = subKey.GetValue("ParentKeyName")?.ToString();
if (ShouldExclude(displayName, systemComponent, parentKeyName))
continue;
results.Add(new SoftwareEntry
{
DisplayName = displayName,
DisplayVersion = subKey.GetValue("DisplayVersion")?.ToString(),
Publisher = subKey.GetValue("Publisher")?.ToString(),
InstallDate = subKey.GetValue("InstallDate")?.ToString(),
InstallLocation = subKey.GetValue("InstallLocation")?.ToString(),
UninstallString = subKey.GetValue("UninstallString")?.ToString(),
IsWindowsInstaller = Convert.ToInt32(subKey.GetValue("WindowsInstaller") ?? 0) == 1,
RegistrySource = source
});
}
}
catch (Exception ex)
{
_logger.Debug($"{Name}: Error reading subkey {subKeyName}: {ex.Message}");
}
}
}
}
catch (Exception ex)
{
_logger.Warning($"{Name}: Failed to read {source}\\{subKeyPath}: {ex.Message}");
}
}
// -----------------------------------------------------------------------
// Filtering
// -----------------------------------------------------------------------
private static bool ShouldExclude(string displayName, object systemComponent, string parentKeyName)
{
// No display name = not a user-visible application
if (string.IsNullOrWhiteSpace(displayName))
return true;
// System component flag
if (systemComponent != null)
{
try
{
if (Convert.ToInt32(systemComponent) == 1)
return true;
}
catch { /* non-integer value, ignore */ }
}
// Child component / update (has a parent)
if (!string.IsNullOrEmpty(parentKeyName))
return true;
// KB updates (Windows Updates)
if (KbUpdatePattern.IsMatch(displayName))
return true;
// Known noise patterns (contains)
foreach (var pattern in ExcludeContains)
{
if (displayName.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) >= 0)
return true;
}
// Known noise patterns (starts with)
foreach (var pattern in ExcludeStartsWith)
{
if (displayName.StartsWith(pattern, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
// -----------------------------------------------------------------------
// Normalization
// -----------------------------------------------------------------------
private void NormalizeEntry(SoftwareEntry entry)
{
entry.NormalizedPublisher = NormalizePublisher(entry.Publisher);
entry.NormalizedName = NormalizeName(entry.DisplayName);
entry.NormalizedVersion = NormalizeVersion(entry.DisplayVersion);
AssessConfidence(entry);
}
private static string NormalizePublisher(string publisher)
{
if (string.IsNullOrWhiteSpace(publisher))
return string.Empty;
var trimmed = publisher.Trim();
// Check known publisher map
if (PublisherMap.TryGetValue(trimmed, out var mapped))
return mapped;
// Fallback: lowercase + strip common suffixes
var lower = trimmed.ToLowerInvariant();
foreach (var suffix in PublisherSuffixes)
{
if (lower.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
lower = lower.Substring(0, lower.Length - suffix.Length).TrimEnd();
break; // strip only one suffix
}
}
return lower;
}
private static string NormalizeName(string displayName)
{
if (string.IsNullOrWhiteSpace(displayName))
return string.Empty;
var name = displayName.Trim().ToLowerInvariant();
// Strip architecture markers: (x64), (x86), (64-bit), - x64, etc.
name = ArchitecturePattern.Replace(name, "");
// Strip trailing version-like suffix: "chrome 134.0.6998.89" → "chrome"
name = TrailingVersionPattern.Replace(name, "");
return name.Trim();
}
private static string NormalizeVersion(string displayVersion)
{
if (string.IsNullOrWhiteSpace(displayVersion))
return string.Empty;
var match = VersionExtractPattern.Match(displayVersion.Trim());
return match.Success ? match.Groups[1].Value : displayVersion.Trim();
}
private void AssessConfidence(SoftwareEntry entry)
{
bool publisherKnown = !string.IsNullOrEmpty(entry.Publisher) &&
PublisherMap.ContainsKey(entry.Publisher.Trim());
bool versionClean = !string.IsNullOrEmpty(entry.DisplayVersion) &&
VersionExtractPattern.IsMatch(entry.DisplayVersion);
if (publisherKnown && versionClean)
entry.NormalizationConfidence = "high";
else if (publisherKnown || versionClean)
entry.NormalizationConfidence = "medium";
else
entry.NormalizationConfidence = "low";
}
// -----------------------------------------------------------------------
// Delta computation
// -----------------------------------------------------------------------
private static List<SoftwareEntry> ComputeDelta(List<SoftwareEntry> baseline, List<SoftwareEntry> current)
{
var baselineKeys = new HashSet<string>(
baseline.Select(e => MakeKey(e)),
StringComparer.OrdinalIgnoreCase);
return current.Where(e => !baselineKeys.Contains(MakeKey(e))).ToList();
}
private static string MakeKey(SoftwareEntry e)
{
return $"{e.RegistrySource}|{e.DisplayName}|{e.DisplayVersion ?? ""}";
}
// -----------------------------------------------------------------------
// Event emission (with chunking for large inventories)
// -----------------------------------------------------------------------
private void EmitInventoryEvents(
string trigger,
EnrollmentPhase phase,
List<SoftwareEntry> inventory,
List<SoftwareEntry> newInstalls)
{
int totalChunks = Math.Max(1, (int)Math.Ceiling(inventory.Count / (double)ChunkSize));
var highCount = inventory.Count(e => e.NormalizationConfidence == "high");
var medCount = inventory.Count(e => e.NormalizationConfidence == "medium");
var lowCount = inventory.Count(e => e.NormalizationConfidence == "low");
for (int i = 0; i < totalChunks; i++)
{
var chunk = inventory.Skip(i * ChunkSize).Take(ChunkSize).ToList();
var data = new Dictionary<string, object>
{
{ "triggered_at", trigger },
{ "total_count", inventory.Count },
{ "chunk_index", i },
{ "chunk_count", totalChunks },
{ "inventory", chunk.Select(SerializeEntry).ToList() }
};
// Confidence summary only in first chunk
if (i == 0)
{
data["confidence_summary"] = new Dictionary<string, object>
{
{ "high", highCount },
{ "medium", medCount },
{ "low", lowCount }
};
}
// New installs only in last chunk
if (i == totalChunks - 1 && newInstalls != null)
{
data["new_installs_during_enrollment"] = newInstalls.Select(SerializeEntry).ToList();
data["new_installs_count"] = newInstalls.Count;
}
var message = i == 0
? (trigger == "startup"
? $"{Name}: Baseline inventory ({inventory.Count} items, {highCount} high-confidence)"
: $"{Name}: Shutdown inventory ({inventory.Count} items, {newInstalls?.Count ?? 0} new during enrollment)")
: $"{Name}: Inventory chunk {i + 1}/{totalChunks}";
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "software_inventory_analysis",
Severity = EventSeverity.Info,
Source = Name,
Phase = phase,
Message = message,
Data = data
});
}
}
private static Dictionary<string, object> SerializeEntry(SoftwareEntry e)
{
return new Dictionary<string, object>
{
{ "displayName", e.DisplayName ?? "" },
{ "displayVersion", e.DisplayVersion ?? "" },
{ "publisher", e.Publisher ?? "" },
{ "installDate", e.InstallDate ?? "" },
{ "registrySource", e.RegistrySource ?? "" },
{ "normalizedPublisher", e.NormalizedPublisher ?? "" },
{ "normalizedName", e.NormalizedName ?? "" },
{ "normalizedVersion", e.NormalizedVersion ?? "" },
{ "normalizationConfidence", e.NormalizationConfidence ?? "low" }
};
}
// -----------------------------------------------------------------------
// Private model
// -----------------------------------------------------------------------
private class SoftwareEntry
{
public string DisplayName { get; set; }
public string DisplayVersion { get; set; }
public string Publisher { get; set; }
public string InstallDate { get; set; }
public string InstallLocation { get; set; }
public string UninstallString { get; set; }
public bool IsWindowsInstaller { get; set; }
public string RegistrySource { get; set; }
// Normalized fields
public string NormalizedPublisher { get; set; }
public string NormalizedName { get; set; }
public string NormalizedVersion { get; set; }
public string NormalizationConfidence { get; set; }
}
}
}
@@ -440,6 +440,21 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
_logger.Info("LocalAdminAnalyzer disabled by remote config");
}
if (analyzerConfig.EnableSoftwareInventoryAnalyzer)
{
_analyzers.Add(new SoftwareInventoryAnalyzer(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger
));
_logger.Info("SoftwareInventoryAnalyzer registered");
}
else
{
_logger.Info("SoftwareInventoryAnalyzer disabled by remote config");
}
_logger.Info($"Analyzers initialized: {_analyzers.Count} active");
}
@@ -1 +1 @@
522
532
@@ -15,6 +15,7 @@
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.0.1" />
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.29.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" Version="4.3.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<PackageReference Include="Azure.Data.Tables" Version="12.9.1" />
@@ -35,6 +36,7 @@
<EmbeddedResource Include="..\..\..\rules\dist\gather-rules.json" LogicalName="AutopilotMonitor.Functions.Rules.gather-rules.json" />
<EmbeddedResource Include="..\..\..\rules\dist\analyze-rules.json" LogicalName="AutopilotMonitor.Functions.Rules.analyze-rules.json" />
<EmbeddedResource Include="..\..\..\rules\dist\ime-log-patterns.json" LogicalName="AutopilotMonitor.Functions.Rules.ime-log-patterns.json" />
<EmbeddedResource Include="Data\cpe-mapping-seed.json" LogicalName="AutopilotMonitor.Functions.Data.cpe-mapping-seed.json" />
</ItemGroup>
</Project>
File diff suppressed because it is too large Load Diff
@@ -136,7 +136,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new AgentConfigResponse
{
ConfigVersion = 19, // AAD join with user overrides SkipUserStatusPage device-only classification
ConfigVersion = 20, // Software inventory analyzer
UploadIntervalSeconds = 10,
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
KeepLogFile = tenantConfig.KeepLogFile ?? false,
@@ -159,7 +159,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
Analyzers = new AnalyzerConfiguration
{
EnableLocalAdminAnalyzer = tenantConfig.EnableLocalAdminAnalyzer ?? true,
LocalAdminAllowedAccounts = tenantConfig.GetLocalAdminAllowedAccounts()
LocalAdminAllowedAccounts = tenantConfig.GetLocalAdminAllowedAccounts(),
EnableSoftwareInventoryAnalyzer = tenantConfig.EnableSoftwareInventoryAnalyzer ?? true
},
SendTraceEvents = tenantConfig.SendTraceEvents,
UnrestrictedMode = tenantConfig.UnrestrictedModeEnabled && tenantConfig.UnrestrictedMode,
@@ -3,6 +3,7 @@ using System.Net;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Azure.Functions.Worker.Http;
@@ -139,18 +140,22 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
await _storageService.StoreAppInstallSummaryAsync(summary.Summary);
}
// Extract geo-location data and merge into session row
// Extract geo-location data and merge into session row (fire-and-forget —
// subsequent event batches arrive frequently so the UI sees it almost immediately)
if (classification.DeviceLocationEvent?.Data != null)
{
var geoData = classification.DeviceLocationEvent.Data;
await _storageService.UpdateSessionGeoAsync(
request.TenantId,
request.SessionId,
var geoTenantId = request.TenantId;
var geoSessionId = request.SessionId;
_ = _storageService.UpdateSessionGeoAsync(
geoTenantId,
geoSessionId,
geoData.ContainsKey("country") ? geoData["country"]?.ToString() : null,
geoData.ContainsKey("region") ? geoData["region"]?.ToString() : null,
geoData.ContainsKey("city") ? geoData["city"]?.ToString() : null,
geoData.ContainsKey("loc") ? geoData["loc"]?.ToString() : null
);
).ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget UpdateSessionGeoAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
}
// Update session status based on events
@@ -170,37 +175,140 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
);
}
// Run full analysis when enrollment ends (cost-efficient: one pass over all events)
// Run full analysis when enrollment ends — fire-and-forget so the agent
// gets its HTTP 200 immediately. Rule results are stored in Table Storage and
// the UI picks them up on the next poll or via the existing "Re-Analyze" button.
// This avoids 500ms-10s+ blocking on the critical ingest path.
var newRuleResults = new List<AutopilotMonitor.Shared.Models.RuleResult>();
if (classification.CompletionEvent != null || classification.FailureEvent != null)
{
try
var ruleSessionId = request.SessionId;
var ruleTenantId = request.TenantId;
var rulePrefix = sessionPrefix;
_ = Task.Run(async () =>
{
var ruleEngine = new RuleEngine(_analyzeRuleService, _storageService, _logger);
newRuleResults = await ruleEngine.AnalyzeSessionAsync(request.TenantId, request.SessionId);
foreach (var result in newRuleResults)
try
{
await _storageService.StoreRuleResultAsync(result);
var ruleEngine = new RuleEngine(_analyzeRuleService, _storageService, _logger);
var results = await ruleEngine.AnalyzeSessionAsync(ruleTenantId, ruleSessionId);
foreach (var result in results)
{
await _storageService.StoreRuleResultAsync(result);
}
if (results.Count > 0)
{
_logger.LogInformation("{Prefix} Enrollment-end analysis (async): {Count} issue(s) detected",
rulePrefix, results.Count);
// Push SignalR notification so the UI fetches results immediately
await _signalRNotification.NotifyRuleResultsAvailableAsync(
ruleTenantId, ruleSessionId, results.Count);
}
// Increment platform stats for detected issues
if (results.Count > 0)
{
_ = _storageService.IncrementPlatformStatAsync("IssuesDetected", results.Count)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
}
}
if (newRuleResults.Count > 0)
catch (Exception ruleEx)
{
_logger.LogInformation($"{sessionPrefix} Enrollment-end analysis: {newRuleResults.Count} issue(s) detected");
_logger.LogWarning(ruleEx, "{Prefix} Enrollment-end analysis failed (async, non-fatal)", rulePrefix);
}
});
}
// 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).
var shutdownInventoryDetected = storedEvents.Any(e =>
e.EventType == AutopilotMonitor.Shared.Constants.EventTypes.SoftwareInventoryAnalysis &&
e.Data != null &&
e.Data.ContainsKey("triggered_at") &&
e.Data["triggered_at"]?.ToString() == "shutdown" &&
e.Data.ContainsKey("chunk_index") &&
Convert.ToInt32(e.Data["chunk_index"]) == 0);
if (shutdownInventoryDetected)
{
// Capture everything we need before going async (request object may be disposed)
var capturedSessionId = request.SessionId;
var capturedTenantId = request.TenantId;
var capturedPrefix = sessionPrefix;
// Merge all inventory chunks from this batch into a self-contained list
var allInventoryItems = new List<Dictionary<string, object>>();
var inventoryChunks = storedEvents
.Where(e => e.EventType == AutopilotMonitor.Shared.Constants.EventTypes.SoftwareInventoryAnalysis &&
e.Data != null &&
e.Data.ContainsKey("triggered_at") &&
e.Data["triggered_at"]?.ToString() == "shutdown" &&
e.Data.ContainsKey("inventory"))
.OrderBy(e => Convert.ToInt32(e.Data.GetValueOrDefault("chunk_index", 0)))
.ToList();
foreach (var chunk in inventoryChunks)
{
if (chunk.Data["inventory"] is System.Collections.IEnumerable items)
{
foreach (var item in items)
{
if (item is Dictionary<string, object> dict)
allInventoryItems.Add(dict);
}
}
}
catch (Exception ruleEx)
if (allInventoryItems.Count > 0)
{
_logger.LogWarning(ruleEx, $"{sessionPrefix} Enrollment-end analysis failed (non-fatal)");
_ = Task.Run(async () =>
{
try
{
var adminConfig = await _adminConfigService.GetConfigurationAsync();
if (adminConfig?.VulnerabilityCorrelationEnabled != true)
return;
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
var vulnReport = await _vulnerabilityCorrelation.CorrelateAsync(
capturedSessionId, capturedTenantId, allInventoryItems, cts.Token);
if (vulnReport != 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);
// 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
&& summary.ContainsKey("overall_risk")
? summary["overall_risk"]?.ToString() ?? "unknown"
: "unknown";
await _signalRNotification.NotifyVulnerabilityReportAvailableAsync(
capturedTenantId, capturedSessionId, overallRisk);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "{Prefix} Vulnerability correlation failed (async, non-fatal)", capturedPrefix);
}
});
}
}
// Increment platform stats (fire-and-forget, non-blocking)
// Note: IssuesDetected is now incremented inside the async rule engine task above.
_ = _storageService.IncrementPlatformStatAsync("TotalEventsProcessed", processedCount)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
if (newRuleResults.Count > 0)
_ = _storageService.IncrementPlatformStatAsync("IssuesDetected", newRuleResults.Count)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
if (classification.CompletionEvent != null)
_ = _storageService.IncrementPlatformStatAsync("SuccessfulEnrollments")
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
@@ -2,6 +2,7 @@ using System.Net;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -23,6 +24,9 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
private readonly BlockedDeviceService _blockedDeviceService;
private readonly BlockedVersionService _blockedVersionService;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly VulnerabilityCorrelationService _vulnerabilityCorrelation;
private readonly AdminConfigurationService _adminConfigService;
private readonly SignalRNotificationService _signalRNotification;
public IngestEventsFunction(
ILogger<IngestEventsFunction> logger,
@@ -35,7 +39,10 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
WebhookNotificationService webhookNotificationService,
BlockedDeviceService blockedDeviceService,
BlockedVersionService blockedVersionService,
BootstrapSessionService bootstrapSessionService)
BootstrapSessionService bootstrapSessionService,
VulnerabilityCorrelationService vulnerabilityCorrelation,
AdminConfigurationService adminConfigService,
SignalRNotificationService signalRNotification)
{
_logger = logger;
_storageService = storageService;
@@ -48,6 +55,9 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_blockedDeviceService = blockedDeviceService;
_blockedVersionService = blockedVersionService;
_bootstrapSessionService = bootstrapSessionService;
_vulnerabilityCorrelation = vulnerabilityCorrelation;
_adminConfigService = adminConfigService;
_signalRNotification = signalRNotification;
}
[Function("IngestEvents")]
@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Azure.Data.Tables;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
/// <summary>
/// Admin endpoint that aggregates frequently unmatched software across all sessions.
/// Used by the /update-cpe-seed skill and Admin UI to identify gaps in the CPE mapping.
/// </summary>
public class GetUnmatchedSoftwareFunction
{
private readonly ILogger<GetUnmatchedSoftwareFunction> _logger;
private readonly TableStorageService _storageService;
private readonly GalacticAdminService _galacticAdminService;
public GetUnmatchedSoftwareFunction(
ILogger<GetUnmatchedSoftwareFunction> logger,
TableStorageService storageService,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_storageService = storageService;
_galacticAdminService = galacticAdminService;
}
[Function("GetUnmatchedSoftware")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "admin/vulnerability/unmatched-software")] HttpRequestData req)
{
try
{
// Galactic Admin only
var authHeader = req.Headers.Contains("Authorization")
? req.Headers.GetValues("Authorization").FirstOrDefault()
: null;
if (string.IsNullOrEmpty(authHeader))
{
var unauthorized = req.CreateResponse(HttpStatusCode.Unauthorized);
return unauthorized;
}
// Scan recent vulnerability_report events 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'",
maxPerPage: 200);
await foreach (var entity in recentEvents)
{
var dataJson = entity.GetString("Data");
if (string.IsNullOrEmpty(dataJson)) continue;
try
{
var data = JsonSerializer.Deserialize<Dictionary<string, object>>(dataJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (data == null || !data.ContainsKey("unmatched_software")) continue;
var unmatchedJson = data["unmatched_software"]?.ToString();
if (string.IsNullOrEmpty(unmatchedJson)) continue;
var unmatchedList = JsonSerializer.Deserialize<List<Dictionary<string, string>>>(unmatchedJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (unmatchedList == null) continue;
foreach (var item in unmatchedList)
{
var softwareName = item.GetValueOrDefault("softwareName", "");
if (string.IsNullOrWhiteSpace(softwareName)) continue;
var key = softwareName.ToLowerInvariant();
if (!unmatchedAgg.ContainsKey(key))
{
unmatchedAgg[key] = new UnmatchedSoftwareAgg
{
SoftwareName = softwareName,
Publisher = item.GetValueOrDefault("publisher", ""),
Frequency = 0,
LastSeenAt = entity.GetDateTimeOffset("Timestamp")?.UtcDateTime ?? DateTime.UtcNow,
ExampleSessionId = entity.PartitionKey?.Split('_').LastOrDefault() ?? ""
};
}
unmatchedAgg[key].Frequency++;
var ts = entity.GetDateTimeOffset("Timestamp")?.UtcDateTime ?? DateTime.MinValue;
if (ts > unmatchedAgg[key].LastSeenAt)
{
unmatchedAgg[key].LastSeenAt = ts;
unmatchedAgg[key].ExampleSessionId = entity.PartitionKey?.Split('_').LastOrDefault() ?? "";
}
}
}
catch { /* skip malformed events */ }
}
var result = unmatchedAgg.Values
.OrderByDescending(x => x.Frequency)
.Take(100)
.ToList();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { software = result, total = result.Count });
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get unmatched software report");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteStringAsync("Internal server error");
return errorResponse;
}
}
private class UnmatchedSoftwareAgg
{
public string SoftwareName { get; set; }
public string Publisher { get; set; }
public int Frequency { get; set; }
public DateTime LastSeenAt { get; set; }
public string ExampleSessionId { get; set; }
}
}
}
@@ -0,0 +1,70 @@
using System;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Vulnerability;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
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)
/// </summary>
public class VulnerabilityDataSyncFunction
{
private readonly KevDataService _kevService;
private readonly AdminConfigurationService _adminConfigService;
private readonly ILogger<VulnerabilityDataSyncFunction> _logger;
public VulnerabilityDataSyncFunction(
KevDataService kevService,
AdminConfigurationService adminConfigService,
ILogger<VulnerabilityDataSyncFunction> logger)
{
_kevService = kevService;
_adminConfigService = adminConfigService;
_logger = logger;
}
/// <summary>
/// Runs daily at 03:00 UTC to refresh vulnerability data caches.
/// </summary>
[Function("VulnerabilityDataSync")]
public async Task Run(
[TimerTrigger("0 0 3 * * *")] TimerInfo timer)
{
_logger.LogInformation("Starting vulnerability data sync...");
try
{
// 1. Refresh CISA KEV catalog (small JSON, fast)
await _kevService.RefreshAsync();
_logger.LogInformation("KEV catalog refreshed: {Count} entries", _kevService.CatalogSize);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "KEV catalog refresh failed");
}
// 2. Update sync timestamp in AdminConfiguration
try
{
var config = await _adminConfigService.GetConfigurationAsync();
if (config != null)
{
config.VulnerabilityDataLastSyncUtc = DateTime.UtcNow.ToString("o");
await _adminConfigService.SaveConfigurationAsync(config);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update vulnerability sync timestamp");
}
_logger.LogInformation("Vulnerability data sync completed");
}
}
}
@@ -118,6 +118,15 @@ builder.Services.AddSingleton<BlockedVersionService>();
builder.Services.AddSingleton<SessionReportService>();
builder.Services.AddSingleton<BootstrapSessionService>();
// Programmatic SignalR push for background tasks (rule engine, vulnerability correlation)
builder.Services.AddSingleton<SignalRNotificationService>();
// Vulnerability correlation services
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.NvdApiClient>();
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.KevDataService>();
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.MsrcApiClient>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelationService>();
// Register agent Function classes so bootstrap wrappers can inject them for code reuse
builder.Services.AddSingleton<IngestEventsFunction>();
builder.Services.AddSingleton<RegisterSessionFunction>();
@@ -185,7 +185,10 @@ namespace AutopilotMonitor.Functions.Services
{ "CollectorIdleTimeoutMinutes", config.CollectorIdleTimeoutMinutes },
{ "MaxSessionWindowHours", config.MaxSessionWindowHours },
{ "MaintenanceBlockDurationHours", config.MaintenanceBlockDurationHours },
{ "DiagnosticsGlobalLogPathsJson", config.DiagnosticsGlobalLogPathsJson }
{ "DiagnosticsGlobalLogPathsJson", config.DiagnosticsGlobalLogPathsJson },
{ "NvdApiKey", config.NvdApiKey },
{ "VulnerabilityCorrelationEnabled", config.VulnerabilityCorrelationEnabled },
{ "VulnerabilityDataLastSyncUtc", config.VulnerabilityDataLastSyncUtc }
};
return entity;
@@ -204,7 +207,10 @@ namespace AutopilotMonitor.Functions.Services
CollectorIdleTimeoutMinutes = entity.GetInt32("CollectorIdleTimeoutMinutes") ?? 15,
MaxSessionWindowHours = entity.GetInt32("MaxSessionWindowHours") ?? 24,
MaintenanceBlockDurationHours = entity.GetInt32("MaintenanceBlockDurationHours") ?? 12,
DiagnosticsGlobalLogPathsJson = entity.GetString("DiagnosticsGlobalLogPathsJson")
DiagnosticsGlobalLogPathsJson = entity.GetString("DiagnosticsGlobalLogPathsJson"),
NvdApiKey = entity.GetString("NvdApiKey"),
VulnerabilityCorrelationEnabled = entity.GetBoolean("VulnerabilityCorrelationEnabled") ?? true,
VulnerabilityDataLastSyncUtc = entity.GetString("VulnerabilityDataLastSyncUtc")
};
}
}
@@ -0,0 +1,119 @@
using System;
using System.Threading.Tasks;
using Microsoft.Azure.SignalR.Management;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Programmatic SignalR message sender for background tasks that run outside
/// of Azure Function output bindings (e.g., async rule engine, vulnerability correlation).
/// Uses the Azure SignalR Management SDK to send messages to connected clients.
/// </summary>
public class SignalRNotificationService : IDisposable
{
private readonly ILogger<SignalRNotificationService> _logger;
private readonly ServiceManager _serviceManager;
private IServiceHubContext? _hubContext;
private static readonly SemaphoreSlim _initLock = new(1, 1);
private const string HubName = "autopilotmonitor";
public SignalRNotificationService(IConfiguration configuration, ILogger<SignalRNotificationService> logger)
{
_logger = logger;
var connectionString = configuration["AzureSignalRConnectionString"]
?? throw new InvalidOperationException("AzureSignalRConnectionString is not configured");
_serviceManager = new ServiceManagerBuilder()
.WithOptions(o =>
{
o.ConnectionString = connectionString;
o.ServiceTransportType = ServiceTransportType.Persistent;
})
.BuildServiceManager();
}
/// <summary>
/// Notify a session's connected clients that new rule analysis results are available.
/// Sends to the session-specific SignalR group so only the relevant session detail page reacts.
/// </summary>
public async Task NotifyRuleResultsAvailableAsync(string tenantId, string sessionId, int resultCount)
{
try
{
var hub = await GetHubContextAsync();
var groupName = $"session-{tenantId}-{sessionId}";
await hub.Clients.Group(groupName).SendCoreAsync("ruleResultsReady", new object[]
{
new
{
sessionId,
tenantId,
ruleResultCount = resultCount,
timestamp = DateTime.UtcNow.ToString("o")
}
});
_logger.LogDebug("Sent ruleResultsReady to group {Group} ({Count} results)", groupName, resultCount);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send ruleResultsReady notification for session {SessionId}", sessionId);
}
}
/// <summary>
/// Notify a session's connected clients that vulnerability correlation results are available.
/// </summary>
public async Task NotifyVulnerabilityReportAvailableAsync(string tenantId, string sessionId, string overallRisk)
{
try
{
var hub = await GetHubContextAsync();
var groupName = $"session-{tenantId}-{sessionId}";
await hub.Clients.Group(groupName).SendCoreAsync("vulnerabilityReportReady", new object[]
{
new
{
sessionId,
tenantId,
overallRisk,
timestamp = DateTime.UtcNow.ToString("o")
}
});
_logger.LogDebug("Sent vulnerabilityReportReady to group {Group} (risk={Risk})", groupName, overallRisk);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send vulnerabilityReportReady notification for session {SessionId}", sessionId);
}
}
private async Task<IServiceHubContext> GetHubContextAsync()
{
if (_hubContext != null) return _hubContext;
await _initLock.WaitAsync();
try
{
_hubContext ??= await _serviceManager.CreateHubContextAsync(HubName, default);
return _hubContext;
}
finally
{
_initLock.Release();
}
}
public void Dispose()
{
(_hubContext as IDisposable)?.Dispose();
_serviceManager?.Dispose();
}
}
}
@@ -33,6 +33,15 @@ namespace AutopilotMonitor.Functions.Services
_tableServiceClient = new TableServiceClient(connectionString);
}
/// <summary>
/// Returns a TableClient for the specified table name.
/// Used by services that need direct table access (e.g. VulnerabilityCorrelationService).
/// </summary>
public TableClient GetTableClient(string tableName)
{
return _tableServiceClient.GetTableClient(tableName);
}
/// <summary>
/// Initializes all Azure Table Storage tables.
/// This method is idempotent and safe to call multiple times.
@@ -239,6 +239,7 @@ namespace AutopilotMonitor.Functions.Services
{ "AgentMaxLifetimeMinutes", config.AgentMaxLifetimeMinutes },
{ "SendTraceEvents", config.SendTraceEvents },
{ "EnableLocalAdminAnalyzer", config.EnableLocalAdminAnalyzer },
{ "EnableSoftwareInventoryAnalyzer", config.EnableSoftwareInventoryAnalyzer },
{ "LocalAdminAllowedAccountsJson", config.LocalAdminAllowedAccountsJson },
{ "BootstrapTokenEnabled", config.BootstrapTokenEnabled },
{ "UnrestrictedModeEnabled", config.UnrestrictedModeEnabled },
@@ -301,6 +302,7 @@ namespace AutopilotMonitor.Functions.Services
AgentMaxLifetimeMinutes = entity.GetInt32("AgentMaxLifetimeMinutes"),
SendTraceEvents = entity.GetBoolean("SendTraceEvents") ?? true,
EnableLocalAdminAnalyzer = entity.GetBoolean("EnableLocalAdminAnalyzer"),
EnableSoftwareInventoryAnalyzer = entity.GetBoolean("EnableSoftwareInventoryAnalyzer"),
LocalAdminAllowedAccountsJson = entity.GetString("LocalAdminAllowedAccountsJson"),
BootstrapTokenEnabled = entity.GetBoolean("BootstrapTokenEnabled") ?? false,
UnrestrictedModeEnabled = entity.GetBoolean("UnrestrictedModeEnabled") ?? false,
@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services.Vulnerability
{
/// <summary>
/// Downloads and caches the CISA Known Exploited Vulnerabilities (KEV) catalog.
/// The catalog is ~500 KB JSON and is held entirely in memory for fast lookup.
/// Auto-refreshes when the cache is older than 24 hours.
/// </summary>
public class KevDataService
{
private readonly HttpClient _http;
private readonly ILogger<KevDataService> _logger;
private Dictionary<string, KevEntry> _kevByCveId = new Dictionary<string, KevEntry>(StringComparer.OrdinalIgnoreCase);
private DateTime _lastRefresh = DateTime.MinValue;
private static readonly SemaphoreSlim _refreshLock = new SemaphoreSlim(1, 1);
private const string KevUrl = "https://www.cisa.gov/sites/default/files/feeds/known-exploited-vulnerabilities.json";
private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(24);
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
public KevDataService(HttpClient http, ILogger<KevDataService> logger)
{
_http = http ?? throw new ArgumentNullException(nameof(http));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_http.Timeout = TimeSpan.FromSeconds(60);
}
/// <summary>
/// Check if a CVE is in the KEV catalog. Auto-refreshes if cache is stale.
/// Returns the KevEntry if found, null otherwise.
/// </summary>
public async Task<KevEntry> CheckKevAsync(string cveId)
{
await EnsureCacheAsync();
if (string.IsNullOrWhiteSpace(cveId))
return null;
_kevByCveId.TryGetValue(cveId, out var entry);
return entry;
}
/// <summary>
/// Get the total number of entries in the cached KEV catalog.
/// </summary>
public int CatalogSize => _kevByCveId.Count;
/// <summary>
/// Last time the KEV catalog was successfully refreshed.
/// </summary>
public DateTime LastRefresh => _lastRefresh;
/// <summary>
/// Force refresh the KEV catalog from CISA.
/// </summary>
public async Task RefreshAsync()
{
await _refreshLock.WaitAsync();
try
{
_logger.LogInformation("Refreshing CISA KEV catalog from {Url}", KevUrl);
using var request = new HttpRequestMessage(HttpMethod.Get, KevUrl);
request.Headers.Add("User-Agent", "AutopilotMonitor/1.0");
var response = await _http.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var catalog = JsonSerializer.Deserialize<KevCatalog>(json, JsonOptions);
if (catalog?.Vulnerabilities == null || catalog.Vulnerabilities.Count == 0)
{
_logger.LogWarning("KEV catalog returned empty or null vulnerabilities list");
return;
}
var newIndex = new Dictionary<string, KevEntry>(catalog.Vulnerabilities.Count, StringComparer.OrdinalIgnoreCase);
foreach (var vuln in catalog.Vulnerabilities)
{
if (!string.IsNullOrWhiteSpace(vuln.CveID))
{
newIndex[vuln.CveID] = vuln;
}
}
_kevByCveId = newIndex;
_lastRefresh = DateTime.UtcNow;
_logger.LogInformation("KEV catalog refreshed: {Count} entries, catalog version {Version}",
newIndex.Count, catalog.CatalogVersion ?? "unknown");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh KEV catalog");
}
finally
{
_refreshLock.Release();
}
}
private async Task EnsureCacheAsync()
{
if (DateTime.UtcNow - _lastRefresh > CacheTtl)
{
await RefreshAsync();
}
}
}
// -----------------------------------------------------------------------
// KEV Catalog Models
// -----------------------------------------------------------------------
public class KevCatalog
{
public string Title { get; set; }
public string CatalogVersion { get; set; }
public string DateReleased { get; set; }
public int Count { get; set; }
public List<KevEntry> Vulnerabilities { get; set; } = new List<KevEntry>();
}
public class KevEntry
{
public string CveID { get; set; }
public string VendorProject { get; set; }
public string Product { get; set; }
public string VulnerabilityName { get; set; }
public string DateAdded { get; set; }
public string ShortDescription { get; set; }
public string RequiredAction { get; set; }
public string DueDate { get; set; }
public string KnownRansomwareCampaignUse { get; set; }
}
}
@@ -0,0 +1,221 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services.Vulnerability
{
/// <summary>
/// Client for the MSRC Security Update Guide API.
/// Provides Microsoft-specific vulnerability data (CVEs for Office, Edge, Windows components, etc.).
/// </summary>
public class MsrcApiClient
{
private readonly HttpClient _http;
private readonly ILogger<MsrcApiClient> _logger;
private const string MsrcBaseUrl = "https://api.msrc.microsoft.com";
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public MsrcApiClient(HttpClient http, ILogger<MsrcApiClient> logger)
{
_http = http ?? throw new ArgumentNullException(nameof(http));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_http.Timeout = TimeSpan.FromSeconds(30);
}
/// <summary>
/// Get the list of recent MSRC security updates (last 6 months).
/// Each update represents a monthly Patch Tuesday release.
/// </summary>
public async Task<List<MsrcUpdateSummary>> GetRecentUpdatesAsync(CancellationToken ct = default)
{
try
{
var url = $"{MsrcBaseUrl}/cvrf/v3.0/updates";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("Accept", "application/json");
var response = await _http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("MSRC updates API returned {StatusCode}", (int)response.StatusCode);
return new List<MsrcUpdateSummary>();
}
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<MsrcUpdatesResponse>(json, JsonOptions);
return result?.Value ?? new List<MsrcUpdateSummary>();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to fetch MSRC updates list");
return new List<MsrcUpdateSummary>();
}
}
/// <summary>
/// Get the CVRF document for a specific monthly update (e.g., "2025-Mar").
/// Contains detailed vulnerability information per Microsoft product.
/// </summary>
public async Task<MsrcCvrfDocument> GetCvrfAsync(string documentId, CancellationToken ct = default)
{
try
{
var url = $"{MsrcBaseUrl}/cvrf/v3.0/cvrf/{Uri.EscapeDataString(documentId)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("Accept", "application/json");
var response = await _http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("MSRC CVRF API returned {StatusCode} for {DocumentId}",
(int)response.StatusCode, documentId);
return null;
}
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<MsrcCvrfDocument>(json, JsonOptions);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to fetch MSRC CVRF document {DocumentId}", documentId);
return null;
}
}
}
// -----------------------------------------------------------------------
// MSRC API Response Models
// -----------------------------------------------------------------------
public class MsrcUpdatesResponse
{
public List<MsrcUpdateSummary> Value { get; set; } = new List<MsrcUpdateSummary>();
}
public class MsrcUpdateSummary
{
public string ID { get; set; }
public string Alias { get; set; }
public string DocumentTitle { get; set; }
public string InitialReleaseDate { get; set; }
public string CurrentReleaseDate { get; set; }
public string CvrfUrl { get; set; }
}
public class MsrcCvrfDocument
{
public MsrcDocumentTitle DocumentTitle { get; set; }
public MsrcDocumentTracking DocumentTracking { get; set; }
public MsrcProductTree ProductTree { get; set; }
public List<MsrcVulnerability> Vulnerability { get; set; } = new List<MsrcVulnerability>();
}
public class MsrcDocumentTitle
{
public string Value { get; set; }
}
public class MsrcDocumentTracking
{
public MsrcRevisionHistory Identification { get; set; }
}
public class MsrcRevisionHistory
{
public MsrcDocumentId ID { get; set; }
}
public class MsrcDocumentId
{
public string Value { get; set; }
}
public class MsrcProductTree
{
public List<MsrcBranch> Branch { get; set; } = new List<MsrcBranch>();
public List<MsrcFullProductName> FullProductName { get; set; } = new List<MsrcFullProductName>();
}
public class MsrcBranch
{
public string Type { get; set; }
public string Name { get; set; }
public List<MsrcBranch> Items { get; set; } = new List<MsrcBranch>();
}
public class MsrcFullProductName
{
public string ProductID { get; set; }
public string Value { get; set; }
}
public class MsrcVulnerability
{
public MsrcOrdinal Ordinal { get; set; }
public MsrcCveTitle Title { get; set; }
public List<MsrcNote> Notes { get; set; } = new List<MsrcNote>();
public MsrcCveNumber CVE { get; set; }
public List<MsrcProductStatus> ProductStatuses { get; set; } = new List<MsrcProductStatus>();
public List<MsrcThreat> Threats { get; set; } = new List<MsrcThreat>();
public List<MsrcCvssScoreSet> CVSSScoreSets { get; set; } = new List<MsrcCvssScoreSet>();
}
public class MsrcOrdinal
{
public string Value { get; set; }
}
public class MsrcCveTitle
{
public string Value { get; set; }
}
public class MsrcCveNumber
{
public string Value { get; set; }
}
public class MsrcNote
{
public string Title { get; set; }
public string Type { get; set; }
public string Value { get; set; }
}
public class MsrcProductStatus
{
public string Type { get; set; }
public List<string> ProductID { get; set; } = new List<string>();
}
public class MsrcThreat
{
public string Type { get; set; }
public MsrcThreatDescription Description { get; set; }
public List<string> ProductID { get; set; } = new List<string>();
}
public class MsrcThreatDescription
{
public string Value { get; set; }
}
public class MsrcCvssScoreSet
{
public double BaseScore { get; set; }
public string TemporalScore { get; set; }
public string Vector { get; set; }
public List<string> ProductID { get; set; } = new List<string>();
}
}
@@ -0,0 +1,203 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services.Vulnerability
{
/// <summary>
/// Typed HttpClient for the NVD API 2.0.
/// Handles rate limiting (5 req/30s without key, 50 req/30s with key).
/// </summary>
public class NvdApiClient
{
private readonly HttpClient _http;
private readonly AdminConfigurationService _adminConfig;
private readonly ILogger<NvdApiClient> _logger;
private static readonly SemaphoreSlim _rateLimiter = new SemaphoreSlim(1, 1);
private static DateTime _lastRequestTime = DateTime.MinValue;
private const string NvdCveUrl = "https://services.nvd.nist.gov/rest/json/cves/2.0";
private const string NvdCpeUrl = "https://services.nvd.nist.gov/rest/json/cpes/2.0";
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public NvdApiClient(HttpClient http, AdminConfigurationService adminConfig, ILogger<NvdApiClient> logger)
{
_http = http ?? throw new ArgumentNullException(nameof(http));
_adminConfig = adminConfig ?? throw new ArgumentNullException(nameof(adminConfig));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_http.Timeout = TimeSpan.FromSeconds(30);
}
/// <summary>
/// Search CVEs for a specific CPE name. Returns CVEs with version range configurations.
/// </summary>
public async Task<NvdCveResponse> SearchCvesByCpeAsync(string cpeName, CancellationToken ct = default)
{
var url = $"{NvdCveUrl}?cpeName={Uri.EscapeDataString(cpeName)}&resultsPerPage=100";
return await ExecuteWithRateLimitAsync<NvdCveResponse>(url, ct);
}
/// <summary>
/// Search CPE dictionary by keyword (vendor + product).
/// Used when seed mapping doesn't cover a product.
/// </summary>
public async Task<NvdCpeResponse> SearchCpeAsync(string keywordSearch, CancellationToken ct = default)
{
var url = $"{NvdCpeUrl}?keywordSearch={Uri.EscapeDataString(keywordSearch)}&resultsPerPage=20";
return await ExecuteWithRateLimitAsync<NvdCpeResponse>(url, ct);
}
private async Task<T> ExecuteWithRateLimitAsync<T>(string url, CancellationToken ct) where T : class, new()
{
await _rateLimiter.WaitAsync(ct);
try
{
// Determine rate limit delay based on API key presence
var adminConfig = await _adminConfig.GetConfigurationAsync();
bool hasApiKey = !string.IsNullOrWhiteSpace(adminConfig?.NvdApiKey);
int delayMs = hasApiKey ? 600 : 6000; // 50/30s vs 5/30s
var elapsed = (DateTime.UtcNow - _lastRequestTime).TotalMilliseconds;
if (elapsed < delayMs)
{
await Task.Delay((int)(delayMs - elapsed), ct);
}
using var request = new HttpRequestMessage(HttpMethod.Get, url);
if (hasApiKey)
{
request.Headers.Add("apiKey", adminConfig.NvdApiKey);
}
request.Headers.Add("User-Agent", "AutopilotMonitor/1.0");
_lastRequestTime = DateTime.UtcNow;
var response = await _http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("NVD API returned {StatusCode} for {Url}", (int)response.StatusCode, url);
return new T();
}
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(json, JsonOptions) ?? new T();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "NVD API request failed for {Url}", url);
return new T();
}
finally
{
_rateLimiter.Release();
}
}
}
// -----------------------------------------------------------------------
// NVD API Response Models
// -----------------------------------------------------------------------
public class NvdCveResponse
{
public int TotalResults { get; set; }
public List<NvdVulnerabilityItem> Vulnerabilities { get; set; } = new List<NvdVulnerabilityItem>();
}
public class NvdVulnerabilityItem
{
public NvdCve Cve { get; set; }
}
public class NvdCve
{
public string Id { get; set; }
public List<NvdDescription> Descriptions { get; set; } = new List<NvdDescription>();
public NvdMetrics Metrics { get; set; }
public List<NvdConfiguration> Configurations { get; set; } = new List<NvdConfiguration>();
public string Published { get; set; }
public string LastModified { get; set; }
}
public class NvdDescription
{
public string Lang { get; set; }
public string Value { get; set; }
}
public class NvdMetrics
{
public List<NvdCvssMetricV31> CvssMetricV31 { get; set; } = new List<NvdCvssMetricV31>();
public List<NvdCvssMetricV31> CvssMetricV30 { get; set; } = new List<NvdCvssMetricV31>();
}
public class NvdCvssMetricV31
{
public NvdCvssData CvssData { get; set; }
}
public class NvdCvssData
{
public double BaseScore { get; set; }
public string BaseSeverity { get; set; }
public string VectorString { get; set; }
}
public class NvdConfiguration
{
public List<NvdNode> Nodes { get; set; } = new List<NvdNode>();
}
public class NvdNode
{
public string Operator { get; set; }
public bool Negate { get; set; }
public List<NvdCpeMatch> CpeMatch { get; set; } = new List<NvdCpeMatch>();
}
public class NvdCpeMatch
{
public bool Vulnerable { get; set; }
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; }
}
// CPE search response
public class NvdCpeResponse
{
public int TotalResults { get; set; }
public List<NvdCpeProduct> Products { get; set; } = new List<NvdCpeProduct>();
}
public class NvdCpeProduct
{
public NvdCpeEntry Cpe { get; set; }
}
public class NvdCpeEntry
{
public string CpeName { get; set; }
public string CpeNameId { get; set; }
public List<NvdCpeTitle> Titles { get; set; } = new List<NvdCpeTitle>();
}
public class NvdCpeTitle
{
public string Title { get; set; }
public string Lang { get; set; }
}
}
@@ -0,0 +1,87 @@
using System;
using System.Linq;
namespace AutopilotMonitor.Functions.Services.Vulnerability
{
/// <summary>
/// Semantic version comparison for CVE version range evaluation.
/// Handles partial versions (e.g., "1.2" vs "1.2.3") by padding with zeros.
/// </summary>
public static class VersionComparer
{
/// <summary>
/// Compare two version strings numerically component by component.
/// Returns: negative if v1 &lt; v2, 0 if equal, positive if v1 &gt; v2.
/// </summary>
public static int Compare(string version1, string version2)
{
if (string.IsNullOrWhiteSpace(version1) && string.IsNullOrWhiteSpace(version2))
return 0;
if (string.IsNullOrWhiteSpace(version1))
return -1;
if (string.IsNullOrWhiteSpace(version2))
return 1;
var parts1 = version1.Split('.').Select(ParsePart).ToArray();
var parts2 = version2.Split('.').Select(ParsePart).ToArray();
int maxLen = Math.Max(parts1.Length, parts2.Length);
for (int i = 0; i < maxLen; i++)
{
long p1 = i < parts1.Length ? parts1[i] : 0;
long p2 = i < parts2.Length ? parts2[i] : 0;
if (p1 < p2) return -1;
if (p1 > p2) return 1;
}
return 0;
}
/// <summary>
/// Check if installedVersion falls within the affected range defined by NVD CPE match.
/// </summary>
public static bool IsVersionAffected(
string installedVersion,
string versionStartIncluding,
string versionStartExcluding,
string versionEndIncluding,
string versionEndExcluding)
{
if (string.IsNullOrWhiteSpace(installedVersion))
return false;
// Check start boundary
if (!string.IsNullOrWhiteSpace(versionStartIncluding))
{
if (Compare(installedVersion, versionStartIncluding) < 0)
return false;
}
if (!string.IsNullOrWhiteSpace(versionStartExcluding))
{
if (Compare(installedVersion, versionStartExcluding) <= 0)
return false;
}
// Check end boundary
if (!string.IsNullOrWhiteSpace(versionEndIncluding))
{
if (Compare(installedVersion, versionEndIncluding) > 0)
return false;
}
if (!string.IsNullOrWhiteSpace(versionEndExcluding))
{
if (Compare(installedVersion, versionEndExcluding) >= 0)
return false;
}
return true;
}
private static long ParsePart(string part)
{
// Strip non-numeric suffixes (e.g., "89beta" → 89)
var numericPart = new string(part.TakeWhile(char.IsDigit).ToArray());
return long.TryParse(numericPart, out var result) ? result : 0;
}
}
}
@@ -0,0 +1,517 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
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;
namespace AutopilotMonitor.Functions.Services.Vulnerability
{
public class VulnerabilityCorrelationService
{
private readonly TableStorageService _storage;
private readonly NvdApiClient _nvdApi;
private readonly KevDataService _kevService;
private readonly MsrcApiClient _msrcApi;
private readonly AdminConfigurationService _adminConfig;
private readonly ILogger<VulnerabilityCorrelationService> _logger;
private List<CpeMappingEntry> _cpeMappings;
private static readonly SemaphoreSlim _initLock = new SemaphoreSlim(1, 1);
private static bool _seedLoaded = false;
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public VulnerabilityCorrelationService(
TableStorageService storage,
NvdApiClient nvdApi,
KevDataService kevService,
MsrcApiClient msrcApi,
AdminConfigurationService adminConfig,
ILogger<VulnerabilityCorrelationService> logger)
{
_storage = storage;
_nvdApi = nvdApi;
_kevService = kevService;
_msrcApi = msrcApi;
_adminConfig = adminConfig;
_logger = logger;
}
public async Task<EnrollmentEvent> CorrelateAsync(
string sessionId,
string tenantId,
List<Dictionary<string, object>> inventoryItems,
CancellationToken ct = default)
{
await EnsureMappingsLoadedAsync();
var findings = new List<Dictionary<string, object>>();
var unmatchedSoftware = new List<Dictionary<string, object>>();
int matchedCount = 0;
int totalCves = 0;
int criticalCves = 0;
int highCves = 0;
int mediumCves = 0;
int lowCves = 0;
int kevMatches = 0;
string overallRisk = "none";
foreach (var item in inventoryItems)
{
if (ct.IsCancellationRequested) break;
var normalizedVendor = item.ContainsKey("normalizedPublisher") ? item["normalizedPublisher"]?.ToString() ?? "" : "";
var normalizedName = item.ContainsKey("normalizedName") ? item["normalizedName"]?.ToString() ?? "" : "";
var installedVersion = item.ContainsKey("normalizedVersion") ? item["normalizedVersion"]?.ToString() ?? "" : "";
var displayName = item.ContainsKey("displayName") ? item["displayName"]?.ToString() ?? "" : "";
// Find CPE mapping
var cpeMapping = FindCpeMapping(normalizedVendor, normalizedName, displayName);
if (cpeMapping == null)
{
unmatchedSoftware.Add(new Dictionary<string, object>
{
{ "softwareName", displayName },
{ "installedVersion", installedVersion },
{ "publisher", item.ContainsKey("publisher") ? item["publisher"]?.ToString() ?? "" : "" },
{ "reason", "no_cpe_match" }
});
continue;
}
matchedCount++;
var cpeUri = cpeMapping.CpeUri;
// Look up CVEs
List<Dictionary<string, object>> vulns;
try
{
vulns = await GetVulnerabilitiesForCpeAsync(cpeUri, installedVersion, ct);
}
catch (OperationCanceledException)
{
break;
}
if (vulns.Count == 0)
continue;
// Check KEV for each CVE
foreach (var vuln in vulns)
{
var cveId = vuln["cveId"]?.ToString();
var kevEntry = await _kevService.CheckKevAsync(cveId);
if (kevEntry != null)
{
vuln["isKev"] = true;
vuln["kevDueDate"] = kevEntry.DueDate ?? "";
vuln["kevRequiredAction"] = kevEntry.RequiredAction ?? "";
vuln["knownRansomware"] = kevEntry.KnownRansomwareCampaignUse ?? "";
kevMatches++;
}
else
{
vuln["isKev"] = false;
}
}
// Count severities
foreach (var vuln in vulns)
{
totalCves++;
var severity = vuln.ContainsKey("cvssSeverity") ? vuln["cvssSeverity"]?.ToString() ?? "" : "";
switch (severity.ToUpperInvariant())
{
case "CRITICAL": criticalCves++; break;
case "HIGH": highCves++; break;
case "MEDIUM": mediumCves++; break;
case "LOW": lowCves++; break;
}
}
// Determine risk level for this software
string riskLevel = "none";
bool hasKev = vulns.Any(v => v.ContainsKey("isKev") && (bool)v["isKev"]);
double maxCvss = vulns.Max(v => v.ContainsKey("cvssScore") ? Convert.ToDouble(v["cvssScore"]) : 0);
if (hasKev || maxCvss >= 9.0) riskLevel = "critical";
else if (maxCvss >= 7.0) riskLevel = "high";
else if (maxCvss >= 4.0) riskLevel = "medium";
else if (maxCvss > 0) riskLevel = "low";
findings.Add(new Dictionary<string, object>
{
{ "softwareName", displayName },
{ "installedVersion", installedVersion },
{ "cpeUri", $"{cpeUri}:{installedVersion}" },
{ "matchConfidence", cpeMapping.MatchConfidence ?? "high" },
{ "riskLevel", riskLevel },
{ "vulnerabilities", vulns }
});
}
// Determine overall risk
if (kevMatches > 0 || criticalCves > 0) overallRisk = "critical";
else if (highCves > 0) overallRisk = "high";
else if (mediumCves > 0) overallRisk = "medium";
else if (lowCves > 0) overallRisk = "low";
// Sort findings by risk (critical first)
var riskOrder = new Dictionary<string, int>
{
{ "critical", 0 }, { "high", 1 }, { "medium", 2 }, { "low", 3 }, { "none", 4 }
};
findings.Sort((a, b) =>
{
var ra = riskOrder.GetValueOrDefault(a["riskLevel"]?.ToString() ?? "none", 4);
var rb = riskOrder.GetValueOrDefault(b["riskLevel"]?.ToString() ?? "none", 4);
return ra.CompareTo(rb);
});
// Cap findings at top 50
int additionalCount = 0;
if (findings.Count > 50)
{
additionalCount = findings.Count - 50;
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>
{
{ "total_software_scanned", inventoryItems.Count },
{ "matched_to_cpe", matchedCount },
{ "unmatched", unmatchedSoftware.Count },
{ "total_cves_found", totalCves },
{ "critical_cves", criticalCves },
{ "high_cves", highCves },
{ "medium_cves", mediumCves },
{ "low_cves", lowCves },
{ "kev_matches", kevMatches },
{ "overall_risk", overallRisk },
{ "additional_findings_count", additionalCount }
}
},
{ "findings", findings },
{ "unmatched_software", unmatchedSoftware.Take(100).ToList() },
{ "data_freshness", new Dictionary<string, object>
{
{ "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) }
}
}
};
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
};
}
// -----------------------------------------------------------------------
// CPE Mapping
// -----------------------------------------------------------------------
private CpeMappingEntry FindCpeMapping(string normalizedVendor, string normalizedName, string displayName)
{
if (_cpeMappings == null || _cpeMappings.Count == 0)
return null;
// 1. Exact match on normalizedVendor + normalizedProduct
var exact = _cpeMappings.FirstOrDefault(m =>
string.Equals(m.NormalizedVendor, normalizedVendor, StringComparison.OrdinalIgnoreCase) &&
string.Equals(m.NormalizedProduct, normalizedName, StringComparison.OrdinalIgnoreCase));
if (exact != null)
{
exact.MatchConfidence = "high";
return exact;
}
// 2. Pattern match on displayNamePatterns
var displayNameLower = (displayName ?? "").ToLowerInvariant();
var patternMatch = _cpeMappings.FirstOrDefault(m =>
m.DisplayNamePatterns != null &&
m.DisplayNamePatterns.Any(p => displayNameLower.Contains(p.ToLowerInvariant())));
if (patternMatch != null)
{
patternMatch.MatchConfidence = "medium";
return patternMatch;
}
// 3. Publisher + partial product name
if (!string.IsNullOrWhiteSpace(normalizedVendor))
{
var vendorMatch = _cpeMappings.FirstOrDefault(m =>
m.PublisherPatterns != null &&
m.PublisherPatterns.Any(p => string.Equals(p, normalizedVendor, StringComparison.OrdinalIgnoreCase)) &&
!string.IsNullOrWhiteSpace(normalizedName) &&
normalizedName.Contains(m.CpeProduct?.Replace("_", " ") ?? "", StringComparison.OrdinalIgnoreCase));
if (vendorMatch != null)
{
vendorMatch.MatchConfidence = "medium";
return vendorMatch;
}
}
return null;
}
// -----------------------------------------------------------------------
// CVE Lookup
// -----------------------------------------------------------------------
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);
if (cveResponse?.Vulnerabilities == null)
return results;
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";
var cvssMetric = cve.Metrics?.CvssMetricV31?.FirstOrDefault()
?? cve.Metrics?.CvssMetricV30?.FirstOrDefault();
if (cvssMetric?.CvssData != null)
{
cvssScore = cvssMetric.CvssData.BaseScore;
cvssSeverity = cvssMetric.CvssData.BaseSeverity ?? "UNKNOWN";
}
// 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>
{
{ "cveId", cve.Id ?? "" },
{ "cvssScore", cvssScore },
{ "cvssSeverity", cvssSeverity },
{ "description", description },
{ "publishedDate", cve.Published ?? "" },
{ "source", "NVD" },
{ "isKev", false },
{ "kevDueDate", "" },
{ "kevRequiredAction", "" },
{ "knownRansomware", "" }
});
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to look up CVEs for CPE {CpeUri}", cpeUri);
}
return results;
}
// -----------------------------------------------------------------------
// Seed Loading
// -----------------------------------------------------------------------
private async Task EnsureMappingsLoadedAsync()
{
if (_seedLoaded && _cpeMappings != null)
return;
await _initLock.WaitAsync();
try
{
if (_seedLoaded && _cpeMappings != null)
return;
_cpeMappings = new List<CpeMappingEntry>();
// Load from embedded seed resource
try
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = assembly.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith("cpe-mapping-seed.json", StringComparison.OrdinalIgnoreCase));
if (resourceName != null)
{
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream != null)
{
using var reader = new StreamReader(stream);
var json = await reader.ReadToEndAsync();
var seed = JsonSerializer.Deserialize<CpeMappingSeed>(json, JsonOptions);
if (seed?.Mappings != null)
{
_cpeMappings.AddRange(seed.Mappings);
_logger.LogInformation("Loaded {Count} CPE mappings from seed", seed.Mappings.Count);
}
}
}
else
{
_logger.LogWarning("CPE mapping seed resource not found in assembly");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load CPE mapping seed");
}
// Load custom mappings from VulnerabilityCache table
try
{
var tableClient = _storage.GetTableClient(AutopilotMonitor.Shared.Constants.TableNames.VulnerabilityCache);
var customMappings = tableClient.QueryAsync<TableEntity>(
filter: $"PartitionKey eq 'cpe_map_custom'");
await foreach (var entity in customMappings)
{
_cpeMappings.Add(new CpeMappingEntry
{
NormalizedVendor = entity.GetString("NormalizedVendor") ?? "",
NormalizedProduct = entity.GetString("NormalizedProduct") ?? "",
CpeVendor = entity.GetString("CpeVendor") ?? "",
CpeProduct = entity.GetString("CpeProduct") ?? "",
CpeUri = entity.GetString("CpeUri") ?? "",
Category = entity.GetString("Category") ?? "custom",
DisplayNamePatterns = JsonSerializer.Deserialize<List<string>>(
entity.GetString("DisplayNamePatternsJson") ?? "[]"),
PublisherPatterns = JsonSerializer.Deserialize<List<string>>(
entity.GetString("PublisherPatternsJson") ?? "[]")
});
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "No custom CPE mappings loaded (table may not exist yet)");
}
_seedLoaded = true;
_logger.LogInformation("CPE mappings ready: {Count} total entries", _cpeMappings.Count);
}
finally
{
_initLock.Release();
}
}
}
// -----------------------------------------------------------------------
// CPE Mapping Models
// -----------------------------------------------------------------------
public class CpeMappingSeed
{
public string Version { get; set; }
public string LastUpdated { get; set; }
public List<CpeMappingEntry> Mappings { get; set; } = new List<CpeMappingEntry>();
}
public class CpeMappingEntry
{
public string NormalizedVendor { get; set; }
public string NormalizedProduct { get; set; }
public List<string> DisplayNamePatterns { get; set; } = new List<string>();
public List<string> PublisherPatterns { get; set; } = new List<string>();
public string CpeVendor { get; set; }
public string CpeProduct { get; set; }
public string CpeUri { get; set; }
public string Category { get; set; }
// Set during matching -- not serialized
[JsonIgnore]
public string MatchConfidence { get; set; }
}
}
@@ -151,6 +151,8 @@ namespace AutopilotMonitor.Shared
public const string ScriptCompleted = "script_completed";
public const string ScriptFailed = "script_failed";
public const string EspProvisioningStatus = "esp_provisioning_status";
public const string SoftwareInventoryAnalysis = "software_inventory_analysis";
public const string VulnerabilityReport = "vulnerability_report";
}
// -----------------------------------------------------------------------
@@ -251,6 +253,9 @@ namespace AutopilotMonitor.Shared
// Galactic Admin in-app notifications (persistent until dismissed)
public const string GalacticNotifications = "GalacticNotifications";
// Vulnerability data cache (CPE mappings, CVE data, KEV catalog)
public const string VulnerabilityCache = "VulnerabilityCache";
/// <summary>
/// Returns all table names for initialization
/// </summary>
@@ -279,7 +284,8 @@ namespace AutopilotMonitor.Shared
BlockedVersions,
SessionReports,
BootstrapSessions,
GalacticNotifications
GalacticNotifications,
VulnerabilityCache
};
}
}
@@ -123,6 +123,28 @@ namespace AutopilotMonitor.Shared.Models
}
}
// ===== VULNERABILITY CORRELATION SETTINGS =====
/// <summary>
/// NVD API key for higher rate limits (50 req/30s vs 5 req/30s without key).
/// Free registration at https://nvd.nist.gov/developers/request-an-api-key
/// null = operate without API key (slower, still functional).
/// </summary>
public string NvdApiKey { get; set; }
/// <summary>
/// Whether vulnerability correlation is globally enabled.
/// When false, agents still collect inventory but backend skips correlation.
/// Default: true
/// </summary>
public bool VulnerabilityCorrelationEnabled { get; set; } = true;
/// <summary>
/// Last successful vulnerability data sync timestamp (UTC ISO 8601).
/// Updated by VulnerabilityDataSyncFunction.
/// </summary>
public string VulnerabilityDataLastSyncUtc { get; set; }
/// <summary>
/// Creates default configuration
/// </summary>
@@ -241,5 +241,13 @@ namespace AutopilotMonitor.Shared.Models
/// Default: empty list (built-in defaults only)
/// </summary>
public List<string> LocalAdminAllowedAccounts { get; set; } = new List<string>();
/// <summary>
/// Whether to run the SoftwareInventoryAnalyzer at startup and shutdown.
/// Collects installed software registry inventory, normalizes entries, and
/// emits a baseline snapshot (startup) plus delta (shutdown) for vulnerability insight.
/// Default: true
/// </summary>
public bool EnableSoftwareInventoryAnalyzer { get; set; } = true;
}
}
@@ -272,6 +272,12 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool? EnableLocalAdminAnalyzer { get; set; } = null;
/// <summary>
/// Whether the SoftwareInventoryAnalyzer is enabled for this tenant's devices.
/// null = use agent default (true).
/// </summary>
public bool? EnableSoftwareInventoryAnalyzer { get; set; } = null;
/// <summary>
/// JSON-serialized list of additional local account names that are considered expected
/// on a newly enrolled device (merged with built-in defaults on the agent).
@@ -0,0 +1,806 @@
"use client";
import { useState, useMemo } from "react";
interface VulnerabilityReportSectionProps {
events: any[];
expanded: boolean;
setExpanded: (v: boolean) => void;
}
type TabKey = "findings" | "inventory" | "delta";
interface CveDetail {
cveId: string;
cvss?: number;
severity?: string;
description?: string;
kev?: boolean;
publishedDate?: string;
references?: string[];
}
interface Finding {
softwareName: string;
version: string;
cves: CveDetail[];
maxCvss: number;
maxSeverity: string;
kevCount: number;
}
interface InventoryItem {
name: string;
version: string;
publisher?: string;
installDate?: string;
source?: string;
confidence?: string;
}
const SEVERITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
function normalizeSeverity(s?: string): string {
if (!s) return "info";
const lower = s.toLowerCase();
if (lower === "critical") return "critical";
if (lower === "high") return "high";
if (lower === "medium" || lower === "moderate") return "medium";
if (lower === "low") return "low";
return "info";
}
function severityFromCvss(cvss: number): string {
if (cvss >= 9.0) return "critical";
if (cvss >= 7.0) return "high";
if (cvss >= 4.0) return "medium";
if (cvss > 0) return "low";
return "info";
}
const severityBadgeColors: Record<string, string> = {
critical: "bg-red-100 text-red-800",
high: "bg-orange-100 text-orange-800",
medium: "bg-yellow-100 text-yellow-800",
low: "bg-blue-100 text-blue-800",
info: "bg-gray-100 text-gray-800",
};
const severityHeaderBadgeColors: Record<string, string> = {
critical: "bg-red-100 text-red-700",
high: "bg-orange-100 text-orange-700",
medium: "bg-yellow-100 text-yellow-700",
low: "bg-blue-100 text-blue-700",
};
function confidenceBadgeColor(confidence?: string): string {
if (!confidence) return "bg-gray-100 text-gray-700";
const c = confidence.toLowerCase();
if (c === "high") return "bg-green-100 text-green-700";
if (c === "medium") return "bg-yellow-100 text-yellow-700";
if (c === "low") return "bg-red-100 text-red-700";
return "bg-gray-100 text-gray-700";
}
export default function VulnerabilityReportSection({
events,
expanded,
setExpanded,
}: VulnerabilityReportSectionProps) {
const [activeTab, setActiveTab] = useState<TabKey>("findings");
const [findingsSearch, setFindingsSearch] = useState("");
const [findingsSortKey, setFindingsSortKey] = useState<"severity" | "software" | "cves" | "cvss">("severity");
const [findingsSortAsc, setFindingsSortAsc] = useState(false);
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
const [inventorySearch, setInventorySearch] = useState("");
const [unmatchedExpanded, setUnmatchedExpanded] = useState(false);
// Filter relevant events
const vulnEvents = useMemo(() => events.filter(e => e.eventType === "vulnerability_report"), [events]);
const inventoryEvents = useMemo(() => events.filter(e => e.eventType === "software_inventory_analysis"), [events]);
// Return null if no relevant events
if (vulnEvents.length === 0 && inventoryEvents.length === 0) return null;
// --- Findings data ---
const { findings, unmatchedSoftware, totalSoftware, kevTotal, dataFreshness } = useMemo(() => {
const findingsMap = new Map<string, Finding>();
let unmatched: string[] = [];
let total = 0;
let freshness = "";
let kevCount = 0;
for (const evt of vulnEvents) {
const d = evt.data || {};
if (d.findings && Array.isArray(d.findings)) {
for (const f of d.findings) {
const key = `${f.softwareName || f.software || "Unknown"}|||${f.version || ""}`;
const cves: CveDetail[] = (f.cves || f.vulnerabilities || []).map((c: any) => ({
cveId: c.cveId || c.id || c.cve_id || "Unknown",
cvss: c.cvss ?? c.cvssScore ?? c.cvss_score ?? 0,
severity: normalizeSeverity(c.severity),
description: c.description || c.summary || "",
kev: c.kev ?? c.isKev ?? c.is_kev ?? false,
publishedDate: c.publishedDate || c.published_date || "",
references: c.references || [],
}));
const maxCvss = cves.reduce((max: number, c: CveDetail) => Math.max(max, c.cvss ?? 0), 0);
const maxSev = cves.reduce((worst: string, c: CveDetail) => {
const cSev = c.severity || severityFromCvss(c.cvss ?? 0);
return (SEVERITY_ORDER[cSev] ?? 4) < (SEVERITY_ORDER[worst] ?? 4) ? cSev : worst;
}, "info");
const cveKevCount = cves.filter(c => c.kev).length;
if (findingsMap.has(key)) {
const existing = findingsMap.get(key)!;
// Merge CVEs (deduplicate by cveId)
const existingIds = new Set(existing.cves.map(c => c.cveId));
for (const c of cves) {
if (!existingIds.has(c.cveId)) {
existing.cves.push(c);
}
}
existing.maxCvss = Math.max(existing.maxCvss, maxCvss);
existing.maxSeverity = (SEVERITY_ORDER[maxSev] ?? 4) < (SEVERITY_ORDER[existing.maxSeverity] ?? 4) ? maxSev : existing.maxSeverity;
existing.kevCount += cveKevCount;
} else {
findingsMap.set(key, {
softwareName: f.softwareName || f.software || "Unknown",
version: f.version || "",
cves,
maxCvss,
maxSeverity: maxSev,
kevCount: cveKevCount,
});
}
kevCount += cveKevCount;
}
}
if (d.unmatchedSoftware && Array.isArray(d.unmatchedSoftware)) {
unmatched = [...unmatched, ...d.unmatchedSoftware];
} else if (d.unmatched_software && Array.isArray(d.unmatched_software)) {
unmatched = [...unmatched, ...d.unmatched_software];
}
total = d.totalSoftware ?? d.total_software ?? d.totalCount ?? total;
freshness = d.dataFreshness || d.data_freshness || d.analyzedAt || d.analyzed_at || freshness;
}
// Deduplicate unmatched
const uniqueUnmatched = [...new Set(unmatched)];
return {
findings: Array.from(findingsMap.values()),
unmatchedSoftware: uniqueUnmatched,
totalSoftware: total,
kevTotal: kevCount,
dataFreshness: freshness,
};
}, [vulnEvents]);
// Severity counts for header badges
const severityCounts = useMemo(() => {
const counts: Record<string, number> = { critical: 0, high: 0, medium: 0, low: 0 };
for (const f of findings) {
const sev = f.maxSeverity;
if (sev in counts) counts[sev] += f.cves.length;
}
return counts;
}, [findings]);
// --- Inventory data (merge chunks) ---
const inventoryItems = useMemo(() => {
// Group by triggered_at to handle chunks
const chunkGroups = new Map<string, { items: InventoryItem[]; chunkCount: number; seenChunks: Set<number> }>();
for (const evt of inventoryEvents) {
const d = evt.data || {};
const triggeredAt = d.triggered_at || d.triggeredAt || evt.timestamp || "default";
const chunkIndex = d.chunk_index ?? d.chunkIndex ?? 0;
const chunkCount = d.chunk_count ?? d.chunkCount ?? 1;
const items: InventoryItem[] = (d.inventory || d.software || d.items || []).map((item: any) => ({
name: item.name || item.softwareName || item.software_name || "Unknown",
version: item.version || "",
publisher: item.publisher || item.vendor || "",
installDate: item.installDate || item.install_date || "",
source: item.source || "",
confidence: item.confidence || "",
}));
if (!chunkGroups.has(triggeredAt)) {
chunkGroups.set(triggeredAt, { items: [], chunkCount, seenChunks: new Set() });
}
const group = chunkGroups.get(triggeredAt)!;
if (!group.seenChunks.has(chunkIndex)) {
group.seenChunks.add(chunkIndex);
group.items.push(...items);
}
group.chunkCount = Math.max(group.chunkCount, chunkCount);
}
// Take the latest group (most items or latest triggered_at)
let allItems: InventoryItem[] = [];
for (const group of chunkGroups.values()) {
if (group.items.length > allItems.length) {
allItems = group.items;
}
}
return allItems;
}, [inventoryEvents]);
// --- Delta data (new installs during enrollment) ---
const deltaItems = useMemo(() => {
const items: InventoryItem[] = [];
// Look in vulnerability_report events for new_installs_during_enrollment in shutdown data
for (const evt of [...vulnEvents, ...inventoryEvents]) {
const d = evt.data || {};
const newInstalls = d.new_installs_during_enrollment || d.newInstallsDuringEnrollment || [];
if (Array.isArray(newInstalls)) {
for (const item of newInstalls) {
items.push({
name: item.name || item.softwareName || item.software_name || "Unknown",
version: item.version || "",
publisher: item.publisher || item.vendor || "",
installDate: item.installDate || item.install_date || "",
source: item.source || "",
confidence: item.confidence || "",
});
}
}
}
// Also check all events for shutdown events
for (const evt of events) {
if (evt.eventType === "enrollment_complete" || evt.eventType === "enrollment_failed" || evt.eventType === "collector_shutdown") {
const d = evt.data || {};
const newInstalls = d.new_installs_during_enrollment || d.newInstallsDuringEnrollment || [];
if (Array.isArray(newInstalls)) {
for (const item of newInstalls) {
// Deduplicate by name+version
if (!items.some(i => i.name === (item.name || item.softwareName || "Unknown") && i.version === (item.version || ""))) {
items.push({
name: item.name || item.softwareName || item.software_name || "Unknown",
version: item.version || "",
publisher: item.publisher || item.vendor || "",
installDate: item.installDate || item.install_date || "",
source: item.source || "",
confidence: item.confidence || "",
});
}
}
}
}
}
return items;
}, [vulnEvents, inventoryEvents, events]);
// --- Findings: sort & filter ---
const filteredFindings = useMemo(() => {
let result = [...findings];
if (findingsSearch.trim()) {
const q = findingsSearch.toLowerCase();
result = result.filter(
f => f.softwareName.toLowerCase().includes(q) ||
f.version.toLowerCase().includes(q) ||
f.cves.some(c => c.cveId.toLowerCase().includes(q))
);
}
result.sort((a, b) => {
let cmp = 0;
switch (findingsSortKey) {
case "severity":
cmp = (SEVERITY_ORDER[a.maxSeverity] ?? 4) - (SEVERITY_ORDER[b.maxSeverity] ?? 4);
break;
case "software":
cmp = a.softwareName.localeCompare(b.softwareName);
break;
case "cves":
cmp = a.cves.length - b.cves.length;
break;
case "cvss":
cmp = a.maxCvss - b.maxCvss;
break;
}
return findingsSortAsc ? cmp : -cmp;
});
return result;
}, [findings, findingsSearch, findingsSortKey, findingsSortAsc]);
// Inventory: filter
const filteredInventory = useMemo(() => {
if (!inventorySearch.trim()) return inventoryItems;
const q = inventorySearch.toLowerCase();
return inventoryItems.filter(
i => i.name.toLowerCase().includes(q) ||
i.version.toLowerCase().includes(q) ||
(i.publisher || "").toLowerCase().includes(q) ||
(i.source || "").toLowerCase().includes(q)
);
}, [inventoryItems, inventorySearch]);
const toggleFindingsSort = (key: typeof findingsSortKey) => {
if (findingsSortKey === key) {
setFindingsSortAsc(!findingsSortAsc);
} else {
setFindingsSortKey(key);
setFindingsSortAsc(false);
}
};
const toggleRowExpanded = (key: string) => {
setExpandedRows(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const SortIndicator = ({ column }: { column: typeof findingsSortKey }) => {
if (findingsSortKey !== column) return <span className="text-gray-300 ml-1">&#8597;</span>;
return <span className="ml-1">{findingsSortAsc ? "\u25B2" : "\u25BC"}</span>;
};
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
{/* Section Header */}
<div
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-between w-full text-left mb-4 cursor-pointer"
>
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
<h2 className="text-xl font-semibold text-gray-900">Vulnerability Report</h2>
{findings.length > 0 && (
<>
<span className="text-xs text-gray-400">
({findings.reduce((sum, f) => sum + f.cves.length, 0)} CVEs across {findings.length} {findings.length === 1 ? "package" : "packages"})
</span>
<div className="flex items-center space-x-2 text-xs">
{severityCounts.critical > 0 && (
<span className={`px-2 py-0.5 rounded-full font-medium ${severityHeaderBadgeColors.critical}`}>
{severityCounts.critical} Critical
</span>
)}
{severityCounts.high > 0 && (
<span className={`px-2 py-0.5 rounded-full font-medium ${severityHeaderBadgeColors.high}`}>
{severityCounts.high} High
</span>
)}
{severityCounts.medium > 0 && (
<span className={`px-2 py-0.5 rounded-full font-medium ${severityHeaderBadgeColors.medium}`}>
{severityCounts.medium} Medium
</span>
)}
{severityCounts.low > 0 && (
<span className={`px-2 py-0.5 rounded-full font-medium ${severityHeaderBadgeColors.low}`}>
{severityCounts.low} Low
</span>
)}
{kevTotal > 0 && (
<span className="px-2 py-0.5 rounded-full font-medium bg-red-100 text-red-700">
{kevTotal} KEV
</span>
)}
</div>
</>
)}
</div>
<svg className={`w-5 h-5 text-gray-400 transition-transform duration-200 ${expanded ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
{expanded && (
<div>
{/* Tabs */}
<div className="flex border-b border-gray-200 mb-4">
{(
[
{ key: "findings" as TabKey, label: "Findings", count: findings.length },
{ key: "inventory" as TabKey, label: "Inventory", count: inventoryItems.length },
{ key: "delta" as TabKey, label: "Delta", count: deltaItems.length },
] as const
).map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.key
? "border-indigo-500 text-indigo-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
{tab.label}
{tab.count > 0 && (
<span className="ml-1.5 px-1.5 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600">
{tab.count}
</span>
)}
</button>
))}
</div>
{/* Findings Tab */}
{activeTab === "findings" && (
<div>
{findings.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">
No vulnerability findings reported.
</div>
) : (
<>
{/* Summary Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-4">
<SummaryCard label="Critical" value={severityCounts.critical} color="text-red-700" bg="bg-red-50 border-red-200" />
<SummaryCard label="High" value={severityCounts.high} color="text-orange-700" bg="bg-orange-50 border-orange-200" />
<SummaryCard label="Medium" value={severityCounts.medium} color="text-yellow-700" bg="bg-yellow-50 border-yellow-200" />
<SummaryCard label="Low" value={severityCounts.low} color="text-blue-700" bg="bg-blue-50 border-blue-200" />
<SummaryCard
label="KEV Matches"
value={kevTotal}
color="text-red-700"
bg="bg-red-50 border-red-200"
icon={
<svg className="w-4 h-4 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
}
/>
<SummaryCard
label="Matched / Total"
value={`${findings.length} / ${totalSoftware || "?"}`}
color="text-gray-700"
bg="bg-gray-50 border-gray-200"
/>
</div>
{/* Search */}
<div className="mb-3">
<input
type="text"
placeholder="Search software, versions, CVE IDs..."
value={findingsSearch}
onChange={e => setFindingsSearch(e.target.value)}
className="w-full sm:w-80 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
/>
</div>
{/* Findings Table */}
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("severity")}>
Risk <SortIndicator column="severity" />
</th>
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("software")}>
Software <SortIndicator column="software" />
</th>
<th className="py-2 px-3">Version</th>
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("cves")}>
CVEs <SortIndicator column="cves" />
</th>
<th className="py-2 px-3">KEV</th>
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("cvss")}>
Max CVSS <SortIndicator column="cvss" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredFindings.map(f => {
const rowKey = `${f.softwareName}|||${f.version}`;
const isRowExpanded = expandedRows.has(rowKey);
return (
<FindingRow
key={rowKey}
finding={f}
isExpanded={isRowExpanded}
onToggle={() => toggleRowExpanded(rowKey)}
/>
);
})}
{filteredFindings.length === 0 && (
<tr>
<td colSpan={6} className="py-4 text-center text-gray-400">
No findings match your search.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Unmatched Software */}
{unmatchedSoftware.length > 0 && (
<div className="mt-4 border border-gray-200 rounded-lg">
<button
onClick={() => setUnmatchedExpanded(!unmatchedExpanded)}
className="flex items-center justify-between w-full px-4 py-3 text-left text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
<span>Unmatched Software ({unmatchedSoftware.length})</span>
<svg className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${unmatchedExpanded ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{unmatchedExpanded && (
<div className="px-4 pb-3">
<p className="text-xs text-gray-400 mb-2">
Software detected on the device that could not be matched to any known vulnerability database entry.
</p>
<div className="flex flex-wrap gap-1.5">
{unmatchedSoftware.map((name, i) => (
<span key={i} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
{name}
</span>
))}
</div>
</div>
)}
</div>
)}
{/* Data Freshness */}
{dataFreshness && (
<div className="mt-3 text-xs text-gray-400">
Data Freshness: {dataFreshness}
</div>
)}
</>
)}
</div>
)}
{/* Inventory Tab */}
{activeTab === "inventory" && (
<div>
{inventoryItems.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">
No software inventory data available.
</div>
) : (
<>
<div className="mb-3">
<input
type="text"
placeholder="Search name, version, publisher, source..."
value={inventorySearch}
onChange={e => setInventorySearch(e.target.value)}
className="w-full sm:w-80 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
/>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="py-2 px-3">Name</th>
<th className="py-2 px-3">Version</th>
<th className="py-2 px-3">Publisher</th>
<th className="py-2 px-3">Install Date</th>
<th className="py-2 px-3">Source</th>
<th className="py-2 px-3">Confidence</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredInventory.map((item, i) => (
<tr key={`${item.name}-${item.version}-${i}`} className="hover:bg-gray-50">
<td className="py-2 px-3 font-medium text-gray-900">{item.name}</td>
<td className="py-2 px-3 text-gray-600 font-mono text-xs">{item.version || "-"}</td>
<td className="py-2 px-3 text-gray-600">{item.publisher || "-"}</td>
<td className="py-2 px-3 text-gray-600">{item.installDate || "-"}</td>
<td className="py-2 px-3 text-gray-600">{item.source || "-"}</td>
<td className="py-2 px-3">
{item.confidence ? (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${confidenceBadgeColor(item.confidence)}`}>
{item.confidence}
</span>
) : (
<span className="text-gray-400">-</span>
)}
</td>
</tr>
))}
{filteredInventory.length === 0 && (
<tr>
<td colSpan={6} className="py-4 text-center text-gray-400">
No inventory items match your search.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="mt-2 text-xs text-gray-400">
Showing {filteredInventory.length} of {inventoryItems.length} items
</div>
</>
)}
</div>
)}
{/* Delta Tab */}
{activeTab === "delta" && (
<div>
{deltaItems.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">
No new software installations detected during enrollment.
</div>
) : (
<>
<div className="flex items-center space-x-2 mb-4">
<span className="px-3 py-1 text-sm font-semibold bg-indigo-100 text-indigo-700 rounded-full">
{deltaItems.length} new {deltaItems.length === 1 ? "install" : "installs"} during enrollment
</span>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="py-2 px-3">Name</th>
<th className="py-2 px-3">Version</th>
<th className="py-2 px-3">Publisher</th>
<th className="py-2 px-3">Install Date</th>
<th className="py-2 px-3">Source</th>
<th className="py-2 px-3">Confidence</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{deltaItems.map((item, i) => (
<tr key={`${item.name}-${item.version}-${i}`} className="hover:bg-gray-50">
<td className="py-2 px-3 font-medium text-gray-900">{item.name}</td>
<td className="py-2 px-3 text-gray-600 font-mono text-xs">{item.version || "-"}</td>
<td className="py-2 px-3 text-gray-600">{item.publisher || "-"}</td>
<td className="py-2 px-3 text-gray-600">{item.installDate || "-"}</td>
<td className="py-2 px-3 text-gray-600">{item.source || "-"}</td>
<td className="py-2 px-3">
{item.confidence ? (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${confidenceBadgeColor(item.confidence)}`}>
{item.confidence}
</span>
) : (
<span className="text-gray-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)}
</div>
)}
</div>
);
}
// --- Sub-components ---
function SummaryCard({ label, value, color, bg, icon }: {
label: string;
value: number | string;
color: string;
bg: string;
icon?: React.ReactNode;
}) {
return (
<div className={`border rounded-lg p-3 ${bg}`}>
<div className="flex items-center space-x-1.5">
{icon}
<span className="text-xs text-gray-500">{label}</span>
</div>
<div className={`text-xl font-bold mt-1 ${color}`}>{value}</div>
</div>
);
}
function FindingRow({ finding, isExpanded, onToggle }: {
finding: Finding;
isExpanded: boolean;
onToggle: () => void;
}) {
const severityRowColors: Record<string, string> = {
critical: "bg-red-50/50",
high: "bg-orange-50/50",
medium: "bg-yellow-50/30",
low: "",
};
return (
<>
<tr
onClick={onToggle}
className={`cursor-pointer hover:bg-gray-50 ${severityRowColors[finding.maxSeverity] || ""}`}
>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${severityBadgeColors[finding.maxSeverity] || severityBadgeColors.info}`}>
{finding.maxSeverity.toUpperCase()}
</span>
</td>
<td className="py-2 px-3 font-medium text-gray-900">{finding.softwareName}</td>
<td className="py-2 px-3 text-gray-600 font-mono text-xs">{finding.version || "-"}</td>
<td className="py-2 px-3 text-gray-600">{finding.cves.length}</td>
<td className="py-2 px-3">
{finding.kevCount > 0 ? (
<span className="inline-flex items-center space-x-1 px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<span>KEV</span>
</span>
) : (
<span className="text-gray-300">-</span>
)}
</td>
<td className="py-2 px-3">
<CvssScore value={finding.maxCvss} />
</td>
</tr>
{isExpanded && (
<tr>
<td colSpan={6} className="p-0">
<div className="bg-gray-50 border-t border-b border-gray-100 px-6 py-3">
<h4 className="text-xs font-medium text-gray-500 uppercase mb-2">
CVE Details ({finding.cves.length})
</h4>
<div className="space-y-2">
{finding.cves.map(cve => (
<div key={cve.cveId} className="flex items-start space-x-3 p-2 bg-white rounded border border-gray-100">
<div className="flex-shrink-0 mt-0.5">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${severityBadgeColors[cve.severity || severityFromCvss(cve.cvss ?? 0)] || severityBadgeColors.info}`}>
{(cve.severity || severityFromCvss(cve.cvss ?? 0)).toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<a
href={`https://nvd.nist.gov/vuln/detail/${cve.cveId}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-blue-600 hover:text-blue-800 underline"
>
{cve.cveId}
</a>
<CvssScore value={cve.cvss ?? 0} />
{cve.kev && (
<span className="inline-flex items-center space-x-1 px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<span>KEV</span>
</span>
)}
{cve.publishedDate && (
<span className="text-xs text-gray-400">{cve.publishedDate}</span>
)}
</div>
{cve.description && (
<p className="text-xs text-gray-600 mt-1 line-clamp-2">{cve.description}</p>
)}
</div>
</div>
))}
</div>
</div>
</td>
</tr>
)}
</>
);
}
function CvssScore({ value }: { value: number }) {
if (!value || value === 0) return <span className="text-gray-300 text-xs">-</span>;
let color = "text-blue-700 bg-blue-50";
if (value >= 9.0) color = "text-red-700 bg-red-50";
else if (value >= 7.0) color = "text-orange-700 bg-orange-50";
else if (value >= 4.0) color = "text-yellow-700 bg-yellow-50";
return (
<span className={`px-1.5 py-0.5 rounded text-xs font-mono font-medium ${color}`}>
{value.toFixed(1)}
</span>
);
}
@@ -20,11 +20,12 @@ import SessionInfoCard from "./components/SessionInfoCard";
import PhaseTimeline from "./components/PhaseTimeline";
import EventTimeline from "./components/EventTimeline";
import AnalysisResultsSection from "./components/AnalysisResultsSection";
import VulnerabilityReportSection from "./components/VulnerabilityReportSection";
import AdminOverrideModal from "./components/AdminOverrideModal";
import ReportSessionModal from "./components/ReportSessionModal";
import { usePageSections } from "../../../hooks/usePageSections";
import { PageSectionItem } from "../../../contexts/SidebarContext";
import { InformationCircleIcon, ComputerDesktopIcon, PlayCircleIcon, SparklesIcon, ChartBarIcon, CodeBracketIcon, ArrowDownTrayIcon, ListBulletIcon, ClockIcon } from "../../../lib/sidebarIcons";
import { InformationCircleIcon, ComputerDesktopIcon, PlayCircleIcon, SparklesIcon, ChartBarIcon, CodeBracketIcon, ArrowDownTrayIcon, ListBulletIcon, ClockIcon, ShieldCheckIcon } from "../../../lib/sidebarIcons";
import DeviceDetailsCard from "./components/DeviceDetailsCard";
import { generateUiExport, generateCsvExport, generateSessionCsvExport, generateRuleResultsCsvExport, SessionExportEvent } from "@/lib/sessionExportUtils";
import { Session, EnrollmentEvent, RuleResult } from "@/types";
@@ -51,6 +52,7 @@ export default function SessionDetailPage() {
const [analysisResults, setAnalysisResults] = useState<RuleResult[]>([]);
const [loadingAnalysis, setLoadingAnalysis] = useState(false);
const [analysisExpanded, setAnalysisExpanded] = useState(true);
const [vulnerabilityReportExpanded, setVulnerabilityReportExpanded] = useState(false);
const [phaseTimelineExpanded, setPhaseTimelineExpanded] = useState(true);
const [perfExpanded, setPerfExpanded] = useState(true);
const [timelineExpanded, setTimelineExpanded] = useState(true);
@@ -280,12 +282,30 @@ export default function SessionDetailPage() {
}
};
// Listen for async rule engine results (pushed after background analysis completes)
const handleRuleResultsReady = (data: { sessionId: string; tenantId: string; ruleResultCount: number }) => {
if (data.sessionId !== sessionIdRef.current) return;
console.info(`[SessionDetail] ruleResultsReady signal: ${data.ruleResultCount} results`);
fetchAnalysisResults();
};
// Listen for async vulnerability correlation results
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
};
on('eventStream', handleEventStream);
on('newevents', handleNewEvents);
on('ruleResultsReady', handleRuleResultsReady);
on('vulnerabilityReportReady', handleVulnerabilityReportReady);
return () => {
off('eventStream', handleEventStream);
off('newevents', handleNewEvents);
off('ruleResultsReady', handleRuleResultsReady);
off('vulnerabilityReportReady', handleVulnerabilityReportReady);
if (eventRefreshTimeoutRef.current) {
clearTimeout(eventRefreshTimeoutRef.current);
}
@@ -833,13 +853,16 @@ export default function SessionDetailPage() {
if (!isGatherRulesSession) s.push({ id: "section-device-details", label: "Device Details", icon: <ComputerDesktopIcon /> });
if (!isGatherRulesSession && session) s.push({ id: "section-enrollment-progress", label: "Enrollment Progress", icon: <PlayCircleIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-analysis", label: "Analysis", icon: <SparklesIcon /> });
if (!isGatherRulesSession && events.some(e => e.eventType === "vulnerability_report" || e.eventType === "software_inventory_analysis")) {
s.push({ id: "section-vulnerability-report", label: "Vulnerability Report", icon: <ShieldCheckIcon /> });
}
if (!isGatherRulesSession) s.push({ id: "section-performance", label: "Performance", icon: <ChartBarIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-scripts", label: "Script Executions", icon: <CodeBracketIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-downloads", label: "Downloads", icon: <ArrowDownTrayIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-install-progress", label: "Install Progress", icon: <ListBulletIcon /> });
s.push({ id: "section-event-timeline", label: "Event Timeline", icon: <ClockIcon /> });
return s;
}, [session, isGatherRulesSession]);
}, [session, isGatherRulesSession, events]);
usePageSections(sessionSections, "Sections", "scroll-spy");
@@ -995,6 +1018,17 @@ export default function SessionDetailPage() {
</div>
)}
{/* Vulnerability Report */}
{!isGatherRulesSession && (
<div id="section-vulnerability-report">
<VulnerabilityReportSection
events={events}
expanded={vulnerabilityReportExpanded}
setExpanded={setVulnerabilityReportExpanded}
/>
</div>
)}
{/* Performance Metrics (from performance_snapshot events) */}
{!isGatherRulesSession && (
<div id="section-performance">
@@ -7,6 +7,8 @@ interface AgentAnalyzersSectionProps {
setLocalAdminAllowedAccounts: (value: string[]) => void;
newAllowedAccount: string;
setNewAllowedAccount: (value: string) => void;
enableSoftwareInventoryAnalyzer: boolean;
setEnableSoftwareInventoryAnalyzer: (value: boolean) => void;
onSave: () => void;
onReset: () => void;
saving: boolean;
@@ -33,6 +35,8 @@ export default function AgentAnalyzersSection({
setLocalAdminAllowedAccounts,
newAllowedAccount,
setNewAllowedAccount,
enableSoftwareInventoryAnalyzer,
setEnableSoftwareInventoryAnalyzer,
onSave,
onReset,
saving,
@@ -150,6 +154,29 @@ export default function AgentAnalyzersSection({
)}
</div>
{/* Software Inventory & Vulnerability Analyzer */}
<div className="bg-rose-50 border border-rose-200 rounded-lg p-3">
<p className="text-sm text-rose-900">
The <strong>Software Inventory & Vulnerability Analyzer</strong> collects installed software from the
Windows registry at enrollment start and completion. It produces a normalized inventory snapshot,
detects software installed during enrollment (delta detection), and correlates findings against
known vulnerabilities (NVD CVEs, CISA KEV, MSRC).
</p>
</div>
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-rose-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Software Inventory & Vulnerability Analyzer</p>
<p className="text-sm text-gray-500">Collect installed software inventory and correlate against known vulnerabilities</p>
</div>
<button
onClick={() => setEnableSoftwareInventoryAnalyzer(!enableSoftwareInventoryAnalyzer)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableSoftwareInventoryAnalyzer ? 'bg-rose-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableSoftwareInventoryAnalyzer ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Save / Reset */}
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-gray-200">
<button
@@ -106,6 +106,7 @@ export default function SettingsPage() {
const [enableLocalAdminAnalyzer, setEnableLocalAdminAnalyzer] = useState(true);
const [localAdminAllowedAccounts, setLocalAdminAllowedAccounts] = useState<string[]>([]);
const [newAllowedAccount, setNewAllowedAccount] = useState("");
const [enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer] = useState(true);
// Unrestricted mode state
const [unrestrictedMode, setUnrestrictedMode] = useState(false);
@@ -182,6 +183,7 @@ export default function SettingsPage() {
} catch {
setLocalAdminAllowedAccounts([]);
}
setEnableSoftwareInventoryAnalyzer(data.enableSoftwareInventoryAnalyzer ?? true);
setUnrestrictedMode(data.unrestrictedMode ?? false);
// Parse SAS expiry and fire notification to bell if needed
@@ -346,6 +348,7 @@ export default function SettingsPage() {
diagnosticsLogPathsJson: tenantDiagPaths.length > 0 ? JSON.stringify(tenantDiagPaths) : undefined,
enableLocalAdminAnalyzer,
localAdminAllowedAccountsJson: localAdminAllowedAccounts.length > 0 ? JSON.stringify(localAdminAllowedAccounts) : undefined,
enableSoftwareInventoryAnalyzer,
unrestrictedMode,
};
@@ -527,6 +530,7 @@ export default function SettingsPage() {
setLocalAdminAllowedAccounts(config.localAdminAllowedAccountsJson ? JSON.parse(config.localAdminAllowedAccountsJson) : []);
} catch { setLocalAdminAllowedAccounts([]); }
setNewAllowedAccount("");
setEnableSoftwareInventoryAnalyzer(config.enableSoftwareInventoryAnalyzer ?? true);
};
const handleSaveNotifications = () => saveConfiguration("notifications");
@@ -1032,6 +1036,8 @@ export default function SettingsPage() {
setLocalAdminAllowedAccounts={setLocalAdminAllowedAccounts}
newAllowedAccount={newAllowedAccount}
setNewAllowedAccount={setNewAllowedAccount}
enableSoftwareInventoryAnalyzer={enableSoftwareInventoryAnalyzer}
setEnableSoftwareInventoryAnalyzer={setEnableSoftwareInventoryAnalyzer}
onSave={handleSaveAgentAnalyzers}
onReset={handleResetAgentAnalyzers}
saving={savingSection === "agentAnalyzers"}
@@ -51,6 +51,7 @@ export interface TenantConfiguration {
// Agent analyzer settings
enableLocalAdminAnalyzer?: boolean;
localAdminAllowedAccountsJson?: string;
enableSoftwareInventoryAnalyzer?: boolean;
// Bootstrap token
bootstrapTokenEnabled?: boolean;
// Unrestricted mode
File diff suppressed because one or more lines are too long