mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
better CPE matching (Fuzzy Jaro-Winkler) + matching telemetry for matching engine fine tuning
This commit is contained in:
@@ -1 +1 @@
|
||||
873
|
||||
877
|
||||
|
||||
+215
@@ -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; } = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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() ?? "" : "";
|
||||
|
||||
+184
@@ -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
|
||||
|
||||
+172
-6
@@ -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 && <> · Resolved: {autoResolveProgress.resolvedSoFar}</>}
|
||||
{autoResolveProgress.failedSoFar > 0 && <> · 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}–{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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user