mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-12 12:43:46 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6191831bd | |||
| ccea1dacfe | |||
| 33e614da5d |
+1
-1
Submodule internal updated: ae04903859...fea540d819
@@ -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
@@ -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",
|
||||
|
||||
+35
@@ -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)]
|
||||
|
||||
+43
-1
@@ -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
|
||||
{
|
||||
|
||||
+11
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-3
@@ -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)
|
||||
{
|
||||
|
||||
+47
-33
@@ -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";
|
||||
|
||||
+4
-4
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user