Compare commits

...

5 Commits

Author SHA1 Message Date
Oliver Kieselbach 66f5543fb7 fix(security): ConsoleBypassWatcher mirrors cmd.exe first-switch-wins parsing — only a leading /c classifies as scripted; a /c inside a /k payload (cmd /k rem /c, cmd /k "title /c") no longer silences a persistent interactive console, which is surfaced as interactive_console_with_command (low)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 02:05:55 +02:00
Oliver Kieselbach 21d200a1e4 fix(security): config PUT derives its preserved system-owned fields from the PATCH deny-list (BaseDeniedFields) so ProDowngradedUtc/OnboardedAt can no longer be written by a tenant admin — a client-supplied ProDowngradedUtc refreshed the retention-downgrade grace anchor indefinitely, keeping the Pro retention cap on a Community tenant
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 02:01:32 +02:00
Oliver Kieselbach 8c0cb2c423 fix(security): RemoveGlobalAdmin, RemoveTenantAdmin, DisableTenantAdmin and sub-Admin UpdateMemberPermissions now call DisconnectUserAsync(upn) so a revoked/demoted user loses already-joined global-admins / tenant-{tid} / notify / session SignalR streams instead of keeping them until the socket drops (group authz is join-time only)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 01:59:51 +02:00
Oliver Kieselbach 84d06e1cf6 fix(security): LocalAdminAnalyzer never exempts a logged-in local SAM account from the shutdown scan (CWE-807) and matches built-ins by well-known RID so localized Guest/Administrator are not flagged
The shutdown run allowed every logged-in user (explorer.exe owner) dynamically, so a backdoor account created after the startup scan and signed in before enrollment completed was scored 0. The WMI inventory is now read first and a logged-in name that is a local account is rejected for the account and the profile check (reported as logged_in_local_accounts); only Entra/domain identities — the only ones seen in real sessions — remain dynamically allowed for their profile folder.

