feat(backend): RuleEngine dot-path resolver + synth vulnerability_report

Two related gaps that blocked ANALYZE-ID-003:

(1) RuleEngine.GetDataFieldValue did flat dictionary lookup only — rules
    targeting nested payloads (scan_summary.critical_cves) had no way to
    descend. Extended with a dot-path resolver that walks both
    Dictionary<string,object> (in-process typed sidecar) and JsonElement
    (rehydrated-from-storage) trees, case-insensitive at each level, and
    bails cleanly when a path lands in a scalar. Flat lookup runs first
    so legacy events that wrote literal-dot keys still match.

(2) Vulnerability findings only lived in the VulnerabilityReports table
    — never as an event — so analyze rules had no surface to fire on.
    EventIngestProcessor now synthesises a vulnerability_report event
    after a successful StoreVulnerabilityReportAsync, carrying the
    scan_summary slice + reportAvailable + phase. Severity is mapped
    from overall_risk (critical/high/medium/-> Critical/Error/Warning).
    The full per-CVE list stays in the table.

Rerun-after-emit: the end-of-session rule analysis at line 205 is
triggered by classification.CompletionEvent and may finish before the
async vulnerability task does. ReanalyzeAfterVulnerabilityEmitAsync
runs AnalyzeSessionAsync(reanalyze=false) right after the synth-emit;
existing results are skipped (no duplicates), un-evaluated rules pick up
the new event, and SignalR fires NotifyRuleResultsAvailable so the UI
updates without manual refresh.

Tests: RuleEngineDataPathResolverTests (7 cases — flat/Dict-descent/
JsonElement-descent/case-insensitive/missing-path/scalar-descent) +
existing 752 backend tests still green.

Legacy IngestEventsFunction path intentionally not mirrored — the V1
agent ingest is on its way out, no need to backport the synth-emit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-27 21:55:50 +02:00
parent b65326f3d7
commit aa748cd212
3 changed files with 312 additions and 7 deletions
@@ -0,0 +1,111 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Unit tests for the dot-path resolver inside <see cref="RuleEngine.GetDataFieldValue"/>.
/// Exercises both nested <c>Dictionary&lt;string,object&gt;</c> and <see cref="JsonElement"/>
/// 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.
/// </summary>
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<string, object>
{
["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<string, object> { ["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<string, object>
{
["scan_summary"] = new Dictionary<string, object>
{
["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<string, object>
{
["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<string, object>
{
["Scan_Summary"] = new Dictionary<string, object>
{
["Critical_CVEs"] = 4,
},
};
Assert.Equal("4", RuleEngine.ResolveDotPath(data, "scan_summary.critical_cves"));
}
[Fact]
public void ResolveDotPath_MissingPath_ReturnsNull()
{
var data = new Dictionary<string, object>
{
["scan_summary"] = new Dictionary<string, object> { ["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<string, object>
{
["scan_summary"] = "not-an-object",
};
Assert.Null(RuleEngine.ResolveDotPath(data, "scan_summary.critical_cves"));
}
}
@@ -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<Dictionary<string, object>>
: null;
@@ -478,6 +496,105 @@ namespace AutopilotMonitor.Functions.Services
};
}
/// <summary>
/// Stores a synthetic <c>vulnerability_report</c> event after a successful scan so analyze
/// rules can fire on the same event-stream surface as agent-emitted events. Carries only
/// <c>scan_summary</c> + <c>reportAvailable</c> + <c>phase</c>; the full per-CVE list lives
/// in the <c>VulnerabilityReports</c> table (read separately by the report API).
/// </summary>
private async Task EmitVulnerabilityReportEventAsync(
string tenantId,
string sessionId,
string sessionPrefix,
Dictionary<string, object> reportData,
string? phaseLabel)
{
try
{
var scanSummary = reportData.TryGetValue("scan_summary", out var summaryObj)
&& summaryObj is Dictionary<string, object> summaryDict
? summaryDict
: new Dictionary<string, object>();
var overallRisk = scanSummary.TryGetValue("overall_risk", out var riskObj)
? riskObj?.ToString()
: null;
var data = new Dictionary<string, object>(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<EnrollmentEvent> { evt });
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"{Prefix} vulnerability_report synth-event emit failed (non-fatal)", sessionPrefix);
}
}
/// <summary>
/// Runs <see cref="RuleEngine.AnalyzeSessionAsync"/> 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.
/// </summary>
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,
};
}
/// <summary>
@@ -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<string,object> / 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<string, object> 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<string, object> root, string path)
{
var parts = path.Split('.');
object? current = root;
foreach (var part in parts)
{
if (current == null) return null;
switch (current)
{
case IDictionary<string, object> 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())