Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs
T
Oliver Kieselbach 81c14bb3ed fix(rules): close the rule-ID namespace gaps around the shared global keyspace
Custom rules are tenant-partitioned and functionally collision-free, but three
surfaces still keyed on the bare non-unique ruleId string:

- RuleStats global aggregate (global_{ruleId}) summed same-ID custom rules of
  unrelated tenants (title/severity last-writer-wins). The global row is now
  catalog-only: analyze gates on IsBuiltIn/IsCommunity, gather gates on the
  reserved-namespace pattern (agent events carry no flag; custom rules can
  never occupy the reserved pattern).
- The 'never ship a built-in in the CUSTOM namespace' commitment from
  RuleIdPolicy was documented but unenforced: combine.js now fails on
  duplicate or non-reserved built-in IDs, BuiltInRuleCatalogPolicyTests pins
  the same invariants on the embedded catalog, and the GitHub reseed rejects
  non-reserved IDs before writing to the global partition.
- DeleteRuleStatesForRuleIdAcrossTenantsAsync (cross-partition delete by bare
  RowKey) now refuses IDs outside the reserved namespace with failed=-1 so
  callers abort the follow-up global catalog delete.

Verified: prod global partitions (45 analyze + 11 gather) are fully inside the
reserved namespace, so the guards touch no existing data. New OKF doc
docs/rules/rule-id-namespace.md captures the identity/collision model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 14:13:46 +02:00