Built-ins are recognised by RID 500/501/503/504 on the machine SID in addition to their English names: since disabled accounts are inventoried (88dfd331), localized Gast/Invité/Administrateur would otherwise flag every non-English device. kioskUser0 (Assigned Access autologon) joins the built-in list; the profile check skips junctions and $<32 hex> folders (cannot be an account name, pure payload noise). ANALYZE-ID-002 1.3.0 explanation mentions the new field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 01:42:51 +02:00
Oliver Kieselbach 88dfd331dd 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>
2026-08-30 01:00:29 +02:00
18 changed files with 1269 additions and 94 deletions
+139
View File
@@ -0,0 +1,139 @@
---
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), why built-ins are matched by well-known RID (localized Guest/Administrator), why the shutdown-time dynamic allowance never covers a local SAM account (CWE-807 — the login state is attacker-controlled), which folders are not profiles, 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, junctions and `$<32 hex>` folders skipped | `profiles_found`, `unexpected_profiles` |
## Allowed list
Built-in names (`Administrator`, `Guest`, `DefaultAccount`, `WDAGUtilityAccount`,
`defaultuser0/1/2`, `kioskUser0` — the Assigned Access autologon account of kiosk profiles —
and the non-account folders `Public`, `Default`, `Default User`, `All Users`) plus the
tenant's *Allowed Local Accounts* (glob `*`/`?`, case-insensitive).
`defaultuser1/2` stay allowlisted deliberately: they do appear during OOBE enrollment.
**Well-known RIDs.** Built-in accounts are additionally recognised by RID on the machine SID
(`S-1-5-21-…-500` Administrator, `-501` Guest, `-503` DefaultAccount, `-504`
WDAGUtilityAccount), for the WMI inventory and for the SAM-member fallback alike. Localized
installs name them `Gast`, `Invité`, `Гость`, `Administrateur`, … — disabled by default and
therefore invisible before disabled accounts were inventoried; without the RID match every
non-English device would flag its own Guest. Name matching is unchanged (an ordinary-RID
account named `Gast` is still unexpected).
## Dynamic allowance at shutdown
The shutdown run adds the logged-in users (explorer.exe owners outside session 0) to the
allowed list — but **only names that are not a local SAM account**. The WMI inventory is read
first; a logged-in name found in it is rejected for both the account and the profile check
and reported as `logged_in_local_accounts` (Warning in the agent log).
Why this is safe for false positives: in real sessions the logged-in identity is an Entra or
domain user (user-driven, hybrid, or an Entra user signing in to a self-deploying device
after ESP). Those never appear in `Win32_UserAccount`; the allowance only ever mattered for
their `C:\Users` folder — and `unexpected_profiles` alone scores 0. A logged-in *local*
account is the one case the allowance changed the score, and that is the attack: an account
created after the startup scan that signs itself in before the shutdown scan (CWE-807 —
exempting on a condition the adversary controls). Kiosk autologon (`kioskUser0`) is covered
by the built-in list instead.
## Profile folders that are not profiles
Junctions (`All Users`, `Default User` and localized variants such as `Все пользователи`,
all pointing at ProgramData / Default) and `$<32 hex>` folders are skipped entirely — neither
can be the profile of a local account (a SAM account name is at most 20 characters), so they
can never contribute to the profile-overlap score; listing them was payload noise.
## 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 user backdoor P@ss /add` + `net localgroup Administrators backdoor /add` during ESP,
then an interactive sign-in as `backdoor` (fast user switching) before enrollment completes:
the shutdown scan finds `backdoor` in the SAM inventory → not allowed dynamically →
`unexpected_accounts=[backdoor]`, `unexpected_admin_members=[backdoor]`, profile overlap →
confidence 100, Error, `logged_in_local_accounts=[backdoor]`. Before this change the login
alone silenced the finding to 0.
German device, tenant allowed list empty: `Gast` (RID 501, disabled) and `Administrator`
are inventoried and matched by RID → `unexpected_accounts=[]`, confidence 0.
`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`
* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerDynamicAllowanceTests.cs`
* `rules/analyze/ANALYZE-ID-002.json`
+1
View File
@@ -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, built-ins matched by well-known RID (localized Guest/Administrator never flagged), shutdown-time dynamic allowance never covers a local SAM account (CWE-807), junctions and $GUID folders are not profiles, 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.
+6
View File
@@ -1,5 +1,11 @@
# Log
## 2026-08-30 (3)
* **Update**: `agent/local-admin-analyzer.md` — built-ins matched by well-known RID (500/501/503/504) so localized Guest/Administrator are not flagged now that disabled accounts are inventoried; `kioskUser0` built-in; shutdown-time dynamic allowance restricted to non-SAM identities (a logged-in local account is never exempted — CWE-807) with new `logged_in_local_accounts` field; junctions and `$<32 hex>` folders skipped by the profile check.
* **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.
+3 -3
View File
@@ -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.3.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), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), 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
View File
@@ -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.3.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), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), 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"
]
}
@@ -0,0 +1,97 @@
#nullable enable
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
{
/// <summary>
/// LocalAdminAnalyzer shutdown-time dynamic allowance and profile-folder filtering.
/// Pins the CWE-807 fix: a logged-in user is only exempted when the name is NOT a local
/// SAM account — a backdoor account that signs itself in before the shutdown scan must
/// still be scored. All names are synthetic.
/// </summary>
public sealed class LocalAdminAnalyzerDynamicAllowanceTests
{
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, int rid = 1001) =>
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = false, Sid = "S-1-5-21-111-222-333-" + rid };
private static readonly List<LocalAdminAnalyzer.LocalAccountInfo> Inventory = new List<LocalAdminAnalyzer.LocalAccountInfo>
{
Account("Administrator", 500),
Account("Guest", 501),
Account("defaultuser0", 1000),
Account("backdoor", 1001)
};
// -------------------------------------------------------------- logged-in split
[Fact]
public void Entra_or_domain_user_is_allowed_dynamically()
{
var split = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "A0D715F", "jane.doe" }, Inventory);
Assert.Equal(new[] { "A0D715F", "jane.doe" }, split.Allowed);
Assert.Empty(split.LocalAccounts);
}
[Fact]
public void Logged_in_local_account_is_never_allowed_dynamically()
{
var split = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "backdoor" }, Inventory);
Assert.Empty(split.Allowed);
Assert.Equal(new[] { "backdoor" }, split.LocalAccounts);
}
[Fact]
public void Local_account_match_is_case_insensitive()
{
var split = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "BACKDOOR" }, Inventory);
Assert.Empty(split.Allowed);
Assert.Equal(new[] { "BACKDOOR" }, split.LocalAccounts);
}
[Fact]
public void Mixed_logins_are_split_and_blank_names_dropped()
{
var split = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "kftest", "", "backdoor", " " }, Inventory);
Assert.Equal(new[] { "kftest" }, split.Allowed);
Assert.Equal(new[] { "backdoor" }, split.LocalAccounts);
}
[Fact]
public void No_logins_or_no_inventory_yield_empty_or_all_allowed()
{
var none = LocalAdminAnalyzer.SplitLoggedInUsers(null, Inventory);
Assert.Empty(none.Allowed);
Assert.Empty(none.LocalAccounts);
// WMI failed: nothing to compare against — the SAM membership check still covers admins.
var noInventory = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "backdoor" }, null);
Assert.Equal(new[] { "backdoor" }, noInventory.Allowed);
}
// -------------------------------------------------------------- profile folders
[Theory]
[InlineData("All Users", FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Hidden, true)]
[InlineData("Default User", FileAttributes.Directory | FileAttributes.ReparsePoint, true)]
[InlineData("Все пользователи", FileAttributes.Directory | FileAttributes.ReparsePoint, true)]
[InlineData("$4720C0479E8F4B51ABD4C5195A3F6399", FileAttributes.Directory, true)]
[InlineData("$4720c0479e8f4b51abd4c5195a3f6399", FileAttributes.Directory, true)]
[InlineData("Public", FileAttributes.Directory, false)]
[InlineData("Default", FileAttributes.Directory | FileAttributes.Hidden, false)]
[InlineData("backdoor", FileAttributes.Directory, false)]
[InlineData("$backdoor", FileAttributes.Directory, false)]
[InlineData("$4720C0479E8F4B51ABD4C5195A3F639", FileAttributes.Directory, false)] // 31 hex
[InlineData("$4720C0479E8F4B51ABD4C5195A3F6399G", FileAttributes.Directory, false)] // non-hex
public void Non_profile_folders_are_recognised(string name, FileAttributes attributes, bool expected)
{
Assert.Equal(expected, LocalAdminAnalyzer.IsNonProfileFolder(name, attributes));
}
}
}
@@ -0,0 +1,269 @@
#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);
}
// -------------------------------------------------------------- localized built-ins (RID)
[Fact]
public void Localized_builtin_accounts_are_allowed_by_well_known_rid()
{
// German / French install: the English names are on the allowed list, the local
// names are not — before the RID match every disabled Guest was an unexpected account.
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
{
Account("Administrateur", disabled: true, rid: 500),
Account("Gast", disabled: true, rid: 501),
Account("Standardkonto", disabled: true, rid: 503),
Account("WDAGDienstprogramm", disabled: true, rid: 504),
Account("defaultuser0", disabled: false, rid: 1000)
};
var members = new List<LocalGroupMember> { LocalMember("Administrateur", 500) };
var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, members, true, Machine, Allowed);
Assert.Empty(result.Unexpected);
Assert.Empty(result.UnexpectedAdminMembers);
Assert.Equal(5, result.AllChecked.Count);
}
[Fact]
public void Localized_builtin_admin_member_missing_from_wmi_is_not_flagged()
{
var members = new List<LocalGroupMember>
{
LocalMember("Administrateur", 500),
LocalMember("backdoor", 1001)
};
var result = LocalAdminAnalyzer.EvaluateAccounts(
new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, true, Machine, Allowed);
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
}
[Fact]
public void Well_known_rid_does_not_cover_ordinary_accounts_or_foreign_sids()
{
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "500"));
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "501"));
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "503"));
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "504"));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "502"));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1500"));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1001"));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-12-1-1-2-3-500"));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-5-32-544"));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(null));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(""));
}
[Fact]
public void Account_named_like_a_builtin_but_with_an_ordinary_rid_is_still_matched_by_name_only()
{
// Name-based allowance is unchanged: the RID match only ADDS coverage.
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
{
Account("Gast", disabled: false, rid: 1001)
};
var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, new List<LocalGroupMember>(), true, Machine, Allowed);
Assert.Equal(new[] { "Gast" }, result.Unexpected);
}
// -------------------------------------------------------------- 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);
}
}
}
@@ -94,6 +94,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
Assert.Equal("interactive_console_with_command", info.Classification);
}
[Theory]
[InlineData(@"cmd /k rem /c")]
[InlineData(@"cmd.exe /k ""title /c""")]
public void Cmd_k_with_slash_c_in_payload_is_not_demoted_to_scripted(string commandLine)
{
// cmd.exe honors only the FIRST /c-or-/k switch; a '/c' inside the /k payload is inert, so
// the console is a persistent human-usable shell and must still be surfaced (low confidence).
using var rig = new Rig(Probe(commandLine));
rig.Sut.HandleStart(2223, 1, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
var info = Assert.Single(rig.Detected);
Assert.Equal("low", info.Confidence);
Assert.Equal("interactive_console_with_command", info.Classification);
}
[Fact]
public void Instant_close_unreadable_commandline_raises_low_confidence()
{
@@ -146,6 +162,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
[InlineData(2, @"C:\x\cmd.exe /K bar", "InteractiveWithCommand")] // L12: /k stays interactive
[InlineData(1, @"cmd.exe /k whoami", "InteractiveWithCommand")] // technician-planted shell
[InlineData(1, @"cmd.exe /d/q/c exit 9", "Ignore")] // combined run-and-exit wins
[InlineData(1, @"cmd /k rem /c", "InteractiveWithCommand")] // cmd.exe honors the FIRST switch: /k stays interactive
[InlineData(1, @"cmd.exe /k ""title /c""", "InteractiveWithCommand")] // /c inside the /k payload is not a switch
[InlineData(1, @"cmd /k /c", "InteractiveWithCommand")] // /c after /k is just the /k payload
[InlineData(1, @"cmd.exe /c cmd /k", "Ignore")] // first switch /c wins the other way round
public void Classify_maps_session_and_commandline(int sessionId, string? commandLine, string expected)
{
Assert.Equal(expected, ConsoleBypassWatcher.Classify(sessionId, commandLine).ToString());
@@ -165,6 +185,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
[InlineData(@"cmd.exe /k", false)] // L12: /k leaves an interactive shell
[InlineData(@"cmd.exe /d/q/c exit 9", true)] // combined switches — the field case
[InlineData(@"cmd.exe /D/Q/K stay", false)] // L12: run-and-stay is not scripted
[InlineData(@"cmd.exe /k rem /c", false)] // first switch /k wins — payload /c is inert
[InlineData(@"cmd.exe /c rem /k", true)] // first switch /c wins
[InlineData(@"""C:\WINDOWS\system32\cmd.exe"" /c ""x.bat""", true)]
public void HasScriptArgument_detects_run_command_switch(string commandLine, bool expected)
{
@@ -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);
}
}
}
}
@@ -19,13 +19,32 @@ 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.
///
/// Built-in accounts are recognised by well-known RID (500/501/503/504 on the machine SID)
/// in addition to their English names, so localized installs (Gast, Invité, Administrateur)
/// do not flag their own disabled Guest/Administrator.
///
/// At shutdown the logged-in users are allowed dynamically — but only identities that are
/// NOT local SAM accounts (Entra / domain users, whose profile folder is the only artifact
/// they leave). A logged-in local account is exactly the delta this analyzer exists to
/// catch and is never exempted by its own login (CWE-807: the login state is under the
/// adversary's control); it is reported as <c>logged_in_local_accounts</c>.
///
/// Emits a single "local_admin_analysis" event at startup and at shutdown,
/// enabling delta detection between pre- and post-enrollment state.
@@ -53,6 +72,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
"defaultuser0", // Temporary OOBE/Autopilot system account, present during enrollment
"defaultuser1", // Sometimes seen in OOBE, but not always present
"defaultuser2", // Sometimes seen in OOBE, but not always present
"kioskUser0", // Assigned Access (kiosk profile) autologon account, created by Windows
"Public", // Profile folder (not a user account)
"Default", // Default user profile template
"Default User", // Symlink to Default in some Windows versions
@@ -102,9 +122,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
{
_logger.Info($"{Name}: Running shutdown analysis");
// At shutdown a user has logged in — their profile folder and account are expected.
// Detect logged-in users via explorer.exe owner (same technique as DesktopArrivalDetector)
// and add them dynamically so they don't trigger false positives.
// At shutdown a user has logged in — their profile folder is expected. Detect logged-in
// users via explorer.exe owner (same technique as DesktopArrivalDetector); RunAnalysis
// allows them dynamically unless the name is a local SAM account (see SplitLoggedInUsers).
var loggedInUsers = GetLoggedInUserNames();
// Phase=Unknown: this is an analysis event, not a phase declaration.
// Phase-tagged events are reserved for explicit phase transitions (esp_phase_changed etc.).
@@ -115,16 +135,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
// Core analysis
// -----------------------------------------------------------------------
private void RunAnalysis(string trigger, EnrollmentPhase phase, List<string> dynamicAllowedUsers = null)
private void RunAnalysis(string trigger, EnrollmentPhase phase, List<string> loggedInUsers = null)
{
try
{
// Build effective allowed list: static + optional dynamic (logged-in users at shutdown)
// The SAM inventory is read first: it decides which logged-in users may be allowed
// dynamically. A logged-in name that IS a local account is never exempted — its
// login state is under the adversary's control, and a local account created after
// the startup scan is precisely the delta this run exists to catch.
var localAccounts = ReadLocalAccounts();
var loggedIn = SplitLoggedInUsers(loggedInUsers, localAccounts);
foreach (var user in loggedIn.LocalAccounts)
_logger.Warning($"{Name}: Logged-in user '{user}' is a local account — not allowed dynamically");
// Build effective allowed list: static + dynamic (non-local logged-in users at shutdown)
var effectiveAllowed = _allowedAccounts;
if (dynamicAllowedUsers != null && dynamicAllowedUsers.Count > 0)
if (loggedIn.Allowed.Count > 0)
{
effectiveAllowed = new List<string>(_allowedAccounts);
foreach (var user in dynamicAllowedUsers)
foreach (var user in loggedIn.Allowed)
{
if (!effectiveAllowed.Any(a => string.Equals(a, user, StringComparison.OrdinalIgnoreCase)))
{
@@ -135,7 +165,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
}
var bypassNroResult = CheckBypassNroRegistry();
var accountsResult = CheckLocalAdminAccounts(effectiveAllowed);
var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveAllowed);
var profilesResult = CheckUserProfiles(effectiveAllowed);
int confidenceScore = 0;
@@ -146,6 +176,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 +218,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>
@@ -193,7 +231,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
{ "triggered_at", trigger },
{ "enrollment_phase_at_check", phase.ToString() },
{ "allowed_accounts", effectiveAllowed },
{ "dynamically_allowed_users", dynamicAllowedUsers ?? new List<string>() },
{ "dynamically_allowed_users", loggedIn.Allowed },
{ "logged_in_local_accounts", loggedIn.LocalAccounts },
{ "checks", new Dictionary<string, object>
{
{ "bypass_nro", new Dictionary<string, object>
@@ -203,8 +242,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 }
}
}
@@ -287,6 +336,82 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
return allowedAccounts.Any(a => MatchesAllowedEntry(name, a));
}
/// <summary>
/// Well-known built-in accounts of the local machine domain, recognised by RID so that
/// localized names (Gast, Invité, Administrateur, …) are not flagged:
/// 500 Administrator, 501 Guest, 503 DefaultAccount, 504 WDAGUtilityAccount.
/// Only S-1-5-21-… SIDs qualify (the WMI query is already restricted to local accounts).
/// Internal for direct unit-testing via InternalsVisibleTo.
/// </summary>
internal static bool IsWellKnownBuiltInRid(string sid)
{
if (string.IsNullOrEmpty(sid) || !sid.StartsWith("S-1-5-21-", StringComparison.Ordinal))
return false;
var dash = sid.LastIndexOf('-');
if (dash < 0 || dash == sid.Length - 1)
return false;
switch (sid.Substring(dash + 1))
{
case "500":
case "501":
case "503":
case "504":
return true;
default:
return false;
}
}
/// <summary>
/// Splits the logged-in user names into those that may be allowed dynamically (not a
/// local SAM account — Entra / domain identities whose only artifact is a profile
/// folder) and those that are local accounts (never allowed; reported).
/// Pure; internal for direct unit-testing via InternalsVisibleTo.
/// </summary>
internal static LoggedInUserSplit SplitLoggedInUsers(IList<string> loggedInUsers, IList<LocalAccountInfo> localAccounts)
{
var split = new LoggedInUserSplit();
if (loggedInUsers == null)
return split;
var localNames = new HashSet<string>(
(localAccounts ?? new List<LocalAccountInfo>()).Select(a => a.Name),
StringComparer.OrdinalIgnoreCase);
foreach (var user in loggedInUsers)
{
if (string.IsNullOrWhiteSpace(user))
continue;
if (localNames.Contains(user))
split.LocalAccounts.Add(user);
else
split.Allowed.Add(user);
}
return split;
}
/// <summary>
/// Top-level entries of C:\Users that are not user profiles and are skipped entirely:
/// junctions (<c>All Users</c>, <c>Default User</c> and their localized variants point
/// at ProgramData / Default) and <c>$&lt;32 hex&gt;</c> folders. Neither can be the
/// profile of a local account — a SAM account name is at most 20 characters — so they
/// can never contribute to the profile-overlap score; listing them is pure noise.
/// Internal for direct unit-testing via InternalsVisibleTo.
/// </summary>
internal static bool IsNonProfileFolder(string folderName, FileAttributes attributes)
{
if ((attributes & FileAttributes.ReparsePoint) != 0)
return true;
return folderName != null
&& folderName.Length == 33
&& folderName[0] == '$'
&& folderName.Skip(1).All(Uri.IsHexDigit);
}
// -----------------------------------------------------------------------
// Individual checks
// -----------------------------------------------------------------------
@@ -325,35 +450,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
}
}
private LocalAccountCheckResult CheckLocalAdminAccounts(List<string> allowedAccounts)
/// <summary>Local account inventory via WMI; empty on failure (logged at Warning).</summary>
private List<LocalAccountInfo> ReadLocalAccounts()
{
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 +478,112 @@ 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 };
return accounts;
}
private LocalAccountCheckResult CheckLocalAdminAccounts(List<LocalAccountInfo> accounts, List<string> allowedAccounts)
{
// 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;
/// - an account is allowed by name (allowed list, globs) or by well-known RID
/// (<see cref="IsWellKnownBuiltInRid"/>) — localized built-ins are never flagged;
/// - <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);
var adminLocalMembers = new List<LocalGroupMember>();
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);
adminLocalMembers.Add(member);
}
}
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 (IsWellKnownBuiltInRid(account.Sid) || 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 member in adminLocalMembers)
{
var name = member.Name;
if (IsWellKnownBuiltInRid(member.Sid) || 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)
@@ -387,6 +608,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
if (string.IsNullOrEmpty(folderName))
continue;
if (IsNonProfileFolder(folderName, new DirectoryInfo(dir).Attributes))
{
_logger.Debug($"{Name}: Skipping non-profile folder: {folderName}");
continue;
}
allFound.Add(folderName);
if (!IsAllowed(folderName, allowedAccounts))
@@ -494,10 +721,46 @@ 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; }
}
/// <summary>Logged-in users at shutdown, split by whether the name is a local SAM account.</summary>
internal sealed class LoggedInUserSplit
{
/// <summary>Not a local account (Entra / domain identity) — allowed dynamically.</summary>
public List<string> Allowed { get; } = new List<string>();
/// <summary>Local accounts that are logged in — never allowed, reported.</summary>
public List<string> LocalAccounts { get; } = new List<string>();
}
private class UserProfileCheckResult
@@ -47,17 +47,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
/// </summary>
internal static readonly string[] WatchedProcessNames = { "cmd.exe" };
// /c turns the shell into a one-shot scripted invocation (install/script) — ignored. Matched
// as a token anywhere in the ARGUMENTS (the executable path is stripped first) so combined
// switches like /d/q/c are caught too.
private static readonly Regex RunAndExitSwitchRegex =
new Regex(@"/c\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// cmd.exe honors only the FIRST of /c or /k; everything after that switch is the command
// payload. /c turns the shell into a one-shot scripted invocation (install/script) — ignored.
// /k runs its command and then STAYS interactive (L12) — unlike /c this leaves a shell a
// human can type into (e.g. a technician-planted `cmd /k whoami`), so it is surfaced as a
// low-confidence interactive console instead of being silently ignored.
private static readonly Regex RunAndStaySwitchRegex =
new Regex(@"/k\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// low-confidence interactive console instead of being silently ignored. One ordered scan over
// the ARGUMENTS (executable path stripped first) finds the first switch token, so combined
// switches like /d/q/c are caught and a `/c` inside a /k payload (`cmd /k rem /c`) can no
// longer demote an interactive shell to a scripted one.
private static readonly Regex FirstRunSwitchRegex =
new Regex(@"/(?<sw>[ck])\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private enum RunSwitch { None, RunAndExit, RunAndStay }
/// <summary>The first <c>/c</c> or <c>/k</c> switch in the argument tail — cmd.exe precedence.</summary>
private static RunSwitch FirstRunSwitch(string arguments)
{
var m = FirstRunSwitchRegex.Match(arguments);
if (!m.Success) return RunSwitch.None;
return char.ToLowerInvariant(m.Groups["sw"].Value[0]) == 'c' ? RunSwitch.RunAndExit : RunSwitch.RunAndStay;
}
private readonly AgentLogger _logger;
private readonly Func<int, ProcessProbe?> _processProbe;
@@ -282,20 +291,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
{
if (sessionId == 0) return ConsoleVerdict.Ignore;
if (commandLine == null) return ConsoleVerdict.UnclassifiedInteractive;
var arguments = StripExecutable(commandLine);
if (RunAndExitSwitchRegex.IsMatch(arguments)) return ConsoleVerdict.Ignore;
if (RunAndStaySwitchRegex.IsMatch(arguments)) return ConsoleVerdict.InteractiveWithCommand;
return ConsoleVerdict.InteractiveConsole;
switch (FirstRunSwitch(StripExecutable(commandLine)))
{
case RunSwitch.RunAndExit: return ConsoleVerdict.Ignore;
case RunSwitch.RunAndStay: return ConsoleVerdict.InteractiveWithCommand;
default: return ConsoleVerdict.InteractiveConsole;
}
}
/// <summary>True when the command line carries a <c>/c</c> run-and-exit switch (a scripted
/// invocation), false for a bare interactive shell or a <c>/k</c> run-and-stay shell. The
/// executable token is stripped first so a path can never spoof a switch, and switches may
/// be combined (e.g. <c>cmd /d/q/c exit 9</c>).</summary>
/// <summary>True when the FIRST <c>/c</c>-or-<c>/k</c> switch on the command line is <c>/c</c>
/// (a scripted run-and-exit invocation, mirroring cmd.exe precedence), false for a bare
/// interactive shell or a <c>/k</c> run-and-stay shell — even one whose payload contains a
/// later <c>/c</c>. The executable token is stripped first so a path can never spoof a
/// switch, and switches may be combined (e.g. <c>cmd /d/q/c exit 9</c>).</summary>
internal static bool HasScriptArgument(string commandLine)
{
if (string.IsNullOrEmpty(commandLine)) return false;
return RunAndExitSwitchRegex.IsMatch(StripExecutable(commandLine));
return FirstRunSwitch(StripExecutable(commandLine)) == RunSwitch.RunAndExit;
}
/// <summary>Returns the command line with the leading executable token (quoted or
@@ -6,6 +6,11 @@ using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
using System.Net;
using System.Security.Claims;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.DependencyInjection;
namespace AutopilotMonitor.Functions.Tests;
@@ -26,6 +31,7 @@ public class AuthFunctionSideEffectTests
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
private readonly Mock<IMetricsRepository> _metricsRepoMock;
private readonly Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer> _autoApproveEnqueuerMock;
private readonly FakeSignalRNotificationService _signalR;
private readonly AuthFunction _sut;
public AuthFunctionSideEffectTests()
@@ -82,6 +88,7 @@ public class AuthFunctionSideEffectTests
_metricsRepoMock = new Mock<IMetricsRepository>();
_autoApproveEnqueuerMock = new Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer>();
_signalR = new FakeSignalRNotificationService();
_sut = new AuthFunction(
Mock.Of<ILogger<AuthFunction>>(),
globalAdminMock.Object,
@@ -98,7 +105,8 @@ public class AuthFunctionSideEffectTests
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()),
new Mock<AdminIdentityResolver>(
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object);
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
_signalR);
// Default: all fire-and-forget calls succeed
_tenantConfigMock
@@ -123,6 +131,63 @@ public class AuthFunctionSideEffectTests
// HandleNewTenantDomainAsync
// -------------------------------------------------------------------------
// ── RemoveGlobalAdmin cuts live SignalR streams (join-time-only group authz) ──
[Fact]
public async Task RemoveGlobalAdmin_DisconnectsTheRemovedAdminsSignalRConnections()
{
var (req, ctx) = BuildAuthenticatedRequest(callerUpn: "operator@contoso.com");
var response = await _sut.RemoveGlobalAdmin(req, "Removed@Contoso.com", ctx);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(new[] { "removed@contoso.com" }, _signalR.DisconnectedUsers);
}
[Fact]
public async Task RemoveGlobalAdmin_SelfRemovalIsRejectedWithoutDisconnect()
{
var (req, ctx) = BuildAuthenticatedRequest(callerUpn: "operator@contoso.com");
var response = await _sut.RemoveGlobalAdmin(req, "operator@contoso.com", ctx);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Empty(_signalR.DisconnectedUsers);
}
private static (HttpRequestData Req, FunctionContext Ctx) BuildAuthenticatedRequest(string callerUpn)
{
var services = new ServiceCollection();
services.AddOptions();
services.Configure<WorkerOptions>(o => o.Serializer =
new Azure.Core.Serialization.JsonObjectSerializer(
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)));
var provider = services.BuildServiceProvider();
var principal = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim("preferred_username", callerUpn) }, "test"));
var context = new Mock<FunctionContext>();
context.SetupGet(c => c.Items).Returns(
new Dictionary<object, object> { ["ClaimsPrincipal"] = principal });
context.SetupGet(c => c.InstanceServices).Returns(provider);
var req = new Mock<HttpRequestData>(context.Object);
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
req.SetupGet(r => r.Body).Returns(new MemoryStream());
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context.Object));
return (req.Object, context.Object);
}
private sealed class FakeHttpResponseData : HttpResponseData
{
public FakeHttpResponseData(FunctionContext context) : base(context) { }
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
public override HttpHeadersCollection Headers { get; set; } = new();
public override Stream Body { get; set; } = new MemoryStream();
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
}
[Fact]
public async Task HandleNewTenantDomain_WhenUpnDomainIsNotAHostName_DoesNotSeed()
{
@@ -133,12 +133,64 @@ public class TenantAdminManagementFunctionTests
TenantId, CallerUpn, Constants.TenantRoles.Admin, true), Times.Once);
}
// ── revocation cuts live SignalR streams (join-time-only group authz) ────
[Fact]
public async Task Remove_disconnects_the_removed_members_signalr_connections()
{
var h = BuildHarness();
var (req, ctx) = BuildRequest(new { });
var response = await h.Function.RemoveTenantAdmin(req, TenantId, "Demoted@Contoso.com", ctx);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(new[] { "demoted@contoso.com" }, h.SignalR.DisconnectedUsers);
}
[Fact]
public async Task Disable_disconnects_the_disabled_members_signalr_connections()
{
var h = BuildHarness();
var (req, ctx) = BuildRequest(new { });
var response = await h.Function.DisableTenantAdmin(req, TenantId, "Demoted@Contoso.com", ctx);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(new[] { "demoted@contoso.com" }, h.SignalR.DisconnectedUsers);
}
[Fact]
public async Task Enable_does_not_disconnect()
{
var h = BuildHarness();
var (req, ctx) = BuildRequest(new { });
await h.Function.EnableTenantAdmin(req, TenantId, "user@contoso.com", ctx);
Assert.Empty(h.SignalR.DisconnectedUsers);
}
[Fact]
public async Task Demotion_below_admin_disconnects_but_promotion_to_admin_does_not()
{
var h = BuildHarness();
var (demote, ctx1) = BuildRequest(new { role = "viewer", canManageBootstrapTokens = false });
await h.Function.UpdateMemberPermissions(demote, TenantId, "User@Contoso.com", ctx1);
Assert.Equal(new[] { "user@contoso.com" }, h.SignalR.DisconnectedUsers);
var (promote, ctx2) = BuildRequest(new { role = "admin", canManageBootstrapTokens = false });
await h.Function.UpdateMemberPermissions(promote, TenantId, "other@contoso.com", ctx2);
Assert.Equal(new[] { "user@contoso.com" }, h.SignalR.DisconnectedUsers);
}
// ── harness ─────────────────────────────────────────────────────────────
private sealed class Harness
{
public TenantAdminManagementFunction Function = default!;
public Mock<IAdminRepository> AdminRepo = default!;
public FakeSignalRNotificationService SignalR = default!;
}
private static Harness BuildHarness()
@@ -157,12 +209,14 @@ public class TenantAdminManagementFunctionTests
NullLogger<TenantAdminsService>.Instance);
var maintenanceRepo = new Mock<IMaintenanceRepository>(MockBehavior.Loose);
var signalR = new FakeSignalRNotificationService();
return new Harness
{
Function = new TenantAdminManagementFunction(
NullLogger<TenantAdminManagementFunction>.Instance, service, maintenanceRepo.Object),
NullLogger<TenantAdminManagementFunction>.Instance, service, maintenanceRepo.Object, signalR),
AdminRepo = adminRepo,
SignalR = signalR,
};
}
@@ -680,6 +680,51 @@ public class TenantConfigPatchServiceTests
Assert.Equal(schema.Count, schema.Select(f => f.Name).Distinct().Count());
}
/// <summary>
/// The full-model PUT (config/{tenantId}) must honour the same deny-list as the field
/// patch: a client body carrying ProDowngradedUtc (retention-downgrade grace anchor),
/// OnboardedAt, DomainName, plan/trial or provenance fields must not change the stored
/// values. Driven by the deny-list itself so a new denied field is covered automatically.
/// </summary>
[Fact]
public void PreserveDeniedFields_OverwritesEveryDeniedModelPropertyFromStored()
{
var props = typeof(TenantConfiguration)
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(p => p.CanWrite && TenantConfigPatchService.BaseDeniedFields.Contains(p.Name))
.ToList();
Assert.Contains(props, p => p.Name == "ProDowngradedUtc");
Assert.Contains(props, p => p.Name == "OnboardedAt");
Assert.Contains(props, p => p.Name == "DomainName");
var stored = new TenantConfiguration { TenantId = "t1" };
var incoming = new TenantConfiguration { TenantId = "t1" };
foreach (var p in props)
{
p.SetValue(stored, Distinct(p.PropertyType, 1));
p.SetValue(incoming, Distinct(p.PropertyType, 2));
Assert.NotEqual(p.GetValue(stored), p.GetValue(incoming));
}
TenantConfigPatchService.PreserveDeniedFields(incoming, stored);
foreach (var p in props)
Assert.Equal(p.GetValue(stored), p.GetValue(incoming));
}
private static object Distinct(Type t, int seed)
{
var u = Nullable.GetUnderlyingType(t) ?? t;
if (u == typeof(string)) return $"value-{seed}";
if (u == typeof(bool)) return seed % 2 == 0;
if (u == typeof(int)) return seed;
if (u == typeof(long)) return (long)seed;
if (u == typeof(DateTime)) return new DateTime(2026, 1, seed, 0, 0, 0, DateTimeKind.Utc);
if (u == typeof(DateTimeOffset)) return new DateTimeOffset(2026, 1, seed, 0, 0, 0, TimeSpan.Zero);
if (u.IsEnum) return Enum.GetValues(u).GetValue(seed % Enum.GetValues(u).Length)!;
throw new NotSupportedException($"Add a Distinct() case for {u.Name}");
}
[Fact]
public void FieldsSchema_DeniedFields_MatchThePatchDenyList()
{
@@ -18,17 +18,31 @@ public class TenantAdminManagementFunction
private readonly ILogger<TenantAdminManagementFunction> _logger;
private readonly TenantAdminsService _tenantAdminsService;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly ISignalRNotificationService _signalRService;
public TenantAdminManagementFunction(
ILogger<TenantAdminManagementFunction> logger,
TenantAdminsService tenantAdminsService,
IMaintenanceRepository maintenanceRepo)
IMaintenanceRepository maintenanceRepo,
ISignalRNotificationService signalRService)
{
_logger = logger;
_tenantAdminsService = tenantAdminsService;
_maintenanceRepo = maintenanceRepo;
_signalRService = signalRService;
}
/// <summary>
/// SignalR group authorization is evaluated only at join time (SignalRAddToGroupFunction), so a
/// member whose role was removed, disabled or lowered would keep their already-joined
/// 'tenant-{tid}' broadcast, notify-group and session-group streams until the socket drops.
/// Cutting the UPN's connections (negotiate binds userId = lowercased UPN) forces a reconnect that
/// re-runs the join gates against the new role. Coarse by design — a still-authorized client
/// simply reconnects — and mirrors the delegated-admin / tenant-group revoke paths.
/// </summary>
private Task CutLiveStreamsAsync(string adminUpn) =>
_signalRService.DisconnectUserAsync(adminUpn.ToLowerInvariant());
/// <summary>
/// GET /api/tenants/{tenantId}/admins
/// Gets all admins for a tenant
@@ -158,6 +172,8 @@ public class TenantAdminManagementFunction
upn!
);
await CutLiveStreamsAsync(adminUpn);
_logger.LogInformation($"Tenant Admin removed: {adminUpn} from tenant {requestCtx.TargetTenantId} by {upn}");
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -194,6 +210,8 @@ public class TenantAdminManagementFunction
new Dictionary<string, string> { { "Action", "Disable" } }
);
await CutLiveStreamsAsync(adminUpn);
_logger.LogInformation($"Tenant Admin disabled: {adminUpn} for tenant {requestCtx.TargetTenantId} by {upn}");
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -308,6 +326,10 @@ public class TenantAdminManagementFunction
}
);
// Only Admin holds the admin-tier notify group; any other target role may have lost it.
if (role != AutopilotMonitor.Shared.Constants.TenantRoles.Admin)
await CutLiveStreamsAsync(adminUpn);
_logger.LogInformation("Member permissions updated: {AdminUpn} -> role={Role} in tenant {TenantId} by {Upn}", adminUpn, role, requestCtx.TargetTenantId, upn);
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -110,11 +110,15 @@ namespace AutopilotMonitor.Functions.Functions.Config
// admins are served the FULL config so this is normally a no-op for them.)
config.RestoreRedactedSecretsFrom(existingConfig);
// DomainName is tenant identity: seeded once from the first login's UPN (AuthFunction)
// and never client-writable — for ANY caller, GA included — mirroring the patch flow
// ("identity — never writable"). Without this preserve the full-model PUT persisted an
// arbitrary string here, which is rendered into the welcome/farewell mails.
config.DomainName = existingConfig.DomainName;
// System-owned fields are never client-writable — for ANY caller, GA included:
// identity (DomainName, seeded once from the first login's UPN), onboarding/auth
// provenance (OnboardedAt/OnboardedBy/LastAuthClientId*), app-reg homing
// (HomedAppClientId — the 2026-07-31 stale-round-trip incident), and plan/trial/
// downgrade state (PlanTier/Trial*/ProDowngradedUtc — mutable only via the plan
// and trial endpoints; a client-supplied ProDowngradedUtc would refresh the
// retention-downgrade grace anchor indefinitely). The set is the PATCH endpoint's
// BaseDeniedFields, so the two write paths cannot drift.
TenantConfigPatchService.PreserveDeniedFields(config, existingConfig);
// Shared model validation (rate limits, contact address, webhook/Teams SSRF, custom
// headers, notification channels, diagnostics SAS, retention cap) — single source
@@ -164,26 +168,6 @@ namespace AutopilotMonitor.Functions.Functions.Config
config.DisabledUntil = existingConfig.DisabledUntil;
}
// App-reg homing is IMMUTABLE via the generic PUT — for ALL callers, GA included.
// The only writers are the consent-driven auto-flip and POST config/{tenantId}/
// app-homing (AppHomingFunction). Prod incident 2026-07-31 18:03Z: the consent
// auto-flip landed, and 600ms later the frontend's routine "persist validation
// toggle" PUT — round-tripping a config loaded BEFORE the flip — silently reverted
// the homing to legacy, because a GA caller's stale null was indistinguishable
// from an intentional revert. A full-model PUT can never carry that intent.
config.HomedAppClientId = existingConfig.HomedAppClientId;
// Last-seen auth provenance is system-written (AuthFunction) — never via PUT,
// for ANY caller: a round-tripped stale view must not rewind the observability.
config.LastAuthClientId = existingConfig.LastAuthClientId;
config.LastAuthClientIdSince = existingConfig.LastAuthClientIdSince;
// OnboardedBy is system-written once at first login (AuthFunction) and immutable
// after that — never via PUT, for ANY caller: a revoke → re-approve cycle
// auto-promotes whatever UPN is stored here, so a client-supplied value could
// smuggle in an arbitrary promotable user (or null out the audit provenance).
config.OnboardedBy = existingConfig.OnboardedBy;
// Safety: if GA gate is off, force UnrestrictedMode to false
if (!config.UnrestrictedModeEnabled)
{
@@ -193,16 +177,6 @@ namespace AutopilotMonitor.Functions.Functions.Config
// MaxNdjsonPayloadSizeMB is table-only — always preserve existing value
config.MaxNdjsonPayloadSizeMB = existingConfig.MaxNdjsonPayloadSizeMB;
// Plan/trial fields are mutable ONLY via the dedicated plan/trial endpoints
// (PATCH config/{tenantId}/plan, POST config/{tenantId}/trial). The generic PUT
// deserializes the full model, so without this preserve a round-tripped stale
// view would silently reset the tenant's edition/trial — for ALL callers, GA included.
config.PlanTier = existingConfig.PlanTier;
config.TrialExpiresUtc = existingConfig.TrialExpiresUtc;
config.TrialStartedUtc = existingConfig.TrialStartedUtc;
config.TrialConsumed = existingConfig.TrialConsumed;
config.TrialGrantedBy = existingConfig.TrialGrantedBy;
// Save configuration (retention cap already enforced by ValidateModel above)
var writeSource = ResolveWriteSource(req.Query["intent"]);
await _configService.SaveConfigurationAsync(config, writeSource, null);
@@ -34,6 +34,7 @@ public class AuthFunction
private readonly Services.Activation.ITenantAutoApproveEnqueuer _tenantAutoApproveEnqueuer;
private readonly EntraAppRegistry _appRegistry;
private readonly AdminIdentityResolver _identityResolver;
private readonly ISignalRNotificationService _signalRService;
public AuthFunction(
ILogger<AuthFunction> logger,
@@ -48,10 +49,12 @@ public class AuthFunction
McpUserService mcpUserService,
Services.Activation.ITenantAutoApproveEnqueuer tenantAutoApproveEnqueuer,
EntraAppRegistry appRegistry,
AdminIdentityResolver identityResolver)
AdminIdentityResolver identityResolver,
ISignalRNotificationService signalRService)
{
_logger = logger;
_identityResolver = identityResolver;
_signalRService = signalRService;
_globalAdminService = globalAdminService;
_delegatedAdminService = delegatedAdminService;
_tenantConfigService = tenantConfigService;
@@ -316,6 +319,12 @@ public class AuthFunction
await _globalAdminService.RemoveGlobalAdminAsync(upn);
// SignalR group authorization is join-time only: a removed Global Admin/Reader with a live
// connection in 'global-admins' would keep receiving cross-tenant pushes until the socket
// drops. Cut the UPN's connections (negotiate binds userId = lowercased UPN); a reconnect
// re-runs the join gates against the now-missing platform role.
await _signalRService.DisconnectUserAsync(upn.ToLowerInvariant());
_logger.LogInformation($"Global Admin removed: {upn} by {currentUpn}");
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -127,6 +127,25 @@ namespace AutopilotMonitor.Functions.Services
"ProDowngradedUtc",
};
/// <summary>
/// Full-model PUT counterpart of <see cref="BaseDeniedFields"/>: copies every denied model
/// property from the stored config onto the incoming one so a client body can never write
/// them — for ANY caller, GA included. The PUT deserializes the whole model, so a hand-kept
/// preserve list drifts (ProDowngradedUtc/OnboardedAt were missing, letting a tenant admin
/// refresh the retention-downgrade grace anchor forever). Deriving it from the single
/// deny-list keeps both write paths in lock-step. Server-stamped fields (TenantId,
/// UpdatedBy, LastUpdated) are copied too; the PUT overwrites them afterwards.
/// </summary>
internal static void PreserveDeniedFields(TenantConfiguration target, TenantConfiguration stored)
{
foreach (var name in BaseDeniedFields)
{
// PartitionKey/RowKey/Timestamp/ETag are table plumbing, not model properties.
if (ModelProperties.TryGetValue(name, out var prop))
prop.SetValue(target, prop.GetValue(stored));
}
}
/// <summary>
/// Additional deny-list for non-GA tiers (phase 2): the GA-only toggles the PUT
/// endpoint silently reverts for tenant admins are an explicit 400 here.