mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
a6184f165b
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>
226 lines
10 KiB
C#
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; } = "";
|
|
}
|
|
}
|
|
}
|