mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
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:
@@ -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<string,object></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())
|
||||
|
||||
Reference in New Issue
Block a user