mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
fix(security): LocalAdminAnalyzer inventories disabled accounts and reads Administrators-group membership from the SAM (NetLocalGroupGetMembers on S-1-5-32-544); a dormant /active:no admin backdoor now surfaces as unexpected_admin_members (+40, Error), built-in Administrator enabled-state reported for evidence
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Local Admin Analyzer — account inventory, dormant accounts and Administrators membership
|
||||
description: What the local_admin_analysis event asserts about a device at enrollment start and completion, why disabled accounts are inventoried (a /active:no backdoor re-enabled after enrollment is dormant at both scans), how Administrators-group membership is read from the SAM by well-known SID (NetLocalGroupGetMembers, independent of WMI and account state), which members are scored versus only listed, and why the built-in Administrator's enabled state is reported but not scored.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs
|
||||
tags:
|
||||
- agent
|
||||
- security
|
||||
- local-admin
|
||||
- analyze-rules
|
||||
- bypass-detection
|
||||
timestamp: 2026-08-30T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Local Admin Analyzer
|
||||
|
||||
`LocalAdminAnalyzer` emits one `local_admin_analysis` event at agent startup and one at
|
||||
shutdown. The pair is a before/after inventory of local accounts on the device; the backend
|
||||
rule `ANALYZE-ID-002` alerts when the finding is `unexpected_local_admins_detected`.
|
||||
The threat model is the classic Autopilot bypass: a person with a Shift+F10 SYSTEM console
|
||||
during OOBE/ESP who creates a persistent local administrator.
|
||||
|
||||
# Schema
|
||||
|
||||
## Checks
|
||||
|
||||
| Check | Source | What it yields |
|
||||
|---|---|---|
|
||||
| `bypass_nro` | `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE\BypassNRO` | value + `flagged` |
|
||||
| Local accounts | WMI `Win32_UserAccount WHERE LocalAccount = True` (Name, Disabled, SID) | `accounts_checked`, `account_details` (name / disabled / administrators_member), `unexpected_accounts`, `builtin_administrator_enabled` |
|
||||
| Administrators membership | `NetLocalGroupGetMembers` level 2 on the group resolved from `S-1-5-32-544` (`LocalGroupNativeMethods`) | `administrators_group.{enumerated,error_code,members}`, `unexpected_admin_members` |
|
||||
| Profile folders | `C:\Users` top-level directories | `profiles_found`, `unexpected_profiles` |
|
||||
|
||||
## Allowed list
|
||||
|
||||
Built-in names (`Administrator`, `Guest`, `DefaultAccount`, `WDAGUtilityAccount`,
|
||||
`defaultuser0/1/2`, the non-account folders `Public`, `Default`, `Default User`, `All Users`)
|
||||
plus the tenant's *Allowed Local Accounts* (glob `*`/`?`, case-insensitive). At shutdown the
|
||||
logged-in users (explorer.exe owners outside session 0) are added dynamically.
|
||||
`defaultuser1/2` stay allowlisted deliberately: they do appear during OOBE enrollment.
|
||||
|
||||
## Evaluation (`EvaluateAccounts`, pure)
|
||||
|
||||
* Every local account is evaluated, **disabled ones included**; the disabled state is reported
|
||||
per account instead of excluding the account.
|
||||
* An account is an Administrators member when its SID matches a group member, or — SID
|
||||
unavailable — its name matches a member of the local machine domain.
|
||||
* `unexpected_accounts` = accounts not matching the allowed list (any state).
|
||||
* `unexpected_admin_members` = unexpected accounts holding Administrators membership, plus
|
||||
local-domain members not on the allowed list that WMI did not return at all (WMI failure
|
||||
or provider gap — the SAM is authoritative for membership).
|
||||
* Members outside the machine domain (Entra role SIDs such as `S-1-12-1-…` on Entra-joined
|
||||
devices, domain groups on hybrid devices, unresolved/deleted SIDs) are **listed for delta
|
||||
comparison but never scored** — they are expected on joined devices.
|
||||
* `builtin_administrator_enabled` = `!Disabled` of the RID-500 account (null when absent).
|
||||
Reported, not scored: whether OOBE enables it transiently is not established; the field
|
||||
collects evidence first.
|
||||
|
||||
## Scoring
|
||||
|
||||
| Signal | Weight |
|
||||
|---|---|
|
||||
| `BypassNRO = 1` | +20 |
|
||||
| any `unexpected_accounts` | +40 |
|
||||
| any `unexpected_admin_members` | +40 |
|
||||
| unexpected account with a matching `C:\Users` folder | +40 |
|
||||
|
||||
Capped at 100. `0` → Info `no_unexpected_admins_detected`; `<40` → Info
|
||||
`bypass_nro_flag_only`; `40–79` → Warning, `≥80` → Error, both
|
||||
`unexpected_local_admins_detected`. A dormant backdoor with admin membership therefore lands
|
||||
at 80 (Error) without ever having a profile folder.
|
||||
|
||||
## Failure behaviour
|
||||
|
||||
WMI and the SAM read are independent; either failing is logged at Warning with the native
|
||||
status (`NET_API_STATUS=…`) and the other source still evaluates. The event carries
|
||||
`administrators_group.enumerated=false` + `error_code` so a "no members" result is never
|
||||
mistaken for an empty group.
|
||||
|
||||
# Examples
|
||||
|
||||
`net user backdoor P@ss /add /active:no` + `net localgroup Administrators backdoor /add`
|
||||
during OOBE, re-enabled by a scheduled task after enrollment: at both scans the account is
|
||||
disabled; before this change it was skipped entirely. Now `unexpected_accounts=[backdoor]`,
|
||||
`unexpected_admin_members=[backdoor]`, `account_details[backdoor].disabled=true` →
|
||||
confidence 80, Error.
|
||||
|
||||
`net localgroup Administrators adm-helpdesk /add` where `adm-*` is allowlisted: not flagged
|
||||
(the allowed list is the tenant's statement of intent), but `administrators_group.members`
|
||||
shows the new membership in the shutdown event for delta review.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/LocalGroupNativeMethods.cs`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerEvaluateAccountsTests.cs`
|
||||
* `rules/analyze/ANALYZE-ID-002.json`
|
||||
@@ -31,6 +31,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [CMTrace Time Resolution](agent/cmtrace-time-resolution.md) - Converting bias-less CMTrace local times to UTC without trusting any process's timezone belief: per-line self-anchoring on the 15-minute offset grid for provably fresh lines, uniform marked reader-zone fallback for backlog, and the provenance vocabulary (bias / line-anchored / reader-zone-fallback) that keeps every event re-derivable; plus the linear-cost CMTrace line parser (first open / last parseable trailer, timeout-backed) and the agent multiline caps.
|
||||
* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
|
||||
* [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop.
|
||||
* [Local Admin Analyzer](agent/local-admin-analyzer.md) - What local_admin_analysis asserts at enrollment start/end: every local account inventoried, disabled ones included (a /active:no backdoor is dormant at both scans), Administrators membership read from the SAM by well-known SID (NetLocalGroupGetMembers, WMI-independent), unexpected admin members scored +40, non-local members (Entra role SIDs) listed but never flagged, built-in Administrator enabled-state reported but not scored.
|
||||
* [Power-State Watcher](agent/power-state-watcher.md) - Live AC↔battery transitions and 50/30/15% downward battery-threshold crossings during enrollment via a WMI Win32_PowerManagementEvent push subscription (zero polling, never armed on battery-less devices): the power_state_change event (transition field, per-level lifetime latch, 5s debounce, emission cap) and the analyze rules ANALYZE-DEV-009 (battery <15%, high) / ANALYZE-DEV-010 (AC→battery, warning) with on_event interim triggers.
|
||||
* [System Timeline Watcher](agent/system-timeline-watcher.md) - Ground truth for two timeline blind spots from one System-channel EventLogWatcher with pre-agent backfill: system_clock_changed (Kernel-General 1 — oldTime/newTime/signed timeDeltaMs/reason/setting process; 2 s micro-slew floor, ≥5 min Warning+immediate) and system_sleep_episode (Power-Troubleshooter 1 sleep/hibernate + Kernel-Power 507 Modern Standby; authoritative enteredAt/exitedAt payload instants, 60 s Modern-Standby floor). Consumed by the TimeJumpBadge clock-set cause, the SessionInfoCard standby note, time-attribution SleepSpans (AttributionVersion 3) and ANALYZE-DEV-012.
|
||||
* [DO GroupId Auto-Set](agent/do-group-id-auto-set.md) - EnableDoGroupIdAutoSet (ConfigVersion 40): the agent seeds the Delivery Optimization DOGroupId policy with a deterministic network-fingerprint GUID (default gateway IPv4 + SendARP MAC, RealmJoin-compatible byte layout) so same-network devices form one DO peering group during ESP; ownership-tracked via the StartupEventGate fingerprint, foreign DOGroupId/DOGroupIdSource policies always win, no random-GUID fallback.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-30 (3)
|
||||
|
||||
* **New**: `agent/local-admin-analyzer.md` — `LocalAdminAnalyzer` no longer skips disabled accounts (a `/active:no` backdoor re-enabled after enrollment was invisible at both scans) and reads Administrators-group membership from the SAM via `NetLocalGroupGetMembers` on `S-1-5-32-544` (`LocalGroupNativeMethods`, WMI-independent). New event fields `unexpected_admin_members` (+40), `account_details`, `administrators_group.{enumerated,error_code,members}`, `builtin_administrator_enabled` (reported, not scored). Non-local members (Entra role SIDs, domain groups) are listed, never flagged. `ANALYZE-ID-002` 1.2.0 explanation names the new fields. Registered in `index.md`.
|
||||
|
||||
## 2026-08-30 (2)
|
||||
|
||||
* **Update**: `agent/cmtrace-time-resolution.md` — new "Line parsing (bounded cost)" section: `CmTraceLogParser.TryParseLine` is no longer one greedy-`.*` regex (quadratic on hostile lines, no timeout) but a linear first-open / last-parseable-trailer search with a `\G`-anchored, timeout-backed trailer regex that returns exactly what the old regex did; the agent's multiline buffer gains a 1 MB char cap next to the 100-line cap, both logged at Warning, and the tail of a dropped entry is skipped instead of raw-matched.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -56,7 +56,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list), unexpected_profiles (C:\\Users profile directories not on the allowed list), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -79,7 +79,7 @@
|
||||
"steps": [
|
||||
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
|
||||
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
|
||||
"Check whether the unexpected account has admin group membership",
|
||||
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
|
||||
+3
-3
@@ -2766,7 +2766,7 @@
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -2817,7 +2817,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list), unexpected_profiles (C:\\Users profile directories not on the allowed list), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -2840,7 +2840,7 @@
|
||||
"steps": [
|
||||
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
|
||||
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
|
||||
"Check whether the unexpected account has admin group membership",
|
||||
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// LocalAdminAnalyzer.EvaluateAccounts — the account/admin-membership evaluation contract.
|
||||
/// Pins the two evasions that the old name-only, enabled-only scan missed: a disabled
|
||||
/// (dormant) backdoor account, and Administrators membership as an independent signal.
|
||||
/// All names are synthetic; the machine name is a fixture constant.
|
||||
/// </summary>
|
||||
public sealed class LocalAdminAnalyzerEvaluateAccountsTests
|
||||
{
|
||||
private const string Machine = "DESKTOP-TEST01";
|
||||
private const string MachineSidPrefix = "S-1-5-21-111-222-333-";
|
||||
|
||||
private static readonly List<string> Allowed = new List<string>
|
||||
{
|
||||
"Administrator", "Guest", "DefaultAccount", "WDAGUtilityAccount", "defaultuser0", "defaultuser1", "adm-*"
|
||||
};
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, bool disabled, int rid) =>
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid };
|
||||
|
||||
private static LocalGroupMember LocalMember(string name, int rid) =>
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\" + name, Sid = MachineSidPrefix + rid, SidUsage = 1 };
|
||||
|
||||
// -------------------------------------------------------------- disabled backdoor
|
||||
|
||||
[Fact]
|
||||
public void Disabled_unexpected_account_is_still_reported()
|
||||
{
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
Account("Administrator", disabled: true, rid: 500),
|
||||
Account("backdoor", disabled: true, rid: 1001)
|
||||
};
|
||||
|
||||
var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, new List<LocalGroupMember>(), true, Machine, Allowed);
|
||||
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
Assert.Empty(result.UnexpectedAdminMembers);
|
||||
var detail = result.AccountDetails.Single(d => (string)d["name"] == "backdoor");
|
||||
Assert.True((bool)detail["disabled"]);
|
||||
Assert.False((bool)detail["administrators_member"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_unexpected_account_with_admin_membership_is_an_unexpected_admin_member()
|
||||
{
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
Account("Administrator", disabled: true, rid: 500),
|
||||
Account("backdoor", disabled: true, rid: 1001)
|
||||
};
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
LocalMember("Administrator", 500),
|
||||
LocalMember("backdoor", 1001)
|
||||
};
|
||||
|
||||
var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, members, true, Machine, Allowed);
|
||||
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
var detail = result.AccountDetails.Single(d => (string)d["name"] == "backdoor");
|
||||
Assert.True((bool)detail["administrators_member"]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- allowed list
|
||||
|
||||
[Fact]
|
||||
public void Allowed_admin_members_are_not_flagged_but_listed()
|
||||
{
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
Account("Administrator", disabled: false, rid: 500),
|
||||
Account("adm-4711", disabled: false, rid: 1002)
|
||||
};
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
LocalMember("Administrator", 500),
|
||||
LocalMember("adm-4711", 1002)
|
||||
};
|
||||
|
||||
var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, members, true, Machine, Allowed);
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Empty(result.UnexpectedAdminMembers);
|
||||
Assert.Equal(2, result.AdministratorsGroupMembers.Count);
|
||||
Assert.True(result.AccountDetails.All(d => (bool)d["administrators_member"]));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- non-local members
|
||||
|
||||
[Fact]
|
||||
public void Members_outside_the_machine_domain_are_listed_but_never_flagged()
|
||||
{
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
Account("Administrator", disabled: true, rid: 500)
|
||||
};
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
LocalMember("Administrator", 500),
|
||||
// Entra role SIDs on an Entra-joined device resolve to nothing — the API returns the SID
|
||||
new LocalGroupMember { DomainAndName = "S-1-12-1-123-456-789-1011", Sid = "S-1-12-1-123-456-789-1011", SidUsage = 8 },
|
||||
// Domain group on a hybrid device
|
||||
new LocalGroupMember { DomainAndName = "CORP\\Workstation Admins", Sid = "S-1-5-21-9-9-9-1105", SidUsage = 2 }
|
||||
};
|
||||
|
||||
var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, members, true, Machine, Allowed);
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Empty(result.UnexpectedAdminMembers);
|
||||
Assert.Equal(3, result.AdministratorsGroupMembers.Count);
|
||||
Assert.Contains("S-1-12-1-123-456-789-1011", result.AdministratorsGroupMembers);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- WMI gap
|
||||
|
||||
[Fact]
|
||||
public void Local_admin_member_missing_from_the_wmi_inventory_is_still_flagged()
|
||||
{
|
||||
// WMI failed or did not list the account — the SAM group membership alone is enough.
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
LocalMember("Administrator", 500),
|
||||
LocalMember("backdoor", 1001)
|
||||
};
|
||||
|
||||
var result = LocalAdminAnalyzer.EvaluateAccounts(
|
||||
new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, true, Machine, Allowed);
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Membership_matches_by_sid_when_the_member_name_differs_in_case()
|
||||
{
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo> { Account("BackDoor", false, 1001) };
|
||||
var members = new List<LocalGroupMember> { LocalMember("backdoor", 1001) };
|
||||
|
||||
var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, members, true, Machine, Allowed);
|
||||
|
||||
Assert.Equal(new[] { "BackDoor" }, result.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- built-in Administrator
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
public void Builtin_administrator_enabled_state_is_reported_by_rid_500(bool disabled, bool expectedEnabled)
|
||||
{
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
Account("Administrator", disabled, rid: 500),
|
||||
Account("Guest", disabled: true, rid: 501)
|
||||
};
|
||||
|
||||
var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, new List<LocalGroupMember>(), true, Machine, Allowed);
|
||||
|
||||
Assert.Equal(expectedEnabled, result.BuiltInAdministratorEnabled);
|
||||
Assert.Empty(result.Unexpected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- native path (Windows only, net48)
|
||||
|
||||
[Fact]
|
||||
public void Native_administrators_enumeration_succeeds_and_contains_the_builtin_administrator()
|
||||
{
|
||||
// Proves the P/Invoke marshalling end-to-end on the executing machine: the SAM
|
||||
// always holds the RID-500 Administrator in BUILTIN\Administrators.
|
||||
var result = LocalGroupNativeMethods.GetAdministratorsMembers();
|
||||
|
||||
Assert.True(result.Succeeded, $"NET_API_STATUS={result.ErrorCode}");
|
||||
Assert.False(string.IsNullOrEmpty(result.GroupName));
|
||||
Assert.Contains(result.Members, m => m.Sid != null && m.Sid.EndsWith("-500"));
|
||||
Assert.All(result.Members, m => Assert.False(string.IsNullOrEmpty(m.DomainAndName)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builtin_administrator_state_is_null_when_rid_500_is_absent()
|
||||
{
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo> { Account("Guest", true, 501) };
|
||||
|
||||
var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, new List<LocalGroupMember>(), false, Machine, Allowed);
|
||||
|
||||
Assert.Null(result.BuiltInAdministratorEnabled);
|
||||
Assert.False(result.AdministratorsGroupEnumerated);
|
||||
}
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// One member of a local group as returned by <c>NetLocalGroupGetMembers</c> level 2.
|
||||
/// <see cref="DomainAndName"/> is <c>DOMAIN\name</c> for resolvable members; for members
|
||||
/// whose SID cannot be resolved on the device (Entra role SIDs on an Entra-joined device,
|
||||
/// deleted accounts) it falls back to the SID string so the member is still visible.
|
||||
/// </summary>
|
||||
internal sealed class LocalGroupMember
|
||||
{
|
||||
public string Sid { get; set; }
|
||||
public string DomainAndName { get; set; }
|
||||
public int SidUsage { get; set; }
|
||||
|
||||
/// <summary>Domain part of <see cref="DomainAndName"/> (empty when there is none).</summary>
|
||||
public string Domain
|
||||
{
|
||||
get
|
||||
{
|
||||
var idx = (DomainAndName ?? string.Empty).IndexOf('\\');
|
||||
return idx > 0 ? DomainAndName.Substring(0, idx) : string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Account part of <see cref="DomainAndName"/> (the whole string when there is no domain).</summary>
|
||||
public string Name
|
||||
{
|
||||
get
|
||||
{
|
||||
var value = DomainAndName ?? string.Empty;
|
||||
var idx = value.IndexOf('\\');
|
||||
return idx >= 0 && idx < value.Length - 1 ? value.Substring(idx + 1) : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Result of <see cref="LocalGroupNativeMethods.GetAdministratorsMembers"/>.</summary>
|
||||
internal sealed class LocalGroupMembersResult
|
||||
{
|
||||
public string GroupName { get; set; }
|
||||
public List<LocalGroupMember> Members { get; set; } = new List<LocalGroupMember>();
|
||||
|
||||
/// <summary>NET_API_STATUS of the enumeration; 0 = success.</summary>
|
||||
public int ErrorCode { get; set; }
|
||||
|
||||
public bool Succeeded => ErrorCode == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Win32 P/Invoke declarations for local group membership queries
|
||||
/// (<c>NetLocalGroupGetMembers</c>). Used by <c>LocalAdminAnalyzer</c> to enumerate the
|
||||
/// members of the built-in Administrators group directly from the SAM — independent of
|
||||
/// WMI and of account enabled/disabled state, so a dormant (disabled) admin account is
|
||||
/// still reported. The group is addressed by its well-known SID (S-1-5-32-544) so the
|
||||
/// localized group name ("Administratoren", "Administrateurs") does not matter.
|
||||
/// </summary>
|
||||
internal static class LocalGroupNativeMethods
|
||||
{
|
||||
private const uint MAX_PREFERRED_LENGTH = 0xFFFFFFFF;
|
||||
|
||||
/// <summary>LOCALGROUP_MEMBERS_INFO_2 — SID, SID usage and DOMAIN\name of a member.</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct LOCALGROUP_MEMBERS_INFO_2
|
||||
{
|
||||
public IntPtr lgrmi2_sid;
|
||||
public int lgrmi2_sidusage;
|
||||
[MarshalAs(UnmanagedType.LPWStr)]
|
||||
public string lgrmi2_domainandname;
|
||||
}
|
||||
|
||||
[DllImport("netapi32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int NetLocalGroupGetMembers(
|
||||
string servername,
|
||||
string localgroupname,
|
||||
int level,
|
||||
out IntPtr bufptr,
|
||||
uint prefmaxlen,
|
||||
out int entriesread,
|
||||
out int totalentries,
|
||||
IntPtr resumehandle);
|
||||
|
||||
[DllImport("netapi32.dll")]
|
||||
private static extern int NetApiBufferFree(IntPtr buffer);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the localized name of the built-in Administrators group from its well-known
|
||||
/// SID. Falls back to the English name when the translation is unavailable.
|
||||
/// </summary>
|
||||
public static string ResolveAdministratorsGroupName()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||
var account = (NTAccount)sid.Translate(typeof(NTAccount));
|
||||
var value = account.Value ?? string.Empty; // "BUILTIN\Administrators"
|
||||
var idx = value.IndexOf('\\');
|
||||
var name = idx >= 0 && idx < value.Length - 1 ? value.Substring(idx + 1) : value;
|
||||
return string.IsNullOrEmpty(name) ? "Administrators" : name;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "Administrators";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the members of the built-in Administrators group on the local machine.
|
||||
/// Never throws: a failed enumeration returns an empty member list with the native
|
||||
/// error code in <see cref="LocalGroupMembersResult.ErrorCode"/> so the caller can log it.
|
||||
/// </summary>
|
||||
public static LocalGroupMembersResult GetAdministratorsMembers()
|
||||
{
|
||||
var result = new LocalGroupMembersResult { GroupName = ResolveAdministratorsGroupName() };
|
||||
var buffer = IntPtr.Zero;
|
||||
|
||||
try
|
||||
{
|
||||
var status = NetLocalGroupGetMembers(
|
||||
null, result.GroupName, 2, out buffer, MAX_PREFERRED_LENGTH,
|
||||
out var entriesRead, out _, IntPtr.Zero);
|
||||
|
||||
if (status != 0)
|
||||
{
|
||||
result.ErrorCode = status;
|
||||
return result;
|
||||
}
|
||||
|
||||
var entrySize = Marshal.SizeOf(typeof(LOCALGROUP_MEMBERS_INFO_2));
|
||||
for (var i = 0; i < entriesRead; i++)
|
||||
{
|
||||
var entryPtr = new IntPtr(buffer.ToInt64() + (long)i * entrySize);
|
||||
var entry = (LOCALGROUP_MEMBERS_INFO_2)Marshal.PtrToStructure(entryPtr, typeof(LOCALGROUP_MEMBERS_INFO_2));
|
||||
|
||||
string sid = null;
|
||||
try
|
||||
{
|
||||
if (entry.lgrmi2_sid != IntPtr.Zero)
|
||||
sid = new SecurityIdentifier(entry.lgrmi2_sid).Value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Malformed SID — keep the member with whatever name the API returned.
|
||||
}
|
||||
|
||||
var domainAndName = entry.lgrmi2_domainandname;
|
||||
if (string.IsNullOrEmpty(domainAndName))
|
||||
domainAndName = sid ?? string.Empty;
|
||||
|
||||
result.Members.Add(new LocalGroupMember
|
||||
{
|
||||
Sid = sid,
|
||||
DomainAndName = domainAndName,
|
||||
SidUsage = entry.lgrmi2_sidusage
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Marshalling failure or missing export — report as a generic failure code
|
||||
// (HRESULT when available) so the analyzer logs it instead of reporting an empty group as fact.
|
||||
result.ErrorCode = ex.HResult != 0 ? ex.HResult : -1;
|
||||
result.Members.Clear();
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (buffer != IntPtr.Zero) NetApiBufferFree(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-21
@@ -19,13 +19,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
///
|
||||
/// Checks performed:
|
||||
/// 1. BypassNRO registry flag (HKLM\...\OOBE\BypassNRO = 1)
|
||||
/// 2. Unexpected local user accounts (via WMI Win32_UserAccount)
|
||||
/// 3. Unexpected C:\Users profile directories
|
||||
/// 2. Unexpected local user accounts (via WMI Win32_UserAccount) — disabled accounts
|
||||
/// included: a backdoor created with /active:no and re-enabled after enrollment
|
||||
/// is dormant at both scans and would otherwise never surface
|
||||
/// 3. Administrators-group membership (NetLocalGroupGetMembers on S-1-5-32-544) —
|
||||
/// an unexpected account that also holds admin membership is the actual backdoor
|
||||
/// 4. Unexpected C:\Users profile directories
|
||||
///
|
||||
/// Confidence scoring:
|
||||
/// BypassNRO = 1 → +20 (low indicator)
|
||||
/// Unexpected local account found → +40 (medium indicator)
|
||||
/// Account + matching C:\Users profile → +40 (high indicator, profile overlap)
|
||||
/// BypassNRO = 1 → +20 (low indicator)
|
||||
/// Unexpected local account found → +40 (medium indicator)
|
||||
/// Unexpected account is an Administrators member → +40 (high indicator)
|
||||
/// Account + matching C:\Users profile → +40 (high indicator, profile overlap)
|
||||
///
|
||||
/// The enabled-state of the built-in Administrator (RID 500) is reported
|
||||
/// (<c>builtin_administrator_enabled</c>) but not scored: whether OOBE enables it
|
||||
/// transiently is not established, so the field first collects evidence.
|
||||
///
|
||||
/// Emits a single "local_admin_analysis" event at startup and at shutdown,
|
||||
/// enabling delta detection between pre- and post-enrollment state.
|
||||
@@ -146,6 +155,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
if (accountsResult.Unexpected.Count > 0)
|
||||
confidenceScore += 40;
|
||||
|
||||
// An unexpected account that is also a member of Administrators is the
|
||||
// actual backdoor (enabled or dormant) — high indicator on its own.
|
||||
if (accountsResult.UnexpectedAdminMembers.Count > 0)
|
||||
confidenceScore += 40;
|
||||
|
||||
// Profile overlap: unexpected account AND matching C:\Users folder
|
||||
bool profileOverlap = accountsResult.Unexpected.Any(a =>
|
||||
profilesResult.Unexpected.Any(p =>
|
||||
@@ -183,6 +197,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
$"{Name}: confidence={confidenceScore}, finding={findingLabel}, " +
|
||||
$"bypassNro={bypassNroResult.Value}, " +
|
||||
$"unexpectedAccounts={accountsResult.Unexpected.Count}, " +
|
||||
$"unexpectedAdminMembers={accountsResult.UnexpectedAdminMembers.Count}, " +
|
||||
$"adminGroupEnumerated={accountsResult.AdministratorsGroupEnumerated}, " +
|
||||
$"builtinAdministratorEnabled={accountsResult.BuiltInAdministratorEnabled?.ToString() ?? "unknown"}, " +
|
||||
$"unexpectedProfiles={profilesResult.Unexpected.Count}");
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
@@ -203,8 +220,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
},
|
||||
{ "unexpected_accounts", accountsResult.Unexpected },
|
||||
{ "unexpected_admin_members", accountsResult.UnexpectedAdminMembers },
|
||||
{ "unexpected_profiles", profilesResult.Unexpected },
|
||||
{ "accounts_checked", accountsResult.AllChecked },
|
||||
{ "account_details", accountsResult.AccountDetails },
|
||||
{ "administrators_group", new Dictionary<string, object>
|
||||
{
|
||||
{ "enumerated", accountsResult.AdministratorsGroupEnumerated },
|
||||
{ "error_code", accountsResult.AdministratorsGroupErrorCode },
|
||||
{ "members", accountsResult.AdministratorsGroupMembers }
|
||||
}
|
||||
},
|
||||
{ "builtin_administrator_enabled", accountsResult.BuiltInAdministratorEnabled },
|
||||
{ "profiles_found", profilesResult.AllFound }
|
||||
}
|
||||
}
|
||||
@@ -327,33 +354,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
|
||||
private LocalAccountCheckResult CheckLocalAdminAccounts(List<string> allowedAccounts)
|
||||
{
|
||||
var allChecked = new List<string>();
|
||||
var unexpected = new List<string>();
|
||||
var accounts = new List<LocalAccountInfo>();
|
||||
|
||||
try
|
||||
{
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Name, Disabled FROM Win32_UserAccount WHERE LocalAccount = True"))
|
||||
"SELECT Name, Disabled, SID FROM Win32_UserAccount WHERE LocalAccount = True"))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
var name = obj["Name"]?.ToString() ?? string.Empty;
|
||||
var disabled = obj["Disabled"] != null && Convert.ToBoolean(obj["Disabled"]);
|
||||
var sid = obj["SID"]?.ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(name))
|
||||
continue;
|
||||
|
||||
allChecked.Add(name);
|
||||
|
||||
// Skip disabled accounts — they cannot be used to log in
|
||||
if (disabled)
|
||||
continue;
|
||||
|
||||
if (!IsAllowed(name, allowedAccounts))
|
||||
{
|
||||
unexpected.Add(name);
|
||||
_logger.Debug($"{Name}: Unexpected local account: {name}");
|
||||
}
|
||||
accounts.Add(new LocalAccountInfo { Name = name, Disabled = disabled, Sid = sid });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,7 +379,100 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
_logger.Warning($"{Name}: Failed to enumerate local accounts via WMI: {ex.Message}");
|
||||
}
|
||||
|
||||
return new LocalAccountCheckResult { AllChecked = allChecked, Unexpected = unexpected };
|
||||
// Administrators-group membership is read from the SAM, independent of WMI and of
|
||||
// the enabled state — a dormant backdoor with admin rights is still a member.
|
||||
var group = LocalGroupNativeMethods.GetAdministratorsMembers();
|
||||
if (!group.Succeeded)
|
||||
_logger.Warning($"{Name}: Failed to enumerate members of '{group.GroupName}': NET_API_STATUS={group.ErrorCode}");
|
||||
|
||||
var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowedAccounts);
|
||||
result.AdministratorsGroupErrorCode = group.ErrorCode;
|
||||
|
||||
foreach (var name in result.Unexpected)
|
||||
_logger.Debug($"{Name}: Unexpected local account: {name}");
|
||||
foreach (var name in result.UnexpectedAdminMembers)
|
||||
_logger.Debug($"{Name}: Unexpected Administrators member: {name}");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure evaluation of the account inventory against the allowed list — separated from
|
||||
/// the WMI / SAM reads for unit-testing (InternalsVisibleTo).
|
||||
///
|
||||
/// Rules:
|
||||
/// - every local account is checked, disabled ones included (state is reported);
|
||||
/// - a local account is an Administrators member when its SID matches a group member,
|
||||
/// or (SID unavailable) its name matches a member of the local machine domain;
|
||||
/// - <c>UnexpectedAdminMembers</c> = unexpected accounts holding membership, plus local
|
||||
/// members not on the allowed list that the WMI inventory did not return at all;
|
||||
/// - members outside the local machine domain (Entra role SIDs, domain groups) are
|
||||
/// listed for delta comparison but never flagged — they are expected on joined devices.
|
||||
/// </summary>
|
||||
internal static LocalAccountCheckResult EvaluateAccounts(
|
||||
IList<LocalAccountInfo> accounts,
|
||||
IList<LocalGroupMember> adminMembers,
|
||||
bool adminGroupEnumerated,
|
||||
string machineName,
|
||||
List<string> allowedAccounts)
|
||||
{
|
||||
var result = new LocalAccountCheckResult { AdministratorsGroupEnumerated = adminGroupEnumerated };
|
||||
|
||||
var adminSids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var adminLocalNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var member in adminMembers ?? new List<LocalGroupMember>())
|
||||
{
|
||||
result.AdministratorsGroupMembers.Add(member.DomainAndName ?? string.Empty);
|
||||
if (!string.IsNullOrEmpty(member.Sid))
|
||||
adminSids.Add(member.Sid);
|
||||
if (IsLocalMember(member, machineName))
|
||||
adminLocalNames.Add(member.Name);
|
||||
}
|
||||
|
||||
foreach (var account in accounts ?? new List<LocalAccountInfo>())
|
||||
{
|
||||
var isAdmin = (!string.IsNullOrEmpty(account.Sid) && adminSids.Contains(account.Sid))
|
||||
|| adminLocalNames.Contains(account.Name);
|
||||
|
||||
result.AllChecked.Add(account.Name);
|
||||
result.AccountDetails.Add(new Dictionary<string, object>
|
||||
{
|
||||
{ "name", account.Name },
|
||||
{ "disabled", account.Disabled },
|
||||
{ "administrators_member", isAdmin }
|
||||
});
|
||||
|
||||
if (account.Sid != null && account.Sid.EndsWith("-500", StringComparison.Ordinal))
|
||||
result.BuiltInAdministratorEnabled = !account.Disabled;
|
||||
|
||||
if (IsAllowed(account.Name, allowedAccounts))
|
||||
continue;
|
||||
|
||||
result.Unexpected.Add(account.Name);
|
||||
if (isAdmin)
|
||||
result.UnexpectedAdminMembers.Add(account.Name);
|
||||
}
|
||||
|
||||
// Local members the WMI inventory did not surface (WMI failure, or an account the
|
||||
// provider does not list) — still an unexpected admin when not on the allowed list.
|
||||
foreach (var name in adminLocalNames)
|
||||
{
|
||||
if (IsAllowed(name, allowedAccounts))
|
||||
continue;
|
||||
if (result.UnexpectedAdminMembers.Any(u => string.Equals(u, name, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
result.UnexpectedAdminMembers.Add(name);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsLocalMember(LocalGroupMember member, string machineName)
|
||||
{
|
||||
var domain = member.Domain;
|
||||
if (string.IsNullOrEmpty(domain))
|
||||
return false; // unresolved SID (Entra role, deleted account) — not a local account
|
||||
return string.Equals(domain, machineName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private UserProfileCheckResult CheckUserProfiles(List<string> allowedAccounts)
|
||||
@@ -494,10 +604,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
public bool KeyExists { get; set; }
|
||||
}
|
||||
|
||||
private class LocalAccountCheckResult
|
||||
/// <summary>One local account as returned by Win32_UserAccount.</summary>
|
||||
internal sealed class LocalAccountInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public bool Disabled { get; set; }
|
||||
public string Sid { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class LocalAccountCheckResult
|
||||
{
|
||||
/// <summary>All local account names (enabled and disabled).</summary>
|
||||
public List<string> AllChecked { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Account names not on the allowed list, disabled ones included.</summary>
|
||||
public List<string> Unexpected { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Unexpected accounts that are members of the Administrators group.</summary>
|
||||
public List<string> UnexpectedAdminMembers { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Per-account state: name, disabled, administrators_member.</summary>
|
||||
public List<Dictionary<string, object>> AccountDetails { get; set; } = new List<Dictionary<string, object>>();
|
||||
|
||||
/// <summary>Every Administrators member (DOMAIN\name or SID), local or not — for delta comparison.</summary>
|
||||
public List<string> AdministratorsGroupMembers { get; set; } = new List<string>();
|
||||
|
||||
public bool AdministratorsGroupEnumerated { get; set; }
|
||||
public int AdministratorsGroupErrorCode { get; set; }
|
||||
|
||||
/// <summary>Enabled state of the built-in Administrator (RID 500); null when not found.</summary>
|
||||
public bool? BuiltInAdministratorEnabled { get; set; }
|
||||
}
|
||||
|
||||
private class UserProfileCheckResult
|
||||
|
||||
Reference in New Issue
Block a user