mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat: enhanced software inventory and vulnerability report
This commit is contained in:
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"lastUpdated": "2025-03-20",
|
||||
"mappings": []
|
||||
}
|
||||
+137
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+105
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -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)
|
||||
|
||||
+77
@@ -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);
|
||||
}
|
||||
|
||||
+429
@@ -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}–{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>
|
||||
);
|
||||
}
|
||||
+359
@@ -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>
|
||||
|
||||
+25
-2
@@ -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
Reference in New Issue
Block a user