mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat: Software Inventory Analyzer with Vulnerability Correlation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+546
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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,
|
||||
|
||||
+128
-20
@@ -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")]
|
||||
|
||||
+139
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -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 < v2, 0 if equal, positive if v1 > 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+517
@@ -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).
|
||||
|
||||
+806
@@ -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">↕</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
Reference in New Issue
Block a user