diff --git a/rules/analyze/ANALYZE-SEC-002.json b/rules/analyze/ANALYZE-SEC-002.json new file mode 100644 index 00000000..a02d7be5 --- /dev/null +++ b/rules/analyze/ANALYZE-SEC-002.json @@ -0,0 +1,69 @@ +{ + "$schema": "../schema/analyze-rule.schema.json", + "ruleId": "ANALYZE-SEC-002", + "title": "AutoLogon enabled on device", + "description": "Fires when the AutoLogon analyzer reports an active automatic sign-in (AutoAdminLogon or ForceAutoLogon set in Winlogon). AutoLogon can be a legitimate kiosk or shared-device configuration, but it is also a classic way to automate enrollment or OOBE, so an active AutoLogon during Autopilot is worth surfacing.", + "severity": "warning", + "category": "security", + "version": "1.0.0", + "enabled": true, + "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": "sysinternals_suspected", + "source": "event_data", + "eventType": "autologon_analysis", + "dataField": "checks.sysinternals_autologon_suspected", + "operator": "equals", + "value": "true", + "required": false + } + ], + "confidenceFactors": [ + { "signal": "sysinternals_suspected", "condition": "exists", "weight": 10 } + ], + "confidenceThreshold": 40, + "explanation": "This device has **automatic sign-in (AutoLogon) enabled** in the registry (`HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon`). The `AutoAdminLogon` and/or `ForceAutoLogon` value is set, so Windows will log a user in without prompting for credentials.\n\nThis is legitimate for kiosk and shared-device scenarios, but it is also a common technique to automate enrollment or OOBE. If AutoLogon was not part of your intended deployment, treat it as a possible manipulation: review the configured default user, the source that wrote the keys (provisioning script, Win32 app, or the Sysinternals Autologon tool), and whether the account is more privileged than the kiosk use case requires.\n\n**Sysinternals Autologon:** when the evidence includes `sysinternals_suspected`, the AutoLogon password is stored as an encrypted **LSA secret** instead of a plaintext `DefaultPassword` registry value — the signature of the Sysinternals Autologon tool (AutoLogon enabled, a default user set, and no registry password). That is a deliberate, tool-assisted setup: still legitimate for an approved kiosk, but a stronger indicator of intent than a flag left behind by accident (the rule raises its confidence accordingly).\n\nReview the `autologon_analysis` event in the session timeline for the exact Winlogon values (default user / domain, AutoLogon count, whether a Sysinternals LSA-secret password is suspected).", + "remediation": [ + { + "title": "Confirm AutoLogon is intended", + "steps": [ + "Open the autologon_analysis event in the session timeline and review the default user/domain and AutoLogon flags", + "Confirm whether AutoLogon is part of an approved kiosk / shared-device deployment", + "If unexpected, identify what wrote the Winlogon keys (provisioning script, Win32 app, or Sysinternals Autologon)" + ] + }, + { + "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" + ] + }, + { + "title": "Run deliberate kiosks? Use the allow-list instead", + "steps": [ + "If AutoLogon is an approved kiosk configuration, configure the template rule ANALYZE-SEC-004 with your expected kiosk account name(s)", + "Then disable this rule (ANALYZE-SEC-002) so approved kiosk AutoLogon stays quiet while AutoLogon for unexpected users still warns" + ] + } + ], + "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" + } + ], + "tags": ["security", "autologon", "winlogon", "kiosk", "credentials"] +} diff --git a/rules/analyze/ANALYZE-SEC-003.json b/rules/analyze/ANALYZE-SEC-003.json new file mode 100644 index 00000000..236c7cf4 --- /dev/null +++ b/rules/analyze/ANALYZE-SEC-003.json @@ -0,0 +1,49 @@ +{ + "$schema": "../schema/analyze-rule.schema.json", + "ruleId": "ANALYZE-SEC-003", + "title": "AutoLogon plaintext password stored in registry", + "description": "Fires when the AutoLogon analyzer reports a DefaultPassword value present in Winlogon. A plaintext AutoLogon password stored in the registry is readable by any local administrator and is a credential exposure — more severe than AutoLogon alone (the Sysinternals Autologon tool deliberately avoids this by storing the secret in the LSA instead).", + "severity": "high", + "category": "security", + "version": "1.0.0", + "enabled": true, + "trigger": "single", + "baseConfidence": 90, + "conditions": [ + { + "signal": "default_password_present", + "source": "event_type", + "eventType": "autologon_analysis", + "dataField": "checks.default_password_present", + "operator": "equals", + "value": "true", + "required": true + } + ], + "confidenceThreshold": 40, + "explanation": "This device has a **plaintext AutoLogon password stored in the registry** (`DefaultPassword` under `HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon`). This value is readable by any local administrator and exposes the AutoLogon account's credentials in clear text.\n\nEven when AutoLogon itself is intended (e.g. a kiosk), the password should never be left as a registry value. The Sysinternals Autologon tool stores the secret as an encrypted LSA secret precisely to avoid this; a plaintext `DefaultPassword` usually means a script or manual edit wrote the credentials directly.\n\nNote: the agent only reports the *presence* of this value — it never reads or transmits the password itself.", + "remediation": [ + { + "title": "Remove the plaintext password", + "steps": [ + "Delete the DefaultPassword value under HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon", + "If AutoLogon is still required, reconfigure it with the Sysinternals Autologon tool so the secret is stored as an encrypted LSA secret rather than a registry value", + "Rotate the exposed account's password, since it may have been readable on disk" + ] + }, + { + "title": "Reduce blast radius", + "steps": [ + "Verify the AutoLogon account is the least-privileged account that satisfies the use case (not a domain or local admin where avoidable)", + "For kiosk scenarios, prefer Assigned Access / kiosk profiles over a raw AutoLogon credential" + ] + } + ], + "relatedDocs": [ + { + "title": "Sysinternals Autologon (encrypted LSA-secret password storage)", + "url": "https://learn.microsoft.com/en-us/sysinternals/downloads/autologon" + } + ], + "tags": ["security", "autologon", "winlogon", "credentials", "plaintext-password"] +} diff --git a/rules/analyze/ANALYZE-SEC-004.json b/rules/analyze/ANALYZE-SEC-004.json new file mode 100644 index 00000000..83de6651 --- /dev/null +++ b/rules/analyze/ANALYZE-SEC-004.json @@ -0,0 +1,81 @@ +{ + "$schema": "../schema/analyze-rule.schema.json", + "ruleId": "ANALYZE-SEC-004", + "title": "AutoLogon enabled for an unexpected user", + "description": "Template variant of the AutoLogon warning with a kiosk allow-list. 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: set your allowed account name(s), then disable the broad ANALYZE-SEC-002 so deliberate kiosk AutoLogon stops raising a warning while unexpected AutoLogon still does.", + "severity": "warning", + "category": "security", + "version": "1.0.0", + "enabled": false, + "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 + }, + { + "signal": "sysinternals_suspected", + "source": "event_data", + "eventType": "autologon_analysis", + "dataField": "checks.sysinternals_autologon_suspected", + "operator": "equals", + "value": "true", + "required": false + } + ], + "confidenceFactors": [ + { "signal": "sysinternals_suspected", "condition": "exists", "weight": 10 } + ], + "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 — letting you keep deliberate kiosk AutoLogon quiet while still surfacing unexpected automatic sign-in.\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`), then disable the broad **ANALYZE-SEC-002 — AutoLogon enabled on device** rule so it does not also fire on your approved kiosk devices.\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" + } + ], + "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" + } + ] +} diff --git a/rules/dist/analyze-rules.json b/rules/dist/analyze-rules.json index 531b3b78..0bd9c096 100644 --- a/rules/dist/analyze-rules.json +++ b/rules/dist/analyze-rules.json @@ -1433,6 +1433,228 @@ "compliance" ] }, + { + "ruleId": "ANALYZE-SEC-002", + "title": "AutoLogon enabled on device", + "description": "Fires when the AutoLogon analyzer reports an active automatic sign-in (AutoAdminLogon or ForceAutoLogon set in Winlogon). AutoLogon can be a legitimate kiosk or shared-device configuration, but it is also a classic way to automate enrollment or OOBE, so an active AutoLogon during Autopilot is worth surfacing.", + "severity": "warning", + "category": "security", + "version": "1.0.0", + "enabled": true, + "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": "sysinternals_suspected", + "source": "event_data", + "eventType": "autologon_analysis", + "dataField": "checks.sysinternals_autologon_suspected", + "operator": "equals", + "value": "true", + "required": false + } + ], + "confidenceFactors": [ + { + "signal": "sysinternals_suspected", + "condition": "exists", + "weight": 10 + } + ], + "confidenceThreshold": 40, + "explanation": "This device has **automatic sign-in (AutoLogon) enabled** in the registry (`HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon`). The `AutoAdminLogon` and/or `ForceAutoLogon` value is set, so Windows will log a user in without prompting for credentials.\n\nThis is legitimate for kiosk and shared-device scenarios, but it is also a common technique to automate enrollment or OOBE. If AutoLogon was not part of your intended deployment, treat it as a possible manipulation: review the configured default user, the source that wrote the keys (provisioning script, Win32 app, or the Sysinternals Autologon tool), and whether the account is more privileged than the kiosk use case requires.\n\n**Sysinternals Autologon:** when the evidence includes `sysinternals_suspected`, the AutoLogon password is stored as an encrypted **LSA secret** instead of a plaintext `DefaultPassword` registry value — the signature of the Sysinternals Autologon tool (AutoLogon enabled, a default user set, and no registry password). That is a deliberate, tool-assisted setup: still legitimate for an approved kiosk, but a stronger indicator of intent than a flag left behind by accident (the rule raises its confidence accordingly).\n\nReview the `autologon_analysis` event in the session timeline for the exact Winlogon values (default user / domain, AutoLogon count, whether a Sysinternals LSA-secret password is suspected).", + "remediation": [ + { + "title": "Confirm AutoLogon is intended", + "steps": [ + "Open the autologon_analysis event in the session timeline and review the default user/domain and AutoLogon flags", + "Confirm whether AutoLogon is part of an approved kiosk / shared-device deployment", + "If unexpected, identify what wrote the Winlogon keys (provisioning script, Win32 app, or Sysinternals Autologon)" + ] + }, + { + "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" + ] + }, + { + "title": "Run deliberate kiosks? Use the allow-list instead", + "steps": [ + "If AutoLogon is an approved kiosk configuration, configure the template rule ANALYZE-SEC-004 with your expected kiosk account name(s)", + "Then disable this rule (ANALYZE-SEC-002) so approved kiosk AutoLogon stays quiet while AutoLogon for unexpected users still warns" + ] + } + ], + "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" + } + ], + "tags": [ + "security", + "autologon", + "winlogon", + "kiosk", + "credentials" + ] + }, + { + "ruleId": "ANALYZE-SEC-003", + "title": "AutoLogon plaintext password stored in registry", + "description": "Fires when the AutoLogon analyzer reports a DefaultPassword value present in Winlogon. A plaintext AutoLogon password stored in the registry is readable by any local administrator and is a credential exposure — more severe than AutoLogon alone (the Sysinternals Autologon tool deliberately avoids this by storing the secret in the LSA instead).", + "severity": "high", + "category": "security", + "version": "1.0.0", + "enabled": true, + "trigger": "single", + "baseConfidence": 90, + "conditions": [ + { + "signal": "default_password_present", + "source": "event_type", + "eventType": "autologon_analysis", + "dataField": "checks.default_password_present", + "operator": "equals", + "value": "true", + "required": true + } + ], + "confidenceThreshold": 40, + "explanation": "This device has a **plaintext AutoLogon password stored in the registry** (`DefaultPassword` under `HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon`). This value is readable by any local administrator and exposes the AutoLogon account's credentials in clear text.\n\nEven when AutoLogon itself is intended (e.g. a kiosk), the password should never be left as a registry value. The Sysinternals Autologon tool stores the secret as an encrypted LSA secret precisely to avoid this; a plaintext `DefaultPassword` usually means a script or manual edit wrote the credentials directly.\n\nNote: the agent only reports the *presence* of this value — it never reads or transmits the password itself.", + "remediation": [ + { + "title": "Remove the plaintext password", + "steps": [ + "Delete the DefaultPassword value under HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon", + "If AutoLogon is still required, reconfigure it with the Sysinternals Autologon tool so the secret is stored as an encrypted LSA secret rather than a registry value", + "Rotate the exposed account's password, since it may have been readable on disk" + ] + }, + { + "title": "Reduce blast radius", + "steps": [ + "Verify the AutoLogon account is the least-privileged account that satisfies the use case (not a domain or local admin where avoidable)", + "For kiosk scenarios, prefer Assigned Access / kiosk profiles over a raw AutoLogon credential" + ] + } + ], + "relatedDocs": [ + { + "title": "Sysinternals Autologon (encrypted LSA-secret password storage)", + "url": "https://learn.microsoft.com/en-us/sysinternals/downloads/autologon" + } + ], + "tags": [ + "security", + "autologon", + "winlogon", + "credentials", + "plaintext-password" + ] + }, + { + "ruleId": "ANALYZE-SEC-004", + "title": "AutoLogon enabled for an unexpected user", + "description": "Template variant of the AutoLogon warning with a kiosk allow-list. 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: set your allowed account name(s), then disable the broad ANALYZE-SEC-002 so deliberate kiosk AutoLogon stops raising a warning while unexpected AutoLogon still does.", + "severity": "warning", + "category": "security", + "version": "1.0.0", + "enabled": false, + "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 + }, + { + "signal": "sysinternals_suspected", + "source": "event_data", + "eventType": "autologon_analysis", + "dataField": "checks.sysinternals_autologon_suspected", + "operator": "equals", + "value": "true", + "required": false + } + ], + "confidenceFactors": [ + { + "signal": "sysinternals_suspected", + "condition": "exists", + "weight": 10 + } + ], + "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 — letting you keep deliberate kiosk AutoLogon quiet while still surfacing unexpected automatic sign-in.\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`), then disable the broad **ANALYZE-SEC-002 — AutoLogon enabled on device** rule so it does not also fire on your approved kiosk devices.\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" + } + ], + "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": "Provisioning Package Detected", diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/AutoLogonAnalyzerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/AutoLogonAnalyzerTests.cs new file mode 100644 index 00000000..d9e00be5 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/AutoLogonAnalyzerTests.cs @@ -0,0 +1,218 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.Shared; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers +{ + /// + /// AutoLogonAnalyzer — pure classification (BuildPayload) plus emission contract. The analyzer + /// reports raw Winlogon facts at Info severity; the Warning/escalation grading lives in backend + /// analyze-rules (ANALYZE-SEC-002/003), so these tests assert facts, not severity judgements. + /// + public sealed class AutoLogonAnalyzerTests + { + private static AgentLogger NewLogger(string dir) => new AgentLogger(dir, AgentLogLevel.Info); + + private static Dictionary Checks(Dictionary payload) + => (Dictionary)payload["checks"]; + + // -------------------------------------------------------------- BuildPayload classification + + [Fact] + public void BuildPayload_empty_snapshot_reports_no_autologon() + { + var data = AutoLogonAnalyzer.BuildPayload(new AutoLogonSnapshot(), "shutdown"); + + Assert.Equal("no_autologon", data["finding"]); + Assert.Equal("info", data["severity"]); + Assert.Equal("shutdown", data["triggered_at"]); + Assert.Empty((List)data["findings"]); + + var checks = Checks(data); + Assert.False((bool)checks["autologon_enabled"]); + Assert.False((bool)checks["default_password_present"]); + Assert.False((bool)checks["default_identity_present"]); + } + + [Fact] + public void BuildPayload_auto_admin_logon_set_marks_autologon_active() + { + var snap = new AutoLogonSnapshot { WinlogonKeyPresent = true, AutoAdminLogon = "1" }; + var data = AutoLogonAnalyzer.BuildPayload(snap, "device_setup_complete"); + + Assert.Equal("autologon_active", data["finding"]); + Assert.Equal("device_setup_complete", data["triggered_at"]); + Assert.Contains("auto_admin_logon_enabled", (List)data["findings"]); + + var checks = Checks(data); + Assert.True((bool)checks["auto_admin_logon_enabled"]); + Assert.True((bool)checks["autologon_enabled"]); + } + + [Fact] + public void BuildPayload_force_auto_logon_alone_enables_autologon() + { + var snap = new AutoLogonSnapshot { WinlogonKeyPresent = true, ForceAutoLogon = "1" }; + var data = AutoLogonAnalyzer.BuildPayload(snap, "shutdown"); + + var checks = Checks(data); + Assert.False((bool)checks["auto_admin_logon_enabled"]); + Assert.True((bool)checks["force_auto_logon_enabled"]); + Assert.True((bool)checks["autologon_enabled"]); + Assert.Equal("autologon_active", data["finding"]); + } + + [Fact] + public void BuildPayload_plaintext_password_is_primary_finding_and_value_never_emitted() + { + var snap = new AutoLogonSnapshot + { + WinlogonKeyPresent = true, + AutoAdminLogon = "1", + DefaultUserName = "kioskuser", + DefaultPasswordPresent = true, + }; + var data = AutoLogonAnalyzer.BuildPayload(snap, "shutdown"); + + Assert.Equal("plaintext_password_present", data["finding"]); + Assert.Contains("default_password_present", (List)data["findings"]); + + var checks = Checks(data); + Assert.True((bool)checks["default_password_present"]); + // Presence-only: the password value must never appear anywhere in the payload. + Assert.False(checks.ContainsKey("default_password")); + Assert.DoesNotContain("DefaultPassword", checks.Keys); + // A plaintext password means it is NOT the Sysinternals (LSA-secret) shape. + Assert.False((bool)checks["sysinternals_autologon_suspected"]); + } + + [Fact] + public void BuildPayload_sysinternals_heuristic_when_enabled_user_set_no_reg_password() + { + var snap = new AutoLogonSnapshot + { + WinlogonKeyPresent = true, + AutoAdminLogon = "1", + DefaultUserName = "kioskuser", + DefaultPasswordPresent = false, + }; + var data = AutoLogonAnalyzer.BuildPayload(snap, "shutdown"); + + var checks = Checks(data); + Assert.True((bool)checks["sysinternals_autologon_suspected"]); + Assert.Contains("sysinternals_autologon_suspected", (List)data["findings"]); + } + + [Fact] + public void BuildPayload_default_identity_only_is_benign_baseline() + { + var snap = new AutoLogonSnapshot + { + WinlogonKeyPresent = true, + DefaultUserName = "alice", + DefaultDomainName = "CONTOSO", + }; + var data = AutoLogonAnalyzer.BuildPayload(snap, "shutdown"); + + Assert.Equal("default_identity_present", data["finding"]); + var checks = Checks(data); + Assert.False((bool)checks["autologon_enabled"]); + Assert.True((bool)checks["default_identity_present"]); + Assert.Equal("alice", checks["default_user_name"]); + Assert.Equal("CONTOSO", checks["default_domain_name"]); + } + + [Theory] + [InlineData("1", true)] + [InlineData("true", true)] + [InlineData("TRUE", true)] + [InlineData("0", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void BuildPayload_interprets_auto_admin_logon_flag(string? raw, bool expectedEnabled) + { + var snap = new AutoLogonSnapshot { WinlogonKeyPresent = true, AutoAdminLogon = raw }; + var data = AutoLogonAnalyzer.BuildPayload(snap, "shutdown"); + Assert.Equal(expectedEnabled, (bool)Checks(data)["auto_admin_logon_enabled"]); + } + + // -------------------------------------------------------------- Emission contract + + [Fact] + public void RunScan_via_override_emits_single_info_autologon_event_with_trigger() + { + using var tmp = new TempDirectory(); + var sink = new FakeSignalIngressSink(); + var post = new InformationalEventPost(sink, new VirtualClock(new DateTime(2026, 6, 6, 12, 0, 0, DateTimeKind.Utc))); + var sut = new AutoLogonAnalyzer("S1", "T1", post, NewLogger(tmp.Path)) + { + SnapshotOverride = new AutoLogonSnapshot { WinlogonKeyPresent = true, AutoAdminLogon = "1", DefaultUserName = "kioskuser" }, + }; + + sut.AnalyzeAtDeviceSetupComplete(); + + var events = sink.Posted.Where(p => p.Payload != null + && p.Payload.TryGetValue("eventType", out var et) && et == Constants.EventTypes.AutoLogonAnalysis).ToList(); + Assert.Single(events); + + var data = Assert.IsType>(events[0].TypedPayload); + Assert.Equal("device_setup_complete", data["triggered_at"]); + Assert.Equal("info", data["severity"]); + Assert.Equal("autologon_active", data["finding"]); + } + + [Fact] + public void AnalyzeAtStartup_is_a_no_op_and_emits_nothing() + { + using var tmp = new TempDirectory(); + var sink = new FakeSignalIngressSink(); + var post = new InformationalEventPost(sink, new VirtualClock(new DateTime(2026, 6, 6, 12, 0, 0, DateTimeKind.Utc))); + var sut = new AutoLogonAnalyzer("S1", "T1", post, NewLogger(tmp.Path)) + { + SnapshotOverride = new AutoLogonSnapshot { WinlogonKeyPresent = true, AutoAdminLogon = "1" }, + }; + + sut.AnalyzeAtStartup(); + + Assert.Empty(sink.Posted); + } + + [Fact] + public void AnalyzeAtShutdown_reads_real_registry_fail_soft_and_emits_one_event() + { + using var tmp = new TempDirectory(); + var sink = new FakeSignalIngressSink(); + var post = new InformationalEventPost(sink, new VirtualClock(new DateTime(2026, 6, 6, 12, 0, 0, DateTimeKind.Utc))); + var sut = new AutoLogonAnalyzer("S1", "T1", post, NewLogger(tmp.Path)); + + var ex = Record.Exception(() => sut.AnalyzeAtShutdown()); + Assert.Null(ex); + + var events = sink.Posted.Where(p => p.Payload != null + && p.Payload.TryGetValue("eventType", out var et) && et == Constants.EventTypes.AutoLogonAnalysis).ToList(); + Assert.Single(events); + var data = Assert.IsType>(events[0].TypedPayload); + Assert.Equal("shutdown", data["triggered_at"]); + } + + [Fact] + public void Ctor_rejects_null_required_dependencies() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp.Path); + var post = new InformationalEventPost(new FakeSignalIngressSink(), new VirtualClock(new DateTime(2026, 6, 6, 12, 0, 0, DateTimeKind.Utc))); + Assert.Throws(() => new AutoLogonAnalyzer(null!, "T1", post, logger)); + Assert.Throws(() => new AutoLogonAnalyzer("S1", null!, post, logger)); + Assert.Throws(() => new AutoLogonAnalyzer("S1", "T1", null!, logger)); + Assert.Throws(() => new AutoLogonAnalyzer("S1", "T1", post, null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/AgentAnalyzerManagerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/AgentAnalyzerManagerTests.cs index cc382408..1ae54e4c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/AgentAnalyzerManagerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/AgentAnalyzerManagerTests.cs @@ -33,7 +33,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime new InformationalEventPost(new FakeSignalIngressSink(), new VirtualClock(new DateTime(2026, 4, 23, 10, 0, 0, DateTimeKind.Utc))); [Fact] - public void Initialize_respects_toggle_all_off_registers_no_analyzers() + public void Initialize_with_all_toggles_off_still_registers_always_on_AutoLogon() { using var tmp = new TempDirectory(); var sut = new AgentAnalyzerManager( @@ -46,7 +46,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime }); sut.Initialize(); - Assert.Empty(sut.Analyzers); + // AutoLogonAnalyzer has no toggle — it is always registered. + var names = sut.Analyzers.Select(a => a.Name).ToList(); + Assert.Equal(new[] { "AutoLogonAnalyzer" }, names); } [Fact] @@ -67,7 +69,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime Assert.Contains("LocalAdminAnalyzer", names); Assert.DoesNotContain("SoftwareInventoryAnalyzer", names); Assert.Contains("IntegrityBypassAnalyzer", names); - Assert.Equal(2, sut.Analyzers.Count); + // AutoLogonAnalyzer is always-on, in addition to the two enabled toggled analyzers. + Assert.Contains("AutoLogonAnalyzer", names); + Assert.Equal(3, sut.Analyzers.Count); } [Fact] @@ -81,6 +85,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime var names = sut.Analyzers.Select(a => a.Name).ToList(); Assert.Contains("LocalAdminAnalyzer", names); Assert.Contains("IntegrityBypassAnalyzer", names); + Assert.Contains("AutoLogonAnalyzer", names); Assert.DoesNotContain("SoftwareInventoryAnalyzer", names); } @@ -180,7 +185,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime } [Fact] - public void RunShutdown_is_safe_on_empty_analyzer_list() + public void RunShutdown_is_safe_with_only_the_always_on_AutoLogon_analyzer() { using var tmp = new TempDirectory(); var sut = new AgentAnalyzerManager(Config(), NewLogger(tmp.Path), NewPost(), @@ -194,5 +199,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime var ex = Record.Exception(() => sut.RunShutdown()); Assert.Null(ex); } + + [Fact] + public void RunDeviceSetupCompleteAutoLogonCheck_emits_one_device_setup_autologon_event() + { + using var tmp = new TempDirectory(); + var sink = new FakeSignalIngressSink(); + var post = new InformationalEventPost(sink, + new VirtualClock(new DateTime(2026, 6, 6, 12, 0, 0, DateTimeKind.Utc))); + var sut = new AgentAnalyzerManager(Config(), NewLogger(tmp.Path), post, + new AnalyzerConfiguration + { + EnableLocalAdminAnalyzer = false, + EnableSoftwareInventoryAnalyzer = false, + EnableIntegrityBypassAnalyzer = false, + }); + + sut.RunDeviceSetupCompleteAutoLogonCheck(); + + var events = sink.Posted.Where(p => p.Payload != null + && p.Payload.TryGetValue("eventType", out var et) + && et == AutopilotMonitor.Shared.Constants.EventTypes.AutoLogonAnalysis).ToList(); + Assert.Single(events); + var data = Assert.IsType>(events[0].TypedPayload); + Assert.Equal("device_setup_complete", data["triggered_at"]); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/AutoLogonAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/AutoLogonAnalyzer.cs new file mode 100644 index 00000000..bc271b8b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/AutoLogonAnalyzer.cs @@ -0,0 +1,293 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers +{ + /// + /// Reads the Winlogon AutoLogon configuration and emits a single + /// event carrying the raw facts. + /// + /// AutoLogon (HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon) is a classic + /// way to automate sign-in. It can be legitimate (kiosk / shared-device deployment) OR a + /// manipulation vector to automate enrollment / OOBE. The Sysinternals Autologon tool + /// stores the password as an LSA secret rather than the plaintext DefaultPassword value; + /// we detect that heuristically (AutoLogon enabled + a default user but no registry password) + /// WITHOUT reading the LSA secret. + /// + /// + /// This analyzer reports facts ONLY, always at . It never reads + /// or emits the DefaultPassword value — only its presence. Whether an AutoLogon is a + /// problem is judged by backend analyze-rules (ANALYZE-SEC-002 = AutoLogon active → Warning, + /// ANALYZE-SEC-003 = plaintext DefaultPassword on disk → escalated). + /// + /// + /// Trigger: NOT at agent start — the Winlogon keys are written later by a provisioning + /// script / app / kiosk config, so an at-start read inspects an empty key. Runs instead at + /// DeviceSetup-phase completion (device_setup_complete) and again at final shutdown + /// (shutdown), giving the backend a device-phase vs user-phase delta. + /// + /// + public sealed class AutoLogonAnalyzer : IAgentAnalyzer + { + internal const string SourceName = "AutoLogonAnalyzer"; + + private const string WinlogonSubKey = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"; + + private readonly string _sessionId; + private readonly string _tenantId; + private readonly InformationalEventPost _post; + private readonly AgentLogger _logger; + + // Test seam: when set, the analyzer uses this snapshot instead of reading the registry. + // Production never assigns it. Surfaced to the test assembly via InternalsVisibleTo. + internal AutoLogonSnapshot? SnapshotOverride { get; set; } + + public AutoLogonAnalyzer( + string sessionId, + string tenantId, + InformationalEventPost post, + AgentLogger logger) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _post = post ?? throw new ArgumentNullException(nameof(post)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public string Name => SourceName; + + /// + /// Startup is a deliberate no-op: the AutoLogon keys are written later during enrollment, + /// so a read here would only ever inspect an empty Winlogon key. + /// + public void AnalyzeAtStartup() + { + _logger.Debug($"{Name}: Startup no-op (AutoLogon keys are written later during enrollment)."); + } + + /// Final-shutdown scan — captures any AutoLogon written during the user phase. + public void AnalyzeAtShutdown() + { + _logger.Info($"{Name}: Running shutdown AutoLogon scan"); + RunScan("shutdown"); + } + + /// + /// DeviceSetup-phase-completion scan — captures any AutoLogon injected by a device-targeted + /// provisioning script / app before the user phase. Invoked by the device-setup trigger. + /// + public void AnalyzeAtDeviceSetupComplete() + { + _logger.Info($"{Name}: Running device-setup-complete AutoLogon scan"); + RunScan("device_setup_complete"); + } + + // ----------------------------------------------------------------------- + // Core scan + // ----------------------------------------------------------------------- + + private void RunScan(string trigger) + { + try + { + var snapshot = SnapshotOverride ?? ReadWinlogon(); + var data = BuildPayload(snapshot, trigger); + + var autologonEnabled = data.TryGetValue("checks", out var c) + && c is Dictionary checks + && checks.TryGetValue("autologon_enabled", out var ae) + && ae is bool b && b; + + var passwordPresent = snapshot.DefaultPasswordPresent; + var message = autologonEnabled || passwordPresent + ? $"{Name}: AutoLogon indicators detected ({string.Join(",", (List)data["findings"])})" + : $"{Name}: No AutoLogon configured"; + + _post.Emit(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.AutoLogonAnalysis, + // Raw facts only — severity is always Info; backend analyze-rules grade it. + Severity = EventSeverity.Info, + Source = Name, + Phase = EnrollmentPhase.Unknown, + Message = message, + Data = data, + ImmediateUpload = true, + }); + } + catch (Exception ex) + { + _logger.Error($"{Name}: AutoLogon scan failed unexpectedly", ex); + } + } + + /// + /// Reads the Winlogon AutoLogon values. Forced Registry64 view — AnyCPU net48 may resolve + /// to 32-bit and silently read the stale WOW6432Node mirror (same rationale as the PPKG + /// collector and TenantIdResolver). Fail-soft: probe errors are captured, never thrown. + /// + internal AutoLogonSnapshot ReadWinlogon() + { + var snap = new AutoLogonSnapshot(); + try + { + using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64)) + using (var key = hklm.OpenSubKey(WinlogonSubKey, writable: false)) + { + if (key == null) + { + snap.Errors.Add("winlogon_key_missing"); + return snap; + } + + snap.WinlogonKeyPresent = true; + snap.AutoAdminLogon = ReadString(key, "AutoAdminLogon"); + snap.ForceAutoLogon = ReadString(key, "ForceAutoLogon"); + snap.DefaultUserName = ReadString(key, "DefaultUserName"); + snap.DefaultDomainName = ReadString(key, "DefaultDomainName"); + snap.AltDefaultUserName = ReadString(key, "AltDefaultUserName"); + snap.AltDefaultDomainName = ReadString(key, "AltDefaultDomainName"); + snap.AutoLogonSid = ReadString(key, "AutoLogonSID"); + snap.AutoLogonCount = ReadInt(key, "AutoLogonCount"); + // Presence only — detected via value-NAME enumeration so the credential value + // is never pulled into process memory (GetValue would retrieve it). The value + // itself is never read or emitted; only the fact that it exists. + snap.DefaultPasswordPresent = key.GetValueNames() + .Any(n => string.Equals(n, "DefaultPassword", StringComparison.OrdinalIgnoreCase)); + } + } + catch (Exception ex) + { + snap.Errors.Add($"registry:{ex.GetType().Name}: {ex.Message}"); + _logger.Warning($"{Name}: Failed to read Winlogon registry: {ex.Message}"); + } + return snap; + } + + // ----------------------------------------------------------------------- + // Pure aggregation — no IO, unit-tested directly. + // ----------------------------------------------------------------------- + + internal static Dictionary BuildPayload(AutoLogonSnapshot snap, string trigger) + { + bool autoAdminLogonEnabled = IsTruthy(snap.AutoAdminLogon); + bool forceAutoLogonEnabled = IsTruthy(snap.ForceAutoLogon); + bool autologonEnabled = autoAdminLogonEnabled || forceAutoLogonEnabled; + bool defaultIdentityPresent = + !string.IsNullOrEmpty(snap.DefaultUserName) || !string.IsNullOrEmpty(snap.DefaultDomainName); + // Sysinternals Autologon stores the password as an LSA secret, leaving AutoLogon enabled + // with a default user but NO plaintext DefaultPassword in the registry. + bool sysinternalsSuspected = + autologonEnabled && !string.IsNullOrEmpty(snap.DefaultUserName) && !snap.DefaultPasswordPresent; + + // Factual indicator labels (no judgement — severity is the rule's job). + var findings = new List(); + if (autoAdminLogonEnabled) findings.Add("auto_admin_logon_enabled"); + if (forceAutoLogonEnabled) findings.Add("force_auto_logon_enabled"); + if (snap.DefaultPasswordPresent) findings.Add("default_password_present"); + if (sysinternalsSuspected) findings.Add("sysinternals_autologon_suspected"); + if (defaultIdentityPresent) findings.Add("default_identity_present"); + + // Primary label, most-notable-first (still factual). + string finding = + snap.DefaultPasswordPresent ? "plaintext_password_present" + : autologonEnabled ? "autologon_active" + : defaultIdentityPresent ? "default_identity_present" + : "no_autologon"; + + var checks = new Dictionary + { + { "winlogon_key_present", snap.WinlogonKeyPresent }, + { "auto_admin_logon", snap.AutoAdminLogon ?? string.Empty }, + { "auto_admin_logon_enabled", autoAdminLogonEnabled }, + { "force_auto_logon", snap.ForceAutoLogon ?? string.Empty }, + { "force_auto_logon_enabled", forceAutoLogonEnabled }, + { "autologon_enabled", autologonEnabled }, + { "default_user_name", snap.DefaultUserName ?? string.Empty }, + { "default_domain_name", snap.DefaultDomainName ?? string.Empty }, + { "alt_default_user_name", snap.AltDefaultUserName ?? string.Empty }, + { "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 }, + { "default_password_present", snap.DefaultPasswordPresent }, + { "sysinternals_autologon_suspected", sysinternalsSuspected }, + { "default_identity_present", defaultIdentityPresent }, + }; + + return new Dictionary + { + { "severity", "info" }, + { "finding", finding }, + { "findings", findings }, + { "triggered_at", trigger }, + { "enrollment_phase_at_check", EnrollmentPhase.Unknown.ToString() }, + { "checks", checks }, + { "scan_errors", snap.Errors.ToList() }, + }; + } + + // ----------------------------------------------------------------------- + // Small fail-soft helpers. + // ----------------------------------------------------------------------- + + /// AutoLogon flags are REG_SZ "0"/"1"; treat "1"/"true" (any case) as enabled. + private static bool IsTruthy(string? value) + { + if (value is not string raw || string.IsNullOrWhiteSpace(raw)) return false; + var v = raw.Trim(); + return v == "1" || v.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + private static string? ReadString(RegistryKey key, string valueName) + { + try + { + var v = key.GetValue(valueName); + if (v == null) return null; + var s = v.ToString(); + return string.IsNullOrEmpty(s) ? null : s; + } + catch { return null; } + } + + private static int? ReadInt(RegistryKey key, string valueName) + { + try + { + var v = key.GetValue(valueName); + if (v == null) return null; + return Convert.ToInt32(v, CultureInfo.InvariantCulture); + } + catch { return null; } + } + } + + // -------------------------------------------------------------------------------------- + // Raw scan model (internal; surfaced to the test assembly via InternalsVisibleTo). + // -------------------------------------------------------------------------------------- + + internal sealed class AutoLogonSnapshot + { + public bool WinlogonKeyPresent { get; set; } + public string? AutoAdminLogon { get; set; } + public string? ForceAutoLogon { get; set; } + public string? DefaultUserName { get; set; } + public string? DefaultDomainName { get; set; } + public string? AltDefaultUserName { get; set; } + public string? AltDefaultDomainName { get; set; } + public string? AutoLogonSid { get; set; } + public int? AutoLogonCount { get; set; } + public bool DefaultPasswordPresent { get; set; } + public List Errors { get; } = new List(); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/AutoLogonDeviceSetupTrigger.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/AutoLogonDeviceSetupTrigger.cs new file mode 100644 index 00000000..cdc9566b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/AutoLogonDeviceSetupTrigger.cs @@ -0,0 +1,76 @@ +#nullable enable +using System; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Subscribes to and + /// triggers a single AutoLogon scan when the ESP device phase completes — the first point at + /// which a device-targeted provisioning script / app could have written the Winlogon AutoLogon + /// keys. The eventual final shutdown re-scans (via AgentAnalyzerManager.RunShutdown) to + /// capture any AutoLogon written during the user phase. + /// + /// Lifecycle: Constructor subscribes; unsubscribes. Single-fire — + /// a defensive guards against duplicate + /// DeviceSetupProvisioningComplete emissions (the tracker fires at most once today, but + /// the guard makes the contract explicit). + /// + /// + /// Action injection: calls a caller-supplied rather than holding a + /// direct reference to AgentAnalyzerManager — keeps the trigger trivially testable and + /// avoids a Core→Runtime coupling. Production wires + /// () => analyzerManager.RunDeviceSetupCompleteAutoLogonCheck(). Mirrors + /// . + /// + /// + internal sealed class AutoLogonDeviceSetupTrigger : IDisposable + { + private readonly IDeviceSetupCompletedSource _host; + private readonly Action _onTrigger; + private readonly AgentLogger _logger; + + private int _fired; // 0 = not yet fired, 1 = fired + private int _disposed; // 0 = live, 1 = disposed + + public AutoLogonDeviceSetupTrigger( + IDeviceSetupCompletedSource host, + Action onTrigger, + AgentLogger logger) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _onTrigger = onTrigger ?? throw new ArgumentNullException(nameof(onTrigger)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _host.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete; + } + + private void OnDeviceSetupProvisioningComplete(object? sender, EventArgs e) + { + if (Interlocked.Exchange(ref _fired, 1) == 1) + { + _logger.Info("AutoLogonDeviceSetupTrigger: duplicate DeviceSetupProvisioningComplete ignored (already fired)"); + return; + } + + _logger.Info("AutoLogonDeviceSetupTrigger: DeviceSetupProvisioningComplete observed — running AutoLogon scan"); + + try + { + _onTrigger(); + } + catch (Exception ex) + { + // Trigger action failures must not propagate into the tracker's event loop. + _logger.Error("AutoLogonDeviceSetupTrigger: trigger action threw", ex); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + try { _host.DeviceSetupProvisioningComplete -= OnDeviceSetupProvisioningComplete; } catch { } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs index ae5005fb..6d144538 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs @@ -8,7 +8,7 @@ using AutopilotMonitor.DecisionCore.Engine; namespace AutopilotMonitor.Agent.V2.Core.Orchestration { - internal sealed class EspAndHelloHost : ICollectorHost, IWhiteGloveCompletedSource + internal sealed class EspAndHelloHost : ICollectorHost, IWhiteGloveCompletedSource, IDeviceSetupCompletedSource { public string Name => "EspAndHelloTracker"; @@ -28,6 +28,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// public event EventHandler? WhiteGloveCompleted; + /// + /// Forwarded from the internal + /// event so external components (e.g. AutoLogonDeviceSetupTrigger) can react to the + /// end of the ESP device phase without reaching into the (private) tracker. Fires once when + /// DeviceSetup provisioning resolves with success (or the fallback confirmed). + /// + public event EventHandler? DeviceSetupProvisioningComplete; + /// /// Session 080edee9 follow-up + Codex review (P2/P3, 2026-05-28) — forwards /// . Read by @@ -71,6 +79,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds); _tracker.WhiteGloveCompleted += OnTrackerWhiteGloveCompleted; + _tracker.DeviceSetupProvisioningComplete += OnTrackerDeviceSetupProvisioningComplete; _adapter = new EspAndHelloTrackerAdapter(_tracker, ingress, clock); } @@ -80,10 +89,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration private void OnTrackerWhiteGloveCompleted(object sender, EventArgs e) => WhiteGloveCompleted?.Invoke(this, e); + private void OnTrackerDeviceSetupProvisioningComplete(object sender, EventArgs e) + => DeviceSetupProvisioningComplete?.Invoke(this, e); + public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 1) return; try { _tracker.WhiteGloveCompleted -= OnTrackerWhiteGloveCompleted; } catch { } + try { _tracker.DeviceSetupProvisioningComplete -= OnTrackerDeviceSetupProvisioningComplete; } catch { } try { _adapter.Dispose(); } catch { } try { _tracker.Dispose(); } catch { } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/IDeviceSetupCompletedSource.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/IDeviceSetupCompletedSource.cs new file mode 100644 index 00000000..b9264145 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/IDeviceSetupCompletedSource.cs @@ -0,0 +1,21 @@ +#nullable enable +using System; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Surface for components that observe the DeviceSetup-phase completion signal and need to + /// forward it to dependents that don't share the host's lifecycle directly. Implemented by + /// in production and by tiny test stubs in + /// unit tests. + /// + internal interface IDeviceSetupCompletedSource + { + /// + /// Fires once when DeviceSetup provisioning resolves with success (or the fallback + /// confirmed) — i.e. the device phase of the ESP is done. Fire-once upstream + /// (ProvisioningStatusTracker guards against duplicate emissions). + /// + event EventHandler? DeviceSetupProvisioningComplete; + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/AgentAnalyzerManager.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/AgentAnalyzerManager.cs index c049d67c..d68db4f5 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/AgentAnalyzerManager.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/AgentAnalyzerManager.cs @@ -95,6 +95,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime _logger.Info("IntegrityBypassAnalyzer disabled by remote config"); } + // AutoLogon analyzer is ALWAYS registered (no remote-config toggle): it reports raw + // Winlogon facts at Info severity and the grading lives in backend analyze-rules. Its + // startup run is a no-op; it fires at DeviceSetup-phase completion and at final shutdown. + _analyzers.Add(new AutoLogonAnalyzer( + _configuration.SessionId, + _configuration.TenantId, + _post, + _logger)); + _logger.Info("AutoLogonAnalyzer registered (always-on)"); + _logger.Info($"Analyzers initialized: {_analyzers.Count} active"); } @@ -149,6 +159,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime } } + /// + /// Runs ONLY the at DeviceSetup-phase completion. Triggered + /// by AutoLogonDeviceSetupTrigger when the agent observes + /// DeviceSetupProvisioningComplete. Captures any AutoLogon injected by a + /// device-targeted provisioning script / app before the user phase; the eventual final + /// shutdown re-scans via to capture user-phase changes. + /// + public void RunDeviceSetupCompleteAutoLogonCheck() + { + if (!_initialised) Initialize(); + if (_analyzers.Count == 0) return; + + foreach (var analyzer in _analyzers) + { + if (analyzer is not AutoLogonAnalyzer autoLogonAnalyzer) continue; + + try + { + _logger.Info("DeviceSetup complete — running AutoLogonAnalyzer.AnalyzeAtDeviceSetupComplete()"); + autoLogonAnalyzer.AnalyzeAtDeviceSetupComplete(); + } + catch (Exception ex) + { + _logger.Error("AutoLogonAnalyzer threw during DeviceSetup-complete check", ex); + } + return; // AutoLogonAnalyzer is registered at most once + } + + _logger.Warning("DeviceSetup-complete AutoLogon check requested but AutoLogonAnalyzer is not registered"); + } + /// /// Runs ONLY the shutdown snapshot tagged with /// whiteGlovePart=1. Triggered by WhiteGloveInventoryTrigger when the diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs index 3a7a6dbc..cbba6297 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs @@ -148,6 +148,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime // at step 14) and analyzerManager (created in the onIngressReady hook at step 13b) // are both live. Disposed in the same finally block as the rest of the lifecycle. WhiteGloveInventoryTrigger whiteGloveInventoryTrigger = null; + AutoLogonDeviceSetupTrigger autoLogonDeviceSetupTrigger = null; // Single-rail refactor (plan §5.1) — lifecycle events flow through // InformationalEventPost. The post is constructed inside the @@ -452,10 +453,19 @@ namespace AutopilotMonitor.Agent.V2.Runtime host: componentFactory.EspAndHelloHost, onTrigger: () => analyzerManager?.RunWhiteGlovePart1InventorySnapshot(), logger: logger); + + // AutoLogon scan at DeviceSetup-phase completion — first point at which + // a device-targeted provisioning script/app could write the Winlogon + // AutoLogon keys. Final shutdown re-scans via RunShutdown for user-phase + // changes. Same host + lifecycle as the WG inventory trigger above. + autoLogonDeviceSetupTrigger = new AutoLogonDeviceSetupTrigger( + host: componentFactory.EspAndHelloHost, + onTrigger: () => analyzerManager?.RunDeviceSetupCompleteAutoLogonCheck(), + logger: logger); } else { - logger.Warning("WhiteGloveInventoryTrigger not wired — componentFactory.EspAndHelloHost is null after orchestrator.Start."); + logger.Warning("WhiteGloveInventoryTrigger / AutoLogonDeviceSetupTrigger not wired — componentFactory.EspAndHelloHost is null after orchestrator.Start."); } // WhiteGlove Part-2 resume: EnrollmentOrchestrator.Start (PR-A) detected @@ -619,6 +629,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime try { whiteGloveInventoryTrigger?.Dispose(); } catch (Exception ex) { logger.Warning($"WhiteGloveInventoryTrigger.Dispose threw: {ex.Message}"); } + try { autoLogonDeviceSetupTrigger?.Dispose(); } + catch (Exception ex) { logger.Warning($"AutoLogonDeviceSetupTrigger.Dispose threw: {ex.Message}"); } + // L4 gap-closure (2026-05-15): defensive fallback emit for any path // that returned to finally without claiming the gate — e.g. a future // direct shutdown.Set() from a server-action handler that bypasses diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index cdf11a8d..b4f71605 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -926 +938 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 93aa9a4f..19ef72cd 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1415 +1419 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/BuiltInRulesTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/BuiltInRulesTests.cs index 103532e4..b0ccdd54 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/BuiltInRulesTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/BuiltInRulesTests.cs @@ -86,6 +86,48 @@ public class BuiltInRulesTests } } + [Fact] + public void BuiltInAnalyzeRules_AutoLogon_rules_have_expected_shape() + { + var rules = BuiltInAnalyzeRules.GetAll(); + + // SEC-002: broad AutoLogon warning, default-on, NOT a template. + var sec002 = rules.FirstOrDefault(r => r.RuleId == "ANALYZE-SEC-002"); + Assert.NotNull(sec002); + Assert.True(sec002!.Enabled, "ANALYZE-SEC-002 should be enabled by default"); + Assert.Equal("warning", sec002.Severity); + Assert.True(sec002.TemplateVariables == null || sec002.TemplateVariables.Count == 0); + Assert.Contains(sec002.Conditions, c => c.DataField == "checks.autologon_enabled"); + + // The sysinternals confidence factor must be backed by a real (non-required) condition + // carrying the same signal — otherwise the `exists` factor can never match (dead factor). + var sec002SysFactor = sec002.ConfidenceFactors?.FirstOrDefault(f => f.Signal == "sysinternals_suspected"); + Assert.NotNull(sec002SysFactor); + Assert.Contains(sec002.Conditions, c => + c.Signal == "sysinternals_suspected" + && c.DataField == "checks.sysinternals_autologon_suspected" + && !c.Required); + + // SEC-003: plaintext password, default-on, escalated. + 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() { diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index 34d961ad..0118400e 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -137,6 +137,7 @@ export const EVENT_TYPES_CATALOG = { security_events: [ 'integrity_bypass_analysis', 'local_admin_analysis', + 'autologon_analysis', 'security_warning', 'security_audit', 'provisioning_package_scan', diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index ce26c46d..ae2dbb20 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -408,6 +408,12 @@ namespace AutopilotMonitor.Shared // cannot iterate the aggregate scan event's packages[] array). Drives the PPKG // analyze rules incl. the per-tenant allow-list template (not_regex on `identity`). public const string ProvisioningPackageDetected = "provisioning_package_detected"; + // AutoLogon scan — runs at DeviceSetup-phase completion and at final shutdown. Reports + // raw facts only (Winlogon registry indicators; DefaultPassword presence-only, never the + // value) at Info severity. Backend analyze-rules (ANALYZE-SEC-002/003) judge the facts: + // an active AutoLogon is a Warning, a plaintext DefaultPassword on disk is escalated. + // AutoLogon may be legitimate (kiosk deployment) OR an enrollment/OOBE manipulation vector. + public const string AutoLogonAnalysis = "autologon_analysis"; // Termination / diagnostics / server actions public const string EnrollmentSummaryShown = "enrollment_summary_shown"; diff --git a/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentInternals.tsx b/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentInternals.tsx index 516da008..4abba1bd 100644 --- a/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentInternals.tsx +++ b/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentInternals.tsx @@ -1016,6 +1016,7 @@ export function SectionAgentInternals() { ["configmgr_client_detected", "EnrollmentTracker", "ConfigMgr co-management detected"], ["local_admin_analysis", "LocalAdminAnalyzer", "Local admin audit"], ["software_inventory_analysis", "SoftwareInventoryAnalyzer", "Software inventory + delta"], + ["autologon_analysis", "AutoLogonAnalyzer", "Winlogon AutoLogon facts (kiosk / manipulation)"], ["security_warning", "GatherRuleCollectors", "Security finding from gather rule"], ]} /> diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts index 618c42d6..a5ca4307 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts +++ b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts @@ -104,6 +104,12 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [ description: "Aggregated vulnerability scan finding." }, { value: "software_inventory_analysis", label: "software_inventory_analysis", category: "diagnostics", description: "Software inventory snapshot with high-confidence findings." }, + { value: "integrity_bypass_analysis", label: "integrity_bypass_analysis", category: "diagnostics", + description: "Windows 11 install-time integrity-bypass audit (LabConfig TPM/SecureBoot/CPU/RAM/Disk bypass keys, MoSetup upgrade bypass, per-user PCHC eligibility, suspicious SetupComplete/ErrorHandler scripts)." }, + { 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. Backend analyze-rules (ANALYZE-SEC-002/003/004) grade it; 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",