feat: enhanced software inventory and vulnerability report

This commit is contained in:
Oliver Kieselbach
2026-03-20 19:01:16 +01:00
parent 65702f7a64
commit f1d653e60e
18 changed files with 1322 additions and 20 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"version": "1.0.0",
"lastUpdated": "2025-03-20",
"mappings": []
}
@@ -106,6 +106,143 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Analyzers
{ "node.js", "nodejs" },
{ "node.js foundation", "nodejs" },
{ "openjs foundation", "nodejs" },
// Endpoint security & AV
{ "sophos", "sophos" },
{ "sophos limited", "sophos" },
{ "eset, spol. s r.o.", "eset" },
{ "eset", "eset" },
{ "malwarebytes", "malwarebytes" },
{ "kaspersky", "kaspersky" },
{ "kaspersky lab", "kaspersky" },
{ "trend micro", "trendmicro" },
{ "trend micro inc.", "trendmicro" },
{ "mcafee, inc.", "mcafee" },
{ "mcafee, llc", "mcafee" },
{ "symantec corporation", "symantec" },
{ "broadcom inc.", "broadcom" },
{ "carbon black, inc.", "carbonblack" },
{ "sentinelone", "sentinelone" },
// VPN & network security
{ "zscaler, inc.", "zscaler" },
{ "zscaler", "zscaler" },
{ "netskope", "netskope" },
{ "netskope, inc.", "netskope" },
{ "pulse secure, llc", "pulsesecure" },
{ "juniper networks", "juniper" },
{ "openvpn inc.", "openvpn" },
{ "openvpn technologies", "openvpn" },
{ "wireguard", "wireguard" },
// Device management & enterprise
{ "glueckkanja ag", "glueckkanja" },
{ "glueckkanja-gab ag", "glueckkanja" },
{ "beyond trust", "beyondtrust" },
{ "beyondtrust software", "beyondtrust" },
{ "beyondtrust corporation", "beyondtrust" },
{ "ivanti", "ivanti" },
{ "ivanti, inc.", "ivanti" },
{ "1e", "1e" },
{ "tanium", "tanium" },
{ "tanium inc.", "tanium" },
{ "bigfix", "bigfix" },
{ "hcl technologies limited", "hcl" },
{ "sap se", "sap" },
{ "sap", "sap" },
{ "servicenow", "servicenow" },
// Communication & collaboration
{ "webex communications", "webex" },
{ "discord inc.", "discord" },
{ "signal messenger, llc", "signal" },
{ "signal", "signal" },
// Multimedia & documents
{ "videolan", "videolan" },
{ "videolan team", "videolan" },
{ "the document foundation", "libreoffice" },
{ "foxit software inc.", "foxit" },
{ "foxit", "foxit" },
{ "tracker software products", "pdfxchange" },
{ "audacity team", "audacity" },
{ "gimp", "gimp" },
{ "the gimp team", "gimp" },
{ "irfanview", "irfanview" },
{ "irfan skiljan", "irfanview" },
// Remote access
{ "anydesk software gmbh", "anydesk" },
{ "anydesk", "anydesk" },
{ "rustdesk", "rustdesk" },
{ "realvnc", "realvnc" },
{ "realvnc ltd", "realvnc" },
{ "devolutions", "devolutions" },
{ "devolutions inc.", "devolutions" },
{ "splashtop", "splashtop" },
{ "splashtop inc.", "splashtop" },
{ "bomgar", "bomgar" },
// Development tools
{ "jetbrains s.r.o.", "jetbrains" },
{ "jetbrains", "jetbrains" },
{ "postman, inc.", "postman" },
{ "postman", "postman" },
{ "docker inc.", "docker" },
{ "docker", "docker" },
{ "github, inc.", "github" },
{ "github", "github" },
{ "atlassian", "atlassian" },
{ "atlassian pty ltd", "atlassian" },
{ "hashicorp", "hashicorp" },
{ "hashicorp, inc.", "hashicorp" },
{ "sublime hq pty ltd", "sublimetext" },
// Utilities
{ "filezilla project", "filezilla" },
{ "tim kosse", "filezilla" },
{ "keepass", "keepass" },
{ "dominik reichl", "keepass" },
{ "greenshot", "greenshot" },
{ "paint.net", "paintnet" },
{ "dotpdn llc", "paintnet" },
{ "win32diskimager", "win32diskimager" },
{ "angusj", "7-zip" },
{ "rarlab", "winrar" },
{ "alexander roshal", "winrar" },
{ "winrar", "winrar" },
{ "wireshark foundation", "wireshark" },
{ "the wireshark team", "wireshark" },
{ "nmap project", "nmap" },
{ "insecure.com", "nmap" },
{ "voidtools", "everything" },
// Hardware vendors
{ "intel corporation", "intel" },
{ "intel", "intel" },
{ "nvidia corporation", "nvidia" },
{ "nvidia", "nvidia" },
{ "amd", "amd" },
{ "advanced micro devices, inc.", "amd" },
{ "realtek semiconductor", "realtek" },
{ "realtek", "realtek" },
{ "logitech", "logitech" },
{ "logitech inc.", "logitech" },
// Database & data tools
{ "dbeaver corp", "dbeaver" },
{ "mysql ab", "mysql" },
{ "mariadb", "mariadb" },
{ "the postgresql global development group", "postgresql" },
{ "pgadmin development team", "pgadmin" },
// Backup & imaging
{ "veeam software", "veeam" },
{ "veeam", "veeam" },
{ "acronis", "acronis" },
{ "acronis international gmbh", "acronis" },
{ "macrium software", "macrium" },
{ "corel corporation", "corel" },
};
// -----------------------------------------------------------------------
@@ -1 +1 @@
533
541
@@ -37,6 +37,7 @@
<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" />
<EmbeddedResource Include="..\..\..\rules\dist\cpe-community-mappings.json" LogicalName="AutopilotMonitor.Functions.Rules.cpe-community-mappings.json" />
</ItemGroup>
</Project>
@@ -1462,16 +1462,6 @@
"cpeUri": "cpe:2.3:a:jamf:jamf",
"category": "enterprise"
},
{
"normalizedVendor": "microsoft",
"normalizedProduct": "microsoft intune management extension",
"displayNamePatterns": ["microsoft intune management extension", "intune management extension"],
"publisherPatterns": ["microsoft", "microsoft corporation"],
"cpeVendor": "microsoft",
"cpeProduct": "intune_management_extension",
"cpeUri": "cpe:2.3:a:microsoft:intune_management_extension",
"category": "enterprise"
},
{
"normalizedVendor": "microsoft",
"normalizedProduct": "microsoft sql server",
@@ -1,6 +1,9 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Vulnerability;
using Azure.Data.Tables;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -82,6 +85,13 @@ namespace AutopilotMonitor.Functions.Functions.Admin
imeResult = new { deleted = d, written = w };
}
var cpeMappingsResult = new { deleted = 0, written = 0 };
if (typeParam == "all" || typeParam == "cpe")
{
var (d, w) = await ReseedCpeCommunityMappingsAsync();
cpeMappingsResult = new { deleted = d, written = w };
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
@@ -89,7 +99,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin
message = "Reseed from GitHub complete",
gather = gatherResult,
analyze = analyzeResult,
ime = imeResult
ime = imeResult,
cpeCommunityMappings = cpeMappingsResult
});
return response;
}
@@ -180,5 +191,54 @@ namespace AutopilotMonitor.Functions.Functions.Admin
_logger.LogInformation($"GitHub reseed IME: {deleted} deleted, {patterns.Count} written");
return (deleted, patterns.Count);
}
private async Task<(int deleted, int written)> ReseedCpeCommunityMappingsAsync()
{
var tableClient = _storageService.GetTableClient(AutopilotMonitor.Shared.Constants.TableNames.VulnerabilityCache);
// Delete existing community CPE mapping entries
var deleted = 0;
var existingEntities = tableClient.QueryAsync<TableEntity>(
filter: $"PartitionKey eq 'cpe_map_community'");
await foreach (var entity in existingEntities)
{
await tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey);
deleted++;
}
// Fetch community CPE mappings from GitHub
var json = await _gitHubRepo.FetchCpeCommunityMappingsAsync();
var seed = JsonSerializer.Deserialize<CpeMappingSeed>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
var written = 0;
if (seed?.Mappings != null)
{
foreach (var mapping in seed.Mappings)
{
var entity = new TableEntity("cpe_map_community", $"{mapping.CpeVendor}_{mapping.CpeProduct}")
{
{ "NormalizedVendor", mapping.NormalizedVendor ?? "" },
{ "NormalizedProduct", mapping.NormalizedProduct ?? "" },
{ "CpeVendor", mapping.CpeVendor ?? "" },
{ "CpeProduct", mapping.CpeProduct ?? "" },
{ "CpeUri", mapping.CpeUri ?? "" },
{ "Category", mapping.Category ?? "community" },
{ "DisplayNamePatternsJson", JsonSerializer.Serialize(mapping.DisplayNamePatterns ?? new List<string>()) },
{ "PublisherPatternsJson", JsonSerializer.Serialize(mapping.PublisherPatterns ?? new List<string>()) }
};
await tableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
written++;
}
}
// Reset the static seed-loaded flag so the next correlation picks up fresh data
VulnerabilityCorrelationService.ResetMappingsCache();
_logger.LogInformation("GitHub reseed community CPE mappings: {Deleted} deleted, {Written} written", deleted, written);
return (deleted, written);
}
}
}
@@ -37,7 +37,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
[Function("GetUnmatchedSoftware")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "admin/vulnerability/unmatched-software")] HttpRequestData req)
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "vulnerability/unmatched-software")] HttpRequestData req)
{
try
{
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using Azure.Data.Tables;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
public class SaveCustomCpeMappingFunction
{
private readonly TableStorageService _storageService;
private readonly ILogger<SaveCustomCpeMappingFunction> _logger;
public SaveCustomCpeMappingFunction(
TableStorageService storageService,
ILogger<SaveCustomCpeMappingFunction> logger)
{
_storageService = storageService;
_logger = logger;
}
[Function("SaveCustomCpeMapping")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "vulnerability/cpe-mapping")] HttpRequestData req)
{
try
{
// Galactic Admin only - require Authorization header
var authHeader = req.Headers.Contains("Authorization")
? req.Headers.GetValues("Authorization").FirstOrDefault()
: null;
if (string.IsNullOrEmpty(authHeader))
{
var unauthorized = req.CreateResponse(HttpStatusCode.Unauthorized);
return unauthorized;
}
var body = await req.ReadAsStringAsync();
var mapping = JsonSerializer.Deserialize<CpeMappingRequest>(body ?? "",
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (mapping == null || string.IsNullOrWhiteSpace(mapping.NormalizedProduct) || string.IsNullOrWhiteSpace(mapping.CpeUri))
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteAsJsonAsync(new { success = false, message = "normalizedProduct and cpeUri are required" });
return badReq;
}
var tableClient = _storageService.GetTableClient(Constants.TableNames.VulnerabilityCache);
var rowKey = $"{(mapping.NormalizedVendor ?? "unknown")}:{mapping.NormalizedProduct}".ToLowerInvariant();
// Sanitize RowKey (Table Storage doesn't allow /, \, #, ?)
rowKey = rowKey.Replace("/", "_").Replace("\\", "_").Replace("#", "_").Replace("?", "_");
var entity = new TableEntity("cpe_map_custom", rowKey)
{
{ "NormalizedVendor", mapping.NormalizedVendor ?? "" },
{ "NormalizedProduct", mapping.NormalizedProduct },
{ "CpeVendor", mapping.CpeVendor ?? "" },
{ "CpeProduct", mapping.CpeProduct ?? "" },
{ "CpeUri", mapping.CpeUri },
{ "Category", mapping.Category ?? "custom" },
{ "DisplayNamePatternsJson", JsonSerializer.Serialize(mapping.DisplayNamePatterns ?? new List<string>()) },
{ "PublisherPatternsJson", JsonSerializer.Serialize(mapping.PublisherPatterns ?? new List<string>()) },
{ "Source", "manual" },
{ "CreatedAt", DateTime.UtcNow.ToString("o") }
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogInformation("Custom CPE mapping saved: {RowKey} -> {CpeUri}", rowKey, mapping.CpeUri);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true, message = "CPE mapping saved", rowKey });
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save custom CPE mapping");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Failed to save mapping" });
return errorResponse;
}
}
private class CpeMappingRequest
{
public string NormalizedVendor { get; set; } = "";
public string NormalizedProduct { get; set; } = "";
public string CpeVendor { get; set; } = "";
public string CpeProduct { get; set; } = "";
public string CpeUri { get; set; } = "";
public string Category { get; set; } = "custom";
public List<string> DisplayNamePatterns { get; set; } = new();
public List<string> PublisherPatterns { get; set; } = new();
}
}
}
@@ -0,0 +1,71 @@
using System;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Vulnerability;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
/// <summary>
/// HTTP-triggered manual sync for vulnerability data (KEV catalog refresh).
/// Galactic Admin only.
/// </summary>
public class TriggerVulnerabilityDataSyncFunction
{
private readonly KevDataService _kevService;
private readonly AdminConfigurationService _adminConfigService;
private readonly GalacticAdminService _galacticAdminService;
private readonly ILogger<TriggerVulnerabilityDataSyncFunction> _logger;
public TriggerVulnerabilityDataSyncFunction(
KevDataService kevService,
AdminConfigurationService adminConfigService,
GalacticAdminService galacticAdminService,
ILogger<TriggerVulnerabilityDataSyncFunction> logger)
{
_kevService = kevService;
_adminConfigService = adminConfigService;
_galacticAdminService = galacticAdminService;
_logger = logger;
}
[Function("TriggerVulnerabilityDataSync")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "vulnerability/sync")] HttpRequestData req)
{
try
{
// Refresh KEV catalog
await _kevService.RefreshAsync();
// Update sync timestamp
var config = await _adminConfigService.GetConfigurationAsync();
if (config != null)
{
config.VulnerabilityDataLastSyncUtc = DateTime.UtcNow.ToString("o");
await _adminConfigService.SaveConfigurationAsync(config);
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
message = "Vulnerability data sync completed",
kevCatalogEntries = _kevService.CatalogSize,
syncedAt = DateTime.UtcNow.ToString("o")
});
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Manual vulnerability sync failed");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Sync failed: " + ex.Message });
return errorResponse;
}
}
}
}
@@ -68,6 +68,15 @@ namespace AutopilotMonitor.Functions.Services
return patterns;
}
public async Task<string> FetchCpeCommunityMappingsAsync()
{
var url = $"{_baseUrl}/dist/cpe-community-mappings.json";
_logger.LogInformation("Fetching community CPE mappings from {Url}", url);
var json = await FetchWithRetryAsync(url);
_logger.LogInformation("Fetched community CPE mappings JSON from GitHub ({Length} bytes)", json.Length);
return json;
}
/// <summary>
/// Fetches a URL with retry logic and exponential backoff.
/// Retries on transient HTTP errors (408, 429, 5xx) and network exceptions.
@@ -230,6 +230,9 @@ namespace AutopilotMonitor.Functions.Services
private bool EvaluateConfidenceFactor(ConfidenceFactor factor, List<EnrollmentEvent> events, Dictionary<string, object> matchedConditions)
{
if (string.IsNullOrEmpty(factor.Condition))
return false;
if (factor.Condition.StartsWith("phase_duration >"))
{
if (int.TryParse(factor.Condition.Replace("phase_duration >", "").Trim(), out var threshold))
@@ -44,7 +44,10 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
/// </summary>
public async Task<NvdCveResponse> SearchCvesByCpeAsync(string cpeName, CancellationToken ct = default)
{
var url = $"{NvdCveUrl}?cpeName={Uri.EscapeDataString(cpeName)}&resultsPerPage=100";
// Use virtualMatchString for partial CPE matches (without version).
// cpeName requires a full CPE URI with version which we don't always have.
// virtualMatchString matches all versions of a product.
var url = $"{NvdCveUrl}?virtualMatchString={Uri.EscapeDataString(cpeName)}&resultsPerPage=100";
return await ExecuteWithRateLimitAsync<NvdCveResponse>(url, ct);
}
@@ -63,10 +66,13 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
await _rateLimiter.WaitAsync(ct);
try
{
// Determine rate limit delay based on API key presence
// Determine rate limit delay based on API key presence.
// NIST recommends "sleep for several seconds between requests" even with an API key.
// We use conservative delays well below the hard limits (50/30s with key, 5/30s without)
// to avoid firewall blocks and ensure all requests are served in sequence.
var adminConfig = await _adminConfig.GetConfigurationAsync();
bool hasApiKey = !string.IsNullOrWhiteSpace(adminConfig?.NvdApiKey);
int delayMs = hasApiKey ? 600 : 6000; // 50/30s vs 5/30s
int delayMs = hasApiKey ? 2000 : 8000; // ~15 req/30s with key, ~3.7 req/30s without
var elapsed = (DateTime.UtcNow - _lastRequestTime).TotalMilliseconds;
if (elapsed < delayMs)
@@ -48,6 +48,16 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
_logger = logger;
}
/// <summary>
/// Resets the static mappings cache so the next call to EnsureMappingsLoadedAsync
/// reloads all mappings (seed, custom, community) from their sources.
/// Called after reseed operations that update community mappings in table storage.
/// </summary>
public static void ResetMappingsCache()
{
_seedLoaded = false;
}
public async Task<EnrollmentEvent> CorrelateAsync(
string sessionId,
string tenantId,
@@ -478,6 +488,73 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
_logger.LogDebug(ex, "No custom CPE mappings loaded (table may not exist yet)");
}
// Load community CPE mappings (table storage from reseed, falling back to embedded resource)
int communityCount = 0;
try
{
var vulnTable = _storage.GetTableClient(AutopilotMonitor.Shared.Constants.TableNames.VulnerabilityCache);
var communityMappings = vulnTable.QueryAsync<TableEntity>(
filter: $"PartitionKey eq 'cpe_map_community'");
await foreach (var entity in communityMappings)
{
_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") ?? "community",
DisplayNamePatterns = JsonSerializer.Deserialize<List<string>>(
entity.GetString("DisplayNamePatternsJson") ?? "[]"),
PublisherPatterns = JsonSerializer.Deserialize<List<string>>(
entity.GetString("PublisherPatternsJson") ?? "[]")
});
communityCount++;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "No community CPE mappings in table storage, falling back to embedded resource");
}
// Fallback: load from embedded resource if table had no community entries
if (communityCount == 0)
{
try
{
var assembly = Assembly.GetExecutingAssembly();
var communityResourceName = assembly.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith("cpe-community-mappings.json", StringComparison.OrdinalIgnoreCase));
if (communityResourceName != null)
{
using var communityStream = assembly.GetManifestResourceStream(communityResourceName);
if (communityStream != null)
{
using var communityReader = new StreamReader(communityStream);
var communityJson = await communityReader.ReadToEndAsync();
var communitySeed = JsonSerializer.Deserialize<CpeMappingSeed>(communityJson, JsonOptions);
if (communitySeed?.Mappings != null && communitySeed.Mappings.Count > 0)
{
_cpeMappings.AddRange(communitySeed.Mappings);
communityCount = communitySeed.Mappings.Count;
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load community CPE mappings from embedded resource");
}
}
if (communityCount > 0)
{
_logger.LogInformation("Loaded {Count} community CPE mappings", communityCount);
}
_seedLoaded = true;
_logger.LogInformation("CPE mappings ready: {Count} total entries", _cpeMappings.Count);
}
@@ -0,0 +1,429 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
interface UnmatchedSoftwareEntry {
softwareName: string;
publisher: string;
frequency: number;
lastSeenAt: string;
exampleSessionId: string;
}
interface UnmatchedSoftwareSectionProps {
getAccessToken: () => Promise<string | null>;
setError: (error: string | null) => void;
}
export function UnmatchedSoftwareSection({
getAccessToken,
setError,
}: UnmatchedSoftwareSectionProps) {
const [expanded, setExpanded] = useState(false);
const [loading, setLoading] = useState(false);
const [entries, setEntries] = useState<UnmatchedSoftwareEntry[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(0);
const pageSize = 20;
// CPE mapping state
const [expandedMappingRow, setExpandedMappingRow] = useState<string | null>(null);
const [cpeInputs, setCpeInputs] = useState<Record<string, string>>({});
const [savingMapping, setSavingMapping] = useState<string | null>(null);
const [savedMappings, setSavedMappings] = useState<Set<string>>(new Set());
const fetchUnmatchedSoftware = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/vulnerability/unmatched-software`,
getAccessToken
);
if (!response.ok) {
throw new Error(`Failed to load unmatched software: ${response.statusText}`);
}
const data = await response.json();
const sorted = (data.software || []).sort(
(a: UnmatchedSoftwareEntry, b: UnmatchedSoftwareEntry) => b.frequency - a.frequency
);
setEntries(sorted);
setTotal(data.total ?? sorted.length);
setCurrentPage(0);
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while fetching unmatched software");
} else {
console.error("Error fetching unmatched software:", err);
}
setError(err instanceof Error ? err.message : "Failed to load unmatched software");
} finally {
setLoading(false);
}
}, [getAccessToken, setError]);
useEffect(() => {
if (expanded && entries.length === 0) {
fetchUnmatchedSoftware();
}
}, [expanded, entries.length, fetchUnmatchedSoftware]);
const totalPages = Math.ceil(entries.length / pageSize);
const paginatedEntries = entries.slice(
currentPage * pageSize,
(currentPage + 1) * pageSize
);
const formatDate = (dateStr: string): string => {
try {
return new Date(dateStr).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return dateStr;
}
};
const getFrequencyBadgeClasses = (frequency: number): string => {
if (frequency >= 100) {
return "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300";
}
if (frequency >= 50) {
return "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300";
}
if (frequency >= 20) {
return "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300";
}
if (frequency >= 5) {
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300";
}
return "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300";
};
const getRowKey = (entry: UnmatchedSoftwareEntry): string =>
`${entry.softwareName}::${entry.publisher}`;
const toggleMappingRow = (entry: UnmatchedSoftwareEntry) => {
const key = getRowKey(entry);
if (expandedMappingRow === key) {
setExpandedMappingRow(null);
} else {
setExpandedMappingRow(key);
// Pre-fill if not already set
if (!cpeInputs[key]) {
setCpeInputs((prev) => ({ ...prev, [key]: "" }));
}
}
};
const handleSaveMapping = async (entry: UnmatchedSoftwareEntry) => {
const key = getRowKey(entry);
const cpeUri = (cpeInputs[key] || "").trim();
if (!cpeUri) return;
try {
setSavingMapping(key);
setError(null);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/vulnerability/cpe-mapping`,
getAccessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
normalizedProduct: entry.softwareName,
normalizedVendor: entry.publisher || "",
cpeUri: cpeUri,
displayNamePatterns: [entry.softwareName],
publisherPatterns: entry.publisher ? [entry.publisher] : [],
}),
}
);
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.message || `Failed to save mapping: ${response.statusText}`);
}
setSavedMappings((prev) => new Set(prev).add(key));
setExpandedMappingRow(null);
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while saving CPE mapping");
} else {
console.error("Error saving CPE mapping:", err);
}
setError(err instanceof Error ? err.message : "Failed to save CPE mapping");
} finally {
setSavingMapping(null);
}
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden border border-amber-200 dark:border-amber-800">
{/* Header */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-gradient-to-br from-amber-100 to-orange-100 dark:from-amber-900 dark:to-orange-900 rounded-lg flex items-center justify-center">
<svg className="w-4 h-4 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<div className="text-left">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Unmatched Software (Vulnerability Analyzer)
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Software frequently seen across sessions that could not be mapped to a CPE identifier for vulnerability correlation. High-frequency entries should be added to the CPE mapping seed.
</p>
</div>
</div>
<svg
className={`w-5 h-5 text-gray-400 transition-transform shrink-0 ml-4 ${expanded ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Content */}
{expanded && (
<div className="px-6 pb-6 border-t border-gray-200 dark:border-gray-700">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600" />
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading unmatched software...</span>
</div>
) : entries.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
<p className="text-sm">No unmatched software found. All detected software has been mapped to CPE identifiers.</p>
</div>
) : (
<>
{/* Stats bar */}
<div className="flex flex-wrap items-center gap-4 py-4 text-sm">
<span className="text-gray-600 dark:text-gray-300">
<span className="font-semibold text-amber-600 dark:text-amber-400">{total}</span> unmatched software entries
</span>
<button
onClick={fetchUnmatchedSoftware}
className="ml-auto text-sm text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 flex items-center gap-1.5"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.996 19.632h-4.991" />
</svg>
Refresh
</button>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Software Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Publisher</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Frequency</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Seen</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Example Session</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{paginatedEntries.map((entry) => {
const key = getRowKey(entry);
const isMapped = savedMappings.has(key);
const isMappingExpanded = expandedMappingRow === key;
const isSaving = savingMapping === key;
return (
<tr key={key} className="group">
{/* Main row */}
<td colSpan={6} className="p-0">
<div
className={`hover:bg-amber-50 dark:hover:bg-amber-900/10 transition-colors ${isMappingExpanded ? "bg-amber-50/50 dark:bg-amber-900/5" : ""}`}
>
<div className="flex">
<div className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 flex-1 min-w-0">
{entry.softwareName}
</div>
<div className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400 flex-1 min-w-0">
{entry.publisher || <span className="text-gray-300 dark:text-gray-600 italic">unknown</span>}
</div>
<div className="px-4 py-3 text-sm flex-shrink-0 w-32">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${getFrequencyBadgeClasses(entry.frequency)}`}>
{entry.frequency} {entry.frequency === 1 ? "session" : "sessions"}
</span>
</div>
<div className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap flex-shrink-0 w-28">
{formatDate(entry.lastSeenAt)}
</div>
<div className="px-4 py-3 text-sm flex-shrink-0 w-28">
<a
href={`/sessions/${entry.exampleSessionId}`}
className="text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 font-mono text-xs hover:underline"
onClick={(e) => e.stopPropagation()}
>
{entry.exampleSessionId.length > 8
? `${entry.exampleSessionId.slice(0, 8)}...`
: entry.exampleSessionId}
</a>
</div>
<div className="px-4 py-3 text-sm flex-shrink-0 w-40 flex items-center gap-2">
{isMapped ? (
<span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400 font-medium">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
Mapped
</span>
) : (
<>
<button
onClick={() => toggleMappingRow(entry)}
className={`text-xs px-2.5 py-1 rounded-md font-medium transition-colors ${
isMappingExpanded
? "bg-amber-200 text-amber-800 dark:bg-amber-800 dark:text-amber-200"
: "bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/50 dark:text-amber-400 dark:hover:bg-amber-800/50"
}`}
>
Map
</button>
<a
href={`https://nvd.nist.gov/products/cpe/search/results?keyword=${encodeURIComponent(entry.softwareName)}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-gray-500 hover:text-amber-600 dark:text-gray-400 dark:hover:text-amber-400 transition-colors"
title="Search NVD for CPE"
onClick={(e) => e.stopPropagation()}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
</>
)}
</div>
</div>
{/* Inline mapping form */}
{isMappingExpanded && (
<div className="px-4 pb-3 pt-1 border-t border-amber-100 dark:border-amber-900/30 bg-amber-50/80 dark:bg-amber-900/10">
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap flex-shrink-0">
CPE URI:
</label>
<input
type="text"
placeholder="cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*"
value={cpeInputs[key] || ""}
onChange={(e) =>
setCpeInputs((prev) => ({ ...prev, [key]: e.target.value }))
}
onKeyDown={(e) => {
if (e.key === "Enter" && (cpeInputs[key] || "").trim()) {
handleSaveMapping(entry);
}
if (e.key === "Escape") {
setExpandedMappingRow(null);
}
}}
className="flex-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none"
autoFocus
disabled={isSaving}
/>
<button
onClick={() => handleSaveMapping(entry)}
disabled={isSaving || !(cpeInputs[key] || "").trim()}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5 flex-shrink-0"
>
{isSaving ? (
<>
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white" />
Saving...
</>
) : (
"Save Mapping"
)}
</button>
<button
onClick={() => setExpandedMappingRow(null)}
disabled={isSaving}
className="px-2 py-1.5 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors flex-shrink-0"
>
Cancel
</button>
<a
href={`https://nvd.nist.gov/products/cpe/search/results?keyword=${encodeURIComponent(entry.softwareName)}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 whitespace-nowrap flex items-center gap-1 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
Search NVD
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
</div>
</div>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md mt-2">
<span className="text-xs text-gray-500 dark:text-gray-400">
{currentPage * pageSize + 1}&ndash;{Math.min((currentPage + 1) * pageSize, entries.length)} of {entries.length}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage((p) => p - 1)}
disabled={currentPage === 0}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-xs text-gray-500 dark:text-gray-400">
{currentPage + 1} / {totalPages}
</span>
<button
onClick={() => setCurrentPage((p) => p + 1)}
disabled={(currentPage + 1) * pageSize >= entries.length}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,359 @@
"use client";
import { useCallback, useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import SaveResetBar from "../../settings/components/SaveResetBar";
interface AdminConfiguration {
partitionKey: string;
rowKey: string;
lastUpdated: string;
updatedBy: string;
globalRateLimitRequestsPerMinute: number;
platformStatsBlobSasUrl?: string;
collectorIdleTimeoutMinutes?: number;
maxSessionWindowHours?: number;
maintenanceBlockDurationHours?: number;
diagnosticsGlobalLogPathsJson?: string;
customSettings?: string;
nvdApiKey?: string;
vulnerabilityCorrelationEnabled?: boolean;
vulnerabilityDataLastSyncUtc?: string;
}
interface VulnerabilityDataSectionProps {
adminConfig: AdminConfiguration | null;
loadingConfig: boolean;
getAccessToken: () => Promise<string | null>;
setError: (error: string | null) => void;
setSuccessMessage: (message: string | null) => void;
onAdminConfigUpdated: (config: AdminConfiguration) => void;
}
export function VulnerabilityDataSection({
adminConfig,
loadingConfig,
getAccessToken,
setError,
setSuccessMessage,
onAdminConfigUpdated,
}: VulnerabilityDataSectionProps) {
// Local state for editable fields
const [nvdApiKey, setNvdApiKey] = useState(adminConfig?.nvdApiKey ?? "");
const [showApiKey, setShowApiKey] = useState(false);
const [vulnerabilityCorrelationEnabled, setVulnerabilityCorrelationEnabled] = useState(
adminConfig?.vulnerabilityCorrelationEnabled ?? true
);
const [saving, setSaving] = useState(false);
// Sync state
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<{
success: boolean;
message: string;
kevCatalogEntries?: number;
syncedAt?: string;
} | null>(null);
// Sync local state when adminConfig changes (initial load)
const [lastConfigKey, setLastConfigKey] = useState<string | null>(null);
const configKey = adminConfig
? `${adminConfig.nvdApiKey ?? ""}-${adminConfig.vulnerabilityCorrelationEnabled}`
: null;
if (configKey && configKey !== lastConfigKey) {
setLastConfigKey(configKey);
setNvdApiKey(adminConfig?.nvdApiKey ?? "");
setVulnerabilityCorrelationEnabled(adminConfig?.vulnerabilityCorrelationEnabled ?? true);
}
const handleSave = useCallback(async () => {
if (!adminConfig) return;
setSaving(true);
setError(null);
setSuccessMessage(null);
try {
const updatedConfig: AdminConfiguration = {
...adminConfig,
nvdApiKey: nvdApiKey.trim() || undefined,
vulnerabilityCorrelationEnabled,
};
const response = await authenticatedFetch(`${API_BASE_URL}/api/global/config`, getAccessToken, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedConfig),
});
if (!response.ok) {
throw new Error(`Failed to save vulnerability configuration: ${response.statusText}`);
}
const result = await response.json();
onAdminConfigUpdated(result.config);
setSuccessMessage("Vulnerability configuration saved successfully!");
setTimeout(() => setSuccessMessage(null), 4000);
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while saving vulnerability configuration");
}
const message = err instanceof Error ? err.message : "Failed to save vulnerability configuration";
setError(message);
throw err; // Let SaveResetBar show "Save failed"
} finally {
setSaving(false);
}
}, [adminConfig, nvdApiKey, vulnerabilityCorrelationEnabled, getAccessToken, setError, setSuccessMessage, onAdminConfigUpdated]);
const handleReset = useCallback(() => {
setNvdApiKey(adminConfig?.nvdApiKey ?? "");
setVulnerabilityCorrelationEnabled(adminConfig?.vulnerabilityCorrelationEnabled ?? true);
setSyncResult(null);
}, [adminConfig]);
const handleSync = useCallback(async () => {
setSyncing(true);
setSyncResult(null);
setError(null);
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/vulnerability/sync`,
getAccessToken,
{ method: "POST" }
);
if (!response.ok) {
throw new Error(`Sync failed: ${response.statusText}`);
}
const data = await response.json();
setSyncResult({
success: true,
message: data.message || "Sync completed",
kevCatalogEntries: data.kevCatalogEntries,
syncedAt: data.syncedAt,
});
// Refresh admin config to get updated timestamp
try {
const configResponse = await authenticatedFetch(`${API_BASE_URL}/api/global/config`, getAccessToken);
if (configResponse.ok) {
const updatedConfig = await configResponse.json();
onAdminConfigUpdated(updatedConfig);
}
} catch {
// Non-critical: config refresh after sync
}
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired during vulnerability sync");
}
setSyncResult({
success: false,
message: err instanceof Error ? err.message : "Sync failed",
});
setError(err instanceof Error ? err.message : "Vulnerability data sync failed");
} finally {
setSyncing(false);
}
}, [getAccessToken, setError, onAdminConfigUpdated]);
const formatTimestamp = (ts: string | undefined): string => {
if (!ts) return "Never";
try {
return new Date(ts).toLocaleString();
} catch {
return ts;
}
};
return (
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-gray-800 dark:to-gray-800 border-2 border-emerald-300 dark:border-emerald-700 rounded-lg shadow-lg">
{/* Header */}
<div className="p-6 border-b border-emerald-200 dark:border-emerald-700 bg-gradient-to-r from-emerald-100 to-teal-100 dark:from-emerald-900/40 dark:to-teal-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
<div>
<h2 className="text-xl font-semibold text-emerald-900 dark:text-emerald-100">Vulnerability Data Configuration</h2>
<p className="text-sm text-emerald-600 dark:text-emerald-300 mt-1">Configure vulnerability correlation data sources and API keys.</p>
</div>
</div>
</div>
{/* Content */}
<div className="p-6">
{loadingConfig ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600 dark:border-emerald-400 mx-auto"></div>
<p className="mt-3 text-emerald-800 dark:text-emerald-200 text-sm">Loading configuration...</p>
</div>
) : (
<div className="space-y-6">
{/* NVD API Key */}
<div>
<label className="block">
<span className="text-emerald-900 dark:text-emerald-100 font-medium">NVD API Key</span>
<p className="text-sm text-emerald-800 dark:text-gray-300 mb-2">
Optional. Increases NVD rate limit from 5 to 50 requests per 30 seconds. Get a free key at{" "}
<a
href="https://nvd.nist.gov/developers/request-an-api-key"
target="_blank"
rel="noopener noreferrer"
className="text-emerald-600 dark:text-emerald-400 underline hover:text-emerald-700 dark:hover:text-emerald-300"
>
nvd.nist.gov
</a>
</p>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={nvdApiKey}
onChange={(e) => setNvdApiKey(e.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
className="mt-1 block w-full px-4 py-2 pr-12 border border-emerald-300 dark:border-emerald-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-colors font-mono text-sm"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 mt-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
title={showApiKey ? "Hide API key" : "Show API key"}
>
{showApiKey ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)}
</button>
</div>
</label>
</div>
{/* Vulnerability Correlation Enabled Toggle */}
<div>
<div className="flex items-center justify-between">
<div className="flex-1 mr-4">
<span className="text-emerald-900 dark:text-emerald-100 font-medium">Enable Vulnerability Correlation</span>
<p className="text-sm text-emerald-800 dark:text-gray-300 mt-1">
When enabled, the backend correlates software inventory against NVD CVEs, CISA KEV, and MSRC. When disabled, agents still collect inventory but no correlation runs.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={vulnerabilityCorrelationEnabled}
onClick={() => setVulnerabilityCorrelationEnabled(!vulnerabilityCorrelationEnabled)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
vulnerabilityCorrelationEnabled
? "bg-emerald-600"
: "bg-gray-300 dark:bg-gray-600"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
vulnerabilityCorrelationEnabled ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
</div>
{/* Sync Status */}
<div className="bg-emerald-50 dark:bg-gray-700 border border-emerald-200 dark:border-emerald-600 rounded-lg p-4">
<h3 className="text-sm font-medium text-emerald-900 dark:text-emerald-100 mb-3">Data Sync Status</h3>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-emerald-800 dark:text-gray-300">Last Sync</span>
<span className="text-gray-900 dark:text-gray-100 font-mono text-xs">
{formatTimestamp(adminConfig?.vulnerabilityDataLastSyncUtc)}
</span>
</div>
{syncResult?.kevCatalogEntries !== undefined && (
<div className="flex items-center justify-between text-sm">
<span className="text-emerald-800 dark:text-gray-300">KEV Catalog Entries</span>
<span className="text-gray-900 dark:text-gray-100 font-semibold">
{syncResult.kevCatalogEntries.toLocaleString()}
</span>
</div>
)}
{/* Sync Result Message */}
{syncResult && (
<div
className={`mt-2 p-2 rounded text-sm ${
syncResult.success
? "bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300 border border-green-200 dark:border-green-800"
: "bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300 border border-red-200 dark:border-red-800"
}`}
>
{syncResult.success ? (
<div className="flex items-center space-x-2">
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{syncResult.message}</span>
</div>
) : (
<div className="flex items-center space-x-2">
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{syncResult.message}</span>
</div>
)}
</div>
)}
{/* Sync Now Button */}
<div className="pt-2">
<button
onClick={handleSync}
disabled={syncing}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
>
{syncing ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
<span>Syncing...</span>
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.996 19.632h-4.991" />
</svg>
<span>Sync Now</span>
</>
)}
</button>
</div>
</div>
</div>
{/* NVD Attribution Notice */}
<div className="bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-3">
<p className="text-xs text-gray-500 dark:text-gray-400 italic">
This product uses the NVD API but is not endorsed or certified by the NVD.
</p>
</div>
{/* Save / Reset Bar */}
<SaveResetBar
onSave={handleSave}
onReset={handleReset}
saving={saving}
/>
</div>
)}
</div>
</div>
);
}
@@ -15,9 +15,11 @@ import { SessionExportSection } from "./components/SessionExportSection";
import { AdminConfigSettingsSection } from "./components/AdminConfigSettingsSection";
import { SessionReportsSection } from "./components/SessionReportsSection";
import { FeedbackSection } from "./components/FeedbackSection";
import { UnmatchedSoftwareSection } from "./components/UnmatchedSoftwareSection";
import { VulnerabilityDataSection } from "./components/VulnerabilityDataSection";
import { usePageSections } from "../../hooks/usePageSections";
import { PageSectionItem } from "../../contexts/SidebarContext";
import { BuildingOfficeIcon, DocumentTextIcon, ArrowDownTrayIcon, GearIcon, FolderIcon, WrenchScrewdriverIcon, NoSymbolIcon, ArrowPathIcon, SparklesIcon } from "../../lib/sidebarIcons";
import { BuildingOfficeIcon, DocumentTextIcon, ArrowDownTrayIcon, GearIcon, FolderIcon, WrenchScrewdriverIcon, NoSymbolIcon, ArrowPathIcon, SparklesIcon, ShieldCheckIcon, KeyIcon } from "../../lib/sidebarIcons";
interface AdminConfiguration {
partitionKey: string;
@@ -31,6 +33,9 @@ interface AdminConfiguration {
maintenanceBlockDurationHours?: number;
diagnosticsGlobalLogPathsJson?: string;
customSettings?: string;
nvdApiKey?: string;
vulnerabilityCorrelationEnabled?: boolean;
vulnerabilityDataLastSyncUtc?: string;
}
export default function AdminConfigurationPage() {
@@ -262,6 +267,8 @@ export default function AdminConfigurationPage() {
{ id: "fetch-and-reseed", label: "Fetch & Reseed", icon: <ArrowPathIcon /> },
{ id: "device-block", label: "Device Block", icon: <NoSymbolIcon /> },
{ id: "version-block", label: "Version Block", icon: <NoSymbolIcon /> },
{ id: "unmatched-software", label: "Unmatched Software", icon: <ShieldCheckIcon /> },
{ id: "vulnerability-data", label: "Vulnerability Data", icon: <KeyIcon /> },
], []);
usePageSections(adminSections, "Admin", "scroll-spy");
@@ -412,6 +419,26 @@ export default function AdminConfigurationPage() {
setSuccessMessage={setSuccessMessage}
/>
</div>
{/* Unmatched Software (Vulnerability Analyzer) */}
<div id="unmatched-software">
<UnmatchedSoftwareSection
getAccessToken={getAccessToken}
setError={setError}
/>
</div>
{/* Vulnerability Data Configuration */}
<div id="vulnerability-data">
<VulnerabilityDataSection
adminConfig={adminConfig}
loadingConfig={loadingConfig}
getAccessToken={getAccessToken}
setError={setError}
setSuccessMessage={setSuccessMessage}
onAdminConfigUpdated={setAdminConfig}
/>
</div>
</div>
</main>
</div>
@@ -82,6 +82,14 @@ function confidenceBadgeColor(confidence?: string): string {
return "bg-gray-100 text-gray-700";
}
function confidenceTooltip(confidence?: string): string {
const c = (confidence || "").toLowerCase();
if (c === "high") return "High: Publisher and version clearly identified — reliable for CVE matching";
if (c === "medium") return "Medium: Publisher or version identified, but not both — CVE match may be imprecise";
if (c === "low") return "Low: Publisher and version unclear — unlikely to match against known vulnerabilities";
return "Unknown identification confidence";
}
export default function VulnerabilityReportSection({
events,
expanded,
@@ -557,6 +565,9 @@ export default function VulnerabilityReportSection({
Data Freshness: {dataFreshness}
</div>
)}
<p className="text-xs text-gray-400 mt-2 italic">
This product uses the NVD API but is not endorsed or certified by the NVD.
</p>
</>
)}
</div>
@@ -602,7 +613,7 @@ export default function VulnerabilityReportSection({
<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)}`}>
<span className={`px-2 py-0.5 rounded text-xs font-medium cursor-help ${confidenceBadgeColor(item.confidence)}`} title={confidenceTooltip(item.confidence)}>
{item.confidence}
</span>
) : (
@@ -624,6 +635,12 @@ export default function VulnerabilityReportSection({
<div className="mt-2 text-xs text-gray-400">
Showing {filteredInventory.length} of {inventoryItems.length} items
</div>
<div className="mt-3 pt-3 border-t border-gray-100 flex items-start gap-4 text-xs text-gray-400">
<span className="font-medium text-gray-500 whitespace-nowrap">Identification Confidence:</span>
<span><span className="inline-block px-1.5 py-0.5 rounded bg-green-100 text-green-700 font-medium mr-1">high</span> Publisher + version identified</span>
<span><span className="inline-block px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700 font-medium mr-1">medium</span> Partial match</span>
<span><span className="inline-block px-1.5 py-0.5 rounded bg-red-100 text-red-700 font-medium mr-1">low</span> Unidentified</span>
</div>
</>
)}
</div>
@@ -665,7 +682,7 @@ export default function VulnerabilityReportSection({
<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)}`}>
<span className={`px-2 py-0.5 rounded text-xs font-medium cursor-help ${confidenceBadgeColor(item.confidence)}`} title={confidenceTooltip(item.confidence)}>
{item.confidence}
</span>
) : (
@@ -677,6 +694,12 @@ export default function VulnerabilityReportSection({
</tbody>
</table>
</div>
<div className="mt-3 pt-3 border-t border-gray-100 flex items-start gap-4 text-xs text-gray-400">
<span className="font-medium text-gray-500 whitespace-nowrap">Identification Confidence:</span>
<span><span className="inline-block px-1.5 py-0.5 rounded bg-green-100 text-green-700 font-medium mr-1">high</span> Publisher + version identified</span>
<span><span className="inline-block px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700 font-medium mr-1">medium</span> Partial match</span>
<span><span className="inline-block px-1.5 py-0.5 rounded bg-red-100 text-red-700 font-medium mr-1">low</span> Unidentified</span>
</div>
</>
)}
</div>
File diff suppressed because one or more lines are too long