mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
feat(backend): wire MSRC into correlation + sampling-bypass observability
MsrcApiClient was injected into VulnerabilityCorrelationService for months but never invoked — every report shipped with source="NVD" only. New MsrcCorrelationService owns refresh + per-CVE lookup against an in-memory index built from the last 6 Patch Tuesdays; daily VulnerabilityDataSync populates it; CorrelateAsync enriches each finding with msrcDocumentId, msrcReleaseDate, msrcSeverity, msrcBaseScore, msrcVector and flips source to "NVD+MSRC" when the CVE was published in the indexed window. Health check also surfaced that all worker-side traces of the daily sync and CPE auto-resolve were sampled out of App Insights despite running. Adds Event to host.json excludedTypes and emits TelemetryClient events for VulnerabilityDataSyncCompleted, VulnerabilityCorrelationCompleted, CpeAutoResolveHit/Miss, CpeMappingPersisted so customEvents queries can prove freshness without depending on sampled traces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -102,7 +102,7 @@ public class VulnerabilityCorrelationServiceTests
|
||||
private VulnerabilityCorrelationService CreateServiceForFilterTests()
|
||||
{
|
||||
// FilterByVersion doesn't use any dependencies — we can pass nulls
|
||||
return new VulnerabilityCorrelationService(null!, null!, null!, null!, null!, null!, null!);
|
||||
return new VulnerabilityCorrelationService(null!, null!, null!, null!, null!, null!, null, null!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -430,7 +430,7 @@ public class VulnerabilityCorrelationServiceTests
|
||||
|
||||
private VulnerabilityCorrelationService CreateServiceWithMappings(List<CpeMappingEntry> mappings)
|
||||
{
|
||||
var svc = new VulnerabilityCorrelationService(null!, null!, null!, null!, null!, null!, null!);
|
||||
var svc = new VulnerabilityCorrelationService(null!, null!, null!, null!, null!, null!, null, null!);
|
||||
svc._cpeMappings = mappings;
|
||||
return svc;
|
||||
}
|
||||
|
||||
+78
-7
@@ -7,6 +7,7 @@ using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -25,21 +26,27 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
{
|
||||
private readonly KevDataService _kevService;
|
||||
private readonly NvdApiClient _nvdApi;
|
||||
private readonly MsrcCorrelationService _msrcCorrelation;
|
||||
private readonly IVulnerabilityRepository _vulnRepo;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TelemetryClient? _telemetryClient;
|
||||
private readonly ILogger<VulnerabilityDataSyncFunction> _logger;
|
||||
|
||||
public VulnerabilityDataSyncFunction(
|
||||
KevDataService kevService,
|
||||
NvdApiClient nvdApi,
|
||||
MsrcCorrelationService msrcCorrelation,
|
||||
IVulnerabilityRepository vulnRepo,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TelemetryClient? telemetryClient,
|
||||
ILogger<VulnerabilityDataSyncFunction> logger)
|
||||
{
|
||||
_kevService = kevService;
|
||||
_nvdApi = nvdApi;
|
||||
_msrcCorrelation = msrcCorrelation;
|
||||
_vulnRepo = vulnRepo;
|
||||
_adminConfigService = adminConfigService;
|
||||
_telemetryClient = telemetryClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -50,23 +57,56 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
public async Task Run(
|
||||
[TimerTrigger("0 0 3 * * *")] TimerInfo timer)
|
||||
{
|
||||
var startedAt = DateTime.UtcNow;
|
||||
_logger.LogInformation("[VulnSync] Starting vulnerability data sync...");
|
||||
|
||||
int kevCount = 0;
|
||||
string? kevError = null;
|
||||
int cveRefreshed = 0;
|
||||
int cveFailed = 0;
|
||||
int cveStale = 0;
|
||||
int msrcDocs = 0;
|
||||
int msrcCves = 0;
|
||||
string? msrcError = null;
|
||||
|
||||
// 1. Refresh CISA KEV catalog
|
||||
try
|
||||
{
|
||||
await _kevService.RefreshAsync();
|
||||
_logger.LogInformation("[VulnSync] KEV catalog refreshed: {Count} entries", _kevService.CatalogSize);
|
||||
kevCount = _kevService.CatalogSize;
|
||||
_logger.LogInformation("[VulnSync] KEV catalog refreshed: {Count} entries", kevCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
kevError = ex.Message;
|
||||
_logger.LogWarning(ex, "[VulnSync] KEV catalog refresh failed");
|
||||
}
|
||||
|
||||
// 2. Refresh stale CVE cache entries
|
||||
await RefreshStaleCveCacheAsync();
|
||||
// 2. Refresh MSRC CVE index (last 6 Patch Tuesdays).
|
||||
try
|
||||
{
|
||||
var msrc = await _msrcCorrelation.RefreshAsync();
|
||||
msrcDocs = msrc.DocumentsFetched;
|
||||
msrcCves = msrc.IndexedCves;
|
||||
if (!string.IsNullOrEmpty(msrc.Error))
|
||||
msrcError = msrc.Error;
|
||||
_logger.LogInformation(
|
||||
"[VulnSync] MSRC index refreshed: {Cves} CVEs across {Docs} CVRF documents (failed={Failed}, ms={Ms:F0})",
|
||||
msrc.IndexedCves, msrc.DocumentsFetched, msrc.DocumentsFailed, msrc.DurationMs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
msrcError = ex.Message;
|
||||
_logger.LogWarning(ex, "[VulnSync] MSRC index refresh failed");
|
||||
}
|
||||
|
||||
// 3. Update sync timestamp
|
||||
// 3. Refresh stale CVE cache entries
|
||||
var cveStats = await RefreshStaleCveCacheAsync();
|
||||
cveStale = cveStats.Stale;
|
||||
cveRefreshed = cveStats.Refreshed;
|
||||
cveFailed = cveStats.Failed;
|
||||
|
||||
// 4. Update sync timestamp
|
||||
try
|
||||
{
|
||||
var config = await _adminConfigService.GetConfigurationAsync();
|
||||
@@ -81,7 +121,30 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
_logger.LogWarning(ex, "[VulnSync] Failed to update sync timestamp");
|
||||
}
|
||||
|
||||
_logger.LogInformation("[VulnSync] Vulnerability data sync completed");
|
||||
var durationMs = (DateTime.UtcNow - startedAt).TotalMilliseconds;
|
||||
_logger.LogInformation("[VulnSync] Vulnerability data sync completed in {DurationMs:F0}ms", durationMs);
|
||||
|
||||
// Sampling-bypass observability: host.json adds "Event" to excludedTypes so this
|
||||
// survives even when default trace sampling is aggressive.
|
||||
_telemetryClient?.TrackEvent("VulnerabilityDataSyncCompleted", new Dictionary<string, string>
|
||||
{
|
||||
{ "kevCount", kevCount.ToString() },
|
||||
{ "kevError", kevError ?? "" },
|
||||
{ "msrcDocs", msrcDocs.ToString() },
|
||||
{ "msrcCves", msrcCves.ToString() },
|
||||
{ "msrcError", msrcError ?? "" },
|
||||
{ "cveStale", cveStale.ToString() },
|
||||
{ "cveRefreshed", cveRefreshed.ToString() },
|
||||
{ "cveFailed", cveFailed.ToString() },
|
||||
{ "durationMs", durationMs.ToString("F0", System.Globalization.CultureInfo.InvariantCulture) }
|
||||
});
|
||||
}
|
||||
|
||||
private struct CveCacheStats
|
||||
{
|
||||
public int Stale;
|
||||
public int Refreshed;
|
||||
public int Failed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -89,8 +152,9 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
/// and refreshes them by re-querying the NVD API. Processes sequentially with
|
||||
/// rate limiting to avoid NVD API throttling.
|
||||
/// </summary>
|
||||
private async Task RefreshStaleCveCacheAsync()
|
||||
private async Task<CveCacheStats> RefreshStaleCveCacheAsync()
|
||||
{
|
||||
var stats = new CveCacheStats();
|
||||
try
|
||||
{
|
||||
var staleCutoff = DateTime.UtcNow.AddDays(-7).ToString("o");
|
||||
@@ -116,10 +180,12 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
}
|
||||
}
|
||||
|
||||
stats.Stale = staleEntries.Count;
|
||||
|
||||
if (staleEntries.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("[VulnSync] CVE cache: no stale entries found");
|
||||
return;
|
||||
return stats;
|
||||
}
|
||||
|
||||
_logger.LogInformation("[VulnSync] CVE cache: {StaleCount} stale entries to refresh", staleEntries.Count);
|
||||
@@ -227,11 +293,16 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
|
||||
_logger.LogInformation("[VulnSync] CVE cache refresh complete: {Refreshed} refreshed, {Failed} failed out of {Total} stale entries",
|
||||
refreshed, failed, staleEntries.Count);
|
||||
|
||||
stats.Refreshed = refreshed;
|
||||
stats.Failed = failed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[VulnSync] CVE cache refresh scan failed");
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,7 @@ builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
|
||||
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.MsrcApiClient>()
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Vulnerability.MsrcCorrelationService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelationService>();
|
||||
|
||||
// Register agent Function classes so bootstrap wrappers can inject them for code reuse
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds an in-memory index of CVEs published in recent MSRC monthly CVRF documents
|
||||
/// (Patch Tuesday releases). Refreshed daily by <c>VulnerabilityDataSyncFunction</c>;
|
||||
/// queried per-CVE during session correlation to enrich findings with Microsoft-source
|
||||
/// metadata (release date, MSRC severity, CVRF document title).
|
||||
///
|
||||
/// MSRC has no per-CVE lookup API — the only available shape is whole-month CVRF
|
||||
/// documents. Building an index up-front keeps correlation O(1) per CVE.
|
||||
/// </summary>
|
||||
public class MsrcCorrelationService
|
||||
{
|
||||
private readonly MsrcApiClient _msrcApi;
|
||||
private readonly ILogger<MsrcCorrelationService> _logger;
|
||||
|
||||
// Concurrent because correlation may read while a parallel refresh writes.
|
||||
private ConcurrentDictionary<string, MsrcCveEntry> _index =
|
||||
new ConcurrentDictionary<string, MsrcCveEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private DateTime _lastRefresh = DateTime.MinValue;
|
||||
private readonly List<string> _coveredDocuments = new List<string>();
|
||||
|
||||
private static readonly SemaphoreSlim _refreshLock = new SemaphoreSlim(1, 1);
|
||||
|
||||
/// <summary>How many recent CVRF documents to index. Each = ~one Patch Tuesday.</summary>
|
||||
public const int DefaultMonthsToIndex = 6;
|
||||
|
||||
public MsrcCorrelationService(MsrcApiClient msrcApi, ILogger<MsrcCorrelationService> logger)
|
||||
{
|
||||
_msrcApi = msrcApi ?? throw new ArgumentNullException(nameof(msrcApi));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>Number of CVEs currently indexed.</summary>
|
||||
public int IndexSize => _index.Count;
|
||||
|
||||
/// <summary>UTC timestamp of the last successful refresh.</summary>
|
||||
public DateTime LastRefresh => _lastRefresh;
|
||||
|
||||
/// <summary>List of CVRF document IDs that contributed to the current index.</summary>
|
||||
public IReadOnlyList<string> CoveredDocuments => _coveredDocuments.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the MSRC entry for a CVE, or null if not present in the indexed window.
|
||||
/// "Not in index" does not mean "not Microsoft" — it just means the CVE was not
|
||||
/// published in the last <see cref="DefaultMonthsToIndex"/> Patch Tuesdays.
|
||||
/// </summary>
|
||||
public MsrcCveEntry? Lookup(string? cveId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cveId)) return null;
|
||||
return _index.TryGetValue(cveId, out var entry) ? entry : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the index from the most recent MSRC CVRF documents. Safe to call
|
||||
/// concurrently — only one refresh runs at a time; others observe the prior index.
|
||||
/// </summary>
|
||||
public async Task<MsrcRefreshResult> RefreshAsync(int monthsToIndex = DefaultMonthsToIndex, CancellationToken ct = default)
|
||||
{
|
||||
var result = new MsrcRefreshResult { StartedAt = DateTime.UtcNow };
|
||||
|
||||
if (!await _refreshLock.WaitAsync(0, ct))
|
||||
{
|
||||
// Another refresh is in progress — return the current state without waiting.
|
||||
result.Skipped = true;
|
||||
result.IndexedCves = _index.Count;
|
||||
result.CoveredDocuments = _coveredDocuments.Count;
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updates = await _msrcApi.GetRecentUpdatesAsync(ct);
|
||||
if (updates.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("MSRC refresh: GetRecentUpdatesAsync returned 0 documents — keeping prior index of {Size} CVEs",
|
||||
_index.Count);
|
||||
result.IndexedCves = _index.Count;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Pick the most-recent N documents by InitialReleaseDate (descending).
|
||||
var pickedDocs = updates
|
||||
.Where(u => !string.IsNullOrWhiteSpace(u.ID))
|
||||
.OrderByDescending(u => u.InitialReleaseDate ?? "", StringComparer.Ordinal)
|
||||
.Take(monthsToIndex)
|
||||
.ToList();
|
||||
|
||||
var newIndex = new ConcurrentDictionary<string, MsrcCveEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
var newCovered = new List<string>();
|
||||
int docsFetched = 0;
|
||||
int docsFailed = 0;
|
||||
int cvesIndexed = 0;
|
||||
|
||||
foreach (var summary in pickedDocs)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
var doc = await _msrcApi.GetCvrfAsync(summary.ID!, ct);
|
||||
if (doc == null)
|
||||
{
|
||||
docsFailed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
docsFetched++;
|
||||
newCovered.Add(summary.ID!);
|
||||
|
||||
var docTitle = doc.DocumentTitle?.Value ?? summary.DocumentTitle ?? summary.ID!;
|
||||
var releaseDate = summary.InitialReleaseDate ?? "";
|
||||
|
||||
foreach (var v in doc.Vulnerability)
|
||||
{
|
||||
var cveId = v.CVE?.Value;
|
||||
if (string.IsNullOrWhiteSpace(cveId)) continue;
|
||||
|
||||
// Pick the maximum MSRC base score across product-specific score sets.
|
||||
double? bestScore = null;
|
||||
string? bestVector = null;
|
||||
if (v.CVSSScoreSets != null)
|
||||
{
|
||||
foreach (var s in v.CVSSScoreSets)
|
||||
{
|
||||
if (bestScore == null || s.BaseScore > bestScore.Value)
|
||||
{
|
||||
bestScore = s.BaseScore;
|
||||
bestVector = s.Vector;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MSRC severity comes from a Threat with Type="Severity".
|
||||
var severity = v.Threats?
|
||||
.FirstOrDefault(t => string.Equals(t.Type, "Severity", StringComparison.OrdinalIgnoreCase))
|
||||
?.Description?.Value;
|
||||
|
||||
var entry = new MsrcCveEntry
|
||||
{
|
||||
CveId = cveId!,
|
||||
DocumentId = summary.ID!,
|
||||
DocumentTitle = docTitle,
|
||||
ReleaseDate = releaseDate,
|
||||
BaseScore = bestScore,
|
||||
Vector = bestVector,
|
||||
Severity = severity,
|
||||
Title = v.Title?.Value
|
||||
};
|
||||
|
||||
// Last-write-wins is fine — same CVE in multiple docs is rare and the
|
||||
// newest doc wins because we iterate in descending date order.
|
||||
newIndex[cveId!] = entry;
|
||||
cvesIndexed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic swap — readers observe either the old or the new index, never half.
|
||||
_index = newIndex;
|
||||
_coveredDocuments.Clear();
|
||||
_coveredDocuments.AddRange(newCovered);
|
||||
_lastRefresh = DateTime.UtcNow;
|
||||
|
||||
result.DocumentsFetched = docsFetched;
|
||||
result.DocumentsFailed = docsFailed;
|
||||
result.IndexedCves = cvesIndexed;
|
||||
result.CoveredDocuments = newCovered.Count;
|
||||
|
||||
_logger.LogInformation(
|
||||
"MSRC refresh: indexed {Cves} CVEs across {Docs} CVRF documents ({Failed} failed)",
|
||||
cvesIndexed, docsFetched, docsFailed);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Error = ex.Message;
|
||||
_logger.LogWarning(ex, "MSRC refresh failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshLock.Release();
|
||||
result.CompletedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public class MsrcCveEntry
|
||||
{
|
||||
public string CveId { get; set; } = "";
|
||||
public string DocumentId { get; set; } = "";
|
||||
public string DocumentTitle { get; set; } = "";
|
||||
public string ReleaseDate { get; set; } = "";
|
||||
public double? BaseScore { get; set; }
|
||||
public string? Vector { get; set; }
|
||||
public string? Severity { get; set; }
|
||||
public string? Title { get; set; }
|
||||
}
|
||||
|
||||
public class MsrcRefreshResult
|
||||
{
|
||||
public DateTime StartedAt { get; set; }
|
||||
public DateTime CompletedAt { get; set; }
|
||||
public bool Skipped { get; set; }
|
||||
public int DocumentsFetched { get; set; }
|
||||
public int DocumentsFailed { get; set; }
|
||||
public int IndexedCves { get; set; }
|
||||
public int CoveredDocuments { get; set; }
|
||||
public string? Error { get; set; }
|
||||
|
||||
public double DurationMs => (CompletedAt - StartedAt).TotalMilliseconds;
|
||||
}
|
||||
}
|
||||
+77
-7
@@ -7,6 +7,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
@@ -17,8 +18,9 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
private readonly NvdApiClient _nvdApi;
|
||||
private readonly KevDataService _kevService;
|
||||
private readonly MsrcApiClient _msrcApi;
|
||||
private readonly MsrcCorrelationService _msrcCorrelation;
|
||||
private readonly AdminConfigurationService _adminConfig;
|
||||
private readonly TelemetryClient? _telemetryClient;
|
||||
private readonly ILogger<VulnerabilityCorrelationService> _logger;
|
||||
|
||||
internal List<CpeMappingEntry>? _cpeMappings;
|
||||
@@ -37,16 +39,18 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
ISessionRepository sessionRepo,
|
||||
NvdApiClient nvdApi,
|
||||
KevDataService kevService,
|
||||
MsrcApiClient msrcApi,
|
||||
MsrcCorrelationService msrcCorrelation,
|
||||
AdminConfigurationService adminConfig,
|
||||
TelemetryClient? telemetryClient,
|
||||
ILogger<VulnerabilityCorrelationService> logger)
|
||||
{
|
||||
_vulnRepo = vulnRepo;
|
||||
_sessionRepo = sessionRepo;
|
||||
_nvdApi = nvdApi;
|
||||
_kevService = kevService;
|
||||
_msrcApi = msrcApi;
|
||||
_msrcCorrelation = msrcCorrelation;
|
||||
_adminConfig = adminConfig;
|
||||
_telemetryClient = telemetryClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -78,6 +82,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
int mediumCves = 0;
|
||||
int lowCves = 0;
|
||||
int kevMatches = 0;
|
||||
int msrcMatches = 0;
|
||||
string overallRisk = "none";
|
||||
|
||||
var cpeMappingsDict = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -140,7 +145,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check KEV for each CVE
|
||||
// Check KEV + MSRC for each CVE in a single pass.
|
||||
foreach (var vuln in vulns)
|
||||
{
|
||||
var cveId = vuln["cveId"]?.ToString();
|
||||
@@ -157,6 +162,26 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
{
|
||||
vuln["isKev"] = false;
|
||||
}
|
||||
|
||||
// MSRC enrichment — only present if the CVE was published in a recent
|
||||
// Patch Tuesday document. Augments source label and adds Microsoft-side
|
||||
// metadata so the UI can show "confirmed by Microsoft" alongside NVD data.
|
||||
var msrcEntry = _msrcCorrelation?.Lookup(cveId);
|
||||
if (msrcEntry != null)
|
||||
{
|
||||
vuln["source"] = "NVD+MSRC";
|
||||
vuln["msrcDocumentId"] = msrcEntry.DocumentId;
|
||||
vuln["msrcDocumentTitle"] = msrcEntry.DocumentTitle;
|
||||
vuln["msrcReleaseDate"] = msrcEntry.ReleaseDate;
|
||||
vuln["msrcSeverity"] = msrcEntry.Severity ?? "";
|
||||
if (msrcEntry.BaseScore.HasValue)
|
||||
vuln["msrcBaseScore"] = msrcEntry.BaseScore.Value;
|
||||
if (!string.IsNullOrEmpty(msrcEntry.Vector))
|
||||
vuln["msrcVector"] = msrcEntry.Vector;
|
||||
if (!string.IsNullOrEmpty(msrcEntry.Title))
|
||||
vuln["msrcTitle"] = msrcEntry.Title;
|
||||
msrcMatches++;
|
||||
}
|
||||
}
|
||||
|
||||
// Count severities
|
||||
@@ -228,9 +253,28 @@ 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}",
|
||||
"cves={TotalCves} (critical={Critical}, high={High}, medium={Medium}, low={Low}), kev={Kev}, msrc={Msrc}, risk={Risk}",
|
||||
sessionId, inventoryItems.Count, matchedCount, unmatchedSoftware.Count,
|
||||
totalCves, criticalCves, highCves, mediumCves, lowCves, kevMatches, overallRisk);
|
||||
totalCves, criticalCves, highCves, mediumCves, lowCves, kevMatches, msrcMatches, overallRisk);
|
||||
|
||||
// Sampling-bypass observability event (host.json excludedTypes includes "Event").
|
||||
// Mirrors the ILogger summary above in a structured, queryable form.
|
||||
_telemetryClient?.TrackEvent("VulnerabilityCorrelationCompleted", new Dictionary<string, string>
|
||||
{
|
||||
{ "tenantId", tenantId },
|
||||
{ "sessionId", sessionId },
|
||||
{ "scanned", inventoryItems.Count.ToString() },
|
||||
{ "matched", matchedCount.ToString() },
|
||||
{ "unmatched", unmatchedSoftware.Count.ToString() },
|
||||
{ "totalCves", totalCves.ToString() },
|
||||
{ "critical", criticalCves.ToString() },
|
||||
{ "high", highCves.ToString() },
|
||||
{ "medium", mediumCves.ToString() },
|
||||
{ "low", lowCves.ToString() },
|
||||
{ "kev", kevMatches.ToString() },
|
||||
{ "msrc", msrcMatches.ToString() },
|
||||
{ "risk", overallRisk }
|
||||
});
|
||||
|
||||
// No findings = no report (same as RuleResults: don't store empty results)
|
||||
if (findings.Count == 0)
|
||||
@@ -257,6 +301,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
{ "medium_cves", mediumCves },
|
||||
{ "low_cves", lowCves },
|
||||
{ "kev_matches", kevMatches },
|
||||
{ "msrc_matches", msrcMatches },
|
||||
{ "overall_risk", overallRisk },
|
||||
{ "additional_findings_count", additionalCount }
|
||||
}
|
||||
@@ -268,7 +313,9 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
{
|
||||
{ "cpe_cache_entries", _cpeMappings?.Count ?? 0 },
|
||||
{ "kev_catalog_entries", _kevService.CatalogSize },
|
||||
{ "kev_last_sync", _kevService.LastRefresh.ToString("o") }
|
||||
{ "kev_last_sync", _kevService.LastRefresh.ToString("o") },
|
||||
{ "msrc_indexed_cves", _msrcCorrelation?.IndexSize ?? 0 },
|
||||
{ "msrc_last_sync", _msrcCorrelation?.LastRefresh.ToString("o") ?? "" }
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -411,6 +458,13 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
_logger.LogInformation(
|
||||
"CPE auto-resolve MISS: '{DisplayName}' — {CandidateCount} NVD candidates, none above threshold 0.6 (cached for {CacheDays}d)",
|
||||
displayName, candidateCount, AutoResolveFailedCacheDays);
|
||||
_telemetryClient?.TrackEvent("CpeAutoResolveMiss", new Dictionary<string, string>
|
||||
{
|
||||
{ "displayName", displayName ?? "" },
|
||||
{ "normalizedVendor", normalizedVendor ?? "" },
|
||||
{ "normalizedName", normalizedName ?? "" },
|
||||
{ "candidateCount", candidateCount.ToString() }
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -428,6 +482,15 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
_logger.LogInformation(
|
||||
"CPE auto-resolve HIT: '{DisplayName}' -> {CpeUri} (score={Score:F2}, candidates={CandidateCount}, cpeName={CpeName})",
|
||||
displayName, mapping.CpeUri, score, candidateCount, cpeEntry.CpeName);
|
||||
_telemetryClient?.TrackEvent("CpeAutoResolveHit", new Dictionary<string, string>
|
||||
{
|
||||
{ "displayName", displayName ?? "" },
|
||||
{ "normalizedVendor", normalizedVendor ?? "" },
|
||||
{ "normalizedName", normalizedName ?? "" },
|
||||
{ "cpeUri", mapping.CpeUri ?? "" },
|
||||
{ "score", score.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) },
|
||||
{ "candidateCount", candidateCount.ToString() }
|
||||
});
|
||||
|
||||
// Fire-and-forget: persist the mapping for future sessions
|
||||
_ = Task.Run(async () =>
|
||||
@@ -457,6 +520,13 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
await _vulnRepo.UpdateSoftwareInventoryCpeAsync(mapping.NormalizedProduct ?? "", mapping.CpeUri ?? "");
|
||||
|
||||
_logger.LogInformation("Persisted auto-resolved CPE mapping: {RowKey} -> {CpeUri}", rowKey, mapping.CpeUri);
|
||||
_telemetryClient?.TrackEvent("CpeMappingPersisted", new Dictionary<string, string>
|
||||
{
|
||||
{ "rowKey", rowKey },
|
||||
{ "cpeUri", mapping.CpeUri ?? "" },
|
||||
{ "normalizedVendor", mapping.NormalizedVendor ?? "" },
|
||||
{ "normalizedProduct", mapping.NormalizedProduct ?? "" }
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"applicationInsights": {
|
||||
"samplingSettings": {
|
||||
"isEnabled": true,
|
||||
"excludedTypes": "Request"
|
||||
"excludedTypes": "Request;Event"
|
||||
},
|
||||
"enableLiveMetricsFilters": true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user