mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(agent): AutoLogon detector + analyze rules (V2)
V2-only AutoLogonAnalyzer that reads the Winlogon AutoLogon configuration and emits an autologon_analysis event with raw facts at Info severity. AutoLogon can be a legitimate kiosk/shared-device setup or a way to automate enrollment/OOBE; the agent reports facts only and never reads the DefaultPassword value (presence detected via value-name enumeration). Backend analyze-rules grade the facts. - AutoLogonAnalyzer (always-on, no tenant toggle): startup no-op, runs at DeviceSetup-phase completion (new AutoLogonDeviceSetupTrigger on EspAndHelloHost.DeviceSetupProvisioningComplete) and at final shutdown. - Sysinternals Autologon heuristic (AutoLogon enabled + default user + no registry password = LSA-secret credential); never reads the LSA secret. - Analyze rules: ANALYZE-SEC-002 (AutoLogon enabled -> warning, default-on), ANALYZE-SEC-003 (plaintext DefaultPassword -> high), ANALYZE-SEC-004 (kiosk allow-list template). Sysinternals graded via a real confidence condition inside SEC-002/004 so the allow-list still suppresses approved kiosks. - autologon_analysis added to all event-type catalogs; rule-builder picker also gains the previously-missing integrity_bypass_analysis + local_admin_analysis. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+222
@@ -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",
|
||||
|
||||
+218
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class AutoLogonAnalyzerTests
|
||||
{
|
||||
private static AgentLogger NewLogger(string dir) => new AgentLogger(dir, AgentLogLevel.Info);
|
||||
|
||||
private static Dictionary<string, object> Checks(Dictionary<string, object> payload)
|
||||
=> (Dictionary<string, object>)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<string>)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<string>)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<string>)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<string>)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<Dictionary<string, object>>(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<Dictionary<string, object>>(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<ArgumentNullException>(() => new AutoLogonAnalyzer(null!, "T1", post, logger));
|
||||
Assert.Throws<ArgumentNullException>(() => new AutoLogonAnalyzer("S1", null!, post, logger));
|
||||
Assert.Throws<ArgumentNullException>(() => new AutoLogonAnalyzer("S1", "T1", null!, logger));
|
||||
Assert.Throws<ArgumentNullException>(() => new AutoLogonAnalyzer("S1", "T1", post, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-4
@@ -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<System.Collections.Generic.Dictionary<string, object>>(events[0].TypedPayload);
|
||||
Assert.Equal("device_setup_complete", data["triggered_at"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+293
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the Winlogon AutoLogon configuration and emits a single
|
||||
/// <see cref="Constants.EventTypes.AutoLogonAnalysis"/> event carrying the raw facts.
|
||||
/// <para>
|
||||
/// AutoLogon (<c>HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon</c>) 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 <c>Autologon</c> tool
|
||||
/// stores the password as an LSA secret rather than the plaintext <c>DefaultPassword</c> value;
|
||||
/// we detect that heuristically (AutoLogon enabled + a default user but no registry password)
|
||||
/// WITHOUT reading the LSA secret.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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-002 = AutoLogon active → Warning,
|
||||
/// ANALYZE-SEC-003 = plaintext DefaultPassword on disk → escalated).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Trigger</b>: 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 (<c>device_setup_complete</c>) and again at final shutdown
|
||||
/// (<c>shutdown</c>), giving the backend a device-phase vs user-phase delta.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void AnalyzeAtStartup()
|
||||
{
|
||||
_logger.Debug($"{Name}: Startup no-op (AutoLogon keys are written later during enrollment).");
|
||||
}
|
||||
|
||||
/// <summary>Final-shutdown scan — captures any AutoLogon written during the user phase.</summary>
|
||||
public void AnalyzeAtShutdown()
|
||||
{
|
||||
_logger.Info($"{Name}: Running shutdown AutoLogon scan");
|
||||
RunScan("shutdown");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string, object> 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<string>)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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string, object> 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<string>();
|
||||
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<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>AutoLogon flags are REG_SZ "0"/"1"; treat "1"/"true" (any case) as enabled.</summary>
|
||||
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<string> Errors { get; } = new List<string>();
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Subscribes to <see cref="IDeviceSetupCompletedSource.DeviceSetupProvisioningComplete"/> 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 <c>AgentAnalyzerManager.RunShutdown</c>) to
|
||||
/// capture any AutoLogon written during the user phase.
|
||||
/// <para>
|
||||
/// <b>Lifecycle:</b> Constructor subscribes; <see cref="Dispose"/> unsubscribes. Single-fire —
|
||||
/// a defensive <see cref="Interlocked.Exchange(ref int, int)"/> guards against duplicate
|
||||
/// <c>DeviceSetupProvisioningComplete</c> emissions (the tracker fires at most once today, but
|
||||
/// the guard makes the contract explicit).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Action injection:</b> calls a caller-supplied <see cref="Action"/> rather than holding a
|
||||
/// direct reference to <c>AgentAnalyzerManager</c> — keeps the trigger trivially testable and
|
||||
/// avoids a Core→Runtime coupling. Production wires
|
||||
/// <c>() => analyzerManager.RunDeviceSetupCompleteAutoLogonCheck()</c>. Mirrors
|
||||
/// <see cref="WhiteGloveInventoryTrigger"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// </summary>
|
||||
public event EventHandler? WhiteGloveCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// Forwarded from the internal <see cref="EspAndHelloTracker.DeviceSetupProvisioningComplete"/>
|
||||
/// event so external components (e.g. <c>AutoLogonDeviceSetupTrigger</c>) 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).
|
||||
/// </summary>
|
||||
public event EventHandler? DeviceSetupProvisioningComplete;
|
||||
|
||||
/// <summary>
|
||||
/// Session 080edee9 follow-up + Codex review (P2/P3, 2026-05-28) — forwards
|
||||
/// <see cref="EspAndHelloTracker.LastEspTerminalFailure"/>. 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 { }
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="EspAndHelloHost"/> in production and by tiny test stubs in
|
||||
/// <see cref="AutoLogonDeviceSetupTrigger"/> unit tests.
|
||||
/// </summary>
|
||||
internal interface IDeviceSetupCompletedSource
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// (<c>ProvisioningStatusTracker</c> guards against duplicate emissions).
|
||||
/// </summary>
|
||||
event EventHandler? DeviceSetupProvisioningComplete;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs ONLY the <see cref="AutoLogonAnalyzer"/> at DeviceSetup-phase completion. Triggered
|
||||
/// by <c>AutoLogonDeviceSetupTrigger</c> when the agent observes
|
||||
/// <c>DeviceSetupProvisioningComplete</c>. Captures any AutoLogon injected by a
|
||||
/// device-targeted provisioning script / app before the user phase; the eventual final
|
||||
/// shutdown re-scans via <see cref="RunShutdown"/> to capture user-phase changes.
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs ONLY the <see cref="SoftwareInventoryAnalyzer"/> shutdown snapshot tagged with
|
||||
/// <c>whiteGlovePart=1</c>. Triggered by <c>WhiteGloveInventoryTrigger</c> when the
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
926
|
||||
938
|
||||
|
||||
@@ -1 +1 @@
|
||||
1415
|
||||
1419
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"],
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user