mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
fix(agent,rules): AccountSetup activity means observed progress; same-event filter for event_data conditions (q8n)
Agent: ProvisioningStatusTracker.HasAccountSetupActivity now requires at least one AccountSetup subcategory past notStarted. Windows writes AccountSetupCategory.Status with every subcategory notStarted BEFORE the Device-ESP page exits, so the presence check confirmed the Device->Account handoff (Shell-Core 62407) as the final ESP exit; the user-apps-settled synthesis then completed AccountSetup and released the User-ESP keep-awake minutes before the real exit (fleet: 77 % of keep-awake sessions, median 3 min; on battery that is enough for Modern Standby). D-183. Rule engine: filterField/filterOperator/filterValue now apply to event_data and event_data_array as a same-event filter (shared ApplyValueFilter with event_count). ANALYZE-DEV-012 v1.1.0 excludes backfilled pre-session episodes (backfilled equals false); a second independent condition could not, since each condition scans all events on its own. D-184. Schema, Shared model, MCP authoring guide and generated types follow. Tests: ProvisioningStatusTrackerAccountSetupActivityTests (the verbatim all-notStarted registry JSON), SleepEpisodeRuleTests (+3 backfill cases), RuleEngineEventDataFilterTests. Solution build 0 warnings; agent suite 2820 and backend suite 5791 green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+1
-1
Submodule internal updated: d97bc98dc6...0b5da6ce1e
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-DEV-012",
|
||||
"title": "Device Entered Standby During Enrollment",
|
||||
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log — pre-agent episodes are backfilled). Uses a single required condition on the episode's durationSeconds so the match is pinned to one event instance and the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
|
||||
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log). The watcher also backfills pre-agent episodes from the last 24 h for timeline context (backfilled=true); those ended before the enrollment started and are excluded via the same-event filter backfilled equals false — a separate condition could not do that, because each condition scans all events on its own and a pre-session hibernate would still satisfy the duration test. A single required condition keeps the match pinned to one event instance so the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -23,6 +23,9 @@
|
||||
"dataField": "durationSeconds",
|
||||
"operator": "gte",
|
||||
"value": "300",
|
||||
"filterField": "backfilled",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "false",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
|
||||
+5
-2
@@ -1772,10 +1772,10 @@
|
||||
{
|
||||
"ruleId": "ANALYZE-DEV-012",
|
||||
"title": "Device Entered Standby During Enrollment",
|
||||
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log — pre-agent episodes are backfilled). Uses a single required condition on the episode's durationSeconds so the match is pinned to one event instance and the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
|
||||
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log). The watcher also backfills pre-agent episodes from the last 24 h for timeline context (backfilled=true); those ended before the enrollment started and are excluded via the same-event filter backfilled equals false — a separate condition could not do that, because each condition scans all events on its own and a pre-session hibernate would still satisfy the duration test. A single required condition keeps the match pinned to one event instance so the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -1793,6 +1793,9 @@
|
||||
"dataField": "durationSeconds",
|
||||
"operator": "gte",
|
||||
"value": "300",
|
||||
"filterField": "backfilled",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "false",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
|
||||
@@ -204,16 +204,16 @@
|
||||
},
|
||||
"filterField": {
|
||||
"type": "string",
|
||||
"description": "For event_count only: optional value filter — only events whose filterField satisfies filterOperator/filterValue are counted (e.g. count only performance_snapshot events with memory_used_percent > 90). Applies before counting for both count_gte and count_per_group_gte."
|
||||
"description": "Optional same-event value filter for event_count, event_data and event_data_array: only events whose filterField satisfies filterOperator/filterValue are considered. For event_count it applies before counting (both count_gte and count_per_group_gte, e.g. count only performance_snapshot events with memory_used_percent > 90). For event_data/event_data_array the filter and the main dataField/operator test are evaluated on the SAME event instance — the only way to pin two fields of one event together, since separate conditions each scan all events independently (e.g. system_sleep_episode with durationSeconds gte 300 AND backfilled equals false)."
|
||||
},
|
||||
"filterOperator": {
|
||||
"type": "string",
|
||||
"description": "Operator for the event_count value filter.",
|
||||
"description": "Operator for the same-event value filter. Same vocabulary as operator.",
|
||||
"enum": ["equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "in", "not_in"]
|
||||
},
|
||||
"filterValue": {
|
||||
"type": "string",
|
||||
"description": "Value for the event_count value filter."
|
||||
"description": "Value for the same-event value filter. Boolean values are stringified (\"true\"/\"false\")."
|
||||
},
|
||||
"correlateEventType": {
|
||||
"type": "string",
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Backlog q8n — <see cref="ProvisioningStatusTracker.HasAccountSetupActivity"/> must mean
|
||||
/// observed progress, not registry presence. Windows writes
|
||||
/// <c>AccountSetupCategory.Status</c> with every subcategory <c>notStarted</c> BEFORE the
|
||||
/// Device-ESP page exits, so a presence check labelled the intermediate Device→Account
|
||||
/// Shell-Core 62407 as the final ESP exit; the user-apps-settled synthesis then completed
|
||||
/// AccountSetup minutes early and released the User-ESP keep-awake while the ESP page was
|
||||
/// still up (session a2256107, standby on battery at 09:43 with the real exit at 09:47).
|
||||
/// Both <c>EspAndHelloTracker</c> guards (<c>IsIntermediateDeviceEspExit</c>,
|
||||
/// <c>IsConfirmedPostAccountSetupExit</c>) hang off this property.
|
||||
/// </summary>
|
||||
public sealed class ProvisioningStatusTrackerAccountSetupActivityTests
|
||||
{
|
||||
private static readonly DateTime Fixed = new DateTime(2026, 9, 4, 9, 37, 42, DateTimeKind.Utc);
|
||||
|
||||
// Verbatim shape of the pre-written registry JSON seen at first_seen on 25H2/26200.
|
||||
private const string AllNotStartedJson = @"{
|
||||
""categoryState"":""notStarted"",
|
||||
""AccountSetup.WaitingForAadRegistrationSubcategory"":{""subcategoryState"":""notStarted""},
|
||||
""AccountSetup.PrepareMultifactorAuth"":{""subcategoryState"":""notStarted""},
|
||||
""AccountSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""notStarted""},
|
||||
""AccountSetup.CertificatesSubcategory"":{""subcategoryState"":""notStarted""},
|
||||
""AccountSetup.NetworkConnectionsSubcategory"":{""subcategoryState"":""notStarted""},
|
||||
""AccountSetup.AppsSubcategory"":{""subcategoryState"":""notStarted""},
|
||||
""AccountSetup.SendResultsToMdmServer"":{""subcategoryState"":""notStarted""}
|
||||
}";
|
||||
|
||||
private const string FirstProgressJson = @"{
|
||||
""categoryState"":""inProgress"",
|
||||
""AccountSetup.WaitingForAadRegistrationSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""succeeded""},
|
||||
""AccountSetup.PrepareMultifactorAuth"":{""subcategoryState"":""notStarted""},
|
||||
""AccountSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""notStarted""},
|
||||
""AccountSetup.CertificatesSubcategory"":{""subcategoryState"":""notStarted""},
|
||||
""AccountSetup.NetworkConnectionsSubcategory"":{""subcategoryState"":""notStarted""},
|
||||
""AccountSetup.AppsSubcategory"":{""subcategoryState"":""notStarted""},
|
||||
""AccountSetup.SendResultsToMdmServer"":{""subcategoryState"":""notStarted""}
|
||||
}";
|
||||
|
||||
private sealed class Fixture : IDisposable
|
||||
{
|
||||
public TempDirectory Tmp { get; } = new TempDirectory();
|
||||
public FakeSignalIngressSink Sink { get; } = new FakeSignalIngressSink();
|
||||
public ProvisioningStatusTracker Tracker { get; }
|
||||
|
||||
public Fixture()
|
||||
{
|
||||
var clock = new VirtualClock(Fixed);
|
||||
var post = new InformationalEventPost(Sink, clock);
|
||||
var logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
|
||||
Tracker = new ProvisioningStatusTracker(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: post,
|
||||
logger: logger);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Tracker.Dispose();
|
||||
Tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NothingTracked_IsNotActivity()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
Assert.False(f.Tracker.HasAccountSetupActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreWrittenAllNotStartedJson_IsNotActivity()
|
||||
{
|
||||
// The registry JSON exists (7 subcategories) but nothing has started — this is the
|
||||
// state at the intermediate Device-ESP exit and must NOT confirm it as final.
|
||||
using var f = new Fixture();
|
||||
f.Tracker.ProcessCategoryStatusForTest("AccountSetupCategory.Status", AllNotStartedJson);
|
||||
|
||||
Assert.False(f.Tracker.HasAccountSetupActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstSubcategoryLeavingNotStarted_IsActivity()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
f.Tracker.ProcessCategoryStatusForTest("AccountSetupCategory.Status", AllNotStartedJson);
|
||||
f.Tracker.ProcessCategoryStatusForTest("AccountSetupCategory.Status", FirstProgressJson);
|
||||
|
||||
Assert.True(f.Tracker.HasAccountSetupActivity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("inProgress")]
|
||||
[InlineData("in_progress")]
|
||||
[InlineData("failed")]
|
||||
[InlineData("notRequired")]
|
||||
public void AnyNonNotStartedState_IsActivity(string state)
|
||||
{
|
||||
using var f = new Fixture();
|
||||
f.Tracker.ProcessCategoryStatusForTest("AccountSetupCategory.Status",
|
||||
"{\"categoryState\":\"inProgress\"," +
|
||||
"\"AccountSetup.AppsSubcategory\":{\"subcategoryState\":\"" + state + "\"}," +
|
||||
"\"AccountSetup.CertificatesSubcategory\":{\"subcategoryState\":\"notStarted\"}}");
|
||||
|
||||
Assert.True(f.Tracker.HasAccountSetupActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupProgress_DoesNotCountAsAccountSetupActivity()
|
||||
{
|
||||
// Adversarial: a fully progressed DeviceSetup category must not leak into the
|
||||
// AccountSetup answer.
|
||||
using var f = new Fixture();
|
||||
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status",
|
||||
"{\"categoryState\":\"succeeded\",\"DeviceSetup.AppsSubcategory\":{\"subcategoryState\":\"succeeded\"}}");
|
||||
f.Tracker.ProcessCategoryStatusForTest("AccountSetupCategory.Status", AllNotStartedJson);
|
||||
|
||||
Assert.False(f.Tracker.HasAccountSetupActivity);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -201,7 +201,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
public bool DeviceSetupCategorySucceeded => _provisioningTracker?.DeviceSetupCategorySucceeded ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// True when any AccountSetup subcategory has been tracked (resolved or in progress).
|
||||
/// True once an AccountSetup subcategory has left <c>notStarted</c> (observed progress, not
|
||||
/// registry presence — see <see cref="ProvisioningStatusTracker.HasAccountSetupActivity"/>).
|
||||
/// </summary>
|
||||
public bool HasAccountSetupActivity => _provisioningTracker?.HasAccountSetupActivity ?? false;
|
||||
|
||||
@@ -558,9 +559,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// <list type="bullet">
|
||||
/// <item>SkipUser is unknown or explicitly <c>true</c> (device-only / SkipUser flow —
|
||||
/// AwaitingHello is legitimately reachable directly after Device-ESP).</item>
|
||||
/// <item>The provisioning tracker has observed AccountSetup activity (registry JSON
|
||||
/// under <c>AccountSetupCategory.Status</c> has surfaced at least one subcategory),
|
||||
/// i.e. the current exit is the final post-Account-ESP one.</item>
|
||||
/// <item>The provisioning tracker has observed AccountSetup progress (at least one
|
||||
/// subcategory under <c>AccountSetupCategory.Status</c> has left <c>notStarted</c>),
|
||||
/// i.e. the current exit is the final post-Account-ESP one. Presence of the JSON
|
||||
/// alone is not evidence — Windows writes it all-<c>notStarted</c> before the
|
||||
/// Device-ESP exit (q8n).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private bool IsIntermediateDeviceEspExit()
|
||||
@@ -593,8 +596,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// it back. So this one demands positive evidence and treats "unknown" as "not confirmed":
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>the provisioning tracker has seen AccountSetup activity — the page that just
|
||||
/// tore down is demonstrably the AccountSetup one; or</item>
|
||||
/// <item>the provisioning tracker has seen AccountSetup progress (a subcategory past
|
||||
/// <c>notStarted</c>) — the page that just tore down is demonstrably the
|
||||
/// AccountSetup one; or</item>
|
||||
/// <item>SkipUser is explicitly <c>true</c> — the profile has no user ESP at all, so the
|
||||
/// Device-ESP exit IS the final one and no second exit is coming.</item>
|
||||
/// </list>
|
||||
|
||||
+16
-4
@@ -208,7 +208,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when any AccountSetup subcategory has been tracked (resolved or in progress).
|
||||
/// True when at least one AccountSetup subcategory has left <c>notStarted</c> — i.e. the
|
||||
/// user ESP page has demonstrably started working (in progress, succeeded, failed or
|
||||
/// notRequired). Mere presence of <c>AccountSetupCategory.Status</c> does NOT count:
|
||||
/// Windows writes that JSON with every subcategory <c>notStarted</c> BEFORE the Device-ESP
|
||||
/// page exits (observed on 25H2/26200 across tenants, session a2256107: JSON at 09:37:42,
|
||||
/// intermediate Shell-Core 62407 at 09:37:53, first real progress at 09:38:25). A
|
||||
/// presence check therefore mislabelled the Device→Account handoff exit as the final
|
||||
/// exit, which released the User-ESP keep-awake minutes early (backlog q8n).
|
||||
/// </summary>
|
||||
public bool HasAccountSetupActivity
|
||||
{
|
||||
@@ -218,9 +225,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
if (_lastSubcategoryStates == null)
|
||||
return false;
|
||||
return _lastSubcategoryStates.TryGetValue("AccountSetupCategory.Status", out var subs)
|
||||
&& subs != null
|
||||
&& subs.Count > 0;
|
||||
if (!_lastSubcategoryStates.TryGetValue("AccountSetupCategory.Status", out var subs) || subs == null)
|
||||
return false;
|
||||
foreach (var state in subs.Values)
|
||||
{
|
||||
if (!string.Equals(state, "notStarted", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the same-event value filter (FilterField/FilterOperator/FilterValue) on the
|
||||
/// <c>event_data</c> and <c>event_data_array</c> condition sources (backlog q8n). Every
|
||||
/// condition scans all events of its type independently, so two conditions cannot express
|
||||
/// "field A AND field B on the SAME event" — the filter can. Mirrors the event_count filter
|
||||
/// semantics (<see cref="RuleEngineEventCountFilterTests"/>): no filter = unchanged behaviour,
|
||||
/// a missing filter field stringifies to empty.
|
||||
/// </summary>
|
||||
public class RuleEngineEventDataFilterTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "c3d4e5f6-a7b8-9012-cdef-123456789012";
|
||||
|
||||
[Fact]
|
||||
public async Task EventData_FilterAndMainTest_MustHoldOnTheSameEvent()
|
||||
{
|
||||
// Event 1 passes the main test but fails the filter; event 2 passes the filter but
|
||||
// fails the main test. Independent evaluation would fire — the same-event filter must not.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Episode(sequence: 1, durationSeconds: 5000, backfilled: true),
|
||||
Episode(sequence: 2, durationSeconds: 90, backfilled: false),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(MakeEventDataRule(), events);
|
||||
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EventData_MatchesTheEventThatSatisfiesBoth_AndReportsItAsEvidence()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Episode(sequence: 1, durationSeconds: 5000, backfilled: true),
|
||||
Episode(sequence: 2, durationSeconds: 900, backfilled: false),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(MakeEventDataRule(), events);
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
var evidence = Assert.IsAssignableFrom<IDictionary<string, object>>(result.MatchedConditions["long_live_episode"]);
|
||||
Assert.Equal(2, Convert.ToInt32(evidence["sequence"]));
|
||||
Assert.Equal("900", evidence["value"]?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EventData_NoFilterConfigured_KeepsIndependentBehaviour()
|
||||
{
|
||||
var rule = MakeEventDataRule();
|
||||
rule.Conditions[0].FilterField = null!;
|
||||
rule.Conditions[0].FilterOperator = null!;
|
||||
rule.Conditions[0].FilterValue = null!;
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Episode(sequence: 1, durationSeconds: 5000, backfilled: true),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
|
||||
Assert.Single(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EventData_MissingFilterField_IsExcludedByEquals_AndKeptByNotEquals()
|
||||
{
|
||||
var evt = Episode(sequence: 1, durationSeconds: 5000, backfilled: false);
|
||||
evt.Data!.Remove("backfilled");
|
||||
var events = new List<EnrollmentEvent> { evt };
|
||||
|
||||
// equals "false" on a missing field → empty string ≠ "false" → excluded.
|
||||
Assert.Empty((await RunAsync(MakeEventDataRule(), events)).Results);
|
||||
|
||||
// not_equals "true" on a missing field → kept.
|
||||
var lenient = MakeEventDataRule();
|
||||
lenient.Conditions[0].FilterOperator = "not_equals";
|
||||
lenient.Conditions[0].FilterValue = "true";
|
||||
Assert.Single((await RunAsync(lenient, events)).Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EventDataArray_FilterSelectsWhichEventsArraysAreIterated()
|
||||
{
|
||||
// The array element would match in both events; only the event passing the filter counts.
|
||||
var rule = new AnalyzeRule
|
||||
{
|
||||
RuleId = "ANALYZE-TST-011",
|
||||
Title = "Array with same-event filter",
|
||||
Severity = "warning",
|
||||
Category = "security",
|
||||
Enabled = true,
|
||||
IsBuiltIn = false,
|
||||
BaseConfidence = 80,
|
||||
ConfidenceThreshold = 40,
|
||||
Conditions = new List<RuleCondition>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Signal = "unexpected_artifact",
|
||||
Source = "event_data_array",
|
||||
EventType = "provisioning_package_scan",
|
||||
DataField = "artifacts",
|
||||
ItemField = "identity",
|
||||
Operator = "regex",
|
||||
Value = "^Evil",
|
||||
FilterField = "source",
|
||||
FilterOperator = "equals",
|
||||
FilterValue = "registry",
|
||||
Required = true
|
||||
}
|
||||
},
|
||||
Explanation = "test"
|
||||
};
|
||||
|
||||
var fileScan = ScanEvent(sequence: 1, source: "file", "Evil.ppkg");
|
||||
Assert.Empty((await RunAsync(rule, new List<EnrollmentEvent> { fileScan })).Results);
|
||||
|
||||
var registryScan = ScanEvent(sequence: 2, source: "registry", "Evil.ppkg");
|
||||
var outcome = await RunAsync(rule, new List<EnrollmentEvent> { fileScan, registryScan });
|
||||
var result = Assert.Single(outcome.Results);
|
||||
var evidence = Assert.IsAssignableFrom<IDictionary<string, object>>(result.MatchedConditions["unexpected_artifact"]);
|
||||
Assert.Equal(2, Convert.ToInt32(evidence["sequence"]));
|
||||
}
|
||||
|
||||
// ===== Builders =====
|
||||
|
||||
private static AnalyzeRule MakeEventDataRule() => new()
|
||||
{
|
||||
RuleId = "ANALYZE-TST-010",
|
||||
Title = "Long live sleep episode",
|
||||
Severity = "info",
|
||||
Category = "device",
|
||||
Enabled = true,
|
||||
// Synthetic rule ID outside the embedded catalog — see RuleEngineEventCountFilterTests.
|
||||
IsBuiltIn = false,
|
||||
BaseConfidence = 90,
|
||||
ConfidenceThreshold = 40,
|
||||
Conditions = new List<RuleCondition>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Signal = "long_live_episode",
|
||||
Source = "event_data",
|
||||
EventType = "system_sleep_episode",
|
||||
DataField = "durationSeconds",
|
||||
Operator = "gte",
|
||||
Value = "300",
|
||||
FilterField = "backfilled",
|
||||
FilterOperator = "equals",
|
||||
FilterValue = "false",
|
||||
Required = true
|
||||
}
|
||||
},
|
||||
Explanation = "test"
|
||||
};
|
||||
|
||||
private static EnrollmentEvent Episode(int sequence, long durationSeconds, bool backfilled) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "system_sleep_episode",
|
||||
Timestamp = DateTime.UtcNow.AddMinutes(sequence),
|
||||
Sequence = sequence,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["kind"] = "modern_standby",
|
||||
["durationSeconds"] = durationSeconds,
|
||||
["backfilled"] = backfilled,
|
||||
}
|
||||
};
|
||||
|
||||
private static EnrollmentEvent ScanEvent(int sequence, string source, params string[] identities) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "provisioning_package_scan",
|
||||
Timestamp = DateTime.UtcNow.AddMinutes(sequence),
|
||||
Sequence = sequence,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["source"] = source,
|
||||
["artifacts"] = identities
|
||||
.Select(id => (object)new Dictionary<string, object> { ["identity"] = id })
|
||||
.ToList(),
|
||||
}
|
||||
};
|
||||
|
||||
private static async Task<AnalysisOutcome> RunAsync(AnalyzeRule rule, List<EnrollmentEvent> events)
|
||||
{
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { rule });
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
|
||||
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
|
||||
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
|
||||
|
||||
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<RuleEngineEventDataFilterTests>.Instance);
|
||||
|
||||
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
|
||||
}
|
||||
}
|
||||
@@ -90,9 +90,62 @@ public class SleepEpisodeRuleTests
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_012_does_not_fire_on_a_backfilled_pre_session_episode()
|
||||
{
|
||||
// Backlog q8n: the watcher backfills pre-agent episodes for timeline context. A 91 h
|
||||
// hibernate that ended before the enrollment started (session a2256107) satisfied the
|
||||
// duration test and fired the rule although the device never slept while enrolling.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-012");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
SleepEpisode(durationSeconds: 329_140, kind: "hibernate", backfilled: true),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_012_pins_duration_and_backfilled_to_the_same_episode()
|
||||
{
|
||||
// The adversarial mix from a2256107: a long backfilled episode plus a short live one.
|
||||
// Two independent conditions (duration >= 300, backfilled = false) would each find a
|
||||
// match on a different event and fire; the same-event filter must not.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-012");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
SleepEpisode(durationSeconds: 329_140, kind: "hibernate", backfilled: true),
|
||||
SleepEpisode(durationSeconds: 89, kind: "modern_standby", backfilled: false),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_012_fires_on_a_live_episode_next_to_a_backfilled_one()
|
||||
{
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-012");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
SleepEpisode(durationSeconds: 329_140, kind: "hibernate", backfilled: true),
|
||||
SleepEpisode(durationSeconds: 600, kind: "modern_standby", backfilled: false),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
var matched = AsDict(result.MatchedConditions["slept_during_enrollment"]);
|
||||
Assert.Equal("600", AsString(matched["value"])); // the live episode, not the backfilled one
|
||||
}
|
||||
|
||||
// ===== Event builder — mirrors the SystemTimelineTracker emit shape =====
|
||||
|
||||
private static EnrollmentEvent SleepEpisode(long durationSeconds, string kind) => new()
|
||||
private static EnrollmentEvent SleepEpisode(long durationSeconds, string kind, bool backfilled = false) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
@@ -106,7 +159,7 @@ public class SleepEpisodeRuleTests
|
||||
["enteredAt"] = DateTime.UtcNow.AddSeconds(-durationSeconds).ToString("o"),
|
||||
["exitedAt"] = DateTime.UtcNow.ToString("o"),
|
||||
["durationSeconds"] = durationSeconds,
|
||||
["backfilled"] = false,
|
||||
["backfilled"] = backfilled,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -163,9 +163,31 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return (true, existsEvidence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional same-event value filter (FilterField/FilterOperator/FilterValue): keeps only
|
||||
/// events whose filter field satisfies the operator. Shared by <c>event_count</c> (which
|
||||
/// events are counted) and <c>event_data</c>/<c>event_data_array</c> (which event
|
||||
/// instances the main dataField/operator may match). The filter and the main test are
|
||||
/// evaluated on the SAME event, which independent conditions cannot express — each
|
||||
/// condition scans all events on its own, so "durationSeconds ≥ 300" and
|
||||
/// "backfilled = false" as two conditions would match two different episodes
|
||||
/// (ANALYZE-DEV-012 firing on a pre-session backfilled hibernate, backlog q8n).
|
||||
/// A missing filter field stringifies to empty, so <c>equals</c> excludes the event
|
||||
/// and <c>not_equals</c> keeps it. No filter configured = unchanged behaviour.
|
||||
/// </summary>
|
||||
private List<EnrollmentEvent> ApplyValueFilter(RuleCondition condition, List<EnrollmentEvent> matchingEvents)
|
||||
{
|
||||
if (string.IsNullOrEmpty(condition.FilterField) || string.IsNullOrEmpty(condition.FilterOperator))
|
||||
return matchingEvents;
|
||||
|
||||
return matchingEvents
|
||||
.Where(e => MatchesOperator(GetDataFieldValue(e, condition.FilterField) ?? string.Empty, condition.FilterOperator, condition.FilterValue))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private (bool matched, object evidence) EvaluateEventDataCondition(RuleCondition condition, List<EnrollmentEvent> events)
|
||||
{
|
||||
var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList();
|
||||
var matchingEvents = ApplyValueFilter(condition, events.Where(e => MatchesEventType(e, condition.EventType)).ToList());
|
||||
|
||||
foreach (var evt in matchingEvents)
|
||||
{
|
||||
@@ -214,7 +236,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// </summary>
|
||||
private (bool matched, object evidence) EvaluateEventDataArrayCondition(RuleCondition condition, List<EnrollmentEvent> events)
|
||||
{
|
||||
var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList();
|
||||
var matchingEvents = ApplyValueFilter(condition, events.Where(e => MatchesEventType(e, condition.EventType)).ToList());
|
||||
|
||||
foreach (var evt in matchingEvents)
|
||||
{
|
||||
@@ -362,16 +384,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
private (bool matched, object evidence) EvaluateEventCountCondition(RuleCondition condition, List<EnrollmentEvent> events)
|
||||
{
|
||||
var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList();
|
||||
|
||||
// Optional value filter: count only events whose FilterField satisfies FilterOperator/FilterValue
|
||||
// (e.g. only performance_snapshot events with memory_used_percent > 90).
|
||||
if (!string.IsNullOrEmpty(condition.FilterField) && !string.IsNullOrEmpty(condition.FilterOperator))
|
||||
{
|
||||
matchingEvents = matchingEvents
|
||||
.Where(e => MatchesOperator(GetDataFieldValue(e, condition.FilterField) ?? string.Empty, condition.FilterOperator, condition.FilterValue))
|
||||
.ToList();
|
||||
}
|
||||
var matchingEvents = ApplyValueFilter(condition, events.Where(e => MatchesEventType(e, condition.EventType)).ToList());
|
||||
|
||||
// count_per_group_gte: group by DataField value (e.g. appId), fire if any group >= threshold
|
||||
if (condition.Operator == "count_per_group_gte" && !string.IsNullOrEmpty(condition.DataField) && int.TryParse(condition.Value, out var groupThreshold))
|
||||
|
||||
@@ -2842,11 +2842,11 @@ export interface RuleCondition {
|
||||
value: string;
|
||||
/** Whether this condition must match for the rule to fire If false, it only contributes to confidence scoring */
|
||||
required: boolean;
|
||||
/** Optional value filter for "event_count": only events whose FilterField satisfies FilterOperator/FilterValue are counted (e.g. count only performance_snapshot events with memory_used_percent > 90). Applies before counting for both count_gte and count_per_group_gte. */
|
||||
/** Optional same-event value filter: only events whose FilterField satisfies FilterOperator/FilterValue are considered by the condition. For "event_count": applies before counting (both count_gte and count_per_group_gte), e.g. count only performance_snapshot events with memory_used_percent > 90. For "event_data"/"event_data_array": the filter and the main DataField/Operator test are evaluated on the SAME event instance — the only way to pin two fields of one event together, since separate conditions each scan all events independently (e.g. system_sleep_episode with durationSeconds >= 300 AND backfilled = false). */
|
||||
filterField: string;
|
||||
/** Operator for the count filter. Uses same operators as the main Operator field. */
|
||||
/** Operator for the same-event filter. Uses the same operators as the main Operator field. */
|
||||
filterOperator: string;
|
||||
/** Value for the count filter. */
|
||||
/** Value for the same-event filter. */
|
||||
filterValue: string;
|
||||
/** The second event type to correlate with (Event B). Example: "app_install_failed" */
|
||||
correlateEventType: string;
|
||||
|
||||
@@ -145,7 +145,10 @@ export const RULE_AUTHORING_GUIDE = {
|
||||
'on a field inside those events. Most common source.',
|
||||
event_data:
|
||||
'Compare a data field inside events of eventType (dataField + operator + value). Supports ' +
|
||||
'dot-paths into nested objects; "message" resolves to the event message.',
|
||||
'dot-paths into nested objects; "message" resolves to the event message. Optional ' +
|
||||
'filterField/filterOperator/filterValue pin a second field of the SAME event (e.g. ' +
|
||||
'durationSeconds gte 300 with filter backfilled equals false) — two separate conditions ' +
|
||||
'would match two different events.',
|
||||
event_data_array:
|
||||
'Iterate an ARRAY field (dataField) inside ONE event and test a sub-field of each element ' +
|
||||
'(itemField; empty = element itself). Matches if ANY element passes. Allow-list pattern: ' +
|
||||
|
||||
@@ -496,11 +496,11 @@ export const ANALYZE_RULE_SCHEMA: Record<string, unknown> = {
|
||||
},
|
||||
"filterField": {
|
||||
"type": "string",
|
||||
"description": "For event_count only: optional value filter — only events whose filterField satisfies filterOperator/filterValue are counted (e.g. count only performance_snapshot events with memory_used_percent > 90). Applies before counting for both count_gte and count_per_group_gte."
|
||||
"description": "Optional same-event value filter for event_count, event_data and event_data_array: only events whose filterField satisfies filterOperator/filterValue are considered. For event_count it applies before counting (both count_gte and count_per_group_gte, e.g. count only performance_snapshot events with memory_used_percent > 90). For event_data/event_data_array the filter and the main dataField/operator test are evaluated on the SAME event instance — the only way to pin two fields of one event together, since separate conditions each scan all events independently (e.g. system_sleep_episode with durationSeconds gte 300 AND backfilled equals false)."
|
||||
},
|
||||
"filterOperator": {
|
||||
"type": "string",
|
||||
"description": "Operator for the event_count value filter.",
|
||||
"description": "Operator for the same-event value filter. Same vocabulary as operator.",
|
||||
"enum": [
|
||||
"equals",
|
||||
"not_equals",
|
||||
@@ -520,7 +520,7 @@ export const ANALYZE_RULE_SCHEMA: Record<string, unknown> = {
|
||||
},
|
||||
"filterValue": {
|
||||
"type": "string",
|
||||
"description": "Value for the event_count value filter."
|
||||
"description": "Value for the same-event value filter. Boolean values are stringified (\"true\"/\"false\")."
|
||||
},
|
||||
"correlateEventType": {
|
||||
"type": "string",
|
||||
|
||||
@@ -279,24 +279,28 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool Required { get; set; } = false;
|
||||
|
||||
// ===== Event Count Filter Properties =====
|
||||
// Used only when Source = "event_count"
|
||||
// ===== Same-Event Value Filter Properties =====
|
||||
// Used when Source = "event_count", "event_data" or "event_data_array"
|
||||
|
||||
/// <summary>
|
||||
/// Optional value filter for "event_count": only events whose FilterField
|
||||
/// satisfies FilterOperator/FilterValue are counted (e.g. count only
|
||||
/// performance_snapshot events with memory_used_percent > 90).
|
||||
/// Applies before counting for both count_gte and count_per_group_gte.
|
||||
/// Optional same-event value filter: only events whose FilterField satisfies
|
||||
/// FilterOperator/FilterValue are considered by the condition.
|
||||
/// For "event_count": applies before counting (both count_gte and count_per_group_gte),
|
||||
/// e.g. count only performance_snapshot events with memory_used_percent > 90.
|
||||
/// For "event_data"/"event_data_array": the filter and the main DataField/Operator test
|
||||
/// are evaluated on the SAME event instance — the only way to pin two fields of one
|
||||
/// event together, since separate conditions each scan all events independently
|
||||
/// (e.g. system_sleep_episode with durationSeconds >= 300 AND backfilled = false).
|
||||
/// </summary>
|
||||
public string FilterField { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Operator for the count filter. Uses same operators as the main Operator field.
|
||||
/// Operator for the same-event filter. Uses the same operators as the main Operator field.
|
||||
/// </summary>
|
||||
public string FilterOperator { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Value for the count filter.
|
||||
/// Value for the same-event filter.
|
||||
/// </summary>
|
||||
public string FilterValue { get; set; } = default!;
|
||||
|
||||
|
||||
@@ -13257,7 +13257,7 @@
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "Optional value filter for \"event_count\": only events whose FilterField satisfies FilterOperator/FilterValue are counted (e.g. count only performance_snapshot events with memory_used_percent > 90). Applies before counting for both count_gte and count_per_group_gte."
|
||||
"doc": "Optional same-event value filter: only events whose FilterField satisfies FilterOperator/FilterValue are considered by the condition. For \"event_count\": applies before counting (both count_gte and count_per_group_gte), e.g. count only performance_snapshot events with memory_used_percent > 90. For \"event_data\"/\"event_data_array\": the filter and the main DataField/Operator test are evaluated on the SAME event instance — the only way to pin two fields of one event together, since separate conditions each scan all events independently (e.g. system_sleep_episode with durationSeconds >= 300 AND backfilled = false)."
|
||||
},
|
||||
{
|
||||
"name": "filterOperator",
|
||||
@@ -13265,7 +13265,7 @@
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "Operator for the count filter. Uses same operators as the main Operator field."
|
||||
"doc": "Operator for the same-event filter. Uses the same operators as the main Operator field."
|
||||
},
|
||||
{
|
||||
"name": "filterValue",
|
||||
@@ -13273,7 +13273,7 @@
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "Value for the count filter."
|
||||
"doc": "Value for the same-event filter."
|
||||
},
|
||||
{
|
||||
"name": "correlateEventType",
|
||||
|
||||
@@ -2842,11 +2842,11 @@ export interface RuleCondition {
|
||||
value: string;
|
||||
/** Whether this condition must match for the rule to fire If false, it only contributes to confidence scoring */
|
||||
required: boolean;
|
||||
/** Optional value filter for "event_count": only events whose FilterField satisfies FilterOperator/FilterValue are counted (e.g. count only performance_snapshot events with memory_used_percent > 90). Applies before counting for both count_gte and count_per_group_gte. */
|
||||
/** Optional same-event value filter: only events whose FilterField satisfies FilterOperator/FilterValue are considered by the condition. For "event_count": applies before counting (both count_gte and count_per_group_gte), e.g. count only performance_snapshot events with memory_used_percent > 90. For "event_data"/"event_data_array": the filter and the main DataField/Operator test are evaluated on the SAME event instance — the only way to pin two fields of one event together, since separate conditions each scan all events independently (e.g. system_sleep_episode with durationSeconds >= 300 AND backfilled = false). */
|
||||
filterField: string;
|
||||
/** Operator for the count filter. Uses same operators as the main Operator field. */
|
||||
/** Operator for the same-event filter. Uses the same operators as the main Operator field. */
|
||||
filterOperator: string;
|
||||
/** Value for the count filter. */
|
||||
/** Value for the same-event filter. */
|
||||
filterValue: string;
|
||||
/** The second event type to correlate with (Event B). Example: "app_install_failed" */
|
||||
correlateEventType: string;
|
||||
|
||||
Reference in New Issue
Block a user