mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1daf631a02 | |||
| d0190f443d | |||
| e96f49799f | |||
| 4864b2d21d | |||
| 56c37866d1 | |||
| 2852418413 | |||
| 268d8652f1 | |||
| 66f5543fb7 | |||
| 21d200a1e4 | |||
| 8c0cb2c423 | |||
| 84d06e1cf6 | |||
| 88dfd331dd | |||
| d294415d29 | |||
| 76af1234ed | |||
| 0515523e49 | |||
| a47e94f9a5 | |||
| ecfa59d049 | |||
| 6c50a54249 | |||
| 6e3487756a | |||
| dbb154bae9 |
@@ -76,12 +76,23 @@ jobs:
|
||||
- name: Reserve build number (counter blob CAS)
|
||||
id: version
|
||||
shell: pwsh
|
||||
# Input and secret reach the script only as environment variables, never as script
|
||||
# text: ${{ }} is substituted before pwsh parses the block, so a crafted
|
||||
# build_number could otherwise break out of the quotes and run arbitrary code.
|
||||
env:
|
||||
BUILD_NUMBER_OVERRIDE: ${{ inputs.build_number }}
|
||||
AGENT_SAS_TOKEN: ${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}
|
||||
run: |
|
||||
$override = 0
|
||||
if ('${{ inputs.build_number }}' -ne '') { $override = [int]'${{ inputs.build_number }}' }
|
||||
if ($env:BUILD_NUMBER_OVERRIDE -ne '') {
|
||||
if ($env:BUILD_NUMBER_OVERRIDE -notmatch '^\d+$') {
|
||||
throw "build_number must be a positive integer, got '$($env:BUILD_NUMBER_OVERRIDE)'"
|
||||
}
|
||||
$override = [int]$env:BUILD_NUMBER_OVERRIDE
|
||||
}
|
||||
|
||||
$counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v$($env:LINE_MAJOR).txt"
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' -Override $override
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -SasToken $env:AGENT_SAS_TOKEN -Override $override
|
||||
|
||||
$csproj = Get-Content $env:CSPROJ_PATH -Raw
|
||||
if ($csproj -match '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
type: concept
|
||||
title: Diagnostics Package
|
||||
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, caps, and the package manifest.
|
||||
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, the handle-validated reads that close the enumerate-then-open junction race, caps, and the package manifest.
|
||||
resource: agent
|
||||
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal]
|
||||
timestamp: 2026-08-23
|
||||
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal, security]
|
||||
timestamp: 2026-08-30
|
||||
---
|
||||
|
||||
# Schema
|
||||
@@ -90,10 +90,50 @@ validates **admin-typed** paths: allowlisted prefixes, wildcard only in the last
|
||||
`C:\Users` blocked except the token's `AppData\Local|Roaming`. The catalog is reviewed code,
|
||||
exactly like the five sections that never went through the guard before — and
|
||||
`%ProgramData%\RealmJoin` is not on the allowlist at all. What does apply to every section:
|
||||
the reparse-point skip (files and directories), the `.evtx` channel export, and the caps
|
||||
the handle-validated read (next section), the `.evtx` channel export, and the caps
|
||||
(100 MB per file, 500 MB total, 5000 files) tracked globally across sections by
|
||||
`BudgetTracker`, with every skip recorded in the manifest and `_TRUNCATED.txt`.
|
||||
|
||||
## Reads are validated on the handle, not the path
|
||||
|
||||
Every guard above judges a path string. Reading the bytes from that string later —
|
||||
`File.GetAttributes` on the candidate, then a `FileStream` open after earlier files have been
|
||||
compressed — leaves a window: under any folder a local user can write to (the user-profile
|
||||
token paths, `ProgramData` subtrees, `C:\Install\Log`) that user can turn a subdirectory into
|
||||
a junction in between. An attribute check on the file resolves through a junction in a
|
||||
*parent* component, and SYSTEM would copy the junction target into the package.
|
||||
`PinnedSourceFolder` (`Monitoring/Runtime/PinnedSourceFolder.cs`, Win32 declarations in
|
||||
`Monitoring/Interop/FileNativeMethods.cs`) closes the window by deciding on the handle that
|
||||
is read:
|
||||
|
||||
* `AddLogFiles` pins the section folder once. It is opened with
|
||||
`FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS`, must not be a reparse point
|
||||
itself, and its `GetFinalPathNameByHandle` result — every reparse point resolved — must
|
||||
equal the validated lexical path (8.3 names expanded through `GetLongPathName`, so a
|
||||
`%TEMP%`-style short name is not mistaken for a junction). Anything else skips the section
|
||||
with `FOLDER REJECTED (<reason>)`; a missing folder stays `FOLDER MISSING`. The handle is
|
||||
held, with `FILE_LIST_DIRECTORY` access and no delete share, until the section is done:
|
||||
NTFS refuses to rename or delete a directory while it or anything beneath it is open, so
|
||||
the validated chain cannot be swapped underneath the build either.
|
||||
* Every candidate file is opened by `PinnedSourceFolder.TryOpenFile` with
|
||||
`FILE_FLAG_OPEN_REPARSE_POINT` — a symlink at the final component is opened as itself and
|
||||
refused (`SKIPPED (reparse point)`) — and the handle's final path must be exactly
|
||||
`<canonical folder>\<relative path>`. A subdirectory that became a junction after
|
||||
enumeration resolves elsewhere: `SKIPPED (resolved outside validated folder)`, naming only
|
||||
the requested path in the manifest, never the resolved one. The length charged against the
|
||||
caps and the stream copied into the archive come from that same handle; any other open
|
||||
failure is `FAILED (open): <path>: <reason>`.
|
||||
* `CollectFilesNoReparseDirs` still skips reparse-point directories while enumerating — as a
|
||||
candidate filter and to keep the walk out of junction loops — but nothing it decides is
|
||||
trusted at copy time.
|
||||
* The `.evtx` channel export is the agent's own file under an unguessable name in SYSTEM's
|
||||
temp folder and is streamed as-is. Hard links are out of scope: Windows refuses to create
|
||||
one to a file the caller cannot write.
|
||||
|
||||
The race itself is pinned by `DiagnosticsPackageServiceTests`: the `BeforeSourceFileOpen`
|
||||
seam swaps a subdirectory for a junction after enumeration and before the open, and the
|
||||
archive must not carry the file.
|
||||
|
||||
## Configured paths: global ∪ tenant
|
||||
|
||||
`GetAgentConfigFunction.MergeDiagnosticsLogPaths` sends global paths (Global Admin,
|
||||
@@ -120,6 +160,8 @@ same-named folder share one ZIP folder.
|
||||
catalog ids; `sectionFolderOverrides` covers any other section; `devicePreparationProbe`
|
||||
replaces the registry read (seam callers default to "not WDP", so existing rigs never touch
|
||||
the registry). `UserProfileResolver.SetForTesting(path|null)` / `Reset()` drive the token.
|
||||
`BeforeSourceFileOpen` fires with each candidate path after enumeration and before its open
|
||||
— the point at which the junction-race test swaps the directory.
|
||||
* Catalog invariants live in the backend test project (`DiagnosticsBuiltInSectionsTests`),
|
||||
because Shared has no test project.
|
||||
|
||||
@@ -139,9 +181,11 @@ entry is removed.
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsBuiltInSections.cs` — the catalog
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, caps, evtx export
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, `AddLogFiles` / `AddLogFile` / `OpenSource`, caps, evtx export
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/PinnedSourceFolder.cs` — pinned folder handle, `TryOpenFile`, `SourceFile`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/FileNativeMethods.cs` — `CreateFileW`, `GetFileInformationByHandle`, `GetFinalPathNameByHandleW`, `GetLongPathNameW`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs` — `EnableRealmJoinWatcher` mirror
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs` — `GET diagnostics/paths`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs` — `MergeDiagnosticsLogPaths`
|
||||
* `src/Web/autopilot-monitor-web/components/diagnostics/` — `DiagnosticsPathRow`, `BuiltInSectionsList`, `builtInSectionDisplay`
|
||||
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
|
||||
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure, junction swapped in after enumeration, junction as section folder), `PinnedSourceFolderTests` (final-path equality, short names, junction in the chain, symlink at the final component, rename refused while pinned), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Local Admin Analyzer — account inventory, dormant accounts and Administrators membership
|
||||
description: What the local_admin_analysis event asserts about a device at enrollment start and completion, why disabled accounts are inventoried (a /active:no backdoor re-enabled after enrollment is dormant at both scans), how Administrators-group membership is read from the SAM by well-known SID (NetLocalGroupGetMembers, independent of WMI and account state), why built-ins are matched by well-known RID (localized Guest/Administrator), why the shutdown-time dynamic allowance never covers a local SAM account (CWE-807 — the login state is attacker-controlled), which folders are not profiles, which members are scored versus only listed, and why the built-in Administrator's enabled state is reported but not scored.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs
|
||||
tags:
|
||||
- agent
|
||||
- security
|
||||
- local-admin
|
||||
- analyze-rules
|
||||
- bypass-detection
|
||||
timestamp: 2026-08-30T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Local Admin Analyzer
|
||||
|
||||
`LocalAdminAnalyzer` emits one `local_admin_analysis` event at agent startup and one at
|
||||
shutdown. The pair is a before/after inventory of local accounts on the device; the backend
|
||||
rule `ANALYZE-ID-002` alerts when the finding is `unexpected_local_admins_detected`.
|
||||
The threat model is the classic Autopilot bypass: a person with a Shift+F10 SYSTEM console
|
||||
during OOBE/ESP who creates a persistent local administrator.
|
||||
|
||||
# Schema
|
||||
|
||||
## Checks
|
||||
|
||||
| Check | Source | What it yields |
|
||||
|---|---|---|
|
||||
| `bypass_nro` | `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE\BypassNRO` | value + `flagged` |
|
||||
| Local accounts | WMI `Win32_UserAccount WHERE LocalAccount = True` (Name, Disabled, SID) | `accounts_checked`, `account_details` (name / disabled / administrators_member), `unexpected_accounts`, `builtin_administrator_enabled` |
|
||||
| Administrators membership | `NetLocalGroupGetMembers` level 2 on the group resolved from `S-1-5-32-544` (`LocalGroupNativeMethods`) | `administrators_group.{enumerated,error_code,members}`, `unexpected_admin_members` |
|
||||
| Profile folders | `C:\Users` top-level directories, junctions and `$<32 hex>` folders skipped | `profiles_found`, `unexpected_profiles` |
|
||||
|
||||
## Allowed list
|
||||
|
||||
Built-in names (`Administrator`, `Guest`, `DefaultAccount`, `WDAGUtilityAccount`,
|
||||
`defaultuser0/1/2`, `kioskUser0` — the Assigned Access autologon account of kiosk profiles —
|
||||
and the non-account folders `Public`, `Default`, `Default User`, `All Users`) plus the
|
||||
tenant's *Allowed Local Accounts* (glob `*`/`?`, case-insensitive).
|
||||
`defaultuser1/2` stay allowlisted deliberately: they do appear during OOBE enrollment.
|
||||
|
||||
**Well-known RIDs.** Built-in accounts are additionally recognised by RID on the machine SID
|
||||
(`S-1-5-21-…-500` Administrator, `-501` Guest, `-503` DefaultAccount, `-504`
|
||||
WDAGUtilityAccount), for the WMI inventory and for the SAM-member fallback alike. Localized
|
||||
installs name them `Gast`, `Invité`, `Гость`, `Administrateur`, … — disabled by default and
|
||||
therefore invisible before disabled accounts were inventoried; without the RID match every
|
||||
non-English device would flag its own Guest. Name matching is unchanged (an ordinary-RID
|
||||
account named `Gast` is still unexpected).
|
||||
|
||||
## Dynamic allowance at shutdown
|
||||
|
||||
The shutdown run adds the logged-in users (explorer.exe owners outside session 0) to the
|
||||
allowed list — but **only names that are not a local SAM account**. The WMI inventory is read
|
||||
first; a logged-in name found in it is rejected for both the account and the profile check
|
||||
and reported as `logged_in_local_accounts` (Warning in the agent log).
|
||||
|
||||
Why this is safe for false positives: in real sessions the logged-in identity is an Entra or
|
||||
domain user (user-driven, hybrid, or an Entra user signing in to a self-deploying device
|
||||
after ESP). Those never appear in `Win32_UserAccount`; the allowance only ever mattered for
|
||||
their `C:\Users` folder — and `unexpected_profiles` alone scores 0. A logged-in *local*
|
||||
account is the one case the allowance changed the score, and that is the attack: an account
|
||||
created after the startup scan that signs itself in before the shutdown scan (CWE-807 —
|
||||
exempting on a condition the adversary controls). Kiosk autologon (`kioskUser0`) is covered
|
||||
by the built-in list instead.
|
||||
|
||||
## Profile folders that are not profiles
|
||||
|
||||
Junctions (`All Users`, `Default User` and localized variants such as `Все пользователи`,
|
||||
all pointing at ProgramData / Default) and `$<32 hex>` folders are skipped entirely — neither
|
||||
can be the profile of a local account (a SAM account name is at most 20 characters), so they
|
||||
can never contribute to the profile-overlap score; listing them was payload noise.
|
||||
|
||||
## Evaluation (`EvaluateAccounts`, pure)
|
||||
|
||||
* Every local account is evaluated, **disabled ones included**; the disabled state is reported
|
||||
per account instead of excluding the account.
|
||||
* An account is an Administrators member when its SID matches a group member, or — SID
|
||||
unavailable — its name matches a member of the local machine domain.
|
||||
* `unexpected_accounts` = accounts not matching the allowed list (any state).
|
||||
* `unexpected_admin_members` = unexpected accounts holding Administrators membership, plus
|
||||
local-domain members not on the allowed list that WMI did not return at all (WMI failure
|
||||
or provider gap — the SAM is authoritative for membership).
|
||||
* Members outside the machine domain (Entra role SIDs such as `S-1-12-1-…` on Entra-joined
|
||||
devices, domain groups on hybrid devices, unresolved/deleted SIDs) are **listed for delta
|
||||
comparison but never scored** — they are expected on joined devices.
|
||||
* `builtin_administrator_enabled` = `!Disabled` of the RID-500 account (null when absent).
|
||||
Reported, not scored: whether OOBE enables it transiently is not established; the field
|
||||
collects evidence first.
|
||||
|
||||
## Scoring
|
||||
|
||||
| Signal | Weight |
|
||||
|---|---|
|
||||
| `BypassNRO = 1` | +20 |
|
||||
| any `unexpected_accounts` | +40 |
|
||||
| any `unexpected_admin_members` | +40 |
|
||||
| unexpected account with a matching `C:\Users` folder | +40 |
|
||||
|
||||
Capped at 100. `0` → Info `no_unexpected_admins_detected`; `<40` → Info
|
||||
`bypass_nro_flag_only`; `40–79` → Warning, `≥80` → Error, both
|
||||
`unexpected_local_admins_detected`. A dormant backdoor with admin membership therefore lands
|
||||
at 80 (Error) without ever having a profile folder.
|
||||
|
||||
## Failure behaviour
|
||||
|
||||
WMI and the SAM read are independent; either failing is logged at Warning with the native
|
||||
status (`NET_API_STATUS=…`) and the other source still evaluates. The event carries
|
||||
`administrators_group.enumerated=false` + `error_code` so a "no members" result is never
|
||||
mistaken for an empty group.
|
||||
|
||||
# Examples
|
||||
|
||||
`net user backdoor P@ss /add /active:no` + `net localgroup Administrators backdoor /add`
|
||||
during OOBE, re-enabled by a scheduled task after enrollment: at both scans the account is
|
||||
disabled; before this change it was skipped entirely. Now `unexpected_accounts=[backdoor]`,
|
||||
`unexpected_admin_members=[backdoor]`, `account_details[backdoor].disabled=true` →
|
||||
confidence 80, Error.
|
||||
|
||||
`net user backdoor P@ss /add` + `net localgroup Administrators backdoor /add` during ESP,
|
||||
then an interactive sign-in as `backdoor` (fast user switching) before enrollment completes:
|
||||
the shutdown scan finds `backdoor` in the SAM inventory → not allowed dynamically →
|
||||
`unexpected_accounts=[backdoor]`, `unexpected_admin_members=[backdoor]`, profile overlap →
|
||||
confidence 100, Error, `logged_in_local_accounts=[backdoor]`. Before this change the login
|
||||
alone silenced the finding to 0.
|
||||
|
||||
German device, tenant allowed list empty: `Gast` (RID 501, disabled) and `Administrator`
|
||||
are inventoried and matched by RID → `unexpected_accounts=[]`, confidence 0.
|
||||
|
||||
`net localgroup Administrators adm-helpdesk /add` where `adm-*` is allowlisted: not flagged
|
||||
(the allowed list is the tenant's statement of intent), but `administrators_group.members`
|
||||
shows the new membership in the shutdown event for delta review.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/LocalGroupNativeMethods.cs`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerEvaluateAccountsTests.cs`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerDynamicAllowanceTests.cs`
|
||||
* `rules/analyze/ANALYZE-ID-002.json`
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Admin Identity Binding — tid + oid behind every cross-tenant-role UPN
|
||||
description: Why a GlobalAdmins or DelegatedAdmins row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant, the AdminIdentityBindings table that pins each such UPN to its immutable home tenant id and object id, the fail-closed resolution order in both role services, the first-sign-in object-id pin, and the explicit rebind surface.
|
||||
description: Why a GlobalAdmins, DelegatedAdmins or McpUsers row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant, the AdminIdentityBindings table that pins each such UPN to its immutable home tenant id and object id, the fail-closed resolution order in the role services and the MCP whitelist, the first-sign-in object-id pin, and the explicit rebind surface.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs
|
||||
tags:
|
||||
- backend
|
||||
@@ -10,7 +10,8 @@ tags:
|
||||
- multi-tenant
|
||||
- global-admin
|
||||
- delegated-admin
|
||||
timestamp: 2026-08-29T00:00:00+02:00
|
||||
- mcp
|
||||
timestamp: 2026-08-30T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
@@ -83,10 +84,28 @@ winner). Two accounts racing the first sign-in inside the home tenant therefore
|
||||
Row lookups stay cached by UPN; the binding has its own 30 s cache keyed by UPN and is invalidated on
|
||||
pin, rebind and removal. A rebind on one scaled-out instance converges on the others within seconds.
|
||||
|
||||
## The MCP whitelist is a third UPN-keyed grant
|
||||
|
||||
`McpUsers` (PK `McpUsers`, RK = lowercase UPN) is the WhitelistOnly allow-list consulted by
|
||||
`GET auth/mcp`, which runs under the AuthenticatedUser policy — a token from any Entra tenant reaches it.
|
||||
Before 2026-08-30 the row was honoured by UPN alone, so a same-UPN identity from another tenant
|
||||
received `allowed=true` / `accessGrant=McpUser`, inherited the row's `UsagePlan` (a paid quota tier
|
||||
provisioned for another organization's user), and was also hit by the row's Disabled kill-switch. The
|
||||
data reach stayed tenant-bound (the MCP server routes a non-global caller on its own JWT `tid`), but the
|
||||
GA-curated whitelist and the per-user entitlement were bypassable.
|
||||
|
||||
`McpUserService` now resolves the whitelist exactly like the role services: row by UPN (cached 30 s) →
|
||||
no row ⇒ `NotPresent` without touching the binding → `IsBoundAsync(identity)` false ⇒ `NotPresent`. Grant,
|
||||
usage-plan override (`GetBoundMcpUserAsync`, used by `McpQuotaService.ResolvePlanAsync` and the
|
||||
self-service usage view) and the Disabled kill-switch all belong to the bound identity only; an unbound
|
||||
legacy row is inert. `POST global/mcp-users` binds first via the same resolver / 422 / 409 contract as the
|
||||
other grant endpoints, and the MCP users settings section shows the same home-tenant picker on 422. The
|
||||
AllMembers path is unaffected — it is tenant-bound by the auth middleware itself.
|
||||
|
||||
## Grants bind first — and resolve the identity themselves
|
||||
|
||||
Granting is "type the UPN". The three grant endpoints (`POST auth/global-admins`,
|
||||
`POST global/delegated-admins`, `POST global/tenant-groups/{id}/assignees`) accept `homeTenantId` /
|
||||
Granting is "type the UPN". The four grant endpoints (`POST auth/global-admins`,
|
||||
`POST global/delegated-admins`, `POST global/tenant-groups/{id}/assignees`, `POST global/mcp-users`) accept `homeTenantId` /
|
||||
`objectId` only as optional overrides; normally `AdminIdentityResolver` supplies them:
|
||||
|
||||
1. **Sign-in history** (`UserActivity`, filtered on the UPN): the person has signed in before ⇒ tid + oid
|
||||
@@ -98,7 +117,7 @@ Granting is "type the UPN". The three grant endpoints (`POST auth/global-admins`
|
||||
tenants) and retries with `homeTenantId`. This is the only manual step, and only for a UPN whose
|
||||
domain the platform has never seen.
|
||||
|
||||
`AddGlobalAdminAsync`, `DelegatedAdminService.UpsertAsync` and `AssignGroupAsync` then take the resolved
|
||||
`AddGlobalAdminAsync`, `DelegatedAdminService.UpsertAsync`, `AssignGroupAsync` and `AddMcpUserAsync` then take the resolved
|
||||
`homeTenantId` / `objectId` and call `EnsureBoundAsync` **before** writing the role row. `EnsureBoundAsync` creates the binding, keeps a compatible one (same tenant; the
|
||||
supplied object id equals the pin or upgrades an unpinned one), and throws
|
||||
`IdentityBindingConflictException` for a different tenant or a different pinned object id — the
|
||||
@@ -158,7 +177,7 @@ Same row, binding without an object id: the first `tid=1111…` sign-in pins its
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs` — verdict, pin, grant-time binding, rebind; the table entity.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityResolver.cs` — sign-in-history / UPN-domain resolution at grant time (`AdminIdentityResolverTests`).
|
||||
* `src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx` — the per-tenant inspect/correct panel.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs`, `DelegatedAdminService.cs` — resolution order.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs`, `DelegatedAdminService.cs`, `McpUserService.cs` — resolution order (`McpUserServiceIdentityBindingTests` for the whitelist matrix).
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs` — `TryPinIdentityObjectIdAsync` and the binding CRUD.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/IdentityBindingManagementFunction.cs` — the rebind surface and the shared `IdentityBindingRequest.Validate`.
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/AdminIdentityBindingServiceTests.cs`, `AdminIdentityBindingAuthorizationTests.cs` — verdict matrix, race semantics, and the middleware-level denials for foreign-tenant and recycled-UPN tokens.
|
||||
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
- archive
|
||||
- blob
|
||||
- queue
|
||||
timestamp: 2026-08-29T17:30:00+02:00
|
||||
timestamp: 2026-08-30T12:00:00+02:00
|
||||
---
|
||||
|
||||
# IME Installer Archive
|
||||
@@ -22,7 +22,7 @@ on. Two stores:
|
||||
| Store | Content | Written by |
|
||||
| --- | --- | --- |
|
||||
| Blob container `ime-archive` (private, kept by design, not tenant-scoped) | `{version}/IntuneWindowsAgent.msi` (write-once) + `{version}/provenance.json` (derived, overwritable) | `ImeMsiArchiver` via the `ime-msi-archive` queue; `/ime-decompile` skill as manual backfill |
|
||||
| Table `ImeVersionHistory` (partition `Global`, RowKey = version) | first/last seen, session count, `MsiArchiveStatus`, `MsiArchiveUpdatedAt`, `MsiArchiveBlobPath`, `MsiSha256`, `MsiBytes`, `MsiSourceUrl` | `RecordImeVersionAsync` (sighting), `UpdateImeVersionArchiveInfoAsync` (archive outcome / queued stamp) |
|
||||
| Table `ImeVersionHistory` (partition `Global`, RowKey = version) | first/last seen, session count, `CorroboratedAt`, `MsiArchiveStatus`, `MsiArchiveUpdatedAt`, `MsiArchiveBlobPath`, `MsiSha256`, `MsiBytes`, `MsiSourceUrl` | `RecordImeVersionAsync` (sighting), `UpdateImeVersionArchiveInfoAsync` (archive outcome / queued stamp) |
|
||||
|
||||
# Schema
|
||||
|
||||
@@ -45,6 +45,25 @@ older than 24 h (`RequeueBackoff`) or never happened. The ingest path then merge
|
||||
sessions of that version see "in flight" and do not pile on. Worst case for a version no
|
||||
host serves any more: one walk over the hosts per day, not one per enrollment.
|
||||
|
||||
## Trust boundary of a sighting
|
||||
|
||||
The version string is **device-supplied** (any device holding a tenant agent credential can
|
||||
send an `ime_agent_version` event) and becomes the RowKey of a caller-independent GLOBAL
|
||||
partition that every tenant reads. Four layers keep one tenant's device from turning a
|
||||
claim into a platform fact:
|
||||
|
||||
| Layer | Where | Effect |
|
||||
| --- | --- | --- |
|
||||
| Plausibility guard | `ImeMsiArchiver.IsPlausibleVersion`, called first in `RecordImeVersionAsync` (and again by the archiver) | plain dotted digits, 2–4 components, inside Windows Installer `ProductVersion` bounds (major/minor ≤ 255, build ≤ 65535). Anything else → `ImeVersionSighting.Rejected`: no row, no ops event, no archive job, no re-queue. |
|
||||
| One sighting per session | `UpdateSessionImeAgentVersionAsync` returns whether the session's stored version changed; only then does the ingest path call `RecordImeVersionAsync` | `SessionCount` moves once per session and version; a re-sent or replayed event, a tombstoned or unregistered session, count nothing. |
|
||||
| Corroboration | merge path of `RecordImeVersionAsync` stamps `CorroboratedAt` on the first sighting from a tenant other than `FirstSeenTenantId` | the first-seen tenant's own devices can never corroborate their own claim. |
|
||||
| Fleet confirmation on read | `GetImeVersionHistoryFunction.IsFleetConfirmed` (MemberRead projection only) | tenant members see a version once it is `Archived` (ProductVersion matched a real package), or corroborated, or first seen before `LegacyTrustCutoffUtc` (2026-08-31 — the 16 rows reviewed on 2026-08-30 are all genuine builds). Global scope sees every row, unconfirmed ones included. |
|
||||
|
||||
What remains possible for a hostile device: a plausible, never-shipped version creates a
|
||||
row visible to Global Admins plus one `NewImeVersionDetected` ops event and one archive walk
|
||||
that ends `Failed:VersionMismatch` (no blob). It is never listed to other tenants and can
|
||||
never occupy the archive folder of a real build.
|
||||
|
||||
## Candidate hosts and the version check
|
||||
|
||||
Microsoft distributes IME from several versionless URLs that carry **different rollout
|
||||
|
||||
@@ -36,6 +36,14 @@ same table) — the only reliable address: it is typed by the user on the activa
|
||||
page (`PUT /api/preview/notification-email`). The signup admin UPN may have no mailbox and
|
||||
`ContactEmail` is merely seeded from the notification email.
|
||||
|
||||
The PUT is `AuthenticatedUserWithRole`, not a member tier, because the signup user is still
|
||||
roleless until approval auto-promotes them to Admin. The function re-gates on the resolved
|
||||
role (`PreviewWhitelistFunction.MayWriteNotificationEmailAsync`): a member role or platform
|
||||
scope always may write; a roleless caller may write only while the tenant has **no enabled
|
||||
member** at all. Once any member exists the window is closed for good — the address feeds the
|
||||
welcome and farewell mails and is shown to operators as the tenant contact, so an ordinary
|
||||
employee with a JWT but no product role must not be able to redirect or clear it (403).
|
||||
|
||||
That makes activation and address entry concurrent writers. With auto-approve, activation
|
||||
usually wins (observed in production: approve at T+64 s, address saved at T+69 s → the
|
||||
approval path found no address and the mail was silently skipped).
|
||||
|
||||
+4
-3
@@ -19,7 +19,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
|
||||
# Agent
|
||||
|
||||
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [diagnostics package](agent/diagnostics-package.md) (built-in section catalog shared with the portal, RealmJoin/Device Preparation collection gates, caps, manifest), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
|
||||
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [diagnostics package](agent/diagnostics-package.md) (built-in section catalog shared with the portal, RealmJoin/Device Preparation collection gates, handle-validated reads against the junction race, caps, manifest), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
|
||||
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
|
||||
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
|
||||
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
|
||||
@@ -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.
|
||||
@@ -83,7 +84,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Vulnerability Correlation Sources](backend/vulnerability-correlation-sources.md) - How the software inventory becomes CVE findings from four cached primaries (NVD per-CPE cache with budget and TTLs, CISA KEV, MSRC, FIRST EPSS stored on the cached CVE and re-scored nightly), the NVD throttle contract (a refused answer is never cached as "0 CVEs"; bounded process-wide cooldown honouring Retry-After), the persisted CVSS vector, and the act/attend/track priority that keeps severity and likelihood as two visible signals instead of one composite score.
|
||||
* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, the shadow-then-enforce rollout with its volume-controlled telemetry, and why `X-ARR-ClientCert` is the only certificate transport (TLS handshake = proof of possession; bootstrap routes token-only, fail-closed).
|
||||
* [Admin Identity Binding](backend/admin-identity-binding.md) - Why a GlobalAdmins / DelegatedAdmins row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant (domain re-registration, UPN recycling), the AdminIdentityBindings table pinning each cross-tenant-role UPN to its immutable home tid + oid, the fail-closed resolution order in both role services (row → binding → entitlement), the first-sign-in object-id pin with its race semantics, the 409 on grant-time conflicts, and the explicit GET/PUT/DELETE rebind surface.
|
||||
* [IME Installer Archive](backend/ime-msi-archive.md) - How every fleet-observed IME build is archived as its MSI: the ime_agent_version sighting that queues the job (first sighting, or a later sighting with a version-authoritative CSP URL after a 24 h backoff), the primary/secondary/hotfix candidate walk, the managed MSI ProductVersion check that keeps another ring's build out of the version folder (Failed:VersionMismatch), provenance with the full candidate walk, and the skill-side overwrite path for corrections.
|
||||
* [IME Installer Archive](backend/ime-msi-archive.md) - How every fleet-observed IME build is archived as its MSI: the ime_agent_version sighting that queues the job (first sighting, or a later sighting with a version-authoritative CSP URL after a 24 h backoff), the primary/secondary/hotfix candidate walk, the managed MSI ProductVersion check that keeps another ring's build out of the version folder (Failed:VersionMismatch), provenance with the full candidate walk, the skill-side overwrite path for corrections, and the trust boundary of a device-reported version (plausibility guard, one sighting per session, second-tenant corroboration, fleet-confirmed tenant view).
|
||||
|
||||
# Conventions for this bundle
|
||||
|
||||
|
||||
+34
@@ -1,5 +1,39 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-30 (6)
|
||||
|
||||
* **Update**: `rules/rule-id-namespace.md` — guard 1 rewritten: gather RuleStats (tenant + `global_*` rows) are now resolved against the tenant's active gather catalog (`EventIngestProcessor.ResolveFiredGatherRules`), metadata catalog-sourced, unknown IDs dropped — same design as the analyze path; the `IsReservedBuiltInId` pattern gate on device-supplied IDs is gone.
|
||||
|
||||
## 2026-08-30 (5)
|
||||
|
||||
* **Update**: `backend/admin-identity-binding.md` — new "The MCP whitelist is a third UPN-keyed grant" section: `McpUsers` rows (grant, `UsagePlan` override and Disabled kill-switch) are honoured only for the identity the UPN is bound to (`McpUserService` row → `IsBoundAsync`, `GetBoundMcpUserAsync` for the quota plan); `POST global/mcp-users` binds first with the shared resolver / 422 / 409 contract; unbound legacy rows are inert.
|
||||
|
||||
## 2026-08-30 (4)
|
||||
|
||||
* **Update**: `backend/ime-msi-archive.md` — new "Trust boundary of a sighting" section: the device-supplied `ime_agent_version` string is gated by `ImeMsiArchiver.IsPlausibleVersion` (dotted digits within MSI ProductVersion bounds) before it can become a GLOBAL `ImeVersionHistory` RowKey (`ImeVersionSighting.Rejected` → no row/ops event/archive job); a session counts once (`UpdateSessionImeAgentVersionAsync` returns whether the version changed); a second tenant's sighting stamps `CorroboratedAt`; the MemberRead projection lists only fleet-confirmed versions (Archived, corroborated, or first seen before 2026-08-31), Global scope sees every row.
|
||||
|
||||
## 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.
|
||||
|
||||
## 2026-08-29 (12)
|
||||
|
||||
* **Update**: `backend/tenant-activation-welcome-mail.md` — `PUT preview/notification-email` moved to `AuthenticatedUserWithRole`; roleless callers may write the address only while the tenant has no enabled member (signup window), afterwards a member role is required.
|
||||
|
||||
## 2026-08-29 (11)
|
||||
|
||||
* **Update**: `rules/gather-rule-phase-scoping.md` — `on_event` feedback-loop guard: `TimelineEventStream` publishes the event source and `GatherRuleExecutorHost` never dispatches `on_event` for gather-emitted events (a rule triggering on its own output type looped for the whole session); per-rule on_event execution cap (100/session); backend rejects `on_event` rules whose trigger equals their output type or `gather_result`.
|
||||
|
||||
## 2026-08-29 (10)
|
||||
|
||||
* **Update**: `backend/tenant-activation-welcome-mail.md` — welcome/farewell mails HTML-encode the domain at substitution; `DomainName` is frozen on the full-model config PUT (was persisted verbatim) and the first-login seed only accepts a strict DNS host name.
|
||||
|
||||
@@ -8,7 +8,7 @@ tags:
|
||||
- backend
|
||||
- gather-rules
|
||||
- config
|
||||
timestamp: 2026-08-04T00:00:00+02:00
|
||||
timestamp: 2026-08-29T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
@@ -89,6 +89,18 @@ Consequences of the emitted-event feed:
|
||||
the raw signal stream — the documented "On Event with `enrollment_complete`" end-of-enrollment
|
||||
pattern only works because of this. This restores V1 semantics (V1 fired triggers from every
|
||||
emitted `EnrollmentEvent`).
|
||||
* **Feedback-loop guard** (2026-08-29): gather output travels the same post → ingress →
|
||||
reducer → emitter → stream path, so the stream would hand the executor's own events straight
|
||||
back to `OnEvent`. A rule with `triggerEventType == outputEventType` (or two rules chained
|
||||
A→B→A) then executed and emitted without bound for the whole session — `emitMode:
|
||||
on_change` does not help when the collected result varies. `TimelineEventStream` therefore
|
||||
publishes the event **source**, and `GatherRuleExecutorHost` never dispatches `on_event` for
|
||||
`Source == GatherRuleExecutor.SourceName` (phase declarations on such events are still
|
||||
honoured). Second layer: `GatherRuleExecutor.MaxOnEventExecutionsPerRule` (100 per rule and
|
||||
session) bounds any cycle that reaches the executor through a non-gather event; the boundary
|
||||
is logged once (agent log Warning + debug-trace `trigger` line). Backend validation rejects
|
||||
`on_event` rules whose trigger equals their own output type or `gather_result` — such a rule
|
||||
could never fire.
|
||||
* Other hosts (`DeviceInfoHost`, `UserEspKeepAwakeHost`, `ProvisioningPackageHost`) still key
|
||||
off the raw signal stream deliberately — their collections are about OS state at the raw
|
||||
boundary (ESP exit, keep-awake), not about timeline agreement.
|
||||
@@ -103,7 +115,8 @@ Consequences of the emitted-event feed:
|
||||
`_startupRulesExecuted` dedup; it is not part of the `WaitForStartupRules` latch).
|
||||
* `phase_change` — evaluated against the **new** phase; the scope gate runs before the
|
||||
per-(rule, phase) dedup so an out-of-scope pass does not consume the execution slot.
|
||||
* `on_event` — gated by the current phase.
|
||||
* `on_event` — gated by the current phase; never fires on gather output; capped at
|
||||
`MaxOnEventExecutionsPerRule` per rule and session.
|
||||
* **Before the first phase signal** of a session (`_currentPhase == Unknown`) scoped rules are
|
||||
inactive. Sessions that never produce phase signals never run them (documented behavior).
|
||||
* **From-phase latch**: activates when `phase != Unknown && phase != Failed && (int)phase >=
|
||||
@@ -164,4 +177,4 @@ Consequences of the emitted-event feed:
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/GatherRulesFunction.cs` — `ValidateScopeAndEmitMode`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs` — partial-PUT merge, `ContentEquivalent`
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs` — field contracts
|
||||
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests`, `GatherRuleExecutorHostTimelineFeedTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
|
||||
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests`, `GatherRuleExecutorHostTimelineFeedTests`, `GatherRuleExecutorOnEventCapTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
|
||||
|
||||
@@ -31,7 +31,7 @@ Namespace split (`RuleIdPolicy.cs`, shared model):
|
||||
|
||||
# Guards (all enforced, 2026-08-21)
|
||||
|
||||
1. **Global RuleStats aggregate is catalog-only.** The `global_{ruleId}` row would otherwise sum same-ID custom rules of unrelated tenants (title/severity last-writer-wins). Analyze gates on `rule.IsBuiltIn || rule.IsCommunity` (`AnalyzeOnEnrollmentEndHandler`), gather gates on `RuleIdPolicy.IsReservedBuiltInId` (`EventIngestProcessor.RuleStats`) because agent events carry no flag — valid because custom rules can never occupy the reserved pattern.
|
||||
1. **Global RuleStats aggregate is catalog-only.** The `global_{ruleId}` row would otherwise sum same-ID custom rules of unrelated tenants (title/severity last-writer-wins). Both paths gate on the server-side catalog rule object: analyze on `rule.IsBuiltIn || rule.IsCommunity` of the evaluated rule (`AnalyzeOnEnrollmentEndHandler`), gather on the same flags of the rule resolved from the tenant's active gather catalog (`EventIngestProcessor.ResolveFiredGatherRules`). The agent event contributes only the `ruleId`; title/category/severity are taken from the catalog, never from event `Data`, and a claimed ID that is not an active rule for the tenant is dropped — so a device cannot mint `global_*` rows for retired/unshipped IDs, inflate a built-in rule's counters under a foreign ID, or respell its title. (Earlier the gather path selected the global row by `RuleIdPolicy.IsReservedBuiltInId` on the device-supplied ID and wrote the device-supplied title — pattern membership is not catalog membership.)
|
||||
2. **Catalog build fails on namespace violations.** `rules/scripts/combine.js` rejects duplicate IDs (case-insensitive) and any gather/analyze built-in outside the reserved pattern; `BuiltInRuleCatalogPolicyTests` pins the same invariants on the embedded resource actually deployed.
|
||||
3. **GitHub reseed rejects non-reserved IDs** before writing to the `global` partition (`ReseedFromGitHubFunction`) — a seeded `*-CUSTOM-*` rule would silently shadow every tenant's same-ID custom rule at merge time.
|
||||
4. **Cross-tenant RuleState GC is namespace-gated.** `DeleteRuleStatesForRuleIdAcrossTenantsAsync` deletes by bare RowKey across all partitions; it now refuses non-reserved IDs with `failed=-1` (callers then skip the global catalog delete, same contract as enumeration failure).
|
||||
|
||||
@@ -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
@@ -2766,7 +2766,7 @@
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -2817,7 +2817,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list), unexpected_profiles (C:\\Users profile directories not on the allowed list), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -2840,7 +2840,7 @@
|
||||
"steps": [
|
||||
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
|
||||
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
|
||||
"Check whether the unexpected account has admin group membership",
|
||||
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,54 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates NTFS reparse points for guard tests through <c>cmd.exe /c mklink</c> — the stock
|
||||
/// System32 binary started as-is, nothing copied or renamed (EDR-neutral). Directory
|
||||
/// junctions need no privilege; file symlinks need SeCreateSymbolicLinkPrivilege or
|
||||
/// Developer Mode, so that helper reports failure instead of throwing.
|
||||
/// </summary>
|
||||
internal static class NtfsLinks
|
||||
{
|
||||
public static void CreateJunction(string link, string target)
|
||||
{
|
||||
var output = RunMklink($"/J \"{link}\" \"{target}\"");
|
||||
if (!Directory.Exists(link) || (File.GetAttributes(link) & FileAttributes.ReparsePoint) == 0)
|
||||
throw new InvalidOperationException($"mklink /J failed for '{link}' -> '{target}': {output}");
|
||||
}
|
||||
|
||||
public static bool TryCreateFileSymlink(string link, string target)
|
||||
{
|
||||
RunMklink($"\"{link}\" \"{target}\"");
|
||||
return File.Exists(link) && (File.GetAttributes(link) & FileAttributes.ReparsePoint) != 0;
|
||||
}
|
||||
|
||||
/// <summary>Removes a junction or symlink itself, never the target's content.</summary>
|
||||
public static void RemoveLink(string link)
|
||||
{
|
||||
if (Directory.Exists(link)) Directory.Delete(link, recursive: false);
|
||||
else if (File.Exists(link)) File.Delete(link);
|
||||
}
|
||||
|
||||
private static string RunMklink(string arguments)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = Path.Combine(Environment.SystemDirectory, "cmd.exe"),
|
||||
Arguments = "/c mklink " + arguments,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null) throw new InvalidOperationException("cmd.exe did not start");
|
||||
var stdout = process.StandardOutput.ReadToEnd();
|
||||
var stderr = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit(15000);
|
||||
return (stdout + stderr).Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+269
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// on_event has no once-per-(rule, phase) dedup like the phase triggers. The per-rule
|
||||
/// execution cap (<see cref="GatherRuleExecutor.MaxOnEventExecutionsPerRule"/>) bounds any
|
||||
/// trigger cycle that survives the host's source guard (e.g. two rules chained through an
|
||||
/// intermediate non-gather event) so a mis-authored rule can never spin for a whole session.
|
||||
/// Executions are observed through the emitted events; the rule uses a registry path that
|
||||
/// exists on every Windows box so each execution emits exactly one event.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorOnEventCapTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly object _gate = new object();
|
||||
private readonly GatherRuleExecutor _executor;
|
||||
|
||||
public GatherRuleExecutorOnEventCapTests()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_executor = new GatherRuleExecutor("sess", "tenant",
|
||||
evt => { lock (_gate) _events.Add(evt); }, logger)
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_executor.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private int EventCount { get { lock (_gate) return _events.Count; } }
|
||||
|
||||
private bool WaitForEventCount(int count, int timeoutMs = 20000)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
while (sw.ElapsedMilliseconds < timeoutMs)
|
||||
{
|
||||
if (EventCount >= count) return true;
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
return EventCount >= count;
|
||||
}
|
||||
|
||||
private static GatherRule OnEventRule(string id, string triggerEventType) => new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
Title = id,
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion",
|
||||
Trigger = "on_event",
|
||||
TriggerEventType = triggerEventType,
|
||||
OutputEventType = "gather_cap_test",
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void OnEvent_ExecutesAtMostMaxOnEventExecutionsPerRule_PerSession()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { OnEventRule("GATHER-CAP-001", "ext_event") });
|
||||
|
||||
var cap = GatherRuleExecutor.MaxOnEventExecutionsPerRule;
|
||||
for (var i = 0; i < cap + 25; i++)
|
||||
_executor.OnEvent("ext_event");
|
||||
|
||||
Assert.True(WaitForEventCount(cap), $"expected {cap} executions, got {EventCount}");
|
||||
Thread.Sleep(500);
|
||||
Assert.Equal(cap, EventCount);
|
||||
|
||||
// Further triggers stay ignored for the rest of the session.
|
||||
_executor.OnEvent("ext_event");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(cap, EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnEventCap_IsPerRule_NotGlobal()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
OnEventRule("GATHER-CAP-002", "ext_event"),
|
||||
OnEventRule("GATHER-CAP-003", "ext_event"),
|
||||
});
|
||||
|
||||
var cap = GatherRuleExecutor.MaxOnEventExecutionsPerRule;
|
||||
for (var i = 0; i < cap + 5; i++)
|
||||
_executor.OnEvent("ext_event");
|
||||
|
||||
Assert.True(WaitForEventCount(2 * cap), $"expected {2 * cap} executions, got {EventCount}");
|
||||
Thread.Sleep(500);
|
||||
Assert.Equal(2 * cap, EventCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -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 "<![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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+92
@@ -811,5 +811,97 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
Assert.False(DiagnosticsPackageService.IsReparsePoint(FileAttributes.ReadOnly | FileAttributes.Hidden));
|
||||
Assert.False(DiagnosticsPackageService.IsReparsePoint(FileAttributes.Archive));
|
||||
}
|
||||
|
||||
// ========================================== handle-validated reads (TOCTOU) ====
|
||||
// Every guard up to here inspects a PATH: the config guard, the enumeration's reparse
|
||||
// skip, the per-file attribute check. A local user who can write under a recursive
|
||||
// source folder swaps a subdirectory for a junction between enumeration and open, and
|
||||
// the bytes would come from the junction target. The read is validated on the handle
|
||||
// that is actually copied, so the swapped file is skipped and the manifest says why.
|
||||
|
||||
private static string AllZipEntryTexts(byte[] zipBytes)
|
||||
{
|
||||
using var ms = new MemoryStream(zipBytes);
|
||||
using var archive = new ZipArchive(ms, ZipArchiveMode.Read);
|
||||
var sb = new StringBuilder();
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
|
||||
sb.AppendLine(reader.ReadToEnd());
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_skips_file_whose_parent_became_a_junction_after_enumeration()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
using var outside = new TempDirectory();
|
||||
var sub1 = Path.Combine(rig.StateFolder, "sub1");
|
||||
var sub2 = Path.Combine(rig.StateFolder, "sub2");
|
||||
Directory.CreateDirectory(sub1);
|
||||
Directory.CreateDirectory(sub2);
|
||||
File.WriteAllText(Path.Combine(sub1, "decoy.txt"), "decoy");
|
||||
File.WriteAllText(Path.Combine(sub2, "x.txt"), "harmless");
|
||||
File.WriteAllText(Path.Combine(outside.Path, "x.txt"), "OUTSIDE-THE-VALIDATED-FOLDER");
|
||||
|
||||
var svc = rig.Build();
|
||||
var swapped = false;
|
||||
svc.BeforeSourceFileOpen = candidate =>
|
||||
{
|
||||
if (swapped || !candidate.EndsWith(Path.Combine("sub2", "x.txt"), System.StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
// Enumeration is done and sub2 passed its reparse check — now it becomes a junction.
|
||||
Directory.Delete(sub2, recursive: true);
|
||||
NtfsLinks.CreateJunction(sub2, outside.Path);
|
||||
swapped = true;
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = svc.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
Assert.True(swapped, "race seam never fired for sub2/x.txt");
|
||||
var entries = ZipEntryNames(bytes);
|
||||
Assert.Contains("AgentState/sub1/decoy.txt", entries);
|
||||
Assert.DoesNotContain("AgentState/sub2/x.txt", entries);
|
||||
Assert.DoesNotContain("OUTSIDE-THE-VALIDATED-FOLDER", AllZipEntryTexts(bytes));
|
||||
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Contains("SKIPPED (resolved outside validated folder)", manifest!);
|
||||
Assert.Contains("ADDED: AgentState/sub1/decoy.txt", manifest!);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NtfsLinks.RemoveLink(sub2);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_rejects_source_folder_that_is_a_junction()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
using var outside = new TempDirectory();
|
||||
File.WriteAllText(Path.Combine(outside.Path, "snapshot.json"), "{\"leak\":true}");
|
||||
var link = Path.Combine(rig.Tmp.Path, "StateLink");
|
||||
NtfsLinks.CreateJunction(link, outside.Path);
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = rig.Build(sectionFolderOverrides: new Dictionary<string, string> { ["AgentState"] = link })
|
||||
.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var entries = ZipEntryNames(bytes);
|
||||
Assert.DoesNotContain(entries, e => e.StartsWith("AgentState/"));
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Contains("FOLDER REJECTED (reparse point)", manifest!);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NtfsLinks.RemoveLink(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// PinnedSourceFolder is the diagnostics packager's answer to the enumerate-then-open race:
|
||||
/// the folder and every file are judged on the handle that is read, never on the path
|
||||
/// string. Junctions come from the stock mklink (no privilege needed); the file-symlink
|
||||
/// case degrades to a no-op on hosts without the symlink privilege.
|
||||
/// </summary>
|
||||
public sealed class PinnedSourceFolderTests
|
||||
{
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern uint GetShortPathNameW(string lpszLongPath, StringBuilder lpszShortPath, uint cchBuffer);
|
||||
|
||||
private static string? ShortPathOf(string path)
|
||||
{
|
||||
var sb = new StringBuilder(1024);
|
||||
var len = GetShortPathNameW(path, sb, (uint)sb.Capacity);
|
||||
return len == 0 || len >= sb.Capacity ? null : sb.ToString();
|
||||
}
|
||||
|
||||
private static string LongPathOf(string path) => PinnedSourceFolder.TryGetLongPath(path) ?? path;
|
||||
|
||||
private static bool SamePath(string a, string b) =>
|
||||
string.Equals(a.TrimEnd('\\'), b.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
[Fact]
|
||||
public void TryOpen_pins_a_real_folder_and_reports_where_it_points()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var folder = Path.Combine(tmp.Path, "Logs");
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
using var pinned = PinnedSourceFolder.TryOpen(folder + "\\", out var rejection);
|
||||
|
||||
Assert.Null(rejection);
|
||||
Assert.NotNull(pinned);
|
||||
Assert.Equal(folder, pinned!.LexicalPath);
|
||||
Assert.True(SamePath(LongPathOf(folder), pinned.CanonicalPath), $"canonical '{pinned.CanonicalPath}' vs '{folder}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryOpen_accepts_a_short_name_path_and_canonicalizes_it()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var folder = Path.Combine(tmp.Path, "LongFolderNameForShortNames");
|
||||
Directory.CreateDirectory(folder);
|
||||
// 8.3 generation may be disabled on the volume; then the short form IS the long form
|
||||
// and this degrades to the plain case (CI runners hand out C:\Users\RUNNER~1\...).
|
||||
var shortForm = ShortPathOf(folder) ?? folder;
|
||||
|
||||
using var pinned = PinnedSourceFolder.TryOpen(shortForm, out var rejection);
|
||||
|
||||
Assert.Null(rejection);
|
||||
Assert.NotNull(pinned);
|
||||
Assert.True(SamePath(LongPathOf(folder), pinned!.CanonicalPath), $"canonical '{pinned.CanonicalPath}' vs '{folder}' (opened as '{shortForm}')");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryOpen_reports_a_missing_folder()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
|
||||
var pinned = PinnedSourceFolder.TryOpen(Path.Combine(tmp.Path, "nope"), out var rejection);
|
||||
|
||||
Assert.Null(pinned);
|
||||
Assert.Equal(PinnedSourceFolder.RejectMissing, rejection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryOpen_rejects_a_folder_that_is_a_junction()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var target = new TempDirectory();
|
||||
var link = Path.Combine(tmp.Path, "link");
|
||||
NtfsLinks.CreateJunction(link, target.Path);
|
||||
try
|
||||
{
|
||||
var pinned = PinnedSourceFolder.TryOpen(link, out var rejection);
|
||||
|
||||
Assert.Null(pinned);
|
||||
Assert.Equal(PinnedSourceFolder.RejectReparsePoint, rejection);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NtfsLinks.RemoveLink(link);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryOpen_rejects_a_folder_reached_through_a_junction()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var target = new TempDirectory();
|
||||
Directory.CreateDirectory(Path.Combine(target.Path, "Logs"));
|
||||
var link = Path.Combine(tmp.Path, "link");
|
||||
NtfsLinks.CreateJunction(link, target.Path);
|
||||
try
|
||||
{
|
||||
// link\Logs is a real directory reached THROUGH a junction: its own attributes are
|
||||
// clean, only the handle's final path gives it away.
|
||||
var pinned = PinnedSourceFolder.TryOpen(Path.Combine(link, "Logs"), out var rejection);
|
||||
|
||||
Assert.Null(pinned);
|
||||
Assert.Equal(PinnedSourceFolder.RejectResolvedElsewhere, rejection);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NtfsLinks.RemoveLink(link);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryOpenFile_returns_the_bytes_of_a_real_file()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var sub = Path.Combine(tmp.Path, "sub");
|
||||
Directory.CreateDirectory(sub);
|
||||
var file = Path.Combine(sub, "a.log");
|
||||
File.WriteAllText(file, "hello");
|
||||
|
||||
using var pinned = PinnedSourceFolder.TryOpen(tmp.Path, out _);
|
||||
Assert.NotNull(pinned);
|
||||
using var source = pinned!.TryOpenFile(file, out var rejection);
|
||||
|
||||
Assert.Null(rejection);
|
||||
Assert.NotNull(source);
|
||||
Assert.Equal(5, source!.Length);
|
||||
using var reader = new StreamReader(source.Stream, Encoding.UTF8);
|
||||
Assert.Equal("hello", reader.ReadToEnd());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryOpenFile_rejects_a_file_reached_through_a_junction_created_after_the_pin()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var outside = new TempDirectory();
|
||||
var sub = Path.Combine(tmp.Path, "sub");
|
||||
Directory.CreateDirectory(sub);
|
||||
var file = Path.Combine(sub, "x.log");
|
||||
File.WriteAllText(file, "harmless");
|
||||
File.WriteAllText(Path.Combine(outside.Path, "x.log"), "outside");
|
||||
|
||||
using var pinned = PinnedSourceFolder.TryOpen(tmp.Path, out _);
|
||||
Assert.NotNull(pinned);
|
||||
// Enumeration would have listed sub\x.log by now — the attacker swaps sub for a junction.
|
||||
Directory.Delete(sub, recursive: true);
|
||||
NtfsLinks.CreateJunction(sub, outside.Path);
|
||||
try
|
||||
{
|
||||
var source = pinned!.TryOpenFile(file, out var rejection);
|
||||
|
||||
Assert.Null(source);
|
||||
Assert.Equal(PinnedSourceFolder.RejectResolvedElsewhere, rejection);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NtfsLinks.RemoveLink(sub);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryOpenFile_rejects_a_symlink_at_the_final_component()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var outside = new TempDirectory();
|
||||
var targetFile = Path.Combine(outside.Path, "secret.log");
|
||||
File.WriteAllText(targetFile, "outside");
|
||||
var link = Path.Combine(tmp.Path, "x.log");
|
||||
if (!NtfsLinks.TryCreateFileSymlink(link, targetFile))
|
||||
return; // no SeCreateSymbolicLinkPrivilege / Developer Mode on this host — nothing to assert
|
||||
try
|
||||
{
|
||||
using var pinned = PinnedSourceFolder.TryOpen(tmp.Path, out _);
|
||||
Assert.NotNull(pinned);
|
||||
|
||||
var source = pinned!.TryOpenFile(link, out var rejection);
|
||||
|
||||
Assert.Null(source);
|
||||
Assert.Equal(PinnedSourceFolder.RejectReparsePoint, rejection);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NtfsLinks.RemoveLink(link);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryOpenFile_refuses_a_candidate_outside_the_pinned_folder()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var other = new TempDirectory();
|
||||
var elsewhere = Path.Combine(other.Path, "x.log");
|
||||
File.WriteAllText(elsewhere, "x");
|
||||
|
||||
using var pinned = PinnedSourceFolder.TryOpen(tmp.Path, out _);
|
||||
Assert.NotNull(pinned);
|
||||
|
||||
var source = pinned!.TryOpenFile(elsewhere, out var rejection);
|
||||
|
||||
Assert.Null(source);
|
||||
Assert.Equal(PinnedSourceFolder.RejectOutsideFolder, rejection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pinned_folder_and_its_ancestors_cannot_be_renamed_while_pinned()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var parent = Path.Combine(tmp.Path, "parent");
|
||||
var folder = Path.Combine(parent, "Logs");
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
using (var pinned = PinnedSourceFolder.TryOpen(folder, out _))
|
||||
{
|
||||
Assert.NotNull(pinned);
|
||||
Assert.ThrowsAny<Exception>(() => Directory.Move(folder, Path.Combine(parent, "Logs2")));
|
||||
Assert.ThrowsAny<Exception>(() => Directory.Move(parent, Path.Combine(tmp.Path, "parent2")));
|
||||
Assert.True(Directory.Exists(folder));
|
||||
}
|
||||
|
||||
// Released: the same rename now succeeds.
|
||||
Directory.Move(parent, Path.Combine(tmp.Path, "parent2"));
|
||||
Assert.True(Directory.Exists(Path.Combine(tmp.Path, "parent2", "Logs")));
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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)
|
||||
{
|
||||
|
||||
+43
-10
@@ -122,13 +122,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
// The raw-world sequence of session 32312a32 as it reaches the emitted timeline:
|
||||
// esp_exiting and hello_wizard_started are emitted WITHOUT a phase declaration
|
||||
// (the engine holds FinalizingSetup back behind the RealmJoin gate) — no fire.
|
||||
_stream.Publish("esp_exiting", EnrollmentPhase.Unknown);
|
||||
_stream.Publish("hello_wizard_started", EnrollmentPhase.Unknown);
|
||||
_stream.Publish("esp_exiting", EnrollmentPhase.Unknown, "Engine");
|
||||
_stream.Publish("hello_wizard_started", EnrollmentPhase.Unknown, "Engine");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, _sink.CountOf("gather_host_test"));
|
||||
|
||||
// Only the engine's actual phase declaration fires the rule.
|
||||
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup);
|
||||
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup, "Engine");
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
// enrollment_complete" trigger was dead under the previous SignalPosted feed.
|
||||
BuildHost(Rule("GATHER-HOST-002", "on_event", triggerEventType: "enrollment_complete"));
|
||||
|
||||
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown);
|
||||
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown, "Engine");
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
}
|
||||
|
||||
@@ -151,14 +151,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
// identical phase (e.g. duplicate esp_phase_changed) must not count as one.
|
||||
BuildHost(Rule("GATHER-HOST-003", "phase_change", triggerPhase: ""));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup);
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup, "Engine");
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup);
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup, "Engine");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, _sink.CountOf("gather_host_test"));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup);
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup, "Engine");
|
||||
Assert.True(WaitForCount("gather_host_test", 2));
|
||||
}
|
||||
|
||||
@@ -167,11 +167,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
BuildHost(Rule("GATHER-HOST-004", "phase_exit", triggerPhase: "AccountSetup"));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup);
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup, "Engine");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, _sink.CountOf("gather_host_test"));
|
||||
|
||||
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup);
|
||||
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup, "Engine");
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnEventRule_NeverFiresOnGatherExecutorOutput_BreaksSelfTriggerLoop()
|
||||
{
|
||||
// A rule whose TriggerEventType equals its own OutputEventType: the event it emits
|
||||
// travels post → ingress → reducer → emitter → stream and comes back here with
|
||||
// Source == GatherRuleExecutor. Without the source guard that re-enters OnEvent and
|
||||
// the rule loops for the whole session (execute → emit → trigger → execute …).
|
||||
BuildHost(Rule("GATHER-HOST-006", "on_event", triggerEventType: "gather_host_test"));
|
||||
|
||||
_stream.Publish("gather_host_test", EnrollmentPhase.Unknown,
|
||||
Core.Monitoring.Telemetry.Gather.GatherRuleExecutor.SourceName);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, _sink.CountOf("gather_host_test"));
|
||||
|
||||
// The same event type from any other origin still triggers exactly once per event.
|
||||
_stream.Publish("gather_host_test", EnrollmentPhase.Unknown, "Engine");
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, _sink.CountOf("gather_host_test"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhaseDeclarationOnGatherSourcedEvent_StillDrivesPhaseTriggers()
|
||||
{
|
||||
// The source guard only suppresses on_event dispatch; a phase declaration is a
|
||||
// timeline fact regardless of which component carried it.
|
||||
BuildHost(Rule("GATHER-HOST-007", "phase_change", triggerPhase: "DeviceSetup"));
|
||||
|
||||
_stream.Publish("gather_host_test", EnrollmentPhase.DeviceSetup,
|
||||
Core.Monitoring.Telemetry.Gather.GatherRuleExecutor.SourceName);
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
}
|
||||
|
||||
@@ -181,7 +214,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
BuildHost(Rule("GATHER-HOST-005", "on_event", triggerEventType: "enrollment_complete"));
|
||||
|
||||
_host!.Stop();
|
||||
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown);
|
||||
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown, "Engine");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, _sink.CountOf("gather_host_test"));
|
||||
}
|
||||
|
||||
+3
-3
@@ -161,7 +161,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
|
||||
using var r = new Rig();
|
||||
var stream = new AutopilotMonitor.Agent.V2.Core.Orchestration.TimelineEventStream();
|
||||
var published = new List<(string EventType, EnrollmentPhase Phase)>();
|
||||
stream.EventEmitted += (eventType, phase) => published.Add((eventType, phase));
|
||||
stream.EventEmitted += (eventType, phase, _) => published.Add((eventType, phase));
|
||||
var sut = new EventTimelineEmitter(r.Inner, stream);
|
||||
|
||||
sut.Emit(
|
||||
@@ -190,8 +190,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
|
||||
using var r = new Rig();
|
||||
var stream = new AutopilotMonitor.Agent.V2.Core.Orchestration.TimelineEventStream();
|
||||
var secondSubscriberCalls = 0;
|
||||
stream.EventEmitted += (_, _) => throw new InvalidOperationException("subscriber bug");
|
||||
stream.EventEmitted += (_, _) => secondSubscriberCalls++;
|
||||
stream.EventEmitted += (_, _, _) => throw new InvalidOperationException("subscriber bug");
|
||||
stream.EventEmitted += (_, _, _) => secondSubscriberCalls++;
|
||||
var sut = new EventTimelineEmitter(r.Inner, stream);
|
||||
|
||||
sut.Emit(
|
||||
|
||||
+17
-3
@@ -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,78 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Win32 file-handle declarations for <c>PinnedSourceFolder</c>: open a file or directory
|
||||
/// without following a reparse point at its final component, and ask the handle where it
|
||||
/// really points (<c>GetFinalPathNameByHandle</c>) instead of trusting the path string.
|
||||
/// </summary>
|
||||
internal static class FileNativeMethods
|
||||
{
|
||||
public const uint GENERIC_READ = 0x80000000;
|
||||
public const uint FILE_LIST_DIRECTORY = 0x0001;
|
||||
public const uint FILE_READ_ATTRIBUTES = 0x0080;
|
||||
|
||||
public const uint FILE_SHARE_READ = 0x1;
|
||||
public const uint FILE_SHARE_WRITE = 0x2;
|
||||
|
||||
public const uint OPEN_EXISTING = 3;
|
||||
|
||||
public const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000;
|
||||
public const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
|
||||
public const uint FILE_FLAG_SEQUENTIAL_SCAN = 0x08000000;
|
||||
|
||||
public const uint FILE_ATTRIBUTE_DIRECTORY = 0x10;
|
||||
public const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x400;
|
||||
|
||||
/// <summary>GetFinalPathNameByHandle flags: FILE_NAME_NORMALIZED | VOLUME_NAME_DOS (both zero).</summary>
|
||||
public const uint FINAL_PATH_NORMALIZED_DOS = 0;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct BY_HANDLE_FILE_INFORMATION
|
||||
{
|
||||
public uint FileAttributes;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
|
||||
public uint VolumeSerialNumber;
|
||||
public uint FileSizeHigh;
|
||||
public uint FileSizeLow;
|
||||
public uint NumberOfLinks;
|
||||
public uint FileIndexHigh;
|
||||
public uint FileIndexLow;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
public static extern SafeFileHandle CreateFileW(
|
||||
string lpFileName,
|
||||
uint dwDesiredAccess,
|
||||
uint dwShareMode,
|
||||
IntPtr lpSecurityAttributes,
|
||||
uint dwCreationDisposition,
|
||||
uint dwFlagsAndAttributes,
|
||||
IntPtr hTemplateFile);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool GetFileInformationByHandle(
|
||||
SafeFileHandle hFile,
|
||||
out BY_HANDLE_FILE_INFORMATION lpFileInformation);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
public static extern uint GetFinalPathNameByHandleW(
|
||||
SafeFileHandle hFile,
|
||||
StringBuilder lpszFilePath,
|
||||
uint cchFilePath,
|
||||
uint dwFlags);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
public static extern uint GetLongPathNameW(
|
||||
string lpszShortPath,
|
||||
StringBuilder lpszLongPath,
|
||||
uint cchBuffer);
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// One member of a local group as returned by <c>NetLocalGroupGetMembers</c> level 2.
|
||||
/// <see cref="DomainAndName"/> is <c>DOMAIN\name</c> for resolvable members; for members
|
||||
/// whose SID cannot be resolved on the device (Entra role SIDs on an Entra-joined device,
|
||||
/// deleted accounts) it falls back to the SID string so the member is still visible.
|
||||
/// </summary>
|
||||
internal sealed class LocalGroupMember
|
||||
{
|
||||
public string Sid { get; set; }
|
||||
public string DomainAndName { get; set; }
|
||||
public int SidUsage { get; set; }
|
||||
|
||||
/// <summary>Domain part of <see cref="DomainAndName"/> (empty when there is none).</summary>
|
||||
public string Domain
|
||||
{
|
||||
get
|
||||
{
|
||||
var idx = (DomainAndName ?? string.Empty).IndexOf('\\');
|
||||
return idx > 0 ? DomainAndName.Substring(0, idx) : string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Account part of <see cref="DomainAndName"/> (the whole string when there is no domain).</summary>
|
||||
public string Name
|
||||
{
|
||||
get
|
||||
{
|
||||
var value = DomainAndName ?? string.Empty;
|
||||
var idx = value.IndexOf('\\');
|
||||
return idx >= 0 && idx < value.Length - 1 ? value.Substring(idx + 1) : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Result of <see cref="LocalGroupNativeMethods.GetAdministratorsMembers"/>.</summary>
|
||||
internal sealed class LocalGroupMembersResult
|
||||
{
|
||||
public string GroupName { get; set; }
|
||||
public List<LocalGroupMember> Members { get; set; } = new List<LocalGroupMember>();
|
||||
|
||||
/// <summary>NET_API_STATUS of the enumeration; 0 = success.</summary>
|
||||
public int ErrorCode { get; set; }
|
||||
|
||||
public bool Succeeded => ErrorCode == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Win32 P/Invoke declarations for local group membership queries
|
||||
/// (<c>NetLocalGroupGetMembers</c>). Used by <c>LocalAdminAnalyzer</c> to enumerate the
|
||||
/// members of the built-in Administrators group directly from the SAM — independent of
|
||||
/// WMI and of account enabled/disabled state, so a dormant (disabled) admin account is
|
||||
/// still reported. The group is addressed by its well-known SID (S-1-5-32-544) so the
|
||||
/// localized group name ("Administratoren", "Administrateurs") does not matter.
|
||||
/// </summary>
|
||||
internal static class LocalGroupNativeMethods
|
||||
{
|
||||
private const uint MAX_PREFERRED_LENGTH = 0xFFFFFFFF;
|
||||
|
||||
/// <summary>LOCALGROUP_MEMBERS_INFO_2 — SID, SID usage and DOMAIN\name of a member.</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct LOCALGROUP_MEMBERS_INFO_2
|
||||
{
|
||||
public IntPtr lgrmi2_sid;
|
||||
public int lgrmi2_sidusage;
|
||||
[MarshalAs(UnmanagedType.LPWStr)]
|
||||
public string lgrmi2_domainandname;
|
||||
}
|
||||
|
||||
[DllImport("netapi32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int NetLocalGroupGetMembers(
|
||||
string servername,
|
||||
string localgroupname,
|
||||
int level,
|
||||
out IntPtr bufptr,
|
||||
uint prefmaxlen,
|
||||
out int entriesread,
|
||||
out int totalentries,
|
||||
IntPtr resumehandle);
|
||||
|
||||
[DllImport("netapi32.dll")]
|
||||
private static extern int NetApiBufferFree(IntPtr buffer);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the localized name of the built-in Administrators group from its well-known
|
||||
/// SID. Falls back to the English name when the translation is unavailable.
|
||||
/// </summary>
|
||||
public static string ResolveAdministratorsGroupName()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||
var account = (NTAccount)sid.Translate(typeof(NTAccount));
|
||||
var value = account.Value ?? string.Empty; // "BUILTIN\Administrators"
|
||||
var idx = value.IndexOf('\\');
|
||||
var name = idx >= 0 && idx < value.Length - 1 ? value.Substring(idx + 1) : value;
|
||||
return string.IsNullOrEmpty(name) ? "Administrators" : name;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "Administrators";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the members of the built-in Administrators group on the local machine.
|
||||
/// Never throws: a failed enumeration returns an empty member list with the native
|
||||
/// error code in <see cref="LocalGroupMembersResult.ErrorCode"/> so the caller can log it.
|
||||
/// </summary>
|
||||
public static LocalGroupMembersResult GetAdministratorsMembers()
|
||||
{
|
||||
var result = new LocalGroupMembersResult { GroupName = ResolveAdministratorsGroupName() };
|
||||
var buffer = IntPtr.Zero;
|
||||
|
||||
try
|
||||
{
|
||||
var status = NetLocalGroupGetMembers(
|
||||
null, result.GroupName, 2, out buffer, MAX_PREFERRED_LENGTH,
|
||||
out var entriesRead, out _, IntPtr.Zero);
|
||||
|
||||
if (status != 0)
|
||||
{
|
||||
result.ErrorCode = status;
|
||||
return result;
|
||||
}
|
||||
|
||||
var entrySize = Marshal.SizeOf(typeof(LOCALGROUP_MEMBERS_INFO_2));
|
||||
for (var i = 0; i < entriesRead; i++)
|
||||
{
|
||||
var entryPtr = new IntPtr(buffer.ToInt64() + (long)i * entrySize);
|
||||
var entry = (LOCALGROUP_MEMBERS_INFO_2)Marshal.PtrToStructure(entryPtr, typeof(LOCALGROUP_MEMBERS_INFO_2));
|
||||
|
||||
string sid = null;
|
||||
try
|
||||
{
|
||||
if (entry.lgrmi2_sid != IntPtr.Zero)
|
||||
sid = new SecurityIdentifier(entry.lgrmi2_sid).Value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Malformed SID — keep the member with whatever name the API returned.
|
||||
}
|
||||
|
||||
var domainAndName = entry.lgrmi2_domainandname;
|
||||
if (string.IsNullOrEmpty(domainAndName))
|
||||
domainAndName = sid ?? string.Empty;
|
||||
|
||||
result.Members.Add(new LocalGroupMember
|
||||
{
|
||||
Sid = sid,
|
||||
DomainAndName = domainAndName,
|
||||
SidUsage = entry.lgrmi2_sidusage
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Marshalling failure or missing export — report as a generic failure code
|
||||
// (HRESULT when available) so the analyzer logs it instead of reporting an empty group as fact.
|
||||
result.ErrorCode = ex.HResult != 0 ? ex.HResult : -1;
|
||||
result.Members.Clear();
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (buffer != IntPtr.Zero) NetApiBufferFree(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
-123
@@ -102,6 +102,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
// hundreds of MB of fixture data. Production callers leave this at Default.
|
||||
internal DiagnosticsBudget Budget { get; set; } = DiagnosticsBudget.Default;
|
||||
|
||||
// Test seam: invoked with each candidate path right before it is opened, so a test can
|
||||
// reproduce the enumerate-then-open race (a subdirectory swapped for a junction) at the
|
||||
// exact point production is exposed. Null in production.
|
||||
internal Action<string> BeforeSourceFileOpen { get; set; }
|
||||
|
||||
// Tracks per-build inclusion totals + skip reasons. Threaded through every
|
||||
// AddLogFiles call so caps are global across all sections, not per section.
|
||||
private sealed class BudgetTracker
|
||||
@@ -562,138 +567,162 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
private void AddLogFiles(ZipArchive archive, string sourceFolder, string zipFolder, string searchPattern,
|
||||
BudgetTracker tracker, bool includeSubfolders = false)
|
||||
{
|
||||
if (!Directory.Exists(sourceFolder))
|
||||
// The folder stays pinned (open handle) for this whole section. The path guards and
|
||||
// the enumeration below only ever judge path strings; every byte that reaches the
|
||||
// archive comes from a handle that PinnedSourceFolder verified to point inside the
|
||||
// validated location at the moment it was opened.
|
||||
using (var pinned = PinnedSourceFolder.TryOpen(sourceFolder, out var folderRejection))
|
||||
{
|
||||
_logger.Debug($"Log folder not found, skipping: {sourceFolder}");
|
||||
ManifestLine($"FOLDER MISSING: {sourceFolder} (pattern '{searchPattern}')");
|
||||
return;
|
||||
}
|
||||
if (pinned == null)
|
||||
{
|
||||
if (folderRejection == PinnedSourceFolder.RejectMissing)
|
||||
{
|
||||
_logger.Debug($"Log folder not found, skipping: {sourceFolder}");
|
||||
ManifestLine($"FOLDER MISSING: {sourceFolder} (pattern '{searchPattern}')");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning($"Diagnostics source folder rejected ({folderRejection}): {sourceFolder}");
|
||||
ManifestLine($"FOLDER REJECTED ({folderRejection}): {sourceFolder} (pattern '{searchPattern}')");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
List<string> files;
|
||||
try
|
||||
{
|
||||
files = EnumerateFilesNoReparseDirs(sourceFolder, searchPattern, includeSubfolders);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}");
|
||||
ManifestLine($"ENUMERATION FAILED: {sourceFolder} (pattern '{searchPattern}'): {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.Count == 0)
|
||||
ManifestLine($"NO MATCH: {sourceFolder} pattern '{searchPattern}'");
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
List<string> files;
|
||||
try
|
||||
{
|
||||
// Per-file reparse check: a top-level file in the folder may itself be a
|
||||
// symlink/junction even though its parent directory is real.
|
||||
FileAttributes attrs;
|
||||
try { attrs = File.GetAttributes(file); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to read attributes for {file}: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
if (IsReparsePoint(attrs))
|
||||
{
|
||||
_logger.Warning($"Skipping reparse-point file: {file}");
|
||||
tracker.RecordSkip(file, "reparse", 0);
|
||||
ManifestLine($"SKIPPED (reparse point): {file}");
|
||||
continue;
|
||||
}
|
||||
|
||||
long length;
|
||||
try { length = new FileInfo(file).Length; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to stat {file}: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Active event-log channels hold their .evtx exclusively locked — a raw
|
||||
// FileStream open fails with a sharing violation. Export the channel via
|
||||
// wevtutil instead and stream the export (deleted below). Session a11102f4:
|
||||
// the tenant-configured BootstrapperAgent channel never made it into the ZIP.
|
||||
string streamSource = file;
|
||||
string tempExport = null;
|
||||
if (file.EndsWith(".evtx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
tempExport = TryExportEventLogChannel(file);
|
||||
if (tempExport != null)
|
||||
{
|
||||
streamSource = tempExport;
|
||||
try { length = new FileInfo(tempExport).Length; }
|
||||
catch { /* keep original length estimate */ }
|
||||
}
|
||||
else
|
||||
{
|
||||
// Export failed (channel not resolvable / wevtutil error) — fall through
|
||||
// to the raw copy. Inactive or archived evtx files are not locked, so
|
||||
// the plain FileStream often still succeeds; if the channel IS active
|
||||
// and locked, the existing per-file catch logs the failure.
|
||||
_logger.Warning($"Event log export unavailable for {file} — attempting raw copy.");
|
||||
ManifestLine($"EVTX EXPORT FAILED (falling back to raw copy): {file}");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (length > tracker.Budget.MaxSingleFileBytes)
|
||||
{
|
||||
_logger.Warning($"Skipping oversized file ({length} bytes > {tracker.Budget.MaxSingleFileBytes} cap): {file}");
|
||||
tracker.RecordSkip(file, "size", length);
|
||||
ManifestLine($"SKIPPED (single-file cap, {length} bytes): {file}");
|
||||
continue;
|
||||
}
|
||||
if (tracker.WouldExceedCount())
|
||||
{
|
||||
_logger.Warning($"Skipping file (file-count cap {tracker.Budget.MaxFileCount} reached): {file}");
|
||||
tracker.RecordSkip(file, "count", length);
|
||||
ManifestLine($"SKIPPED (file-count cap): {file}");
|
||||
continue;
|
||||
}
|
||||
if (tracker.WouldExceedTotal(length))
|
||||
{
|
||||
_logger.Warning($"Skipping file (total-bytes cap {tracker.Budget.MaxTotalUncompressedBytes} reached): {file}");
|
||||
tracker.RecordSkip(file, "total", length);
|
||||
ManifestLine($"SKIPPED (total-bytes cap): {file}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Preserve subfolder structure in the ZIP when includeSubfolders is enabled
|
||||
var relativePath = file.Substring(sourceFolder.Length).TrimStart(Path.DirectorySeparatorChar);
|
||||
var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
|
||||
|
||||
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||
// Stream directly from disk into the entry — no per-file MemoryStream/byte[]
|
||||
// copy. FileShare.ReadWrite avoids locking conflicts with active log writers.
|
||||
using (var fs = new FileStream(streamSource, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
using (var entryStream = entry.Open())
|
||||
{
|
||||
fs.CopyTo(entryStream);
|
||||
}
|
||||
|
||||
tracker.RecordIncluded(length);
|
||||
_logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
|
||||
ManifestLine($"ADDED: {entryName} ({length} bytes)");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tempExport != null)
|
||||
{
|
||||
try { File.Delete(tempExport); } catch { }
|
||||
}
|
||||
}
|
||||
files = EnumerateFilesNoReparseDirs(pinned.LexicalPath, searchPattern, includeSubfolders);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}");
|
||||
ManifestLine($"FAILED (copy): {file}: {ex.Message}");
|
||||
_logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}");
|
||||
ManifestLine($"ENUMERATION FAILED: {sourceFolder} (pattern '{searchPattern}'): {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.Count == 0)
|
||||
ManifestLine($"NO MATCH: {sourceFolder} pattern '{searchPattern}'");
|
||||
|
||||
foreach (var file in files)
|
||||
AddLogFile(archive, pinned, file, zipFolder, tracker);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddLogFile(ZipArchive archive, PinnedSourceFolder pinned, string file, string zipFolder, BudgetTracker tracker)
|
||||
{
|
||||
string tempExport = null;
|
||||
try
|
||||
{
|
||||
// Active event-log channels hold their .evtx exclusively locked — a raw
|
||||
// FileStream open fails with a sharing violation. Export the channel via
|
||||
// wevtutil instead and stream the export (deleted below). Session a11102f4:
|
||||
// the tenant-configured BootstrapperAgent channel never made it into the ZIP.
|
||||
if (file.EndsWith(".evtx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
tempExport = TryExportEventLogChannel(file);
|
||||
if (tempExport == null)
|
||||
{
|
||||
// Export failed (channel not resolvable / wevtutil error) — fall through
|
||||
// to the raw copy. Inactive or archived evtx files are not locked, so
|
||||
// the validated open often still succeeds; if the channel IS active
|
||||
// and locked, the open failure is recorded below.
|
||||
_logger.Warning($"Event log export unavailable for {file} — attempting raw copy.");
|
||||
ManifestLine($"EVTX EXPORT FAILED (falling back to raw copy): {file}");
|
||||
}
|
||||
}
|
||||
|
||||
BeforeSourceFileOpen?.Invoke(file);
|
||||
|
||||
using (var source = OpenSource(pinned, file, tempExport, tracker))
|
||||
{
|
||||
if (source == null) return;
|
||||
|
||||
var length = source.Length;
|
||||
if (length > tracker.Budget.MaxSingleFileBytes)
|
||||
{
|
||||
_logger.Warning($"Skipping oversized file ({length} bytes > {tracker.Budget.MaxSingleFileBytes} cap): {file}");
|
||||
tracker.RecordSkip(file, "size", length);
|
||||
ManifestLine($"SKIPPED (single-file cap, {length} bytes): {file}");
|
||||
return;
|
||||
}
|
||||
if (tracker.WouldExceedCount())
|
||||
{
|
||||
_logger.Warning($"Skipping file (file-count cap {tracker.Budget.MaxFileCount} reached): {file}");
|
||||
tracker.RecordSkip(file, "count", length);
|
||||
ManifestLine($"SKIPPED (file-count cap): {file}");
|
||||
return;
|
||||
}
|
||||
if (tracker.WouldExceedTotal(length))
|
||||
{
|
||||
_logger.Warning($"Skipping file (total-bytes cap {tracker.Budget.MaxTotalUncompressedBytes} reached): {file}");
|
||||
tracker.RecordSkip(file, "total", length);
|
||||
ManifestLine($"SKIPPED (total-bytes cap): {file}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Preserve subfolder structure in the ZIP when includeSubfolders is enabled
|
||||
var relativePath = file.Substring(pinned.LexicalPath.Length).TrimStart(Path.DirectorySeparatorChar);
|
||||
var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
|
||||
|
||||
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||
// Stream straight from the validated handle into the entry — no per-file
|
||||
// MemoryStream/byte[] copy.
|
||||
using (var entryStream = entry.Open())
|
||||
{
|
||||
source.Stream.CopyTo(entryStream);
|
||||
}
|
||||
|
||||
tracker.RecordIncluded(length);
|
||||
_logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
|
||||
ManifestLine($"ADDED: {entryName} ({length} bytes)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}");
|
||||
ManifestLine($"FAILED (copy): {file}: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tempExport != null)
|
||||
{
|
||||
try { File.Delete(tempExport); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The readable source behind one candidate, or null once the skip has been recorded.
|
||||
// An event-log export is the agent's own temp file and is streamed as-is; everything
|
||||
// else must come back from the pinned folder's validated open.
|
||||
private SourceFile OpenSource(PinnedSourceFolder pinned, string file, string tempExport, BudgetTracker tracker)
|
||||
{
|
||||
if (tempExport != null)
|
||||
return SourceFile.OpenAgentOwned(tempExport);
|
||||
|
||||
var source = pinned.TryOpenFile(file, out var rejection);
|
||||
if (source != null) return source;
|
||||
|
||||
switch (rejection)
|
||||
{
|
||||
case PinnedSourceFolder.RejectReparsePoint:
|
||||
_logger.Warning($"Skipping reparse-point file: {file}");
|
||||
tracker.RecordSkip(file, "reparse", 0);
|
||||
ManifestLine($"SKIPPED (reparse point): {file}");
|
||||
break;
|
||||
case PinnedSourceFolder.RejectResolvedElsewhere:
|
||||
// The path was inside the folder when it was enumerated; the handle is not.
|
||||
// A directory in between became a junction or symlink — nothing from behind
|
||||
// it is copied, and the manifest names only the path we were asked for.
|
||||
_logger.Warning($"Skipping file that resolves outside its validated folder (reparse point introduced during packaging): {file}");
|
||||
tracker.RecordSkip(file, "resolved-outside-folder", 0);
|
||||
ManifestLine($"SKIPPED (resolved outside validated folder): {file}");
|
||||
break;
|
||||
default:
|
||||
_logger.Warning($"Failed to open {file}: {rejection}");
|
||||
ManifestLine($"FAILED (open): {file}: {rejection}");
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// A diagnostics source folder held open for one section build, and the only way the
|
||||
/// package reads bytes out of it.
|
||||
///
|
||||
/// The path guards (<see cref="Telemetry.Gather.DiagnosticsPathGuards"/>) and the
|
||||
/// enumeration's reparse-point skip are check-then-act: they judge a path string, and the
|
||||
/// bytes are read from that string later. Under any folder a local user can write to, a
|
||||
/// subdirectory can become a junction between the check and the read, and the copy would
|
||||
/// follow it into whatever SYSTEM can read. This class puts both decisions on the handle
|
||||
/// that is actually read:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <see cref="TryOpen"/> opens the folder once. The handle's final path
|
||||
/// (<c>GetFinalPathNameByHandle</c> — every reparse point resolved) must equal the
|
||||
/// validated lexical path, otherwise a junction or symlink sits somewhere in the chain
|
||||
/// and the whole folder is rejected. The handle stays open, without a delete share,
|
||||
/// until <see cref="Dispose"/>: NTFS refuses to rename or delete a directory while it
|
||||
/// or anything beneath it is open, so the validated chain cannot be swapped underneath
|
||||
/// the build either.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <see cref="TryOpenFile"/> opens each candidate with <c>FILE_FLAG_OPEN_REPARSE_POINT</c>
|
||||
/// (a symlink at the final component is opened as itself and refused) and requires the
|
||||
/// handle's final path to be exactly <c><canonical folder>\<relative path></c>.
|
||||
/// A subdirectory that became a junction after enumeration resolves elsewhere and the
|
||||
/// file is skipped. The stream handed back wraps that same validated handle.
|
||||
/// </item>
|
||||
/// </list>
|
||||
///
|
||||
/// Neither fact is re-derived from a path once the handle exists, so there is no window
|
||||
/// between validation and use. Hard links are out of scope: Windows refuses to create one
|
||||
/// to a file the caller cannot write.
|
||||
/// </summary>
|
||||
internal sealed class PinnedSourceFolder : IDisposable
|
||||
{
|
||||
public const string RejectMissing = "folder missing";
|
||||
public const string RejectNotDirectory = "not a directory";
|
||||
public const string RejectNotFile = "not a file";
|
||||
public const string RejectReparsePoint = "reparse point";
|
||||
public const string RejectResolvedElsewhere = "resolved outside validated folder";
|
||||
public const string RejectOutsideFolder = "outside pinned folder";
|
||||
|
||||
private readonly SafeFileHandle _handle;
|
||||
|
||||
/// <summary>Full lexical path the caller validated, without a trailing separator.</summary>
|
||||
public string LexicalPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Where the pinned handle really points; equal to <see cref="LexicalPath"/> up to
|
||||
/// case and 8.3 expansion, as verified by <see cref="TryOpen"/>.
|
||||
/// </summary>
|
||||
public string CanonicalPath { get; }
|
||||
|
||||
private PinnedSourceFolder(SafeFileHandle handle, string lexicalPath, string canonicalPath)
|
||||
{
|
||||
_handle = handle;
|
||||
LexicalPath = lexicalPath;
|
||||
CanonicalPath = canonicalPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins <paramref name="folder"/>. Returns null with a rejection reason (one of the
|
||||
/// <c>Reject*</c> constants or an open/query error) when the folder is missing, is not
|
||||
/// a directory, is a reparse point itself, or is reached through one.
|
||||
/// </summary>
|
||||
public static PinnedSourceFolder TryOpen(string folder, out string rejection)
|
||||
{
|
||||
string lexical;
|
||||
try
|
||||
{
|
||||
lexical = TrimSeparators(Path.GetFullPath(folder));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
rejection = $"invalid path: {ex.Message}";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(lexical))
|
||||
{
|
||||
rejection = File.Exists(lexical) ? RejectNotDirectory : RejectMissing;
|
||||
return null;
|
||||
}
|
||||
|
||||
SafeFileHandle handle = null;
|
||||
try
|
||||
{
|
||||
// FILE_LIST_DIRECTORY (a data access) is what makes this handle count for share
|
||||
// tracking: an attribute-only open would neither block nor be blocked, and a
|
||||
// rename could slip past the missing delete share.
|
||||
handle = FileNativeMethods.CreateFileW(
|
||||
lexical,
|
||||
FileNativeMethods.FILE_LIST_DIRECTORY | FileNativeMethods.FILE_READ_ATTRIBUTES,
|
||||
FileNativeMethods.FILE_SHARE_READ | FileNativeMethods.FILE_SHARE_WRITE,
|
||||
IntPtr.Zero,
|
||||
FileNativeMethods.OPEN_EXISTING,
|
||||
FileNativeMethods.FILE_FLAG_BACKUP_SEMANTICS | FileNativeMethods.FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
IntPtr.Zero);
|
||||
if (handle.IsInvalid)
|
||||
{
|
||||
rejection = "open failed: " + DescribeLastError();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!FileNativeMethods.GetFileInformationByHandle(handle, out var info))
|
||||
{
|
||||
rejection = "attributes unavailable: " + DescribeLastError();
|
||||
return null;
|
||||
}
|
||||
if ((info.FileAttributes & FileNativeMethods.FILE_ATTRIBUTE_REPARSE_POINT) != 0)
|
||||
{
|
||||
rejection = RejectReparsePoint;
|
||||
return null;
|
||||
}
|
||||
if ((info.FileAttributes & FileNativeMethods.FILE_ATTRIBUTE_DIRECTORY) == 0)
|
||||
{
|
||||
rejection = RejectNotDirectory;
|
||||
return null;
|
||||
}
|
||||
|
||||
var canonical = TryGetFinalPath(handle, out var finalPathError);
|
||||
if (canonical == null)
|
||||
{
|
||||
rejection = "final path unavailable: " + finalPathError;
|
||||
return null;
|
||||
}
|
||||
|
||||
// The lexical path may carry 8.3 components (%TEMP%-style expansions); a final
|
||||
// path never does. Expand before comparing so a short name is not mistaken for
|
||||
// a reparse point — a failed expansion keeps the raw string and fails closed.
|
||||
var expected = TryGetLongPath(lexical) ?? lexical;
|
||||
if (!PathEquals(canonical, expected))
|
||||
{
|
||||
rejection = RejectResolvedElsewhere;
|
||||
return null;
|
||||
}
|
||||
|
||||
var pinned = new PinnedSourceFolder(handle, lexical, canonical);
|
||||
handle = null; // owned by the instance from here on
|
||||
rejection = null;
|
||||
return pinned;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
rejection = "open failed: " + ex.Message;
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens <paramref name="candidatePath"/> (a lexical descendant of the pinned folder, as
|
||||
/// produced by enumeration) for reading. Returns null with a rejection reason when the
|
||||
/// candidate lies outside the folder, is a reparse point, is not a file, or — the race
|
||||
/// this class exists for — its handle resolves anywhere other than
|
||||
/// <c><canonical folder>\<relative path></c>.
|
||||
/// </summary>
|
||||
public SourceFile TryOpenFile(string candidatePath, out string rejection)
|
||||
{
|
||||
if (!TryGetRelativePath(candidatePath, out var relative))
|
||||
{
|
||||
rejection = RejectOutsideFolder;
|
||||
return null;
|
||||
}
|
||||
var expected = CanonicalPath + Path.DirectorySeparatorChar + relative;
|
||||
|
||||
SafeFileHandle handle = null;
|
||||
try
|
||||
{
|
||||
handle = FileNativeMethods.CreateFileW(
|
||||
candidatePath,
|
||||
FileNativeMethods.GENERIC_READ,
|
||||
FileNativeMethods.FILE_SHARE_READ | FileNativeMethods.FILE_SHARE_WRITE,
|
||||
IntPtr.Zero,
|
||||
FileNativeMethods.OPEN_EXISTING,
|
||||
FileNativeMethods.FILE_FLAG_OPEN_REPARSE_POINT | FileNativeMethods.FILE_FLAG_SEQUENTIAL_SCAN,
|
||||
IntPtr.Zero);
|
||||
if (handle.IsInvalid)
|
||||
{
|
||||
rejection = "open failed: " + DescribeLastError();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!FileNativeMethods.GetFileInformationByHandle(handle, out var info))
|
||||
{
|
||||
rejection = "attributes unavailable: " + DescribeLastError();
|
||||
return null;
|
||||
}
|
||||
if ((info.FileAttributes & FileNativeMethods.FILE_ATTRIBUTE_REPARSE_POINT) != 0)
|
||||
{
|
||||
rejection = RejectReparsePoint;
|
||||
return null;
|
||||
}
|
||||
if ((info.FileAttributes & FileNativeMethods.FILE_ATTRIBUTE_DIRECTORY) != 0)
|
||||
{
|
||||
rejection = RejectNotFile;
|
||||
return null;
|
||||
}
|
||||
|
||||
var finalPath = TryGetFinalPath(handle, out var finalPathError);
|
||||
if (finalPath == null)
|
||||
{
|
||||
rejection = "final path unavailable: " + finalPathError;
|
||||
return null;
|
||||
}
|
||||
if (!PathEquals(finalPath, expected))
|
||||
{
|
||||
rejection = RejectResolvedElsewhere;
|
||||
return null;
|
||||
}
|
||||
|
||||
var length = ((long)info.FileSizeHigh << 32) | info.FileSizeLow;
|
||||
var stream = new FileStream(handle, FileAccess.Read, 81920);
|
||||
handle = null; // owned by the stream from here on
|
||||
rejection = null;
|
||||
return new SourceFile(stream, length);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
rejection = "open failed: " + ex.Message;
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _handle.Dispose();
|
||||
|
||||
private bool TryGetRelativePath(string candidatePath, out string relative)
|
||||
{
|
||||
relative = null;
|
||||
string full;
|
||||
try
|
||||
{
|
||||
full = Path.GetFullPath(candidatePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (full.Length <= LexicalPath.Length + 1) return false;
|
||||
if (!full.StartsWith(LexicalPath, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (full[LexicalPath.Length] != Path.DirectorySeparatorChar) return false;
|
||||
|
||||
relative = full.Substring(LexicalPath.Length + 1);
|
||||
return relative.Length > 0 && relative[relative.Length - 1] != Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
/// <summary>Final (reparse-free, long-name) DOS path of an open handle, or null with the Win32 error.</summary>
|
||||
private static string TryGetFinalPath(SafeFileHandle handle, out string error)
|
||||
{
|
||||
var buffer = new StringBuilder(512);
|
||||
for (var attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
var length = FileNativeMethods.GetFinalPathNameByHandleW(
|
||||
handle, buffer, (uint)buffer.Capacity, FileNativeMethods.FINAL_PATH_NORMALIZED_DOS);
|
||||
if (length == 0)
|
||||
{
|
||||
error = DescribeLastError();
|
||||
return null;
|
||||
}
|
||||
if (length < buffer.Capacity)
|
||||
{
|
||||
error = null;
|
||||
return StripExtendedPrefix(buffer.ToString());
|
||||
}
|
||||
buffer.EnsureCapacity((int)length + 1);
|
||||
}
|
||||
error = "path longer than the retry buffer";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Long-name form of a path (8.3 components expanded), or null when Windows cannot resolve it.</summary>
|
||||
internal static string TryGetLongPath(string path)
|
||||
{
|
||||
var buffer = new StringBuilder(512);
|
||||
for (var attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
var length = FileNativeMethods.GetLongPathNameW(path, buffer, (uint)buffer.Capacity);
|
||||
if (length == 0) return null;
|
||||
if (length < buffer.Capacity) return buffer.ToString();
|
||||
buffer.EnsureCapacity((int)length + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string StripExtendedPrefix(string path)
|
||||
{
|
||||
if (path.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) return @"\\" + path.Substring(8);
|
||||
if (path.StartsWith(@"\\?\", StringComparison.Ordinal)) return path.Substring(4);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string TrimSeparators(string path) =>
|
||||
path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
private static bool PathEquals(string a, string b) =>
|
||||
string.Equals(TrimSeparators(a), TrimSeparators(b), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string DescribeLastError()
|
||||
{
|
||||
var code = Marshal.GetLastWin32Error();
|
||||
return $"{new Win32Exception(code).Message} (win32 {code})";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A readable source for one archive entry: the stream and the length the caps are charged
|
||||
/// with. From <see cref="PinnedSourceFolder.TryOpenFile"/> the stream is the validated
|
||||
/// handle itself.
|
||||
/// </summary>
|
||||
internal sealed class SourceFile : IDisposable
|
||||
{
|
||||
public Stream Stream { get; }
|
||||
public long Length { get; }
|
||||
|
||||
public SourceFile(Stream stream, long length)
|
||||
{
|
||||
Stream = stream;
|
||||
Length = length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file the agent wrote itself — an event-log export under an unguessable name in
|
||||
/// SYSTEM's temp folder — never a tenant-configured location, so a plain open is enough.
|
||||
/// </summary>
|
||||
public static SourceFile OpenAgentOwned(string path)
|
||||
{
|
||||
var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
return new SourceFile(stream, stream.Length);
|
||||
}
|
||||
|
||||
public void Dispose() => Stream.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
@@ -268,8 +268,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
|
||||
// Step 3: Download ZIP (timeout = downloadTimeoutMs)
|
||||
// ZIP lives under %ProgramData% (SYSTEM/Admin ACL) instead of C:\Windows\Temp so
|
||||
// a non-admin local user cannot plant a junction at the ZIP filename.
|
||||
// ZIP lives under %ProgramData% instead of C:\Windows\Temp so a non-admin local
|
||||
// user cannot plant a junction at the ZIP filename. The directory inherits the
|
||||
// ProgramData ACL (no explicit hardening — the updater runs during OOBE where only
|
||||
// SYSTEM exists); the verify→extract TOCTOU is closed by the exclusive handle below.
|
||||
var zipDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateDownloadDir);
|
||||
Directory.CreateDirectory(zipDir);
|
||||
var zipPath = Path.Combine(zipDir, "AutopilotMonitor-Agent-Update.zip");
|
||||
@@ -304,10 +306,51 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
expectedSha256 = null;
|
||||
}
|
||||
|
||||
var verifySw = Stopwatch.StartNew();
|
||||
var integrityOk = VerifyZipIntegrity(zipPath, expectedSha256, log);
|
||||
verifySw.Stop();
|
||||
verifyMs = verifySw.ElapsedMilliseconds;
|
||||
// Steps 3b + 4 share ONE exclusive handle (FileShare.None): the bytes that are
|
||||
// hashed are the bytes that get extracted. With separate opens a local user who
|
||||
// pre-owned the Updates directory could swap the ZIP after the hash passed and
|
||||
// before extraction (TOCTOU). The handle is disposed BEFORE CleanupStaging so the
|
||||
// ZIP delete does not fail with a sharing violation.
|
||||
FileStream zipStream;
|
||||
try
|
||||
{
|
||||
zipStream = new FileStream(zipPath, FileMode.Open, FileAccess.Read, FileShare.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: cannot open downloaded ZIP exclusively — {ex.Message}. Aborting update.");
|
||||
CleanupStaging(null, zipPath);
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("integrity_mismatch", currentVersion, latestVersion, "downloaded ZIP could not be opened exclusively", log);
|
||||
return;
|
||||
}
|
||||
|
||||
bool integrityOk;
|
||||
bool extractOk;
|
||||
var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir);
|
||||
using (zipStream)
|
||||
{
|
||||
var verifySw = Stopwatch.StartNew();
|
||||
integrityOk = VerifyZipIntegrity(zipStream, expectedSha256, log);
|
||||
verifySw.Stop();
|
||||
verifyMs = verifySw.ElapsedMilliseconds;
|
||||
|
||||
if (integrityOk)
|
||||
{
|
||||
// PR5: integrity verified — silent before, opaque whether the verify step actually ran.
|
||||
log($"Self-update: ZIP integrity verified ({verifyMs}ms)");
|
||||
|
||||
// Step 4: Extract to staging directory (same handle as the hash above)
|
||||
var extractSw = Stopwatch.StartNew();
|
||||
extractOk = ExtractToStaging(zipStream, stagingDir, log);
|
||||
extractSw.Stop();
|
||||
extractMs = extractSw.ElapsedMilliseconds;
|
||||
}
|
||||
else
|
||||
{
|
||||
extractOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!integrityOk)
|
||||
{
|
||||
@@ -316,15 +359,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
WriteSkipMarker("integrity_mismatch", currentVersion, latestVersion, "SHA-256 mismatch on downloaded ZIP", log);
|
||||
return;
|
||||
}
|
||||
// PR5: integrity verified — silent before, opaque whether the verify step actually ran.
|
||||
log($"Self-update: ZIP integrity verified ({verifyMs}ms)");
|
||||
|
||||
// Step 4: Extract to staging directory
|
||||
var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir);
|
||||
var extractSw = Stopwatch.StartNew();
|
||||
var extractOk = ExtractToStaging(zipPath, stagingDir, log);
|
||||
extractSw.Stop();
|
||||
extractMs = extractSw.ElapsedMilliseconds;
|
||||
if (!extractOk)
|
||||
{
|
||||
if (writeMarkers)
|
||||
@@ -444,8 +478,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// <summary>
|
||||
/// Verifies the SHA-256 hash of the downloaded ZIP against the expected hash.
|
||||
/// Returns true if the hash matches or if no expected hash is available (backward compat).
|
||||
/// Operates on the caller's exclusive handle (not the path) so the verified bytes are the
|
||||
/// ones later extracted; the stream is rewound to position 0 on return.
|
||||
/// </summary>
|
||||
private static bool VerifyZipIntegrity(string zipPath, string expectedSha256, Action<string> log)
|
||||
private static bool VerifyZipIntegrity(FileStream zipStream, string expectedSha256, Action<string> log)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expectedSha256))
|
||||
{
|
||||
@@ -456,9 +492,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
try
|
||||
{
|
||||
using (var sha256 = SHA256.Create())
|
||||
using (var stream = File.OpenRead(zipPath))
|
||||
{
|
||||
var hashBytes = sha256.ComputeHash(stream);
|
||||
zipStream.Position = 0;
|
||||
var hashBytes = sha256.ComputeHash(zipStream);
|
||||
zipStream.Position = 0;
|
||||
var actualHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
|
||||
|
||||
if (string.Equals(actualHash, expectedSha256.ToLowerInvariant(), StringComparison.Ordinal))
|
||||
@@ -576,7 +613,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// the staging dir (e.g. ..\..\System32\evil.dll). We iterate entries manually and
|
||||
/// reject any whose resolved absolute path falls outside the staging root.
|
||||
/// </summary>
|
||||
private static bool ExtractToStaging(string zipPath, string stagingDir, Action<string> log)
|
||||
private static bool ExtractToStaging(FileStream zipStream, string stagingDir, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -592,7 +629,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
stagingRoot += Path.DirectorySeparatorChar;
|
||||
|
||||
int fileCount = 0;
|
||||
using (var archive = ZipFile.OpenRead(zipPath))
|
||||
zipStream.Position = 0;
|
||||
using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true))
|
||||
{
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
|
||||
+294
-31
@@ -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>$<32 hex></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
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = Constants.EventTypes.SecurityWarning,
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "GatherRuleExecutor",
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Blocked command not on allowlist: {command} (Rule: {rule.RuleId})",
|
||||
Data = data
|
||||
});
|
||||
|
||||
+2
-2
@@ -291,7 +291,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "GatherRuleExecutor",
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
@@ -360,7 +360,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
Timestamp = entryUtc,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "GatherRuleExecutor",
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = Constants.EventTypes.SecurityWarning,
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "GatherRuleExecutor",
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Blocked {collectorType} target not on allowlist: {target} (Rule: {rule.RuleId})",
|
||||
Data = data
|
||||
});
|
||||
|
||||
+38
-1
@@ -21,6 +21,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
/// </summary>
|
||||
public class GatherRuleExecutor : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="EnrollmentEvent.Source"/> label stamped on every event the gather pipeline
|
||||
/// emits (executor, context guard events, collectors). The timeline feed uses it to keep
|
||||
/// gather output from re-triggering on_event rules.
|
||||
/// </summary>
|
||||
public const string SourceName = "GatherRuleExecutor";
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on on_event executions per rule and session. on_event has no natural
|
||||
/// once-per-(rule, phase) dedup like the phase triggers; this cap bounds any remaining
|
||||
/// cycle (e.g. two rules chained through an intermediate non-gather event) and any
|
||||
/// pathological trigger type that fires on every timeline entry.
|
||||
/// </summary>
|
||||
internal const int MaxOnEventExecutionsPerRule = 100;
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly GatherRuleDebugLog _debug; // null unless EnableGatherRuleDebugLog / --gather-debug-log
|
||||
private readonly GatherRuleContext _context;
|
||||
@@ -30,6 +45,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
private readonly Dictionary<string, Timer> _intervalTimers = new Dictionary<string, Timer>();
|
||||
private readonly HashSet<string> _startupRulesExecuted = new HashSet<string>();
|
||||
private readonly HashSet<string> _phaseRulesExecuted = new HashSet<string>();
|
||||
private readonly Dictionary<string, int> _onEventExecutions = new Dictionary<string, int>();
|
||||
private CountdownEvent _startupRulesLatch; // non-null only while startup rules are pending
|
||||
|
||||
// Phase-scope + emit-mode state (ActivePhases / ActiveFromPhase / EmitMode on GatherRule).
|
||||
@@ -394,6 +410,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
continue;
|
||||
}
|
||||
|
||||
// Per-rule execution cap (see MaxOnEventExecutionsPerRule). The counter is
|
||||
// bumped once past the cap so the boundary is logged exactly once.
|
||||
int executions;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
_onEventExecutions.TryGetValue(rule.RuleId, out executions);
|
||||
if (executions <= MaxOnEventExecutionsPerRule)
|
||||
_onEventExecutions[rule.RuleId] = executions + 1;
|
||||
}
|
||||
|
||||
if (executions >= MaxOnEventExecutionsPerRule)
|
||||
{
|
||||
if (executions == MaxOnEventExecutionsPerRule)
|
||||
{
|
||||
_logger.Warning($"Gather rule {rule.RuleId} reached the on_event execution cap ({MaxOnEventExecutionsPerRule}) for this session — further {eventType} triggers are ignored");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger,
|
||||
$"on_event {eventType} ignored: per-rule execution cap ({MaxOnEventExecutionsPerRule}) reached for this session");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Info($"Event triggered rule {rule.RuleId} (event: {eventType})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger, $"on_event {eventType} fired");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
|
||||
@@ -482,7 +519,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "GatherRuleExecutor",
|
||||
Source = SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = result
|
||||
});
|
||||
|
||||
+30
-18
@@ -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
|
||||
|
||||
+13
-3
@@ -33,7 +33,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// the enrollment reaches this phase"). Null on test fakes / standalone configurations —
|
||||
// then phase/event triggers degrade off (startup + interval rules still run).
|
||||
private readonly TimelineEventStream? _timelineEvents;
|
||||
private Action<string, EnrollmentPhase>? _timelineHandler;
|
||||
private Action<string, EnrollmentPhase, string>? _timelineHandler;
|
||||
private readonly object _sync = new object();
|
||||
private EnrollmentPhase _lastPhase = EnrollmentPhase.Unknown;
|
||||
|
||||
@@ -101,8 +101,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// realmjoin_resolved, …) that never existed on the raw signal stream. The executor
|
||||
/// dispatches rule execution on the ThreadPool, so this stays off the ingress worker's
|
||||
/// effect path; it also dedups phase rules per (rule, phase).
|
||||
/// <para>
|
||||
/// Feedback-loop guard: events the gather executor emitted itself (Source ==
|
||||
/// <see cref="Monitoring.Telemetry.Gather.GatherRuleExecutor.SourceName"/>) travel the
|
||||
/// same post → ingress → reducer → emitter → stream path and would otherwise re-enter
|
||||
/// <c>OnEvent</c>. A rule whose TriggerEventType equals its own OutputEventType (or two
|
||||
/// rules chained A→B→A) then executes and emits without bound for the whole session.
|
||||
/// Gather output therefore never dispatches on_event rules; phase declarations are still
|
||||
/// honoured (gather events carry Phase=Unknown, so this is a no-op in practice).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void OnTimelineEventEmitted(string eventType, EnrollmentPhase phase)
|
||||
private void OnTimelineEventEmitted(string eventType, EnrollmentPhase phase, string source)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -114,7 +123,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_executor.OnPhaseChanged(phase);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(eventType))
|
||||
if (!string.IsNullOrEmpty(eventType)
|
||||
&& !string.Equals(source, Monitoring.Telemetry.Gather.GatherRuleExecutor.SourceName, StringComparison.Ordinal))
|
||||
{
|
||||
_executor.OnEvent(eventType);
|
||||
}
|
||||
|
||||
@@ -33,18 +33,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// Raised once per emitted timeline event with its <see cref="EnrollmentEvent.EventType"/>
|
||||
/// and parsed <see cref="EnrollmentEvent.Phase"/> (<see cref="EnrollmentPhase.Unknown"/>
|
||||
/// for the vast majority of events — only phase-declaration events carry a real phase,
|
||||
/// which is exactly the set the UI timeline groups on).
|
||||
/// which is exactly the set the UI timeline groups on), plus the originating
|
||||
/// <see cref="EnrollmentEvent.Source"/> component label. The source lets subscribers that
|
||||
/// themselves emit timeline events (the gather-rule executor) recognise their own output
|
||||
/// and not re-trigger on it — see <see cref="GatherRuleExecutorHost"/>.
|
||||
/// </summary>
|
||||
public event Action<string, EnrollmentPhase>? EventEmitted;
|
||||
public event Action<string, EnrollmentPhase, string>? EventEmitted;
|
||||
|
||||
internal void Publish(string eventType, EnrollmentPhase phase)
|
||||
internal void Publish(string eventType, EnrollmentPhase phase, string source)
|
||||
{
|
||||
var handler = EventEmitted;
|
||||
if (handler == null) return;
|
||||
|
||||
foreach (Action<string, EnrollmentPhase> subscriber in handler.GetInvocationList())
|
||||
foreach (Action<string, EnrollmentPhase, string> subscriber in handler.GetInvocationList())
|
||||
{
|
||||
try { subscriber(eventType, phase); }
|
||||
try { subscriber(eventType, phase, source); }
|
||||
catch
|
||||
{
|
||||
// Subscriber isolation — see class doc: the emit path must never throw.
|
||||
|
||||
@@ -148,7 +148,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
// Post-reduce observer surface: published AFTER the transport enqueue so subscribers
|
||||
// (gather-rule phase/event triggers) see exactly the ordering that reaches the wire.
|
||||
// Publish never throws (subscriber isolation inside the stream).
|
||||
_timelineEvents?.Publish(evt.EventType, evt.Phase);
|
||||
_timelineEvents?.Publish(evt.EventType, evt.Phase, evt.Source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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()
|
||||
@@ -75,13 +81,14 @@ public class AuthFunctionSideEffectTests
|
||||
{ CallBase = false };
|
||||
|
||||
var mcpUserMock = new Mock<McpUserService>(
|
||||
adminRepo, cache, Mock.Of<ILogger<McpUserService>>(),
|
||||
adminRepo, new StubAdminIdentityBindingService(bound: true), cache, Mock.Of<ILogger<McpUserService>>(),
|
||||
globalAdminMock.Object, delegatedAdminMock.Object, adminConfigService.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
_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()
|
||||
{
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// RuleStats for gather rules are resolved against the tenant's active catalog, never from
|
||||
/// device-supplied event fields: a forged ruleId/ruleTitle cannot mint or respell a
|
||||
/// (global) RuleStats row. Mirrors the analyze path's catalog-only design.
|
||||
/// </summary>
|
||||
public class EventIngestProcessorGatherRuleStatsTests
|
||||
{
|
||||
private static GatherRule Rule(string id, string title, bool builtIn = true) => new GatherRule
|
||||
{
|
||||
RuleId = id, Title = title, Category = "Network", OutputSeverity = "Warning",
|
||||
IsBuiltIn = builtIn, IsCommunity = false, Enabled = true
|
||||
};
|
||||
|
||||
private static EnrollmentEvent GatherEvent(string? ruleId, string? ruleTitle = null, string source = "GatherRuleExecutor")
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
if (ruleId != null) data["ruleId"] = ruleId;
|
||||
if (ruleTitle != null) data["ruleTitle"] = ruleTitle;
|
||||
return new EnrollmentEvent { Source = source, Data = data };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Metadata_comes_from_catalog_not_from_event()
|
||||
{
|
||||
var catalog = new[] { Rule("GATHER-NET-001", "Catalog title") };
|
||||
var events = new[] { GatherEvent("GATHER-NET-001", "<attacker text>") };
|
||||
|
||||
var fired = EventIngestProcessor.ResolveFiredGatherRules(events, catalog);
|
||||
|
||||
var rule = Assert.Single(fired);
|
||||
Assert.Equal("Catalog title", rule.Title);
|
||||
Assert.Equal("Network", rule.Category);
|
||||
Assert.Equal("Warning", rule.OutputSeverity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GATHER-NET-999")] // reserved namespace, not in catalog (retired / unshipped)
|
||||
[InlineData("ANALYZE-ESP-001")] // reserved namespace of the other rule type
|
||||
[InlineData("CUSTOM-X")] // unknown custom id
|
||||
public void Claimed_id_outside_active_catalog_is_dropped(string forgedId)
|
||||
{
|
||||
var catalog = new[] { Rule("GATHER-NET-001", "Catalog title") };
|
||||
var events = new[] { GatherEvent(forgedId, "forged") };
|
||||
|
||||
Assert.Empty(EventIngestProcessor.ResolveFiredGatherRules(events, catalog));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_GatherRuleExecutor_events_with_ruleId_count_once_per_batch()
|
||||
{
|
||||
var catalog = new[] { Rule("GATHER-NET-001", "A"), Rule("GATHER-NET-002", "B") };
|
||||
var events = new[]
|
||||
{
|
||||
GatherEvent("GATHER-NET-001"),
|
||||
GatherEvent("gather-net-001"), // duplicate (case-insensitive)
|
||||
GatherEvent("GATHER-NET-002", source: "OtherSource"), // wrong source
|
||||
GatherEvent(null), // no ruleId
|
||||
new EnrollmentEvent { Source = "GatherRuleExecutor", Data = null! },
|
||||
};
|
||||
|
||||
var fired = EventIngestProcessor.ResolveFiredGatherRules(events, catalog);
|
||||
|
||||
var rule = Assert.Single(fired);
|
||||
Assert.Equal("GATHER-NET-001", rule.RuleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Custom_tenant_rule_resolves_but_is_not_global()
|
||||
{
|
||||
var catalog = new[] { Rule("MY-RULE", "Custom", builtIn: false) };
|
||||
var fired = EventIngestProcessor.ResolveFiredGatherRules(new[] { GatherEvent("MY-RULE") }, catalog);
|
||||
|
||||
var rule = Assert.Single(fired);
|
||||
Assert.False(rule.IsBuiltIn || rule.IsCommunity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,6 +240,45 @@ public class GatherRuleScopeFieldsTests
|
||||
ValidationRule(activePhases: new List<string> { "" })));
|
||||
}
|
||||
|
||||
private static GatherRule OnEventRule(string triggerEventType, string? outputEventType)
|
||||
=> new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-VAL-EVT",
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\Contoso",
|
||||
Trigger = "on_event",
|
||||
TriggerEventType = triggerEventType,
|
||||
OutputEventType = outputEventType!,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("gather_proxy", "gather_proxy")] // self-cycle
|
||||
[InlineData("GATHER_PROXY", "gather_proxy")] // case-insensitive (agent matches OrdinalIgnoreCase)
|
||||
[InlineData("gather_result", null)] // self-cycle through the default output type
|
||||
[InlineData("gather_result", "")]
|
||||
[InlineData("gather_result", "gather_other")] // gather output namespace never triggers
|
||||
public void Validate_RejectsOnEventRuleTriggeringOnGatherOutput(string trigger, string? output)
|
||||
{
|
||||
Assert.NotNull(GatherRulesFunction.ValidateScopeAndEmitMode(OnEventRule(trigger, output)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("enrollment_complete", "gather_proxy")]
|
||||
[InlineData("app_install_failed", null)]
|
||||
[InlineData("", "gather_proxy")] // partial/toggle payload passes through
|
||||
public void Validate_AcceptsOnEventRuleOnNonGatherEvents(string trigger, string? output)
|
||||
{
|
||||
Assert.Null(GatherRulesFunction.ValidateScopeAndEmitMode(OnEventRule(trigger, output)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_OutputEqualsTrigger_IsOnlyCheckedForOnEventTrigger()
|
||||
{
|
||||
var rule = OnEventRule("gather_same", "gather_same");
|
||||
rule.Trigger = "startup";
|
||||
Assert.Null(GatherRulesFunction.ValidateScopeAndEmitMode(rule));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsBothScopeFieldsSet()
|
||||
{
|
||||
|
||||
@@ -123,13 +123,107 @@ public class GetImeVersionHistoryRedactionTests
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"firstSeenAt", "firstSeenSessionId", "firstSeenTenantId", "lastSeenAt",
|
||||
"corroboratedAt", "firstSeenAt", "firstSeenSessionId", "firstSeenTenantId", "lastSeenAt",
|
||||
"msiArchiveBlobPath", "msiArchiveStatus", "msiArchiveUpdatedAt", "msiBytes", "msiSha256", "msiSourceUrl",
|
||||
"sessionCount", "version",
|
||||
},
|
||||
keys);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Fleet confirmation — what a tenant member may see at all
|
||||
// =========================================================================
|
||||
//
|
||||
// A row exists for whatever string a device reports (guarded for plausibility only).
|
||||
// One tenant's device must not be able to publish an unverified "version" into every
|
||||
// other tenant's rollout view, so the MemberRead projection lists a version only once
|
||||
// the installer was archived with a matching ProductVersion, a second tenant reported
|
||||
// it, or the row predates the guard. Global scope sees every row — including the ones
|
||||
// that need investigating.
|
||||
|
||||
private static readonly DateTime AfterCutoff = GetImeVersionHistoryFunction.LegacyTrustCutoffUtc.AddDays(1);
|
||||
|
||||
private static ImeVersionHistoryEntry Unconfirmed() =>
|
||||
new() { Version = "1.86.999.0", FirstSeenAt = AfterCutoff, LastSeenAt = AfterCutoff, SessionCount = 1 };
|
||||
|
||||
[Fact]
|
||||
public void NonGlobalCaller_DoesNotSeeAnUnconfirmedVersion()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, Unconfirmed()));
|
||||
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalCaller_SeesAnUnconfirmedVersion()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: true, Unconfirmed()));
|
||||
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
Assert.Equal("1.86.999.0", doc.RootElement[0].GetProperty("version").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGlobalCaller_SeesAVersion_OnceItsInstallerIsArchived()
|
||||
{
|
||||
var entry = Unconfirmed();
|
||||
entry.MsiArchiveStatus = "Archived";
|
||||
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, entry));
|
||||
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Queued")]
|
||||
[InlineData("Failed:VersionMismatch")]
|
||||
[InlineData("Failed:Download")]
|
||||
public void NonGlobalCaller_DoesNotSeeAVersion_WhoseArchiveIsNotConfirmed(string status)
|
||||
{
|
||||
var entry = Unconfirmed();
|
||||
entry.MsiArchiveStatus = status;
|
||||
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, entry));
|
||||
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGlobalCaller_SeesAVersion_OnceASecondTenantReportedIt()
|
||||
{
|
||||
var entry = Unconfirmed();
|
||||
entry.CorroboratedAt = AfterCutoff.AddHours(2);
|
||||
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, entry));
|
||||
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGlobalCaller_SeesLegacyRows_ThatPredateTheGuard()
|
||||
{
|
||||
var entry = Unconfirmed();
|
||||
entry.FirstSeenAt = GetImeVersionHistoryFunction.LegacyTrustCutoffUtc.AddSeconds(-1);
|
||||
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, entry));
|
||||
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGlobalCaller_FilterKeepsOrderOfTheRemainingRows()
|
||||
{
|
||||
var newest = MakeEntry("1.103.101.0", First);
|
||||
var bogus = Unconfirmed();
|
||||
var older = MakeEntry("1.101.111.0", First.AddDays(-48));
|
||||
|
||||
using var doc = JsonDocument.Parse(SerializeFor(false, newest, bogus, older));
|
||||
|
||||
Assert.Equal(2, doc.RootElement.GetArrayLength());
|
||||
Assert.Equal("1.103.101.0", doc.RootElement[0].GetProperty("version").GetString());
|
||||
Assert.Equal("1.101.111.0", doc.RootElement[1].GetProperty("version").GetString());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Shape-preserving properties of the projection
|
||||
// =========================================================================
|
||||
|
||||
@@ -155,6 +155,41 @@ public class ImeMsiArchiverTests
|
||||
Assert.Equal(0, archiver.DownloadCalls + archiver.UploadCalls);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same guard gates the GLOBAL ImeVersionHistory row (RecordImeVersionAsync) — a
|
||||
/// device-reported string that cannot be a Windows Installer ProductVersion must create
|
||||
/// nothing anywhere. MSI bounds: major/minor ≤ 255, build ≤ 65535.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("1.105.103.0", true)]
|
||||
[InlineData("1.105.103", true)]
|
||||
[InlineData("1.0", true)]
|
||||
[InlineData("1.86.999.0", true)] // plausible future build — only the archiver's ProductVersion check can refute it
|
||||
[InlineData("1", false)] // single component
|
||||
[InlineData("1.86.999999.0", false)] // build > 65535
|
||||
[InlineData("256.1.1.0", false)] // major > 255
|
||||
[InlineData("1.256.1.0", false)] // minor > 255
|
||||
[InlineData("1.1.1.99999", false)] // revision > 65535
|
||||
[InlineData("1.104.102.0.1", false)]
|
||||
[InlineData("1.104../escape", false)]
|
||||
[InlineData("<script>", false)]
|
||||
[InlineData("1.105.103.0 ", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsPlausibleVersion_cases(string? version, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ImeMsiArchiver.IsPlausibleVersion(version));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRequeueOnSighting_RejectedSighting_False()
|
||||
{
|
||||
// A rejected string has no row; an "unarchived" (null) status must not read as "re-queue".
|
||||
var rejected = new ImeVersionSighting { Rejected = true };
|
||||
|
||||
Assert.False(ImeMsiArchiver.ShouldRequeueOnSighting(rejected, HotfixUrl, "productVersion", DateTime.UtcNow));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// URL selection + version verification
|
||||
// =========================================================================
|
||||
|
||||
@@ -70,7 +70,8 @@ public class McpQuotaServiceTests
|
||||
string? planDefinitionsJson = null,
|
||||
string? mcpUserPlanOverride = null,
|
||||
TenantEdition edition = TenantEdition.Community,
|
||||
IMemoryCache? cache = null)
|
||||
IMemoryCache? cache = null,
|
||||
bool bound = true)
|
||||
{
|
||||
var adminRepo = new Mock<IAdminRepository>();
|
||||
adminRepo.Setup(r => r.GetMcpUserAsync(It.IsAny<string>()))
|
||||
@@ -80,7 +81,7 @@ public class McpQuotaServiceTests
|
||||
|
||||
cache ??= new MemoryCache(new MemoryCacheOptions());
|
||||
var mcpUserService = new McpUserService(
|
||||
adminRepo.Object, cache, NullLogger<McpUserService>.Instance,
|
||||
adminRepo.Object, new StubAdminIdentityBindingService(bound), cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdminService: null!, delegatedAdminService: null!, adminConfigService: null!);
|
||||
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
@@ -183,6 +184,21 @@ public class McpQuotaServiceTests
|
||||
Assert.Equal(10000, d.DailyLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_PerUserOverride_IgnoredWhenCallerIsNotTheBoundIdentity()
|
||||
{
|
||||
// A McpUsers row is keyed on the UPN string; its plan override belongs to the identity (tid + oid)
|
||||
// the UPN is bound to. A same-UPN caller from another tenant gets its own tenant's edition plan.
|
||||
var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 500)), planDefinitionsJson: json, mcpUserPlanOverride: "power", bound: false);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("community", d.Plan);
|
||||
Assert.Equal(100, d.DailyLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_UnknownOverridePlan_FailsClosedToCommunityLimits()
|
||||
{
|
||||
|
||||
@@ -52,7 +52,7 @@ public class McpUserServiceDelegatedTests
|
||||
SetPolicy(McpAccessPolicy.WhitelistOnly);
|
||||
|
||||
_sut = new McpUserService(
|
||||
_adminRepo.Object, cache, NullLogger<McpUserService>.Instance,
|
||||
_adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
_globalAdmin.Object, _delegatedAdmin.Object, _adminConfig.Object);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The McpUsers whitelist is keyed on a mutable, globally non-unique UPN string while the API accepts
|
||||
/// tokens from every Entra tenant. A whitelist row (its grant, its usage-plan override AND its Disabled
|
||||
/// kill-switch) must therefore be honoured only for the identity the UPN is bound to (tid + oid via
|
||||
/// AdminIdentityBindings) — a foreign-tenant or recycled-UPN token carrying the same UPN sees no row.
|
||||
/// </summary>
|
||||
public class McpUserServiceIdentityBindingTests
|
||||
{
|
||||
private const string Upn = "user@customer.example";
|
||||
private const string HomeTenant = "11111111-1111-1111-1111-111111111111";
|
||||
private const string ForeignTenant = "22222222-2222-2222-2222-222222222222";
|
||||
private const string Oid = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
|
||||
private const string OtherOid = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb";
|
||||
|
||||
private static readonly AdminIdentity Bound = AdminIdentity.Create(Upn, HomeTenant, Oid)!;
|
||||
|
||||
private readonly Mock<IAdminRepository> _adminRepo = new();
|
||||
private readonly StubAdminIdentityBindingService _bindings;
|
||||
private readonly McpUserService _sut;
|
||||
|
||||
public McpUserServiceIdentityBindingTests()
|
||||
{
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
// Binding verdict = "is this exactly the bound identity?" — the real service's semantics for a row
|
||||
// bound to (HomeTenant, Oid).
|
||||
_bindings = new StubAdminIdentityBindingService(id =>
|
||||
id != null
|
||||
&& string.Equals(id.Upn, Upn, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(id.TenantId, HomeTenant, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(id.ObjectId, Oid, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var globalAdmin = new Mock<GlobalAdminService>(
|
||||
_adminRepo.Object, _bindings, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
|
||||
globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync((string?)null);
|
||||
var delegatedAdmin = new Mock<DelegatedAdminService>(
|
||||
_adminRepo.Object, _bindings,
|
||||
new StubTenantEntitlementService(TenantEdition.Pro),
|
||||
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync(DelegatedScope.Empty);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.WhitelistOnly.ToString() });
|
||||
|
||||
_sut = new McpUserService(
|
||||
_adminRepo.Object, _bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object);
|
||||
}
|
||||
|
||||
private void SetRow(bool enabled, string? plan = "pro") =>
|
||||
_adminRepo.Setup(x => x.GetMcpUserAsync(Upn))
|
||||
.ReturnsAsync(new McpUserEntry { Upn = Upn, IsEnabled = enabled, UsagePlan = plan });
|
||||
|
||||
// ── grant ────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BoundIdentity_WithEnabledRow_IsGranted()
|
||||
{
|
||||
SetRow(enabled: true);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal("McpUser", result.AccessGrant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForeignTenantToken_WithSameUpn_IsDenied()
|
||||
{
|
||||
// The nOAuth-class premise: a token from an attacker-controlled tenant whose UPN claim renders as the
|
||||
// whitelisted string. Genuine token, only tid differs — must not inherit the row.
|
||||
SetRow(enabled: true);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, ForeignTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Contains("not on the MCP whitelist", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecycledUpn_SameTenantDifferentOid_IsDenied()
|
||||
{
|
||||
SetRow(enabled: true);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, OtherOid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncompleteIdentity_NoTidOid_IsDenied()
|
||||
{
|
||||
SetRow(enabled: true);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, null, null);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoRow_NeverConsultsBinding()
|
||||
{
|
||||
// Ordinary tenant users have no McpUsers row: one cached row read, no binding lookup, no log line.
|
||||
var probed = false;
|
||||
var bindings = new StubAdminIdentityBindingService(_ => { probed = true; return true; });
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var globalAdmin = new Mock<GlobalAdminService>(
|
||||
_adminRepo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
|
||||
globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync((string?)null);
|
||||
var delegatedAdmin = new Mock<DelegatedAdminService>(
|
||||
_adminRepo.Object, bindings, new StubTenantEntitlementService(TenantEdition.Pro),
|
||||
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync(DelegatedScope.Empty);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.WhitelistOnly.ToString() });
|
||||
var sut = new McpUserService(_adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object);
|
||||
_adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>())).ReturnsAsync((McpUserEntry?)null);
|
||||
|
||||
var result = await sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.False(probed);
|
||||
}
|
||||
|
||||
// ── kill-switch ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledRow_DeniesTheBoundIdentity()
|
||||
{
|
||||
SetRow(enabled: false);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Contains("disabled on the MCP whitelist", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledRow_DoesNotFireForAForeignSameUpnIdentity_UnderAllMembers()
|
||||
{
|
||||
// The kill-switch belongs to the bound identity: an operator's per-account disable must not deny an
|
||||
// unrelated same-UPN identity in another tenant (whose access comes from its own tenant's policy).
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var globalAdmin = new Mock<GlobalAdminService>(
|
||||
_adminRepo.Object, _bindings, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
|
||||
globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync((string?)null);
|
||||
var delegatedAdmin = new Mock<DelegatedAdminService>(
|
||||
_adminRepo.Object, _bindings, new StubTenantEntitlementService(TenantEdition.Pro),
|
||||
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync(DelegatedScope.Empty);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.AllMembers.ToString() });
|
||||
var sut = new McpUserService(_adminRepo.Object, _bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object);
|
||||
SetRow(enabled: false);
|
||||
|
||||
var bound = await sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
var foreign = await sut.IsAllowedAsync(Upn, ForeignTenant, Oid);
|
||||
|
||||
Assert.False(bound.IsAllowed);
|
||||
Assert.True(foreign.IsAllowed);
|
||||
Assert.Equal("AllMembers", foreign.AccessGrant);
|
||||
}
|
||||
|
||||
// ── usage-plan override ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetBoundMcpUser_ReturnsRowOnlyForTheBoundIdentity()
|
||||
{
|
||||
SetRow(enabled: true, plan: "pro");
|
||||
|
||||
Assert.NotNull(await _sut.GetBoundMcpUserAsync(Bound));
|
||||
Assert.Null(await _sut.GetBoundMcpUserAsync(AdminIdentity.Create(Upn, ForeignTenant, Oid)));
|
||||
Assert.Null(await _sut.GetBoundMcpUserAsync(AdminIdentity.Create(Upn, HomeTenant, OtherOid)));
|
||||
Assert.Null(await _sut.GetBoundMcpUserAsync(null));
|
||||
}
|
||||
|
||||
// ── grant-time binding ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AddMcpUser_BindsTheUpnBeforeWritingTheRow()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
_bindings.OnEnsureBound = () => calls.Add("bind");
|
||||
_adminRepo.Setup(x => x.AddMcpUserAsync(Upn, "ga@vendor.example"))
|
||||
.Callback(() => calls.Add("row")).ReturnsAsync(true);
|
||||
|
||||
await _sut.AddMcpUserAsync(Upn, "GA@vendor.example", HomeTenant, Oid);
|
||||
|
||||
Assert.Equal(new[] { "bind", "row" }, calls);
|
||||
Assert.Single(_bindings.Bindings);
|
||||
Assert.Equal((Upn, HomeTenant, Oid), _bindings.Bindings[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMcpUser_BindingConflict_WritesNoRow()
|
||||
{
|
||||
_bindings.OnEnsureBound = () => throw new IdentityBindingConflictException("already bound elsewhere");
|
||||
|
||||
await Assert.ThrowsAsync<IdentityBindingConflictException>(
|
||||
() => _sut.AddMcpUserAsync(Upn, "ga@vendor.example", ForeignTenant, null));
|
||||
|
||||
_adminRepo.Verify(x => x.AddMcpUserAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PUT /api/preview/notification-email writes tenant-shared state that feeds the welcome and
|
||||
/// farewell mails. The route must admit roleless callers (first-touch signup) but only while the
|
||||
/// tenant has no member yet — afterwards a member role is required, so an ordinary employee with a
|
||||
/// valid JWT and no product role can no longer redirect or suppress the tenant's correspondence.
|
||||
/// </summary>
|
||||
public class PreviewNotificationEmailWriteGateTests
|
||||
{
|
||||
private const string TenantId = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
[Fact]
|
||||
public void Route_is_AuthenticatedUserWithRole_so_the_function_can_see_the_resolved_role()
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy("PUT", "/api/preview/notification-email");
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("preview/notification-email", entry!.RouteTemplate);
|
||||
Assert.Equal(EndpointPolicy.AuthenticatedUserWithRole, entry.Policy);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Constants.TenantRoles.Admin)]
|
||||
[InlineData(Constants.TenantRoles.Operator)]
|
||||
[InlineData(Constants.TenantRoles.Viewer)]
|
||||
public async Task Member_role_may_write_even_when_the_tenant_has_members(string role)
|
||||
{
|
||||
var admins = AdminsWith(enabledMember: true);
|
||||
var ctx = new RequestContext { UserRole = role };
|
||||
|
||||
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
|
||||
admins.Verify(a => a.GetTenantAdminsAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Global_admin_may_write()
|
||||
{
|
||||
var admins = AdminsWith(enabledMember: true);
|
||||
var ctx = new RequestContext { IsGlobalAdmin = true };
|
||||
|
||||
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Roleless_caller_may_write_while_the_tenant_has_no_member_yet()
|
||||
{
|
||||
var admins = AdminsWith(enabledMember: false);
|
||||
var ctx = new RequestContext { UserRole = "Authenticated" };
|
||||
|
||||
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Roleless_caller_is_rejected_once_the_tenant_has_an_enabled_member()
|
||||
{
|
||||
var admins = AdminsWith(enabledMember: true);
|
||||
var ctx = new RequestContext { UserRole = "Authenticated" };
|
||||
|
||||
Assert.False(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_members_do_not_close_the_signup_window()
|
||||
{
|
||||
var admins = new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
|
||||
{ CallBase = false };
|
||||
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminEntity>
|
||||
{
|
||||
new() { TenantId = TenantId, Upn = "former@contoso.com", IsEnabled = false, Role = Constants.TenantRoles.Admin },
|
||||
});
|
||||
var ctx = new RequestContext { UserRole = "Authenticated" };
|
||||
|
||||
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
|
||||
}
|
||||
|
||||
private static Mock<TenantAdminsService> AdminsWith(bool enabledMember)
|
||||
{
|
||||
var admins = new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
|
||||
{ CallBase = false };
|
||||
var members = new List<TenantAdminEntity>();
|
||||
if (enabledMember)
|
||||
members.Add(new TenantAdminEntity { TenantId = TenantId, Upn = "admin@contoso.com", IsEnabled = true, Role = Constants.TenantRoles.Admin });
|
||||
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(members);
|
||||
return admins;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>RecordImeVersionAsync</c> writes a DEVICE-SUPPLIED string as the RowKey of the
|
||||
/// caller-independent GLOBAL partition of ImeVersionHistory, and its "insert succeeded" is
|
||||
/// what raises the platform ops event and queues the installer archive. Any device holding a
|
||||
/// tenant agent credential can send an <c>ime_agent_version</c> event, so the write site
|
||||
/// itself must (a) refuse strings that cannot be a Windows Installer ProductVersion and
|
||||
/// (b) record when a SECOND tenant reports the version — the corroboration the tenant-facing
|
||||
/// read side requires before it lists a version to every other tenant.
|
||||
/// </summary>
|
||||
public class RecordImeVersionTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private const string SessionId = "33333333-3333-3333-3333-333333333333";
|
||||
|
||||
// =========================================================================
|
||||
// Guard: nothing is written for an implausible string
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("latest")]
|
||||
[InlineData("1.86.999999.0")]
|
||||
[InlineData("../escape")]
|
||||
[InlineData("1")]
|
||||
public async Task ImplausibleVersion_IsRejected_WithoutTouchingStorage(string version)
|
||||
{
|
||||
var harness = new Harness();
|
||||
|
||||
var sighting = await harness.Sut.RecordImeVersionAsync(version, TenantA, SessionId);
|
||||
|
||||
Assert.True(sighting.Rejected);
|
||||
Assert.False(sighting.IsNew);
|
||||
harness.History.Verify(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
harness.History.Verify(t => t.UpsertEntityAsync(It.IsAny<ITableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// First sighting
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task NewVersion_IsInserted_AsGlobalRow_WithoutCorroboration()
|
||||
{
|
||||
var harness = new Harness();
|
||||
|
||||
var sighting = await harness.Sut.RecordImeVersionAsync("1.105.103.0", TenantA, SessionId);
|
||||
|
||||
Assert.True(sighting.IsNew);
|
||||
Assert.False(sighting.Rejected);
|
||||
harness.History.Verify(t => t.AddEntityAsync(
|
||||
It.Is<TableEntity>(e => e.PartitionKey == "Global"
|
||||
&& e.RowKey == "1.105.103.0"
|
||||
&& e.GetString("FirstSeenTenantId") == TenantA
|
||||
&& e.GetInt32("SessionCount") == 1
|
||||
&& !e.ContainsKey("CorroboratedAt")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Known version: SessionCount + corroboration
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task KnownVersion_SameTenant_BumpsCount_ButNeverCorroborates()
|
||||
{
|
||||
// The first-seen tenant's own devices can report the version forever without it
|
||||
// ever counting as a second, independent sighting.
|
||||
var harness = new Harness(existingRow: Row(firstSeenTenantId: TenantA, sessionCount: 4));
|
||||
|
||||
var sighting = await harness.Sut.RecordImeVersionAsync("1.105.103.0", TenantA, SessionId);
|
||||
|
||||
Assert.False(sighting.IsNew);
|
||||
harness.History.Verify(t => t.UpsertEntityAsync(
|
||||
It.Is<ITableEntity>(e => ((TableEntity)e).GetInt32("SessionCount") == 5
|
||||
&& !((TableEntity)e).ContainsKey("CorroboratedAt")),
|
||||
TableUpdateMode.Merge, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownVersion_OtherTenant_StampsCorroboratedAt()
|
||||
{
|
||||
var harness = new Harness(existingRow: Row(firstSeenTenantId: TenantA, sessionCount: 4));
|
||||
|
||||
await harness.Sut.RecordImeVersionAsync("1.105.103.0", TenantB, SessionId);
|
||||
|
||||
harness.History.Verify(t => t.UpsertEntityAsync(
|
||||
It.Is<ITableEntity>(e => ((TableEntity)e).GetInt32("SessionCount") == 5
|
||||
&& ((TableEntity)e).GetDateTime("CorroboratedAt") != null),
|
||||
TableUpdateMode.Merge, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownVersion_AlreadyCorroborated_KeepsOriginalStamp()
|
||||
{
|
||||
var stamped = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
var harness = new Harness(existingRow: Row(firstSeenTenantId: TenantA, sessionCount: 4, corroboratedAt: stamped));
|
||||
|
||||
await harness.Sut.RecordImeVersionAsync("1.105.103.0", TenantB, SessionId);
|
||||
|
||||
harness.History.Verify(t => t.UpsertEntityAsync(
|
||||
It.Is<ITableEntity>(e => !((TableEntity)e).ContainsKey("CorroboratedAt")),
|
||||
TableUpdateMode.Merge, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownVersion_ReturnsArchiveColumns_ForTheRequeueDecision()
|
||||
{
|
||||
var updated = new DateTime(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc);
|
||||
var row = Row(firstSeenTenantId: TenantA, sessionCount: 1);
|
||||
row["MsiArchiveStatus"] = "Failed:VersionMismatch";
|
||||
row["MsiArchiveUpdatedAt"] = updated;
|
||||
var harness = new Harness(existingRow: row);
|
||||
|
||||
var sighting = await harness.Sut.RecordImeVersionAsync("1.105.103.0", TenantB, SessionId);
|
||||
|
||||
Assert.Equal("Failed:VersionMismatch", sighting.MsiArchiveStatus);
|
||||
Assert.Equal(updated, sighting.MsiArchiveUpdatedAt);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private static TableEntity Row(string firstSeenTenantId, int sessionCount, DateTime? corroboratedAt = null)
|
||||
{
|
||||
var row = new TableEntity("Global", "1.105.103.0")
|
||||
{
|
||||
["FirstSeenTenantId"] = firstSeenTenantId,
|
||||
["SessionCount"] = sessionCount,
|
||||
};
|
||||
if (corroboratedAt.HasValue) row["CorroboratedAt"] = corroboratedAt.Value;
|
||||
return row;
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<TableClient> History { get; }
|
||||
public TableStorageService Sut { get; }
|
||||
|
||||
public Harness(TableEntity? existingRow = null)
|
||||
{
|
||||
History = new Mock<TableClient>();
|
||||
|
||||
if (existingRow is null)
|
||||
{
|
||||
History
|
||||
.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
}
|
||||
else
|
||||
{
|
||||
History
|
||||
.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(409, "EntityAlreadyExists"));
|
||||
History
|
||||
.Setup(t => t.GetEntityAsync<TableEntity>("Global", existingRow.RowKey, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(existingRow, Mock.Of<Response>()));
|
||||
History
|
||||
.Setup(t => t.UpsertEntityAsync(It.IsAny<ITableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
}
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.ImeVersionHistory)).Returns(History.Object);
|
||||
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,14 @@ internal sealed class StubAdminIdentityBindingService : AdminIdentityBindingServ
|
||||
_verdict = verdict;
|
||||
}
|
||||
|
||||
/// <summary>Invoked at the start of every EnsureBoundAsync — observe call order or throw a conflict.</summary>
|
||||
public Action? OnEnsureBound { get; set; }
|
||||
|
||||
public override Task<bool> IsBoundAsync(AdminIdentity? identity) => Task.FromResult(_verdict(identity));
|
||||
|
||||
public override Task<AdminIdentityBinding> EnsureBoundAsync(string upn, string tenantId, string? objectId, string boundBy)
|
||||
{
|
||||
OnEnsureBound?.Invoke();
|
||||
Bindings.Add((upn.ToLowerInvariant(), tenantId.ToLowerInvariant(), objectId?.ToLowerInvariant()));
|
||||
return Task.FromResult(new AdminIdentityBinding
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins that the caller-influenced tenantId interpolated into the metrics-summary (SessionsIndex)
|
||||
/// and device-snapshot search (DeviceSnapshot) OData filters is escaped, so a quote in the value
|
||||
/// cannot rewrite the filter grammar (e.g. <c>x' or PartitionKey ne '</c> dissolving the
|
||||
/// single-tenant scope and the RowKey date-window bound).
|
||||
/// </summary>
|
||||
public class TenantFilterEscapingTests
|
||||
{
|
||||
private const string Injection = "x' or PartitionKey ne '";
|
||||
|
||||
[Fact]
|
||||
public void MetricsSummaryFilter_EscapesQuotesInTenantId()
|
||||
{
|
||||
var filter = TableStorageService.BuildMetricsSummaryFilter(Injection, "cutoff");
|
||||
|
||||
Assert.Equal("RowKey lt 'cutoff' and PartitionKey eq 'x'' or PartitionKey ne '''", filter);
|
||||
Assert.DoesNotContain(" or ", filter.Replace("'x'' or PartitionKey ne '''", ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MetricsSummaryFilter_NoTenant_KeepsWindowBoundOnly()
|
||||
{
|
||||
Assert.Equal("RowKey lt 'cutoff'", TableStorageService.BuildMetricsSummaryFilter(null, "cutoff"));
|
||||
Assert.Equal("RowKey lt 'cutoff'", TableStorageService.BuildMetricsSummaryFilter("", "cutoff"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceSnapshotTenantFilter_EscapesQuotesInTenantId()
|
||||
{
|
||||
Assert.Equal("PartitionKey eq 'x'' or PartitionKey ne '''",
|
||||
TableStorageService.BuildDeviceSnapshotTenantFilter(Injection));
|
||||
Assert.Null(TableStorageService.BuildDeviceSnapshotTenantFilter(null));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,13 +118,78 @@ public class UpdateSessionImeAgentVersionTests
|
||||
// No exception thrown.
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Sighting signal: the return value is what makes a session count ONCE in the
|
||||
// global ImeVersionHistory (SessionCount / IsNew) — a device re-sending the event
|
||||
// for the same version must not move the global counters again.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionImeAgentVersionAsync_returns_true_when_version_is_new_for_the_session()
|
||||
{
|
||||
var harness = new Harness();
|
||||
|
||||
Assert.True(await harness.Sut.UpdateSessionImeAgentVersionAsync(TenantId, SessionId, "1.2.3.4"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionImeAgentVersionAsync_returns_true_when_version_changed()
|
||||
{
|
||||
var harness = new Harness(currentVersion: "1.2.3.3");
|
||||
|
||||
Assert.True(await harness.Sut.UpdateSessionImeAgentVersionAsync(TenantId, SessionId, "1.2.3.4"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionImeAgentVersionAsync_returns_false_and_writes_nothing_when_unchanged()
|
||||
{
|
||||
var harness = new Harness(currentVersion: "1.2.3.4");
|
||||
|
||||
Assert.False(await harness.Sut.UpdateSessionImeAgentVersionAsync(TenantId, SessionId, "1.2.3.4"));
|
||||
|
||||
harness.Sessions.Verify(t => t.UpdateEntityAsync(
|
||||
It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
harness.Index.Verify(t => t.UpdateEntityAsync(
|
||||
It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionImeAgentVersionAsync_returns_false_when_row_is_absent()
|
||||
{
|
||||
// Tombstoned / never-registered session: no sighting for the global history either.
|
||||
var harness = new Harness();
|
||||
harness.Sessions
|
||||
.Setup(t => t.GetEntityAsync<TableEntity>(TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "Not Found"));
|
||||
|
||||
Assert.False(await harness.Sut.UpdateSessionImeAgentVersionAsync(TenantId, SessionId, "1.2.3.4"));
|
||||
|
||||
harness.Sessions.Verify(t => t.UpdateEntityAsync(
|
||||
It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionImeAgentVersionAsync_returns_true_on_unknown_storage_error()
|
||||
{
|
||||
// Unknown outcome counts as a sighting — a transient 503 must never drop a genuinely new version.
|
||||
var harness = new Harness();
|
||||
harness.Sessions
|
||||
.Setup(t => t.UpdateEntityAsync(It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(503, "ServiceUnavailable"));
|
||||
|
||||
Assert.True(await harness.Sut.UpdateSessionImeAgentVersionAsync(TenantId, SessionId, "1.2.3.4"));
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<TableClient> Sessions { get; }
|
||||
public Mock<TableClient> Index { get; }
|
||||
public TableStorageService Sut { get; }
|
||||
|
||||
public Harness(string? indexRowKey = IndexRowKey)
|
||||
public Harness(string? indexRowKey = IndexRowKey, string? currentVersion = null)
|
||||
{
|
||||
Sessions = new Mock<TableClient>();
|
||||
Index = new Mock<TableClient>();
|
||||
@@ -133,10 +198,13 @@ public class UpdateSessionImeAgentVersionTests
|
||||
.Setup(t => t.UpdateEntityAsync(It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
|
||||
// Re-read for IndexRowKey (select projection) after the Sessions write.
|
||||
// Point read (select projection) before the Sessions write: current ImeAgentVersion
|
||||
// decides whether this is a sighting, IndexRowKey feeds the SessionsIndex mirror.
|
||||
var indexRef = new TableEntity(TenantId, SessionId);
|
||||
if (!string.IsNullOrEmpty(indexRowKey))
|
||||
indexRef["IndexRowKey"] = indexRowKey;
|
||||
if (!string.IsNullOrEmpty(currentVersion))
|
||||
indexRef["ImeAgentVersion"] = currentVersion;
|
||||
Sessions
|
||||
.Setup(t => t.GetEntityAsync<TableEntity>(TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(indexRef, Mock.Of<Response>()));
|
||||
|
||||
+133
-43
@@ -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 <= 1KB. URL-encode to be safe.</summary>
|
||||
internal static string EncodeRowKey(string value)
|
||||
=> Uri.EscapeDataString(value);
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
string? country, string? region, string? city, string? loc)
|
||||
=> _storage.UpdateSessionGeoAsync(tenantId, sessionId, country, region, city, loc);
|
||||
|
||||
public Task UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version)
|
||||
public Task<bool> UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version)
|
||||
=> _storage.UpdateSessionImeAgentVersionAsync(tenantId, sessionId, version);
|
||||
|
||||
public Task UpdateSessionNetworkLatencyAsync(string tenantId, string sessionId,
|
||||
|
||||
+23
-1
@@ -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);
|
||||
|
||||
+9
-35
@@ -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);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -15,13 +18,16 @@ public class McpUserFunction
|
||||
{
|
||||
private readonly ILogger<McpUserFunction> _logger;
|
||||
private readonly McpUserService _mcpUserService;
|
||||
private readonly AdminIdentityResolver _identityResolver;
|
||||
|
||||
public McpUserFunction(
|
||||
ILogger<McpUserFunction> logger,
|
||||
McpUserService mcpUserService)
|
||||
McpUserService mcpUserService,
|
||||
AdminIdentityResolver identityResolver)
|
||||
{
|
||||
_logger = logger;
|
||||
_mcpUserService = mcpUserService;
|
||||
_identityResolver = identityResolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -42,9 +48,13 @@ public class McpUserFunction
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/admin/mcp-users
|
||||
/// POST /api/global/mcp-users
|
||||
/// Adds a user to the MCP whitelist. GlobalAdminOnly.
|
||||
/// Body: { "upn": "user@domain.com" }
|
||||
/// Body: { "upn": "user@domain.com", "homeTenantId"?: "<guid>", "objectId"?: "<guid>" }.
|
||||
/// The row is inert until the UPN is bound to the identity that may use it; the home tenant is resolved
|
||||
/// automatically (sign-in history, then UPN domain → onboarded tenant) and the body may override it —
|
||||
/// 422 HomeTenantUnresolved when neither works, 409 when the UPN is already bound elsewhere. Same
|
||||
/// contract as POST auth/global-admins and POST global/delegated-admins.
|
||||
/// </summary>
|
||||
[Function("AddMcpUser")]
|
||||
[Authorize]
|
||||
@@ -62,8 +72,32 @@ public class McpUserFunction
|
||||
await badResponse.WriteAsJsonAsync(new { error = "UPN is required" });
|
||||
return badResponse;
|
||||
}
|
||||
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
|
||||
if (bindingError != null)
|
||||
{
|
||||
var badResponse = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badResponse.WriteAsJsonAsync(new { error = bindingError });
|
||||
return badResponse;
|
||||
}
|
||||
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, body.Upn, body.HomeTenantId, body.ObjectId);
|
||||
if (identity == null)
|
||||
{
|
||||
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
|
||||
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
|
||||
return unresolved;
|
||||
}
|
||||
|
||||
var user = await _mcpUserService.AddMcpUserAsync(body.Upn, currentUpn!);
|
||||
McpUserEntry user;
|
||||
try
|
||||
{
|
||||
user = await _mcpUserService.AddMcpUserAsync(body.Upn, currentUpn!, identity.Value.TenantId, identity.Value.ObjectId);
|
||||
}
|
||||
catch (IdentityBindingConflictException ex)
|
||||
{
|
||||
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
|
||||
await conflict.WriteAsJsonAsync(new { error = ex.Message });
|
||||
return conflict;
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.Created);
|
||||
await response.WriteAsJsonAsync(new { user });
|
||||
@@ -207,6 +241,10 @@ public class McpUserFunction
|
||||
public class AddMcpUserRequest
|
||||
{
|
||||
public string Upn { get; set; } = string.Empty;
|
||||
/// <summary>The grantee's HOME Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
|
||||
public string? HomeTenantId { get; set; }
|
||||
/// <summary>The grantee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
|
||||
public string? ObjectId { get; set; }
|
||||
}
|
||||
|
||||
public class SetUsagePlanRequest
|
||||
|
||||
+26
@@ -60,6 +60,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
/// the archive is a platform-wide view of Microsoft's IME rollout, and the route is
|
||||
/// MemberRead precisely so any tenant member can read that view.
|
||||
///
|
||||
/// Non-global callers additionally only see versions that <see cref="IsFleetConfirmed"/>:
|
||||
/// a row is created by whatever string a device reports, and one tenant's devices must
|
||||
/// not be able to publish an unverified "version" into every other tenant's rollout view.
|
||||
/// Global scope sees every row, unconfirmed ones included — that is where a bogus claim
|
||||
/// is investigated.
|
||||
///
|
||||
/// Returned as <see cref="object"/> on purpose — System.Text.Json serializes an
|
||||
/// `object` declared type using the RUNTIME type, so both branches keep the exact
|
||||
/// wire format they had while the projection was inline.
|
||||
@@ -73,8 +79,28 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
}
|
||||
|
||||
return versions
|
||||
.Where(IsFleetConfirmed)
|
||||
.Select(v => new { v.Version, v.FirstSeenAt, v.LastSeenAt, v.SessionCount })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rows first seen before this instant predate both the version guard and the
|
||||
/// corroboration stamp. The whole table was reviewed on 2026-08-30 (16 rows, all
|
||||
/// genuine Microsoft builds), so they are trusted as-is instead of vanishing from the
|
||||
/// tenant view until a second tenant happens to report a version nobody runs any more.
|
||||
/// </summary>
|
||||
public static readonly DateTime LegacyTrustCutoffUtc = new(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>
|
||||
/// A version is confirmed for the tenant view when evidence beyond the first-seen
|
||||
/// tenant's own claim exists: the installer was archived with a matching MSI
|
||||
/// ProductVersion (<c>ImeMsiArchiver</c>), or a SECOND tenant reported it
|
||||
/// (<see cref="ImeVersionHistoryEntry.CorroboratedAt"/>), or the row predates the guard.
|
||||
/// </summary>
|
||||
public static bool IsFleetConfirmed(ImeVersionHistoryEntry entry) =>
|
||||
string.Equals(entry.MsiArchiveStatus, Services.Ime.ImeMsiArchiver.Statuses.Archived, StringComparison.Ordinal)
|
||||
|| entry.CorroboratedAt.HasValue
|
||||
|| entry.FirstSeenAt < LegacyTrustCutoffUtc;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -58,11 +59,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var dateTo = req.Query["dateTo"];
|
||||
|
||||
var records = await _userUsageRepo.GetUsageByUserAsync(userId, dateFrom, dateTo);
|
||||
var mcpUser = !string.IsNullOrEmpty(upn) ? await _mcpUserService.GetMcpUserAsync(upn) : null;
|
||||
// The caller's OWN whitelist row (tid + oid bound) — a same-UPN identity from another
|
||||
// tenant must not see, or be granted, someone else's per-user plan override.
|
||||
var tenantId = principal?.GetTenantId();
|
||||
var mcpUser = await _mcpUserService.GetBoundMcpUserAsync(AdminIdentity.Create(upn, tenantId, userId));
|
||||
|
||||
// Effective quota state: resolved plan (per-user override → tenant edition),
|
||||
// limits (SectionUsagePlans definition → catalog fallback) and current counters.
|
||||
var tenantId = principal?.GetTenantId();
|
||||
var quota = await _quotaService.CheckAsync(userId, upn, tenantId);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
@@ -49,6 +49,18 @@ public class MetricsSummaryFunction
|
||||
// tenant. When absent, returns the full cross-tenant view (null → all tenants).
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var filterTenantId = query["tenantId"];
|
||||
if (!string.IsNullOrWhiteSpace(filterTenantId))
|
||||
{
|
||||
// Query value is interpolated into an OData filter downstream — GUID-gate it here
|
||||
// (belt) in addition to ODataSanitizer at the sink (braces).
|
||||
if (!Guid.TryParse(filterTenantId, out var parsedTenantId))
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { success = false, message = "tenantId must be a GUID" });
|
||||
return bad;
|
||||
}
|
||||
filterTenantId = parsedTenantId.ToString("D");
|
||||
}
|
||||
var summary = await _metricsRepo.GetMetricsSummaryAsync(
|
||||
string.IsNullOrEmpty(filterTenantId) ? null : filterTenantId,
|
||||
days);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -167,12 +168,33 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Validates the phase-scope + emit-mode fields on create/update.
|
||||
/// Returns an error message for a 400 response, or null when valid.
|
||||
/// Validates the phase-scope + emit-mode fields and the on_event trigger wiring on
|
||||
/// create/update. Returns an error message for a 400 response, or null when valid.
|
||||
/// Toggle-style partial payloads carry none of these fields and pass through.
|
||||
/// <para>
|
||||
/// on_event: the agent never dispatches on_event rules for events the gather executor
|
||||
/// itself emitted (feedback-loop guard, <c>GatherRuleExecutorHost</c>), so a rule that
|
||||
/// triggers on its own output — or on the <c>gather_result</c> default output type —
|
||||
/// could never fire and is rejected here with an explanatory message instead of being
|
||||
/// stored as a dead rule.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static string? ValidateScopeAndEmitMode(GatherRule rule)
|
||||
{
|
||||
if (string.Equals(rule.Trigger, "on_event", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrEmpty(rule.TriggerEventType))
|
||||
{
|
||||
var outputEventType = string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? Constants.EventTypes.GatherResult
|
||||
: rule.OutputEventType;
|
||||
|
||||
if (string.Equals(rule.TriggerEventType, outputEventType, StringComparison.OrdinalIgnoreCase))
|
||||
return "triggerEventType must differ from the rule's own outputEventType — gather rules never trigger on their own output.";
|
||||
|
||||
if (string.Equals(rule.TriggerEventType, Constants.EventTypes.GatherResult, StringComparison.OrdinalIgnoreCase))
|
||||
return $"triggerEventType '{Constants.EventTypes.GatherResult}' is the gather-rule output type — gather rules never trigger on gather output.";
|
||||
}
|
||||
|
||||
var hasActivePhases = rule.ActivePhases != null && rule.ActivePhases.Count > 0;
|
||||
var hasFromPhase = !string.IsNullOrEmpty(rule.ActiveFromPhase);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -11,7 +12,8 @@ namespace AutopilotMonitor.Functions.Functions.Rules;
|
||||
|
||||
/// <summary>
|
||||
/// CRUD endpoints for managing the tenant-activation whitelist (table: PreviewWhitelist).
|
||||
/// All endpoints are Global Admin only (except notification-email which is AuthenticatedUser).
|
||||
/// All endpoints are Global Admin only, except PUT notification-email: that one is
|
||||
/// AuthenticatedUserWithRole and gated in-function (member role, or the tenant has no members yet).
|
||||
/// </summary>
|
||||
public class PreviewWhitelistFunction
|
||||
{
|
||||
@@ -19,6 +21,7 @@ public class PreviewWhitelistFunction
|
||||
private readonly PreviewWhitelistService _previewWhitelistService;
|
||||
private readonly TenantApprovalService _tenantApprovalService;
|
||||
private readonly TenantConfigurationService _tenantConfigurationService;
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly IEmailService _emailService;
|
||||
|
||||
public PreviewWhitelistFunction(
|
||||
@@ -26,12 +29,14 @@ public class PreviewWhitelistFunction
|
||||
PreviewWhitelistService previewWhitelistService,
|
||||
TenantApprovalService tenantApprovalService,
|
||||
TenantConfigurationService tenantConfigurationService,
|
||||
TenantAdminsService tenantAdminsService,
|
||||
IEmailService emailService)
|
||||
{
|
||||
_logger = logger;
|
||||
_previewWhitelistService = previewWhitelistService;
|
||||
_tenantApprovalService = tenantApprovalService;
|
||||
_tenantConfigurationService = tenantConfigurationService;
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_emailService = emailService;
|
||||
}
|
||||
|
||||
@@ -156,7 +161,8 @@ public class PreviewWhitelistFunction
|
||||
/// <summary>
|
||||
/// PUT /api/preview/notification-email
|
||||
/// Saves the caller's notification email for the activation notice.
|
||||
/// AuthenticatedUser policy — preview-blocked users can call this.
|
||||
/// AuthenticatedUserWithRole policy — preview-blocked users can call this, but only while the
|
||||
/// tenant has no members (see <see cref="MayWriteNotificationEmailAsync"/>).
|
||||
/// </summary>
|
||||
[Function("SavePreviewNotificationEmail")]
|
||||
[Authorize]
|
||||
@@ -174,6 +180,16 @@ public class PreviewWhitelistFunction
|
||||
return bad;
|
||||
}
|
||||
|
||||
if (!await MayWriteNotificationEmailAsync(req.GetRequestContext(), tenantId, _tenantAdminsService))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Preview notification email write denied for roleless caller {Upn} — tenant {TenantId} already has members",
|
||||
principal?.GetUserPrincipalName() ?? "unknown", tenantId);
|
||||
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await forbidden.WriteAsJsonAsync(new { error = "A tenant member role is required to change the notification email" });
|
||||
return forbidden;
|
||||
}
|
||||
|
||||
var body = await req.ReadFromJsonAsync<SaveNotificationEmailRequest>();
|
||||
var email = body?.Email?.Trim();
|
||||
|
||||
@@ -208,6 +224,25 @@ public class PreviewWhitelistFunction
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The notification email is tenant-shared state consumed by privileged flows (welcome mail,
|
||||
/// offboarding farewell, operator contact). A member role (or platform scope) always may write
|
||||
/// it. A roleless caller may write it ONLY while the tenant has no enabled member at all — the
|
||||
/// first-touch signup window, where the activating user is still roleless because the requester
|
||||
/// is auto-promoted to Admin only on approval. Once any member exists, that window is closed for
|
||||
/// good: an ordinary employee with a valid JWT but no product role must not be able to redirect
|
||||
/// or suppress the tenant's correspondence.
|
||||
/// </summary>
|
||||
internal static async Task<bool> MayWriteNotificationEmailAsync(
|
||||
RequestContext ctx, string tenantId, TenantAdminsService tenantAdminsService)
|
||||
{
|
||||
if (ctx.IsTenantMemberOrGlobalAdmin())
|
||||
return true;
|
||||
|
||||
var members = await tenantAdminsService.GetTenantAdminsAsync(tenantId);
|
||||
return !members.Any(m => m.IsEnabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/preview/send-welcome-email/{tenantId}
|
||||
/// Sends (or resends) the activation welcome email. Global Admin only.
|
||||
|
||||
@@ -56,6 +56,17 @@ public class SearchSessionsFunction
|
||||
else
|
||||
{
|
||||
filterTenantId = query["tenantId"];
|
||||
if (!string.IsNullOrWhiteSpace(filterTenantId))
|
||||
{
|
||||
// Interpolated into OData filters downstream — GUID-gate the raw query value.
|
||||
if (!Guid.TryParse(filterTenantId, out var parsedTenantId))
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { success = false, message = "tenantId must be a GUID" });
|
||||
return bad;
|
||||
}
|
||||
filterTenantId = parsedTenantId.ToString("D");
|
||||
}
|
||||
tenantId = filterTenantId;
|
||||
scope = "search:global";
|
||||
basePath = "/api/global/search/sessions";
|
||||
|
||||
@@ -223,7 +223,11 @@ public static class EndpointAccessPolicyCatalog
|
||||
// ProgressPortalFunction; regression history c4dabeee).
|
||||
new("GET", "progress/sessions/lookup", EndpointPolicy.AuthenticatedUserWithRole, TenantScoping.QueryParam),
|
||||
new("GET", "progress/sessions/{sessionId}/events", EndpointPolicy.AuthenticatedUser, TenantScoping.QueryParam),
|
||||
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUser),
|
||||
// AuthenticatedUserWithRole (not plain AuthenticatedUser): the activating user is still
|
||||
// roleless during first-touch signup, so the tier must admit non-members — but the address is
|
||||
// tenant-shared state feeding the welcome/farewell mails, so the function re-gates on the
|
||||
// resolved role: roleless callers may only write while the tenant has no member yet.
|
||||
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUserWithRole),
|
||||
new("GET", "feedback/status", EndpointPolicy.AuthenticatedUser),
|
||||
new("POST", "feedback", EndpointPolicy.AuthenticatedUser),
|
||||
new("GET", "config/latest-versions", EndpointPolicy.AuthenticatedUser),
|
||||
|
||||
@@ -6,47 +6,72 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Daily rule-fire/evaluation telemetry (per-tenant + global aggregate rows).
|
||||
/// Both methods are fire-and-forget — failures are logged, never thrown.
|
||||
/// Fire-and-forget — failures are logged, never thrown.
|
||||
/// </summary>
|
||||
public sealed partial class EventIngestProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the gather-rule fires claimed by a telemetry batch against the tenant's active
|
||||
/// gather catalog. Only the event's <c>ruleId</c> is read; every other field (title, category,
|
||||
/// severity, built-in flag) comes from the server-side rule definition — mirrors
|
||||
/// <c>AnalyzeOnEnrollmentEndHandler.SafeRecordAnalyzeRuleStatsAsync</c>, where the catalog rule
|
||||
/// object is the sole source of RuleStats metadata. A claimed ID that is not an active rule for
|
||||
/// this tenant is dropped: an agent cannot mint stats rows for retired/unshipped IDs or respell a
|
||||
/// built-in rule's title. Deduped per batch (one increment per rule).
|
||||
/// </summary>
|
||||
internal static List<GatherRule> ResolveFiredGatherRules(
|
||||
IEnumerable<EnrollmentEvent> events, IReadOnlyCollection<GatherRule> activeRules)
|
||||
{
|
||||
var byId = new Dictionary<string, GatherRule>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var rule in activeRules)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(rule.RuleId))
|
||||
byId.TryAdd(rule.RuleId, rule);
|
||||
}
|
||||
|
||||
var fired = new List<GatherRule>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var evt in events)
|
||||
{
|
||||
if (evt.Source != "GatherRuleExecutor" || evt.Data == null) continue;
|
||||
if (!evt.Data.TryGetValue("ruleId", out var raw)) continue;
|
||||
|
||||
var ruleId = raw?.ToString();
|
||||
if (string.IsNullOrEmpty(ruleId) || !seen.Add(ruleId)) continue;
|
||||
if (byId.TryGetValue(ruleId, out var rule))
|
||||
fired.Add(rule);
|
||||
}
|
||||
return fired;
|
||||
}
|
||||
|
||||
private async Task RecordGatherRuleStatsAsync(string tenantId, List<EnrollmentEvent> events)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!events.Any(e => e.Source == "GatherRuleExecutor" && e.Data != null && e.Data.ContainsKey("ruleId")))
|
||||
return;
|
||||
|
||||
var activeRules = await _gatherRuleService.GetActiveRulesForTenantAsync(tenantId).ConfigureAwait(false);
|
||||
var firedRules = ResolveFiredGatherRules(events, activeRules);
|
||||
if (firedRules.Count == 0) return;
|
||||
|
||||
var today = DateTime.UtcNow.ToString("yyyy-MM-dd");
|
||||
|
||||
var gatherEvents = events.Where(e =>
|
||||
e.Data != null &&
|
||||
e.Data.ContainsKey("ruleId") &&
|
||||
e.Source == "GatherRuleExecutor").ToList();
|
||||
|
||||
if (gatherEvents.Count == 0) return;
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var evt in gatherEvents)
|
||||
foreach (var rule in firedRules)
|
||||
{
|
||||
var ruleId = evt.Data!["ruleId"]?.ToString();
|
||||
if (string.IsNullOrEmpty(ruleId) || !seen.Add(ruleId)) continue;
|
||||
|
||||
var ruleTitle = evt.Data.ContainsKey("ruleTitle") ? evt.Data["ruleTitle"]?.ToString() ?? "" : "";
|
||||
|
||||
await _metricsRepo.IncrementRuleStatAsync(
|
||||
today, tenantId, ruleId, "gather",
|
||||
ruleTitle, "", "",
|
||||
fired: true, confidenceScore: null);
|
||||
today, tenantId, rule.RuleId, "gather",
|
||||
rule.Title, rule.Category, rule.OutputSeverity,
|
||||
fired: true, confidenceScore: null).ConfigureAwait(false);
|
||||
|
||||
// Global aggregate row is catalog-only. The agent event carries no
|
||||
// IsBuiltIn flag, but custom rules can never occupy the reserved
|
||||
// built-in namespace (create/update guard), so the ID pattern is the
|
||||
// discriminator. Custom IDs are tenant-chosen and not unique across
|
||||
// tenants — a shared "global_{ruleId}" row would mix their counters.
|
||||
if (RuleIdPolicy.IsReservedBuiltInId(ruleId))
|
||||
// Global aggregate row is catalog-only: custom-rule IDs are tenant-chosen
|
||||
// and not unique across tenants, so a shared "global_{ruleId}" row would
|
||||
// sum unrelated tenants' counters (title/severity last-writer-wins).
|
||||
if (rule.IsBuiltIn || rule.IsCommunity)
|
||||
{
|
||||
await _metricsRepo.IncrementRuleStatAsync(
|
||||
today, "global", ruleId, "gather",
|
||||
ruleTitle, "", "",
|
||||
fired: true, confidenceScore: null);
|
||||
today, "global", rule.RuleId, "gather",
|
||||
rule.Title, rule.Category, rule.OutputSeverity,
|
||||
fired: true, confidenceScore: null).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +80,5 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_logger.LogWarning(ex, "Failed to record gather rule stats (non-fatal)");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private readonly IRuleRepository _ruleRepo;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AnalyzeRuleService _analyzeRuleService;
|
||||
private readonly GatherRuleService _gatherRuleService;
|
||||
private readonly WebhookNotificationService _webhookNotificationService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly OpsEventService _opsEventService;
|
||||
@@ -58,6 +59,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
IRuleRepository ruleRepo,
|
||||
TenantConfigurationService configService,
|
||||
AnalyzeRuleService analyzeRuleService,
|
||||
GatherRuleService gatherRuleService,
|
||||
WebhookNotificationService webhookNotificationService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
OpsEventService opsEventService,
|
||||
@@ -75,6 +77,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_ruleRepo = ruleRepo;
|
||||
_configService = configService;
|
||||
_analyzeRuleService = analyzeRuleService;
|
||||
_gatherRuleService = gatherRuleService;
|
||||
_webhookNotificationService = webhookNotificationService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_opsEventService = opsEventService;
|
||||
@@ -136,10 +139,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var imeVersion = imeVersionEvent.Data!["agentVersion"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(imeVersion))
|
||||
{
|
||||
_ = _sessionRepo.UpdateSessionImeAgentVersionAsync(request.TenantId, request.SessionId, imeVersion)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"ImeAgentVersion update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted);
|
||||
|
||||
// Captured here (not inside the continuation) purely for clarity — the agent's
|
||||
// CSP-registry enrichment; absent on agent builds before 2026-08-17.
|
||||
string? msiDownloadUrl = null, msiMatchedBy = null;
|
||||
@@ -148,36 +147,20 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (imeVersionEvent.Data.TryGetValue("msiMatchedBy", out var matchedByObj))
|
||||
msiMatchedBy = matchedByObj?.ToString();
|
||||
|
||||
_ = _sessionRepo.RecordImeVersionAsync(imeVersion, request.TenantId, request.SessionId)
|
||||
// The GLOBAL version history counts a session once: only when the session's
|
||||
// stored ImeAgentVersion actually changed does this batch become a sighting.
|
||||
// A re-sent (or replayed) event for the same version stops here.
|
||||
_ = _sessionRepo.UpdateSessionImeAgentVersionAsync(request.TenantId, request.SessionId, imeVersion)
|
||||
.ContinueWith(async t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
{
|
||||
_logger.LogWarning(t.Exception?.InnerException,
|
||||
"ImeVersionHistory update failed (non-fatal)");
|
||||
}
|
||||
else if (t.Result?.IsNew == true)
|
||||
{
|
||||
await _opsEventService.RecordNewImeVersionDetectedAsync(
|
||||
imeVersion, request.TenantId, request.SessionId);
|
||||
|
||||
// First fleet-wide sighting → archive the installer binary. The
|
||||
// archiver verifies the package's ProductVersion against every
|
||||
// candidate host, so a first sighting without a CSP URL is fine.
|
||||
// Producer is fail-soft; worker pauses on ImeMsiArchivingEnabled=false.
|
||||
await EnqueueImeMsiArchiveAsync(imeVersion, msiDownloadUrl, msiMatchedBy, request);
|
||||
}
|
||||
else if (ImeMsiArchiver.ShouldRequeueOnSighting(t.Result, msiDownloadUrl, msiMatchedBy, DateTime.UtcNow))
|
||||
{
|
||||
// Known but not archived (no host served the build at first sighting,
|
||||
// or the version predates the archiver) and THIS session brings a
|
||||
// version-authoritative CSP URL → one more try, at most once per
|
||||
// backoff window. The Queued stamp is what other sessions of the
|
||||
// same version see, so they don't pile on.
|
||||
await _sessionRepo.UpdateImeVersionArchiveInfoAsync(
|
||||
imeVersion, ImeMsiArchiver.Statuses.Queued, null, null, null, null);
|
||||
await EnqueueImeMsiArchiveAsync(imeVersion, msiDownloadUrl, msiMatchedBy, request);
|
||||
"ImeAgentVersion update failed (non-fatal)");
|
||||
return;
|
||||
}
|
||||
if (!t.Result) return;
|
||||
await RecordImeVersionSightingAsync(imeVersion, msiDownloadUrl, msiMatchedBy, request);
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
@@ -493,6 +476,52 @@ namespace AutopilotMonitor.Functions.Services
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One sighting of <paramref name="imeVersion"/> by this session against the global
|
||||
/// version history. The repository rejects implausible strings before writing
|
||||
/// (<see cref="ImeVersionSighting.Rejected"/> → nothing else happens); a first
|
||||
/// fleet-wide sighting raises the ops event and queues the installer archive; a known,
|
||||
/// not-yet-archived version may be re-queued when this session brings an authoritative
|
||||
/// CSP URL. Fail-soft: runs on the fire-and-forget continuation of the session stamp.
|
||||
/// </summary>
|
||||
private async Task RecordImeVersionSightingAsync(
|
||||
string imeVersion, string? msiDownloadUrl, string? msiMatchedBy, IngestEventsRequest request)
|
||||
{
|
||||
ImeVersionSighting? sighting;
|
||||
try
|
||||
{
|
||||
sighting = await _sessionRepo.RecordImeVersionAsync(imeVersion, request.TenantId, request.SessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ImeVersionHistory update failed (non-fatal)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (sighting?.IsNew == true)
|
||||
{
|
||||
await _opsEventService.RecordNewImeVersionDetectedAsync(
|
||||
imeVersion, request.TenantId, request.SessionId);
|
||||
|
||||
// First fleet-wide sighting → archive the installer binary. The
|
||||
// archiver verifies the package's ProductVersion against every
|
||||
// candidate host, so a first sighting without a CSP URL is fine.
|
||||
// Producer is fail-soft; worker pauses on ImeMsiArchivingEnabled=false.
|
||||
await EnqueueImeMsiArchiveAsync(imeVersion, msiDownloadUrl, msiMatchedBy, request);
|
||||
}
|
||||
else if (ImeMsiArchiver.ShouldRequeueOnSighting(sighting, msiDownloadUrl, msiMatchedBy, DateTime.UtcNow))
|
||||
{
|
||||
// Known but not archived (no host served the build at first sighting,
|
||||
// or the version predates the archiver) and THIS session brings a
|
||||
// version-authoritative CSP URL → one more try, at most once per
|
||||
// backoff window. The Queued stamp is what other sessions of the
|
||||
// same version see, so they don't pile on.
|
||||
await _sessionRepo.UpdateImeVersionArchiveInfoAsync(
|
||||
imeVersion, ImeMsiArchiver.Statuses.Queued, null, null, null, null);
|
||||
await EnqueueImeMsiArchiveAsync(imeVersion, msiDownloadUrl, msiMatchedBy, request);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session-wide API latency projection: the agent emits cumulative counters
|
||||
/// (net_total_latency_ms / net_total_requests) in every agent_metrics_snapshot, so
|
||||
|
||||
@@ -98,6 +98,24 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
/// <summary>Blob folder name = version — must be plain dotted digits (untrusted input).</summary>
|
||||
internal static readonly Regex VersionRegex = new(@"^\d+(\.\d+){1,3}$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// The version guard for everything keyed by a device-reported IME version: the
|
||||
/// GLOBAL <c>ImeVersionHistory</c> row (RowKey), the ops event and the archive folder.
|
||||
/// Plain dotted digits with 2–4 components (<see cref="VersionRegex"/>), and every
|
||||
/// component inside the range a Windows Installer package can actually carry as its
|
||||
/// <c>ProductVersion</c> (major/minor ≤ 255, build ≤ 65535; the fourth field is
|
||||
/// ignored by MSI, bounded to 65535 here so a 20-digit tail cannot pass either).
|
||||
/// A string that fails this can never be the ProductVersion of a real IME package,
|
||||
/// so nothing downstream should exist for it.
|
||||
/// </summary>
|
||||
public static bool IsPlausibleVersion(string? version)
|
||||
{
|
||||
if (string.IsNullOrEmpty(version) || version.Length > 20) return false;
|
||||
if (!VersionRegex.IsMatch(version)) return false;
|
||||
if (!Version.TryParse(version, out var v)) return false;
|
||||
return v.Major <= 255 && v.Minor <= 255 && v.Build <= 65535 && v.Revision <= 65535;
|
||||
}
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly BlobStorageService _blobStorage;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
@@ -189,7 +207,7 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
public static bool ShouldRequeueOnSighting(
|
||||
ImeVersionSighting? sighting, string? msiDownloadUrl, string? msiMatchedBy, DateTime nowUtc)
|
||||
{
|
||||
if (sighting is null || sighting.IsNew) return false;
|
||||
if (sighting is null || sighting.IsNew || sighting.Rejected) return false;
|
||||
if (!IsAllowedMsiUrl(msiDownloadUrl)) return false;
|
||||
if (!string.Equals(msiMatchedBy, MatchedByProductVersion, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
@@ -204,7 +222,7 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
public virtual async Task<ImeMsiArchiveResult> ArchiveAsync(
|
||||
ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (envelope is null || !VersionRegex.IsMatch(envelope.Version ?? string.Empty))
|
||||
if (envelope is null || !IsPlausibleVersion(envelope.Version))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ImeMsiArchiver: rejecting version {Version} — not a plain dotted version string",
|
||||
|
||||
@@ -12,7 +12,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Resolves and enforces the per-user MCP request quota (daily + monthly).
|
||||
///
|
||||
/// Plan precedence: explicit per-user override (McpUsers.UsagePlan) → the caller's home-tenant
|
||||
/// Plan precedence: explicit per-user override (McpUsers.UsagePlan — honoured only for the identity
|
||||
/// the UPN is bound to, tid + oid, see McpUserService.GetBoundMcpUserAsync) → the caller's home-tenant
|
||||
/// edition default (FeatureEntitlementCatalog.McpUsagePlanName). Limits come from the
|
||||
/// admin-editable SectionUsagePlans definitions (AdminConfiguration.PlanTierDefinitionsJson);
|
||||
/// when no definition matches the plan name, the static catalog fallbacks apply. An override
|
||||
@@ -77,7 +78,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (_cache.TryGetValue<McpQuotaDecision>(cacheKey, out var cached) && cached != null)
|
||||
return cached;
|
||||
|
||||
var (planName, dailyLimit, monthlyLimit) = await ResolvePlanAsync(upn, tenantId);
|
||||
var (planName, dailyLimit, monthlyLimit) = await ResolvePlanAsync(AdminIdentity.Create(upn, tenantId, oid), tenantId);
|
||||
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
long dailyUsed = 0, monthlyUsed = 0;
|
||||
@@ -106,21 +107,24 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
/// <summary>
|
||||
/// Plan resolution only (no counter read) — used by the self-service usage endpoint.
|
||||
/// <paramref name="identity"/> is the caller's validated (upn, tid, oid); the per-user override applies
|
||||
/// only when that identity IS the one the McpUsers UPN is bound to — a null / unbound identity gets the
|
||||
/// tenant edition default.
|
||||
/// </summary>
|
||||
public virtual async Task<(string PlanName, int DailyLimit, int MonthlyLimit)> ResolvePlanAsync(string? upn, string? tenantId)
|
||||
public virtual async Task<(string PlanName, int DailyLimit, int MonthlyLimit)> ResolvePlanAsync(AdminIdentity? identity, string? tenantId)
|
||||
{
|
||||
// 1. Per-user override wins when set.
|
||||
// 1. Per-user override wins when set — for the BOUND identity only.
|
||||
string? overridePlan = null;
|
||||
if (!string.IsNullOrWhiteSpace(upn))
|
||||
if (identity != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mcpUser = await _mcpUserService.GetMcpUserAsync(upn.ToLowerInvariant());
|
||||
var mcpUser = await _mcpUserService.GetBoundMcpUserAsync(identity);
|
||||
overridePlan = string.IsNullOrWhiteSpace(mcpUser?.UsagePlan) ? null : mcpUser!.UsagePlan!.Trim().ToLowerInvariant();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[McpQuota] McpUser lookup failed for {Upn} — falling back to tenant edition plan", upn);
|
||||
_logger.LogWarning(ex, "[McpQuota] McpUser lookup failed for {Upn} — falling back to tenant edition plan", identity.Upn);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,18 @@ namespace AutopilotMonitor.Functions.Services;
|
||||
/// - AllMembers: any authenticated user
|
||||
/// An EXPLICITLY DISABLED McpUsers row is an operator kill-switch that denies access under every
|
||||
/// policy and every grant path (platform role, delegated auto-grant, AllMembers).
|
||||
/// <para>
|
||||
/// The McpUsers row is looked up by UPN but is honoured (grant, usage-plan override AND kill-switch) only
|
||||
/// for the identity the UPN is bound to in <see cref="AdminIdentityBindingService"/> — the caller's validated
|
||||
/// JWT tid + oid must match. The API accepts tokens from every Entra tenant and upn/preferred_username are
|
||||
/// mutable, so a foreign-tenant (or recycled) token carrying a whitelisted UPN string resolves to NO row.
|
||||
/// Rows without a binding are inert, exactly like GlobalAdmins rows (no grandfathering).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class McpUserService
|
||||
{
|
||||
private readonly IAdminRepository _adminRepo;
|
||||
private readonly AdminIdentityBindingService _bindings;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<McpUserService> _logger;
|
||||
private readonly GlobalAdminService _globalAdminService;
|
||||
@@ -34,6 +42,7 @@ public class McpUserService
|
||||
|
||||
public McpUserService(
|
||||
IAdminRepository adminRepo,
|
||||
AdminIdentityBindingService bindings,
|
||||
IMemoryCache cache,
|
||||
ILogger<McpUserService> logger,
|
||||
GlobalAdminService globalAdminService,
|
||||
@@ -41,6 +50,7 @@ public class McpUserService
|
||||
AdminConfigurationService adminConfigService)
|
||||
{
|
||||
_adminRepo = adminRepo;
|
||||
_bindings = bindings;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
_globalAdminService = globalAdminService;
|
||||
@@ -52,9 +62,10 @@ public class McpUserService
|
||||
/// Checks if a user is allowed to access the MCP server based on current policy.
|
||||
/// <paramref name="homeTenantId"/> and <paramref name="objectId"/> are the caller's JWT tid and oid:
|
||||
/// together with the UPN they form the <see cref="AdminIdentity"/> the platform-role and delegated
|
||||
/// (MSP) grant paths resolve on — both are keyed on the identity binding, never on the UPN alone, and
|
||||
/// the delegated path additionally requires a Pro home tenant. Null tid/oid fails closed to no platform
|
||||
/// role and an empty delegated scope; the whitelist (McpUsers) and AllMembers paths are unaffected.
|
||||
/// (MSP) grant paths AND the McpUsers whitelist resolve on — all three are keyed on the identity binding,
|
||||
/// never on the UPN alone, and the delegated path additionally requires a Pro home tenant. Null tid/oid
|
||||
/// fails closed to no platform role, an empty delegated scope and no whitelist row; only the AllMembers
|
||||
/// path (tenant-bound by the auth middleware itself) is unaffected.
|
||||
/// </summary>
|
||||
public virtual async Task<McpAccessCheckResult> IsAllowedAsync(string? upn, string? homeTenantId, string? objectId)
|
||||
{
|
||||
@@ -82,7 +93,8 @@ public class McpUserService
|
||||
var delegatedRole = delegatedScope.IsEmpty ? null : StrongestDelegatedRole(delegatedScope);
|
||||
|
||||
// Resolve the caller's McpUsers whitelist state ONCE (cached): NotPresent / Enabled / Disabled.
|
||||
var whitelistState = await ResolveWhitelistStateAsync(upn);
|
||||
// A row the caller's tid/oid are not bound to is NotPresent for them.
|
||||
var whitelistState = await ResolveWhitelistStateAsync(identity);
|
||||
|
||||
// SECURITY (operator kill-switch): an EXPLICITLY DISABLED McpUsers row denies MCP access under
|
||||
// EVERY policy and EVERY grant path — platform roles, the delegated (MSP) auto-grant, and
|
||||
@@ -134,11 +146,19 @@ public class McpUserService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<McpUserEntry> AddMcpUserAsync(string upn, string addedBy)
|
||||
/// <summary>
|
||||
/// Whitelists a UPN, binding it to its home tenant (and object id, when known) FIRST — a row without a
|
||||
/// binding is inert, and a binding conflict (UPN already homed elsewhere) aborts before any row is written.
|
||||
/// </summary>
|
||||
/// <param name="homeTenantId">The Entra tenant the person signs in from (JWT tid).</param>
|
||||
/// <param name="objectId">The person's Entra object id, or null to pin it on their first sign-in.</param>
|
||||
/// <exception cref="IdentityBindingConflictException">The UPN is already bound to a different identity.</exception>
|
||||
public async Task<McpUserEntry> AddMcpUserAsync(string upn, string addedBy, string homeTenantId, string? objectId)
|
||||
{
|
||||
upn = upn.ToLowerInvariant();
|
||||
addedBy = addedBy.ToLowerInvariant();
|
||||
|
||||
await _bindings.EnsureBoundAsync(upn, homeTenantId, objectId, addedBy);
|
||||
await _adminRepo.AddMcpUserAsync(upn, addedBy);
|
||||
_cache.Remove($"mcp-user:{upn}");
|
||||
|
||||
@@ -171,12 +191,29 @@ public class McpUserService
|
||||
_logger.LogInformation("MCP user {Action}: {Upn}", isEnabled ? "enabled" : "disabled", upn);
|
||||
}
|
||||
|
||||
/// <summary>Operator view of a whitelist row by UPN — NOT an authorization read (see <see cref="GetBoundMcpUserAsync"/>).</summary>
|
||||
public async Task<McpUserEntry?> GetMcpUserAsync(string upn)
|
||||
{
|
||||
upn = upn.ToLowerInvariant();
|
||||
return await _adminRepo.GetMcpUserAsync(upn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The McpUsers row that belongs to the CALLER: the row keyed on the identity's UPN, but only when the
|
||||
/// caller's tid/oid match the UPN's identity binding — otherwise null, even if a row exists. This is the
|
||||
/// read every caller-scoped consumer (usage-plan override, self-service usage view) must use; a plan
|
||||
/// tier provisioned for one organization's user must never be inherited by a same-UPN identity elsewhere.
|
||||
/// </summary>
|
||||
public virtual async Task<McpUserEntry?> GetBoundMcpUserAsync(AdminIdentity? identity)
|
||||
{
|
||||
if (identity == null)
|
||||
return null;
|
||||
var entry = await _adminRepo.GetMcpUserAsync(identity.Upn);
|
||||
if (entry == null)
|
||||
return null;
|
||||
return await _bindings.IsBoundAsync(identity) ? entry : null;
|
||||
}
|
||||
|
||||
public async Task<List<McpUserEntry>> GetAllMcpUsersAsync()
|
||||
{
|
||||
return await _adminRepo.GetAllMcpUsersAsync();
|
||||
@@ -215,13 +252,29 @@ public class McpUserService
|
||||
private enum McpWhitelistState { NotPresent, Enabled, Disabled }
|
||||
|
||||
/// <summary>
|
||||
/// Resolves (and briefly caches) the caller's McpUsers whitelist state: NotPresent (no row),
|
||||
/// Enabled (row + IsEnabled), or Disabled (row + !IsEnabled). A Disabled row is an operator
|
||||
/// kill-switch that denies access under EVERY policy and grant path — see <see cref="IsAllowedAsync"/>.
|
||||
/// Cached under the same key the mutation methods (Add/Remove/SetEnabled) invalidate, so an
|
||||
/// enable/disable self-heals within the TTL (and immediately on the mutating instance).
|
||||
/// Resolves the caller's McpUsers whitelist state: NotPresent (no row, or a row the caller is not the
|
||||
/// bound identity for), Enabled (row + IsEnabled), or Disabled (row + !IsEnabled). A Disabled row is an
|
||||
/// operator kill-switch that denies access under EVERY policy and grant path — see <see cref="IsAllowedAsync"/>.
|
||||
/// Order mirrors <see cref="GlobalAdminService.GetGlobalRoleAsync"/>: the ROW is read by UPN (cached
|
||||
/// briefly under the key the mutation methods invalidate, so an enable/disable self-heals within the TTL);
|
||||
/// the identity binding is consulted only when a row exists, so ordinary callers cost one cached read
|
||||
/// and never produce a binding log line. An incomplete identity (no tid/oid) or an unbound/foreign
|
||||
/// caller sees NotPresent: the grant AND the kill-switch belong to the bound identity alone.
|
||||
/// </summary>
|
||||
private async Task<McpWhitelistState> ResolveWhitelistStateAsync(string upn)
|
||||
private async Task<McpWhitelistState> ResolveWhitelistStateAsync(AdminIdentity? identity)
|
||||
{
|
||||
if (identity == null)
|
||||
return McpWhitelistState.NotPresent;
|
||||
|
||||
var rowState = await ResolveRowStateAsync(identity.Upn);
|
||||
if (rowState == McpWhitelistState.NotPresent)
|
||||
return McpWhitelistState.NotPresent;
|
||||
|
||||
return await _bindings.IsBoundAsync(identity) ? rowState : McpWhitelistState.NotPresent;
|
||||
}
|
||||
|
||||
/// <summary>The state the McpUsers ROW carries for a UPN, ignoring identity binding. Cached briefly.</summary>
|
||||
private async Task<McpWhitelistState> ResolveRowStateAsync(string upn)
|
||||
{
|
||||
var cacheKey = $"mcp-user:{upn}";
|
||||
if (_cache.TryGetValue<McpWhitelistState>(cacheKey, out var cached))
|
||||
|
||||
@@ -660,7 +660,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation)
|
||||
{
|
||||
var snapshotTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.DeviceSnapshot);
|
||||
var oDataFilter = string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{tenantId}'";
|
||||
var oDataFilter = BuildDeviceSnapshotTenantFilter(tenantId);
|
||||
|
||||
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: snapshotTableClient,
|
||||
@@ -838,7 +838,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private async Task<List<SessionSummary>> SearchSessionsByDeviceSnapshotAsync(string? tenantId, SessionSearchFilter filter)
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.DeviceSnapshot);
|
||||
var oDataFilter = string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{tenantId}'";
|
||||
var oDataFilter = BuildDeviceSnapshotTenantFilter(tenantId);
|
||||
|
||||
var sessionIds = new List<string>();
|
||||
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: oDataFilter))
|
||||
@@ -1437,6 +1437,26 @@ namespace AutopilotMonitor.Functions.Services
|
||||
groups[pk] = g.Add(statusStr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SessionsIndex filter for the metrics summary: date-window bound plus optional single-tenant
|
||||
/// scope. <paramref name="tenantId"/> may originate from the HTTP query string, so it is
|
||||
/// escaped — a quote in the value must never alter the filter grammar.
|
||||
/// </summary>
|
||||
internal static string BuildMetricsSummaryFilter(string? tenantId, string cutoffRowKeyPrefix)
|
||||
{
|
||||
var filterParts = new List<string> { $"RowKey lt '{cutoffRowKeyPrefix}'" };
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
filterParts.Add($"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'");
|
||||
return string.Join(" and ", filterParts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DeviceSnapshot filter for the device-property search path: optional single-tenant scope.
|
||||
/// Escaped for the same reason as <see cref="BuildMetricsSummaryFilter"/>.
|
||||
/// </summary>
|
||||
internal static string? BuildDeviceSnapshotTenantFilter(string? tenantId)
|
||||
=> string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'";
|
||||
|
||||
public async Task<List<object>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
|
||||
{
|
||||
// Clamp to a sane range so callers can't accidentally trigger an unbounded scan.
|
||||
@@ -1446,10 +1466,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
||||
var cutoffRowKeyPrefix = ComputeCutoffRowKeyPrefix(days);
|
||||
|
||||
var filterParts = new List<string> { $"RowKey lt '{cutoffRowKeyPrefix}'" };
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
filterParts.Add($"PartitionKey eq '{tenantId}'");
|
||||
var oDataFilter = string.Join(" and ", filterParts);
|
||||
var oDataFilter = BuildMetricsSummaryFilter(tenantId, cutoffRowKeyPrefix);
|
||||
|
||||
// Tally every status into its own bucket so the buckets always reconcile to total
|
||||
// (SessionStatusBuckets enforces this by construction — Pending/Stalled/Unknown no
|
||||
|
||||
@@ -3066,11 +3066,22 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// the correct no-op outcome.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version)
|
||||
public async Task<bool> UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
|
||||
// One point read serves two purposes: the current version decides whether this
|
||||
// batch is a NEW sighting for the session (the agent re-sends ime_agent_version
|
||||
// per IME log pass, and a device could replay it at will — the global history's
|
||||
// SessionCount must not move for the same session twice), and IndexRowKey is
|
||||
// needed for the SessionsIndex mirror below. 404 → row absent → no-op.
|
||||
var current = await tableClient.GetEntityAsync<TableEntity>(
|
||||
tenantId, sessionId, select: new[] { "IndexRowKey", "ImeAgentVersion" });
|
||||
if (string.Equals(current.Value.GetString("ImeAgentVersion"), version, StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
var entity = new TableEntity(tenantId, sessionId)
|
||||
{
|
||||
["ImeAgentVersion"] = version
|
||||
@@ -3080,25 +3091,27 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Mirror to SessionsIndex — ImeAgentVersion is a search-filterable index column
|
||||
// (search/MCP push an OData filter on it against the index), so it must be kept in
|
||||
// sync or the filter matches nothing and the listed value is always empty.
|
||||
var idxRef = await tableClient.GetEntityAsync<TableEntity>(
|
||||
tenantId, sessionId, select: new[] { "IndexRowKey" });
|
||||
var indexRowKey = idxRef.Value.GetString("IndexRowKey");
|
||||
var indexRowKey = current.Value.GetString("IndexRowKey");
|
||||
if (!string.IsNullOrEmpty(indexRowKey))
|
||||
{
|
||||
await MergeSessionIndexAsync(tenantId, indexRowKey,
|
||||
new TableEntity(tenantId, indexRowKey) { ["ImeAgentVersion"] = version });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"UpdateSessionImeAgentVersion no-op: Sessions row {Tenant}/{Session} is absent (tombstoned or never registered)",
|
||||
tenantId, sessionId);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-fatal — don't block ingest
|
||||
// Non-fatal — don't block ingest. Unknown outcome counts as a sighting: a
|
||||
// transient storage error must never silently drop a genuinely new version.
|
||||
_logger.LogWarning(ex, "Failed to update ImeAgentVersion for session {SessionId}", sessionId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3556,9 +3569,27 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// If already known, updates LastSeenAt and increments SessionCount via Merge and
|
||||
/// returns the row's archive status/timestamp (read in the same point read as
|
||||
/// SessionCount) so the ingest path can decide on an installer-archive re-queue.
|
||||
/// <para>
|
||||
/// The version string is DEVICE-SUPPLIED and becomes the RowKey of a caller-independent
|
||||
/// GLOBAL partition that every tenant reads, so it is gated by
|
||||
/// <see cref="Ime.ImeMsiArchiver.IsPlausibleVersion"/> before anything is written
|
||||
/// (<see cref="ImeVersionSighting.Rejected"/>). The merge path additionally stamps
|
||||
/// <c>CorroboratedAt</c> on the first sighting from a tenant other than the one that
|
||||
/// inserted the row: together with the archiver's ProductVersion check this is what
|
||||
/// separates "one tenant's device claimed it" from "the fleet has it" for the tenant
|
||||
/// read side (<c>GetImeVersionHistoryFunction</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<ImeVersionSighting> RecordImeVersionAsync(string version, string tenantId, string sessionId)
|
||||
{
|
||||
if (!Ime.ImeMsiArchiver.IsPlausibleVersion(version))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ImeVersionHistory: rejected implausible IME version {Version} reported by session {SessionId} (tenant {TenantId})",
|
||||
version, sessionId, tenantId);
|
||||
return new ImeVersionSighting { Rejected = true };
|
||||
}
|
||||
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeVersionHistory);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
@@ -3587,7 +3618,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
try
|
||||
{
|
||||
var existing = await tableClient.GetEntityAsync<TableEntity>("Global", version,
|
||||
select: new[] { "SessionCount", "MsiArchiveStatus", "MsiArchiveUpdatedAt" });
|
||||
select: new[] { "SessionCount", "MsiArchiveStatus", "MsiArchiveUpdatedAt", "FirstSeenTenantId", "CorroboratedAt" });
|
||||
var currentCount = existing.Value.GetInt32("SessionCount") ?? 0;
|
||||
sighting.MsiArchiveStatus = existing.Value.GetString("MsiArchiveStatus");
|
||||
sighting.MsiArchiveUpdatedAt = existing.Value.GetDateTime("MsiArchiveUpdatedAt");
|
||||
@@ -3597,6 +3628,17 @@ namespace AutopilotMonitor.Functions.Services
|
||||
["LastSeenAt"] = now,
|
||||
["SessionCount"] = currentCount + 1
|
||||
};
|
||||
|
||||
// First sighting from a SECOND tenant → corroborated. The first-seen tenant's own
|
||||
// devices can report the version as often as they like without ever reaching this.
|
||||
var firstSeenTenantId = existing.Value.GetString("FirstSeenTenantId");
|
||||
if (existing.Value.GetDateTime("CorroboratedAt") is null
|
||||
&& !string.IsNullOrEmpty(firstSeenTenantId)
|
||||
&& !string.Equals(firstSeenTenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mergeEntity["CorroboratedAt"] = now;
|
||||
}
|
||||
|
||||
await tableClient.UpsertEntityAsync(mergeEntity, TableUpdateMode.Merge);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -3630,6 +3672,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
FirstSeenTenantId = entity.GetString("FirstSeenTenantId") ?? string.Empty,
|
||||
LastSeenAt = entity.GetDateTime("LastSeenAt") ?? DateTime.MinValue,
|
||||
SessionCount = entity.GetInt32("SessionCount") ?? 0,
|
||||
CorroboratedAt = entity.GetDateTime("CorroboratedAt"),
|
||||
MsiArchiveStatus = entity.GetString("MsiArchiveStatus"),
|
||||
MsiArchiveUpdatedAt = entity.GetDateTime("MsiArchiveUpdatedAt"),
|
||||
MsiArchiveBlobPath = entity.GetString("MsiArchiveBlobPath"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -135,7 +135,15 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
SessionStatus? status = null, bool? isUserDriven = null, string? verdictPath = null);
|
||||
Task UpdateSessionGeoAsync(string tenantId, string sessionId,
|
||||
string? country, string? region, string? city, string? loc);
|
||||
Task UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version);
|
||||
/// <summary>
|
||||
/// Stamps the session's IME agent version. Returns true when the session's stored
|
||||
/// version actually changed (first report, or a different version) — the signal that
|
||||
/// makes this session count as ONE sighting in the global version history. False when
|
||||
/// the session already carries this version (re-sent event) or the row is absent
|
||||
/// (tombstoned / never registered); a storage failure other than 404 counts as changed
|
||||
/// so a transient error never drops a genuine sighting.
|
||||
/// </summary>
|
||||
Task<bool> UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version);
|
||||
/// <summary>
|
||||
/// Persists the session-wide average agent→backend HTTP latency derived from the
|
||||
/// cumulative counters of the latest <c>agent_metrics_snapshot</c>. Last-write-wins
|
||||
|
||||
@@ -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><![LOG[message]LOG]!><time=... file=""></c>. The
|
||||
/// parser deliberately does NOT express that as one regex with a greedy <c>.*</c> message
|
||||
/// group in front of the <c>]LOG]!><time="</c> literal: on a line that never matches, such a
|
||||
/// regex re-tries every <c><![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><![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]!><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;
|
||||
|
||||
@@ -636,6 +636,15 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public DateTime LastSeenAt { get; set; }
|
||||
public int SessionCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC time of the first sighting from a tenant OTHER than <see cref="FirstSeenTenantId"/>;
|
||||
/// null while only the first-seen tenant has reported the version. A version is shown
|
||||
/// to ordinary tenant members only once it is corroborated this way or its installer
|
||||
/// was archived with a matching ProductVersion — a single tenant's devices (the least
|
||||
/// trusted principal) cannot publish a version to every other tenant on their own.
|
||||
/// </summary>
|
||||
public DateTime? CorroboratedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the automatic installer archiving (<c>ImeMsiArchiver</c>):
|
||||
/// <c>Archived</c>, <c>Queued</c> (re-queued by a later sighting) or a <c>Failed:*</c>
|
||||
@@ -671,6 +680,12 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>True when this sighting inserted the version (first fleet-wide sighting).</summary>
|
||||
public bool IsNew { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the reported string failed the version guard and nothing was written:
|
||||
/// no row, and the caller must emit no ops event and queue no archive job for it.
|
||||
/// </summary>
|
||||
public bool Rejected { get; set; }
|
||||
|
||||
/// <summary>Current <c>MsiArchiveStatus</c> of the row (null when new or never archived).</summary>
|
||||
public string? MsiArchiveStatus { get; set; }
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ export default function DiagnosisPage() {
|
||||
function DiagnosisContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const sessionId = searchParams?.get("id") as string;
|
||||
// Deep-link value is percent-decoded by useSearchParams; only a GUID may reach the API builders.
|
||||
const sessionId = (isGuid(searchParams?.get("id")) ? searchParams!.get("id")!.trim() : "") as string;
|
||||
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [sessionTenantId, setSessionTenantId] = useState<string | null>(null);
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function ProgressPortalPage() {
|
||||
Device Setup Progress
|
||||
</h1>
|
||||
<p className="text-gray-500">
|
||||
Enter your device serial number to check status
|
||||
Check the setup status of your device
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -213,14 +213,15 @@ export default function ProgressPortalPage() {
|
||||
value={serialInput}
|
||||
onChange={(e) => setSerialInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter serial number or device name (Cloud PC: the name shown in the Windows App)..."
|
||||
placeholder="Serial number or device name..."
|
||||
title="Windows 365 Cloud PC: enter the device name shown in the Windows App."
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 text-lg"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={searchBySerial}
|
||||
disabled={searching || !serialInput.trim()}
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium whitespace-nowrap"
|
||||
>
|
||||
{searching ? "Searching..." : "Check Status"}
|
||||
</button>
|
||||
|
||||
@@ -108,7 +108,10 @@ export function useSessionDetail({
|
||||
const response = await authenticatedFetch(endpoint, getAccessToken);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const foundSession = data.session ?? data.sessions?.find((s: Session) => s.sessionId === sessionId);
|
||||
// Accept only a session whose id matches the one we asked for — a misrouted or
|
||||
// unexpected 200 must never hydrate the page (defense in depth behind the GUID gate).
|
||||
const candidate: Session | undefined = data.session ?? data.sessions?.find((s: Session) => s.sessionId === sessionId);
|
||||
const foundSession = candidate?.sessionId?.toLowerCase() === sessionId.toLowerCase() ? candidate : undefined;
|
||||
if (foundSession) {
|
||||
setSession(foundSession);
|
||||
setSessionTenantId(foundSession.tenantId);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { isGuid } from '@/utils/inputValidation';
|
||||
import { api } from '@/lib/api';
|
||||
import { authenticatedFetch } from '@/lib/authenticatedFetch';
|
||||
import { extractContinuation, MAX_EAGER_PAGES } from '@/lib/paginationLink';
|
||||
@@ -91,7 +92,8 @@ export default function NetworkTimelinePage() {
|
||||
|
||||
function Content() {
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = searchParams?.get('id') ?? null;
|
||||
// Deep-link value is percent-decoded by useSearchParams; only a GUID may reach the API builders.
|
||||
const sessionId = isGuid(searchParams?.get('id')) ? searchParams!.get('id')!.trim() : null;
|
||||
const tenantIdOverride = searchParams?.get('tenantId') ?? undefined;
|
||||
|
||||
const { session, events, loading, error } = useSessionData(sessionId, tenantIdOverride);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { diagnosisUrl, inspectorUrl } from "@/lib/routes";
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useSignalR } from "../../contexts/SignalRContext";
|
||||
import { useTenant } from "../../contexts/TenantContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
@@ -15,6 +16,7 @@ import ScriptExecutions from '../../components/ScriptExecutions';
|
||||
import { useLatestVersions } from '@/lib/useLatestVersions';
|
||||
import { useScriptDisplayNames } from '@/lib/scriptDisplayNames';
|
||||
import { api } from "@/lib/api";
|
||||
import { isGuid } from "@/utils/inputValidation";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
|
||||
import { useSessionAnalysis } from "./hooks/useSessionAnalysis";
|
||||
@@ -59,7 +61,11 @@ function SessionDetailContent() {
|
||||
const searchParams = useSearchParams();
|
||||
// Query-string identity (`?id=`) — path params are gone with the static export;
|
||||
// legacy /sessions/{id} URLs are rewritten here by LegacyPathRedirect.
|
||||
const sessionId = searchParams?.get("id") as string;
|
||||
// The raw value is attacker-deliverable (deep link) and useSearchParams returns it
|
||||
// percent-DECODED, so anything but a GUID must never reach an API URL builder: an empty
|
||||
// sessionId keeps every data hook inert and the page renders the invalid-id branch below.
|
||||
const rawSessionId = searchParams?.get("id") ?? "";
|
||||
const sessionId = isGuid(rawSessionId) ? rawSessionId.trim() : "";
|
||||
// Explicit target tenant from the fleet drill-in (`?tenantId=`). Drives the cross-tenant reads for a
|
||||
// delegated ("MSP") admin viewing a managed tenant's session, and flips the page into read-only mode.
|
||||
const tenantIdOverride = searchParams?.get("tenantId") || undefined;
|
||||
@@ -424,6 +430,19 @@ function SessionDetailContent() {
|
||||
// so `loading` never flips and we'd render this branch forever — bypassing the
|
||||
// auth gate and hanging on "Loading session details..." without ever triggering
|
||||
// the MSAL login redirect. Wrapping here lets ProtectedRoute drive re-auth.
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-gray-600">
|
||||
{rawSessionId ? "Invalid session id." : "No session id given."}{" "}
|
||||
<Link href="/dashboard" className="text-blue-600 hover:underline">Back to dashboard</Link>
|
||||
</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && !session && events.length === 0) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useAuth } from "../../../contexts/AuthContext";
|
||||
import { useNotifications } from "../../../contexts/NotificationContext";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { api } from "@/lib/api";
|
||||
import { useTenantList } from "@/hooks/useTenantList";
|
||||
import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding";
|
||||
|
||||
interface McpUser {
|
||||
upn: string;
|
||||
@@ -43,6 +45,11 @@ export default function McpUsersSection() {
|
||||
const [policy, setPolicy] = useState<McpPolicy>("WhitelistOnly");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
// Only shown after the backend answered 422 HomeTenantUnresolved (UPN never signed in AND its domain
|
||||
// belongs to no onboarded tenant) — the one case the identity binding cannot be resolved automatically.
|
||||
const [needHomeTenant, setNeedHomeTenant] = useState(false);
|
||||
const [homeTenantPick, setHomeTenantPick] = useState("");
|
||||
const tenants = useTenantList(needHomeTenant);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [removingUpn, setRemovingUpn] = useState<string | null>(null);
|
||||
const [togglingUpn, setTogglingUpn] = useState<string | null>(null);
|
||||
@@ -131,7 +138,7 @@ export default function McpUsersSection() {
|
||||
}, [getAccessToken, addNotification]);
|
||||
|
||||
const handleAddUser = useCallback(async () => {
|
||||
if (!newEmail.trim()) return;
|
||||
if (!newEmail.trim() || (needHomeTenant && !homeTenantPick)) return;
|
||||
try {
|
||||
setAdding(true);
|
||||
setError(null);
|
||||
@@ -140,16 +147,24 @@ export default function McpUsersSection() {
|
||||
const response = await authenticatedFetch(api.mcpUsers.add(), getAccessToken, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ upn: newEmail.trim() }),
|
||||
body: JSON.stringify({
|
||||
upn: newEmail.trim(),
|
||||
homeTenantId: needHomeTenant ? homeTenantPick : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.status === 422 && data.code === HOME_TENANT_UNRESOLVED) {
|
||||
setNeedHomeTenant(true);
|
||||
}
|
||||
throw new Error(data.error || `Failed to add user: ${response.statusText}`);
|
||||
}
|
||||
|
||||
setSuccessMessage(`MCP user ${newEmail} added successfully!`);
|
||||
setNewEmail("");
|
||||
setNeedHomeTenant(false);
|
||||
setHomeTenantPick("");
|
||||
await fetchMcpUsers();
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
} catch (err) {
|
||||
@@ -161,7 +176,7 @@ export default function McpUsersSection() {
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}, [newEmail, getAccessToken, addNotification, fetchMcpUsers]);
|
||||
}, [newEmail, needHomeTenant, homeTenantPick, getAccessToken, addNotification, fetchMcpUsers]);
|
||||
|
||||
const handleRemoveUser = useCallback(async (upn: string) => {
|
||||
if (!confirm(`Remove ${upn} from MCP users?`)) return;
|
||||
@@ -337,9 +352,24 @@ export default function McpUsersSection() {
|
||||
autoComplete="off"
|
||||
className="flex-1 min-w-0 px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors"
|
||||
/>
|
||||
{needHomeTenant && (
|
||||
<select
|
||||
value={homeTenantPick}
|
||||
onChange={(e) => setHomeTenantPick(e.target.value)}
|
||||
className="flex-1 min-w-0 px-3 py-2 border border-amber-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-purple-500 transition-colors"
|
||||
aria-label="Home tenant of the person"
|
||||
>
|
||||
<option value="">Home tenant they sign in from…</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.tenantId} value={t.tenantId}>
|
||||
{t.domainName || t.tenantId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={handleAddUser}
|
||||
disabled={adding || !newEmail.trim()}
|
||||
disabled={adding || !newEmail.trim() || (needHomeTenant && !homeTenantPick)}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
|
||||
>
|
||||
{adding ? (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// API_BASE_URL is read at import time of lib/api — stub it before the import.
|
||||
vi.mock("@/utils/config", () => ({ API_BASE_URL: "https://test.example" }));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const apiPromise = import("../api") as Promise<{ api: any }>;
|
||||
|
||||
/**
|
||||
* Path-position ids must be percent-encoded so an attacker-controlled id
|
||||
* (deep link `/sessions?id=...`, decoded by useSearchParams) cannot rewrite the
|
||||
* route: '/' and '..' would otherwise be resolved by the URL parser into a
|
||||
* different API path, '#' would truncate the builder's suffix and query string.
|
||||
*/
|
||||
const HOSTILE = "x/../../global/y#";
|
||||
const ENCODED = "x%2F..%2F..%2Fglobal%2Fy%23";
|
||||
|
||||
describe("api path ids are percent-encoded", () => {
|
||||
it("sessions.* keeps a hostile id inside the sessions/{id} segment", async () => {
|
||||
const { api } = await apiPromise;
|
||||
const builders: Array<(id: string) => string> = [
|
||||
(id) => api.sessions.get(id),
|
||||
(id) => api.sessions.events(id),
|
||||
(id) => api.sessions.delete(id, "t"),
|
||||
(id) => api.sessions.analysis(id),
|
||||
(id) => api.sessions.vulnerabilityReport(id),
|
||||
(id) => api.sessions.timeAttribution(id),
|
||||
(id) => api.sessions.markFailed(id),
|
||||
(id) => api.sessions.markSucceeded(id),
|
||||
(id) => api.sessions.queueAction(id),
|
||||
(id) => api.sessions.report(id),
|
||||
(id) => api.sessions.annotations(id),
|
||||
(id) => api.sessions.annotation(id, "lane"),
|
||||
(id) => api.inspector.signals(id),
|
||||
(id) => api.inspector.decisionGraph(id),
|
||||
(id) => api.inspector.reducerVerification(id),
|
||||
];
|
||||
for (const build of builders) {
|
||||
const url = build(HOSTILE);
|
||||
expect(url.startsWith(`https://test.example/api/sessions/${ENCODED}`)).toBe(true);
|
||||
expect(new URL(url).pathname.startsWith(`/api/sessions/${ENCODED}`)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("markFailed keeps its suffix after a '#'-bearing id", async () => {
|
||||
const { api } = await apiPromise;
|
||||
expect(api.sessions.markFailed(HOSTILE, "t")).toBe(
|
||||
`https://test.example/api/sessions/${ENCODED}/mark-failed?tenantId=t`,
|
||||
);
|
||||
});
|
||||
|
||||
it("annotation lane and gather rule id are encoded too", async () => {
|
||||
const { api } = await apiPromise;
|
||||
expect(api.sessions.annotation("s", "a/b")).toBe("https://test.example/api/sessions/s/annotations/a%2Fb");
|
||||
expect(api.rules.gatherRule(HOSTILE)).toBe(`https://test.example/api/rules/gather/${ENCODED}`);
|
||||
});
|
||||
|
||||
it("no path builder lets an id escape its segment", async () => {
|
||||
const { api } = await apiPromise;
|
||||
type Builder = (...args: string[]) => string;
|
||||
const walk = (node: unknown): Builder[] =>
|
||||
typeof node === "function"
|
||||
? [node as Builder]
|
||||
: Object.values(node as Record<string, unknown>).flatMap(walk);
|
||||
for (const fn of walk(api)) {
|
||||
if (fn.length === 0) continue;
|
||||
const url = fn(HOSTILE, HOSTILE);
|
||||
// The WHATWG parser resolves a raw "x/../../global/y" into "/api/global/y";
|
||||
// an encoded id survives verbatim and the fragment marker never appears.
|
||||
expect(new URL(url).pathname, url).not.toMatch(/\/global\/y/);
|
||||
expect(url).not.toContain("#");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -33,44 +33,44 @@ export const api = {
|
||||
continuation: opts?.continuation,
|
||||
})}`,
|
||||
get: (sessionId: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}${qs({ tenantId })}`,
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}${qs({ tenantId })}`,
|
||||
events: (
|
||||
sessionId: string,
|
||||
tenantId?: string,
|
||||
opts?: { pageSize?: number; continuation?: string },
|
||||
) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/events${qs({
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/events${qs({
|
||||
tenantId,
|
||||
pageSize: opts?.pageSize?.toString(),
|
||||
continuation: opts?.continuation,
|
||||
})}`,
|
||||
delete: (sessionId: string, tenantId: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}${qs({ tenantId })}`,
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}${qs({ tenantId })}`,
|
||||
analysis: (sessionId: string, tenantId?: string, reanalyze?: boolean) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/analysis${qs({ tenantId, ...(reanalyze ? { reanalyze: "true" } : {}) })}`,
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/analysis${qs({ tenantId, ...(reanalyze ? { reanalyze: "true" } : {}) })}`,
|
||||
vulnerabilityReport: (sessionId: string, tenantId?: string, rescan?: boolean) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/vulnerability-report${qs({ tenantId, ...(rescan ? { rescan: "true" } : {}) })}`,
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/vulnerability-report${qs({ tenantId, ...(rescan ? { rescan: "true" } : {}) })}`,
|
||||
// F1 time attribution: pre-computed breakdown row (null for pre-feature / non-terminal /
|
||||
// Incomplete sessions — the lane is simply omitted, nothing is re-derived client-side).
|
||||
timeAttribution: (sessionId: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/time-attribution${qs({ tenantId })}`,
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/time-attribution${qs({ tenantId })}`,
|
||||
markFailed: (sessionId: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/mark-failed${qs({ tenantId })}`,
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/mark-failed${qs({ tenantId })}`,
|
||||
markSucceeded: (sessionId: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/mark-succeeded${qs({ tenantId })}`,
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/mark-succeeded${qs({ tenantId })}`,
|
||||
// POST { type, reason } — queues a ServerAction delivered with the agent's next ingest
|
||||
// response (e.g. request_diagnostics for the "Collect Logs" button).
|
||||
queueAction: (sessionId: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/actions${qs({ tenantId })}`,
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/actions${qs({ tenantId })}`,
|
||||
report: (sessionId: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/report${qs({ tenantId })}`,
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/report${qs({ tenantId })}`,
|
||||
// GET — all annotation lanes visible to the caller (backend hides the globaladmin
|
||||
// lane from callers without global scope).
|
||||
annotations: (sessionId: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/annotations${qs({ tenantId })}`,
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/annotations${qs({ tenantId })}`,
|
||||
// PUT { verdict, note } — upserts one lane; both fields null/empty clears it.
|
||||
annotation: (sessionId: string, lane: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/annotations/${lane}${qs({ tenantId })}`,
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/annotations/${encodeURIComponent(lane)}${qs({ tenantId })}`,
|
||||
quickSearch: (q: string) =>
|
||||
`${API_BASE_URL}/api/search/quick${qs({ q })}`,
|
||||
// Server-side free-text search (dashboard search box) — substring across the same
|
||||
@@ -92,11 +92,11 @@ export const api = {
|
||||
// ── Inspector v1 (global admin only — Plan §M6) ───────────────────────────
|
||||
inspector: {
|
||||
signals: (sessionId: string, opts?: { tenantId?: string; maxResults?: number }) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/signals${qs({ tenantId: opts?.tenantId, maxResults: opts?.maxResults?.toString() })}`,
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/signals${qs({ tenantId: opts?.tenantId, maxResults: opts?.maxResults?.toString() })}`,
|
||||
decisionGraph: (sessionId: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/decision-graph${qs({ tenantId })}`,
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/decision-graph${qs({ tenantId })}`,
|
||||
reducerVerification: (sessionId: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/reducer-verification${qs({ tenantId })}`,
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/reducer-verification${qs({ tenantId })}`,
|
||||
},
|
||||
|
||||
// ── Global Sessions (global admin) ────────────────────────────────────────
|
||||
@@ -131,31 +131,31 @@ export const api = {
|
||||
// ── Config ────────────────────────────────────────────────────────────────
|
||||
config: {
|
||||
all: () => `${API_BASE_URL}/api/config/all`,
|
||||
tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}`,
|
||||
tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}`,
|
||||
// Same PUT, labelled: the session-detail Collect Logs quick-config dialog. The backend
|
||||
// maps the intent to write source "portal-collect-logs" and emits the dedicated
|
||||
// CollectLogsQuickConfigEnabled ops event instead of the generic DiagnosticsUploadEnabled.
|
||||
tenantCollectLogsQuickConfig: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}?intent=collect-logs`,
|
||||
tenantCollectLogsQuickConfig: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}?intent=collect-logs`,
|
||||
/**
|
||||
* PATCH — transactional field-level config write (TenantAdminOrGA, own row):
|
||||
* { fields: { <wireFieldName>: value | null, ... }, reason?: string }. The backend
|
||||
* verifies exactly those fields changed and rolls back on drift; every write snapshots
|
||||
* to ConfigurationBackups first. The Settings sections save through this per section.
|
||||
*/
|
||||
fields: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/fields`,
|
||||
featureFlags: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/feature-flags`,
|
||||
fields: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/fields`,
|
||||
featureFlags: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/feature-flags`,
|
||||
/** PATCH — GA-only plan/trial mutation: { planTier?, trialExpiresUtc?: ISO | null }. */
|
||||
plan: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/plan`,
|
||||
plan: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/plan`,
|
||||
/** POST — tenant-admin self-service 30-day Pro trial (once per tenant, 409 after). */
|
||||
trial: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/trial`,
|
||||
trial: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/trial`,
|
||||
autopilotConsentUrl: (tenantId: string, redirectUri: string) =>
|
||||
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-url${qs({ redirectUri })}`,
|
||||
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-url${qs({ redirectUri })}`,
|
||||
autopilotConsentStatus: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-status`,
|
||||
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-status`,
|
||||
autopilotConsentFailure: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-failure`,
|
||||
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-failure`,
|
||||
autopilotConsentSuccess: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-success`,
|
||||
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-success`,
|
||||
/**
|
||||
* Probe whether the app's core validation permission is already effectively granted in
|
||||
* the tenant (pre-approved by someone with consent rights), so a rights-less admin can be
|
||||
@@ -163,16 +163,16 @@ export const api = {
|
||||
* { accessPresent, isTransient, requiredPermission }.
|
||||
*/
|
||||
autopilotAccessCheck: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/access-check`,
|
||||
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/access-check`,
|
||||
/**
|
||||
* POST — dual app-reg homing flip: { target: "primary" | "legacy", force?: boolean }.
|
||||
* Tenant admins: target "primary" only, no force, kill-switch flag on, consent probe must
|
||||
* pass. Global Admins: both directions, force skips the probe.
|
||||
*/
|
||||
appHoming: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/config/${tenantId}/app-homing`,
|
||||
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/app-homing`,
|
||||
testNotification: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/config/${tenantId}/test-notification`,
|
||||
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/test-notification`,
|
||||
latestVersions: (opts?: { refresh?: boolean }) =>
|
||||
`${API_BASE_URL}/api/config/latest-versions${qs({ refresh: opts?.refresh ? "true" : undefined })}`,
|
||||
},
|
||||
@@ -181,13 +181,13 @@ export const api = {
|
||||
graphPermissions: {
|
||||
/** GET status of optional Graph add-on permissions for the tenant (admin-only). */
|
||||
status: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/tenants/${tenantId}/graph-permissions/status`,
|
||||
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/graph-permissions/status`,
|
||||
/** POST to invalidate the backend's cached roles after a grant script run. */
|
||||
refresh: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/tenants/${tenantId}/graph-permissions/refresh`,
|
||||
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/graph-permissions/refresh`,
|
||||
/** POST endpoint to resolve Intune script display names; body carries the refs array. */
|
||||
scriptDisplayNames: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/tenants/${tenantId}/scripts/display-names`,
|
||||
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/scripts/display-names`,
|
||||
},
|
||||
|
||||
// ── Global Config (global admin) ──────────────────────────────────────────
|
||||
@@ -201,17 +201,17 @@ export const api = {
|
||||
// ── Tenants ───────────────────────────────────────────────────────────────
|
||||
tenants: {
|
||||
admins: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/tenants/${tenantId}/admins`,
|
||||
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins`,
|
||||
admin: (tenantId: string, adminUpn: string) =>
|
||||
`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}`,
|
||||
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}`,
|
||||
adminAction: (tenantId: string, adminUpn: string, action: string) =>
|
||||
`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/${action}`,
|
||||
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}/${encodeURIComponent(action)}`,
|
||||
adminPermissions: (tenantId: string, adminUpn: string) =>
|
||||
`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/permissions`,
|
||||
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}/permissions`,
|
||||
offboard: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/tenants/${tenantId}/offboard`,
|
||||
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/offboard`,
|
||||
offboardFeedback: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/tenants/${tenantId}/offboard/feedback`,
|
||||
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/offboard/feedback`,
|
||||
},
|
||||
|
||||
// ── Devices ───────────────────────────────────────────────────────────────
|
||||
@@ -248,7 +248,7 @@ export const api = {
|
||||
gather: (tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/rules/gather${qs({ tenantId })}`,
|
||||
gatherRule: (ruleId: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/rules/gather/${ruleId}${qs({ tenantId })}`,
|
||||
`${API_BASE_URL}/api/rules/gather/${encodeURIComponent(ruleId)}${qs({ tenantId })}`,
|
||||
globalGather: (tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/global/rules/gather${qs({ tenantId })}`,
|
||||
// Cross-tenant edit/toggle/delete of an existing gather rule (Global Admin override) — see globalAnalyzeRule.
|
||||
@@ -406,7 +406,7 @@ export const api = {
|
||||
lookup: (tenantId: string, search: string) =>
|
||||
`${API_BASE_URL}/api/progress/sessions/lookup${qs({ tenantId, search })}`,
|
||||
sessionEvents: (sessionId: string, tenantId: string, serial: string) =>
|
||||
`${API_BASE_URL}/api/progress/sessions/${sessionId}/events${qs({ tenantId, serial })}`,
|
||||
`${API_BASE_URL}/api/progress/sessions/${encodeURIComponent(sessionId)}/events${qs({ tenantId, serial })}`,
|
||||
},
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────────
|
||||
@@ -414,7 +414,7 @@ export const api = {
|
||||
sessions: (tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/bootstrap/sessions${qs({ tenantId })}`,
|
||||
session: (code: string, tenantId: string) =>
|
||||
`${API_BASE_URL}/api/bootstrap/sessions/${code}${qs({ tenantId })}`,
|
||||
`${API_BASE_URL}/api/bootstrap/sessions/${encodeURIComponent(code)}${qs({ tenantId })}`,
|
||||
},
|
||||
|
||||
// ── Annotations (tenant-scoped overview list) ─────────────────────────────
|
||||
@@ -467,7 +467,7 @@ export const api = {
|
||||
downloadUrl: (blobName: string) =>
|
||||
`${API_BASE_URL}/api/global/session-reports/download-url${qs({ blobName })}`,
|
||||
note: (reportId: string) =>
|
||||
`${API_BASE_URL}/api/global/session-reports/${reportId}/note`,
|
||||
`${API_BASE_URL}/api/global/session-reports/${encodeURIComponent(reportId)}/note`,
|
||||
},
|
||||
|
||||
// ── Diag Files Reports (no session context) ───────────────────────────────
|
||||
@@ -553,11 +553,11 @@ export const api = {
|
||||
// ── Notifications ─────────────────────────────────────────────────────────
|
||||
notifications: {
|
||||
list: () => `${API_BASE_URL}/api/global/notifications`,
|
||||
dismiss: (id: string) => `${API_BASE_URL}/api/global/notifications/${id}/dismiss`,
|
||||
dismiss: (id: string) => `${API_BASE_URL}/api/global/notifications/${encodeURIComponent(id)}/dismiss`,
|
||||
dismissAll: () => `${API_BASE_URL}/api/global/notifications/dismiss-all`,
|
||||
// Tenant-scoped persistent notifications (bell). TenantId resolved server-side from JWT.
|
||||
tenantList: () => `${API_BASE_URL}/api/notifications`,
|
||||
tenantDismiss: (id: string) => `${API_BASE_URL}/api/notifications/${id}/dismiss`,
|
||||
tenantDismiss: (id: string) => `${API_BASE_URL}/api/notifications/${encodeURIComponent(id)}/dismiss`,
|
||||
tenantDismissAll: () => `${API_BASE_URL}/api/notifications/dismiss-all`,
|
||||
},
|
||||
|
||||
@@ -608,12 +608,12 @@ export const api = {
|
||||
preview: {
|
||||
whitelist: () => `${API_BASE_URL}/api/preview/whitelist`,
|
||||
whitelistTenant: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/preview/whitelist/${tenantId}`,
|
||||
`${API_BASE_URL}/api/preview/whitelist/${encodeURIComponent(tenantId)}`,
|
||||
sendWelcomeEmail: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/preview/send-welcome-email/${tenantId}`,
|
||||
`${API_BASE_URL}/api/preview/send-welcome-email/${encodeURIComponent(tenantId)}`,
|
||||
notificationEmail: () => `${API_BASE_URL}/api/preview/notification-email`,
|
||||
notificationEmailTenant: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/preview/notification-email/${tenantId}`,
|
||||
`${API_BASE_URL}/api/preview/notification-email/${encodeURIComponent(tenantId)}`,
|
||||
/** GA/Reader: every stored notification address, keyed by lowercased tenant id. */
|
||||
notificationEmails: () => `${API_BASE_URL}/api/preview/notification-emails`,
|
||||
},
|
||||
@@ -721,13 +721,13 @@ export const api = {
|
||||
// ── Email Templates (GA) ──────────────────────────────────────────────────
|
||||
emailTemplates: {
|
||||
get: (kind: "welcome" | "farewell") =>
|
||||
`${API_BASE_URL}/api/global/email-templates/${kind}`,
|
||||
`${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}`,
|
||||
save: (kind: "welcome" | "farewell") =>
|
||||
`${API_BASE_URL}/api/global/email-templates/${kind}`,
|
||||
`${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}`,
|
||||
reset: (kind: "welcome" | "farewell") =>
|
||||
`${API_BASE_URL}/api/global/email-templates/${kind}`,
|
||||
`${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}`,
|
||||
sendTest: (kind: "welcome" | "farewell") =>
|
||||
`${API_BASE_URL}/api/global/email-templates/${kind}/test`,
|
||||
`${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}/test`,
|
||||
},
|
||||
|
||||
// ── Maintenance ───────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user