better CPE matching (Fuzzy Jaro-Winkler) + matching telemetry for matching engine fine tuning

This commit is contained in:
Oliver Kieselbach
2026-04-06 23:09:01 +02:00
parent 67b514f988
commit 90efdca330
12 changed files with 1551 additions and 32 deletions
@@ -1 +1 @@
873
877
@@ -0,0 +1,215 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
/// <summary>
/// Bulk auto-resolve unmapped software by searching the NVD CPE dictionary.
/// Accepts up to 25 items per request. For each item, searches NVD and saves
/// the best CPE match as a custom mapping if the score meets the threshold.
/// </summary>
public class AutoResolveCpeMappingFunction
{
private readonly NvdApiClient _nvdApi;
private readonly IVulnerabilityRepository _vulnRepo;
private readonly ILogger<AutoResolveCpeMappingFunction> _logger;
private const int MaxItemsPerRequest = 25;
public AutoResolveCpeMappingFunction(
NvdApiClient nvdApi,
IVulnerabilityRepository vulnRepo,
ILogger<AutoResolveCpeMappingFunction> logger)
{
_nvdApi = nvdApi;
_vulnRepo = vulnRepo;
_logger = logger;
}
[Function("AutoResolveCpeMapping")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "vulnerability/cpe-mapping/auto-resolve")] HttpRequestData req)
{
try
{
// Global Admin only — enforced by PolicyEnforcementMiddleware
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var body = await req.ReadAsStringAsync();
var request = JsonSerializer.Deserialize<AutoResolveRequest>(body ?? "",
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (request?.Items == null || request.Items.Count == 0)
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteAsJsonAsync(new { success = false, message = "items array is required" });
return badReq;
}
if (request.Items.Count > MaxItemsPerRequest)
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteAsJsonAsync(new { success = false, message = $"Maximum {MaxItemsPerRequest} items per request" });
return badReq;
}
// Deduplicate by softwareName
var uniqueItems = request.Items
.GroupBy(i => (i.SoftwareName ?? "").ToLowerInvariant())
.Select(g => g.First())
.ToList();
var resolved = new List<AutoResolveResultItem>();
var failed = new List<AutoResolveFailedItem>();
foreach (var item in uniqueItems)
{
if (string.IsNullOrWhiteSpace(item.SoftwareName))
{
_logger.LogWarning("Bulk auto-resolve: skipping item with empty name");
failed.Add(new AutoResolveFailedItem { SoftwareName = item.SoftwareName ?? "", Reason = "empty_name" });
continue;
}
try
{
var keyword = CpeMatchScorer.BuildSearchKeyword(item.SoftwareName, item.NormalizedVendor ?? item.Publisher ?? "");
if (string.IsNullOrWhiteSpace(keyword))
{
failed.Add(new AutoResolveFailedItem { SoftwareName = item.SoftwareName, Reason = "empty_keyword" });
continue;
}
var cpeResponse = await _nvdApi.SearchCpeAsync(keyword);
var bestMatch = CpeMatchScorer.SelectBestMatch(
cpeResponse.Products,
item.NormalizedVendor ?? item.Publisher ?? "",
item.SoftwareName);
if (bestMatch == null)
{
failed.Add(new AutoResolveFailedItem
{
SoftwareName = item.SoftwareName,
Reason = cpeResponse.Products?.Count > 0 ? "no_match" : "no_results"
});
continue;
}
var (cpeEntry, score) = bestMatch.Value;
var mapping = CpeMatchScorer.BuildMappingFromNvdResult(
cpeEntry,
item.NormalizedVendor ?? item.Publisher ?? "",
item.SoftwareName,
item.SoftwareName);
// Save as custom mapping
var rowKey = $"{(mapping.NormalizedVendor ?? "unknown")}:{mapping.NormalizedProduct}"
.ToLowerInvariant()
.Replace("/", "_").Replace("\\", "_").Replace("#", "_").Replace("?", "_");
var properties = new Dictionary<string, object>
{
{ "NormalizedVendor", mapping.NormalizedVendor ?? "" },
{ "NormalizedProduct", mapping.NormalizedProduct ?? "" },
{ "CpeVendor", mapping.CpeVendor ?? "" },
{ "CpeProduct", mapping.CpeProduct ?? "" },
{ "CpeUri", mapping.CpeUri ?? "" },
{ "Category", "auto" },
{ "DisplayNamePatternsJson", JsonSerializer.Serialize(mapping.DisplayNamePatterns) },
{ "PublisherPatternsJson", JsonSerializer.Serialize(mapping.PublisherPatterns) },
{ "ExcludePatternsJson", JsonSerializer.Serialize(new List<string>()) },
{ "Source", "auto" },
{ "CreatedAt", DateTime.UtcNow.ToString("o") }
};
await _vulnRepo.UpsertCpeMappingAsync("cpe_map_custom", rowKey, properties);
await _vulnRepo.UpdateSoftwareInventoryCpeAsync(mapping.NormalizedProduct ?? "", mapping.CpeUri ?? "");
resolved.Add(new AutoResolveResultItem
{
SoftwareName = item.SoftwareName,
CpeUri = mapping.CpeUri ?? "",
Confidence = Math.Round(score, 2)
});
_logger.LogInformation("Bulk auto-resolved: '{SoftwareName}' -> {CpeUri} (score={Score:F2})",
item.SoftwareName, mapping.CpeUri, score);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Bulk auto-resolve failed for '{SoftwareName}'", item.SoftwareName);
failed.Add(new AutoResolveFailedItem { SoftwareName = item.SoftwareName, Reason = "nvd_api_error" });
}
}
// Reset mappings cache so future correlations pick up new mappings
if (resolved.Count > 0)
{
VulnerabilityCorrelationService.ResetMappingsCache();
}
_logger.LogInformation(
"Bulk auto-resolve complete: processed={Processed}, resolved={Resolved}, failed={Failed} " +
"(reasons: {FailedReasons})",
resolved.Count + failed.Count, resolved.Count, failed.Count,
string.Join(", ", failed.GroupBy(f => f.Reason).Select(g => $"{g.Key}={g.Count()}")));
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
resolved,
failed,
totalProcessed = resolved.Count + failed.Count,
totalResolved = resolved.Count,
totalFailed = failed.Count
});
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "AutoResolveCpeMapping failed");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Auto-resolve failed" });
return errorResponse;
}
}
// --- Request/Response models ---
private class AutoResolveRequest
{
public List<AutoResolveItem> Items { get; set; } = new();
}
private class AutoResolveItem
{
public string SoftwareName { get; set; } = "";
public string? Publisher { get; set; }
public string? NormalizedVendor { get; set; }
}
private class AutoResolveResultItem
{
public string SoftwareName { get; set; } = "";
public string CpeUri { get; set; } = "";
public double Confidence { get; set; }
}
private class AutoResolveFailedItem
{
public string SoftwareName { get; set; } = "";
public string Reason { get; set; } = "";
}
}
}
@@ -46,6 +46,16 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
// Query all SoftwareInventory entries across tenants and filter for unmatched (no CpeUri)
var allEntities = await _vulnRepo.GetAllSoftwareInventoryAsync();
// Load the persistent ignore list to exclude ignored software
var ignoredEntities = await _vulnRepo.GetCpeMappingsByPartitionAsync("cpe_ignored");
var ignoredNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var ie in ignoredEntities)
{
var name = ie.GetValueOrDefault("NormalizedProduct")?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(name))
ignoredNames.Add(name.ToLowerInvariant());
}
var unmatchedAgg = new Dictionary<string, UnmatchedSoftwareAgg>(StringComparer.OrdinalIgnoreCase);
foreach (var entity in allEntities)
@@ -58,6 +68,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
if (string.IsNullOrWhiteSpace(normalizedName))
continue;
// Skip software on the persistent ignore list
if (ignoredNames.Contains(normalizedName.ToLowerInvariant()))
continue;
var key = normalizedName.ToLowerInvariant();
var sessionCount = entity.ContainsKey("SessionCount") && entity["SessionCount"] is int sc ? sc : 1;
var lastSeenAt = entity.ContainsKey("LastSeenAt") ? entity["LastSeenAt"]?.ToString() ?? "" : "";
@@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
/// <summary>
/// Manage the persistent ignore list for software that should never be auto-resolved.
/// Entries stored in VulnerabilityCache table under partition "cpe_ignored".
/// </summary>
public class IgnoreSoftwareFunction
{
private readonly IVulnerabilityRepository _vulnRepo;
private readonly ILogger<IgnoreSoftwareFunction> _logger;
private const string IgnorePartition = "cpe_ignored";
public IgnoreSoftwareFunction(
IVulnerabilityRepository vulnRepo,
ILogger<IgnoreSoftwareFunction> logger)
{
_vulnRepo = vulnRepo;
_logger = logger;
}
/// <summary>GET: List all ignored software entries.</summary>
[Function("GetIgnoredSoftware")]
public async Task<HttpResponseData> GetIgnored(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "vulnerability/ignored-software")] HttpRequestData req)
{
try
{
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var entities = await _vulnRepo.GetCpeMappingsByPartitionAsync(IgnorePartition);
var items = entities.Select(e => new
{
softwareName = e.GetValueOrDefault("NormalizedProduct")?.ToString() ?? "",
publisher = e.GetValueOrDefault("NormalizedVendor")?.ToString() ?? "",
reason = e.GetValueOrDefault("Reason")?.ToString() ?? "",
ignoredAt = e.GetValueOrDefault("CreatedAt")?.ToString() ?? "",
}).OrderBy(i => i.softwareName).ToList();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { items, total = items.Count });
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get ignored software list");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Failed to load ignored software" });
return errorResponse;
}
}
/// <summary>POST: Add software to ignore list (single or bulk).</summary>
[Function("AddIgnoredSoftware")]
public async Task<HttpResponseData> AddIgnored(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "vulnerability/ignored-software")] HttpRequestData req)
{
try
{
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var body = await req.ReadAsStringAsync();
var request = JsonSerializer.Deserialize<IgnoreRequest>(body ?? "",
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (request?.Items == null || request.Items.Count == 0)
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteAsJsonAsync(new { success = false, message = "items array is required" });
return badReq;
}
int added = 0;
foreach (var item in request.Items)
{
if (string.IsNullOrWhiteSpace(item.SoftwareName)) continue;
var rowKey = $"{(item.Publisher ?? "unknown")}:{item.SoftwareName}"
.ToLowerInvariant()
.Replace("/", "_").Replace("\\", "_").Replace("#", "_").Replace("?", "_");
var properties = new Dictionary<string, object>
{
{ "NormalizedProduct", item.SoftwareName },
{ "NormalizedVendor", item.Publisher ?? "" },
{ "Reason", item.Reason ?? "manual" },
{ "CreatedAt", DateTime.UtcNow.ToString("o") }
};
await _vulnRepo.UpsertCpeMappingAsync(IgnorePartition, rowKey, properties);
added++;
}
_logger.LogInformation("Added {Count} software items to ignore list", added);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true, added });
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to add software to ignore list");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Failed to add to ignore list" });
return errorResponse;
}
}
/// <summary>DELETE: Remove software from ignore list (restore to unmapped).</summary>
[Function("DeleteIgnoredSoftware")]
public async Task<HttpResponseData> DeleteIgnored(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "vulnerability/ignored-software")] HttpRequestData req)
{
try
{
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var body = await req.ReadAsStringAsync();
var request = JsonSerializer.Deserialize<IgnoreDeleteRequest>(body ?? "",
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (string.IsNullOrWhiteSpace(request?.SoftwareName))
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteAsJsonAsync(new { success = false, message = "softwareName is required" });
return badReq;
}
var rowKey = $"{(request.Publisher ?? "unknown")}:{request.SoftwareName}"
.ToLowerInvariant()
.Replace("/", "_").Replace("\\", "_").Replace("#", "_").Replace("?", "_");
await _vulnRepo.DeleteCpeMappingAsync(IgnorePartition, rowKey);
_logger.LogInformation("Removed '{SoftwareName}' from ignore list", request.SoftwareName);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true, message = "Removed from ignore list" });
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to remove software from ignore list");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Failed to remove from ignore list" });
return errorResponse;
}
}
// --- Request models ---
private class IgnoreRequest
{
public List<IgnoreItem> Items { get; set; } = new();
}
private class IgnoreItem
{
public string SoftwareName { get; set; } = "";
public string? Publisher { get; set; }
public string? Reason { get; set; }
}
private class IgnoreDeleteRequest
{
public string SoftwareName { get; set; } = "";
public string? Publisher { get; set; }
}
}
}
@@ -242,8 +242,12 @@ public static class EndpointAccessPolicyCatalog
new("GET", "vulnerability/software-inventory", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/sync", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/cpe-mapping", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/cpe-mapping/auto-resolve", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "vulnerability/cpe-mapping", EndpointPolicy.GlobalAdminOnly),
new("GET", "vulnerability/cpe-mappings", EndpointPolicy.GlobalAdminOnly),
new("GET", "vulnerability/ignored-software", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/ignored-software", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "vulnerability/ignored-software", EndpointPolicy.GlobalAdminOnly),
new("POST", "rules/ime-log-patterns/reseed", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/mcp-users", EndpointPolicy.GlobalAdminOnly),
new("POST", "global/mcp-users", EndpointPolicy.GlobalAdminOnly),
@@ -0,0 +1,343 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace AutopilotMonitor.Functions.Services.Vulnerability
{
/// <summary>
/// Static scoring/matching utilities for NVD CPE auto-resolution.
/// Uses a hybrid approach: exact token overlap + Jaro-Winkler fuzzy matching
/// to handle typos, repackaging errors, and minor naming differences.
/// </summary>
public static class CpeMatchScorer
{
private const double MatchThreshold = 0.6;
private static readonly Regex VersionPattern = new Regex(
@"\b\d+\.\d+[\.\d]*\b", RegexOptions.Compiled);
private static readonly Regex ArchPattern = new Regex(
@"\s*\(?(x64|x86|64-bit|32-bit|arm64|amd64|ia64)\)?\s*",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>
/// Build a clean search keyword from a software display name and vendor.
/// Strips versions, architecture tokens, and normalizes whitespace.
/// </summary>
public static string BuildSearchKeyword(string softwareName, string vendor)
{
var cleaned = softwareName ?? "";
cleaned = VersionPattern.Replace(cleaned, " ");
cleaned = ArchPattern.Replace(cleaned, " ");
cleaned = cleaned.Replace("_", " ");
cleaned = Regex.Replace(cleaned, @"\s+", " ").Trim();
var vendorClean = (vendor ?? "").Replace("_", " ").Trim();
if (!string.IsNullOrWhiteSpace(vendorClean)
&& cleaned.IndexOf(vendorClean, StringComparison.OrdinalIgnoreCase) < 0
&& vendorClean.Length <= 30)
{
cleaned = $"{vendorClean} {cleaned}";
}
if (cleaned.Length > 60)
cleaned = cleaned.Substring(0, 60).TrimEnd();
return cleaned;
}
/// <summary>
/// Score a single NVD CPE entry against the original software metadata.
/// Returns 0.0 – 1.0. Uses hybrid exact + fuzzy matching.
/// </summary>
public static double ScoreCpeMatch(
NvdCpeEntry entry,
string normalizedVendor,
string cleanedProductName)
{
if (entry?.CpeName == null) return 0;
var parts = ParseCpeComponents(entry.CpeName);
if (parts == null) return 0;
var (cpeType, cpeVendor, cpeProduct) = parts.Value;
// --- Type check (0.05) ---
double typeScore = cpeType == "a" ? 1.0 : 0.0;
// --- Vendor match (0.35) — hybrid: token overlap + fuzzy ---
double vendorScore = HybridTokenScore(
NormalizeForComparison(normalizedVendor),
NormalizeForComparison(cpeVendor));
// --- Product match (0.45) — hybrid: token overlap + fuzzy ---
double productScore = HybridTokenScore(
NormalizeForComparison(cleanedProductName),
NormalizeForComparison(cpeProduct));
// --- Title match (0.15) ---
double titleScore = 0;
var title = entry.Titles?.FirstOrDefault(t => t.Lang == "en")?.Title
?? entry.Titles?.FirstOrDefault()?.Title;
if (!string.IsNullOrWhiteSpace(title))
{
var titleNorm = NormalizeForComparison(title);
var productNorm = NormalizeForComparison(cleanedProductName);
if (titleNorm.Contains(productNorm) || productNorm.Contains(titleNorm))
titleScore = 1.0;
else
titleScore = HybridTokenScore(productNorm, titleNorm);
}
return (vendorScore * 0.35)
+ (productScore * 0.45)
+ (titleScore * 0.15)
+ (typeScore * 0.05);
}
/// <summary>
/// Select the best CPE match from NVD results. Returns null if no match meets threshold.
/// </summary>
public static (NvdCpeEntry entry, double score)? SelectBestMatch(
List<NvdCpeProduct> products,
string normalizedVendor,
string cleanedProductName)
{
if (products == null || products.Count == 0) return null;
NvdCpeEntry? bestEntry = null;
double bestScore = 0;
foreach (var product in products)
{
if (product.Cpe == null) continue;
var score = ScoreCpeMatch(product.Cpe, normalizedVendor, cleanedProductName);
if (score > bestScore)
{
bestScore = score;
bestEntry = product.Cpe;
}
}
if (bestEntry == null || bestScore < MatchThreshold) return null;
return (bestEntry, bestScore);
}
/// <summary>
/// Extract a base CPE URI (without version) from a full CPE 2.3 name.
/// e.g. "cpe:2.3:a:google:chrome:120.0:*:*:*:*:*:*:*" -> "cpe:2.3:a:google:chrome"
/// </summary>
public static string ExtractBaseCpeUri(string cpeName)
{
if (string.IsNullOrEmpty(cpeName)) return "";
var parts = cpeName.Split(':');
if (parts.Length < 5) return cpeName;
return $"{parts[0]}:{parts[1]}:{parts[2]}:{parts[3]}:{parts[4]}";
}
/// <summary>
/// Parse type, vendor, product from a CPE 2.3 name.
/// </summary>
public static (string type, string vendor, string product)? ParseCpeComponents(string cpeName)
{
if (string.IsNullOrEmpty(cpeName)) return null;
var parts = cpeName.Split(':');
if (parts.Length < 5) return null;
return (parts[2], parts[3], parts[4]);
}
/// <summary>
/// Build a CpeMappingEntry from an NVD CPE entry for saving as a custom mapping.
/// </summary>
public static CpeMappingEntry BuildMappingFromNvdResult(
NvdCpeEntry cpeEntry,
string normalizedVendor,
string normalizedProduct,
string displayName)
{
var components = ParseCpeComponents(cpeEntry.CpeName ?? "");
var cpeVendor = components?.vendor ?? "";
var cpeProduct = components?.product ?? "";
var baseCpeUri = ExtractBaseCpeUri(cpeEntry.CpeName ?? "");
return new CpeMappingEntry
{
NormalizedVendor = normalizedVendor,
NormalizedProduct = normalizedProduct,
CpeVendor = cpeVendor,
CpeProduct = cpeProduct,
CpeUri = baseCpeUri,
Category = "auto",
DisplayNamePatterns = new List<string> { displayName },
PublisherPatterns = string.IsNullOrWhiteSpace(normalizedVendor)
? new List<string>()
: new List<string> { normalizedVendor },
ExcludePatterns = new List<string>(),
MatchConfidence = "auto"
};
}
// -----------------------------------------------------------------------
// Hybrid scoring: combines exact token overlap with fuzzy matching
// -----------------------------------------------------------------------
/// <summary>
/// Hybrid score: max of (exact token overlap, fuzzy token match, whole-string Jaro-Winkler).
/// This catches typos ("Microsft" vs "Microsoft"), repackaging errors, and minor variations.
/// </summary>
internal static double HybridTokenScore(string a, string b)
{
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return 0;
// Score 1: exact token overlap (fast, handles multi-word names)
double exactScore = ExactTokenOverlapScore(a, b);
// Score 2: fuzzy token matching (handles typos within individual tokens)
double fuzzyTokenScore = FuzzyTokenOverlapScore(a, b);
// Score 3: whole-string Jaro-Winkler (handles single-word names with typos)
double jwScore = JaroWinkler(a, b);
return Math.Max(exactScore, Math.Max(fuzzyTokenScore, jwScore));
}
/// <summary>
/// Exact token overlap: count of shared tokens / max tokens.
/// e.g. "google chrome" vs "chrome" = 1/2 = 0.5
/// </summary>
private static double ExactTokenOverlapScore(string a, string b)
{
var tokensA = a.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToHashSet();
var tokensB = b.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToHashSet();
if (tokensA.Count == 0 || tokensB.Count == 0) return 0;
int overlap = tokensA.Intersect(tokensB).Count();
int maxTokens = Math.Max(tokensA.Count, tokensB.Count);
return (double)overlap / maxTokens;
}
/// <summary>
/// Fuzzy token matching: for each token in A, find the best Jaro-Winkler match in B.
/// Average the best matches. Catches typos like "Microsft" → "Microsoft".
/// </summary>
private static double FuzzyTokenOverlapScore(string a, string b)
{
var tokensA = a.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var tokensB = b.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (tokensA.Length == 0 || tokensB.Length == 0) return 0;
// For each token in the shorter set, find best fuzzy match in the longer set
var shorter = tokensA.Length <= tokensB.Length ? tokensA : tokensB;
var longer = tokensA.Length <= tokensB.Length ? tokensB : tokensA;
double totalScore = 0;
foreach (var token in shorter)
{
double bestMatch = 0;
foreach (var candidate in longer)
{
var jw = JaroWinkler(token, candidate);
if (jw > bestMatch) bestMatch = jw;
}
// Only count as a match if fuzzy score is above 0.85 (high confidence)
totalScore += bestMatch >= 0.85 ? bestMatch : 0;
}
int maxTokens = Math.Max(tokensA.Length, tokensB.Length);
return totalScore / maxTokens;
}
// -----------------------------------------------------------------------
// Jaro-Winkler distance — standard string similarity for name matching.
// Preferred over Levenshtein for names because:
// - Favors strings that match from the beginning (common in product names)
// - Handles transpositions naturally (Buchstabendreher)
// - Returns 0-1 directly (no normalization needed)
// -----------------------------------------------------------------------
/// <summary>
/// Jaro-Winkler similarity between two strings. Returns 0.0 (no match) – 1.0 (identical).
/// Handles transpositions (e.g. "Chorme" vs "Chrome") and prefix matches well.
/// </summary>
internal static double JaroWinkler(string s1, string s2)
{
if (string.IsNullOrEmpty(s1) || string.IsNullOrEmpty(s2)) return 0;
if (string.Equals(s1, s2, StringComparison.Ordinal)) return 1.0;
double jaro = JaroSimilarity(s1, s2);
// Winkler modification: boost score for common prefix (up to 4 chars)
int prefixLength = 0;
int maxPrefix = Math.Min(4, Math.Min(s1.Length, s2.Length));
for (int i = 0; i < maxPrefix; i++)
{
if (s1[i] == s2[i]) prefixLength++;
else break;
}
// Standard Winkler scaling factor p = 0.1
return jaro + (prefixLength * 0.1 * (1.0 - jaro));
}
private static double JaroSimilarity(string s1, string s2)
{
int len1 = s1.Length;
int len2 = s2.Length;
int matchWindow = Math.Max(len1, len2) / 2 - 1;
if (matchWindow < 0) matchWindow = 0;
var matched1 = new bool[len1];
var matched2 = new bool[len2];
int matches = 0;
int transpositions = 0;
// Find matching characters
for (int i = 0; i < len1; i++)
{
int start = Math.Max(0, i - matchWindow);
int end = Math.Min(i + matchWindow + 1, len2);
for (int j = start; j < end; j++)
{
if (matched2[j] || s1[i] != s2[j]) continue;
matched1[i] = true;
matched2[j] = true;
matches++;
break;
}
}
if (matches == 0) return 0;
// Count transpositions
int k = 0;
for (int i = 0; i < len1; i++)
{
if (!matched1[i]) continue;
while (!matched2[k]) k++;
if (s1[i] != s2[k]) transpositions++;
k++;
}
double m = matches;
return (m / len1 + m / len2 + (m - transpositions / 2.0) / m) / 3.0;
}
private static string NormalizeForComparison(string input)
{
if (string.IsNullOrWhiteSpace(input)) return "";
return input
.ToLowerInvariant()
.Replace("_", " ")
.Replace("-", " ")
.Replace(".", " ")
.Trim();
}
}
}
@@ -61,23 +61,28 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
return await ExecuteWithRateLimitAsync<NvdCpeResponse>(url, ct);
}
// Counters for production observability
private static long _totalRequests;
private static long _totalSuccesses;
private static long _totalErrors;
private static long _total429s;
private async Task<T> ExecuteWithRateLimitAsync<T>(string url, CancellationToken ct) where T : class, new()
{
await _rateLimiter.WaitAsync(ct);
var requestStart = DateTime.UtcNow;
try
{
// 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 ? 2000 : 8000; // ~15 req/30s with key, ~3.7 req/30s without
int delayMs = hasApiKey ? 2000 : 8000;
var elapsed = (DateTime.UtcNow - _lastRequestTime).TotalMilliseconds;
if (elapsed < delayMs)
{
await Task.Delay((int)(delayMs - elapsed), ct);
var waitMs = (int)(delayMs - elapsed);
_logger.LogDebug("NVD rate-limit: waiting {WaitMs}ms before request (apiKey={HasKey})", waitMs, hasApiKey);
await Task.Delay(waitMs, ct);
}
using var request = new HttpRequestMessage(HttpMethod.Get, url);
@@ -88,20 +93,46 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
request.Headers.Add("User-Agent", "AutopilotMonitor/1.0");
_lastRequestTime = DateTime.UtcNow;
System.Threading.Interlocked.Increment(ref _totalRequests);
var response = await _http.SendAsync(request, ct);
var durationMs = (DateTime.UtcNow - requestStart).TotalMilliseconds;
if ((int)response.StatusCode == 429)
{
System.Threading.Interlocked.Increment(ref _total429s);
_logger.LogWarning(
"NVD API rate-limited (429) for {Url} after {DurationMs:F0}ms (apiKey={HasKey}, total429s={Total429s})",
url, durationMs, hasApiKey, _total429s);
return new T();
}
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("NVD API returned {StatusCode} for {Url}", (int)response.StatusCode, url);
System.Threading.Interlocked.Increment(ref _totalErrors);
_logger.LogWarning(
"NVD API returned {StatusCode} for {Url} after {DurationMs:F0}ms (totalErrors={TotalErrors})",
(int)response.StatusCode, url, durationMs, _totalErrors);
return new T();
}
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(json, JsonOptions) ?? new T();
var result = JsonSerializer.Deserialize<T>(json, JsonOptions) ?? new T();
System.Threading.Interlocked.Increment(ref _totalSuccesses);
_logger.LogInformation(
"NVD API OK: {Url} in {DurationMs:F0}ms, {ResponseBytes} bytes (apiKey={HasKey}, requests={Total}, ok={Successes}, err={Errors})",
url, durationMs, json.Length, hasApiKey, _totalRequests, _totalSuccesses, _totalErrors);
return result;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "NVD API request failed for {Url}", url);
System.Threading.Interlocked.Increment(ref _totalErrors);
var durationMs = (DateTime.UtcNow - requestStart).TotalMilliseconds;
_logger.LogWarning(ex,
"NVD API request failed for {Url} after {DurationMs:F0}ms (totalErrors={TotalErrors})",
url, durationMs, _totalErrors);
return new T();
}
finally
@@ -21,6 +21,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
private readonly ILogger<VulnerabilityCorrelationService> _logger;
internal List<CpeMappingEntry>? _cpeMappings;
private static HashSet<string>? _ignoredSoftware;
private static readonly SemaphoreSlim _initLock = new SemaphoreSlim(1, 1);
private static bool _seedLoaded = false;
@@ -88,9 +89,13 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
var installedVersion = item.ContainsKey("normalizedVersion") ? item["normalizedVersion"]?.ToString() ?? "" : "";
var displayName = item.ContainsKey("displayName") ? item["displayName"]?.ToString() ?? "" : "";
// Find CPE mapping
// Find CPE mapping (local cache first, then NVD auto-resolve)
var cpeMapping = FindCpeMapping(normalizedVendor, normalizedName, displayName);
if (cpeMapping == null)
{
cpeMapping = await TryAutoResolveCpeAsync(normalizedVendor, normalizedName, displayName, ct);
}
if (cpeMapping == null)
{
unmatchedSoftware.Add(new Dictionary<string, object>
{
@@ -210,13 +215,16 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
}
});
// Summary log for production monitoring
_logger.LogInformation(
"Vulnerability correlation complete: session={SessionId}, scanned={Scanned}, matched={Matched}, unmatched={Unmatched}, " +
"cves={TotalCves} (critical={Critical}, high={High}, medium={Medium}, low={Low}), kev={Kev}, risk={Risk}",
sessionId, inventoryItems.Count, matchedCount, unmatchedSoftware.Count,
totalCves, criticalCves, highCves, mediumCves, lowCves, kevMatches, overallRisk);
// No findings = no report (same as RuleResults: don't store empty results)
if (findings.Count == 0)
{
_logger.LogInformation("Vulnerability correlation: no findings for {Count} scanned, {Unmatched} unmatched",
inventoryItems.Count, unmatchedSoftware.Count);
return null;
}
// Cap findings at top 50
int additionalCount = 0;
@@ -326,6 +334,145 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
return false;
}
// -----------------------------------------------------------------------
// CPE Auto-Resolve via NVD Search
// -----------------------------------------------------------------------
// In-memory cache of recently failed auto-resolve attempts to avoid hammering NVD
// for software that genuinely has no CPE (e.g., internal tools).
private static readonly Dictionary<string, DateTime> _autoResolveFailedCache = new();
private static readonly object _failedCacheLock = new();
private const int AutoResolveFailedCacheDays = 7;
/// <summary>
/// Attempt to auto-resolve a CPE mapping via NVD keyword search.
/// If a good match is found, saves it as a custom mapping for future sessions.
/// </summary>
private async Task<CpeMappingEntry?> TryAutoResolveCpeAsync(
string normalizedVendor, string normalizedName, string displayName, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(normalizedName) && string.IsNullOrWhiteSpace(displayName))
return null;
// Check persistent ignore list first
if (_ignoredSoftware != null && _ignoredSoftware.Contains(normalizedName.ToLowerInvariant()))
return null;
var cacheKey = $"{normalizedVendor}:{normalizedName}".ToLowerInvariant();
// Check failed-lookup cache
lock (_failedCacheLock)
{
if (_autoResolveFailedCache.TryGetValue(cacheKey, out var failedAt)
&& (DateTime.UtcNow - failedAt).TotalDays < AutoResolveFailedCacheDays)
{
return null;
}
}
try
{
var keyword = CpeMatchScorer.BuildSearchKeyword(
!string.IsNullOrWhiteSpace(displayName) ? displayName : normalizedName,
normalizedVendor);
if (string.IsNullOrWhiteSpace(keyword))
return null;
_logger.LogInformation(
"CPE auto-resolve: searching NVD for '{Keyword}' (vendor={Vendor}, product={Name})",
keyword, normalizedVendor, normalizedName);
var cpeResponse = await _nvdApi.SearchCpeAsync(keyword, ct);
var candidateCount = cpeResponse.Products?.Count ?? 0;
var bestMatch = CpeMatchScorer.SelectBestMatch(
cpeResponse.Products ?? new List<NvdCpeProduct>(), normalizedVendor, normalizedName);
if (bestMatch == null)
{
// Cache the failure to avoid re-querying NVD for 7 days
lock (_failedCacheLock)
{
_autoResolveFailedCache[cacheKey] = DateTime.UtcNow;
}
_logger.LogInformation(
"CPE auto-resolve MISS: '{DisplayName}' — {CandidateCount} NVD candidates, none above threshold 0.6 (cached for {CacheDays}d)",
displayName, candidateCount, AutoResolveFailedCacheDays);
return null;
}
var (cpeEntry, score) = bestMatch.Value;
var mapping = CpeMatchScorer.BuildMappingFromNvdResult(
cpeEntry, normalizedVendor, normalizedName, displayName);
_logger.LogInformation(
"CPE auto-resolve HIT: '{DisplayName}' -> {CpeUri} (score={Score:F2}, candidates={CandidateCount}, cpeName={CpeName})",
displayName, mapping.CpeUri, score, candidateCount, cpeEntry.CpeName);
// Fire-and-forget: persist the mapping for future sessions
_ = Task.Run(async () =>
{
try
{
var rowKey = $"{(mapping.NormalizedVendor ?? "unknown")}:{mapping.NormalizedProduct}"
.ToLowerInvariant()
.Replace("/", "_").Replace("\\", "_").Replace("#", "_").Replace("?", "_");
var properties = new Dictionary<string, object>
{
{ "NormalizedVendor", mapping.NormalizedVendor ?? "" },
{ "NormalizedProduct", mapping.NormalizedProduct ?? "" },
{ "CpeVendor", mapping.CpeVendor ?? "" },
{ "CpeProduct", mapping.CpeProduct ?? "" },
{ "CpeUri", mapping.CpeUri ?? "" },
{ "Category", "auto" },
{ "DisplayNamePatternsJson", System.Text.Json.JsonSerializer.Serialize(mapping.DisplayNamePatterns) },
{ "PublisherPatternsJson", System.Text.Json.JsonSerializer.Serialize(mapping.PublisherPatterns) },
{ "ExcludePatternsJson", System.Text.Json.JsonSerializer.Serialize(new List<string>()) },
{ "Source", "auto" },
{ "CreatedAt", DateTime.UtcNow.ToString("o") }
};
await _vulnRepo.UpsertCpeMappingAsync("cpe_map_custom", rowKey, properties);
await _vulnRepo.UpdateSoftwareInventoryCpeAsync(mapping.NormalizedProduct ?? "", mapping.CpeUri ?? "");
_logger.LogInformation("Persisted auto-resolved CPE mapping: {RowKey} -> {CpeUri}", rowKey, mapping.CpeUri);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist auto-resolved CPE mapping for '{Product}'", mapping.NormalizedProduct);
}
}, CancellationToken.None);
// Add to in-memory mappings so subsequent items in the same batch can benefit
_cpeMappings?.Add(mapping);
return mapping;
}
catch (OperationCanceledException)
{
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Auto-resolve CPE failed for '{DisplayName}'", displayName);
return null;
}
}
/// <summary>
/// Clears the failed auto-resolve cache. Called when seed/community mappings are reloaded.
/// </summary>
public static void ResetAutoResolveFailedCache()
{
lock (_failedCacheLock)
{
_autoResolveFailedCache.Clear();
}
}
// -----------------------------------------------------------------------
// CVE Lookup (with Table Storage caching via IVulnerabilityRepository)
// -----------------------------------------------------------------------
@@ -823,8 +970,27 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
_logger.LogWarning(ex, "Failed to load CPE mappings from repository");
}
// Load persistent ignore list (separate partition, outside cpe_map_* range)
try
{
var ignoredEntities = await _vulnRepo.GetCpeMappingsByPartitionAsync("cpe_ignored");
_ignoredSoftware = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var ie in ignoredEntities)
{
var name = ie.ContainsKey("NormalizedProduct") ? ie["NormalizedProduct"]?.ToString() ?? "" : "";
if (!string.IsNullOrWhiteSpace(name))
_ignoredSoftware.Add(name.ToLowerInvariant());
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load ignored software list");
_ignoredSoftware = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
_seedLoaded = true;
_logger.LogInformation("CPE mappings ready: {Count} total entries (from repository)", _cpeMappings.Count);
_logger.LogInformation("CPE mappings ready: {Count} total entries, {Ignored} ignored (from repository)",
_cpeMappings.Count, _ignoredSoftware.Count);
}
finally
{
@@ -13,6 +13,16 @@ interface UnmatchedSoftwareEntry {
frequency: number;
lastSeenAt: string;
exampleSessionId: string;
normalizedVendor?: string;
normalizedVersion?: string;
}
interface AutoResolveResult {
resolved: Array<{ softwareName: string; cpeUri: string; confidence: number }>;
failed: Array<{ softwareName: string; reason: string }>;
totalProcessed: number;
totalResolved: number;
totalFailed: number;
}
interface CpeMappingEntry {
@@ -40,7 +50,7 @@ export function SoftwareMappingSection({
getAccessToken,
setError,
}: SoftwareMappingSectionProps) {
const [activeTab, setActiveTab] = useState<"unmapped" | "mapped">("unmapped");
const [activeTab, setActiveTab] = useState<"unmapped" | "mapped" | "ignored">("unmapped");
// Unmapped state
const [loading, setLoading] = useState(false);
@@ -55,6 +65,17 @@ export function SoftwareMappingSection({
const [savingMapping, setSavingMapping] = useState<string | null>(null);
const [savedMappings, setSavedMappings] = useState<Set<string>>(new Set());
// Bulk auto-resolve state
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [autoResolving, setAutoResolving] = useState(false);
const [autoResolveProgress, setAutoResolveProgress] = useState<{
batchIndex: number;
totalBatches: number;
resolvedSoFar: number;
failedSoFar: number;
} | null>(null);
const [autoResolveResults, setAutoResolveResults] = useState<AutoResolveResult | null>(null);
// Mapped state
const [mappedEntries, setMappedEntries] = useState<CpeMappingEntry[]>([]);
const [mappedLoading, setMappedLoading] = useState(false);
@@ -76,6 +97,14 @@ export function SoftwareMappingSection({
const [savingEdit, setSavingEdit] = useState(false);
const [deletingRow, setDeletingRow] = useState<string | null>(null);
// Ignored state
const [ignoredEntries, setIgnoredEntries] = useState<Array<{ softwareName: string; publisher: string; reason: string; ignoredAt: string }>>([]);
const [ignoredLoading, setIgnoredLoading] = useState(false);
const [ignoredLoaded, setIgnoredLoaded] = useState(false);
const [ignoredPage, setIgnoredPage] = useState(0);
const [ignoringRow, setIgnoringRow] = useState<string | null>(null);
const [restoringRow, setRestoringRow] = useState<string | null>(null);
// --- Unmapped fetching ---
const fetchUnmatchedSoftware = useCallback(async () => {
@@ -156,6 +185,154 @@ export function SoftwareMappingSection({
}
}, [activeTab, mappedLoaded, mappedLoading, fetchCpeMappings]);
// --- Ignored fetching ---
const fetchIgnoredSoftware = useCallback(async () => {
try {
setIgnoredLoading(true);
setError(null);
const response = await authenticatedFetch(api.vulnerability.ignoredSoftware(), getAccessToken);
if (!response.ok) throw new Error(`Failed to load ignored software: ${response.statusText}`);
const data = await response.json();
setIgnoredEntries(data.items || []);
setIgnoredLoaded(true);
setIgnoredPage(0);
} catch (err) {
console.error("Error fetching ignored software:", err);
setError(err instanceof Error ? err.message : "Failed to load ignored software");
} finally {
setIgnoredLoading(false);
}
}, [getAccessToken, setError]);
useEffect(() => {
if (activeTab === "ignored" && !ignoredLoaded && !ignoredLoading) {
fetchIgnoredSoftware();
}
}, [activeTab, ignoredLoaded, ignoredLoading, fetchIgnoredSoftware]);
// --- Ignore / Restore handlers ---
const handleIgnoreSoftware = async (entry: UnmatchedSoftwareEntry) => {
const key = getRowKey(entry);
try {
setIgnoringRow(key);
setError(null);
const response = await authenticatedFetch(api.vulnerability.ignoredSoftware(), getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: [{ softwareName: entry.softwareName, publisher: entry.publisher || "", reason: "manual" }],
}),
});
if (!response.ok) throw new Error(`Failed to ignore software: ${response.statusText}`);
// Remove from unmapped list
setEntries((prev) => prev.filter((e) => getRowKey(e) !== key));
setTotal((prev) => Math.max(0, prev - 1));
setSelectedRows((prev) => { const n = new Set(prev); n.delete(key); return n; });
setIgnoredLoaded(false); // Invalidate ignored cache
} catch (err) {
console.error("Error ignoring software:", err);
setError(err instanceof Error ? err.message : "Failed to ignore software");
} finally {
setIgnoringRow(null);
}
};
const handleBulkIgnoreSelected = async () => {
const selectedEntries = entries.filter((e) => selectedRows.has(getRowKey(e)));
if (selectedEntries.length === 0) return;
try {
setIgnoringRow("bulk");
setError(null);
const response = await authenticatedFetch(api.vulnerability.ignoredSoftware(), getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: selectedEntries.map((e) => ({ softwareName: e.softwareName, publisher: e.publisher || "", reason: "manual" })),
}),
});
if (!response.ok) throw new Error(`Failed to ignore software: ${response.statusText}`);
const keys = new Set(selectedEntries.map(getRowKey));
setEntries((prev) => prev.filter((e) => !keys.has(getRowKey(e))));
setTotal((prev) => Math.max(0, prev - selectedEntries.length));
setSelectedRows(new Set());
setIgnoredLoaded(false);
} catch (err) {
console.error("Error bulk ignoring software:", err);
setError(err instanceof Error ? err.message : "Failed to ignore software");
} finally {
setIgnoringRow(null);
}
};
const handleRestoreSoftware = async (entry: { softwareName: string; publisher: string }) => {
const key = `${entry.softwareName}::${entry.publisher}`;
try {
setRestoringRow(key);
setError(null);
const response = await authenticatedFetch(api.vulnerability.ignoredSoftware(), getAccessToken, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ softwareName: entry.softwareName, publisher: entry.publisher || "" }),
});
if (!response.ok) throw new Error(`Failed to restore software: ${response.statusText}`);
setIgnoredEntries((prev) => prev.filter((e) => `${e.softwareName}::${e.publisher}` !== key));
// Invalidate unmapped cache so it re-fetches with the restored item
setEntries([]);
setTotal(0);
} catch (err) {
console.error("Error restoring software:", err);
setError(err instanceof Error ? err.message : "Failed to restore software");
} finally {
setRestoringRow(null);
}
};
const handleCheckNvdFromIgnored = async (entry: { softwareName: string; publisher: string }) => {
const key = `${entry.softwareName}::${entry.publisher}`;
try {
setRestoringRow(key);
setError(null);
// First, call auto-resolve for this single item
const resolveResponse = await authenticatedFetch(api.vulnerability.cpeAutoResolve(), getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: [{ softwareName: entry.softwareName, publisher: entry.publisher || "", normalizedVendor: entry.publisher || "" }],
}),
});
if (!resolveResponse.ok) throw new Error(`NVD check failed: ${resolveResponse.statusText}`);
const data: AutoResolveResult = await resolveResponse.json();
if (data.totalResolved > 0) {
// Resolved! Remove from ignore list and refresh
await authenticatedFetch(api.vulnerability.ignoredSoftware(), getAccessToken, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ softwareName: entry.softwareName, publisher: entry.publisher || "" }),
});
setIgnoredEntries((prev) => prev.filter((e) => `${e.softwareName}::${e.publisher}` !== key));
setMappedLoaded(false);
setError(null);
trackEvent("ignored_nvd_check_resolved", { softwareName: entry.softwareName, cpeUri: data.resolved[0]?.cpeUri });
} else {
setError(`No CPE match found for "${entry.softwareName}" (${data.failed[0]?.reason || "no match"})`);
}
} catch (err) {
console.error("Error checking NVD:", err);
setError(err instanceof Error ? err.message : "NVD check failed");
} finally {
setRestoringRow(null);
}
};
// --- Ignored helpers ---
const ignoredTotalPages = Math.ceil(ignoredEntries.length / pageSize);
const paginatedIgnoredEntries = ignoredEntries.slice(ignoredPage * pageSize, (ignoredPage + 1) * pageSize);
// --- Unmapped helpers ---
const totalPages = Math.ceil(entries.length / pageSize);
@@ -245,6 +422,115 @@ export function SoftwareMappingSection({
}
};
// --- Bulk auto-resolve ---
const toggleRowSelection = (key: string) => {
setSelectedRows((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const selectAllOnPage = () => {
const pageKeys = paginatedEntries
.filter((e) => !savedMappings.has(getRowKey(e)))
.map((e) => getRowKey(e));
setSelectedRows((prev) => {
const next = new Set(prev);
pageKeys.forEach((k) => next.add(k));
return next;
});
};
const deselectAll = () => setSelectedRows(new Set());
const allOnPageSelected = paginatedEntries.length > 0 &&
paginatedEntries
.filter((e) => !savedMappings.has(getRowKey(e)))
.every((e) => selectedRows.has(getRowKey(e)));
const handleAutoResolveSelected = async () => {
const selectedEntries = entries.filter((e) => selectedRows.has(getRowKey(e)));
if (selectedEntries.length === 0) return;
setAutoResolving(true);
setAutoResolveResults(null);
setAutoResolveProgress(null);
const BATCH_SIZE = 25;
const batches: UnmatchedSoftwareEntry[][] = [];
for (let i = 0; i < selectedEntries.length; i += BATCH_SIZE) {
batches.push(selectedEntries.slice(i, i + BATCH_SIZE));
}
const cumulativeResolved: AutoResolveResult["resolved"] = [];
const cumulativeFailed: AutoResolveResult["failed"] = [];
try {
for (let i = 0; i < batches.length; i++) {
setAutoResolveProgress({
batchIndex: i + 1,
totalBatches: batches.length,
resolvedSoFar: cumulativeResolved.length,
failedSoFar: cumulativeFailed.length,
});
const response = await authenticatedFetch(
api.vulnerability.cpeAutoResolve(),
getAccessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: batches[i].map((e) => ({
softwareName: e.softwareName,
publisher: e.publisher,
normalizedVendor: e.normalizedVendor || e.publisher || "",
})),
}),
}
);
if (!response.ok) {
throw new Error(`Batch ${i + 1} failed: ${response.statusText}`);
}
const data: AutoResolveResult = await response.json();
cumulativeResolved.push(...data.resolved);
cumulativeFailed.push(...data.failed);
// Mark resolved items
data.resolved.forEach((r) => {
const entry = selectedEntries.find((e) => e.softwareName === r.softwareName);
if (entry) setSavedMappings((prev) => new Set(prev).add(getRowKey(entry)));
});
}
setAutoResolveResults({
resolved: cumulativeResolved,
failed: cumulativeFailed,
totalProcessed: cumulativeResolved.length + cumulativeFailed.length,
totalResolved: cumulativeResolved.length,
totalFailed: cumulativeFailed.length,
});
setSelectedRows(new Set());
setMappedLoaded(false);
trackEvent("bulk_auto_resolve_completed", {
totalSelected: selectedEntries.length.toString(),
totalResolved: cumulativeResolved.length.toString(),
totalFailed: cumulativeFailed.length.toString(),
});
} catch (err) {
setError(err instanceof Error ? err.message : "Auto-resolve failed");
} finally {
setAutoResolving(false);
setAutoResolveProgress(null);
}
};
// --- Mapped helpers ---
const getMappedRowKey = (entry: CpeMappingEntry): string =>
@@ -486,6 +772,21 @@ export function SoftwareMappingSection({
</span>
)}
</button>
<button
onClick={() => setActiveTab("ignored")}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
activeTab === "ignored"
? "bg-white dark:bg-gray-600 text-amber-700 dark:text-amber-300 shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
}`}
>
Ignored
{ignoredEntries.length > 0 && activeTab !== "ignored" && (
<span className="ml-1.5 text-xs bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-300 px-1.5 py-0.5 rounded-full">
{ignoredEntries.length}
</span>
)}
</button>
</div>
{/* ==================== UNMAPPED TAB ==================== */}
@@ -512,7 +813,8 @@ export function SoftwareMappingSection({
</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"
disabled={autoResolving}
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 disabled:opacity-40"
>
<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" />
@@ -521,17 +823,121 @@ export function SoftwareMappingSection({
</button>
</div>
{/* Selection toolbar */}
{(selectedRows.size > 0 || autoResolving) && (
<div className="flex flex-wrap items-center gap-3 pb-3 text-sm">
<span className="text-gray-600 dark:text-gray-300 font-medium">
{selectedRows.size} selected
</span>
<button
onClick={allOnPageSelected ? deselectAll : selectAllOnPage}
disabled={autoResolving}
className="text-xs px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
>
{allOnPageSelected ? "Deselect All" : "Select All"}
</button>
{selectedRows.size > 0 && (
<button
onClick={deselectAll}
disabled={autoResolving}
className="text-xs px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
>
Clear
</button>
)}
<button
onClick={handleBulkIgnoreSelected}
disabled={autoResolving || ignoringRow === "bulk" || selectedRows.size === 0}
className="text-xs px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-red-50 hover:text-red-600 hover:border-red-300 dark:hover:bg-red-900/20 dark:hover:text-red-400 disabled:opacity-40 transition-colors"
>
{ignoringRow === "bulk" ? "Ignoring..." : "Ignore Selected"}
</button>
<button
onClick={handleAutoResolveSelected}
disabled={autoResolving || selectedRows.size === 0}
className="ml-auto text-xs px-3 py-1.5 rounded-md font-medium bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
>
{autoResolving ? (
<>
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white" />
Auto-Mapping...
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
Auto-Map Selected
</>
)}
</button>
</div>
)}
{/* Auto-resolve progress */}
{autoResolving && autoResolveProgress && (
<div className="mb-3 px-4 py-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-300">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
<span>
Auto-resolving... Batch {autoResolveProgress.batchIndex}/{autoResolveProgress.totalBatches}
{autoResolveProgress.resolvedSoFar > 0 && <> &middot; Resolved: {autoResolveProgress.resolvedSoFar}</>}
{autoResolveProgress.failedSoFar > 0 && <> &middot; Failed: {autoResolveProgress.failedSoFar}</>}
</span>
</div>
</div>
)}
{/* Auto-resolve results */}
{autoResolveResults && !autoResolving && (
<div className={`mb-3 px-4 py-3 rounded-md text-sm border ${
autoResolveResults.totalFailed === 0
? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800"
: "bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800"
}`}>
<div className="flex items-center justify-between">
<span className={autoResolveResults.totalFailed === 0 ? "text-green-700 dark:text-green-300" : "text-amber-700 dark:text-amber-300"}>
Auto-resolve complete: <strong>{autoResolveResults.totalResolved}</strong> resolved,{" "}
<strong>{autoResolveResults.totalFailed}</strong> failed
</span>
<button
onClick={() => setAutoResolveResults(null)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{autoResolveResults.failed.length > 0 && (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
<span className="font-medium">Failed items:</span>{" "}
{autoResolveResults.failed.map((f) => `${f.softwareName} (${f.reason})`).join(", ")}
</div>
)}
</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="w-10 px-2 py-3">
<input
type="checkbox"
checked={allOnPageSelected && paginatedEntries.length > 0}
onChange={allOnPageSelected ? deselectAll : selectAllOnPage}
disabled={autoResolving}
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40"
/>
</th>
<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>
<th className="w-28 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Frequency</th>
<th className="w-28 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="w-24 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Session</th>
<th className="w-40 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">
@@ -543,18 +949,29 @@ export function SoftwareMappingSection({
return (
<tr key={key} className="group">
<td colSpan={6} className="p-0">
<td colSpan={7} 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">
<div className="w-10 px-2 py-3 flex items-center justify-center flex-shrink-0">
{!isMapped && (
<input
type="checkbox"
checked={selectedRows.has(key)}
onChange={() => toggleRowSelection(key)}
disabled={autoResolving}
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40"
/>
)}
</div>
<div className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 flex-1 min-w-0 truncate">
{entry.softwareName}
</div>
<div className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400 flex-1 min-w-0">
<div className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400 flex-1 min-w-0 truncate">
{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">
<div className="px-4 py-3 text-sm flex-shrink-0 w-28">
<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>
@@ -562,7 +979,7 @@ export function SoftwareMappingSection({
<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">
<div className="px-4 py-3 text-sm flex-shrink-0 w-24">
<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"
@@ -605,6 +1022,20 @@ export function SoftwareMappingSection({
<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>
<button
onClick={() => handleIgnoreSoftware(entry)}
disabled={ignoringRow === key}
className="text-xs text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors"
title="Ignore — never auto-resolve"
>
{ignoringRow === key ? (
<div className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-gray-400" />
) : (
<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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
)}
</button>
</>
)}
</div>
@@ -790,11 +1221,11 @@ export function SoftwareMappingSection({
<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">Product</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Vendor</th>
<th className="w-36 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Vendor</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">CPE URI</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Category</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Source</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>
<th className="w-28 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Category</th>
<th className="w-24 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Source</th>
<th className="w-32 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">
@@ -995,6 +1426,134 @@ export function SoftwareMappingSection({
)}
</>
)}
{/* ==================== IGNORED TAB ==================== */}
{activeTab === "ignored" && (
<>
{ignoredLoading ? (
<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 ignored software...</span>
</div>
) : ignoredEntries.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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<p className="text-sm">No ignored software. Items you ignore from the Unmapped tab will appear here.</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-gray-700 dark:text-gray-200">{ignoredEntries.length}</span> ignored software entries
</span>
<button
onClick={fetchIgnoredSoftware}
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="w-36 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Publisher</th>
<th className="w-28 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Ignored At</th>
<th className="w-48 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">
{paginatedIgnoredEntries.map((entry) => {
const key = `${entry.softwareName}::${entry.publisher}`;
const isRestoring = restoringRow === key;
return (
<tr key={key} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{entry.softwareName}
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400 truncate">
{entry.publisher || <span className="text-gray-300 dark:text-gray-600 italic">unknown</span>}
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
{formatDate(entry.ignoredAt)}
</td>
<td className="px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<button
onClick={() => handleRestoreSoftware(entry)}
disabled={isRestoring}
className="text-xs px-2.5 py-1 rounded-md font-medium bg-gray-100 text-gray-600 hover:bg-amber-100 hover:text-amber-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-amber-900/50 dark:hover:text-amber-400 disabled:opacity-40 transition-colors"
>
{isRestoring ? "..." : "Restore"}
</button>
<button
onClick={() => handleCheckNvdFromIgnored(entry)}
disabled={isRestoring}
className="text-xs px-2.5 py-1 rounded-md font-medium bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-900/20 dark:text-blue-400 dark:hover:bg-blue-900/40 disabled:opacity-40 transition-colors"
>
{isRestoring ? "..." : "Check NVD"}
</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 manually"
>
<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>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Ignored Pagination */}
{ignoredTotalPages > 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">
{ignoredPage * pageSize + 1}&ndash;{Math.min((ignoredPage + 1) * pageSize, ignoredEntries.length)} of {ignoredEntries.length}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setIgnoredPage((p) => p - 1)}
disabled={ignoredPage === 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">
{ignoredPage + 1} / {ignoredTotalPages}
</span>
<button
onClick={() => setIgnoredPage((p) => p + 1)}
disabled={(ignoredPage + 1) * pageSize >= ignoredEntries.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>
);
@@ -328,6 +328,7 @@ export function SectionSettings() {
<p>C:\ProgramData\Microsoft\DiagnosticLogCSP</p>
<p>C:\ProgramData\Microsoft\Windows\WER</p>
<p>C:\Windows\Logs\CBS</p>
<p>C:\Install\Log</p>
</div>
)}
</span>
+2
View File
@@ -231,7 +231,9 @@ export const api = {
`${API_BASE_URL}/api/vulnerability/sync${qs({ reseed: type })}`,
cpeMappings: () => `${API_BASE_URL}/api/vulnerability/cpe-mappings`,
cpeMapping: () => `${API_BASE_URL}/api/vulnerability/cpe-mapping`,
cpeAutoResolve: () => `${API_BASE_URL}/api/vulnerability/cpe-mapping/auto-resolve`,
unmatchedSoftware: () => `${API_BASE_URL}/api/vulnerability/unmatched-software`,
ignoredSoftware: () => `${API_BASE_URL}/api/vulnerability/ignored-software`,
},
// ── MCP Users ─────────────────────────────────────────────────────────────
File diff suppressed because one or more lines are too long