CPE Mappings simplified

This commit is contained in:
Oliver Kieselbach
2026-03-20 23:51:12 +01:00
parent f977f7a31c
commit 6f24bf0c75
11 changed files with 475 additions and 181 deletions
-1
View File
@@ -109,7 +109,6 @@ tmpclaude-*-cwd
.claude/commands/ux-insights.md
.claude/commands/diag-agent-log.md
.claude/commands/diag-ime-log.md
.claude/commands/update-cpe-seed.md
.claude/scripts/query-appinsights.sh
.claude/scripts/query-table.sh
.claude/scripts/fetch-diag-zip.sh
@@ -1 +1 @@
557
562
@@ -0,0 +1,76 @@
using System;
using System.Linq;
using System.Net;
using System.Web;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
/// <summary>
/// Returns the full software inventory for a tenant from the SoftwareInventory table.
/// Galactic Admin only. Query param: ?tenantId=...
/// </summary>
public class GetSoftwareInventoryFunction
{
private readonly ILogger<GetSoftwareInventoryFunction> _logger;
private readonly TableStorageService _storageService;
private readonly GalacticAdminService _galacticAdminService;
public GetSoftwareInventoryFunction(
ILogger<GetSoftwareInventoryFunction> logger,
TableStorageService storageService,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_storageService = storageService;
_galacticAdminService = galacticAdminService;
}
[Function("GetSoftwareInventory")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "vulnerability/software-inventory")] HttpRequestData req)
{
try
{
// Galactic Admin only — enforced by PolicyEnforcementMiddleware
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var query = HttpUtility.ParseQueryString(req.Url.Query);
var tenantId = query["tenantId"];
if (string.IsNullOrEmpty(tenantId))
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { success = false, message = "tenantId query parameter is required" });
return badRequest;
}
var inventory = await _storageService.GetSoftwareInventoryAsync(tenantId);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
tenantId,
total = inventory.Count,
matched = inventory.Count(i => !string.IsNullOrEmpty(i["cpeUri"]?.ToString())),
unmatched = inventory.Count(i => string.IsNullOrEmpty(i["cpeUri"]?.ToString())),
inventory
});
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get software inventory");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteStringAsync("Internal server error");
return errorResponse;
}
}
}
}
@@ -2,12 +2,11 @@ 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.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Azure.Data.Tables;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -16,8 +15,9 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
/// <summary>
/// Admin endpoint that aggregates frequently unmatched software across all sessions.
/// Admin endpoint that returns software without CPE mappings from the SoftwareInventory table.
/// Used by the /update-cpe-seed skill and Admin UI to identify gaps in the CPE mapping.
/// Entries in SoftwareInventory with an empty CpeUri are considered "unmatched".
/// </summary>
public class GetUnmatchedSoftwareFunction
{
@@ -41,74 +41,53 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
try
{
// Galactic Admin only
var authHeader = req.Headers.Contains("Authorization")
? req.Headers.GetValues("Authorization").FirstOrDefault()
: null;
if (string.IsNullOrEmpty(authHeader))
{
var unauthorized = req.CreateResponse(HttpStatusCode.Unauthorized);
return unauthorized;
}
// Galactic Admin only — enforced by PolicyEnforcementMiddleware
var userIdentifier = TenantHelper.GetUserIdentifier(req);
// Query all SoftwareInventory entries across tenants and filter for unmatched (no CpeUri)
var tableClient = _storageService.GetTableClient(Constants.TableNames.SoftwareInventory);
var allEntities = tableClient.QueryAsync<TableEntity>(maxPerPage: 1000);
// Scan vulnerability reports in VulnerabilityReports table for unmatched_software lists
var unmatchedAgg = new Dictionary<string, UnmatchedSoftwareAgg>(StringComparer.OrdinalIgnoreCase);
var tableClient = _storageService.GetTableClient(Constants.TableNames.VulnerabilityReports);
var reportEntities = tableClient.QueryAsync<TableEntity>(
filter: $"RowKey eq 'report'",
maxPerPage: 200);
await foreach (var entity in reportEntities)
await foreach (var entity in allEntities)
{
var reportJson = entity.GetString("ReportJson");
if (string.IsNullOrEmpty(reportJson)) continue;
var cpeUri = entity.GetString("CpeUri") ?? "";
if (!string.IsNullOrEmpty(cpeUri))
continue; // Has a CPE mapping — not unmatched
try
var normalizedName = entity.GetString("NormalizedName") ?? "";
if (string.IsNullOrWhiteSpace(normalizedName))
continue;
var key = normalizedName.ToLowerInvariant();
var sessionCount = entity.GetInt32("SessionCount") ?? 1;
var lastSeenAt = entity.GetString("LastSeenAt") ?? "";
var lastSessionId = entity.GetString("LastSessionId") ?? "";
if (!unmatchedAgg.TryGetValue(key, out var existing))
{
var data = JsonSerializer.Deserialize<Dictionary<string, object>>(reportJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (data == null || !data.ContainsKey("unmatched_software")) continue;
var unmatchedJson = data["unmatched_software"]?.ToString();
if (string.IsNullOrEmpty(unmatchedJson)) continue;
var unmatchedList = JsonSerializer.Deserialize<List<Dictionary<string, string>>>(unmatchedJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (unmatchedList == null) continue;
var sessionId = entity.GetString("SessionId") ?? "";
foreach (var item in unmatchedList)
unmatchedAgg[key] = new UnmatchedSoftwareAgg
{
var softwareName = item.GetValueOrDefault("softwareName", "");
if (string.IsNullOrWhiteSpace(softwareName)) continue;
var key = softwareName.ToLowerInvariant();
if (!unmatchedAgg.ContainsKey(key))
{
unmatchedAgg[key] = new UnmatchedSoftwareAgg
{
SoftwareName = softwareName,
Publisher = item.GetValueOrDefault("publisher", ""),
Frequency = 0,
LastSeenAt = entity.GetDateTimeOffset("Timestamp")?.UtcDateTime ?? DateTime.UtcNow,
ExampleSessionId = sessionId
};
}
unmatchedAgg[key].Frequency++;
var ts = entity.GetDateTimeOffset("Timestamp")?.UtcDateTime ?? DateTime.MinValue;
if (ts > unmatchedAgg[key].LastSeenAt)
{
unmatchedAgg[key].LastSeenAt = ts;
unmatchedAgg[key].ExampleSessionId = sessionId;
}
SoftwareName = entity.GetString("DisplayName") ?? normalizedName,
Publisher = entity.GetString("Publisher") ?? "",
Frequency = sessionCount,
LastSeenAt = lastSeenAt,
ExampleSessionId = lastSessionId,
NormalizedVendor = entity.GetString("NormalizedVendor") ?? "",
NormalizedVersion = entity.GetString("NormalizedVersion") ?? ""
};
}
else
{
// Aggregate across tenants: sum frequency, keep most recent
existing.Frequency += sessionCount;
if (string.Compare(lastSeenAt, existing.LastSeenAt, StringComparison.Ordinal) > 0)
{
existing.LastSeenAt = lastSeenAt;
existing.ExampleSessionId = lastSessionId;
}
}
catch { /* skip malformed reports */ }
}
var result = unmatchedAgg.Values
@@ -134,8 +113,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
public string? SoftwareName { get; set; } = "";
public string? Publisher { get; set; } = "";
public int Frequency { get; set; }
public DateTime LastSeenAt { get; set; }
public string? LastSeenAt { get; set; } = "";
public string? ExampleSessionId { get; set; } = "";
public string? NormalizedVendor { get; set; } = "";
public string? NormalizedVersion { get; set; } = "";
}
}
}
@@ -1,5 +1,6 @@
using System;
using System.Net;
using System.Web;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Vulnerability;
@@ -11,6 +12,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
/// <summary>
/// HTTP-triggered manual sync for vulnerability data (KEV catalog refresh).
/// Supports ?reseed=true to force re-import of CPE mapping seed from embedded resource.
/// Galactic Admin only.
/// </summary>
public class TriggerVulnerabilityDataSyncFunction
@@ -18,17 +20,20 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
private readonly KevDataService _kevService;
private readonly AdminConfigurationService _adminConfigService;
private readonly GalacticAdminService _galacticAdminService;
private readonly TableStorageService _storageService;
private readonly ILogger<TriggerVulnerabilityDataSyncFunction> _logger;
public TriggerVulnerabilityDataSyncFunction(
KevDataService kevService,
AdminConfigurationService adminConfigService,
GalacticAdminService galacticAdminService,
TableStorageService storageService,
ILogger<TriggerVulnerabilityDataSyncFunction> logger)
{
_kevService = kevService;
_adminConfigService = adminConfigService;
_galacticAdminService = galacticAdminService;
_storageService = storageService;
_logger = logger;
}
@@ -41,6 +46,17 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
// Refresh KEV catalog
await _kevService.RefreshAsync();
// If reseed requested, re-import the CPE mapping seed
int cpeSeedEntries = 0;
var query = HttpUtility.ParseQueryString(req.Url.Query);
if (string.Equals(query["reseed"], "true", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("CPE mapping seed re-import requested");
cpeSeedEntries = await _storageService.ImportCpeMappingSeedAsync(force: true);
VulnerabilityCorrelationService.ResetMappingsCache();
_logger.LogInformation("CPE mapping seed re-imported: {Count} entries", cpeSeedEntries);
}
// Update sync timestamp
var config = await _adminConfigService.GetConfigurationAsync();
if (config != null)
@@ -55,6 +71,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
success = true,
message = "Vulnerability data sync completed",
kevCatalogEntries = _kevService.CatalogSize,
cpeSeedEntries,
syncedAt = DateTime.UtcNow.ToString("o")
});
return response;
@@ -183,6 +183,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "health/detailed", EndpointPolicy.AuthenticatedUser),
new("POST", "rules/reseed-from-github", EndpointPolicy.GalacticAdminOnly),
new("GET", "vulnerability/unmatched-software", EndpointPolicy.GalacticAdminOnly),
new("GET", "vulnerability/software-inventory", EndpointPolicy.GalacticAdminOnly),
new("POST", "vulnerability/sync", EndpointPolicy.GalacticAdminOnly),
new("POST", "vulnerability/cpe-mapping", EndpointPolicy.GalacticAdminOnly),
new("POST", "rules/ime-log-patterns/reseed", EndpointPolicy.GalacticAdminOnly),
@@ -1,5 +1,9 @@
using System.IO;
using System.Reflection;
using System.Text.Json;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
@@ -578,5 +582,276 @@ namespace AutopilotMonitor.Functions.Services
return null;
}
}
// ===== SOFTWARE INVENTORY METHODS =====
/// <summary>
/// Upserts software inventory entries for a tenant.
/// PK = tenantId, RK = {normalizedVendor}:{normalizedProduct}:{normalizedVersion} (sanitized).
/// If an entry already exists, increments SessionCount and updates LastSeenAt/LastSessionId.
/// </summary>
public async Task UpsertSoftwareInventoryAsync(string tenantId, List<Dictionary<string, object>> inventoryItems, string sessionId, Dictionary<string, string?>? cpeMappings = null)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SoftwareInventory);
// Read all existing entities for this tenant to merge SessionCount
var existingEntities = new Dictionary<string, TableEntity>(StringComparer.OrdinalIgnoreCase);
var existingQuery = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{tenantId}'");
await foreach (var entity in existingQuery)
{
existingEntities[entity.RowKey] = entity;
}
// Build the batch of entities to upsert
var entitiesToUpsert = new List<TableEntity>();
foreach (var item in inventoryItems)
{
var normalizedName = item.ContainsKey("normalizedName") ? item["normalizedName"]?.ToString() ?? "" : "";
var normalizedVendor = item.ContainsKey("normalizedPublisher") ? item["normalizedPublisher"]?.ToString() ?? "" : "";
var normalizedVersion = item.ContainsKey("normalizedVersion") ? item["normalizedVersion"]?.ToString() ?? "" : "";
var displayName = item.ContainsKey("displayName") ? item["displayName"]?.ToString() ?? "" : "";
var publisher = item.ContainsKey("publisher") ? item["publisher"]?.ToString() ?? "" : "";
var registrySource = item.ContainsKey("registrySource") ? item["registrySource"]?.ToString() ?? "" : "";
var normalizationConfidence = item.ContainsKey("normalizationConfidence") ? item["normalizationConfidence"]?.ToString() ?? "" : "";
var rowKey = SanitizeTableKey($"{normalizedVendor}:{normalizedName}:{normalizedVersion}");
if (string.IsNullOrWhiteSpace(rowKey) || rowKey == "::")
continue;
// Truncate RowKey to Table Storage limit (1KB)
if (rowKey.Length > 512)
rowKey = rowKey.Substring(0, 512);
var now = DateTime.UtcNow;
TableEntity entity;
if (existingEntities.TryGetValue(rowKey, out var existing))
{
// Update existing: increment SessionCount, update LastSeenAt
existing["SessionCount"] = (existing.GetInt32("SessionCount") ?? 0) + 1;
existing["LastSeenAt"] = now.ToString("o");
existing["LastSessionId"] = sessionId;
// Update CpeUri if we have a mapping and current is empty
if (cpeMappings != null && cpeMappings.TryGetValue(normalizedName, out var cpeUri) && !string.IsNullOrEmpty(cpeUri))
{
existing["CpeUri"] = cpeUri;
}
entity = existing;
}
else
{
// Create new entry
entity = new TableEntity(tenantId, rowKey)
{
["DisplayName"] = displayName,
["NormalizedName"] = normalizedName,
["NormalizedVendor"] = normalizedVendor,
["NormalizedVersion"] = normalizedVersion,
["Publisher"] = publisher,
["RegistrySource"] = registrySource,
["NormalizationConfidence"] = normalizationConfidence,
["FirstSeenAt"] = now.ToString("o"),
["LastSeenAt"] = now.ToString("o"),
["FirstSessionId"] = sessionId,
["LastSessionId"] = sessionId,
["SessionCount"] = 1,
["CpeUri"] = cpeMappings != null && cpeMappings.TryGetValue(normalizedName, out var cpeUri) && !string.IsNullOrEmpty(cpeUri) ? cpeUri : ""
};
}
entitiesToUpsert.Add(entity);
}
// Batch write (max 100 per batch, all same PK)
for (int i = 0; i < entitiesToUpsert.Count; i += 100)
{
var batch = entitiesToUpsert.Skip(i).Take(100)
.Select(e => new TableTransactionAction(TableTransactionActionType.UpsertReplace, e))
.ToList();
if (batch.Count > 0)
{
await tableClient.SubmitTransactionAsync(batch);
}
}
_logger.LogInformation("Upserted {Count} software inventory entries for tenant {TenantId}", entitiesToUpsert.Count, tenantId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to upsert software inventory for tenant {TenantId}", tenantId);
throw;
}
}
/// <summary>
/// Gets all software inventory entries for a tenant.
/// PK = tenantId. Returns all rows.
/// </summary>
public async Task<List<Dictionary<string, object>>> GetSoftwareInventoryAsync(string tenantId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SoftwareInventory);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{tenantId}'");
var results = new List<Dictionary<string, object>>();
await foreach (var entity in query)
{
results.Add(new Dictionary<string, object>
{
{ "displayName", entity.GetString("DisplayName") ?? "" },
{ "normalizedName", entity.GetString("NormalizedName") ?? "" },
{ "normalizedVendor", entity.GetString("NormalizedVendor") ?? "" },
{ "normalizedVersion", entity.GetString("NormalizedVersion") ?? "" },
{ "publisher", entity.GetString("Publisher") ?? "" },
{ "registrySource", entity.GetString("RegistrySource") ?? "" },
{ "normalizationConfidence", entity.GetString("NormalizationConfidence") ?? "" },
{ "cpeUri", entity.GetString("CpeUri") ?? "" },
{ "firstSeenAt", entity.GetString("FirstSeenAt") ?? "" },
{ "lastSeenAt", entity.GetString("LastSeenAt") ?? "" },
{ "firstSessionId", entity.GetString("FirstSessionId") ?? "" },
{ "lastSessionId", entity.GetString("LastSessionId") ?? "" },
{ "sessionCount", entity.GetInt32("SessionCount") ?? 0 }
});
}
return results;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get software inventory for tenant {TenantId}", tenantId);
return new List<Dictionary<string, object>>();
}
}
/// <summary>
/// Sanitize a string for use as an Azure Table Storage key.
/// Replaces /, \, #, ? with underscores.
/// </summary>
private static string SanitizeTableKey(string key)
{
return key
.Replace("/", "_")
.Replace("\\", "_")
.Replace("#", "_")
.Replace("?", "_");
}
// ===== CPE MAPPING SEED IMPORT =====
/// <summary>
/// Imports CPE mappings from the embedded seed JSON into the VulnerabilityCache table
/// with PK = "cpe_map_seed". Only imports if no seed entries exist yet (idempotent).
/// When force=true, deletes existing seed entries first and re-imports.
/// Returns the number of entries imported (0 if already seeded and force=false).
/// </summary>
public async Task<int> ImportCpeMappingSeedAsync(bool force = false)
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.VulnerabilityCache);
// Check if seed entries already exist
if (!force)
{
var existingQuery = tableClient.QueryAsync<TableEntity>(
filter: "PartitionKey eq 'cpe_map_seed'",
select: new[] { "PartitionKey" },
maxPerPage: 1);
await foreach (var _ in existingQuery)
{
// At least one seed entry exists — already seeded
return 0;
}
}
else
{
// Force mode: delete all existing seed entries first
var existingEntities = tableClient.QueryAsync<TableEntity>(
filter: "PartitionKey eq 'cpe_map_seed'",
select: new[] { "PartitionKey", "RowKey" });
await foreach (var entity in existingEntities)
{
await tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey);
}
}
// Load the embedded seed JSON
var assembly = Assembly.GetExecutingAssembly();
var resourceName = assembly.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith("cpe-mapping-seed.json", StringComparison.OrdinalIgnoreCase));
if (resourceName == null)
{
_logger.LogWarning("CPE mapping seed resource not found in assembly");
return 0;
}
string json;
using (var stream = assembly.GetManifestResourceStream(resourceName))
{
if (stream == null) return 0;
using var reader = new StreamReader(stream);
json = await reader.ReadToEndAsync();
}
var seed = System.Text.Json.JsonSerializer.Deserialize<CpeMappingSeed>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (seed?.Mappings == null || seed.Mappings.Count == 0)
return 0;
// Build entities
var entities = new List<TableEntity>();
foreach (var mapping in seed.Mappings)
{
var rowKey = SanitizeTableKey(
$"{(mapping.NormalizedVendor ?? "unknown")}:{(mapping.NormalizedProduct ?? "unknown")}".ToLowerInvariant());
// Truncate RowKey to Table Storage limit
if (rowKey.Length > 512)
rowKey = rowKey.Substring(0, 512);
entities.Add(new TableEntity("cpe_map_seed", rowKey)
{
{ "NormalizedVendor", mapping.NormalizedVendor ?? "" },
{ "NormalizedProduct", mapping.NormalizedProduct ?? "" },
{ "CpeVendor", mapping.CpeVendor ?? "" },
{ "CpeProduct", mapping.CpeProduct ?? "" },
{ "CpeUri", mapping.CpeUri ?? "" },
{ "Category", mapping.Category ?? "" },
{ "DisplayNamePatternsJson", System.Text.Json.JsonSerializer.Serialize(mapping.DisplayNamePatterns ?? new List<string>()) },
{ "PublisherPatternsJson", System.Text.Json.JsonSerializer.Serialize(mapping.PublisherPatterns ?? new List<string>()) },
{ "Source", "seed" },
{ "ImportedAt", DateTime.UtcNow.ToString("o") }
});
}
// Batch write (max 100 per batch, all same PK)
for (int i = 0; i < entities.Count; i += 100)
{
var batch = entities.Skip(i).Take(100)
.Select(e => new TableTransactionAction(TableTransactionActionType.UpsertReplace, e))
.ToList();
if (batch.Count > 0)
{
await tableClient.SubmitTransactionAsync(batch);
}
}
_logger.LogInformation("Imported {Count} CPE mapping seed entries into VulnerabilityCache table", entities.Count);
return entities.Count;
}
}
}
@@ -83,6 +83,18 @@ namespace AutopilotMonitor.Functions.Services
stopwatch.Stop();
_logger.LogInformation($"Table initialization completed in {stopwatch.ElapsedMilliseconds}ms: {successCount} succeeded, {failCount} failed");
// Import CPE mapping seed data if not already present
try
{
var seedCount = await ImportCpeMappingSeedAsync();
if (seedCount > 0)
_logger.LogInformation("Imported {Count} CPE mapping seed entries", seedCount);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to import CPE mapping seed (non-fatal)");
}
lock (_initLock)
{
_tablesInitialized = failCount == 0;
@@ -1,8 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
@@ -76,6 +74,8 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
int kevMatches = 0;
string overallRisk = "none";
var cpeMappingsDict = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var item in inventoryItems)
{
if (ct.IsCancellationRequested) break;
@@ -99,6 +99,12 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
continue;
}
// Track CPE mapping for software inventory persistence
if (!string.IsNullOrEmpty(normalizedName) && !cpeMappingsDict.ContainsKey(normalizedName))
{
cpeMappingsDict[normalizedName] = cpeMapping.CpeUri;
}
matchedCount++;
var cpeUri = cpeMapping.CpeUri ?? "";
@@ -188,10 +194,24 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
return ra.CompareTo(rb);
});
// Persist software inventory (fire-and-forget, non-blocking)
_ = Task.Run(async () =>
{
try
{
await _storage.UpsertSoftwareInventoryAsync(tenantId, inventoryItems, sessionId, cpeMappingsDict);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to upsert software inventory (non-fatal)");
}
});
// 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 products", inventoryItems.Count);
_logger.LogInformation("Vulnerability correlation: no findings for {Count} scanned, {Unmatched} unmatched",
inventoryItems.Count, unmatchedSoftware.Count);
return null;
}
@@ -594,7 +614,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
}
// -----------------------------------------------------------------------
// Seed Loading
// Mapping Loading (table-only — no embedded resource fallback)
// -----------------------------------------------------------------------
private async Task EnsureMappingsLoadedAsync()
@@ -610,46 +630,17 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
_cpeMappings = new List<CpeMappingEntry>();
// Load from embedded seed resource
try
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = assembly.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith("cpe-mapping-seed.json", StringComparison.OrdinalIgnoreCase));
if (resourceName != null)
{
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream != null)
{
using var reader = new StreamReader(stream);
var json = await reader.ReadToEndAsync();
var seed = JsonSerializer.Deserialize<CpeMappingSeed>(json, JsonOptions);
if (seed?.Mappings != null)
{
_cpeMappings.AddRange(seed.Mappings);
_logger.LogInformation("Loaded {Count} CPE mappings from seed", seed.Mappings.Count);
}
}
}
else
{
_logger.LogWarning("CPE mapping seed resource not found in assembly");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load CPE mapping seed");
}
// Load custom mappings from VulnerabilityCache table
// Load ALL CPE mappings from VulnerabilityCache table
// (seed + custom + community — all in one table, distinguished by PK prefix)
try
{
var tableClient = _storage.GetTableClient(AutopilotMonitor.Shared.Constants.TableNames.VulnerabilityCache);
var customMappings = tableClient.QueryAsync<TableEntity>(
filter: $"PartitionKey eq 'cpe_map_custom'");
await foreach (var entity in customMappings)
// Query all cpe_map_* entries (seed, custom, community)
var allMappings = tableClient.QueryAsync<TableEntity>(
filter: "PartitionKey ge 'cpe_map' and PartitionKey lt 'cpe_mar'");
await foreach (var entity in allMappings)
{
_cpeMappings.Add(new CpeMappingEntry
{
@@ -658,94 +649,32 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
CpeVendor = entity.GetString("CpeVendor") ?? "",
CpeProduct = entity.GetString("CpeProduct") ?? "",
CpeUri = entity.GetString("CpeUri") ?? "",
Category = entity.GetString("Category") ?? "custom",
DisplayNamePatterns = JsonSerializer.Deserialize<List<string>>(
entity.GetString("DisplayNamePatternsJson") ?? "[]") ?? new List<string>(),
PublisherPatterns = JsonSerializer.Deserialize<List<string>>(
entity.GetString("PublisherPatternsJson") ?? "[]") ?? new List<string>()
Category = entity.GetString("Category") ?? "",
DisplayNamePatterns = DeserializeStringList(entity.GetString("DisplayNamePatternsJson")),
PublisherPatterns = DeserializeStringList(entity.GetString("PublisherPatternsJson"))
});
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "No custom CPE mappings loaded (table may not exist yet)");
}
// Load community CPE mappings (table storage from reseed, falling back to embedded resource)
int communityCount = 0;
try
{
var vulnTable = _storage.GetTableClient(AutopilotMonitor.Shared.Constants.TableNames.VulnerabilityCache);
var communityMappings = vulnTable.QueryAsync<TableEntity>(
filter: $"PartitionKey eq 'cpe_map_community'");
await foreach (var entity in communityMappings)
{
_cpeMappings.Add(new CpeMappingEntry
{
NormalizedVendor = entity.GetString("NormalizedVendor") ?? "",
NormalizedProduct = entity.GetString("NormalizedProduct") ?? "",
CpeVendor = entity.GetString("CpeVendor") ?? "",
CpeProduct = entity.GetString("CpeProduct") ?? "",
CpeUri = entity.GetString("CpeUri") ?? "",
Category = entity.GetString("Category") ?? "community",
DisplayNamePatterns = JsonSerializer.Deserialize<List<string>>(
entity.GetString("DisplayNamePatternsJson") ?? "[]") ?? new List<string>(),
PublisherPatterns = JsonSerializer.Deserialize<List<string>>(
entity.GetString("PublisherPatternsJson") ?? "[]") ?? new List<string>()
});
communityCount++;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "No community CPE mappings in table storage, falling back to embedded resource");
}
// Fallback: load from embedded resource if table had no community entries
if (communityCount == 0)
{
try
{
var assembly = Assembly.GetExecutingAssembly();
var communityResourceName = assembly.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith("cpe-community-mappings.json", StringComparison.OrdinalIgnoreCase));
if (communityResourceName != null)
{
using var communityStream = assembly.GetManifestResourceStream(communityResourceName);
if (communityStream != null)
{
using var communityReader = new StreamReader(communityStream);
var communityJson = await communityReader.ReadToEndAsync();
var communitySeed = JsonSerializer.Deserialize<CpeMappingSeed>(communityJson, JsonOptions);
if (communitySeed?.Mappings != null && communitySeed.Mappings.Count > 0)
{
_cpeMappings.AddRange(communitySeed.Mappings);
communityCount = communitySeed.Mappings.Count;
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load community CPE mappings from embedded resource");
}
}
if (communityCount > 0)
{
_logger.LogInformation("Loaded {Count} community CPE mappings", communityCount);
_logger.LogWarning(ex, "Failed to load CPE mappings from table");
}
_seedLoaded = true;
_logger.LogInformation("CPE mappings ready: {Count} total entries", _cpeMappings.Count);
_logger.LogInformation("CPE mappings ready: {Count} total entries (from table)", _cpeMappings.Count);
}
finally
{
_initLock.Release();
}
}
private static List<string> DeserializeStringList(string? json)
{
if (string.IsNullOrEmpty(json)) return new List<string>();
try { return JsonSerializer.Deserialize<List<string>>(json, JsonOptions) ?? new List<string>(); }
catch { return new List<string>(); }
}
}
// -----------------------------------------------------------------------
@@ -259,6 +259,9 @@ namespace AutopilotMonitor.Shared
// Vulnerability reports per session (like RuleResults but for CVE correlation)
public const string VulnerabilityReports = "VulnerabilityReports";
// Persistent software inventory per tenant (aggregated from enrollment snapshots)
public const string SoftwareInventory = "SoftwareInventory";
/// <summary>
/// Returns all table names for initialization
/// </summary>
@@ -289,7 +292,8 @@ namespace AutopilotMonitor.Shared
BootstrapSessions,
GalacticNotifications,
VulnerabilityCache,
VulnerabilityReports
VulnerabilityReports,
SoftwareInventory
};
}
}
File diff suppressed because one or more lines are too long