1207 lines
59 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services
{
public partial class TableStorageService
{
// ===== RULE RESULTS METHODS =====
/// <summary>
/// Azure Table Storage hard-limits each property to 64 KiB (UTF-16). We trip the
/// guard rail at 60 KiB to leave headroom for the property-name prefix + framing
/// bytes the storage layer adds on the wire.
/// </summary>
private const int MatchedConditionsJsonByteLimit = 60 * 1024;
/// <summary>
/// Stores a rule evaluation result
/// PartitionKey: {TenantId}_{SessionId}, RowKey: RuleId
/// <para>
/// Defense-in-depth: rule authors should keep <see cref="RuleResult.MatchedConditions"/>
/// under <see cref="MatchedConditionsJsonByteLimit"/> bytes (correlation evidence
/// already caps and slims its pairs). If a future rule still produces oversized
/// output, we substitute a truncation marker rather than letting the entire write
/// fail — the rule still fires, the UI sees the explanation + flag, and we get an
/// observable warning instead of an opaque store failure.
/// </para>
/// </summary>
public async Task<bool> StoreRuleResultAsync(RuleResult result)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults);
var partitionKey = $"{result.TenantId}_{result.SessionId}";
var matchedJson = JsonConvert.SerializeObject(result.MatchedConditions ?? new Dictionary<string, object>());
if (matchedJson.Length > MatchedConditionsJsonByteLimit)
{
_logger.LogWarning(
"Rule result {RuleId} (session {SessionId}) MatchedConditionsJson is {Bytes} bytes, exceeds {Limit} byte guard. Substituting truncation marker so the rule still persists.",
result.RuleId, result.SessionId, matchedJson.Length, MatchedConditionsJsonByteLimit);
var truncated = new Dictionary<string, object>
{
["_truncated"] = true,
["_originalBytes"] = matchedJson.Length,
["_reason"] = "MatchedConditions exceeded Table Storage 64KB property limit"
};
matchedJson = JsonConvert.SerializeObject(truncated);
}
var entity = new TableEntity(partitionKey, result.RuleId)
{
["ResultId"] = result.ResultId,
["SessionId"] = result.SessionId,
["TenantId"] = result.TenantId,
["RuleId"] = result.RuleId,
["RuleTitle"] = result.RuleTitle ?? string.Empty,
["Severity"] = result.Severity ?? string.Empty,
["Category"] = result.Category ?? string.Empty,
["ConfidenceScore"] = result.ConfidenceScore,
["Explanation"] = result.Explanation ?? string.Empty,
["RemediationJson"] = JsonConvert.SerializeObject(result.Remediation ?? new List<RemediationStep>()),
["RelatedDocsJson"] = JsonConvert.SerializeObject(result.RelatedDocs ?? new List<RelatedDoc>()),
["MatchedConditionsJson"] = matchedJson,
["DetectedAt"] = result.DetectedAt,
// Evaluation lifecycle (evaluateOn interim triggers). Nullable columns:
// legacy rows without them read back as final/terminal results.
["FirstDetectedAt"] = result.FirstDetectedAt,
["LastEvaluatedAt"] = result.LastEvaluatedAt,
["IsInterim"] = result.IsInterim,
["ResolvedAt"] = result.ResolvedAt,
["NotifiedAt"] = result.NotifiedAt
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogInformation($"Stored rule result {result.RuleId} for session {result.SessionId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store rule result {result.RuleId}");
return false;
}
}
/// <summary>
/// Gets all rule results for a session
/// </summary>
public async Task<List<RuleResult>> GetRuleResultsAsync(string tenantId, string sessionId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults);
var partitionKey = $"{tenantId}_{sessionId}";
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{partitionKey}'");
var results = new List<RuleResult>();
await foreach (var entity in query)
{
results.Add(new RuleResult
{
ResultId = entity.GetString("ResultId") ?? string.Empty,
SessionId = entity.GetString("SessionId") ?? string.Empty,
TenantId = entity.GetString("TenantId") ?? string.Empty,
RuleId = entity.GetString("RuleId") ?? entity.RowKey,
RuleTitle = entity.GetString("RuleTitle") ?? string.Empty,
Severity = entity.GetString("Severity") ?? string.Empty,
Category = entity.GetString("Category") ?? string.Empty,
ConfidenceScore = entity.GetInt32("ConfidenceScore") ?? 0,
Explanation = entity.GetString("Explanation") ?? string.Empty,
Remediation = DeserializeJson<List<RemediationStep>>(entity.GetString("RemediationJson")),
RelatedDocs = DeserializeJson<List<RelatedDoc>>(entity.GetString("RelatedDocsJson")),
MatchedConditions = DeserializeMatchedConditions(entity.GetString("MatchedConditionsJson")),
DetectedAt = entity.GetDateTimeOffset("DetectedAt")?.UtcDateTime ?? DateTime.UtcNow,
FirstDetectedAt = entity.GetDateTimeOffset("FirstDetectedAt")?.UtcDateTime,
LastEvaluatedAt = entity.GetDateTimeOffset("LastEvaluatedAt")?.UtcDateTime,
IsInterim = entity.GetBoolean("IsInterim") ?? false,
ResolvedAt = entity.GetDateTimeOffset("ResolvedAt")?.UtcDateTime,
NotifiedAt = entity.GetDateTimeOffset("NotifiedAt")?.UtcDateTime
});
}
return results.OrderByDescending(r => r.ConfidenceScore).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get rule results for session {sessionId}");
return new List<RuleResult>();
}
}
/// <summary>
/// F3 radar hit set: sessionIds where <paramref name="ruleId"/> produced a result at/after
/// <paramref name="sinceUtc"/>. RuleResults PK = "{tenantId}_{sessionId}", RK = ruleId, so a
/// tenant partition-range scan with server-side RowKey + DetectedAt filters returns exactly
/// the hit rows; only SessionId is transferred. Runs on-fire only (rare) and is fail-soft —
/// an empty result just means the alert carries no dimension claim.
/// </summary>
public async Task<List<string>> GetRuleHitSessionIdsAsync(
string tenantId, string ruleId, DateTime sinceUtc, int maxResults = 2000)
{
var sessionIds = new List<string>();
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(ruleId))
return sessionIds;
try
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults);
var safeTenantId = ODataSanitizer.EscapeValue(tenantId);
var safeRuleId = ODataSanitizer.EscapeValue(ruleId);
var filter = $"PartitionKey ge '{safeTenantId}_' and PartitionKey lt '{safeTenantId}_~' " +
$"and RowKey eq '{safeRuleId}' and DetectedAt ge datetime'{sinceUtc:yyyy-MM-ddTHH:mm:ss}Z'";
await foreach (var entity in tableClient.QueryAsync<TableEntity>(
filter: filter, select: new[] { "SessionId" }))
{
var sessionId = entity.GetString("SessionId");
if (!string.IsNullOrEmpty(sessionId))
sessionIds.Add(sessionId!);
if (sessionIds.Count >= maxResults) break;
}
return sessionIds;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get rule hit sessions for rule {RuleId} in tenant {TenantId}", ruleId, tenantId);
return sessionIds;
}
}
// ===== GATHER RULES METHODS =====
/// <summary>
/// Stores or updates a gather rule
/// PartitionKey: TenantId (or "global" for built-in), RowKey: RuleId
/// </summary>
public async Task<bool> StoreGatherRuleAsync(GatherRule rule, string tenantId = "global")
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules);
var entity = new TableEntity(tenantId, rule.RuleId)
{
["Title"] = rule.Title ?? string.Empty,
["Description"] = rule.Description ?? string.Empty,
["Category"] = rule.Category ?? string.Empty,
["Version"] = rule.Version ?? "1.0.0",
["Author"] = rule.Author ?? "Autopilot Monitor",
["Enabled"] = rule.Enabled,
["IsBuiltIn"] = rule.IsBuiltIn,
["IsCommunity"] = rule.IsCommunity,
["Provenance"] = rule.Provenance ?? string.Empty,
["CollectorType"] = rule.CollectorType ?? string.Empty,
["Target"] = rule.Target ?? string.Empty,
["ParametersJson"] = JsonConvert.SerializeObject(rule.Parameters ?? new Dictionary<string, string>()),
["Trigger"] = rule.Trigger ?? string.Empty,
["IntervalSeconds"] = rule.IntervalSeconds,
["TriggerPhase"] = rule.TriggerPhase ?? string.Empty,
["TriggerEventType"] = rule.TriggerEventType ?? string.Empty,
["ActivePhasesJson"] = JsonConvert.SerializeObject(rule.ActivePhases ?? new List<string>()),
["ActiveFromPhase"] = rule.ActiveFromPhase ?? string.Empty,
["EmitMode"] = rule.EmitMode ?? string.Empty,
["OutputEventType"] = rule.OutputEventType ?? string.Empty,
["OutputSeverity"] = rule.OutputSeverity ?? "Info",
["TagsJson"] = JsonConvert.SerializeObject(rule.Tags ?? new string[0]),
["CreatedAt"] = rule.CreatedAt,
["UpdatedAt"] = rule.UpdatedAt
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogDebug($"Stored gather rule {rule.RuleId} for {tenantId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store gather rule {rule.RuleId}");
return false;
}
}
/// <summary>
/// Gets gather rules for a partition (tenant or "global")
/// </summary>
public async Task<List<GatherRule>> GetGatherRulesAsync(string partitionKey)
{
if (partitionKey != "global")
SecurityValidator.EnsureValidGuid(partitionKey, nameof(partitionKey));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{partitionKey}'");
var rules = new List<GatherRule>();
await foreach (var entity in query)
{
rules.Add(MapToGatherRule(entity));
}
return rules;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get gather rules for {partitionKey}");
return new List<GatherRule>();
}
}
/// <summary>
/// Deletes a gather rule
/// </summary>
public async Task<bool> DeleteGatherRuleAsync(string tenantId, string ruleId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules);
await tableClient.DeleteEntityAsync(tenantId, ruleId);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete gather rule {ruleId}");
return false;
}
}
// Internal (not private) so the Store↔Map roundtrip is testable per the
// table-serialization rule: every model field must appear in BOTH directions.
internal GatherRule MapToGatherRule(TableEntity entity)
{
return new GatherRule
{
RuleId = entity.RowKey,
Title = entity.GetString("Title") ?? string.Empty,
Description = entity.GetString("Description") ?? string.Empty,
Category = entity.GetString("Category") ?? string.Empty,
Version = entity.GetString("Version") ?? "1.0.0",
Author = entity.GetString("Author") ?? "Autopilot Monitor",
Enabled = entity.GetBoolean("Enabled") ?? true,
IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false,
IsCommunity = entity.GetBoolean("IsCommunity") ?? false,
// Absent column (pre-existing rows) → null → treated as "embedded" by RuleProvenance.
Provenance = string.IsNullOrEmpty(entity.GetString("Provenance")) ? null : entity.GetString("Provenance"),
CollectorType = entity.GetString("CollectorType") ?? string.Empty,
Target = entity.GetString("Target") ?? string.Empty,
Parameters = DeserializeJson<Dictionary<string, string>>(entity.GetString("ParametersJson")),
Trigger = entity.GetString("Trigger") ?? string.Empty,
IntervalSeconds = entity.GetInt32("IntervalSeconds"),
TriggerPhase = entity.GetString("TriggerPhase") ?? string.Empty,
TriggerEventType = entity.GetString("TriggerEventType") ?? string.Empty,
ActivePhases = MapActivePhases(entity.GetString("ActivePhasesJson")),
ActiveFromPhase = string.IsNullOrEmpty(entity.GetString("ActiveFromPhase")) ? null : entity.GetString("ActiveFromPhase"),
EmitMode = string.IsNullOrEmpty(entity.GetString("EmitMode")) ? null : entity.GetString("EmitMode"),
OutputEventType = entity.GetString("OutputEventType") ?? string.Empty,
OutputSeverity = entity.GetString("OutputSeverity") ?? "Info",
Tags = DeserializeJsonArray(entity.GetString("TagsJson")),
CreatedAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime ?? DateTime.UtcNow,
UpdatedAt = entity.GetDateTimeOffset("UpdatedAt")?.UtcDateTime ?? DateTime.UtcNow
};
}
/// <summary>
/// Absent column (pre-existing rows) and the stored-empty "[]" both map to null —
/// null and empty are the same "unrestricted" state on <see cref="GatherRule.ActivePhases"/>,
/// and null keeps <see cref="GatherRuleService.ContentEquivalent"/> reseed-stable.
/// </summary>
private List<string>? MapActivePhases(string? json)
{
var phases = DeserializeJson<List<string>>(json);
return phases.Count == 0 ? null : phases;
}
// ===== RULE STATES METHODS =====
/// <summary>
/// Stores or updates the enabled/disabled state for a built-in or community rule per tenant
/// PartitionKey: TenantId, RowKey: RuleId
/// </summary>
public async Task<bool> StoreRuleStateAsync(string tenantId, string ruleId, RuleState state)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStates);
var entity = new TableEntity(tenantId, ruleId)
{
["Enabled"] = state.Enabled,
["UpdatedAt"] = DateTime.UtcNow
};
// Nullable override: write the bool when set; leave the property absent when cleared.
// Use Replace mode so a cleared override actually wipes the column — Merge mode skips
// null values, leaving a stale `true` on the row (reset would silently fail).
if (state.MarkSessionAsFailed.HasValue)
entity["MarkSessionAsFailed"] = state.MarkSessionAsFailed.Value;
if (state.Notify.HasValue)
entity["Notify"] = state.Notify.Value;
if (!string.IsNullOrEmpty(state.NotifyChannelIdsJson))
entity["NotifyChannelIdsJson"] = state.NotifyChannelIdsJson;
await tableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
_logger.LogDebug($"Stored rule state {ruleId} for {tenantId}: enabled={state.Enabled}, markAsFailed={state.MarkSessionAsFailed?.ToString() ?? "inherit"}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store rule state {ruleId} for {tenantId}");
return false;
}
}
/// <summary>
/// Gets all rule states for a tenant as a dictionary of ruleId → RuleState.
/// </summary>
public async Task<Dictionary<string, RuleState>> GetRuleStatesAsync(string tenantId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStates);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{tenantId}'");
var states = new Dictionary<string, RuleState>();
await foreach (var entity in query)
{
states[entity.RowKey] = new RuleState
{
Enabled = entity.GetBoolean("Enabled") ?? true,
MarkSessionAsFailed = entity.GetBoolean("MarkSessionAsFailed"),
Notify = entity.GetBoolean("Notify"),
NotifyChannelIdsJson = entity.GetString("NotifyChannelIdsJson")
};
}
return states;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get rule states for {tenantId}");
return new Dictionary<string, RuleState>();
}
}
/// <summary>
/// Deletes the rule state for a tenant (resets to rule's default enabled state).
/// Idempotent: a 404 (the row was already gone) counts as success so callers can
/// retry safely without flipping their happy-path on the missing-row condition.
/// </summary>
public async Task<bool> DeleteRuleStateAsync(string tenantId, string ruleId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStates);
await tableClient.DeleteEntityAsync(tenantId, ruleId);
return true;
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete rule state {ruleId} for {tenantId}");
return false;
}
}
/// <summary>
/// Cross-partition cleanup: deletes every RuleState row across all tenants whose
/// RowKey matches <paramref name="ruleId"/>. Used as orphan-GC when a built-in
/// rule is removed from the global catalog — without this, per-tenant
/// <c>RuleState{Enabled=true}</c> overrides survive the sunset and would re-fire
/// the moment the rule was ever re-introduced under the same ruleId.
/// <para>
/// Returns a tuple of <c>(deleted, failed)</c>. <c>deleted</c> counts both fresh
/// deletes and 404 (the row was already gone — idempotent retry). <c>failed</c>
/// is non-zero if any individual delete threw a non-404 error, or
/// <c>-1</c> if the cross-partition enumeration itself failed (we can't tell how
/// many rows are still out there). Callers must check <c>failed</c> before
/// proceeding with the global rule delete — otherwise a partial GC followed by a
/// successful catalog delete leaves orphan state with no way to re-enter the diff
/// on a later seed cycle.
/// </para>
/// </summary>
public async Task<(int deleted, int failed)> DeleteRuleStatesForRuleIdAcrossTenantsAsync(string ruleId)
{
if (string.IsNullOrWhiteSpace(ruleId)) return (0, 0);
// This is a cross-partition delete keyed on a bare RowKey string, so only
// catalog (built-in/community) IDs may pass: custom-rule IDs are tenant-chosen
// and not unique across tenants, and a call with one would delete state rows
// of unrelated tenants. failed=-1 reuses the enumeration-failure contract —
// the caller then refuses the follow-up global catalog delete.
if (!RuleIdPolicy.IsReservedBuiltInId(ruleId))
{
_logger.LogError(
"Refusing cross-tenant RuleState GC for '{RuleId}': ID is outside the reserved built-in namespace",
ruleId);
return (0, -1);
}
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStates);
// Cross-partition scan is acceptable here — RuleStates is a small table
// (one row per tenant × overridden rule). Deletion is rare (only on built-in
// sunset), so we don't need a partition-key-indexed shortcut.
var query = tableClient.QueryAsync<TableEntity>(filter: $"RowKey eq '{ruleId.Replace("'", "''")}'");
var deleted = 0;
var failed = 0;
await foreach (var entity in query)
{
try
{
await tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey);
deleted++;
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
// Row was deleted between the query and our delete — idempotent.
deleted++;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to delete orphan RuleState {RuleId} for tenant {TenantId}",
ruleId, entity.PartitionKey);
failed++;
}
}
if (deleted > 0 || failed > 0)
{
_logger.LogInformation(
"Orphan RuleState GC for {RuleId}: {Deleted} deleted, {Failed} failed",
ruleId, deleted, failed);
}
return (deleted, failed);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to enumerate RuleStates for orphan GC of {RuleId}", ruleId);
// -1 signals "we don't know" — caller must NOT proceed to delete the
// global catalog row, otherwise the rule falls out of the diff and any
// orphan state we couldn't enumerate lingers indefinitely.
return (0, -1);
}
}
// ===== ANALYZE RULES METHODS =====
/// <summary>
/// Stores or updates an analyze rule
/// PartitionKey: TenantId (or "global" for built-in), RowKey: RuleId
/// </summary>
public async Task<bool> StoreAnalyzeRuleAsync(AnalyzeRule rule, string tenantId = "global")
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules);
var entity = new TableEntity(tenantId, rule.RuleId)
{
["Title"] = rule.Title ?? string.Empty,
["Description"] = rule.Description ?? string.Empty,
["Severity"] = rule.Severity ?? string.Empty,
["Category"] = rule.Category ?? string.Empty,
["Version"] = rule.Version ?? "1.0.0",
["Author"] = rule.Author ?? "Autopilot Monitor",
["Enabled"] = rule.Enabled,
["IsBuiltIn"] = rule.IsBuiltIn,
["IsCommunity"] = rule.IsCommunity,
["Provenance"] = rule.Provenance ?? string.Empty,
["Trigger"] = rule.Trigger ?? "single",
// Null (absent evaluateOn) serializes as empty string, not "[]" — the mapper
// must round-trip null so the enrollment_end default stays derived, not baked.
["EvaluateOnJson"] = rule.EvaluateOn is { Count: > 0 }
? JsonConvert.SerializeObject(rule.EvaluateOn)
: string.Empty,
["PreconditionsJson"] = JsonConvert.SerializeObject(rule.Preconditions ?? new List<RulePrecondition>()),
["ConditionsJson"] = JsonConvert.SerializeObject(rule.Conditions ?? new List<RuleCondition>()),
["BaseConfidence"] = rule.BaseConfidence,
["ConfidenceFactorsJson"] = JsonConvert.SerializeObject(rule.ConfidenceFactors ?? new List<ConfidenceFactor>()),
["ConfidenceThreshold"] = rule.ConfidenceThreshold,
["Explanation"] = rule.Explanation ?? string.Empty,
["RemediationJson"] = JsonConvert.SerializeObject(rule.Remediation ?? new List<RemediationStep>()),
["RelatedDocsJson"] = JsonConvert.SerializeObject(rule.RelatedDocs ?? new List<RelatedDoc>()),
["TagsJson"] = JsonConvert.SerializeObject(rule.Tags ?? new string[0]),
["TemplateVariablesJson"] = JsonConvert.SerializeObject(rule.TemplateVariables ?? new List<TemplateVariable>()),
["DerivedFromTemplateRuleId"] = rule.DerivedFromTemplateRuleId ?? string.Empty,
["MarkSessionAsFailedDefault"] = rule.MarkSessionAsFailedDefault,
["NotifyDefault"] = rule.NotifyDefault,
// Channel ids are tenant-specific: populated only for tenant custom rules
// (built-in/community rules keep their targets in RuleStates instead).
["NotifyChannelIdsJson"] = JsonConvert.SerializeObject(rule.NotifyChannelIds ?? new List<string>()),
["CreatedAt"] = rule.CreatedAt,
["UpdatedAt"] = rule.UpdatedAt
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogDebug($"Stored analyze rule {rule.RuleId} for {tenantId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store analyze rule {rule.RuleId}");
return false;
}
}
/// <summary>
/// Gets analyze rules for a partition (tenant or "global")
/// </summary>
public async Task<List<AnalyzeRule>> GetAnalyzeRulesAsync(string partitionKey)
{
if (partitionKey != "global")
SecurityValidator.EnsureValidGuid(partitionKey, nameof(partitionKey));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{partitionKey}'");
var rules = new List<AnalyzeRule>();
await foreach (var entity in query)
{
rules.Add(MapToAnalyzeRule(entity));
}
return rules;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get analyze rules for {partitionKey}");
return new List<AnalyzeRule>();
}
}
/// <summary>
/// Deletes an analyze rule. Idempotent: a 404 (the rule was already gone) counts
/// as success so the sunset orchestration in
/// <see cref="AnalyzeRuleService.EnsureBuiltInRulesSeededAsync"/> can retry without
/// flipping its happy-path on the missing-row condition.
/// </summary>
public async Task<bool> DeleteAnalyzeRuleAsync(string tenantId, string ruleId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules);
await tableClient.DeleteEntityAsync(tenantId, ruleId);
return true;
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete analyze rule {ruleId}");
return false;
}
}
// ===== RULE EXISTENCE CHECKS (Point Queries) =====
/// <summary>
/// Checks if an analyze rule exists via point query (PartitionKey + RowKey).
/// O(1) in Table Storage — no partition scan needed.
/// </summary>
public async Task<bool> AnalyzeRuleExistsAsync(string partitionKey, string ruleId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules);
await tableClient.GetEntityAsync<TableEntity>(partitionKey, ruleId, select: new[] { "PartitionKey" });
return true;
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
return false;
}
}
/// <summary>
/// Checks if a gather rule exists via point query (PartitionKey + RowKey).
/// O(1) in Table Storage — no partition scan needed.
/// </summary>
public async Task<bool> GatherRuleExistsAsync(string partitionKey, string ruleId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules);
await tableClient.GetEntityAsync<TableEntity>(partitionKey, ruleId, select: new[] { "PartitionKey" });
return true;
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
return false;
}
}
private AnalyzeRule MapToAnalyzeRule(TableEntity entity)
{
var derivedFrom = entity.GetString("DerivedFromTemplateRuleId");
return new AnalyzeRule
{
RuleId = entity.RowKey,
Title = entity.GetString("Title") ?? string.Empty,
Description = entity.GetString("Description") ?? string.Empty,
Severity = entity.GetString("Severity") ?? string.Empty,
Category = entity.GetString("Category") ?? string.Empty,
Version = entity.GetString("Version") ?? "1.0.0",
Author = entity.GetString("Author") ?? "Autopilot Monitor",
Enabled = entity.GetBoolean("Enabled") ?? true,
IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false,
IsCommunity = entity.GetBoolean("IsCommunity") ?? false,
// Absent column (pre-existing rows) → null → treated as "embedded" by RuleProvenance.
Provenance = string.IsNullOrEmpty(entity.GetString("Provenance")) ? null : entity.GetString("Provenance"),
Trigger = entity.GetString("Trigger") ?? "single",
EvaluateOn = string.IsNullOrEmpty(entity.GetString("EvaluateOnJson"))
? null
: DeserializeJson<List<string>>(entity.GetString("EvaluateOnJson")),
Preconditions = DeserializeJson<List<RulePrecondition>>(entity.GetString("PreconditionsJson")),
Conditions = DeserializeJson<List<RuleCondition>>(entity.GetString("ConditionsJson")),
BaseConfidence = entity.GetInt32("BaseConfidence") ?? 50,
ConfidenceFactors = DeserializeJson<List<ConfidenceFactor>>(entity.GetString("ConfidenceFactorsJson")),
ConfidenceThreshold = entity.GetInt32("ConfidenceThreshold") ?? 40,
Explanation = entity.GetString("Explanation") ?? string.Empty,
Remediation = DeserializeJson<List<RemediationStep>>(entity.GetString("RemediationJson")),
RelatedDocs = DeserializeJson<List<RelatedDoc>>(entity.GetString("RelatedDocsJson")),
Tags = DeserializeJsonArray(entity.GetString("TagsJson")),
TemplateVariables = DeserializeJson<List<TemplateVariable>>(entity.GetString("TemplateVariablesJson")),
DerivedFromTemplateRuleId = string.IsNullOrEmpty(derivedFrom) ? null : derivedFrom,
MarkSessionAsFailedDefault = entity.GetBoolean("MarkSessionAsFailedDefault") ?? false,
NotifyDefault = entity.GetBoolean("NotifyDefault") ?? false,
NotifyChannelIds = DeserializeJson<List<string>>(entity.GetString("NotifyChannelIdsJson")),
CreatedAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime ?? DateTime.UtcNow,
UpdatedAt = entity.GetDateTimeOffset("UpdatedAt")?.UtcDateTime ?? DateTime.UtcNow
};
}
// ===== IME LOG PATTERNS METHODS =====
/// <summary>
/// Stores or updates an IME log pattern
/// PartitionKey: TenantId (or "global" for built-in), RowKey: PatternId
/// </summary>
public async Task<bool> StoreImeLogPatternAsync(ImeLogPattern pattern, string tenantId = "global")
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeLogPatterns);
var entity = new TableEntity(tenantId, pattern.PatternId)
{
["Category"] = pattern.Category ?? string.Empty,
["Pattern"] = pattern.Pattern ?? string.Empty,
["Action"] = pattern.Action ?? string.Empty,
["ParametersJson"] = JsonConvert.SerializeObject(pattern.Parameters ?? new Dictionary<string, string>()),
["Enabled"] = pattern.Enabled,
["Description"] = pattern.Description ?? string.Empty,
["IsBuiltIn"] = pattern.IsBuiltIn
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogDebug($"Stored IME log pattern {pattern.PatternId} for {tenantId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store IME log pattern {pattern.PatternId}");
return false;
}
}
/// <summary>
/// Gets IME log patterns for a partition (tenant or "global")
/// </summary>
public async Task<List<ImeLogPattern>> GetImeLogPatternsAsync(string partitionKey)
{
if (partitionKey != "global")
SecurityValidator.EnsureValidGuid(partitionKey, nameof(partitionKey));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeLogPatterns);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{partitionKey}'");
var patterns = new List<ImeLogPattern>();
await foreach (var entity in query)
{
patterns.Add(MapToImeLogPattern(entity));
}
return patterns;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get IME log patterns for {partitionKey}");
return new List<ImeLogPattern>();
}
}
/// <summary>
/// Deletes an IME log pattern
/// </summary>
public async Task<bool> DeleteImeLogPatternAsync(string tenantId, string patternId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeLogPatterns);
await tableClient.DeleteEntityAsync(tenantId, patternId);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete IME log pattern {patternId}");
return false;
}
}
private ImeLogPattern MapToImeLogPattern(TableEntity entity)
{
return new ImeLogPattern
{
PatternId = entity.RowKey,
Category = entity.GetString("Category") ?? string.Empty,
Pattern = entity.GetString("Pattern") ?? string.Empty,
Action = entity.GetString("Action") ?? string.Empty,
Parameters = DeserializeJson<Dictionary<string, string>>(entity.GetString("ParametersJson")),
Enabled = entity.GetBoolean("Enabled") ?? true,
Description = entity.GetString("Description") ?? string.Empty,
IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false
};
}
// ===== VULNERABILITY REPORT METHODS =====
/// <summary>
// ===== VULNERABILITY REPORT METHODS =====
/// <summary>
/// Stores a vulnerability correlation report for a session.
/// PK = {TenantId}_{SessionId}, RK = "report" (same pattern as RuleResults).
/// Only called when there are actual findings — no empty reports stored.
/// </summary>
public async Task StoreVulnerabilityReportAsync(string tenantId, string sessionId, Dictionary<string, object> reportData)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.VulnerabilityReports);
var partitionKey = $"{tenantId}_{sessionId}";
var scanSummary = reportData.ContainsKey("scan_summary")
? reportData["scan_summary"] as Dictionary<string, object>
: null;
var entity = new TableEntity(partitionKey, "report")
{
["SessionId"] = sessionId,
["TenantId"] = tenantId,
["CreatedAt"] = DateTime.UtcNow.ToString("o"),
["OverallRisk"] = scanSummary?.GetValueOrDefault("overall_risk")?.ToString() ?? "none",
["TotalCvesFound"] = Convert.ToInt32(scanSummary?.GetValueOrDefault("total_cves_found") ?? 0),
["CriticalCves"] = Convert.ToInt32(scanSummary?.GetValueOrDefault("critical_cves") ?? 0),
["HighCves"] = Convert.ToInt32(scanSummary?.GetValueOrDefault("high_cves") ?? 0),
["KevMatches"] = Convert.ToInt32(scanSummary?.GetValueOrDefault("kev_matches") ?? 0),
["TotalScanned"] = Convert.ToInt32(scanSummary?.GetValueOrDefault("total_software_scanned") ?? 0),
["TotalMatched"] = Convert.ToInt32(scanSummary?.GetValueOrDefault("matched_to_cpe") ?? 0),
};
// Chunk ReportJson across multiple properties if it exceeds 30K chars
var reportJson = JsonConvert.SerializeObject(reportData);
foreach (var chunk in TableStorageChunking.ChunkProperty("ReportJson", reportJson))
entity[chunk.Key] = chunk.Value;
await tableClient.UpsertEntityAsync(entity);
_logger.LogInformation("Stored vulnerability report for session {SessionId} (risk={Risk})", sessionId,
scanSummary?.GetValueOrDefault("overall_risk")?.ToString() ?? "none");
}
/// <summary>
/// Deletes the stored vulnerability report for a session.
/// Used when a re-scan finds no vulnerabilities, to clear stale data.
/// </summary>
public async Task DeleteVulnerabilityReportAsync(string tenantId, string sessionId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.VulnerabilityReports);
var partitionKey = $"{tenantId}_{sessionId}";
await tableClient.DeleteEntityAsync(partitionKey, "report");
_logger.LogInformation("Deleted vulnerability report for session {SessionId}", sessionId);
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
// Already deleted or never existed — nothing to do
}
}
/// <summary>
/// Gets the vulnerability correlation report for a session.
/// Returns the deserialized report data, or null if no report exists.
/// </summary>
public async Task<Dictionary<string, object>?> GetVulnerabilityReportAsync(string tenantId, string sessionId)
{
var json = await GetVulnerabilityReportJsonAsync(tenantId, sessionId);
if (json == null) return null;
return JsonConvert.DeserializeObject<Dictionary<string, object>>(json);
}
/// <summary>
/// Returns the raw ReportJson string without deserialization.
/// Avoids Newtonsoft JToken → System.Text.Json serialization mismatch.
/// </summary>
public async Task<string?> GetVulnerabilityReportJsonAsync(string tenantId, string sessionId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.VulnerabilityReports);
var partitionKey = $"{tenantId}_{sessionId}";
var response = await tableClient.GetEntityAsync<TableEntity>(partitionKey, "report");
var entity = response?.Value;
if (entity == null) return null;
var reportJson = TableStorageChunking.ReassembleProperty(entity, "ReportJson", _logger, $"{partitionKey}/report");
return string.IsNullOrEmpty(reportJson) ? null : reportJson;
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
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 (dictionary to deduplicate by RowKey)
var entitiesToUpsert = new Dictionary<string, TableEntity>(StringComparer.OrdinalIgnoreCase);
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[rowKey] = entity;
}
// Batch write (max 100 per batch, all same PK)
var upsertList = entitiesToUpsert.Values.ToList();
for (int i = 0; i < upsertList.Count; i += 100)
{
var batch = upsertList.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}", upsertList.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>
/// <summary>
/// Imports CPE seed mappings from a JSON string (fetched from GitHub).
/// Deletes existing cpe_map_seed entries, then writes the new ones.
/// Returns the number of entries imported.
/// </summary>
public async Task<int> ImportCpeMappingSeedFromJsonAsync(string json)
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.VulnerabilityCache);
// Delete existing seed entries
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);
}
// Parse the JSON
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());
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", CpeUriNormalizer.Normalize(mapping.CpeProduct) },
{ "CpeUri", CpeUriNormalizer.Normalize(mapping.CpeUri) },
{ "Category", mapping.Category ?? "" },
{ "DisplayNamePatternsJson", System.Text.Json.JsonSerializer.Serialize(mapping.DisplayNamePatterns ?? new List<string>()) },
{ "ExcludePatternsJson", System.Text.Json.JsonSerializer.Serialize(mapping.ExcludePatterns ?? 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 seed mapping entries from GitHub JSON into VulnerabilityCache table", entities.Count);
return entities.Count;
}
/// <summary>
/// Imports CPE community mappings from a JSON string (fetched from GitHub).
/// Deletes existing cpe_map_community entries, then writes the new ones.
/// Same format as the seed file (CpeMappingSeed with Mappings list).
/// </summary>
public async Task<int> ImportCpeCommunityMappingsFromJsonAsync(string json)
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.VulnerabilityCache);
// Delete existing community entries
var existingEntities = tableClient.QueryAsync<TableEntity>(
filter: "PartitionKey eq 'cpe_map_community'",
select: new[] { "PartitionKey", "RowKey" });
await foreach (var entity in existingEntities)
{
await tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey);
}
// Parse the JSON (same format as seed)
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());
if (rowKey.Length > 512)
rowKey = rowKey.Substring(0, 512);
entities.Add(new TableEntity("cpe_map_community", rowKey)
{
{ "NormalizedVendor", mapping.NormalizedVendor ?? "" },
{ "NormalizedProduct", mapping.NormalizedProduct ?? "" },
{ "CpeVendor", mapping.CpeVendor ?? "" },
{ "CpeProduct", mapping.CpeProduct ?? "" },
{ "CpeUri", mapping.CpeUri ?? "" },
{ "Category", mapping.Category ?? "community" },
{ "DisplayNamePatternsJson", System.Text.Json.JsonSerializer.Serialize(mapping.DisplayNamePatterns ?? new List<string>()) },
{ "ExcludePatternsJson", System.Text.Json.JsonSerializer.Serialize(mapping.ExcludePatterns ?? new List<string>()) },
{ "PublisherPatternsJson", System.Text.Json.JsonSerializer.Serialize(mapping.PublisherPatterns ?? new List<string>()) },
{ "Source", "community" },
{ "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 community mapping entries from GitHub JSON into VulnerabilityCache table", entities.Count);
return entities.Count;
}
}
}