Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/AutoResolveCpeMappingFunction.cs
T
Oliver Kieselbach a6184f165b feat(vuln): FIRST EPSS as fourth source, act/attend/track priority, CVSS vector; never cache a refused NVD answer
EPSS: EpssApiClient (api.first.org, 100 ids per batch, unauthenticated, fail-soft). Score, percentile
and date live on the cached CVE — one batch per CPE fill, every cve_* row re-scored by the nightly
sync, only changed rows rewritten and with their original CachedAt. CveIndex carries EpssScore (max,
absent when unscored) and Priority; TopCve exposes both. CvePriority: act = CISA KEV, attend =
EPSS >= 0.1 or CVSS >= 9.0, track = rest — riskLevel/overall_risk semantics unchanged.

NVD throttle: Polly already retried 429s; the defect was that a refused answer came back as an
empty response and was cached as "0 CVEs" for seven days (also the negative auto-resolve cache).
NvdFetchResult separates answered from refused; nothing persists a refusal; a 429 opens a
process-wide cooldown (Retry-After capped at 120 s, else 30 s) so a run fails fast instead of
paying a retry chain per software item; hard-expired rows keep serving meanwhile. The NVD->cache
projection and the row builder are now single functions shared by the live fill and the sync.

CVSS v3 vector string persisted and shown. Portal: EPSS column + pill, priority pill, vector line;
exposure panel pills. MCP tool descriptions and the CVE-audit prompt name the fields. Terms
attribution + privacy page updated in the same change. OKF doc backend/vulnerability-correlation-sources.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 11:07:31 +02:00

226 lines
10 KiB
C#

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 cpeFetch = await _nvdApi.SearchCpeAsync(keyword);
if (!cpeFetch.Succeeded)
{
failed.Add(new AutoResolveFailedItem
{
SoftwareName = item.SoftwareName,
Reason = cpeFetch.Throttled ? "nvd_throttled" : "nvd_unavailable"
});
continue;
}
var cpeResponse = cpeFetch.Data;
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; } = "";
}
}
}