mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
c91d8d4501
IME never writes its "Completed user session" line when the user has no assigned Win32 apps - its zero-app branch returns before that statement (ApplicationPoller.cs:713-721, identical in 1.80/1.83/1.97/1.104). So ImeUserSessionCompletedUtc stayed null, every arm of ShouldTransitionToAwaitingHello was unsatisfiable, and the 30-min advisory_completion backstop resolved healthy enrollments as esp_exit_without_completion_evidence. One tenant: 10 of 30 sessions in a week, all false positives - DAD-validated desktop, Hello provisioned, RealmJoin still installing. Every Succeeded session there carries ime_user_session_completed; not one of the failed ones does. IME-USER-SESSION-ZERO-APPS reads the line IME does write there and routes it through the same OnUserSessionCompleted callback. That is IME's own verdict rather than a weakened gate: the zero-app branch locks the ESP in with an empty application list, and the sibling emit site returns ProvisioningComplete right after logging it. Guarded to the AccountSetup phase so it cannot burn the adapter fire-once flag on a pre-sign-in timestamp, and replayed on the phase transition when the observation predates the phase marker. AreUserEspAppsSettled accepts an empty app list only with that explicit observation, never on emptiness alone. Second, independent defect: advisory_completion (30 min) overran the RealmJoin completion gate, which deliberately holds the session and carries its own bounded resolution (60 min from detection, activity-extended, 4 h cap). One session installed 25 RealmJoin packages inside the window, the last 2 min before the failure, while completion_waiting still read "waiting on: realmjoin_resolution". The handler now re-arms while that gate is closed and its own deadline is still armed; convergence comes from the gate itself, which always ends with an Outcome set. ime_user_session_completed gains an evidence field (zero_user_apps_enumerated / user_session_completed) - no new event type, so the Constants -> resource-catalog -> shared-manifests triple stays untouched. The pattern ships via the config API and reaches existing agents, where an unknown action is a single Debug line; the behaviour needs the agent release. Build 0 warnings. DecisionCore 558/558, V2.Core 2530/2530, Backend 4634/4634, MCP event-types-drift 6/6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
240 lines
12 KiB
C#
240 lines
12 KiB
C#
using System.Text.RegularExpressions;
|
|
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared.Models;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
public class BuiltInRulesTests
|
|
{
|
|
[Fact]
|
|
public void BuiltInGatherRules_LoadsFromEmbeddedResource()
|
|
{
|
|
var rules = BuiltInGatherRules.GetAll();
|
|
Assert.NotNull(rules);
|
|
Assert.NotEmpty(rules);
|
|
Assert.Contains(rules, r => r.RuleId == "GATHER-DEVICE-001");
|
|
Assert.Contains(rules, r => r.RuleId == "GATHER-DEVICE-002");
|
|
|
|
foreach (var rule in rules)
|
|
{
|
|
Assert.False(string.IsNullOrEmpty(rule.RuleId), "RuleId must not be empty");
|
|
Assert.False(string.IsNullOrEmpty(rule.Title), $"Rule {rule.RuleId} is missing Title");
|
|
Assert.False(string.IsNullOrEmpty(rule.CollectorType), $"Rule {rule.RuleId} is missing CollectorType");
|
|
Assert.False(string.IsNullOrEmpty(rule.Target), $"Rule {rule.RuleId} is missing Target");
|
|
Assert.False(string.IsNullOrEmpty(rule.Trigger), $"Rule {rule.RuleId} is missing Trigger");
|
|
Assert.False(string.IsNullOrEmpty(rule.OutputEventType), $"Rule {rule.RuleId} is missing OutputEventType");
|
|
Assert.True(rule.IsBuiltIn, $"Rule {rule.RuleId} should have IsBuiltIn=true");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInAnalyzeRules_LoadsFromEmbeddedResource()
|
|
{
|
|
var rules = BuiltInAnalyzeRules.GetAll();
|
|
Assert.NotNull(rules);
|
|
Assert.True(rules.Count >= 18, $"Expected at least 18 rules, got {rules.Count}");
|
|
|
|
foreach (var rule in rules)
|
|
{
|
|
Assert.False(string.IsNullOrEmpty(rule.RuleId), "RuleId must not be empty");
|
|
Assert.False(string.IsNullOrEmpty(rule.Title), $"Rule {rule.RuleId} is missing Title");
|
|
Assert.False(string.IsNullOrEmpty(rule.Severity), $"Rule {rule.RuleId} is missing Severity");
|
|
Assert.False(string.IsNullOrEmpty(rule.Explanation), $"Rule {rule.RuleId} is missing Explanation");
|
|
Assert.NotNull(rule.Conditions);
|
|
Assert.NotEmpty(rule.Conditions);
|
|
Assert.True(rule.IsBuiltIn, $"Rule {rule.RuleId} should have IsBuiltIn=true");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInAnalyzeRules_TemplateVariablesAreValid()
|
|
{
|
|
var rules = BuiltInAnalyzeRules.GetAll();
|
|
var templateRules = rules.Where(r => r.TemplateVariables != null && r.TemplateVariables.Count > 0).ToList();
|
|
|
|
foreach (var rule in templateRules)
|
|
{
|
|
// Template rules should be disabled by default (they need configuration first)
|
|
Assert.False(rule.Enabled, $"Template rule {rule.RuleId} should be disabled by default");
|
|
|
|
foreach (var tv in rule.TemplateVariables)
|
|
{
|
|
Assert.False(string.IsNullOrEmpty(tv.Name), $"Rule {rule.RuleId}: TemplateVariable is missing Name");
|
|
Assert.False(string.IsNullOrEmpty(tv.Label), $"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' is missing Label");
|
|
Assert.False(string.IsNullOrEmpty(tv.Placeholder), $"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' is missing Placeholder");
|
|
|
|
// conditionIndex must reference a valid condition
|
|
Assert.True(tv.ConditionIndex >= 0 && tv.ConditionIndex < rule.Conditions.Count,
|
|
$"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' has conditionIndex {tv.ConditionIndex} but rule only has {rule.Conditions.Count} conditions");
|
|
|
|
// Field must be one of the known fields
|
|
var validFields = new HashSet<string> { "value", "eventType", "dataField", "eventAFilterValue" };
|
|
Assert.True(validFields.Contains(tv.Field),
|
|
$"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' has unknown field '{tv.Field}'");
|
|
|
|
// The placeholder should match the current value in the condition
|
|
var condition = rule.Conditions[tv.ConditionIndex];
|
|
var actualValue = tv.Field switch
|
|
{
|
|
"value" => condition.Value,
|
|
"eventType" => condition.EventType,
|
|
"dataField" => condition.DataField,
|
|
"eventAFilterValue" => condition.EventAFilterValue,
|
|
_ => null
|
|
};
|
|
Assert.Equal(tv.Placeholder, actualValue);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInAnalyzeRules_AutoLogon_rules_have_expected_shape()
|
|
{
|
|
var rules = BuiltInAnalyzeRules.GetAll();
|
|
|
|
// SEC-002 was removed: AutoLogon-enabled alone is the exact fingerprint of Windows' own ESP
|
|
// auto-logon during a normal Autopilot enrollment, so a broad "AutoLogon active" warning was
|
|
// a guaranteed false positive on every device. Only a plaintext password (SEC-003) is graded.
|
|
Assert.DoesNotContain(rules, r => r.RuleId == "ANALYZE-SEC-002");
|
|
|
|
// The non-discriminating sysinternals heuristic was removed everywhere — no surviving rule
|
|
// may reference it (it fired identically for native ESP auto-logon and a Sysinternals kiosk).
|
|
Assert.DoesNotContain(rules, r =>
|
|
r.Conditions != null && r.Conditions.Any(c => c.DataField == "checks.sysinternals_autologon_suspected"));
|
|
Assert.DoesNotContain(rules, r =>
|
|
r.ConfidenceFactors != null && r.ConfidenceFactors.Any(f => f.Signal == "sysinternals_suspected"));
|
|
|
|
// SEC-003: plaintext password, default-on, escalated. The sole default-on AutoLogon signal.
|
|
var sec003 = rules.FirstOrDefault(r => r.RuleId == "ANALYZE-SEC-003");
|
|
Assert.NotNull(sec003);
|
|
Assert.True(sec003!.Enabled, "ANALYZE-SEC-003 should be enabled by default");
|
|
Assert.Equal("high", sec003.Severity);
|
|
Assert.Contains(sec003.Conditions, c => c.DataField == "checks.default_password_present");
|
|
|
|
// SEC-004: kiosk allow-list TEMPLATE — disabled until configured.
|
|
var sec004 = rules.FirstOrDefault(r => r.RuleId == "ANALYZE-SEC-004");
|
|
Assert.NotNull(sec004);
|
|
Assert.False(sec004!.Enabled, "ANALYZE-SEC-004 is a template and must ship disabled");
|
|
Assert.NotNull(sec004.TemplateVariables);
|
|
var tv = Assert.Single(sec004.TemplateVariables);
|
|
Assert.Equal("allowed_autologon_users", tv.Name);
|
|
Assert.Equal(1, tv.ConditionIndex);
|
|
Assert.Equal("value", tv.Field);
|
|
Assert.Equal("not_in", sec004.Conditions[1].Operator);
|
|
Assert.Equal("checks.default_user_name", sec004.Conditions[1].DataField);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInAnalyzeRules_ANALYZE_ID_001_IsTemplate()
|
|
{
|
|
var rules = BuiltInAnalyzeRules.GetAll();
|
|
var rule = rules.FirstOrDefault(r => r.RuleId == "ANALYZE-ID-001");
|
|
|
|
Assert.NotNull(rule);
|
|
Assert.False(rule.Enabled, "ANALYZE-ID-001 should be disabled (it's a template)");
|
|
Assert.NotNull(rule.TemplateVariables);
|
|
Assert.Single(rule.TemplateVariables);
|
|
|
|
var tv = rule.TemplateVariables[0];
|
|
Assert.Equal("cert_subject", tv.Name);
|
|
Assert.Equal("Certificate Subject", tv.Label);
|
|
Assert.Equal(1, tv.ConditionIndex);
|
|
Assert.Equal("value", tv.Field);
|
|
Assert.Equal("CN=YOUR-CERTIFICATE-SUBJECT", tv.Placeholder);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInImeLogPatterns_LoadsFromEmbeddedResource()
|
|
{
|
|
var patterns = BuiltInImeLogPatterns.GetAll();
|
|
Assert.NotNull(patterns);
|
|
Assert.True(patterns.Count >= 47, $"Expected at least 47 patterns, got {patterns.Count}");
|
|
|
|
foreach (var pattern in patterns)
|
|
{
|
|
Assert.False(string.IsNullOrEmpty(pattern.PatternId), "PatternId must not be empty");
|
|
Assert.False(string.IsNullOrEmpty(pattern.Category), $"Pattern {pattern.PatternId} is missing Category");
|
|
Assert.False(string.IsNullOrEmpty(pattern.Pattern), $"Pattern {pattern.PatternId} is missing Pattern");
|
|
Assert.False(string.IsNullOrEmpty(pattern.Action), $"Pattern {pattern.PatternId} is missing Action");
|
|
Assert.False(string.IsNullOrEmpty(pattern.Description),
|
|
$"Pattern {pattern.PatternId} is missing Description");
|
|
Assert.True(pattern.IsBuiltIn, $"Pattern {pattern.PatternId} should have IsBuiltIn=true");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInImeLogPatterns_AllActionsAreKnown()
|
|
{
|
|
var knownActions = new HashSet<string>
|
|
{
|
|
"setCurrentApp", "updateStateInstalled", "updateStateDownloading",
|
|
"updateStateInstalling", "updateStateSkipped", "updateStateError",
|
|
"updateStatePostponed", "espPhaseDetected", "imeStarted",
|
|
"policiesDiscovered", "ignoreCompletedApp", "imeAgentVersion",
|
|
"espTrackStatus", "updateName", "updateWin32AppState",
|
|
"cancelStuckAndSetCurrent", "imeSessionChange", "imeImpersonation", "imeTokenFailure",
|
|
"enrollmentCompleted", "userSessionZeroApps", "updateDoTelemetry", "scriptStarted",
|
|
"scriptContext", "scriptExitCode", "scriptOutput", "scriptCompleted",
|
|
"resetPlatformScriptContext",
|
|
"healthScriptResult", "healthScriptDetectionResult",
|
|
"captureExitCode", "captureHResult",
|
|
"captureAppVersion", "captureAppTypeWinGet", "captureAppTypeMsi",
|
|
"captureAttemptNumber", "captureDetectionResult"
|
|
};
|
|
|
|
var patterns = BuiltInImeLogPatterns.GetAll();
|
|
foreach (var pattern in patterns)
|
|
{
|
|
Assert.True(knownActions.Contains(pattern.Action),
|
|
$"Pattern {pattern.PatternId} has unknown action '{pattern.Action}'");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInImeLogPatterns_AllCategoriesAreValid()
|
|
{
|
|
var validCategories = new HashSet<string> { "always", "currentPhase", "otherPhases" };
|
|
|
|
var patterns = BuiltInImeLogPatterns.GetAll();
|
|
foreach (var pattern in patterns)
|
|
{
|
|
Assert.True(validCategories.Contains(pattern.Category),
|
|
$"Pattern {pattern.PatternId} has invalid category '{pattern.Category}'");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sessions 81daa77f / 75d6ae8e (2026-08-25). IME writes this line and then returns before
|
|
/// its "Completed user session" statement, so on a device with no user-targeted Win32 apps
|
|
/// it is the ONLY evidence that user-phase enforcement finished. The exact wording is
|
|
/// pinned here against the real IME line (verified against decompiled IME 1.104.102.0,
|
|
/// ApplicationPoller and AppWorkloadAbstraction) because a silent drift would bring back
|
|
/// the 30-min esp_exit_without_completion_evidence false positive with no test failing.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuiltInImeLogPatterns_ZeroUserAppsPattern_MatchesTheRealImeLine()
|
|
{
|
|
var pattern = Assert.Single(
|
|
BuiltInImeLogPatterns.GetAll(),
|
|
p => p.PatternId == "IME-USER-SESSION-ZERO-APPS");
|
|
|
|
Assert.Equal("userSessionZeroApps", pattern.Action);
|
|
Assert.Equal("always", pattern.Category);
|
|
|
|
var regex = new Regex(pattern.Pattern, RegexOptions.CultureInvariant);
|
|
|
|
var real = "[Win32App] Get 0 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020";
|
|
var match = regex.Match(real);
|
|
Assert.True(match.Success, "zero-app user session line must match");
|
|
Assert.Equal("1", match.Groups["sessionId"].Value);
|
|
Assert.Equal("4a1ba5db-44ed-4e69-8477-47ed88e76020", match.Groups["userId"].Value);
|
|
|
|
// Must not swallow a session that DOES have apps — that would fabricate completion
|
|
// evidence while enforcement is still running.
|
|
Assert.DoesNotMatch(regex,
|
|
"[Win32App] Get 3 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
|
|
Assert.DoesNotMatch(regex,
|
|
"[Win32App] Get 10 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
|
|
}
|
|
}
|