Compare commits

..

3 Commits

Author SHA1 Message Date
Oliver Kieselbach e6191831bd Rules: remove the AutoLogon allow-list template ANALYZE-SEC-004
The ID stays retired; a test pins that it does not come back and that no rule
matches on the AutoLogon user name.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 22:05:18 +01:00
Oliver Kieselbach ccea1dacfe Agent: leaner payloads for the join-status and AutoLogon events
The domain helper moves next to the join-info reader and is shared by its callers.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 21:47:14 +01:00
Oliver Kieselbach 33e614da5d bump pointer: internal (D-435 released: backend 1.5.1331, agent 2.0.1504, web and MCP 1.6.597 on a143314)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 20:11:32 +01:00
16 changed files with 178 additions and 263 deletions
-94
View File
@@ -1,94 +0,0 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-SEC-004",
"title": "AutoLogon enabled for an unexpected user",
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
"severity": "warning",
"category": "security",
"version": "1.2.0",
"enabled": false,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 80,
"conditions": [
{
"signal": "autologon_enabled",
"source": "event_type",
"eventType": "autologon_analysis",
"dataField": "checks.autologon_enabled",
"operator": "equals",
"value": "true",
"required": true
},
{
"signal": "unexpected_autologon_user",
"source": "event_data",
"eventType": "autologon_analysis",
"dataField": "checks.default_user_name",
"operator": "not_in",
"value": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2",
"required": true
}
],
"confidenceThreshold": 40,
"explanation": "AutoLogon is enabled on this device for a user that is **not on your approved kiosk allow-list**. AutoLogon is legitimate for kiosk and shared-device scenarios, so this rule only warns when the configured default user differs from the account(s) you expect.\n\nThis rule is **off by default**. A normal user-driven Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (`AutoAdminLogon=1`, a `DefaultUserName`, an `AutoLogonSID`, and no plaintext password) — registry-indistinguishable from a kiosk AutoLogon — so AutoLogon-enabled alone is not graded. The only AutoLogon state graded by default is a plaintext `DefaultPassword` on disk (**ANALYZE-SEC-003**).\n\nTo use this rule: configure the allow-list with your approved kiosk account name(s) (comma-separated, case-insensitive exact match against the Winlogon `DefaultUserName`) and enable it. AutoLogon for any other user will then warn.\n\nNote: an AutoLogon with no explicit default user (e.g. ForceAutoLogon only) is treated as unexpected and will warn.",
"remediation": [
{
"title": "Confirm the AutoLogon user",
"steps": [
"Open the autologon_analysis event in the session timeline and check the default user/domain",
"If the user is an approved kiosk account, add it to this rule's allow-list",
"If the user is unexpected, treat it as a possible enrollment/OOBE manipulation and investigate what wrote the Winlogon keys"
]
},
{
"title": "Disable AutoLogon if not required",
"steps": [
"Set HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon\\AutoAdminLogon to 0",
"Remove DefaultPassword (and any Sysinternals LSA secret) if present",
"For kiosk scenarios, prefer Assigned Access / kiosk profiles over raw AutoLogon where possible"
]
}
],
"relatedDocs": [
{
"title": "Microsoft: Configure Windows to automatically sign in (AutoLogon)",
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
}
],
"presentation": {
"role": "advisory",
"summary": "AutoLogon is enabled for an unexpected user: {{unexpected_autologon_user.value}}",
"subject": {
"kind": "account",
"key": "{{unexpected_autologon_user.value}}"
},
"facts": [
{
"label": "AutoLogon user",
"value": "{{unexpected_autologon_user.value}}",
"format": "code"
}
]
},
"tags": [
"security",
"autologon",
"winlogon",
"kiosk",
"allow-list"
],
"templateVariables": [
{
"name": "allowed_autologon_users",
"label": "Allowed AutoLogon users (kiosk accounts)",
"description": "Comma-separated list of approved kiosk / shared-device account names that are expected to use AutoLogon (case-insensitive exact match against the Winlogon DefaultUserName, e.g. 'KioskUser' or 'Reception,LobbyPC'). AutoLogon for any other user raises a warning.",
"conditionIndex": 1,
"field": "value",
"placeholder": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2"
}
]
}
-93
View File
@@ -4926,99 +4926,6 @@
"plaintext-password"
]
},
{
"ruleId": "ANALYZE-SEC-004",
"title": "AutoLogon enabled for an unexpected user",
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
"severity": "warning",
"category": "security",
"version": "1.2.0",
"enabled": false,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 80,
"conditions": [
{
"signal": "autologon_enabled",
"source": "event_type",
"eventType": "autologon_analysis",
"dataField": "checks.autologon_enabled",
"operator": "equals",
"value": "true",
"required": true
},
{
"signal": "unexpected_autologon_user",
"source": "event_data",
"eventType": "autologon_analysis",
"dataField": "checks.default_user_name",
"operator": "not_in",
"value": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2",
"required": true
}
],
"confidenceThreshold": 40,
"explanation": "AutoLogon is enabled on this device for a user that is **not on your approved kiosk allow-list**. AutoLogon is legitimate for kiosk and shared-device scenarios, so this rule only warns when the configured default user differs from the account(s) you expect.\n\nThis rule is **off by default**. A normal user-driven Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (`AutoAdminLogon=1`, a `DefaultUserName`, an `AutoLogonSID`, and no plaintext password) — registry-indistinguishable from a kiosk AutoLogon — so AutoLogon-enabled alone is not graded. The only AutoLogon state graded by default is a plaintext `DefaultPassword` on disk (**ANALYZE-SEC-003**).\n\nTo use this rule: configure the allow-list with your approved kiosk account name(s) (comma-separated, case-insensitive exact match against the Winlogon `DefaultUserName`) and enable it. AutoLogon for any other user will then warn.\n\nNote: an AutoLogon with no explicit default user (e.g. ForceAutoLogon only) is treated as unexpected and will warn.",
"remediation": [
{
"title": "Confirm the AutoLogon user",
"steps": [
"Open the autologon_analysis event in the session timeline and check the default user/domain",
"If the user is an approved kiosk account, add it to this rule's allow-list",
"If the user is unexpected, treat it as a possible enrollment/OOBE manipulation and investigate what wrote the Winlogon keys"
]
},
{
"title": "Disable AutoLogon if not required",
"steps": [
"Set HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon\\AutoAdminLogon to 0",
"Remove DefaultPassword (and any Sysinternals LSA secret) if present",
"For kiosk scenarios, prefer Assigned Access / kiosk profiles over raw AutoLogon where possible"
]
}
],
"relatedDocs": [
{
"title": "Microsoft: Configure Windows to automatically sign in (AutoLogon)",
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
}
],
"presentation": {
"role": "advisory",
"summary": "AutoLogon is enabled for an unexpected user: {{unexpected_autologon_user.value}}",
"subject": {
"kind": "account",
"key": "{{unexpected_autologon_user.value}}"
},
"facts": [
{
"label": "AutoLogon user",
"value": "{{unexpected_autologon_user.value}}",
"format": "code"
}
]
},
"tags": [
"security",
"autologon",
"winlogon",
"kiosk",
"allow-list"
],
"templateVariables": [
{
"name": "allowed_autologon_users",
"label": "Allowed AutoLogon users (kiosk accounts)",
"description": "Comma-separated list of approved kiosk / shared-device account names that are expected to use AutoLogon (case-insensitive exact match against the Winlogon DefaultUserName, e.g. 'KioskUser' or 'Reception,LobbyPC'). AutoLogon for any other user raises a warning.",
"conditionIndex": 1,
"field": "value",
"placeholder": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2"
}
]
},
{
"ruleId": "ANALYZE-SEC-005",
"title": "Unexpected Provisioning Package",
@@ -135,6 +135,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
Assert.Equal("CONTOSO", checks["default_domain_name"]);
}
[Fact]
public void BuildPayload_reports_a_sign_in_address_as_its_domain_only()
{
var snap = new AutoLogonSnapshot
{
WinlogonKeyPresent = true,
AutoAdminLogon = "1",
DefaultUserName = "luke.skywalker@example.net",
AltDefaultUserName = @"AzureAD\luke.skywalker@example.net",
};
var data = AutoLogonAnalyzer.BuildPayload(snap, "shutdown");
var checks = Checks(data);
Assert.Equal("[redacted]@example.net", checks["default_user_name"]);
Assert.Equal("[redacted]@example.net", checks["alt_default_user_name"]);
Assert.DoesNotContain(checks.Values.OfType<string>(), v => v.Contains("skywalker"));
// The reduction is presentation only — the identity is still reported as present.
Assert.True((bool)checks["default_identity_present"]);
}
[Theory]
[InlineData("kioskuser", "kioskuser")]
[InlineData("defaultuser0", "defaultuser0")]
[InlineData("foouser@example.net", "foouser@example.net")]
[InlineData("autopilot@example.net", "autopilot@example.net")]
[InlineData("luke.skywalker@example.net", "[redacted]@example.net")]
[InlineData("luke@skywalker@example.net", "[redacted]@example.net")]
[InlineData("luke.skywalker@", "[redacted]@unknown")]
[InlineData("", "")]
[InlineData(null, "")]
public void ReduceUserName_keeps_local_accounts_and_placeholders_readable(string? raw, string expected)
{
Assert.Equal(expected, AutoLogonAnalyzer.ReduceUserName(raw));
}
[Theory]
[InlineData("1", true)]
[InlineData("true", true)]
@@ -45,7 +45,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.DeviceInfo
{
using var rig = new Rig();
var notJoined = new Dictionary<string, object> { { "joinType", "Not Joined" } };
var joined = new Dictionary<string, object> { { "joinType", "Azure AD Joined" }, { "userEmail", "alice@contoso.com" } };
var joined = DeviceInfoCollector.BuildAadJoinedData("T1", "alice@contoso.com", "AABB", isPlaceholderUser: false);
Assert.True(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, notJoined));
Assert.False(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, notJoined));
@@ -56,6 +56,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.DeviceInfo
Assert.False(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, joined));
}
[Fact]
public void Placeholder_replaced_by_a_user_of_the_same_domain_reemits()
{
// Pre-provisioning hands the device from the placeholder account to the real user
// in the same domain — the two payloads must still differ for the gate.
using var rig = new Rig();
var placeholder = DeviceInfoCollector.BuildAadJoinedData("T1", "foouser@contoso.com", "AABB", isPlaceholderUser: true);
var realUser = DeviceInfoCollector.BuildAadJoinedData("T1", "alice@contoso.com", "AABB", isPlaceholderUser: false);
Assert.True(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, placeholder));
Assert.True(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, realUser));
}
[Fact]
public void Boot_time_and_wifi_signal_are_exempt_from_the_gate()
{
@@ -114,6 +127,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.DeviceInfo
}
}
/// <summary>Payload of <c>aad_join_status</c> for a joined device.</summary>
public sealed class AadJoinStatusPayloadTests
{
[Fact]
public void Joined_payload_carries_the_domain_of_the_signed_in_address_only()
{
var data = DeviceInfoCollector.BuildAadJoinedData("T1", "alice@contoso.com", "AABB", isPlaceholderUser: false);
Assert.Equal("Azure AD Joined", data["joinType"]);
Assert.Equal("T1", data["tenantId"]);
Assert.Equal("AABB", data["thumbprint"]);
Assert.Equal("contoso.com", data["userDomain"]);
Assert.False(data.ContainsKey("isFooUser"));
Assert.DoesNotContain(data.Values, v => v is string s && s.Contains("alice"));
}
[Fact]
public void Joined_payload_flags_the_placeholder_and_omits_the_domain_without_an_address()
{
var placeholder = DeviceInfoCollector.BuildAadJoinedData("T1", "foouser@contoso.com", "AABB", isPlaceholderUser: true);
Assert.Equal(true, placeholder["isFooUser"]);
Assert.Equal("contoso.com", placeholder["userDomain"]);
var deviceOnly = DeviceInfoCollector.BuildAadJoinedData("T1", null!, "AABB", isPlaceholderUser: false);
Assert.False(deviceOnly.ContainsKey("userDomain"));
Assert.False(deviceOnly.ContainsKey("isFooUser"));
}
}
/// <summary>Inventory fingerprint used by the SoftwareInventoryAnalyzer's restart dedup.</summary>
public sealed class SoftwareInventoryFingerprintTests
{
@@ -80,5 +80,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
return email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase)
|| email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// The domain part of a sign-in address, or "unknown" when there is none. Telemetry
/// carries this instead of the address: the part before the last '@' stays on the device.
/// </summary>
internal static string ExtractUserDomain(string email)
{
if (string.IsNullOrEmpty(email)) return "unknown";
var at = email.LastIndexOf('@');
return at >= 0 && at + 1 < email.Length ? email.Substring(at + 1) : "unknown";
}
}
}
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
@@ -31,7 +32,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
/// This analyzer reports facts ONLY, always at <see cref="EventSeverity.Info"/>. It never reads
/// or emits the <c>DefaultPassword</c> value — only its presence. Whether an AutoLogon is a
/// problem is judged by backend analyze-rules (ANALYZE-SEC-003 = plaintext DefaultPassword on
/// disk → escalated; ANALYZE-SEC-004 = optional kiosk allow-list template).
/// disk → escalated).
/// </para>
/// <para>
/// <b>Trigger</b>: NOT at agent start — the Winlogon keys are written later by a provisioning
@@ -218,9 +219,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
{ "force_auto_logon", snap.ForceAutoLogon ?? string.Empty },
{ "force_auto_logon_enabled", forceAutoLogonEnabled },
{ "autologon_enabled", autologonEnabled },
{ "default_user_name", snap.DefaultUserName ?? string.Empty },
{ "default_user_name", ReduceUserName(snap.DefaultUserName) },
{ "default_domain_name", snap.DefaultDomainName ?? string.Empty },
{ "alt_default_user_name", snap.AltDefaultUserName ?? string.Empty },
{ "alt_default_user_name", ReduceUserName(snap.AltDefaultUserName) },
{ "alt_default_domain_name", snap.AltDefaultDomainName ?? string.Empty },
{ "auto_logon_sid", snap.AutoLogonSid ?? string.Empty },
{ "auto_logon_count", snap.AutoLogonCount.HasValue ? (object)snap.AutoLogonCount.Value : string.Empty },
@@ -244,6 +245,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
// Small fail-soft helpers.
// -----------------------------------------------------------------------
/// <summary>
/// A sign-in address is reported as its domain only. Local account names (kiosk,
/// defaultuser0) and the provisioning placeholders stay readable.
/// </summary>
internal static string ReduceUserName(string? userName)
{
if (userName is not string name || name.Length == 0) return string.Empty;
if (name.IndexOf('@') < 0 || AadJoinInfo.IsPlaceholderUserEmail(name)) return name;
return "[redacted]@" + AadJoinInfo.ExtractUserDomain(name);
}
/// <summary>AutoLogon flags are REG_SZ "0"/"1"; treat "1"/"true" (any case) as enabled.</summary>
private static bool IsTruthy(string? value)
{
@@ -640,53 +640,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
{
try
{
var data = new Dictionary<string, object>();
Dictionary<string, object> data;
// Read AAD join info via shared helper. Placeholder users (foouser@, autopilot@)
// are transient provisioning accounts and must NOT be treated as a real AAD join;
// HasAadJoinedUser is only set for real user emails.
if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser))
if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser)
&& !string.IsNullOrEmpty(thumbprint))
{
// Still need TenantId — open the subkey directly when we have a thumbprint.
if (!string.IsNullOrEmpty(thumbprint))
// Still need TenantId — open the subkey directly.
string tenantId;
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
using (var subKey = joinInfoKey?.OpenSubKey(thumbprint))
{
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
using (var subKey = joinInfoKey?.OpenSubKey(thumbprint))
{
data["tenantId"] = subKey?.GetValue("TenantId")?.ToString();
}
data["userEmail"] = userEmail;
data["joinType"] = "Azure AD Joined";
data["thumbprint"] = thumbprint;
if (!string.IsNullOrWhiteSpace(userEmail))
{
if (isPlaceholderUser)
{
IsFooUserDetected = true;
data["isFooUser"] = true;
_logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join");
}
else
{
HasAadJoinedUser = true;
}
}
tenantId = subKey?.GetValue("TenantId")?.ToString();
}
else
data = BuildAadJoinedData(tenantId, userEmail, thumbprint, isPlaceholderUser);
if (!string.IsNullOrWhiteSpace(userEmail))
{
data["joinType"] = "Not Joined";
if (isPlaceholderUser)
{
IsFooUserDetected = true;
_logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join");
}
else
{
HasAadJoinedUser = true;
}
}
}
else
{
data["joinType"] = "Not Joined";
data = new Dictionary<string, object> { ["joinType"] = "Not Joined" };
}
object joinTypeValue;
var joinType = data.TryGetValue("joinType", out joinTypeValue) ? joinTypeValue?.ToString() ?? "Unknown" : "Unknown";
EmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, $"AAD join: {joinType}", data);
EmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, $"AAD join: {data["joinType"]}", data);
}
catch (Exception ex)
{
@@ -694,6 +684,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
}
}
/// <summary>
/// The <c>aad_join_status</c> payload of a joined device. The signed-in address is
/// reported as its domain only.
/// </summary>
internal static Dictionary<string, object> BuildAadJoinedData(
string tenantId, string userEmail, string thumbprint, bool isPlaceholderUser)
{
var data = new Dictionary<string, object>
{
["tenantId"] = tenantId,
["joinType"] = "Azure AD Joined",
["thumbprint"] = thumbprint,
};
if (!string.IsNullOrWhiteSpace(userEmail))
{
data["userDomain"] = AadJoinInfo.ExtractUserDomain(userEmail);
if (isPlaceholderUser)
data["isFooUser"] = true;
}
return data;
}
/// <summary>
/// Reads ESP skip configuration from the enrollment FirstSync registry key via the
/// shared <see cref="EspSkipConfigurationProbe"/> (also used by
@@ -78,7 +78,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
// Don't log the full email in decision signals — user PII. Keep domain only
// for audit trail; full email lives only in local agent logs if at all.
["userDomain"] = ExtractDomain(userEmail),
["userDomain"] = AadJoinInfo.ExtractUserDomain(userEmail),
["hasThumbprint"] = string.IsNullOrEmpty(thumbprint) ? "false" : "true",
// The AadJoinWatcher fires AadUserJoined ONLY when isPlaceholderUser==false
// (placeholder accounts go via the separate PlaceholderUserDetected event).
@@ -137,7 +137,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
var placeholderType = ClassifyPlaceholder(userEmail);
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["userDomain"] = ExtractDomain(userEmail),
["userDomain"] = AadJoinInfo.ExtractUserDomain(userEmail),
["placeholderType"] = placeholderType,
["registryKey"] = @"HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo",
};
@@ -154,13 +154,6 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
occurredAtUtc: _clock.UtcNow);
}
private static string ExtractDomain(string email)
{
if (string.IsNullOrEmpty(email)) return "unknown";
var at = email.IndexOf('@');
return at >= 0 && at + 1 < email.Length ? email.Substring(at + 1) : "unknown";
}
private static string ClassifyPlaceholder(string email)
{
if (string.IsNullOrEmpty(email)) return "unknown";
@@ -27,7 +27,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string RuleId = "ANALYZE-CUSTOM-777";
private const string TemplateRuleId = "ANALYZE-SEC-004";
private const string TemplateRuleId = "ANALYZE-SEC-006";
private sealed class FakeHttpResponseData : HttpResponseData
{
@@ -177,7 +177,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
var template = new AnalyzeRule
{
RuleId = TemplateRuleId,
Title = "AutoLogon enabled for an unexpected user",
Title = "Unexpected Provisioning Package (Custom Allow-List)",
Description = "Template",
Severity = "warning",
Category = "security",
@@ -186,7 +186,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
Author = "Autopilot Monitor",
Conditions = new List<RuleCondition>
{
new() { Signal = "autologon", Source = "event_type", EventType = "autologon_analysis", Operator = "equals", Value = "PLACEHOLDER", Required = true }
new() { Signal = "unexpected_package", Source = "event_type", EventType = "provisioning_package_scan", Operator = "equals", Value = "PLACEHOLDER", Required = true }
},
TemplateVariables = new List<TemplateVariable>
{
@@ -194,7 +194,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
},
};
var (function, stored) = BuildFunction(globalRule: template);
var req = BuildRequest(AlicePrincipal(), new { variables = new Dictionary<string, string> { ["allowed"] = "KIOSK-1" } });
var req = BuildRequest(AlicePrincipal(), new { variables = new Dictionary<string, string> { ["allowed"] = "Contoso.Recovery" } });
var response = await function.CreateFromTemplate(req, TemplateRuleId);
@@ -111,17 +111,12 @@ public class BuiltInRulesTests
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);
// SEC-004 (kiosk allow-list template) was removed. The ID stays retired, and no surviving
// rule may match on the AutoLogon user name: the event reports a sign-in address as its
// domain only, so an exact-match list no longer fits that field.
Assert.DoesNotContain(rules, r => r.RuleId == "ANALYZE-SEC-004");
Assert.DoesNotContain(rules, r =>
r.Conditions != null && r.Conditions.Any(c => c.DataField == "checks.default_user_name"));
}
[Fact]
@@ -68,7 +68,7 @@ public class RuleSubmissionServiceTests
MarkSessionAsFailed = true,
Notify = true,
NotifyChannelIds = new List<string> { "chan-1" },
DerivedFromTemplateRuleId = "ANALYZE-SEC-004",
DerivedFromTemplateRuleId = "ANALYZE-SEC-006",
Explanation = "The PAC file could not be fetched.",
Conditions = new List<RuleCondition>
{
@@ -89,7 +89,7 @@ public class RuleSubmissionServiceTests
Assert.Equal("ANALYZE-CUSTOM-003", node["ruleId"]!.GetValue<string>());
Assert.Equal("Alice Admin", node["author"]!.GetValue<string>());
Assert.Equal("ANALYZE-SEC-004", node["derivedFromTemplateRuleId"]!.GetValue<string>());
Assert.Equal("ANALYZE-SEC-006", node["derivedFromTemplateRuleId"]!.GetValue<string>());
Assert.Single(node["conditions"]!.AsArray());
}
@@ -430,8 +430,8 @@ public class RuleSubmissionServiceTests
Assert.Null(s.ContactEmail);
Assert.Equal("Proxy PAC unreachable", s.Title);
Assert.Equal("network", s.Category);
Assert.Equal("ANALYZE-SEC-004", s.DerivedFromTemplateRuleId);
Assert.Contains(s.ValidationFindings, f => f.Level == "info" && f.Message.Contains("ANALYZE-SEC-004"));
Assert.Equal("ANALYZE-SEC-006", s.DerivedFromTemplateRuleId);
Assert.Contains(s.ValidationFindings, f => f.Level == "info" && f.Message.Contains("ANALYZE-SEC-006"));
Assert.DoesNotContain(s.ValidationFindings, f => f.Level == "error");
Assert.Equal(5, s.SourceFireStats!.FireCount);
Assert.Equal(50, s.SourceFireStats.SessionsEvaluated);
@@ -27,14 +27,14 @@ public class RuleSubmissionsWireParityTests
AttributionName = "Community contribution",
SubmittedAt = T0,
Status = "approved",
ValidationFindings = new List<RuleSubmissionFinding> { new() { Level = "info", Message = "Derived from template ANALYZE-SEC-004" } },
ValidationFindings = new List<RuleSubmissionFinding> { new() { Level = "info", Message = "Derived from template ANALYZE-SEC-006" } },
SourceFireStats = new RuleSubmissionFireStats { Days = 30, FireCount = 42, SessionsEvaluated = 310, EvaluationCount = 312 },
ReviewedBy = "ga@example.com",
ReviewedAt = T0.AddDays(1),
ReviewComment = "Taken.",
WillBeAdapted = true,
PublishedRuleId = "ANALYZE-NET-002",
DerivedFromTemplateRuleId = "ANALYZE-SEC-004",
DerivedFromTemplateRuleId = "ANALYZE-SEC-006",
};
private static object ItemLiteral() => new
@@ -54,14 +54,14 @@ public class RuleSubmissionsWireParityTests
attributionName = "Community contribution",
submittedAt = T0,
status = "approved",
validationFindings = new List<object> { new { level = "info", message = "Derived from template ANALYZE-SEC-004" } },
validationFindings = new List<object> { new { level = "info", message = "Derived from template ANALYZE-SEC-006" } },
sourceFireStats = new { days = 30, fireCount = 42, sessionsEvaluated = 310, evaluationCount = 312 },
reviewedBy = "ga@example.com",
reviewedAt = T0.AddDays(1),
reviewComment = "Taken.",
willBeAdapted = true,
publishedRuleId = "ANALYZE-NET-002",
derivedFromTemplateRuleId = "ANALYZE-SEC-004",
derivedFromTemplateRuleId = "ANALYZE-SEC-006",
};
[Fact]
@@ -33,7 +33,7 @@ public class TableRuleSubmissionRepositoryTests
Status = RuleSubmissionStatuses.Approved,
ValidationFindings = new List<RuleSubmissionFinding>
{
new() { Level = "info", Message = "Derived from template ANALYZE-SEC-004" },
new() { Level = "info", Message = "Derived from template ANALYZE-SEC-006" },
},
SourceFireStats = new RuleSubmissionFireStats { Days = 30, FireCount = 42, SessionsEvaluated = 310, EvaluationCount = 312 },
ReviewedBy = "ga@example.com",
@@ -41,7 +41,7 @@ public class TableRuleSubmissionRepositoryTests
ReviewComment = "Taken, generalised for the community.",
WillBeAdapted = true,
PublishedRuleId = "ANALYZE-NET-002",
DerivedFromTemplateRuleId = "ANALYZE-SEC-004",
DerivedFromTemplateRuleId = "ANALYZE-SEC-006",
};
[Fact]
@@ -1009,9 +1009,9 @@ namespace AutopilotMonitor.Shared
// raw facts only (Winlogon registry indicators; DefaultPassword presence-only, never the
// value) at Info severity. AutoLogon-enabled alone is NOT graded — it is the fingerprint
// of Windows' own ESP auto-logon on every normal Autopilot enrollment. Backend analyze-
// rules grade only the unambiguous cases: a plaintext DefaultPassword on disk (ANALYZE-
// SEC-003, escalated) and an optional kiosk allow-list template (ANALYZE-SEC-004, off by
// default). AutoLogon may be legitimate (kiosk) OR an enrollment/OOBE manipulation vector.
// rules grade only the unambiguous case: a plaintext DefaultPassword on disk (ANALYZE-
// SEC-003, escalated). AutoLogon may be legitimate (kiosk) OR an enrollment/OOBE
// manipulation vector.
public const string AutoLogonAnalysis = "autologon_analysis";
// OOBE-console / Shift+F10 detection (per-tenant opt-OUT, AnalyzerConfiguration.
// EnableConsoleBypassDetection, default ON). Two complementary Warning signals and one count:
@@ -187,7 +187,7 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [
{ value: "local_admin_analysis", label: "local_admin_analysis", category: "diagnostics",
description: "Local administrator / user-profile audit detecting pre-enrollment admin account creation (an Autopilot bypass technique); emitted at startup and shutdown for delta detection." },
{ value: "autologon_analysis", label: "autologon_analysis", category: "diagnostics",
description: "Winlogon AutoLogon facts (raw, Info-only): AutoAdminLogon/ForceAutoLogon, default user/domain, AutoLogon count, and presence-only of a plaintext DefaultPassword. AutoLogon-enabled alone is not graded (Windows' own ESP auto-logon looks identical on every normal enrollment); backend analyze-rules grade only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) plus an optional kiosk allow-list template (ANALYZE-SEC-004). AutoLogon can be a legitimate kiosk or an enrollment/OOBE manipulation vector." },
description: "Winlogon AutoLogon facts (raw, Info-only): AutoAdminLogon/ForceAutoLogon, default user/domain (a sign-in address as its domain only), AutoLogon count, and presence-only of a plaintext DefaultPassword. AutoLogon-enabled alone is not graded (Windows' own ESP auto-logon looks identical on every normal enrollment); backend analyze-rules grade only a plaintext DefaultPassword on disk (ANALYZE-SEC-003). AutoLogon can be a legitimate kiosk or an enrollment/OOBE manipulation vector." },
{ value: "gather_result", label: "gather_result", category: "diagnostics",
description: "Output of another gather rule (can be chained)." },
{ value: "provisioning_package_scan", label: "provisioning_package_scan", category: "diagnostics",