Compare commits

..

8 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
Oliver Kieselbach d294415d29 fix(security): CmTraceLogParser parses in linear time (first open / last parseable trailer, timeout-backed trailer regex) instead of a greedy .* regex that backtracked quadratically on hostile lines without a timeout; agent 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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 00:48:29 +02:00
Oliver Kieselbach 76af1234ed fix(security): BlockedDevices rows are keyed by the canonicalized serial (trim + upper-invariant) so case-variant serials from a rogue agent or an admin's inventory copy still hit the block/kill row; legacy verbatim-case rows are dual-read and lazily re-keyed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 00:27:07 +02:00
Oliver Kieselbach 0515523e49 fix(security): guardrails.json values are emitted into generated TS through a complete encoder (JSON.stringify + U+2028/29) and rejected up-front if they contain control or line-terminator characters; new guardrails schema validated in combine-rules before any bot commit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 00:19:52 +02:00
30 changed files with 1865 additions and 158 deletions
+6
View File
@@ -54,6 +54,12 @@ jobs:
ajv validate -s rules/schema/ime-log-pattern.schema.json -d "$f" --spec=draft2020 -c ajv-formats
done
# guardrails.json values are baked verbatim into generated TypeScript that is
# auto-committed to main and shipped to the portal - the schema forbids control
# and line-terminator characters so a "data" PR can never become code.
- name: Validate Guardrails
run: ajv validate -s rules/schema/guardrails.schema.json -d rules/guardrails.json --spec=draft2020 -c ajv-formats
- name: Combine rules into dist/
run: node rules/scripts/combine.js
+38 -1
View File
@@ -4,7 +4,7 @@ title: CMTrace Time Resolution — Per-Line Self-Anchoring
description: How the agent converts bias-less CMTrace local times to UTC without trusting any process's timezone belief — each provably fresh line anchors its own offset against the agent clock on the 15-minute grid; everything not provably fresh falls back uniformly and says so.
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime
tags: [agent, ime, cmtrace, timestamps, timezone, calibration, provenance]
timestamp: 2026-08-20
timestamp: 2026-08-30
---
# Problem
@@ -86,6 +86,40 @@ with the process zone).
* Mixed pairs remain possible across a restart (start resolved from backlog fallback,
completion line-anchored) and are recognizable via provenance.
# Line parsing (bounded cost)
`CmTraceLogParser.TryParseLine` (Shared) is the single CMTrace line parser for the agent
(`ImeLogTracker`, `LogParserCollector`, `StallProbeCollector`) and the backend
(`TestLogPatternFunction`). Both feed it input an outsider influences: tenant-supplied
sample lines (200 x 8 KB per request) on the backend, and on the agent every tailed log
file, including entries that IME assembles from installer or script output. It is
therefore not one regex. The former shape — a greedy `(?<message>.*)` in front of the
`]LOG]!><time="` literal, unanchored, no match timeout — re-tried every `<![LOG[`
occurrence as a fresh start and backtracked the message group across every later character
for each: quadratic in line length (an 8 KB line of repeated prefixes and `]` filler cost
~7 million engine steps; a 1 MB assembled entry would have spun the SYSTEM agent for
hours and blacked out IME tracking for the rest of the enrollment).
The parser reproduces exactly what that regex returned, in linear time: the message begins
after the first `<![LOG[` (a later start could only match when the first one does, since
`.*` spans the difference) and ends at the last `]LOG]!><time="` occurrence whose trailer
parses — earlier occurrences are tried in turn, which is the order greedy backtracking
visited them. The trailer is checked with a `\G`-anchored regex that has no ambiguous
quantifier and a 1-second match timeout as a backstop; a timeout means "not a CMTrace
line" (callers already have that path: raw-text matching in the tracker, `parse_failed`
on the backend), never an exception. `CmTraceLogParserTests` pins the equivalence against
the legacy regex over the shapes that matter (multiline messages, bias suffix, trailer
literal inside the message, a last trailer that fails to parse, BOM prefix) and the
reported worst case at 200 lines.
On the agent, `ImeLogTracker` additionally caps an assembled multiline entry at 100 raw
lines **and** 1 MB of characters (`MaxMultiLineBufferChars`). A capped entry is dropped
and logged at **Warning** — visible at the default `Info` level, so a real IME entry that
large shows up in the client log and can be acted on. The remaining physical lines of a
dropped entry are skipped until it closes or a new `<![LOG[` entry begins; previously they
fell through to raw-text pattern matching, so fragments of a discarded entry could fire
patterns without CMTrace context.
# Backend regression tripwire
The backend watches for the failure mode this design eliminates. On every terminal ingest
@@ -129,3 +163,6 @@ precisely so an anchoring regression can never surface as a customer finding.
* `tasks/todo.md` (2026-08-20) — full evidence trail: field measurements, the revert,
the two-writer-era fixture, and the disproven foreign-binary hypothesis (the SHA
mismatch was the known release race against the 5-minute AdminConfiguration cache).
* `CmTraceLogParser.TryParseLine` + `CmTraceLogParserTests` — the linear parser and its
legacy-regex equivalence oracle; `ImeLogTracker.LogProcessing.cs` multiline buffering
(`MaxMultiLineBufferLines` / `MaxMultiLineBufferChars`, `skippingDroppedEntry`).
+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`
+2 -1
View File
@@ -28,9 +28,10 @@ https://docs.autopilotmonitor.com (separate repository).
* [Zero User-Targeted Apps — the starved user-phase completion gate](agent/zero-user-apps-completion-gate.md) - Why a device with no user-targeted Intune apps could never satisfy the IME user-session gate — IME skips its "Completed user session" line entirely in the zero-app branch — turning the 30-minute completion backstop into `esp_exit_without_completion_evidence` on a third of one tenant's enrollments, with the user at the desktop and RealmJoin still installing. The two fixes: read the line IME does write (IME-USER-SESSION-ZERO-APPS), and never fail a session while a completion gate with its own armed deadline is deliberately holding it.
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
* [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.
* [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.
+10
View File
@@ -1,5 +1,15 @@
# 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.
## 2026-08-30 (1)
* **Update**: `agent/diagnostics-package.md` — reads are validated on the handle, not the path: `PinnedSourceFolder` pins each section folder (its final path must equal the validated path; held open so the chain cannot be renamed) and opens every file with `FILE_FLAG_OPEN_REPARSE_POINT`, requiring the handle's final path to be `<canonical folder>\<relative path>`. A subdirectory swapped for a junction between enumeration and open is skipped (`SKIPPED (resolved outside validated folder)`) instead of copied into the package.
+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"
]
}
+62
View File
@@ -0,0 +1,62 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/OliverKieselbach/Autopilot-Monitor/main/rules/schema/guardrails.schema.json",
"title": "Autopilot Monitor Gather-Rule Guardrails",
"description": "Agent-side collection allowlists/blocklists. Every string is emitted verbatim into generated TypeScript (web + MCP), so values are single-line and must not contain control characters or the U+2028/U+2029 line terminators.",
"type": "object",
"properties": {
"$schema": { "type": "string" },
"registryPrefixes": { "$ref": "#/$defs/categorizedList" },
"filePrefixes": { "$ref": "#/$defs/stringList" },
"wmiQueryPrefixes": { "$ref": "#/$defs/stringList" },
"eventLogChannels": { "$ref": "#/$defs/categorizedList" },
"blockedEventLogChannels": { "$ref": "#/$defs/stringList" },
"allowedCommands": { "$ref": "#/$defs/categorizedList" },
"diagnosticsPathPrefixes": { "$ref": "#/$defs/stringList" },
"blockedFilePrefixes": { "$ref": "#/$defs/stringList" },
"blockedCommandPatterns": { "$ref": "#/$defs/stringList" },
"blockedInterimTriggerEventTypes": { "$ref": "#/$defs/stringList" },
"maxCommandLength": { "type": "integer", "minimum": 1, "maximum": 100000 }
},
"required": [
"registryPrefixes",
"filePrefixes",
"wmiQueryPrefixes",
"eventLogChannels",
"blockedEventLogChannels",
"allowedCommands",
"diagnosticsPathPrefixes",
"blockedFilePrefixes",
"blockedCommandPatterns",
"maxCommandLength"
],
"additionalProperties": false,
"$defs": {
"safeString": {
"type": "string",
"minLength": 1,
"maxLength": 2000,
"pattern": "^[^\\u0000-\\u001F\\u007F\\u2028\\u2029]+$"
},
"stringList": {
"type": "array",
"items": { "$ref": "#/$defs/safeString" }
},
"categorizedList": {
"type": "array",
"items": {
"type": "object",
"properties": {
"category": { "$ref": "#/$defs/safeString" },
"prefixes": { "$ref": "#/$defs/stringList" },
"channels": { "$ref": "#/$defs/stringList" },
"commands": { "$ref": "#/$defs/stringList" }
},
"required": ["category"],
"minProperties": 2,
"maxProperties": 2,
"additionalProperties": false
}
}
}
}
+58 -5
View File
@@ -97,18 +97,71 @@ const guardrailsOutput = path.resolve(
if (fs.existsSync(guardrailsPath)) {
const guardrails = JSON.parse(fs.readFileSync(guardrailsPath, 'utf8'));
/** Escape a string for use inside a TS string literal (double-quoted). */
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// Every value below is emitted into a TypeScript module that ships in the
// portal bundle (and is auto-committed + auto-deployed from main). A string
// containing a raw newline / CR / U+2028 / U+2029 inside a naive "..." literal
// would terminate the literal and turn the remainder of the value into CODE.
// Two independent defences: (1) reject any control/line-terminator character
// outright — no legitimate prefix, command or channel name contains one —
// and (2) render every literal through a complete encoder (JSON.stringify),
// so even a value that slipped past (1) stays an inert string constant.
// rules/schema/guardrails.schema.json enforces the same constraints in CI
// before this script runs; this in-process check keeps local runs honest.
const UNSAFE_CHAR = /[\u0000-\u001F\u007F\u2028\u2029]/;
const fail = (msg) => {
console.error(`ERROR: guardrails.json ${msg}`);
process.exit(1);
};
const assertSafeString = (value, where) => {
if (typeof value !== 'string' || value.length === 0) {
fail(`${where}: expected a non-empty string, got ${JSON.stringify(value)}`);
}
if (UNSAFE_CHAR.test(value)) {
fail(`${where}: control or line-terminator character in ${JSON.stringify(value)}`);
}
};
const assertStringList = (list, where) => {
if (!Array.isArray(list)) fail(`${where}: expected an array`);
list.forEach((s, i) => assertSafeString(s, `${where}[${i}]`));
};
const assertCategorized = (groups, itemsKey, where) => {
if (!Array.isArray(groups)) fail(`${where}: expected an array`);
groups.forEach((g, i) => {
assertSafeString(g && g.category, `${where}[${i}].category`);
assertStringList(g[itemsKey], `${where}[${i}].${itemsKey}`);
});
};
assertCategorized(guardrails.registryPrefixes, 'prefixes', 'registryPrefixes');
assertCategorized(guardrails.allowedCommands, 'commands', 'allowedCommands');
assertCategorized(guardrails.eventLogChannels, 'channels', 'eventLogChannels');
for (const key of [
'filePrefixes', 'wmiQueryPrefixes', 'diagnosticsPathPrefixes', 'blockedFilePrefixes',
'blockedEventLogChannels', 'blockedCommandPatterns'
]) {
assertStringList(guardrails[key], key);
}
assertStringList(guardrails.blockedInterimTriggerEventTypes ?? [], 'blockedInterimTriggerEventTypes');
if (!Number.isInteger(guardrails.maxCommandLength) || guardrails.maxCommandLength < 1) {
fail(`maxCommandLength: expected a positive integer, got ${JSON.stringify(guardrails.maxCommandLength)}`);
}
/**
* Render a string as a complete, self-contained TS double-quoted literal.
* JSON.stringify escapes backslash, quote and every C0 control character;
* U+2028/U+2029 are escaped on top because JSON does not require it.
*/
const lit = (s) => JSON.stringify(s).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
/** Render an array of strings as a TS readonly array. */
const flatArray = (items) =>
items.map((s) => ` "${esc(s)}",`).join('\n');
items.map((s) => ` ${lit(s)},`).join('\n');
/** Render a categorized list as a TS readonly array of { category, items }. */
const categorizedArray = (groups, itemsKey) =>
groups.map((g) => {
const items = g[itemsKey].map((s) => ` "${esc(s)}",`).join('\n');
return ` {\n category: "${esc(g.category)}",\n items: [\n${items}\n ],\n },`;
const items = g[itemsKey].map((s) => ` ${lit(s)},`).join('\n');
return ` {\n category: ${lit(g.category)},\n items: [\n${items}\n ],\n },`;
}).join('\n');
// Flatten categorized lists for validation
@@ -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);
}
}
}
@@ -1,4 +1,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared.Logging;
using Xunit;
@@ -137,5 +142,107 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
Assert.NotNull(entry);
Assert.Equal(hugeMessage.Length, entry.Message.Length);
}
// ── Hostile input: cost must be linear, never quadratic ───────────────
/// <summary>
/// The legacy single-regex parser (greedy <c>.*</c> message group before the trailer
/// literal, unanchored, Singleline, no timeout). Kept here ONLY as the semantic oracle:
/// the linear parser must return exactly what this regex returned for every line that
/// matches, and false for every line it rejected.
/// </summary>
private static readonly Regex LegacyRegex = new Regex(
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)(?<bias>[+-]\d{1,4})?""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
RegexOptions.Singleline);
public static IEnumerable<object[]> EquivalenceLines()
{
const string trailer = "]LOG]!><time=\"06:08:04.8834397\" date=\"2-8-2026\" component=\"C\" context=\"\" type=\"2\" thread=\"7\" file=\"\">";
const string badTrailer = "]LOG]!><time=\"06:08:04\" date=\"2-8-2026\" component=\"C\" context=\"\" type=\"x\" thread=\"7\" file=\"\">";
yield return new object[] { CmTraceLine("plain", "06:08:04.8834397", "2-8-2026") };
yield return new object[] { CmTraceLine("multi\nline\r\nmessage", "06:08:04.8834397", "2-8-2026") };
yield return new object[] { CmTraceLine("bias", "11:46:19.0226610+480", "7-29-2026") };
yield return new object[] { CmTraceLine("", "06:08:04.8834397", "2-8-2026") };
// The trailer literal INSIDE the message: greedy .* takes the LAST parseable trailer.
yield return new object[] { "<![LOG[inner " + trailer + " outer" + trailer };
// Last trailer occurrence does not parse (type="x") → the earlier one wins, and the
// failing trailer text is part of the message.
yield return new object[] { "<![LOG[first" + trailer + " second" + badTrailer };
// Only a broken trailer → false.
yield return new object[] { "<![LOG[msg" + badTrailer };
// A nested "<![LOG[" inside the message: the FIRST open wins (message keeps the inner one).
yield return new object[] { "<![LOG[outer <![LOG[inner" + trailer };
// Whitespace variants the old regex tolerated (\s+ between attributes).
yield return new object[] { "<![LOG[ws]LOG]!><time=\"06:08:04.8834397\" date=\"2-8-2026\"\tcomponent=\"C\" context=\"\" type=\"1\" thread=\"1\" file=\"x.cpp:12\">" };
// BOM-prefixed first line of a fresh log.
yield return new object[] { "\uFEFF" + CmTraceLine("bom", "06:08:04.8834397", "2-8-2026") };
// Trailer text without its leading ']' → false on both.
yield return new object[] { "<![LOG[" + trailer.Substring(1) };
yield return new object[] { "<![LOG[truncated" };
}
[Theory]
[MemberData(nameof(EquivalenceLines))]
public void TryParseLine_is_semantically_identical_to_the_legacy_greedy_regex(string line)
{
var legacy = LegacyRegex.Match(line);
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
Assert.Equal(legacy.Success, ok);
if (!legacy.Success) return;
Assert.NotNull(entry);
Assert.Equal(legacy.Groups["message"].Value, entry.Message);
Assert.Equal(legacy.Groups["component"].Value, entry.Component);
Assert.Equal(int.Parse(legacy.Groups["type"].Value), entry.Type);
Assert.Equal(legacy.Groups["thread"].Value, entry.Thread);
var expectedBias = legacy.Groups["bias"].Success ? int.Parse(legacy.Groups["bias"].Value) : (int?)null;
Assert.Equal(expectedBias, entry.BiasMinutes);
}
/// <summary>
/// The reported worst case: 585 x "&lt;![LOG[" (4095 chars) + 4097 x "]" — passes the StartsWith
/// gate, never matches, and drove ~7 million backtracking steps through the legacy regex
/// (k prefix restarts x m filler positions). The linear parser has no trailer literal to
/// find and returns false in a handful of string searches.
/// </summary>
[Fact]
public void TryParseLine_rejects_reported_worst_case_in_bounded_time()
{
var line = string.Concat(Enumerable.Repeat("<![LOG[", 585)) + new string(']', 4097);
Assert.Equal(8192, line.Length);
var sw = Stopwatch.StartNew();
for (var i = 0; i < 200; i++)
{
Assert.False(CmTraceLogParser.TryParseLine(line, out _));
}
sw.Stop();
// 200 lines = one maximal backend request. Generous bound for a slow CI box; the
// legacy regex needed seconds here.
Assert.True(sw.ElapsedMilliseconds < 500, $"200 worst-case lines took {sw.ElapsedMilliseconds} ms");
}
/// <summary>
/// The agent variant: a multi-megabyte assembled entry stuffed with prefixes, trailer
/// literals whose attributes never parse, and filler — every occurrence is tried once,
/// each trailer probe fails at its first attribute, total work stays linear.
/// </summary>
[Fact]
public void TryParseLine_rejects_multi_megabyte_hostile_entry_in_bounded_time()
{
var sb = new StringBuilder(2 * 1024 * 1024);
sb.Append("<![LOG[");
while (sb.Length < 1024 * 1024)
sb.Append("<![LOG[").Append("]LOG]!><time=\"x\" ").Append(new string(']', 40)).Append('\n');
var line = sb.ToString();
var sw = Stopwatch.StartNew();
Assert.False(CmTraceLogParser.TryParseLine(line, out _));
sw.Stop();
Assert.True(sw.ElapsedMilliseconds < 1000, $"1 MB hostile entry took {sw.ElapsedMilliseconds} ms");
}
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
@@ -437,5 +438,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
}
private DateTime? _expectedForCurrentEntry;
// ── Multiline buffer caps ─────────────────────────────────────────────
/// <summary>
/// 100 raw lines of unbounded length could still assemble a multi-megabyte entry. The
/// char cap drops such an entry (its marker never fires) and says so at Warning, which
/// is visible at the default Info level — a capped real entry would be news worth
/// reacting to. A same-shaped entry under the cap still assembles and matches.
/// </summary>
[Theory]
[InlineData(2 * 1024 * 1024, false)] // over cap → dropped, warned
[InlineData(64 * 1024, true)] // under cap → assembled, matched
public async Task MultilineEntry_IsDroppedAndWarned_WhenItExceedsTheCharCap(int fillerChars, bool expectMatch)
{
using var h = new Harness();
h.Append("AgentExecutor.log", Line("marker 0", h.Now, TimeSpan.Zero));
await h.Pass();
var matched = new List<string>();
h.Tracker.OnPatternMatched += id => matched.Add(id);
// One entry spanning 3 physical lines; the closing trailer sits on the last line.
var filler = new string('x', fillerChars / 2);
h.Append("AgentExecutor.log", "<![LOG[write output done. output = " + filler);
h.Append("AgentExecutor.log", filler);
h.Append("AgentExecutor.log", Line("marker 7", h.Now, TimeSpan.Zero).Substring("<![LOG[".Length));
h.Now = T0.AddSeconds(10);
await h.Pass();
Assert.Equal(expectMatch ? 1 : 0, matched.Count);
var agentLog = string.Concat(Directory.GetFiles(h.PathOf(""), "*.log")
.Where(f => !Path.GetFileName(f).Equals("AgentExecutor.log", StringComparison.OrdinalIgnoreCase))
.Select(File.ReadAllText));
if (expectMatch)
Assert.DoesNotContain("discarding multiline CMTrace buffer", agentLog);
else
Assert.Contains("discarding multiline CMTrace buffer in AgentExecutor.log", agentLog);
}
}
}
@@ -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)
{
@@ -123,12 +123,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// "write output done. output = ..." spans many lines)
StringBuilder multiLineBuffer = null;
int multiLineCount = 0;
// Set after a capped entry is dropped: its remaining physical lines
// are skipped instead of being matched as raw text, until the entry
// closes or a new entry begins (so no real entry is lost).
bool skippingDroppedEntry = false;
string line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (token.IsCancellationRequested) break;
if (skippingDroppedEntry)
{
if (line.Contains("]LOG]!>")) { skippingDroppedEntry = false; continue; }
if (!line.StartsWith("<![LOG[")) continue;
skippingDroppedEntry = false;
}
// --- Multiline CMTrace buffering ---
// CMTrace entries: <![LOG[message]LOG]!><time=...>
// When message contains newlines, the entry spans multiple lines.
@@ -146,12 +157,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
multiLineBuffer = null;
multiLineCount = 0;
}
else if (multiLineCount >= MaxMultiLineBufferLines)
else if (multiLineCount >= MaxMultiLineBufferLines || multiLineBuffer.Length >= MaxMultiLineBufferChars)
{
// Safety limit — discard to prevent unbounded memory usage
_logger.Debug($"ImeLogTracker: discarding multiline CMTrace buffer after {multiLineCount} lines (corrupt entry?)");
// Safety limit — discard to bound memory and parser work. Warning
// (not Debug) so a capped entry is visible in the client log at the
// default level: a real IME entry this large would be news.
_logger.Warning($"ImeLogTracker: discarding multiline CMTrace buffer in {Path.GetFileName(filePath)} after {multiLineCount} lines / {multiLineBuffer.Length} chars (cap {MaxMultiLineBufferLines} lines / {MaxMultiLineBufferChars} chars) — entry dropped");
multiLineBuffer = null;
multiLineCount = 0;
skippingDroppedEntry = true;
continue;
}
else
@@ -224,6 +224,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private const int MaxScriptOutputLength = 2048;
private const int MaxMultiLineBufferLines = 100;
// Companion byte cap: 100 raw lines of unbounded length could still assemble a
// multi-megabyte entry. Any process able to append to a tailed log can plant one; the
// parser is linear, but memory and the per-entry work must stay bounded regardless.
// Real IME entries (AgentExecutor script output is capped by IME itself) stay far below.
internal const int MaxMultiLineBufferChars = 1024 * 1024;
// Counter for HS-NEW-RESULT JSON parse failures — visible to operators via tracker
// metrics so we can detect IME log-format drift in production.
@@ -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()
{
@@ -0,0 +1,39 @@
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// BlockedDevices rows must be keyed case-insensitively: the enforcement cache
/// (BlockedDeviceService) and Autopilot inventory validation both compare serials
/// OrdinalIgnoreCase, so a case-variant serial must resolve to the same storage row.
/// </summary>
public class TableDeviceSecurityRepositoryKeyTests
{
[Theory]
[InlineData("ABC123", "ABC123")]
[InlineData("abc123", "ABC123")]
[InlineData(" aBc123 ", "ABC123")]
[InlineData(null, "")]
public void CanonicalizeSerial_TrimsAndUpperCases(string? input, string expected)
=> Assert.Equal(expected, TableDeviceSecurityRepository.CanonicalizeSerial(input));
[Theory]
[InlineData("ABC123", "abc123")]
[InlineData("ABC123", "Abc123 ")]
[InlineData("PF/1#2", "pf/1#2")]
public void DeviceRowKey_IsIdenticalForCaseVariants(string a, string b)
=> Assert.Equal(TableDeviceSecurityRepository.DeviceRowKey(a), TableDeviceSecurityRepository.DeviceRowKey(b));
[Fact]
public void DeviceRowKey_EscapesReservedCharacters()
=> Assert.Equal("PF%2F1%232", TableDeviceSecurityRepository.DeviceRowKey("pf/1#2"));
[Fact]
public void LegacyDeviceRowKey_IsNull_WhenAlreadyCanonical()
=> Assert.Null(TableDeviceSecurityRepository.LegacyDeviceRowKey("ABC123"));
[Fact]
public void LegacyDeviceRowKey_IsVerbatimKey_WhenCaseDiffers()
=> Assert.Equal("abc123", TableDeviceSecurityRepository.LegacyDeviceRowKey("abc123"));
}
@@ -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()
{
@@ -1,3 +1,5 @@
using System.Diagnostics;
using System.Linq;
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Functions.Rules;
@@ -142,4 +144,26 @@ public class TestLogPatternFunctionTests
}));
}
}
// ===== hostile sample lines: the built-in CMTrace parser must stay bounded =====
/// <summary>A maximal request (200 x 8192-char lines) built from the reported worst case:
/// each line passes the CMTrace prefix gate and never matches. The built-in parser used to
/// backtrack quadratically here (~1.4e9 steps per request) — before the tenant's own
/// 1-second-timeout regex ever ran.</summary>
[Fact]
public void CmTraceMode_WorstCaseSampleLines_CompleteInBoundedTime()
{
var line = string.Concat(Enumerable.Repeat("<![LOG[", 585)) + new string(']', 4097);
Assert.Equal(TestLogPatternFunction.MaxLineLength, line.Length);
var lines = Enumerable.Repeat(line, TestLogPatternFunction.MaxSampleLines).ToArray();
var sw = Stopwatch.StartNew();
var result = TestLogPatternFunction.EvaluatePattern(AgentRegex("error"), isTextMode: false, lines);
sw.Stop();
Assert.Equal(TestLogPatternFunction.MaxSampleLines, result.ParseFailureCount);
Assert.All(result.Lines, l => Assert.Equal("parse_failed", l.Outcome));
Assert.True(sw.ElapsedMilliseconds < 1000, $"maximal worst-case request took {sw.ElapsedMilliseconds} ms");
}
}
@@ -43,24 +43,44 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(serialNumber))
return (false, null, "Block", null);
var entity = await GetDeviceBlockEntityAsync(tenantId, serialNumber);
if (entity == null)
return (false, null, "Block", null);
var unblockAt = entity.GetDateTimeOffset("UnblockAt")?.UtcDateTime ?? DateTime.MinValue;
if (DateTime.UtcNow >= unblockAt)
return (false, null, "Block", null);
var action = entity.GetString("Action") ?? "Block";
var blockedSessionIds = entity.GetString("BlockedSessionIds");
return (true, unblockAt, action, blockedSessionIds);
}
/// <summary>
/// Point-reads the block row for a serial under its canonical key, falling back to the
/// legacy verbatim-case key for rows written before serials were canonicalized.
/// Returns null when neither exists.
/// </summary>
private async Task<TableEntity?> GetDeviceBlockEntityAsync(string tenantId, string serialNumber)
{
var entity = await TryGetEntityAsync(tenantId, DeviceRowKey(serialNumber));
if (entity != null)
return entity;
var legacyKey = LegacyDeviceRowKey(serialNumber);
return legacyKey == null ? null : await TryGetEntityAsync(tenantId, legacyKey);
}
private async Task<TableEntity?> TryGetEntityAsync(string partitionKey, string rowKey)
{
try
{
var response = await _blockedDevicesTable.GetEntityAsync<TableEntity>(tenantId, EncodeRowKey(serialNumber));
var entity = response?.Value;
if (entity == null)
return (false, null, "Block", null);
var unblockAt = entity.GetDateTimeOffset("UnblockAt")?.UtcDateTime ?? DateTime.MinValue;
if (DateTime.UtcNow >= unblockAt)
return (false, null, "Block", null);
var action = entity.GetString("Action") ?? "Block";
var blockedSessionIds = entity.GetString("BlockedSessionIds");
return (true, unblockAt, action, blockedSessionIds);
var response = await _blockedDevicesTable.GetEntityAsync<TableEntity>(partitionKey, rowKey);
return response?.Value;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return (false, null, "Block", null);
return null;
}
}
@@ -81,6 +101,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
result.Add(MapToBlockedDeviceEntry(entity, tenantId, now));
await MigrateLegacyRowKeyAsync(entity);
}
// Clean up expired entries (fire-and-forget, best effort)
@@ -106,6 +127,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
result.Add(MapToBlockedDeviceEntry(entity, entity.PartitionKey, now));
await MigrateLegacyRowKeyAsync(entity);
}
// Clean up expired entries (fire-and-forget, best effort)
@@ -123,37 +145,31 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
var now = DateTime.UtcNow;
var unblockAt = now.AddHours(durationHours);
var canonicalSerial = CanonicalizeSerial(serialNumber);
// If a session-aware block already exists, merge session IDs
string? blockedSessionIds = blockedSessionId;
if (!string.IsNullOrEmpty(blockedSessionId))
{
try
{
var existing = await _blockedDevicesTable.GetEntityAsync<TableEntity>(tenantId, EncodeRowKey(serialNumber));
var existingSessionIds = existing?.Value?.GetString("BlockedSessionIds");
var existing = await GetDeviceBlockEntityAsync(tenantId, serialNumber);
var existingSessionIds = existing?.GetString("BlockedSessionIds");
if (existingSessionIds == null)
{
// Existing whole-device block takes precedence — don't downgrade to session-aware
if (existing?.Value != null)
blockedSessionIds = null;
}
else
{
// Merge: append new session ID if not already present
blockedSessionIds = MergeSessionId(existingSessionIds, blockedSessionId);
}
}
catch (RequestFailedException ex) when (ex.Status == 404)
if (existingSessionIds == null)
{
// No existing record — use single session ID
// Existing whole-device block takes precedence — don't downgrade to session-aware
if (existing != null)
blockedSessionIds = null;
}
else
{
// Merge: append new session ID if not already present
blockedSessionIds = MergeSessionId(existingSessionIds, blockedSessionId);
}
}
var entity = new TableEntity(tenantId, EncodeRowKey(serialNumber))
var entity = new TableEntity(tenantId, DeviceRowKey(serialNumber))
{
["SerialNumber"] = serialNumber,
["SerialNumber"] = canonicalSerial,
["BlockedAt"] = now,
["UnblockAt"] = unblockAt,
["BlockedByEmail"] = blockedByEmail ?? string.Empty,
@@ -166,7 +182,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
entity["BlockedSessionIds"] = blockedSessionIds;
await _blockedDevicesTable.UpsertEntityAsync(entity);
await _publisher.PublishAsync("device.blocked", new { tenantId, serialNumber, action, durationHours }, tenantId);
await DeleteLegacyRowAsync(tenantId, serialNumber);
await _publisher.PublishAsync("device.blocked", new { tenantId, serialNumber = canonicalSerial, action, durationHours }, tenantId);
}
internal static string MergeSessionId(string? existingList, string newSessionId)
@@ -189,16 +206,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public async Task UnblockDeviceAsync(string tenantId, string serialNumber)
{
try
{
await _blockedDevicesTable.DeleteEntityAsync(tenantId, EncodeRowKey(serialNumber));
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Already removed
}
await DeleteIgnoringNotFoundAsync(tenantId, DeviceRowKey(serialNumber));
await DeleteLegacyRowAsync(tenantId, serialNumber);
await _publisher.PublishAsync("device.unblocked", new { tenantId, serialNumber }, tenantId);
await _publisher.PublishAsync("device.unblocked", new { tenantId, serialNumber = CanonicalizeSerial(serialNumber) }, tenantId);
}
// -----------------------------------------------------------------------
@@ -396,6 +407,85 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
/// <summary>
/// Serial numbers are matched case-insensitively everywhere else (agent inventory
/// validation, BlockedDeviceService cache), so the storage key must not depend on the
/// casing the admin typed or the agent reported: trim + upper-case invariant.
/// </summary>
internal static string CanonicalizeSerial(string? serialNumber)
=> (serialNumber ?? string.Empty).Trim().ToUpperInvariant();
/// <summary>Canonical BlockedDevices RowKey for a serial number.</summary>
internal static string DeviceRowKey(string? serialNumber)
=> EncodeRowKey(CanonicalizeSerial(serialNumber));
/// <summary>
/// RowKey a pre-canonicalization row would have been written under (verbatim case),
/// or null when it is identical to the canonical key.
/// </summary>
internal static string? LegacyDeviceRowKey(string? serialNumber)
{
var legacy = EncodeRowKey(serialNumber ?? string.Empty);
return string.Equals(legacy, DeviceRowKey(serialNumber), StringComparison.Ordinal) ? null : legacy;
}
/// <summary>
/// Re-keys a row written under a non-canonical RowKey to its canonical key (best effort).
/// Runs from the list paths so the pre-canonicalization backlog drains over time.
/// </summary>
private async Task MigrateLegacyRowKeyAsync(TableEntity entity)
{
var serial = entity.GetString("SerialNumber") ?? DecodeRowKey(entity.RowKey);
var canonicalKey = DeviceRowKey(serial);
if (string.Equals(entity.RowKey, canonicalKey, StringComparison.Ordinal))
return;
try
{
var migrated = new TableEntity(entity.PartitionKey, canonicalKey);
foreach (var kv in entity)
{
if (kv.Key is "PartitionKey" or "RowKey" or "Timestamp" or "odata.etag" or "ETag")
continue;
migrated[kv.Key] = kv.Value;
}
migrated["SerialNumber"] = CanonicalizeSerial(serial);
// Never clobber a canonical row that already exists (it is the newer write).
await _blockedDevicesTable.AddEntityAsync(migrated);
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
// Canonical row already present — just drop the legacy duplicate below.
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to migrate legacy BlockedDevices row {PartitionKey}/{RowKey}", entity.PartitionKey, entity.RowKey);
return;
}
await DeleteIgnoringNotFoundAsync(entity.PartitionKey, entity.RowKey);
}
private async Task DeleteLegacyRowAsync(string tenantId, string serialNumber)
{
var legacyKey = LegacyDeviceRowKey(serialNumber);
if (legacyKey != null)
await DeleteIgnoringNotFoundAsync(tenantId, legacyKey);
}
private async Task DeleteIgnoringNotFoundAsync(string partitionKey, string rowKey)
{
try
{
await _blockedDevicesTable.DeleteEntityAsync(partitionKey, rowKey);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Already removed
}
}
/// <summary>Azure Table RowKey must not contain /\#? and must be &lt;= 1KB. URL-encode to be safe.</summary>
internal static string EncodeRowKey(string value)
=> Uri.EscapeDataString(value);
@@ -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.
@@ -73,32 +73,84 @@ namespace AutopilotMonitor.Shared.Logging
/// </summary>
public static class CmTraceLogParser
{
// Pre-compiled regex for the CMTrace format. The time field may carry a UTC-bias
// suffix in minutes ("06:08:04.8834397+480", GetTimeZoneInformation convention:
// UTC = local + bias) — without the optional bias group such lines would not match
// at all and their content would be invisible to every pattern.
private static readonly Regex CmTraceRegex = new Regex(
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)(?<bias>[+-]\d{1,4})?""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
RegexOptions.Compiled | RegexOptions.Singleline
/// <summary>
/// Every CMTrace line has the shape <c>&lt;![LOG[message]LOG]!&gt;&lt;time=... file=""&gt;</c>. The
/// parser deliberately does NOT express that as one regex with a greedy <c>.*</c> message
/// group in front of the <c>]LOG]!&gt;&lt;time="</c> literal: on a line that never matches, such a
/// regex re-tries every <c>&lt;![LOG[</c> occurrence as a fresh start and, for each, backtracks
/// <c>.*</c> across every later character — Theta(k*m), quadratic in line length. Both sinks
/// parse attacker-influenced input (tenant-supplied sample lines on the backend, tailed log
/// files assembled into multi-line entries on the SYSTEM agent), so the cost must be linear.
///
/// <para>
/// The greedy regex's semantics are reproduced exactly with string search: the message
/// starts after the FIRST <c>&lt;![LOG[</c> (a later start can only match when the first one
/// does too, since <c>.*</c> spans the difference), and the message ends at the LAST
/// <c>]LOG]!&gt;&lt;time="</c> occurrence whose trailer parses — earlier occurrences are tried in
/// turn exactly as backtracking would. The trailer regex is anchored with <c>\G</c>, has no
/// ambiguous quantifier, and additionally carries a match timeout as a backstop; a timeout
/// is reported as "does not match", never thrown.
/// </para>
/// </summary>
private const string LogOpen = "<![LOG[";
private const string TrailerOpen = "]LOG]!><time=\"";
// The time field may carry a UTC-bias suffix in minutes ("06:08:04.8834397+480",
// GetTimeZoneInformation convention: UTC = local + bias) — without the optional bias
// group such lines would not match at all and their content would be invisible to
// every pattern.
private static readonly Regex TrailerRegex = new Regex(
@"\G\]LOG\]!><time=""(?<time>[\d:.]+)(?<bias>[+-]\d{1,4})?""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
RegexOptions.Compiled,
TimeSpan.FromSeconds(1)
);
/// <summary>
/// Attempts to parse a single line of CMTrace-formatted log.
/// Returns true if parsing succeeded, false if the line doesn't match the format.
/// Cost is linear in the line length for arbitrary input; it never throws.
/// </summary>
public static bool TryParseLine(string? line, [NotNullWhen(true)] out CmTraceLogEntry? entry)
{
entry = null;
// netstandard2.0's IsNullOrEmpty lacks [NotNullWhen(false)] — assert non-null after it.
if (string.IsNullOrEmpty(line) || !line!.StartsWith("<![LOG["))
// StartsWith is culture-sensitive on purpose: a leading BOM (U+FEFF) is ignorable
// there, so a freshly created log's first line still parses.
if (string.IsNullOrEmpty(line) || !line!.StartsWith(LogOpen))
return false;
var match = CmTraceRegex.Match(line);
if (!match.Success)
var open = line.IndexOf(LogOpen, StringComparison.Ordinal);
if (open < 0)
return false;
var messageStart = open + LogOpen.Length;
Match? match = null;
var trailerPos = line.LastIndexOf(TrailerOpen, StringComparison.Ordinal);
try
{
while (trailerPos >= messageStart)
{
var candidate = TrailerRegex.Match(line, trailerPos);
if (candidate.Success)
{
match = candidate;
break;
}
if (trailerPos == messageStart)
break;
trailerPos = line.LastIndexOf(TrailerOpen, trailerPos - 1, StringComparison.Ordinal);
}
}
catch (RegexMatchTimeoutException)
{
return false;
}
if (match == null)
return false;
var message = match.Groups["message"].Value;
var message = line.Substring(messageStart, trailerPos - messageStart);
var timeStr = match.Groups["time"].Value;
var biasStr = match.Groups["bias"].Value;
var dateStr = match.Groups["date"].Value;