diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RuleEngineDataPathResolverTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RuleEngineDataPathResolverTests.cs
new file mode 100644
index 00000000..a88f9b04
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/RuleEngineDataPathResolverTests.cs
@@ -0,0 +1,111 @@
+using System.Text.Json;
+using AutopilotMonitor.Functions.Services;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Unit tests for the dot-path resolver inside .
+/// Exercises both nested Dictionary<string,object> and
+/// payload shapes — both occur in production depending on whether the event came in via
+/// the in-process emitter (typed sidecar) or was rehydrated from Table Storage JSON.
+///
+public class RuleEngineDataPathResolverTests
+{
+ [Fact]
+ public void TryFlatLookup_LiteralKeyWithDot_StillResolves()
+ {
+ // Pre-existing v1 events used keys like "scan_summary.critical_cves" with a literal
+ // dot. The flat lookup must keep working so legacy rules don't break.
+ var data = new Dictionary
+ {
+ ["scan_summary.critical_cves"] = 7,
+ };
+
+ var ok = RuleEngine.TryFlatLookup(data, "scan_summary.critical_cves", out var value);
+
+ Assert.True(ok);
+ Assert.Equal("7", value);
+ }
+
+ [Fact]
+ public void TryFlatLookup_CaseInsensitive()
+ {
+ var data = new Dictionary { ["FailedSubcategories"] = "Certificates,VPN" };
+
+ var ok = RuleEngine.TryFlatLookup(data, "failedsubcategories", out var value);
+
+ Assert.True(ok);
+ Assert.Equal("Certificates,VPN", value);
+ }
+
+ [Fact]
+ public void ResolveDotPath_NestedDictionary_ReturnsLeafScalar()
+ {
+ var data = new Dictionary
+ {
+ ["scan_summary"] = new Dictionary
+ {
+ ["critical_cves"] = 3,
+ ["overall_risk"] = "high",
+ },
+ };
+
+ Assert.Equal("3", RuleEngine.ResolveDotPath(data, "scan_summary.critical_cves"));
+ Assert.Equal("high", RuleEngine.ResolveDotPath(data, "scan_summary.overall_risk"));
+ }
+
+ [Fact]
+ public void ResolveDotPath_NestedJsonElement_ReturnsLeafScalar()
+ {
+ // Events read back from Table Storage hit the resolver as JsonElement subtrees.
+ var json = """{"scan_summary":{"critical_cves":5,"kev_matches":2,"overall_risk":"critical"}}""";
+ using var doc = JsonDocument.Parse(json);
+ var data = new Dictionary
+ {
+ ["scan_summary"] = doc.RootElement.GetProperty("scan_summary").Clone(),
+ };
+
+ Assert.Equal("5", RuleEngine.ResolveDotPath(data, "scan_summary.critical_cves"));
+ Assert.Equal("2", RuleEngine.ResolveDotPath(data, "scan_summary.kev_matches"));
+ Assert.Equal("critical", RuleEngine.ResolveDotPath(data, "scan_summary.overall_risk"));
+ }
+
+ [Fact]
+ public void ResolveDotPath_CaseInsensitiveAtEachLevel()
+ {
+ var data = new Dictionary
+ {
+ ["Scan_Summary"] = new Dictionary
+ {
+ ["Critical_CVEs"] = 4,
+ },
+ };
+
+ Assert.Equal("4", RuleEngine.ResolveDotPath(data, "scan_summary.critical_cves"));
+ }
+
+ [Fact]
+ public void ResolveDotPath_MissingPath_ReturnsNull()
+ {
+ var data = new Dictionary
+ {
+ ["scan_summary"] = new Dictionary { ["critical_cves"] = 1 },
+ };
+
+ Assert.Null(RuleEngine.ResolveDotPath(data, "scan_summary.missing"));
+ Assert.Null(RuleEngine.ResolveDotPath(data, "missing.critical_cves"));
+ }
+
+ [Fact]
+ public void ResolveDotPath_DescendIntoScalar_ReturnsNull()
+ {
+ // Path traverses past a scalar — must not throw, must return null so the rule
+ // misses cleanly instead of stringifying an unrelated value.
+ var data = new Dictionary
+ {
+ ["scan_summary"] = "not-an-object",
+ };
+
+ Assert.Null(RuleEngine.ResolveDotPath(data, "scan_summary.critical_cves"));
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs
index 29661fde..cfdf0a9e 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs
@@ -344,6 +344,24 @@ namespace AutopilotMonitor.Functions.Services
"{Prefix} Vulnerability correlation complete (async, whiteGlovePart={Part})",
capturedPrefix, capturedWhiteGlovePart?.ToString() ?? "none");
+ // Synthetic `vulnerability_report` event so analyze rules can
+ // match against `scan_summary.*` fields. The full report stays in
+ // the VulnerabilityReports table — this event carries only the
+ // summary slice that rule conditions need.
+ await EmitVulnerabilityReportEventAsync(
+ capturedTenantId, capturedSessionId, capturedPrefix, reportData, phaseLabel);
+
+ // Re-evaluate analyze rules now that the synth event is in the
+ // table. The end-of-session analysis at the top of ProcessEventsAsync
+ // is triggered by classification.CompletionEvent and may finish
+ // before this vulnerability task does — without this re-analyze,
+ // vulnerability-driven rules (ANALYZE-ID-003) miss their evidence.
+ // Uses reanalyze=false: rules with stored results are skipped, so
+ // there's no duplicate-result risk if the initial run already saw
+ // the synth event in a tight race.
+ await ReanalyzeAfterVulnerabilityEmitAsync(
+ capturedTenantId, capturedSessionId, capturedPrefix);
+
var findings = reportData.ContainsKey("findings")
? reportData["findings"] as List>
: null;
@@ -478,6 +496,105 @@ namespace AutopilotMonitor.Functions.Services
};
}
+ ///
+ /// Stores a synthetic vulnerability_report event after a successful scan so analyze
+ /// rules can fire on the same event-stream surface as agent-emitted events. Carries only
+ /// scan_summary + reportAvailable + phase; the full per-CVE list lives
+ /// in the VulnerabilityReports table (read separately by the report API).
+ ///
+ private async Task EmitVulnerabilityReportEventAsync(
+ string tenantId,
+ string sessionId,
+ string sessionPrefix,
+ Dictionary reportData,
+ string? phaseLabel)
+ {
+ try
+ {
+ var scanSummary = reportData.TryGetValue("scan_summary", out var summaryObj)
+ && summaryObj is Dictionary summaryDict
+ ? summaryDict
+ : new Dictionary();
+
+ var overallRisk = scanSummary.TryGetValue("overall_risk", out var riskObj)
+ ? riskObj?.ToString()
+ : null;
+
+ var data = new Dictionary(StringComparer.Ordinal)
+ {
+ ["scan_summary"] = scanSummary,
+ ["reportAvailable"] = true,
+ ["phase"] = phaseLabel ?? "single",
+ };
+
+ var evt = new EnrollmentEvent
+ {
+ TenantId = tenantId,
+ SessionId = sessionId,
+ EventType = "vulnerability_report",
+ Severity = MapVulnerabilityRiskToSeverity(overallRisk),
+ Source = "VulnerabilityCorrelationService",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Vulnerability scan complete (risk={overallRisk ?? "unknown"}).",
+ Timestamp = DateTime.UtcNow,
+ Data = data,
+ };
+
+ await _sessionRepo.StoreEventsBatchAsync(new List { evt });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "{Prefix} vulnerability_report synth-event emit failed (non-fatal)", sessionPrefix);
+ }
+ }
+
+ ///
+ /// Runs after the vulnerability synth event
+ /// has been persisted, so vulnerability-driven rules (e.g. ANALYZE-ID-003) can match
+ /// even when the initial enrollment-end analysis completed before the async correlation
+ /// finished. Skips rules that already have stored results — no duplicates.
+ ///
+ private async Task ReanalyzeAfterVulnerabilityEmitAsync(
+ string tenantId,
+ string sessionId,
+ string sessionPrefix)
+ {
+ try
+ {
+ var ruleEngine = new RuleEngine(_analyzeRuleService, _ruleRepo, _sessionRepo, _logger);
+ var outcome = await ruleEngine.AnalyzeSessionAsync(tenantId, sessionId);
+
+ foreach (var result in outcome.Results)
+ {
+ await _ruleRepo.StoreRuleResultAsync(result);
+ }
+
+ if (outcome.Results.Count > 0)
+ {
+ _logger.LogInformation(
+ "{Prefix} Vulnerability re-analysis: {Count} additional rule result(s)",
+ sessionPrefix, outcome.Results.Count);
+
+ await _signalRNotification.NotifyRuleResultsAvailableAsync(
+ tenantId, sessionId, outcome.Results.Count);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "{Prefix} Vulnerability re-analysis failed (async, non-fatal)", sessionPrefix);
+ }
+ }
+
+ private static EventSeverity MapVulnerabilityRiskToSeverity(string? overallRisk) =>
+ (overallRisk ?? string.Empty).ToLowerInvariant() switch
+ {
+ "critical" => EventSeverity.Critical,
+ "high" => EventSeverity.Error,
+ "medium" => EventSeverity.Warning,
+ _ => EventSeverity.Info,
+ };
}
///
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs
index 7373fa0d..4f1bc077 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs
@@ -1,3 +1,4 @@
+using System.Text.Json;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
@@ -318,18 +319,94 @@ namespace AutopilotMonitor.Functions.Services
if (dataField.Equals("message", StringComparison.OrdinalIgnoreCase))
return evt.Message;
- // Check in Data dictionary
- if (evt.Data.TryGetValue(dataField, out var value))
- return value?.ToString();
+ // Flat lookup first — preserves the legacy contract for keys that contain a
+ // literal dot (e.g. v1 events that wrote "scan_summary.critical_cves" as a
+ // single key) and is the cheap path for the common case.
+ if (TryFlatLookup(evt.Data, dataField, out var flat))
+ return flat;
- // Try case-insensitive lookup
- var key = evt.Data.Keys.FirstOrDefault(k => k.Equals(dataField, StringComparison.OrdinalIgnoreCase));
- if (key != null)
- return evt.Data[key]?.ToString();
+ // Dot-path traversal for nested Dictionary / JsonElement values.
+ // Used by rules that target a structured payload (e.g. `scan_summary.kev_matches`
+ // when the emitter wrote `scan_summary` as a nested dict).
+ if (dataField.IndexOf('.') >= 0)
+ return ResolveDotPath(evt.Data, dataField);
return null;
}
+ internal static bool TryFlatLookup(Dictionary data, string key, out string? value)
+ {
+ if (data.TryGetValue(key, out var raw))
+ {
+ value = raw?.ToString();
+ return true;
+ }
+ var hit = data.Keys.FirstOrDefault(k => k.Equals(key, StringComparison.OrdinalIgnoreCase));
+ if (hit != null)
+ {
+ value = data[hit]?.ToString();
+ return true;
+ }
+ value = null;
+ return false;
+ }
+
+ internal static string? ResolveDotPath(Dictionary root, string path)
+ {
+ var parts = path.Split('.');
+ object? current = root;
+ foreach (var part in parts)
+ {
+ if (current == null) return null;
+
+ switch (current)
+ {
+ case IDictionary dict:
+ if (!dict.TryGetValue(part, out var next))
+ {
+ var hit = dict.Keys.FirstOrDefault(k => k.Equals(part, StringComparison.OrdinalIgnoreCase));
+ if (hit == null) return null;
+ next = dict[hit];
+ }
+ current = next;
+ break;
+
+ case JsonElement jel when jel.ValueKind == JsonValueKind.Object:
+ if (jel.TryGetProperty(part, out var prop))
+ {
+ current = prop;
+ }
+ else
+ {
+ // Case-insensitive fallback for JsonElement.
+ JsonElement? found = null;
+ foreach (var p in jel.EnumerateObject())
+ {
+ if (p.Name.Equals(part, StringComparison.OrdinalIgnoreCase))
+ {
+ found = p.Value;
+ break;
+ }
+ }
+ if (found == null) return null;
+ current = found.Value;
+ }
+ break;
+
+ default:
+ // Scalar reached before path was exhausted — no descent possible.
+ return null;
+ }
+ }
+
+ return current switch
+ {
+ null => null,
+ JsonElement jel => jel.ValueKind == JsonValueKind.String ? jel.GetString() : jel.ToString(),
+ _ => current.ToString(),
+ };
+ }
+
private bool MatchesOperator(string fieldValue, string op, string compareValue)
{
switch (op?.ToLower())