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())