mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1daf631a02 | |||
| d0190f443d | |||
| e96f49799f | |||
| 4864b2d21d | |||
| 56c37866d1 | |||
| 2852418413 | |||
| 268d8652f1 | |||
| 66f5543fb7 | |||
| 21d200a1e4 | |||
| 8c0cb2c423 | |||
| 84d06e1cf6 | |||
| 88dfd331dd | |||
| d294415d29 | |||
| 76af1234ed | |||
| 0515523e49 | |||
| a47e94f9a5 | |||
| ecfa59d049 | |||
| 6c50a54249 | |||
| 6e3487756a | |||
| dbb154bae9 | |||
| c41c2327ab | |||
| 83effea592 | |||
| 1a4a37b77a | |||
| f56851fc9f | |||
| cbe410cbbc | |||
| 43ddc60111 | |||
| 82b26b4ab9 | |||
| 1733f4fd48 | |||
| fc46b4c78e | |||
| c18de5d76c | |||
| 250cc667dd | |||
| d3261fb890 | |||
| b3bbcf1300 | |||
| a306cd9e40 | |||
| 8ec8524252 | |||
| c4be6d4169 | |||
| d558b94cf6 | |||
| 9b4222e729 | |||
| cf76c4338f | |||
| a6184f165b | |||
| d3918cb740 | |||
| 15722a3885 | |||
| 1a4e13f0e9 | |||
| c39092bf72 | |||
| 494744ad3f | |||
| a68c096a26 | |||
| bf156cf862 | |||
| 0eaa3527f9 | |||
| 5055db2de1 | |||
| 040e411958 | |||
| 42c45cf567 | |||
| b31256c748 |
+1
-1
Submodule .claude/commands updated: 3061dcaf32...0d524a5695
@@ -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
|
||||
|
||||
|
||||
@@ -11,7 +11,11 @@ env:
|
||||
ACR_NAME: autopilotmonitoreuacr
|
||||
CONTAINER_APP_NAME: autopilotmonitor-mcp
|
||||
RESOURCE_GROUP: ${{ secrets.AZURE_MCP_RESOURCE_GROUP }}
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
# Unique per RUN, not per commit: the docs corpus is baked into the image, so a
|
||||
# docs-only redeploy at an unchanged code sha must still produce a new image
|
||||
# reference — with a sha-only tag `az containerapp update` saw the same image
|
||||
# and silently created no revision (2026-08-28).
|
||||
IMAGE_TAG: ${{ github.sha }}-${{ github.run_number }}
|
||||
HEALTH_URL: https://mcp.autopilotmonitor.com/health
|
||||
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
|
||||
PACKAGE_JSON: src/McpServer/autopilot-monitor-mcp/package.json
|
||||
@@ -28,8 +32,9 @@ jobs:
|
||||
# baked into the image as the search_docs corpus (see the Dockerfile). No
|
||||
# token is needed. Checking it out here rather than vendoring it keeps the
|
||||
# docs single-sourced — but it also means EDITING THE DOCS DOES NOT UPDATE A
|
||||
# RUNNING SERVER; this workflow must be re-run. /health reports the SHA
|
||||
# captured below so that staleness is checkable.
|
||||
# RUNNING SERVER; this workflow must be re-run (IMAGE_TAG is per run so the
|
||||
# re-run actually rolls a new revision). /health reports the SHA captured
|
||||
# below so that staleness is checkable.
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
|
||||
@@ -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
|
||||
@@ -94,7 +128,11 @@ batch, the counter reconcile's existing event-partition scan additionally collec
|
||||
rest (`CmTraceSkewTripwire`, hooked in `EventIngestProcessor`). If
|
||||
`median(Δ_IME) − median(Δ_other)` is a clean 15-minute multiple (residual < 2 min — the
|
||||
same constants as the anchoring grid guard above) with ≥ 20 samples over ≥ 3 distinct
|
||||
upload batches per side, a `CmTraceTimeSkewRegression` ops event fires (category Agent,
|
||||
upload batches per side, and ≥ 80 % of the individual IME deltas (relative to the other
|
||||
side's median) themselves sit within 2 min of some grid multiple — added after the 2026-08-28
|
||||
soak, when relaunched agents re-tailing the IME log produced one burst holding most of a
|
||||
session's IME samples with a continuum of ages whose median hit the grid by chance (7 false
|
||||
fires in a week, 0 true) — a `CmTraceTimeSkewRegression` ops event fires (category Agent,
|
||||
warning), carrying all numbers plus the session's `sourceOffsetOrigin` histogram.
|
||||
Bias-dominated sessions are suppressed (writer-declared offsets cannot be an anchoring
|
||||
regression); `measuredWriterOffsetMinutes` is never consulted (sticky after era
|
||||
@@ -125,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`).
|
||||
|
||||
@@ -216,7 +216,12 @@ Wired by `EnrollmentOrchestrator.Start` (`src/Agent/AutopilotMonitor.Agent.V2.Co
|
||||
- `SignalIngress` — bounded queue (256, back-pressure) + **one** worker thread. Per
|
||||
signal, in order: assign ordinals → `SignalLog.Append` (durable, *before* reducing;
|
||||
append failure ⇒ reducer does not run, no ordinal gap) → `engine.Reduce` →
|
||||
`DecisionStepProcessor.ApplyStep`.
|
||||
`DecisionStepProcessor.ApplyStep`. A `Post` issued *by the worker thread itself*
|
||||
(the `EffectRunner` posting `ClassifierVerdictIssued` from inside `ApplyStep`) never
|
||||
enters the bounded queue: it goes to a worker-local follow-up queue that is drained,
|
||||
ahead of the channel, right after the current item — the worker is the queue's only
|
||||
consumer, so blocking on a full channel there would freeze the pipeline for the rest
|
||||
of the run.
|
||||
- `DecisionStepProcessor.ApplyStep` (same thread, no locks): journal append (the only
|
||||
hard-throwing path; 3 consecutive failures escalate to quarantine) → effects run
|
||||
synchronously via `EffectRunner` → best-effort snapshot → advance `CurrentState` →
|
||||
|
||||
@@ -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`
|
||||
@@ -109,7 +109,9 @@ telemetry directly:
|
||||
```
|
||||
Collector callback
|
||||
→ SignalAdapter (dual emission: decision signal + InformationalEvent)
|
||||
→ SignalIngress.Post() (bounded queue 256, back-pressure blocks producer)
|
||||
→ SignalIngress.Post() (bounded queue 256, back-pressure blocks producer;
|
||||
posts from the worker thread itself bypass the
|
||||
queue via a worker-local follow-up queue)
|
||||
→ single worker thread:
|
||||
assign ordinals → SignalLog.Append (durable) ← source of truth
|
||||
→ DecisionEngine.Reduce → apply step/effects
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Admin Identity Binding — tid + oid behind every cross-tenant-role UPN
|
||||
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
|
||||
- security
|
||||
- authorization
|
||||
- multi-tenant
|
||||
- global-admin
|
||||
- delegated-admin
|
||||
- mcp
|
||||
timestamp: 2026-08-30T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
The API is a multi-tenant Entra application on purpose: `AuthenticationMiddleware` accepts a token from
|
||||
any tenant (issuer prefix `login.microsoftonline.com/` or `sts.windows.net/`), and signature, audience,
|
||||
lifetime and algorithm are validated strictly. Tenant-member roles (`TenantAdmins`) are keyed on
|
||||
`(tid, upn)` and therefore bound to the verified tenant.
|
||||
|
||||
The two cross-tenant tiers were not. `GlobalAdmins` (GlobalAdmin / GlobalReader) and the delegated MSP
|
||||
tier (`DelegatedAdmins` + `TenantGroupAssignments`) resolved their role from the **UPN string alone**
|
||||
(`upn`, falling back to `preferred_username`). Both claims are mutable and reusable across tenants:
|
||||
|
||||
* **Domain re-registration**: if the domain behind a platform admin's UPN lapses or moves, whoever
|
||||
verifies it in a fresh tenant can mint the identical UPN; the token is genuine, only `tid` differs.
|
||||
* **UPN recycling**: a User Administrator in the home tenant can re-assign the UPN to another account;
|
||||
same `tid`, different `oid`.
|
||||
* **MSP variant**: the delegated tier was gated only on the caller's home tenant being Pro — which any
|
||||
tenant can self-provision via the trial endpoint.
|
||||
|
||||
Any of these yielded full platform scope: raw table dumps including `TenantConfiguration` secrets,
|
||||
tenant offboarding, GA management. Microsoft's own guidance (post-nOAuth) is explicit: authorization
|
||||
data must be keyed on the immutable `tid` + `oid` pair, never on `upn` / `preferred_username`.
|
||||
|
||||
# Mechanism
|
||||
|
||||
## One binding per UPN
|
||||
|
||||
`AdminIdentityBindings` (PK `Bindings`, RK = lowercase UPN) holds, for every UPN that carries a
|
||||
cross-tenant role, the identity that may use it:
|
||||
|
||||
| Column | Meaning |
|
||||
| --- | --- |
|
||||
| `TenantId` | The admin's **home** Entra tenant — must equal the JWT `tid`. Mandatory at grant time. |
|
||||
| `ObjectId` | The admin's Entra object id — must equal the JWT `oid`. Empty until pinned. |
|
||||
| `BoundBy`, `BoundDate` | Operator provenance. |
|
||||
| `ObjectIdPinnedDate` | When the object id was fixed (grant time, or the first matching sign-in). |
|
||||
|
||||
A single table rather than columns on three role tables: a UPN string belongs to exactly one identity
|
||||
at a time, a person may hold a GlobalAdmins row *and* delegated assignments, and the role rows stay
|
||||
what they are — grants. The binding says whose grants they are.
|
||||
|
||||
## Resolution order (both role services)
|
||||
|
||||
`AdminIdentity` is the record `(Upn, TenantId, ObjectId)` built from a validated principal; it is
|
||||
`null` when any of the three claims is missing (app-only tokens, foreign IdP shapes). Both
|
||||
`GlobalAdminService.GetGlobalRoleAsync(AdminIdentity?)` and
|
||||
`DelegatedAdminService.GetScopeAsync(AdminIdentity?)` follow the same order:
|
||||
|
||||
1. `null` identity → no role, no storage read.
|
||||
2. Resolve the **row** by UPN (cached 30 s, unchanged from before).
|
||||
3. No row / empty scope → return; the binding is not even read. Ordinary tenant users cost one cached
|
||||
read and never produce a binding log line.
|
||||
4. `AdminIdentityBindingService.IsBoundAsync(identity)` → false ⇒ no role / empty scope.
|
||||
5. Delegated only: the Pro home-tenant entitlement gate, **after** the binding check — an unbound caller
|
||||
never triggers an edition lookup.
|
||||
|
||||
`IsBoundAsync` is the single choke-point: no binding ⇒ false; `TenantId` mismatch ⇒ false (checked
|
||||
before anything else, so a foreign-tenant token can never claim the pin below); pinned `ObjectId`
|
||||
mismatch ⇒ false; unpinned binding + matching tenant ⇒ **pin** the caller's `oid` and verify against
|
||||
what was actually stored afterwards. Every false outcome is logged at Warning with its reason
|
||||
(`[IdentityBinding] TENANT MISMATCH …` / `OBJECT-ID MISMATCH …` / `holds no identity binding`), the
|
||||
one level the worker forwards to Application Insights; matches are silent. Pins are logged at Warning
|
||||
once — they are one-off, operator-relevant events.
|
||||
|
||||
The pin is a conditional update in the repository (`TryPinIdentityObjectIdAsync`: only onto an unpinned
|
||||
row homed in the caller's tenant, ETag-guarded; a 412 re-reads and the caller compares against the
|
||||
winner). Two accounts racing the first sign-in inside the home tenant therefore leave exactly one bound.
|
||||
|
||||
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 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
|
||||
are known, both from validated tokens. The most recent sign-in wins for the oid. A UPN seen under
|
||||
**more than one tenant** is never auto-resolved — that is the attack shape the binding exists for.
|
||||
2. **UPN domain → onboarded tenant** (`TenantConfiguration.DomainName`, exactly one match): tid only; the
|
||||
oid is pinned on the first sign-in.
|
||||
3. Neither ⇒ **422 `HomeTenantUnresolved`**; the portal then shows a home-tenant picker (onboarded
|
||||
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`, `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
|
||||
functions map that to **409**. A grant can therefore never silently re-home a UPN.
|
||||
|
||||
Why the object id may be unknown: a person who has never signed in has no history to resolve from, and
|
||||
the operator granting an external MSP admin has no directory access to the partner tenant. The first
|
||||
sign-in from the bound tenant pins it; from then on a recycled UPN in the same tenant is refused.
|
||||
|
||||
## Explicit rebind surface
|
||||
|
||||
| Route | Policy | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET global/identity-bindings` | GlobalReadOrAdmin | Audit which tenant / object id each grant is usable from. |
|
||||
| `PUT global/identity-bindings/{upn}` | GlobalAdminOnly | Replace the binding: move a UPN to another tenant, or re-pin after a legitimate account re-creation (omit `objectId` to clear the pin). |
|
||||
| `DELETE global/identity-bindings/{upn}` | GlobalAdminOnly | Make every role row of the UPN inert without touching the rows. Self-removal is refused. |
|
||||
|
||||
Both mutations are audited under the binding's home tenant (`AdminIdentityBinding` entity, previous
|
||||
values in the details) and logged at Warning. The portal surfaces this in **Tenant Management → Edit
|
||||
Tenant Configuration → Identity bindings** (collapsed): the bindings homed in that tenant, with inline
|
||||
edit (re-home / re-pin; empty object id ⇒ re-pinned on the next sign-in) and remove. The grant forms
|
||||
themselves carry no identity fields — the binding is a background property of the grant.
|
||||
|
||||
## Lifecycle and secondary effects
|
||||
|
||||
* `AdminIdentityBindings` is in `TableNames.All`, in `CriticalBackupTables.All` and in
|
||||
`AuthTablesFullRestoreForbidden` (single-row restore only, like the other auth tables).
|
||||
* Tenant offboarding wipes bindings whose `TenantId` is the offboarded tenant (property-only bucket):
|
||||
once a home tenant is gone, its admins' grants become inert.
|
||||
* `RequestContext.ObjectId` carries the JWT `oid`; `AdminIdentity.FromRequestContext` serves the
|
||||
handlers that resolve roles outside the middleware (SignalR group join/leave, health checks).
|
||||
* The throttle identity (`GetCallerId`) now prefers `oid` over the UPN, so two accounts in different
|
||||
tenants that share a UPN string no longer share a rate-limit bucket. Audit and presence rows keep the
|
||||
UPN as their display key; they already carry the tenant id.
|
||||
* Legacy role rows without a binding are **inert, not grandfathered**. Rolling this out therefore means
|
||||
seeding the binding rows for the existing admin UPNs (their `tid` and `oid` are recorded per login in
|
||||
`UserActivity`) *before* the backend deploy — an unseeded platform admin locks themselves out of every
|
||||
GlobalAdminOnly route, including the rebind surface.
|
||||
|
||||
# Examples
|
||||
|
||||
A GlobalAdmin row for `admin@vendor.example` bound to tenant `1111…` with object id `aaaa…`:
|
||||
|
||||
| Token | Result |
|
||||
| --- | --- |
|
||||
| `tid=1111… oid=aaaa… upn=admin@vendor.example` | GlobalAdmin |
|
||||
| `tid=2222… oid=aaaa… upn=admin@vendor.example` | no role — `TENANT MISMATCH` warning |
|
||||
| `tid=1111… oid=bbbb… upn=admin@vendor.example` | no role — `OBJECT-ID MISMATCH` warning |
|
||||
| `tid=1111… upn=admin@vendor.example` (no `oid`) | no role — identity incomplete |
|
||||
|
||||
Same row, binding without an object id: the first `tid=1111…` sign-in pins its `oid` and is admitted; a
|
||||
`tid=2222…` sign-in before that is refused **and does not pin**.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Security/AdminIdentity.cs` — the identity record and its factories.
|
||||
* `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`, `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.
|
||||
* [Client-Certificate Tenant Binding](client-cert-tenant-binding.md) — the same "chain-valid is not tenant-bound" argument on the device side.
|
||||
@@ -10,7 +10,7 @@ tags:
|
||||
- certificates
|
||||
- intune
|
||||
- multi-tenant
|
||||
timestamp: 2026-08-24T00:00:00+02:00
|
||||
timestamp: 2026-08-29T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
@@ -129,6 +129,41 @@ Grep marker for every site involved: `CERT-TENANT-BINDING`.
|
||||
The bootstrap-token path (`X-Bootstrap-Token`) bypasses certificate validation by design and is
|
||||
unaffected by either stage.
|
||||
|
||||
# Certificate transport and proof of possession
|
||||
|
||||
Every stage above validates certificate *content*: chain, validity window, EKU, tenant extension.
|
||||
None of them proves the caller holds the private key — a certificate is public material (sent in
|
||||
clear in a TLS 1.2 client-auth handshake, readable from the machine store by any local user, present
|
||||
on resold hardware). Proof of possession comes from the TLS handshake alone: the platform runs
|
||||
`clientCertMode=Required` on every path not listed in its `clientCertExclusionPaths`, forwards the
|
||||
handshake certificate as `X-ARR-ClientCert`, and **strips any inbound copy of that header — on
|
||||
excluded paths too** (verified 2026-08-29 with a spoofed header against `/api/bootstrap/config`:
|
||||
the validator saw "No certificate provided"). So `X-ARR-ClientCert` is trustworthy exactly because
|
||||
the caller cannot write it.
|
||||
|
||||
Two consequences shape `SecurityValidator`:
|
||||
|
||||
* **No other certificate header is ever read.** A former local-dev fallback (`X-Client-Certificate`)
|
||||
accepted certificate bytes from a client-writable header, which decouples the certificate from
|
||||
key possession: anyone holding a device's public certificate would authenticate as that device.
|
||||
Removed 2026-08-29. Local development sets `X-ARR-ClientCert` directly — Core Tools does not strip
|
||||
it.
|
||||
* **Bootstrap routes are token-only, fail-closed.** `/api/bootstrap/*` is in the platform's
|
||||
exclusion list so cert-less OOBE agents can reach it — which also means nothing on those paths
|
||||
proves a handshake happened. The token is therefore the sole credential there:
|
||||
`SecurityValidator.IsBootstrapRoute` (path prefix, mirroring the exclusion entry so no function
|
||||
can forget to opt in) rejects a missing or **empty** `X-Bootstrap-Token`, and a missing
|
||||
`BootstrapSessionService`, with 401 before the certificate stage can run. The live gap this
|
||||
closes: the function-level `Headers.Contains` gate accepted an empty header while the validator
|
||||
treated it as "no token" and fell through to the certificate stage, where the fallback header
|
||||
above was parsed. Bootstrap tokens are never accepted as a *replacement* for the certificate on
|
||||
platform-enforced routes either — there the cert requirement is already satisfied by the
|
||||
handshake before the request reaches code.
|
||||
|
||||
The exclusion list is portal-only state (no IaC). Adding `/api/agent` to it, or moving a
|
||||
device-authenticated function under an excluded prefix, would silently reopen the header path;
|
||||
`BootstrapRouteFailClosedTests` pins the code half of this contract.
|
||||
|
||||
**Stage 3 — device binding (Global-Admin-only preview).** Tenant binding proves the certificate was
|
||||
issued to this tenant; it does not prove the certificate belongs to a device the tenant still has.
|
||||
`IntuneDeviceBindingValidator` closes that by resolving the Intune managedDevice id from the
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
type: Concept
|
||||
title: IME Installer Archive (version-verified, multi-host)
|
||||
description: How every fleet-observed Intune Management Extension build is archived as its MSI - the ime_agent_version sighting that queues the job, the candidate hosts, the ProductVersion check that keeps a wrong ring's build out of the version folder, the re-queue on later sightings, and the manual skill-side backfill.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Services/Ime/ImeMsiArchiver.cs
|
||||
tags:
|
||||
- backend
|
||||
- ime
|
||||
- archive
|
||||
- blob
|
||||
- queue
|
||||
timestamp: 2026-08-30T12:00:00+02:00
|
||||
---
|
||||
|
||||
# IME Installer Archive
|
||||
|
||||
The platform keeps every Intune Management Extension build it has ever seen in the fleet
|
||||
as the original installer, so any IME version can be decompiled and diffed later
|
||||
(`ime-decompiles` repository, `/ime-decompile` skill) even after Microsoft's CDN has moved
|
||||
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, `CorroboratedAt`, `MsiArchiveStatus`, `MsiArchiveUpdatedAt`, `MsiArchiveBlobPath`, `MsiSha256`, `MsiBytes`, `MsiSourceUrl` | `RecordImeVersionAsync` (sighting), `UpdateImeVersionArchiveInfoAsync` (archive outcome / queued stamp) |
|
||||
|
||||
# Schema
|
||||
|
||||
## Sighting → job
|
||||
|
||||
`EventIngestProcessor` looks at the batch's `ime_agent_version` event (`agentVersion`, plus
|
||||
the agent's CSP-registry enrichment `msiDownloadUrl` / `msiMatchedBy` when present).
|
||||
`RecordImeVersionAsync` returns an `ImeVersionSighting`:
|
||||
|
||||
* `IsNew` — the insert succeeded: first fleet-wide sighting → ops event + enqueue.
|
||||
* otherwise the row's `MsiArchiveStatus` / `MsiArchiveUpdatedAt`, read in the same point
|
||||
read that bumps `SessionCount` (no second table read).
|
||||
|
||||
A known version is **re-queued** (`ImeMsiArchiver.ShouldRequeueOnSighting`) only when all
|
||||
of these hold: the event URL passes the allowlist **and** `msiMatchedBy == productVersion`
|
||||
(the CSP row's ProductVersion equals the observed version, so the URL is authoritative),
|
||||
the status is neither `Archived` nor `Failed:BadVersion`, and the last archive activity is
|
||||
older than 24 h (`RequeueBackoff`) or never happened. The ingest path then merges
|
||||
`MsiArchiveStatus = Queued` (stamping `MsiArchiveUpdatedAt`) before enqueueing, so the other
|
||||
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
|
||||
rings at the same time**. Observed 2026-08-29: `imeswdb-afd-secondary` served 1.104.102.0,
|
||||
`imeswda-afd-primary` 1.105.101.0, `imeswda-afd-hotfix` 1.105.103.0. "A new version is by
|
||||
definition what the canonical URL serves right now" is therefore false, and the first
|
||||
version of the archiver filed a 1.104.102.0 package under `1.105.103.0/` (fixed by hand:
|
||||
blob overwritten, row merged).
|
||||
|
||||
The archiver walks `BuildCandidateUrls(eventUrl)`: the allowlisted event URL first (HTTPS,
|
||||
host `*.manage.microsoft.com`, filename exactly `IntuneWindowsAgent.msi`), then
|
||||
`FallbackMsiUrls` = primary, secondary, hotfix — de-duplicated. For each candidate:
|
||||
|
||||
1. Download with the admin size cap (Content-Length preflight + mid-stream cap) into a
|
||||
delete-on-close temp file while SHA-256 hashing.
|
||||
2. Read the package's `ProductVersion` with `MsiProductVersionReader` — a pure managed OLE
|
||||
compound-file reader (v3 512-byte and v4 4096-byte sectors, DIFAT/FAT/mini-FAT, MSI
|
||||
stream-name decoding, `_StringPool`/`_StringData` incl. long strings and holes, the
|
||||
column-major `Property` table with 2- or 3-byte string refs). It never throws; anything
|
||||
unreadable is `null`. Needed because the Function host is Linux — no `msiexec`.
|
||||
3. `VersionsMatch(observed, productVersion)` (component-count tolerant: `1.105.103` ==
|
||||
`1.105.103.0`). Mismatch or unreadable → log, record the attempt, next candidate.
|
||||
4. Match → write-once upload (If-None-Match:*), then `provenance.json` with
|
||||
`productVersion`, the chosen `url`, `urlFromEvent`, hash/size and the full `candidates`
|
||||
walk (`url`, `outcome` ∈ match / version-mismatch / too-large / download-failed /
|
||||
timeout, `productVersion`).
|
||||
|
||||
Outcome precedence when no candidate archived: any transient failure (download/timeout)
|
||||
→ `Failed:Download` / `Failed:Timeout`, retryable via the queue's visibility ladder (the
|
||||
down host might have had it); every candidate over the cap → `Failed:TooLarge`; otherwise
|
||||
`Failed:VersionMismatch` — **not** retryable by the queue (retrying in minutes changes
|
||||
nothing), left to the sighting re-queue above. Storage/unexpected errors stay
|
||||
`Failed:Error`, retryable. A 409 on the write-once upload means an earlier attempt
|
||||
archived the version: the provenance sidecar is healed from the archived bytes (hash,
|
||||
size, ProductVersion) if missing — this is also how a manual skill backfill turns a null
|
||||
row into `Archived` on the next authoritative sighting.
|
||||
|
||||
## Manual path (skill)
|
||||
|
||||
`/ime-decompile` downloads from the blob (`ime-blob.sh download`), and
|
||||
`fetch-ime-decompile.py` names the decompile folder after the **actual** FileVersion of
|
||||
the extracted binaries — so the decompile repo can never be poisoned, only the blob folder.
|
||||
When the blob's version mismatches, look for `msiDownloadUrl` in *other* sessions of the
|
||||
version (the first-seen session often lacks the enrichment) and fetch from that host.
|
||||
`ime-blob.sh upload … --overwrite` replaces a blob known to hold the wrong build; merge the
|
||||
row's `MsiSha256` / `MsiBytes` / `MsiSourceUrl` afterwards.
|
||||
|
||||
# Examples
|
||||
|
||||
* First sighting of 1.105.103.0 with no URL: primary → 1.105.101.0 (mismatch), secondary →
|
||||
1.104.102.0 (mismatch), hotfix → 1.105.103.0 (match) → archived from hotfix, provenance
|
||||
lists all three.
|
||||
* Hotfix host down during the walk, others mismatching → `Failed:Download`, queue retries.
|
||||
* Version 1.101.111.0 (predates the archiver, row status null): a session whose CSP row
|
||||
matches by ProductVersion arrives → status `Queued`, job runs; if no host serves it any
|
||||
more → `Failed:VersionMismatch`, next try no sooner than 24 h later.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Ime/ImeMsiArchiver.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Ime/MsiProductVersionReader.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Ime/ImeMsiArchiveQueueWorker.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs` (ime_agent_version block)
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs` (`RecordImeVersionAsync`, `UpdateImeVersionArchiveInfoAsync`)
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeMsiInstallSourceProbe.cs` (event enrichment)
|
||||
* `.claude/commands/scripts/ime-blob.sh`, `.claude/commands/scripts/fetch-ime-decompile.py`
|
||||
* Tests: `ImeMsiArchiverTests`, `MsiProductVersionReaderTests` (synthetic packages + local sweep over `ime-files/`)
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Session Owner Binding — Session-Level Authorization for Agent Writes
|
||||
description: Why tenant-level agent authentication (any device certificate, or a bootstrap token) was enough to rewrite every session in the tenant, how each Sessions row is now bound to the device identity that created it, the rule matrix that keeps every legitimate lifecycle working, the shadow-then-enforce rollout, and why enforcement needs the agent to rotate its session id rather than count an auth failure.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Security/SessionOwnershipPolicy.cs
|
||||
tags:
|
||||
- backend
|
||||
- security
|
||||
- authorization
|
||||
- sessions
|
||||
- agent
|
||||
- multi-tenant
|
||||
timestamp: 2026-08-29T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
Agent endpoints authenticate at **tenant** granularity: a chain-valid Intune MDM certificate whose
|
||||
tenant stamp matches the request (see [client-cert-tenant-binding.md](client-cert-tenant-binding.md)),
|
||||
or a bootstrap token minted for the tenant. They authorize writes at **session** granularity using a
|
||||
session id the caller supplies — `IngestTelemetryFunction` takes it from the `PartitionKey` of the
|
||||
first item, `RegisterSessionFunction` from the body, `ReportAgentErrorFunction` from the body. Until
|
||||
2026-08-29 nothing on the Sessions row recorded which device created it, so nothing could be compared.
|
||||
|
||||
The consequence: one enrolled device — or anyone who saw a 6-character bootstrap short code — could,
|
||||
for every other session of the tenant, forge `enrollment_complete` / failure events (flipping the live
|
||||
status, firing webhooks and SLA logic), pollute the timeline, re-register the session with different
|
||||
device metadata (which also redirects the Progress-Portal serial-knowledge proof), and — worst — send a
|
||||
Signal-only batch that **fetches and clears the victim's pending ServerActions**, silently stealing
|
||||
admin commands such as diagnostics collection. Session ids are GUIDs, but they travel in portal URLs
|
||||
and tenant SignalR messages; they were never meant to be a capability.
|
||||
|
||||
# Mechanism
|
||||
|
||||
## Owner on the row
|
||||
|
||||
`SessionOwner` (Shared) is stamped by the backend from the validated caller identity — never from
|
||||
agent input — and lives in six primary-only Sessions columns (`OwnerKind`, `OwnerThumbprint`,
|
||||
`OwnerDeviceId`, `OwnerBootstrapCode`, `OwnerSerial`, `OwnerBoundAt`). They are listed in
|
||||
`SessionIndexFieldManifest.PrimaryOnly`: never mirrored to `SessionsIndex`, never on `SessionSummary`,
|
||||
so no certificate identity travels to clients.
|
||||
|
||||
| Auth path | Identity that binds | Proven by |
|
||||
|---|---|---|
|
||||
| Certificate | `OwnerThumbprint` + `OwnerDeviceId` (Intune device id from the certificate CN, lower-case GUID) | TLS handshake; the CN survives a certificate re-issue |
|
||||
| Bootstrap token | `OwnerBootstrapCode` + `OwnerSerial` | Token possession + the serial the caller announced (header, unverified) |
|
||||
|
||||
`SecurityValidationResult.IntuneDeviceId` is new: the CN was already extracted for the Cloud PC
|
||||
validator, now it is surfaced on the result for every certificate-authenticated request.
|
||||
|
||||
## Rule matrix
|
||||
|
||||
`SessionOwnershipPolicy.Evaluate(existingRow, validation, now)` is pure. It returns an outcome, the
|
||||
owner to stamp (if any) and whether the caller's serial equals the row's serial.
|
||||
|
||||
| Caller | Row owner | Outcome | Stamps | Would reject |
|
||||
|---|---|---|---|---|
|
||||
| any | no row | `Fresh` | caller | no |
|
||||
| any | no owner columns (legacy), serial equal | `ClaimLegacy` | caller | no |
|
||||
| any | no owner columns, serial differs | `LegacySerialMismatch` | — | **yes** |
|
||||
| any | — (validation carried neither thumbprint nor code) | `CallerUnidentified` | — | no |
|
||||
| Cert | Cert, same thumbprint | `Match` | — | no |
|
||||
| Cert | Cert, other thumbprint, same device id | `RebindCertRotation` | caller | no |
|
||||
| Cert | Cert, other thumbprint and device id | `MismatchCert` (+ `serialMatch`) | — | **yes** |
|
||||
| Cert | Bootstrap, same serial | `RebindBootstrapHandoff` | caller | no |
|
||||
| Cert | Bootstrap, other serial | `MismatchBootstrapOwned` | — | **yes** |
|
||||
| Bootstrap | Bootstrap, same code + serial | `Match` | — | no |
|
||||
| Bootstrap | Bootstrap, otherwise | `MismatchBootstrap` | — | **yes** |
|
||||
| Bootstrap | Cert | `DowngradeToBootstrap` | — | **yes** |
|
||||
|
||||
The tolerated rows are the legitimate lifecycle: agent restart and reboot (same certificate),
|
||||
WhiteGlove Part 2 (same certificate), the install→runtime handoff where a bootstrap-registered session
|
||||
continues under the MDM certificate once it exists, a re-issued certificate of the same Intune device,
|
||||
and rows that predate the binding.
|
||||
|
||||
`MismatchCert` with `serialMatch=true` is the one shape that is *not* an attack: an Intune
|
||||
re-enrollment **without a wipe**. `session.id` survives in `%ProgramData%\AutopilotMonitor`, but the
|
||||
device gets a new Intune device id and therefore a new certificate identity. Under enforcement this
|
||||
would be refused — which is why enforcement needs the agent-side rotation below.
|
||||
|
||||
## Where it runs
|
||||
|
||||
`SessionOwnerBindingObserver` is the single side-effect carrier; the three call sites hand it the
|
||||
row they already hold:
|
||||
|
||||
* `RegisterSessionFunction.ProcessRegisterAsync` — the cascade-delete guard now returns the row
|
||||
(`EnsureWritableAndGetRowAsync`); the decision's owner goes into `StoreSessionAsync(registration,
|
||||
ownerToStamp)`, whose Replace preserves an existing owner when nothing new is stamped (including a
|
||||
Merge-stamped owner that landed after the initial read — the CAS re-read selects the owner columns).
|
||||
The bootstrap wrapper inherits this.
|
||||
* `IngestTelemetryFunction.Run` — evaluated against `guardSessionRow` (zero extra reads) before
|
||||
`PersistItemsAsync`, so the Signal-only path that serves pending ServerActions is covered. Legacy
|
||||
claims and rebinds are written with `UpdateSessionOwnerAsync` (Merge, fail-soft).
|
||||
* `ReportAgentErrorFunction` and its bootstrap wrapper — one point-read, observe only.
|
||||
|
||||
## Shadow telemetry (stage 1)
|
||||
|
||||
Same three carriers as the cert-tenant binding:
|
||||
|
||||
* **Denominator** — `requests | extend o = tostring(customDimensions.SessionOwnerBinding) | where
|
||||
isnotempty(o) | summarize count() by o` (request-row dimension via `RequestTelemetryMiddleware`;
|
||||
worker `LogInformation` never reaches App Insights).
|
||||
* **Numerator** — `traces | where message startswith "AgentSessionOwnerBinding"` — one Warning per
|
||||
non-Match/non-Fresh request with `outcome`, `wouldReject`, `callerKind`, `ownerKind`, `serialMatch`,
|
||||
`endpoint`, `ver`.
|
||||
* **Operator signal** — ops event `SessionOwnerMismatch` (Security / Warning, dual-registered in the
|
||||
admin alert-rule catalog) for would-reject outcomes, throttled to one per session+outcome per hour.
|
||||
`serialMatch=true` counts re-enrollments without wipe; `serialMatch=false` is a foreign device.
|
||||
|
||||
Nothing is refused in stage 1. `SessionOwnershipPolicy` deliberately has no `Rejects` member
|
||||
(pinned by `SessionOwnershipPolicyTests.Stage1_has_no_Rejects_rule`).
|
||||
|
||||
## Enforcement contract (stage 2, not yet built)
|
||||
|
||||
* Backend: `Rejects(outcome)` = the would-reject set; register/ingest/error answer 403 with
|
||||
`RegisterSessionResponse.ErrorCode = Constants.AgentErrorCodes.SessionOwnerMismatch`
|
||||
(`"session_owner_mismatch"`). The code and field already exist so the agent could ship first.
|
||||
* Agent (shipped dormant with this change): `BackendApiClient` reads `errorCode` off a JSON 401/403
|
||||
body into `BackendAuthException.ErrorCode`; `SessionRegistrationHelper` reacts to exactly that code
|
||||
by calling `rotateSession` once and re-registering immediately — **without** feeding
|
||||
`AuthFailureTracker` (five consecutive 403s would otherwise soft-shutdown a perfectly authorized
|
||||
agent). `BackendSessionRegistration.RotateSession` rotates `session.id` (keeping
|
||||
`whiteglove.complete`), re-targets `agentConfig.SessionId` and the `EmergencyReporter`, and drops
|
||||
`spool.jsonl` + `upload-cursor.json` because their lines carry the refused partition key. The
|
||||
`agent_started` event then reports `sessionRotated=true` + `previousSessionId`.
|
||||
* Registration is the first session-scoped call of every agent start and everything after Phase 6
|
||||
reads `agentConfig.SessionId` live, so a mismatch on ingest can only mean an agent older than the
|
||||
rotation logic or a genuinely foreign caller.
|
||||
|
||||
Switch on only after: the shadow distribution has run for weeks, every `wouldReject=true` is
|
||||
explained, and the fleet's `X-Agent-Version` is at or above the rotation-capable release. Then add
|
||||
`Rejects`, delete the stage-1 pin test, update the trust pages (isolation model) and bump their
|
||||
"Last reviewed" date.
|
||||
|
||||
# Residual risk (accepted)
|
||||
|
||||
* The serial is a caller-supplied header on both paths. A certificate holder who also knows a victim's
|
||||
serial can still claim a **legacy** row, or a **bootstrap-owned** row during its handoff window.
|
||||
* One bootstrap token serves many devices; two devices on the same short code can still write into
|
||||
each other's session until the certificate handoff closes it.
|
||||
* `SupersedeOrphanedPredecessorsAsync` is keyed on the announced serial and can still resolve foreign
|
||||
open sessions to `Incomplete`. Follow-up: restrict to rows without owner or with the same
|
||||
`OwnerSerial`, and require a minimum age.
|
||||
|
||||
None of these are closable without a signature scheme over the request body, which is deliberately
|
||||
not built (it would duplicate the TLS proof).
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Security/SessionOwnershipPolicy.cs` — matrix, `WouldRejectUnderEnforcement`, row (de)serialization
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/SessionOwnerBindingObserver.cs` — carriers, throttle, stamping
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/Enrollment/SessionOwner.cs` — column names and kinds
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs` — `StoreSessionAsync(…, ownerToStamp)`, `UpdateSessionOwnerAsync`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs` — rotation branch
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendSessionRegistration.cs` — `RotateSession`
|
||||
* Tests: `SessionOwnershipPolicyTests`, `SessionOwnerBindingObserverTests`, `StoreSessionReregistrationPreserveTests`, agent `SessionRegistrationHelperTests`, `BackendSessionRegistrationTests`, `SessionIdPersistenceTests`, `BackendApiClientAuthClassificationTests`
|
||||
* Grep marker: `SESSION-OWNER-BINDING`
|
||||
@@ -10,7 +10,7 @@ tags:
|
||||
- classification
|
||||
- whiteglove
|
||||
- self-deploying
|
||||
timestamp: 2026-08-23T18:00:00+02:00
|
||||
timestamp: 2026-08-27T20:00:00+02:00
|
||||
---
|
||||
|
||||
# Silent-Session Classification
|
||||
@@ -30,7 +30,10 @@ so a session gets the same verdict regardless of which path noticed the silence
|
||||
subcategory counts (incl. the 30s fallback-confirmed messages), `HasExplicitFailure`,
|
||||
`HasEnrollmentComplete` vs. `HasTerminalComplete` (the Part-1 `whiteglove_complete`),
|
||||
`HasAgentEmergencyBreak`, `DesktopArrived`, `HelloResolved`, `HelloPolicyDisabled`,
|
||||
`SkipUserEsp`, RealmJoin detection/resolution. Session-row flags are passed alongside:
|
||||
`SkipUserEsp`, RealmJoin detection/resolution, `HasAppInstallFailure` (any
|
||||
`app_install_failed`), and `HasAgentMaxLifetimeTimeout` (either max-lifetime shape:
|
||||
`enrollment_failed`/`agent_timeout` or `agent_shutting_down`/`max_lifetime` — the agent is
|
||||
provably gone). Session-row flags are passed alongside:
|
||||
`IsPreProvisioned` + `ResumedAt` (WhiteGlove Part 2) and `IsSelfDeployingProfile`
|
||||
(registry-confirmed `CloudAssignedOobeConfig 0x20|0x40`, set at registration).
|
||||
|
||||
@@ -44,7 +47,9 @@ subcategory counts (incl. the 30s fallback-confirmed messages), `HasExplicitFail
|
||||
| 2 | Account Setup all-succeeded, or `enrollment_complete`, or `whiteglove_complete` on a **non-resumed** session | Succeeded |
|
||||
| 3 | `HasAgentEmergencyBreak` | Incomplete immediately (agent self-destructed, nothing can arrive) |
|
||||
| 4 | `DesktopArrived` + (Hello terminal, or Hello disabled + SkipUserEsp) | Succeeded (user provably finished) |
|
||||
| 5 | Device Setup done | AwaitingUser within grace, Incomplete past grace |
|
||||
| 5 | Device Setup done + within grace + **no** max-lifetime watchdog observed | AwaitingUser |
|
||||
| 5a | Device Setup done + past grace (or watchdog observed) + `DesktopArrived` + no `app_install_failed` | **Succeeded** — completed (assumed), `r5_assumed` |
|
||||
| 5b | Device Setup done, otherwise past grace (or watchdog observed) | Incomplete |
|
||||
| 6 | otherwise | Incomplete |
|
||||
|
||||
Grace = `ResolveGraceHours(SessionGraceHours, AbsoluteMaxSessionHours)` (~51h default),
|
||||
@@ -65,7 +70,12 @@ budget on a Wi-Fi link still associating), and rule 5 labeled finished kiosks
|
||||
Incomplete turned a working rollout into a red wall. Other self-deploying tenants completed
|
||||
normally in the same window, so this is a classification gap, not a profile-wide agent bug.
|
||||
Agent-side companion fix: `SessionRegistrationHelper` now waits up to 15s for a network link
|
||||
and retries 6× (~62s backoff) instead of 5× (~30s).
|
||||
and retries 6× (~62s backoff) instead of 5× (~30s). Measured 2026-08-27 (builds
|
||||
2.0.1428/1429, both carrying the fix): the reconcile share did not move, and the backend saw
|
||||
no registration attempt from any relaunch — no success, no 4xx, zero `register:superseded`
|
||||
in the tenant — so the relaunch never reaches the network at all (device powered off after
|
||||
ESP, or the relaunch never runs). The sweep reconcile is the permanent completion path for
|
||||
this profile, not a stopgap.
|
||||
|
||||
## Callers (all pass both gate inputs)
|
||||
|
||||
@@ -78,13 +88,33 @@ and retries 6× (~62s backoff) instead of 5× (~30s).
|
||||
| Agent stall probe (`session_stalled`) | `EventIngestProcessor.TryMapStallToWhiteGloveAwaitingUserAsync` | Only the WG gate (1b); a stall probe on a self-deploying device with Device Setup done means the agent is alive and something else blocks — stays Stalled. |
|
||||
| Admin retro-reconcile | `LegacyReclassificationService` | Modes `legacy_timeouts`, `pending_orphans`, `self_deploying_silent` (Incomplete/AwaitingUser/Stalled rows with the profile flag, heal-only to Succeeded, admin-marked rows skipped). `POST /api/maintenance/reclassify-legacy?mode=…&dryRun=false&tenantId=…&maxSessions=…`, dry-run by default. |
|
||||
|
||||
## Known gap (decided, not built)
|
||||
## Why rule 5a exists (calibration read 2026-08-27)
|
||||
|
||||
The verdict-calibration matrix showed r5-Incomplete devices re-enrolling at 2.6 % within
|
||||
7 days — the succeeded background (`agent:complete_soft` sits at 2.8 %), not the ~29 %
|
||||
failure level — consistently across tenants, and half the sampled sessions were healthy
|
||||
desktops with a clean app record whose agent died before the Hello terminal (some with the
|
||||
agent's own "AccountSetup treated as complete" synthesis as the literally last event). Past
|
||||
the full grace, "the user phase is still running" is no longer a possible explanation, so
|
||||
desktop + zero `app_install_failed` + no explicit failure now reconciles to Succeeded
|
||||
("completed (assumed)") instead of a red Incomplete. Sessions without a desktop (user never
|
||||
began sign-in: battery-drained, powered off, max-lifetime) and sessions with any failed app
|
||||
stay Incomplete — "completed" must not overclaim. Rule 4 remains the IMMEDIATE reconcile
|
||||
(desktop + Hello proof); 5a only fires where waiting is over. Desktop arrival alone is still
|
||||
rejected as a live completion signal.
|
||||
|
||||
## Closed gap: max-lifetime AwaitingUser (2026-08-27)
|
||||
|
||||
After max-lifetime the agent writes `enrollment-complete.marker` and self-destructs, so an
|
||||
AwaitingUser verdict from that path can never heal — it is a 51h countdown to Incomplete.
|
||||
The emergency-break path (rule 3) already terminalizes immediately; max-lifetime still goes
|
||||
through the grace window by design ("user simply absent"). Revisit if customers read
|
||||
AwaitingUser as "still possible".
|
||||
AwaitingUser verdict from that path could never heal — the calibration correction stream
|
||||
confirmed it: all five observed `maxlife:r5_awaiting` episodes expired to Incomplete
|
||||
unhealed, while live-agent `sweep:r5_awaiting` parks upgraded 7 of 26 to Succeeded. Rule 5
|
||||
therefore skips the grace once `HasAgentMaxLifetimeTimeout` is in the stream and decides the
|
||||
terminal fork (5a/5b) immediately; the max-lifetime ingest appends its trigger event to the
|
||||
read stream so the fact is never missed, and late straggler telemetry still heals a terminal
|
||||
verdict via `TryLateTelemetryReconcileAsync`. WhiteGlove rule 1b deliberately keeps its
|
||||
park — sealed/boxed devices do heal (the observed `maxlife:r1b_awaiting` episode resolved to
|
||||
Succeeded).
|
||||
|
||||
# Examples
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -58,6 +66,24 @@ The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantI
|
||||
always sends (explicit intent) and consumes the marker best-effort so the automatic
|
||||
paths never duplicate afterwards.
|
||||
|
||||
## Finding the tenant behind an address
|
||||
|
||||
The provider's delivery log names only the recipient — never the tenant it belonged to. The
|
||||
reverse lookup is therefore part of the tenant console: its search matches `tenantId`,
|
||||
`domainName`, `ContactEmail` **and** the notification address. No other address is searchable
|
||||
(an admin UPN never receives platform mail and would only widen the PII surface of the list).
|
||||
|
||||
The addresses live in a different table than the tenant configs the list is built from, so the
|
||||
console loads them as a map from `GET /api/preview/notification-emails` (GA + read-only Global
|
||||
Reader, `excludeDelegated`) — one cross-partition scan of `RowKey eq 'notification-email'`
|
||||
instead of a point read per tenant. Keys are lowercased on both sides of the join: the address
|
||||
rows are written from the JWT `tid` (lowercase) while a config PartitionKey's casing is not
|
||||
guaranteed. Stored addresses keep the casing the user typed, so matching is case-insensitive.
|
||||
|
||||
The map is **best-effort** in the portal: it powers only the search-by-address, so a failed read
|
||||
degrades that one capability instead of taking the tenant list down. The addresses are not
|
||||
rendered in the list — they are a search key, and stay visible in the tenant editor.
|
||||
|
||||
## Mail transport
|
||||
|
||||
Both transactional mails (welcome, post-offboarding farewell) go through the
|
||||
@@ -89,6 +115,13 @@ properties `Html`, `UpdatedBy`, `UpdatedUtc`), cached 5 minutes, invalidated on
|
||||
|
||||
* The only placeholder is `{{domainName}}`; an empty domain renders as "your organization",
|
||||
matching the built-ins. Subjects are not editable (pinned constants).
|
||||
* The domain is HTML-encoded at the point of substitution (`EmailTemplateService.Render` and the
|
||||
built-in template builders) — the mail goes out under the product's signed sender to a
|
||||
tenant-chosen recipient, so no writer of `DomainName` may ever inject markup into it.
|
||||
`DomainName` itself is tenant identity: seeded once at first login from the UPN domain
|
||||
(`AuthFunction`, only when it passes `TenantConfigValidation.IsValidDomainName` — a strict
|
||||
dotted host name), preserved from the stored row on the full-model config PUT for every
|
||||
caller, and "never writable" in the field-patch flow.
|
||||
* Limit 30 000 characters (Table string property cap is 32K UTF-16 chars).
|
||||
* The send path is fail-soft: a storage error falls back to the built-in body. The admin
|
||||
read path is not — a broken store surfaces as an error instead of silently showing the
|
||||
@@ -118,3 +151,4 @@ properties `Html`, `UpdatedBy`, `UpdatedUtc`), cached 5 minutes, invalidated on
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EmailTemplateService.cs` — override resolution, placeholder rendering, cache
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/EmailTemplatesFunction.cs` — GA endpoints (read / override / reset / test send)
|
||||
* `src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionEmailTemplates.tsx` — portal editor, preview frame, test send
|
||||
* `src/Web/autopilot-monitor-web/app/admin/components/tenantSearch.ts` — the console's match rules (searchable fields, quoted exact mode, lowercased join)
|
||||
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
- classifier
|
||||
- calibration
|
||||
- regression-radar
|
||||
timestamp: 2026-08-23T18:00:00+02:00
|
||||
timestamp: 2026-08-27T20:00:00+02:00
|
||||
---
|
||||
|
||||
# Verdict Calibration (classifier thermometer)
|
||||
@@ -114,15 +114,18 @@ and is wiped with the tenant on offboarding.
|
||||
## Drift radar — `VerdictCalibrationRadar`
|
||||
|
||||
Same statistics as the [rule regression radar](rule-regression-radar.md) (7-day window vs the
|
||||
prior 28 days, ≥ 5 window hits, ≥ 20 window sessions, lift ≥ 2, `MetricsMath.
|
||||
RateIncreaseSeparated`), anchored on yesterday, over every partition with rows (each tenant
|
||||
and `global`):
|
||||
prior 28 days, ≥ 20 window sessions, lift ≥ 2, `MetricsMath.RateIncreaseSeparated`), with
|
||||
stricter floors since the first-month tuning (2026-08-27): **≥ 10 window hits** (verdict
|
||||
shares on single-digit counts are noise — a 3-hit episode survived as such), and a per-path
|
||||
share regression additionally needs **≥ 5 baseline hits** — a path with no established
|
||||
baseline is new (or renamed) vocabulary, not a regression. Anchored on yesterday, over every
|
||||
partition with rows (each tenant and `global`):
|
||||
|
||||
| Kind | Signal | Re-arm |
|
||||
| --- | --- | --- |
|
||||
| `share_regression` | one **backend-decided** path's share of all sessions doubled (`sweep`/`maxlife`/`late`/`retro`/`rule`/`manual`/`ingest`; `agent:*` and `register:*` mirror customer workflow mix — first prod pass fired on `agent:whiteglove_pending` rollout weeks — and `legacy:*` is derived, all excluded) | share < 1.5× baseline, the path stops occurring, or the path is no longer eligible |
|
||||
| `silence_share_regression` | the `sweep:*` + `maxlife:*` share doubled — the backend had to decide more often because the agent went silent (a liveness signal, not a classifier one) | same |
|
||||
| `evidence_gap` | absolute: `r6` (pure fallthrough) decides ≥ 20 % of the window's classifier verdicts, ≥ 20 verdicts | < 15 % or < 20 verdicts |
|
||||
| `share_regression` | one **backend-decided** path's share of all sessions doubled (`sweep`/`maxlife`/`late`/`retro`/`rule`/`manual`/`ingest`; `agent:*` and `register:*` mirror customer workflow mix — first prod pass fired on `agent:whiteglove_pending` rollout weeks — and `legacy:*` is derived, all excluded per-path) | share < 1.5× baseline, the path stops occurring, the path is no longer eligible, or the current numbers fall below the fire floors |
|
||||
| `silence_share_regression` | the `sweep:*` + `maxlife:*` share doubled — the backend had to decide more often because the agent went silent (a liveness signal, not a classifier one). Rule-shaped `legacy:*` paths count into BOTH sums (window and baseline): they were the same backend decisions before the 2026-08-23 instrumentation, and without that continuity the group had a near-zero baseline by construction and fired a lift-124 rollout artifact | share < 1.5× baseline or hits stop |
|
||||
| `evidence_gap` | absolute: `r6` (pure fallthrough) decides ≥ 20 % of the window's classifier verdicts (stamped rule paths plus rule-shaped `legacy:*`, same continuity), ≥ 20 verdicts | < 15 % or < 20 verdicts |
|
||||
|
||||
Episodes are tracker rows `verdictcalibration|{kind}|{path}|{status}` (register = `AddEntity`,
|
||||
409 = already burning, fail-closed; refresh carries `FirstNotifiedAt`; 30-day tracker
|
||||
@@ -140,6 +143,15 @@ Kill switch `VerdictCalibrationRadarDisabled`.
|
||||
re-enrollment within 7 days after Succeeded 8.9 % (lab background), after Failed 24.3 %,
|
||||
after Incomplete 10.6 % — Incomplete behaves like Succeeded, so rules 5/6 are probably too
|
||||
cautious; the matrix now shows this per rule instead of per status.
|
||||
* **First real calibration read (2026-08-27, four days instrumented):** the per-rule and
|
||||
per-tenant split resolved that hand analysis — `legacy:r5_incomplete` re-enrolled at 2.6 %
|
||||
(the `agent:complete_soft` background), spread across tenants, while `legacy:r6`'s 29 %
|
||||
was concentrated in one tenant that disciplinedly redoes failed enrollments (48.5 % there,
|
||||
6.7 % elsewhere — so r6 stays untouched, it correctly flags that tenant). Consequences
|
||||
shipped the same day: rule 5a "completed (assumed)" (see
|
||||
[Silent-Session Classification](silent-session-classification.md)), the max-lifetime grace
|
||||
skip, and the radar floors + legacy group continuity above — the first production month
|
||||
had produced nine standing alerts, every one an instrumentation-rollout artifact.
|
||||
* **Correction stream:** a session swept to `sweep:r5_incomplete`, later completed by the
|
||||
agent → row `agent:complete` with `PriorVerdictPath = sweep:r5_incomplete`; the aggregate
|
||||
increments `OverriddenByLateCompletion` on the `sweep:r5_incomplete / Incomplete` bucket.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Vulnerability Correlation Sources (NVD, KEV, MSRC, EPSS)
|
||||
description: How the software inventory is turned into CVE findings - CPE mapping, the per-CPE NVD cache with its budget and TTLs, KEV/MSRC/EPSS enrichment, the throttle contract that keeps a refused NVD answer out of the cache, and the act/attend/track priority.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs
|
||||
tags:
|
||||
- backend
|
||||
- vulnerability
|
||||
- nvd
|
||||
- epss
|
||||
- kev
|
||||
- msrc
|
||||
- cache
|
||||
timestamp: 2026-08-29T18:00:00+02:00
|
||||
---
|
||||
|
||||
# Vulnerability Correlation Sources
|
||||
|
||||
The Software Inventory & Vulnerability Analyzer (tenant opt-in) ships the installed-software
|
||||
list with the session; the backend turns it into CVE findings entirely from cached public
|
||||
reference data. Four sources, all trustworthy primaries, no aggregators:
|
||||
|
||||
| Source | What it contributes | Fetch shape | Cache |
|
||||
| --- | --- | --- | --- |
|
||||
| NVD 2.0 (NIST) | CVE records per product: CVSS v3.1 (v3.0 fallback) score, severity, **vector**, description, affected version ranges; CPE dictionary for auto-mapping | `cves/2.0?virtualMatchString={cpe}` per product, `cpes/2.0?keywordSearch=` for unmapped titles; optional API key (`AdminConfiguration.NvdApiKey`) | Azure Table `VulnerabilityCache`, one row per CPE (`cve_{cpe}` / `cache_result`), `CveDataJson` chunked; soft TTL 7 d (serve + background refresh), hard 14 d |
|
||||
| CISA KEV | "Exploited in the wild" flag, due date, required action, ransomware use | Whole catalog JSON | In-memory dictionary, 24 h, blob mirror `kev-catalog.json` |
|
||||
| MSRC | Microsoft-side confirmation: document id/title, release date, Microsoft severity / score / vector | CVRF v3.0 index of the last six Patch Tuesdays | In-memory index, blob mirror `msrc-index.json` |
|
||||
| FIRST EPSS | Probability (0..1) that the CVE is exploited within 30 days + percentile + scoring date | `api.first.org/data/v1/epss?cve=a,b,c` - 100 ids per request, unauthenticated | Stored **on the cached CVE** (no separate table): filled when a CPE row is (re)fetched, re-scored for every row by the nightly sync |
|
||||
|
||||
Nothing about a tenant leaves the backend. The one outbound query that carries content is the
|
||||
EPSS lookup, and its payload is the list of public CVE ids held in the shared, tenant-agnostic
|
||||
CPE cache. Customer-facing wording: `autopilotmonitor-docs/trust/data-flows.md` and the
|
||||
terms page attribution block.
|
||||
|
||||
# Schema
|
||||
|
||||
## Cached CVE (`CachedCve`, serialised into `CveDataJson`)
|
||||
|
||||
```
|
||||
CveId, CvssScore, CvssSeverity, CvssVector?, Description (<=500), PublishedDate,
|
||||
EpssScore?, EpssPercentile?, EpssDate?, AffectedVersions[] {Criteria, VersionStart/End Incl/Excl}
|
||||
```
|
||||
|
||||
`JsonOptions` is `WhenWritingNull` + case-insensitive, so rows written before the vector / EPSS
|
||||
properties existed deserialise with those as `null`, and a row is never rewritten just to add
|
||||
nulls. `CveCacheBudget` bounds the row (24 months, or CVSS >= 7 within 60 months; max 100 CVEs
|
||||
per CPE, 50 ranges per CVE) - which is also why one CPE fill is exactly one EPSS request.
|
||||
|
||||
`ProjectNvdResponse` is the single NVD -> `CachedCve` projection; the live fill and the nightly
|
||||
refresh both call it, so both write the same shape. `BuildCveCacheRow(cpe, cves, cachedAt)` is the
|
||||
single row builder; `cachedAt` is the **NVD** freshness stamp, an EPSS-only rewrite passes the
|
||||
existing value through so it does not look like a fresh NVD fetch to the 7-day TTL.
|
||||
|
||||
## Per-finding output (report `findings[].vulnerabilities[]`)
|
||||
|
||||
```
|
||||
cveId, cvssScore, cvssSeverity, cvssVector?, description, publishedDate, source ("NVD" | "NVD+MSRC"),
|
||||
isKev, kevDueDate, kevRequiredAction, knownRansomware,
|
||||
epssScore?, epssPercentile?, epssDate?,
|
||||
priority ("act" | "attend" | "track"),
|
||||
msrc* (when MSRC confirmed), versionMatchConfidence ("low" on wildcard CPE matches)
|
||||
```
|
||||
|
||||
Finding level: `riskLevel` (unchanged CVSS+KEV bands), `priority` (highest band), `maxEpssScore?`.
|
||||
`scan_summary` additionally carries `act_cves` / `attend_cves`. `MergeReports` (Part 1 + Part 2)
|
||||
recomputes all of these. The `CveIndex` row (one per tenant/CVE/session) carries `EpssScore`
|
||||
(max over occurrences, absent when unscored) and `Priority` (highest), which the fleet summary
|
||||
(`TopCve.epssScore` / `priority`) reads.
|
||||
|
||||
## Priority (`CvePriority.Compute`)
|
||||
|
||||
* `act` - in CISA KEV.
|
||||
* `attend` - EPSS >= 0.1 **or** CVSS >= 9.0.
|
||||
* `track` - everything else.
|
||||
|
||||
Three visible rules instead of a weighted composite: severity (`riskLevel`) keeps saying how bad,
|
||||
priority says how soon. A CVE without an EPSS score is "unknown likelihood" - it can still reach
|
||||
`attend` via CVSS, never via a defaulted 0. The thresholds are constants; changing them is a
|
||||
product decision, not a tuning knob.
|
||||
|
||||
# Throttle contract (NVD)
|
||||
|
||||
The typed `HttpClient` runs under the `ExternalDataApi` Polly policy: 429/5xx/timeouts are retried
|
||||
three times (1/2/4 s + jitter) with a circuit breaker. A 429 that reaches `NvdApiClient` therefore
|
||||
means NVD is refusing us for real. Two things then happen, and one deliberately does not:
|
||||
|
||||
1. `NvdFetchResult<T>` reports `Succeeded = false`, `Throttled = true`. `Data` is an empty object
|
||||
so enumerating callers keep working, but **nothing that persists may treat it as an answer**:
|
||||
the CPE cache fill returns null and writes no row; the negative auto-resolve cache is not
|
||||
touched; the admin auto-resolve reports `nvd_throttled`. Before this contract an empty response
|
||||
was cached as "0 CVEs" for seven days - a product NVD happened to throttle disappeared from
|
||||
every report for a week.
|
||||
2. A process-wide cooldown opens: `Retry-After` when present (delta or date, capped at 120 s),
|
||||
otherwise 30 s. During the cooldown every NVD call fails fast with `Throttled` - no wait, no
|
||||
retry chain. Worst case added latency per cooldown window is one Polly chain (~7.5 s), never
|
||||
a per-item stall; the nightly refresh breaks out of its loop on the first throttled answer and
|
||||
reports the remainder as `cveSkippedThrottled`.
|
||||
3. Not done on purpose: no in-process retry beyond Polly, no VulnCheck-style mirror, no separate
|
||||
"throttled" cache state. Hard-expired cache rows keep serving while NVD is unavailable
|
||||
(better than nothing, logged); a product with no row at all is reported without CVE data for
|
||||
that run and gets a real attempt on the next correlation.
|
||||
|
||||
# Nightly sync (`VulnerabilityDataSync`, 03:00 UTC) and the manual buttons
|
||||
|
||||
1. KEV catalog refresh (blob mirror updated).
|
||||
2. MSRC index refresh (last six Patch Tuesdays).
|
||||
3. NVD refresh of cache rows older than 7 days (sequential, breaks on throttle).
|
||||
4. **EPSS re-score of every `cve_*` row** - all ids batched through FIRST, only rows whose score /
|
||||
percentile / date changed are rewritten (with their original `CachedAt`). A missing id in the
|
||||
response keeps yesterday's score: a failed batch never erases data.
|
||||
|
||||
Steps 3 and 4 live in `VulnerabilityCacheRefreshService` (singleton, one in-flight slot per walk),
|
||||
which the timer and the Global-Admin buttons share, so a manual run writes exactly what the nightly
|
||||
run writes. Four endpoints, one per source, each with its own "Last … Sync" line on the admin page
|
||||
(`GET vulnerability/sync-status`):
|
||||
|
||||
| Button | Endpoint | Shape | Timestamp |
|
||||
| --- | --- | --- | --- |
|
||||
| Sync KEV | `POST vulnerability/sync` | synchronous | `VulnerabilityDataLastSyncUtc` |
|
||||
| Sync MSRC | `POST vulnerability/sync-msrc` | synchronous | `MsrcLastSyncUtc` |
|
||||
| Refresh NVD Cache | `POST vulnerability/sync-nvd` | **202 + background** (minutes, NVD-rate-limited); 409 while running; the page polls `nvdRefreshRunning` / `nvdLastRun` every 10 s | `NvdCacheLastRefreshUtc` - stamped only for a walk that reached every stale row (no throttle cut-off) |
|
||||
| Sync EPSS | `POST vulnerability/sync-epss` | synchronous (seconds); 409 while running | `EpssLastSyncUtc` |
|
||||
|
||||
The timer skips a walk whose slot a manual run currently holds (`cveSkippedBusy` in telemetry)
|
||||
rather than queueing a duplicate. `nvdLastRun` / `epssLastRun` are in-memory on the answering
|
||||
instance (null after a cold start); the persisted timestamps are the durable signal.
|
||||
|
||||
Telemetry event `VulnerabilityDataSyncCompleted` carries `epssRows / epssCves / epssScored /
|
||||
epssRowsRewritten / epssError` next to the KEV / MSRC / CVE counters; the manual EPSS run emits
|
||||
`ManualEpssSyncCompleted`.
|
||||
|
||||
# Examples
|
||||
|
||||
* Chrome 126 finding: `CVE-2024-4947` CVSS 8.8 HIGH, `AV:N/AC:L/PR:N/UI:R`, KEV yes -> `priority =
|
||||
act`, riskLevel `critical` (KEV) - unchanged severity semantics, priority now explicit.
|
||||
* A 5.5 MEDIUM local-privilege CVE with EPSS 0.31 -> `attend`; the same CVE with EPSS 0.004 ->
|
||||
`track`. Same severity band, different order of work.
|
||||
* Nightly sync while NVD throttles: `[VulnSync] NVD throttled - leaving 37 stale entries for the
|
||||
next run`, then `[VulnSync] EPSS refresh: 1412/1450 CVEs scored across 212 rows, 180 rows
|
||||
rewritten` - EPSS is independent of NVD's mood.
|
||||
|
||||
# Not done (recorded)
|
||||
|
||||
* **ExploitDB CSV** as a "public exploit exists" flag (static file, daily, same shape as the KEV
|
||||
mirror) - useful, cheap, deliberately not added: four primary sources are enough for now and
|
||||
each additional outbound dependency is a trust-page line. Revisit if customers ask "is there a
|
||||
public exploit?".
|
||||
* GitHub / Nuclei PoC search, composite 0-100 risk scores, CWE -> ATT&CK mapping - noise for an
|
||||
Intune admin, not adopted.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/NvdApiClient.cs` - `NvdFetchResult`, cooldown, `ComputeCooldown`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/EpssApiClient.cs` - batching, `ParseResponse`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CvePriority.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs` - `ProjectNvdResponse`, `ApplyEpssScores`, `BuildCveCacheRow`, `StampFindingPriority`, `FetchAndCacheCvesFromNvdAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs` - `RefreshStaleCveCacheAsync`, `RefreshEpssScoresAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs` - `BuildCveIndexEntities`, `ScanCveIndexAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/EpssAndNvdThrottleTests.cs`
|
||||
* FIRST EPSS API: https://api.first.org/epss/ - CISA KEV: https://www.cisa.gov/known-exploited-vulnerabilities-catalog
|
||||
+9
-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.
|
||||
@@ -59,6 +60,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
|
||||
* [Verdict Calibration](backend/verdict-calibration.md) - How every session status write declares its VerdictPath (origin:detail vocabulary incl. classifier rule ids), how overriding a verdict preserves PriorStatus/PriorVerdictPath as the correction stream, the daily per-path calibration aggregate with the 7-day re-enrollment proxy and read-side legacy derivation, the GA endpoint / MCP tool / admin matrix, and the drift radar (share, silence-share, r6 evidence gap) with its operator-only ops events.
|
||||
* [App Version Duration Regression Radar](backend/app-version-regression-radar.md) - How app versions whose median install duration regressed vs. the previous version are detected (first-seen-ordered versions, median lift ≥2× plus a 5-minute absolute floor over measured installs only), how alert episodes live in the appversionregression tracker keyspace with drain/median re-arm semantics, and the bell/ops-event/analytics surfaces.
|
||||
* [Session Owner Binding](backend/session-owner-binding.md) - Agent endpoints authenticate per tenant but write per caller-supplied session id; each Sessions row is now bound to the device identity that created it (cert thumbprint + Intune device id from the CN, or bootstrap code + serial), the rule matrix that keeps restart / WhiteGlove Part 2 / bootstrap→cert handoff / cert re-issue working, the shadow telemetry (`SessionOwnerBinding` request dimension, `AgentSessionOwnerBinding` warning, `SessionOwnerMismatch` ops event), and the enforcement contract where a 403 `session_owner_mismatch` makes the agent rotate its session id instead of counting an auth failure.
|
||||
* [Plan Downgrade Policy & the Retention Grace Period](backend/plan-downgrade-retention-grace.md) - What happens when a tenant loses Pro: every entitlement gates read-time and reversibly ("gate, don't destroy"), except retention — a 30-day grace keeps the Pro cap before the Community cap starts hard-deleting; the ProDowngradedUtc/trial-expiry anchor model, the TenantPlanDowngraded/RetentionGrace* ops events, and the deliberate non-grace of everything else.
|
||||
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
|
||||
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
|
||||
@@ -68,6 +70,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
|
||||
# MCP Server
|
||||
|
||||
* [MCP Protocol Eras & Client ID Metadata Documents](mcp/protocol-eras-and-cimd.md) - One per-request factory serving the 2026-07-28 stateless revision (server/discover, ttlMs/cacheScope, Mcp-Method routing) and 2025-era initialize clients side by side, why the legacy leg stays a hand-wired JSON+gzip transport, the never-public cache-hint contract, and the CIMD client-registration path (SSRF-gated document fetch, same redirect allowlist) next to the deprecated dynamic registration.
|
||||
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
|
||||
* [MCP Tool Telemetry](mcp/tool-telemetry.md) - The three telemetry layers for judging tool quality: backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
|
||||
|
||||
@@ -78,7 +81,10 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
# Security & Identity
|
||||
|
||||
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
|
||||
* [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, and the shadow-then-enforce rollout with its volume-controlled telemetry.
|
||||
* [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, 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
|
||||
|
||||
|
||||
+89
@@ -1,5 +1,94 @@
|
||||
# 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.
|
||||
|
||||
## 2026-08-29 (9)
|
||||
|
||||
* **Update**: `agent/decision-engine.md`, `agent/overview.md` — `SignalIngress.Post` is worker-thread re-entrancy-safe: signals the worker posts to itself from inside `ApplyStep` (EffectRunner `ClassifierVerdictIssued`) go to a worker-local follow-up queue drained ahead of the bounded channel instead of blocking on it, so a full channel can no longer deadlock the single consumer.
|
||||
|
||||
## 2026-08-29 (8)
|
||||
|
||||
* **Creation**: Added `backend/session-owner-binding.md` — SESSION-OWNER-BINDING stage 1 (shadow): Sessions rows carry the creating device identity (`OwnerKind/OwnerThumbprint/OwnerDeviceId/OwnerBootstrapCode/OwnerSerial/OwnerBoundAt`, primary-only), `SessionOwnershipPolicy` evaluates every register / telemetry / error-report request against it, outcomes ride on the request row (`SessionOwnerBinding`), warnings and a throttled `SessionOwnerMismatch` ops event; nothing is refused yet. Agent ships the dormant counterpart: `errorCode` parsing on 401/403, one-shot session rotation on `session_owner_mismatch` without feeding the auth-failure tracker, spool drop, `agent_started.sessionRotated`.
|
||||
|
||||
## 2026-08-29 (7)
|
||||
|
||||
* **Update**: `backend/client-cert-tenant-binding.md` — new "Certificate transport and proof of possession" section: `X-ARR-ClientCert` (platform-set after the mTLS handshake, inbound copies stripped even on excluded paths) is the only certificate source; the client-writable `X-Client-Certificate` fallback is gone; `/api/bootstrap/*` routes are token-only and fail closed on a missing/empty token or unwired token service instead of falling through to the certificate stage.
|
||||
|
||||
## 2026-08-29 (6)
|
||||
|
||||
* **Update**: `backend/admin-identity-binding.md` — grants are self-maintaining: `AdminIdentityResolver` derives the home tenant + object id from sign-in history (`UserActivity`), falls back to the UPN domain of an onboarded tenant, refuses to guess when a UPN was seen under two tenants; 422 `HomeTenantUnresolved` + portal tenant picker only for a never-seen domain. Identity fields and pills removed from the delegated / tenant-group forms; inspect/correct lives in Tenant Management → Edit Tenant Configuration → Identity bindings (collapsed).
|
||||
|
||||
## 2026-08-29 (5)
|
||||
|
||||
* **Creation**: Added `backend/admin-identity-binding.md` — cross-tenant roles (GlobalAdmin / GlobalReader, delegated MSP) were resolved from the UPN string alone while the API accepts tokens from every Entra tenant. New `AdminIdentityBindings` table (RK = UPN → home `TenantId` + `ObjectId`), `AdminIdentity` (upn + tid + oid) as the key both role services resolve on, binding checked after the row and before the Pro gate, object id pinned on the first sign-in from the bound tenant (ETag-guarded), grants require `homeTenantId` and refuse to re-home (409), `global/identity-bindings` GET/PUT/DELETE as the audited rebind path, `RequestContext.ObjectId`, throttle bucket keyed on oid. Legacy rows are inert until bound — seed bindings before the backend deploy.
|
||||
|
||||
## 2026-08-29 (4)
|
||||
|
||||
* **Creation**: Added `backend/ime-msi-archive.md` — the version-verified, multi-host IME installer archive: MsiProductVersionReader (managed CFB/MSI Property reader), candidate host walk, Failed:VersionMismatch, sighting re-queue with 24 h backoff, and the 2026-08-29 1.105.103.0 poisoning that motivated it.
|
||||
|
||||
## 2026-08-29 (3)
|
||||
|
||||
* **Update**: `backend/vulnerability-correlation-sources.md` - the admin page's Data Sync Status is complete: next to Sync KEV / Sync MSRC there are now Refresh NVD Cache and Sync EPSS, each with its own last-sync line (`NvdCacheLastRefreshUtc`, `EpssLastSyncUtc` on AdminConfiguration; shared manifest regenerated). The NVD and EPSS walks moved out of the timer into `VulnerabilityCacheRefreshService` (singleton, one in-flight slot per walk) so the button and the nightly run are literally the same code; the NVD walk is minutes long and rate-limited, so `POST vulnerability/sync-nvd` answers 202 and the page polls `sync-status` (`nvdRefreshRunning`, `nvdLastRun`) while it runs, `sync-epss` runs synchronously. 409 while a walk is in flight; the timer skips a slot a manual run holds. Operator-only surface - no customer docs.
|
||||
|
||||
## 2026-08-29 (2)
|
||||
|
||||
* **New**: `backend/vulnerability-correlation-sources.md` - FIRST EPSS joins NVD / CISA KEV / MSRC as the fourth vulnerability source: the probability + percentile + date live on the cached CVE (one batch of <= 100 ids per CPE fill, every `cve_*` row re-scored by the nightly sync, only changed rows rewritten and with their original `CachedAt`), the CVSS vector is persisted instead of dropped, and each CVE carries an act / attend / track priority (KEV; EPSS >= 0.1 or CVSS >= 9.0; rest) next to the unchanged severity bands - two visible signals, no composite score. The comparison with an open-source CVE MCP server surfaced the real 429 defect: Polly already retried, but a refused NVD answer came back as an empty response and was cached as "0 CVEs" for seven days. `NvdFetchResult` now separates "answered" from "refused" (nothing persists a refusal - CPE cache, negative auto-resolve cache, admin auto-resolve), and a 429 opens a process-wide cooldown (`Retry-After` capped at 120 s, else 30 s) so the run fails fast instead of paying a retry chain per software item; hard-expired rows keep serving meanwhile. NVD -> cache projection and the row builder are now single functions shared by the live fill and the sync. CveIndex / fleet summary carry `epssScore` + `priority`; portal shows EPSS pill, priority pill, vector; MCP tool descriptions and the CVE-audit prompt name the fields. ExploitDB recorded as a deliberate not-now. Backend before web; trust pages (terms attribution, privacy, data-flows), settings and portal-guide docs updated in the same change.
|
||||
|
||||
## 2026-08-29
|
||||
|
||||
* **New**: `mcp/protocol-eras-and-cimd.md` - the MCP server moved to the 2026-07-28 protocol revision (TypeScript SDK v2: `@modelcontextprotocol/server` + `/node`, zod 4; the monolithic `@modelcontextprotocol/sdk` is gone). One per-request factory (`mcp-server-factory.ts`) feeds both eras through `mcp-http.ts`: 2026 clients are served by the SDK entry (`createMcpHandler`, `legacy: 'reject'`, JSON response mode), 2025 clients keep the existing stateless transport with `enableJsonResponse` - routed on the SDK's own `isLegacyRequest` predicate, because the built-in stateless fallback would answer today's Claude/VS Code clients with SSE and lose the gzip win. Capabilities now declare `listChanged: false` (the SDK default advertised a notification a per-request server cannot send); `cacheHints` stamp `ttlMs`/`cacheScope: 'private'` on discover/list results (5 min) and resource reads (60 min) - never public, the catalog is role-dependent. Client registration: `client_id_metadata_document_supported: true`; an https `client_id` is a Client ID Metadata Document resolved by `cimd.ts` (SSRF gate on the resolved addresses before any request, no redirects, 5 s / 16 KB, exact `client_id` match, bounded `redirect_uris`, 10-60 min cache), the requested redirect_uri still has to pass the dynamic-registration allowlist; HMAC-signed DCR client_ids stay as the fallback. `mcp-oauth-flow.md` updated. Package version 1.5 -> 1.6. Pinned by `mcp-http.test.ts` (real v2 client over loopback HTTP, both eras, role parity), `cimd.test.ts`, `oauth-cimd.test.ts`.
|
||||
|
||||
## 2026-08-28
|
||||
|
||||
* **Update**: `CmTraceSkewTripwire` - the backend regression tripwire additionally requires >= 80 % of the individual IME deltas (relative to the other side's median) to sit within 2 min of some 15-minute grid multiple before `CmTraceTimeSkewRegression` fires; the ops event and its details now carry that fraction. Motivation: the first soak week produced 7 fires, all backlog replays - a relaunched agent re-tailing the IME log uploads one burst holding most of the session's IME samples with a continuum of ages whose median hit the grid by chance (session d832560a: 508 of 627 samples, 46...733 min, median 91). Era-mixing regressions (every line on the grid) still fire. Doc: `docs/agent/cmtrace-time-resolution.md`.
|
||||
|
||||
## 2026-08-27 (2)
|
||||
|
||||
* **Update**: `SecurityValidator` / `DeviceAssociationValidator` - Microsoft released Windows Autopilot device association on 2026-08-25 (learn.microsoft.com/autopilot/device-preparation/device-association/overview: TPM-backed UEFI marker, device-targeted device preparation policies, associated devices are marked corporate-owned by Intune with no corporate identifier upload). The DevPrep association lookup therefore leaves shadow mode and becomes the third accepting method of the device-validation gate, evaluated inline after the Autopilot and Corporate Identifier lookups and before the Cloud PC fallback; a match sets `ValidatedBy = DeviceAssociation`, a miss joins the combined 403 detail, a transient failure the 503 Retry-After - the same contract as its siblings, so enabling it can only widen acceptance, never reject a device the other methods would have taken. `ValidateDeviceAssociation` counts toward "at least one validation enabled" and is no longer GA-only anywhere (`UpdateTenantConfigurationFunction` revert, `TenantConfigPatchService.GaOnlyFields`, the Global-Admin gate on the settings toggle); the toggle sits under Device Preparation next to Corporate Identifier and follows the shared consent rule (first serial-based validation carries the consent, any further one is a plain persisted toggle). Known gap carried over from the shadow phase: the `/beta/deviceManagement/tenantAssociatedDevices` endpoint is still undocumented in Graph reference and every production lookup so far answered `matched=False` for Autopilot v1 devices, i.e. the positive match path is unproven in the field - first associated enrollment must be checked for `ValidatedBy = DeviceAssociation` on the session row. Landing hero carries a release pill linking to the customer docs; dashboard tip added. Backend before web; customer docs updated in `autopilotmonitor-docs`.
|
||||
|
||||
## 2026-08-27
|
||||
|
||||
* **Update**: `backend/silent-session-classification.md`, `backend/verdict-calibration.md` - the first real calibration read tuned what it measures. Rule 5 grew a past-grace fork 5a: Device Setup done + real-user desktop + zero `app_install_failed` reconciles to Succeeded "completed (assumed)" (`r5_assumed`) - r5-Incomplete devices re-enrolled at 2.6 %, the succeeded background, and the sampled desktop half had clean app records; no desktop or any failed app stays Incomplete. The max-lifetime watchdog (either event shape) now skips the AwaitingUser grace entirely - all five observed maxlife parks expired unhealed, and late telemetry heals a terminal verdict anyway - while WhiteGlove rule 1b keeps its park (its one episode healed). The drift radar got floors (≥ 10 window hits; per-path share regression needs ≥ 5 baseline hits, so new/renamed vocabulary cannot "regress") and legacy continuity: rule-shaped `legacy:*` paths count into the silence and classifier-verdict group sums on both sides of the 2026-08-23 cutover, which dissolves the lift-124 silence artifact and lets sub-floor episodes clear themselves. Backend only; the new path shows up in the matrix on its own.
|
||||
|
||||
* **Update**: `backend/tenant-activation-welcome-mail.md` - the reverse lookup. A delivery log names only the recipient, so an address had to lead back to its tenant: the tenant console's search now matches the notification and contact address as well as domain and tenant id, with the quoted exact mode unchanged. The addresses sit in `PreviewWhitelist`, not on the tenant config, so a new GA/Reader-only `GET /api/preview/notification-emails` returns them as one map (single cross-partition scan of `RowKey eq 'notification-email'`) instead of a point read per tenant; both sides join on the lowercased tenant id because the address rows carry the JWT `tid` while a config PartitionKey's casing is not guaranteed, and matching is case-insensitive because stored addresses keep the casing the user typed. The map is best-effort in the portal - a failed read costs only the search-by-address, never the tenant list - and the addresses stay out of the list rows (search key, not a column). Match rules moved into `tenantSearch.ts` with unit pins. Backend before web.
|
||||
|
||||
|
||||
## 2026-08-26
|
||||
|
||||
* **Update**: `agent/wifi-ssid-location-gate.md` - the network timeline now explains its blank signal too, with no agent change: `buildSegments` already merges `wifi_signal_info` into the covering WiFi segment, so `wifiDataLimitedReason` rides in with the sample. Segments that never got a sample (a mid-session reconnect emits none) inherit it - the gate is a device-level setting and cannot flip mid-enrollment - while a segment with a real reading is never overwritten. Tooltip shows `Signal: unavailable - Location services off on this device`; the "Avg WiFi signal" stat reads `n/a (Location services off)` instead of vanishing. `network_state_change` is deliberately left alone: it has never carried `signalPercent` or `radioType` (only `before_/after_wifiSsid` reaches its payload), so there is no absent field there for a reason to explain, and the SSID it does carry is unaffected by the gate. Web-only change.
|
||||
|
||||
+11
-7
@@ -8,7 +8,7 @@ tags:
|
||||
- oauth
|
||||
- entra-id
|
||||
- security
|
||||
timestamp: 2026-07-11T10:59:31+02:00
|
||||
timestamp: 2026-08-29T08:45:00+02:00
|
||||
---
|
||||
|
||||
# MCP OAuth Flow — Who Authenticates Where
|
||||
@@ -41,9 +41,12 @@ The two identities never mix:
|
||||
in the vendor's backend, not on the user's machine.
|
||||
2. **MCP server** (Container App) — acts as a stateless **OAuth proxy /
|
||||
authorization-server façade** in front of Entra ID. It is *not* an IdP:
|
||||
it registers clients dynamically (RFC 7591, HMAC-signed stateless
|
||||
`client_id`), enforces the redirect-URI allowlist and PKCE, and exchanges
|
||||
codes at Entra using its confidential client secret.
|
||||
it establishes the client's identity statelessly — a Client ID Metadata
|
||||
Document (MCP spec 2026-07-28: the `client_id` is an HTTPS URL the server
|
||||
fetches and validates) or, for older clients, dynamic registration
|
||||
(RFC 7591, HMAC-signed stateless `client_id`) — enforces the redirect-URI
|
||||
allowlist and PKCE, and exchanges codes at Entra using its confidential
|
||||
client secret. See [Protocol Eras & CIMD](mcp/protocol-eras-and-cimd.md).
|
||||
3. **Entra ID** — the real identity provider. This is where `adm.luke` lives.
|
||||
|
||||
## Full sequence
|
||||
@@ -54,9 +57,10 @@ Claude Desktop Browser (user's MCP server Entra ID
|
||||
│ │ │ │
|
||||
1. │─ POST /mcp ──────────┼───────────────────────▶│ 401 + WWW-Auth │
|
||||
2. │─ Discovery ──────────┼───────────────────────▶│ RFC 9728/8414 docs │
|
||||
3. │─ POST /oauth/register┼───────────────────────▶│ client_id (HMAC- │
|
||||
│ │ │ signed, embeds the │
|
||||
│ │ │ registered callback) │
|
||||
3. │─ client identity ────┼───────────────────────▶│ CIMD: client_id is a │
|
||||
│ (CIMD, or legacy │ │ URL, doc fetched at │
|
||||
│ POST /oauth/ │ │ /authorize; DCR: │
|
||||
│ register) │ │ HMAC-signed client_id│
|
||||
│ │ │ │
|
||||
4. │─ opens browser ─────▶│─ GET /oauth/authorize ▶│─ 302 to Entra ──────▶│
|
||||
│ │ (PKCE challenge) │ app-reg client_id, │
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
type: Concept
|
||||
title: MCP Protocol Eras & Client ID Metadata Documents
|
||||
description: How the MCP server serves the 2026-07-28 stateless revision and 2025-era clients from one per-request factory, why the legacy leg is hand-wired (JSON + gzip), the cache-hint and capability contract, and the CIMD client-registration path next to the deprecated dynamic registration.
|
||||
resource: /src/McpServer/autopilot-monitor-mcp/src/mcp-http.ts
|
||||
tags:
|
||||
- mcp
|
||||
- protocol
|
||||
- oauth
|
||||
- cimd
|
||||
- security
|
||||
timestamp: 2026-08-29T08:40:00+02:00
|
||||
---
|
||||
|
||||
# MCP Protocol Eras & Client ID Metadata Documents
|
||||
|
||||
**Status:** Concept · **Owner:** MCP Server · **Code:** [`mcp-http.ts`](../../src/McpServer/autopilot-monitor-mcp/src/mcp-http.ts), [`mcp-server-factory.ts`](../../src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts), [`cimd.ts`](../../src/McpServer/autopilot-monitor-mcp/src/cimd.ts), [`oauth.ts`](../../src/McpServer/autopilot-monitor-mcp/src/oauth.ts)
|
||||
|
||||
# Schema
|
||||
|
||||
## The two eras
|
||||
|
||||
MCP revision **2026-07-28** turned the protocol into a stateless request/response
|
||||
protocol: no `initialize` handshake, no `Mcp-Session-Id`, every request carries its
|
||||
protocol version, client identity and capabilities in `_meta`, capabilities and
|
||||
instructions are fetched with `server/discover`, list results carry `ttlMs` /
|
||||
`cacheScope`, and `Mcp-Method` / `Mcp-Name` HTTP headers let gateways route without
|
||||
parsing bodies. Roots, Sampling, Logging and the HTTP+SSE transport are deprecated
|
||||
(twelve-month window); Dynamic Client Registration is deprecated in favour of Client
|
||||
ID Metadata Documents.
|
||||
|
||||
The server was already stateless by design (scale-to-zero Container App, no
|
||||
server→client traffic), so the revision cost no architecture — only wiring:
|
||||
|
||||
| Concern | 2026-07-28 ("modern") | 2025-era ("legacy") |
|
||||
| --- | --- | --- |
|
||||
| Routing decision | `isLegacyRequest()` from the SDK — the exact classifier `createMcpHandler` uses internally | same predicate, inverse |
|
||||
| Serving | `createMcpHandler(factory, { legacy: 'reject', responseMode: 'json' })` via `toNodeHandler` | fresh `NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })` per POST |
|
||||
| Response shape | single `application/json` body | single `application/json` body |
|
||||
| Instructions | `server/discover` result | `initialize` result |
|
||||
| Cache fields | `ttlMs` + `cacheScope` stamped by the SDK from `cacheHints` | none (2025 codec has no cache path) |
|
||||
|
||||
Both legs are fed by **one** factory (`createServerForCaller`), so the tool catalog,
|
||||
resource set, prompts and instructions can never drift between eras — the
|
||||
`mcp-http.test.ts` suite pins that a Global Admin and a plain tenant user see the
|
||||
same catalog on either path.
|
||||
|
||||
## Why the legacy leg is hand-wired
|
||||
|
||||
`createMcpHandler`'s built-in `legacy: 'stateless'` fallback constructs its transport
|
||||
with only `sessionIdGenerator: undefined`, i.e. it answers every 2025 client with an
|
||||
SSE frame. Every production client today (Claude Code, Claude.ai, VS Code) is a 2025
|
||||
client, and their large tool results are gzipped by the compression middleware only
|
||||
because the response is a plain JSON body (measured 5–30×). Routing on the SDK's own
|
||||
predicate in user land keeps that behaviour while still running the 2026 path through
|
||||
the SDK entry unchanged — no re-implementation of the classification, no second
|
||||
catalog.
|
||||
|
||||
## Capability and cache contract
|
||||
|
||||
* `capabilities` are declared explicitly with `listChanged: false` for tools,
|
||||
resources and prompts. The SDK's default (`?? true`) would advertise a
|
||||
notification a per-request server can never emit.
|
||||
* `cacheHints`: `server/discover`, `tools/list`, `prompts/list`, `resources/list`,
|
||||
`resources/templates/list` → `{ ttlMs: 300_000, cacheScope: 'private' }`;
|
||||
`resources/read` → `{ ttlMs: 3_600_000, cacheScope: 'private' }`. **Never
|
||||
`public`**: the catalog and the instructions are role-dependent (Global Admin /
|
||||
Global Reader / delegated MSP / tenant user), so a shared cache would leak one
|
||||
caller's surface to another. Five minutes is the bound on how long a role change
|
||||
can go unnoticed by a caching client; resources are static per deployment.
|
||||
* No Roots / Sampling / Logging / elicitation / subscriptions are used anywhere, so
|
||||
nothing on the deprecation list has to be migrated.
|
||||
|
||||
## Client registration: CIMD first, DCR as fallback
|
||||
|
||||
The authorization-server metadata now advertises
|
||||
`client_id_metadata_document_supported: true` next to the (deprecated)
|
||||
`registration_endpoint`. Clients following the spec's priority order use a
|
||||
**Client ID Metadata Document**: their `client_id` *is* an HTTPS URL that serves a
|
||||
JSON document (`client_id`, `client_name`, `redirect_uris`, …). `oauth.ts` resolves
|
||||
every `client_id` through one helper: an HTTPS URL goes to `cimd.ts`, anything else
|
||||
must be one of our HMAC-signed dynamic-registration tokens. Both yield the same shape
|
||||
(`redirectUris`), and the rest of the authorize/callback logic is unchanged.
|
||||
|
||||
What the document decides — and what it does not:
|
||||
|
||||
* It only asserts **which** redirect URIs the client claims. The requested
|
||||
`redirect_uri` must still pass the host/path allowlist that gates dynamic
|
||||
registration (`isAllowedRedirectUri`), checked *before* the document is fetched. A
|
||||
self-hosted document cannot widen the destinations an authorization code may be
|
||||
sent to. Loopback remains allowed for every client (RFC 8252 §7.3);
|
||||
`application_type: "native"` is informational.
|
||||
* Fetching a caller-chosen URL is an SSRF surface, so the fetch is: HTTPS only, path
|
||||
component required, no fragment/userinfo, no loopback or IP-literal host, every
|
||||
resolved address must be public (RFC 1918/4193/3927/6598, CGNAT, multicast,
|
||||
IPv4-mapped all refused — checked before any request leaves), `redirect: 'error'`,
|
||||
5 s budget, 16 KB body cap (declared *and* streamed), JSON media type required.
|
||||
Nothing from the response reaches the caller beyond an OAuth error code.
|
||||
* The document's `client_id` must equal the URL by simple string comparison — no
|
||||
normalization, per the draft. `redirect_uris` is bounded exactly like a dynamic
|
||||
registration (`oauth-limits.ts`). A missing `client_name` falls back to the host:
|
||||
it only ever reaches a log line, and failing a login over a label would be wrong.
|
||||
* Cache: positive entries live for the document's `Cache-Control: max-age`, clamped
|
||||
to 10–60 min so the `/oauth/callback` re-check within the 10-min signed-state
|
||||
window is a cache hit on the same replica; rejections are cached for 60 s; the map
|
||||
is bounded to 256 entries.
|
||||
|
||||
RFC 9207 `iss` (already emitted since 2026-07-29) completes the authorization side
|
||||
of the 2026-07-28 revision.
|
||||
|
||||
# Examples
|
||||
|
||||
* A 2026 client: `POST /mcp` with `_meta["io.modelcontextprotocol/protocolVersion"] = "2026-07-28"`
|
||||
and `Mcp-Method: tools/list` → `{ tools: [...], ttlMs: 300000, cacheScope: "private", _meta: { "io.modelcontextprotocol/serverInfo": { name: "Autopilot-Monitor", version: "1.6.<build>" } } }`.
|
||||
* A 2025 client: `POST /mcp` `initialize` → `application/json` body with
|
||||
`protocolVersion: "2025-11-25"` and the role-tailored `instructions`; no
|
||||
`Mcp-Session-Id` header.
|
||||
* `GET /oauth/authorize?client_id=https://app.example.test/oauth/client.json&redirect_uri=http://127.0.0.1:49152/callback&code_challenge=…` →
|
||||
document fetched (or served from cache) → 302 to Entra. `client_id=https://10.0.0.8/x` → `400 invalid_client`
|
||||
without any outbound request.
|
||||
|
||||
# Citations
|
||||
|
||||
* MCP spec 2026-07-28 — https://blog.modelcontextprotocol.io/posts/2026-07-28/ ; authorization / client registration:
|
||||
https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration
|
||||
* TypeScript SDK v2 (`@modelcontextprotocol/server` 2.0.0) — `createMcpHandler`, `isLegacyRequest`, `cacheHints`.
|
||||
* draft-ietf-oauth-client-id-metadata-document-00 §6 (security considerations, SSRF).
|
||||
* Tests: `src/__tests__/mcp-http.test.ts`, `src/__tests__/cimd.test.ts`, `src/__tests__/oauth-cimd.test.ts`.
|
||||
* Related: [MCP OAuth Flow](../mcp-oauth-flow.md), [MCP Docs Corpus](docs-corpus.md).
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-017",
|
||||
"title": "App Install Blocked by Another Installer (0x80070652)",
|
||||
"description": "Detects Win32 app installs that failed because the Windows Installer was busy with another installation (HRESULT 0x80070652 / MSI exit code 1618, ERROR_INSTALL_ALREADY_RUNNING). The IME retries on a fixed budget (~15-17 minutes across several attempts) and then reports the app as failed; when the app is ESP-blocking, the Device Setup Apps subcategory terminates with it.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "mutex_blocked_install",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "hresultFromWin32",
|
||||
"operator": "equals",
|
||||
"value": "-2147023278",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "installer_exit_1618",
|
||||
"source": "event_data",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "exitCode",
|
||||
"operator": "equals",
|
||||
"value": "1618",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "esp_failed_with_mutex_code",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_failure_settle_started",
|
||||
"dataField": "errorCode",
|
||||
"operator": "equals",
|
||||
"value": "0x80070652",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "installer_exit_1618",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "esp_failed_with_mutex_code",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 65,
|
||||
"explanation": "App **{{appName}}** failed to install because the Windows Installer service was already busy with another installation — HRESULT **0x80070652** / MSI exit code **1618** (`ERROR_INSTALL_ALREADY_RUNNING`). Only one Windows Installer transaction can run at a time; the Intune Management Extension retried on its fixed budget (typically ~15-17 minutes across several attempts) and then reported the app as failed. If this app is required in the Enrollment Status Page, the Device Setup Apps subcategory fails with it and the enrollment terminates.\n\nThis failure is **deterministic per attempt**: as long as the competing installer keeps winning the race, a plain re-enrollment fails again at roughly the same minute mark — retrying without fixing the collision only churns devices.\n\nCommon sources of the competing installation:\n\n- The **preceding Win32 app in the install order**: its installer exits 0 (so IME moves on) but leaves a child `msiexec` transaction or post-install MSI work running. Security agents and proxy clients that chain multiple MSIs are frequent offenders.\n- **OEM factory software** installing on first boot (factory-image provisioning packages, vendor updaters) — typical on brand-new hardware batches.\n- **Microsoft 365 Apps (C2R), driver updaters or Windows servicing** running installations in parallel outside IME's control.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Identify the competing installer",
|
||||
"steps": [
|
||||
"In this session's timeline, note which app finished installing immediately before {{appName}} started — its installer is the prime suspect for holding the Windows Installer mutex",
|
||||
"On a reproduction device, when the failure window is active, check for running installers: Get-Process msiexec | Select-Object Id,StartTime and the in-progress marker key HKLM\\SOFTWARE\\Microsoft\\Windows\\Installer\\InProgress",
|
||||
"Check the Application event log for MsiInstaller events around the failure timestamp to see which product held the transaction",
|
||||
"On factory-fresh hardware, also look for OEM first-boot installers (vendor updater services, factory provisioning packages) that run outside Intune"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Serialize the install chain",
|
||||
"steps": [
|
||||
"Add an Intune Win32 dependency so {{appName}} depends on the app whose installer causes the collision — IME then orders them explicitly instead of racing",
|
||||
"Alternatively, wrap the offending predecessor so its install script waits until msiexec has fully finished (poll the InProgress registry key or the _MSIExecute mutex with a bounded timeout) before exiting",
|
||||
"If the app does not have to gate the ESP, remove it from the blocking-app list so a transient 1618 no longer terminates the enrollment; Intune retries it after the desktop"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: MsiExec.exe error messages (1618 = another installation is in progress)",
|
||||
"url": "https://learn.microsoft.com/en-us/windows/win32/msi/error-codes"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Troubleshoot Win32 app installations in Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"apps",
|
||||
"msiexec",
|
||||
"mutex",
|
||||
"0x80070652",
|
||||
"1618",
|
||||
"esp"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-NET-001",
|
||||
"title": "Diagnostics Upload Blocked by Web Security Gateway",
|
||||
"description": "Detects diagnostics package uploads that were answered by a security appliance's HTML block page instead of the Autopilot Monitor backend. A forward proxy / secure web gateway (SWG) with SSL inspection is intercepting the upload endpoint, so exactly the sessions where diagnostics matter most (OnFailure uploads) are the ones losing them.",
|
||||
"severity": "warning",
|
||||
"category": "network",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "foreign_html_response",
|
||||
"source": "event_data",
|
||||
"eventType": "diagnostics_upload_failed",
|
||||
"dataField": "errorCode",
|
||||
"operator": "contains",
|
||||
"value": "HTML platform error page",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "security_gateway_marker",
|
||||
"source": "event_data",
|
||||
"eventType": "diagnostics_upload_failed",
|
||||
"dataField": "errorCode",
|
||||
"operator": "regex",
|
||||
"value": "mcafee|skyhigh|zscaler|netskope|blue ?coat|proxysg|forcepoint|websense|palo ?alto|fortigate|fortiguard|sophos|watchguard|barracuda|iboss|umbrella|lightspeed|smoothwall|contentkeeper|web ?gateway|web ?filter|content ?filter|secure ?gateway",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "http_403_block",
|
||||
"source": "event_data",
|
||||
"eventType": "diagnostics_upload_failed",
|
||||
"dataField": "errorCode",
|
||||
"operator": "contains",
|
||||
"value": "403",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "http_403_block",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "The agent collected a diagnostics package but the upload was answered with an **HTML block page from a web security gateway** instead of a response from the Autopilot Monitor backend — the event message names the product that served the page. A forward proxy / SWG with SSL inspection is intercepting HTTPS traffic from the enrollment network and blocking the upload endpoint.\n\nThe consequence is quietly painful: diagnostics upload is typically configured **OnFailure**, so the sessions that fail — the ones where the IME logs and diagnostics ZIP would explain the root cause — are exactly the sessions whose evidence never arrives. Enrollment telemetry itself may still flow (the agent often starts before the gateway's client enforcement does, or other endpoints are treated differently), which makes the gap easy to miss until a failure needs investigating.\n\nNote this rule only fires when the blocking page carries a recognizable security-product marker. A plain HTML error page without one usually means a stale or stopped endpoint instead — that is a different problem.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Exempt the upload endpoint in the web gateway",
|
||||
"steps": [
|
||||
"Identify the product from the block-page title quoted in this event's message (e.g. a Skyhigh/McAfee Web Gateway notification page)",
|
||||
"In that gateway, add the Autopilot Monitor API host shown in the event message to the SSL-inspection bypass AND the URL allowlist for the enrollment network segment",
|
||||
"Enrollment traffic often originates from a dedicated provisioning VLAN or from devices without the proxy client yet — make sure the exemption applies to that segment, not only to managed clients",
|
||||
"If the gateway enforces per-user policy, remember ESP-phase traffic runs as SYSTEM/device before any user signs in — the exemption must cover unauthenticated device traffic"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Verify the fix",
|
||||
"steps": [
|
||||
"Re-run an enrollment (or wait for the next failed one) and confirm a diagnostics package appears on the session in the portal",
|
||||
"Optionally set diagnostics upload to Always temporarily to validate the path without needing a failure"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Windows Autopilot networking requirements",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/networking-requirements"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"network",
|
||||
"proxy",
|
||||
"ssl-inspection",
|
||||
"swg",
|
||||
"diagnostics"
|
||||
]
|
||||
}
|
||||
+174
-3
@@ -943,6 +943,97 @@
|
||||
"delivery-gap"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-017",
|
||||
"title": "App Install Blocked by Another Installer (0x80070652)",
|
||||
"description": "Detects Win32 app installs that failed because the Windows Installer was busy with another installation (HRESULT 0x80070652 / MSI exit code 1618, ERROR_INSTALL_ALREADY_RUNNING). The IME retries on a fixed budget (~15-17 minutes across several attempts) and then reports the app as failed; when the app is ESP-blocking, the Device Setup Apps subcategory terminates with it.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "mutex_blocked_install",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "hresultFromWin32",
|
||||
"operator": "equals",
|
||||
"value": "-2147023278",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "installer_exit_1618",
|
||||
"source": "event_data",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "exitCode",
|
||||
"operator": "equals",
|
||||
"value": "1618",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "esp_failed_with_mutex_code",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_failure_settle_started",
|
||||
"dataField": "errorCode",
|
||||
"operator": "equals",
|
||||
"value": "0x80070652",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "installer_exit_1618",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "esp_failed_with_mutex_code",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 65,
|
||||
"explanation": "App **{{appName}}** failed to install because the Windows Installer service was already busy with another installation — HRESULT **0x80070652** / MSI exit code **1618** (`ERROR_INSTALL_ALREADY_RUNNING`). Only one Windows Installer transaction can run at a time; the Intune Management Extension retried on its fixed budget (typically ~15-17 minutes across several attempts) and then reported the app as failed. If this app is required in the Enrollment Status Page, the Device Setup Apps subcategory fails with it and the enrollment terminates.\n\nThis failure is **deterministic per attempt**: as long as the competing installer keeps winning the race, a plain re-enrollment fails again at roughly the same minute mark — retrying without fixing the collision only churns devices.\n\nCommon sources of the competing installation:\n\n- The **preceding Win32 app in the install order**: its installer exits 0 (so IME moves on) but leaves a child `msiexec` transaction or post-install MSI work running. Security agents and proxy clients that chain multiple MSIs are frequent offenders.\n- **OEM factory software** installing on first boot (factory-image provisioning packages, vendor updaters) — typical on brand-new hardware batches.\n- **Microsoft 365 Apps (C2R), driver updaters or Windows servicing** running installations in parallel outside IME's control.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Identify the competing installer",
|
||||
"steps": [
|
||||
"In this session's timeline, note which app finished installing immediately before {{appName}} started — its installer is the prime suspect for holding the Windows Installer mutex",
|
||||
"On a reproduction device, when the failure window is active, check for running installers: Get-Process msiexec | Select-Object Id,StartTime and the in-progress marker key HKLM\\SOFTWARE\\Microsoft\\Windows\\Installer\\InProgress",
|
||||
"Check the Application event log for MsiInstaller events around the failure timestamp to see which product held the transaction",
|
||||
"On factory-fresh hardware, also look for OEM first-boot installers (vendor updater services, factory provisioning packages) that run outside Intune"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Serialize the install chain",
|
||||
"steps": [
|
||||
"Add an Intune Win32 dependency so {{appName}} depends on the app whose installer causes the collision — IME then orders them explicitly instead of racing",
|
||||
"Alternatively, wrap the offending predecessor so its install script waits until msiexec has fully finished (poll the InProgress registry key or the _MSIExecute mutex with a bounded timeout) before exiting",
|
||||
"If the app does not have to gate the ESP, remove it from the blocking-app list so a transient 1618 no longer terminates the enrollment; Intune retries it after the desktop"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: MsiExec.exe error messages (1618 = another installation is in progress)",
|
||||
"url": "https://learn.microsoft.com/en-us/windows/win32/msi/error-codes"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Troubleshoot Win32 app installations in Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"apps",
|
||||
"msiexec",
|
||||
"mutex",
|
||||
"0x80070652",
|
||||
"1618",
|
||||
"esp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-CORR-003",
|
||||
"title": "Proxy Configuration Causing Download Failure",
|
||||
@@ -2675,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": [
|
||||
@@ -2726,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",
|
||||
@@ -2749,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"
|
||||
]
|
||||
}
|
||||
@@ -2964,6 +3055,86 @@
|
||||
"esp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-NET-001",
|
||||
"title": "Diagnostics Upload Blocked by Web Security Gateway",
|
||||
"description": "Detects diagnostics package uploads that were answered by a security appliance's HTML block page instead of the Autopilot Monitor backend. A forward proxy / secure web gateway (SWG) with SSL inspection is intercepting the upload endpoint, so exactly the sessions where diagnostics matter most (OnFailure uploads) are the ones losing them.",
|
||||
"severity": "warning",
|
||||
"category": "network",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "foreign_html_response",
|
||||
"source": "event_data",
|
||||
"eventType": "diagnostics_upload_failed",
|
||||
"dataField": "errorCode",
|
||||
"operator": "contains",
|
||||
"value": "HTML platform error page",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "security_gateway_marker",
|
||||
"source": "event_data",
|
||||
"eventType": "diagnostics_upload_failed",
|
||||
"dataField": "errorCode",
|
||||
"operator": "regex",
|
||||
"value": "mcafee|skyhigh|zscaler|netskope|blue ?coat|proxysg|forcepoint|websense|palo ?alto|fortigate|fortiguard|sophos|watchguard|barracuda|iboss|umbrella|lightspeed|smoothwall|contentkeeper|web ?gateway|web ?filter|content ?filter|secure ?gateway",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "http_403_block",
|
||||
"source": "event_data",
|
||||
"eventType": "diagnostics_upload_failed",
|
||||
"dataField": "errorCode",
|
||||
"operator": "contains",
|
||||
"value": "403",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "http_403_block",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "The agent collected a diagnostics package but the upload was answered with an **HTML block page from a web security gateway** instead of a response from the Autopilot Monitor backend — the event message names the product that served the page. A forward proxy / SWG with SSL inspection is intercepting HTTPS traffic from the enrollment network and blocking the upload endpoint.\n\nThe consequence is quietly painful: diagnostics upload is typically configured **OnFailure**, so the sessions that fail — the ones where the IME logs and diagnostics ZIP would explain the root cause — are exactly the sessions whose evidence never arrives. Enrollment telemetry itself may still flow (the agent often starts before the gateway's client enforcement does, or other endpoints are treated differently), which makes the gap easy to miss until a failure needs investigating.\n\nNote this rule only fires when the blocking page carries a recognizable security-product marker. A plain HTML error page without one usually means a stale or stopped endpoint instead — that is a different problem.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Exempt the upload endpoint in the web gateway",
|
||||
"steps": [
|
||||
"Identify the product from the block-page title quoted in this event's message (e.g. a Skyhigh/McAfee Web Gateway notification page)",
|
||||
"In that gateway, add the Autopilot Monitor API host shown in the event message to the SSL-inspection bypass AND the URL allowlist for the enrollment network segment",
|
||||
"Enrollment traffic often originates from a dedicated provisioning VLAN or from devices without the proxy client yet — make sure the exemption applies to that segment, not only to managed clients",
|
||||
"If the gateway enforces per-user policy, remember ESP-phase traffic runs as SYSTEM/device before any user signs in — the exemption must cover unauthenticated device traffic"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Verify the fix",
|
||||
"steps": [
|
||||
"Re-run an enrollment (or wait for the next failed one) and confirm a diagnostics package appears on the session in the portal",
|
||||
"Optionally set diagnostics upload to Always temporarily to validate the path without needing a failure"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Windows Autopilot networking requirements",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/networking-requirements"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"network",
|
||||
"proxy",
|
||||
"ssl-inspection",
|
||||
"swg",
|
||||
"diagnostics"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-OFFICE-001",
|
||||
"title": "Microsoft 365 Apps Install Failed",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+102
-3
@@ -527,7 +527,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
{
|
||||
// On-demand mid-session (no outcome yet): the OnFailure gate must NOT skip —
|
||||
// there is no known success to skip on. Reaching the archive build (sentinel
|
||||
// throw → error result) proves the gate was passed; a skip would return null.
|
||||
// throw → error result) proves the gate was passed; a skip would return Skipped.
|
||||
using var rig = new Rig();
|
||||
var result = await BuildGateProbe(rig.Logger, "OnFailure")
|
||||
.CreateAndUploadAsync(enrollmentSucceeded: null, fileNameSuffix: "server-requested");
|
||||
@@ -543,7 +543,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
var result = await BuildGateProbe(rig.Logger, "OnFailure")
|
||||
.CreateAndUploadAsync(enrollmentSucceeded: true);
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.Skipped);
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(DiagnosticsUploadResult.SkipOnFailureSucceeded, result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -553,7 +556,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
var result = await BuildGateProbe(rig.Logger, "Off")
|
||||
.CreateAndUploadAsync(enrollmentSucceeded: null);
|
||||
|
||||
Assert.Null(result);
|
||||
// The gate name travels in ErrorCode so the dispatcher's server_action_failed
|
||||
// event says "mode_off" instead of a generic "diagnostics_upload_failed".
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.Skipped);
|
||||
Assert.Equal(DiagnosticsUploadResult.SkipModeOff, result.ErrorCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -804,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
@@ -122,6 +122,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
Assert.Equal("config_fetch_failed", (string)typed["failureReason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_request_diagnostics_skipped_by_gate_surfaces_gate_name_as_failure_reason()
|
||||
{
|
||||
// Session 2550bcea (2026-08-29): the on-demand collection was refused by the
|
||||
// mode=Off gate and the portal only saw a generic "diagnostics_upload_failed".
|
||||
// The gate name must travel through to the event so the operator can tell a
|
||||
// config gate from a real upload error.
|
||||
using var rig = new Rig();
|
||||
rig.DiagnosticsResult = DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipModeOff);
|
||||
|
||||
await rig.Build().DispatchAsync(new List<ServerAction>
|
||||
{
|
||||
new ServerAction { Type = ServerActionTypes.RequestDiagnostics, QueuedAt = DateTime.UtcNow },
|
||||
});
|
||||
|
||||
Assert.Equal(1, rig.DiagnosticsCalls);
|
||||
Assert.Empty(rig.EventsOfType("server_action_executed"));
|
||||
var failed = rig.EventsOfType("server_action_failed").Single();
|
||||
var typed = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(failed.TypedPayload!);
|
||||
Assert.Equal(DiagnosticsUploadResult.SkipModeOff, (string)typed["failureReason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_unknown_action_type_emits_failed_with_unknown_action_type_reason()
|
||||
{
|
||||
|
||||
+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"));
|
||||
}
|
||||
|
||||
@@ -498,6 +498,207 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
Assert.Equal(5, observedCount);
|
||||
}
|
||||
|
||||
// ============================================================ Worker re-entrancy (2026-08-29)
|
||||
|
||||
[Fact]
|
||||
public void Worker_thread_post_with_full_channel_does_not_deadlock_and_is_processed_next()
|
||||
{
|
||||
// Channel capacity 1, the worker is parked inside ApplyStep on a gate while a
|
||||
// second producer keeps the channel full. When the gate opens, ApplyStep posts a
|
||||
// verdict from the worker thread. Before the fix, Post would block in
|
||||
// BlockingCollection.Add forever (the worker is the only consumer). After the fix,
|
||||
// the verdict goes to the worker-local queue, is processed BEFORE the queued channel
|
||||
// item, and the worker keeps draining.
|
||||
using var tmp = new TempDirectory();
|
||||
var log = new SignalLogWriter(tmp.File("signal-log.jsonl"));
|
||||
SignalIngress? ingRef = null;
|
||||
var gate = new ManualResetEventSlim(false);
|
||||
var inApply = new ManualResetEventSlim(false);
|
||||
var processor = new GatedReentrantProcessor(() => ingRef!, gate, inApply);
|
||||
var ing = new SignalIngress(
|
||||
engine: new DecisionEngine(),
|
||||
signalLog: log,
|
||||
traceCounter: new SessionTraceOrdinalProvider(),
|
||||
processor: processor,
|
||||
clock: new VirtualClock(At),
|
||||
channelCapacity: 1);
|
||||
ingRef = ing;
|
||||
ing.Start();
|
||||
|
||||
// Item 0 — worker takes it and parks inside ApplyStep on the gate.
|
||||
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("s0"));
|
||||
Assert.True(inApply.Wait(5000));
|
||||
|
||||
// Item 1 fills the single slot; item 2 blocks a foreign producer (channel full).
|
||||
ing.Post(DecisionSignalKind.DesktopArrived, At.AddSeconds(1), "Collector", RawEvidence("s1"));
|
||||
var blockedProducer = Task.Run(() =>
|
||||
ing.Post(DecisionSignalKind.DesktopArrived, At.AddSeconds(2), "Collector", RawEvidence("s2")));
|
||||
Assert.True(WaitFor(() => ing.ApproximateQueueLength == 1));
|
||||
Assert.False(blockedProducer.Wait(200)); // genuinely blocked by back-pressure
|
||||
|
||||
// Release the worker: ApplyStep(s0) now posts the verdict from the worker thread
|
||||
// into a full channel.
|
||||
gate.Set();
|
||||
|
||||
Assert.True(blockedProducer.Wait(5000), "foreign producer must be released — worker must keep draining");
|
||||
Assert.True(WaitFor(() => processor.CallCount == 4));
|
||||
ing.Stop();
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
DecisionSignalKind.SessionStarted,
|
||||
DecisionSignalKind.ClassifierVerdictIssued, // follow-up drained BEFORE the next channel item
|
||||
DecisionSignalKind.DesktopArrived,
|
||||
DecisionSignalKind.DesktopArrived,
|
||||
},
|
||||
processor.Kinds);
|
||||
Assert.Equal(1, ing.ReentrantPostCount);
|
||||
Assert.Equal(0, ing.PendingSignalCount);
|
||||
// Same worker thread for every step — single-writer invariant intact.
|
||||
Assert.Single(processor.ThreadIds.Distinct());
|
||||
// Follow-up went through the normal path: ordinal assigned + durably logged.
|
||||
var logged = log.ReadAll();
|
||||
Assert.Equal(4, logged.Count);
|
||||
Assert.Equal(DecisionSignalKind.ClassifierVerdictIssued, logged[1].Kind);
|
||||
Assert.Equal(1, logged[1].SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Worker_thread_post_chain_is_drained_iteratively_in_order()
|
||||
{
|
||||
// A follow-up whose own ApplyStep posts another follow-up: both are processed on
|
||||
// the worker, in order, before the next channel item, without recursion.
|
||||
using var tmp = new TempDirectory();
|
||||
var log = new SignalLogWriter(tmp.File("signal-log.jsonl"));
|
||||
SignalIngress? ingRef = null;
|
||||
var processor = new ChainingProcessor(() => ingRef!);
|
||||
var ing = new SignalIngress(
|
||||
engine: new DecisionEngine(),
|
||||
signalLog: log,
|
||||
traceCounter: new SessionTraceOrdinalProvider(),
|
||||
processor: processor,
|
||||
clock: new VirtualClock(At));
|
||||
ingRef = ing;
|
||||
ing.Start();
|
||||
|
||||
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("s0"));
|
||||
ing.Post(DecisionSignalKind.DesktopArrived, At.AddSeconds(1), "Collector", RawEvidence("s1"));
|
||||
ing.Stop();
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "Collector", "chain:1", "chain:2", "Collector" },
|
||||
processor.Origins);
|
||||
Assert.Equal(2, ing.ReentrantPostCount);
|
||||
Assert.Equal(4, log.ReadAll().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Worker_thread_post_during_Stop_drain_is_still_processed()
|
||||
{
|
||||
// Stop() completes adding, but a verdict posted by the worker while it finishes
|
||||
// the last channel item must not be dropped — it belongs to that step.
|
||||
using var tmp = new TempDirectory();
|
||||
var log = new SignalLogWriter(tmp.File("signal-log.jsonl"));
|
||||
SignalIngress? ingRef = null;
|
||||
var gate = new ManualResetEventSlim(false);
|
||||
var inApply = new ManualResetEventSlim(false);
|
||||
var processor = new GatedReentrantProcessor(() => ingRef!, gate, inApply);
|
||||
var ing = new SignalIngress(
|
||||
engine: new DecisionEngine(),
|
||||
signalLog: log,
|
||||
traceCounter: new SessionTraceOrdinalProvider(),
|
||||
processor: processor,
|
||||
clock: new VirtualClock(At));
|
||||
ingRef = ing;
|
||||
ing.Start();
|
||||
|
||||
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("s0"));
|
||||
Assert.True(inApply.Wait(5000));
|
||||
var stopTask = Task.Run(() => ing.Stop());
|
||||
Assert.True(WaitFor(() => ing.ApproximateQueueLength == 0 && !stopTask.IsCompleted));
|
||||
gate.Set();
|
||||
Assert.True(stopTask.Wait(5000));
|
||||
|
||||
Assert.Equal(
|
||||
new[] { DecisionSignalKind.SessionStarted, DecisionSignalKind.ClassifierVerdictIssued },
|
||||
processor.Kinds);
|
||||
Assert.Equal(2, log.ReadAll().Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parks inside ApplyStep(SessionStarted) on a gate, then posts a verdict back into the
|
||||
/// ingress from the worker thread — the EffectRunner/RunClassifier shape.
|
||||
/// </summary>
|
||||
private sealed class GatedReentrantProcessor : IDecisionStepProcessor
|
||||
{
|
||||
private readonly Func<ISignalIngressSink> _sink;
|
||||
private readonly ManualResetEventSlim _gate;
|
||||
private readonly ManualResetEventSlim _inApply;
|
||||
private readonly List<(DecisionSignalKind Kind, int ThreadId)> _calls = new List<(DecisionSignalKind, int)>();
|
||||
private DecisionCore.State.DecisionState _state = DecisionCore.State.DecisionState.CreateInitial("S1", "T1");
|
||||
|
||||
public GatedReentrantProcessor(Func<ISignalIngressSink> sink, ManualResetEventSlim gate, ManualResetEventSlim inApply)
|
||||
{
|
||||
_sink = sink;
|
||||
_gate = gate;
|
||||
_inApply = inApply;
|
||||
}
|
||||
|
||||
public int CallCount { get { lock (_calls) { return _calls.Count; } } }
|
||||
public DecisionSignalKind[] Kinds { get { lock (_calls) { return _calls.Select(c => c.Kind).ToArray(); } } }
|
||||
public int[] ThreadIds { get { lock (_calls) { return _calls.Select(c => c.ThreadId).ToArray(); } } }
|
||||
|
||||
public DecisionCore.State.DecisionState CurrentState => _state;
|
||||
|
||||
public EffectRunResult ApplyStep(DecisionStep step, DecisionSignal signal)
|
||||
{
|
||||
lock (_calls) { _calls.Add((signal.Kind, Thread.CurrentThread.ManagedThreadId)); }
|
||||
_state = step.NewState;
|
||||
if (signal.Kind == DecisionSignalKind.SessionStarted)
|
||||
{
|
||||
_inApply.Set();
|
||||
_gate.Wait();
|
||||
_sink().Post(
|
||||
DecisionSignalKind.ClassifierVerdictIssued,
|
||||
signal.OccurredAtUtc,
|
||||
"effectrunner:classifier:test",
|
||||
new Evidence(EvidenceKind.Synthetic, "classifier:test:h", "Strong/90"),
|
||||
new Dictionary<string, string> { ["level"] = "Strong" });
|
||||
}
|
||||
return EffectRunResult.Empty();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ChainingProcessor : IDecisionStepProcessor
|
||||
{
|
||||
private readonly Func<ISignalIngressSink> _sink;
|
||||
private DecisionCore.State.DecisionState _state = DecisionCore.State.DecisionState.CreateInitial("S1", "T1");
|
||||
public List<string> Origins { get; } = new List<string>();
|
||||
|
||||
public ChainingProcessor(Func<ISignalIngressSink> sink) { _sink = sink; }
|
||||
|
||||
public DecisionCore.State.DecisionState CurrentState => _state;
|
||||
|
||||
public EffectRunResult ApplyStep(DecisionStep step, DecisionSignal signal)
|
||||
{
|
||||
Origins.Add(signal.SourceOrigin);
|
||||
_state = step.NewState;
|
||||
string? next = signal.Kind == DecisionSignalKind.SessionStarted ? "chain:1"
|
||||
: signal.SourceOrigin == "chain:1" ? "chain:2"
|
||||
: null;
|
||||
if (next != null)
|
||||
{
|
||||
_sink().Post(
|
||||
DecisionSignalKind.ClassifierVerdictIssued,
|
||||
signal.OccurredAtUtc,
|
||||
next,
|
||||
new Evidence(EvidenceKind.Synthetic, next, next));
|
||||
}
|
||||
return EffectRunResult.Empty();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Post_via_ISignalIngressSink_reaches_reducer_like_any_signal()
|
||||
{
|
||||
|
||||
+41
@@ -58,6 +58,47 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.Same(registration, result.Registration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RotateSession_rotates_id_updates_config_drops_spool_and_keeps_whiteglove_marker()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp.Path);
|
||||
var persistence = new SessionIdPersistence(tmp.Path);
|
||||
var original = persistence.GetOrCreate();
|
||||
persistence.SaveWhiteGloveComplete();
|
||||
var config = new AgentConfiguration { ApiBaseUrl = "https://example.invalid", TenantId = "t", SessionId = original };
|
||||
|
||||
var transportDir = Path.Combine(tmp.Path, "Spool");
|
||||
Directory.CreateDirectory(transportDir);
|
||||
File.WriteAllText(Path.Combine(transportDir, "spool.jsonl"), "{}");
|
||||
File.WriteAllText(Path.Combine(transportDir, "upload-cursor.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(transportDir, "unrelated.txt"), "keep");
|
||||
|
||||
var rotated = BackendSessionRegistration.RotateSession(config, persistence, auth: null, transportDir, logger);
|
||||
|
||||
Assert.NotEqual(original, rotated);
|
||||
Assert.Equal(rotated, config.SessionId);
|
||||
Assert.Equal(rotated, new SessionIdPersistence(tmp.Path).GetOrCreate());
|
||||
Assert.True(persistence.IsWhiteGloveResume());
|
||||
Assert.False(File.Exists(Path.Combine(transportDir, "spool.jsonl")));
|
||||
Assert.False(File.Exists(Path.Combine(transportDir, "upload-cursor.json")));
|
||||
Assert.True(File.Exists(Path.Combine(transportDir, "unrelated.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RotateSession_tolerates_a_missing_transport_directory()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp.Path);
|
||||
var persistence = new SessionIdPersistence(tmp.Path);
|
||||
var config = new AgentConfiguration { ApiBaseUrl = "https://example.invalid", TenantId = "t", SessionId = persistence.GetOrCreate() };
|
||||
|
||||
var rotated = BackendSessionRegistration.RotateSession(
|
||||
config, persistence, auth: null, Path.Combine(tmp.Path, "does-not-exist"), logger);
|
||||
|
||||
Assert.Equal(rotated, config.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_throws_on_null_arguments()
|
||||
{
|
||||
|
||||
@@ -72,6 +72,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.NotEqual(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rotate_writes_a_new_id_and_keeps_the_whiteglove_marker()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var sut = new SessionIdPersistence(tmp.Path);
|
||||
var first = sut.GetOrCreate();
|
||||
sut.SaveWhiteGloveComplete();
|
||||
var createdBefore = File.ReadAllText(Path.Combine(tmp.Path, "session.created"));
|
||||
|
||||
var rotated = sut.Rotate();
|
||||
|
||||
Assert.True(Guid.TryParse(rotated, out _));
|
||||
Assert.NotEqual(first, rotated);
|
||||
Assert.Equal(rotated, File.ReadAllText(Path.Combine(tmp.Path, "session.id")));
|
||||
Assert.Equal(rotated, new SessionIdPersistence(tmp.Path).GetOrCreate());
|
||||
Assert.True(sut.IsWhiteGloveResume(), "Rotate must not clear whiteglove.complete (unlike Delete)");
|
||||
Assert.True(File.Exists(Path.Combine(tmp.Path, "session.created")));
|
||||
Assert.NotEqual(createdBefore, File.ReadAllText(Path.Combine(tmp.Path, "session.created")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_creates_data_directory_when_missing()
|
||||
{
|
||||
|
||||
+106
@@ -96,6 +96,112 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Equal(1, tracker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
// ── SESSION-OWNER-BINDING ────────────────────────────────────────────
|
||||
|
||||
private static BackendAuthException OwnerMismatch()
|
||||
=> new BackendAuthException("Backend returned 403 Forbidden.", statusCode: 403,
|
||||
errorCode: AutopilotMonitor.Shared.Constants.AgentErrorCodes.SessionOwnerMismatch);
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_mismatch_rotates_once_and_re_registers_without_recording_an_auth_failure()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var originalSessionId = config.SessionId;
|
||||
var rotatedTo = Guid.NewGuid().ToString();
|
||||
var seenSessionIds = new System.Collections.Generic.List<string>();
|
||||
|
||||
var apiClient = new FakeApiClient(
|
||||
attempt => attempt == 1
|
||||
? throw OwnerMismatch()
|
||||
: new RegisterSessionResponse { Success = true, SessionId = rotatedTo, ValidatedBy = ValidatorType.AutopilotV1 },
|
||||
onRegister: r => seenSessionIds.Add(r.SessionId));
|
||||
|
||||
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
|
||||
clock: SystemClock.Instance, logger: logger);
|
||||
var rotations = 0;
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
authFailureTracker: tracker,
|
||||
backoffDelay: _ => throw new InvalidOperationException("rotation must retry without backoff"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { rotations++; config.SessionId = rotatedTo; return rotatedTo; });
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
|
||||
Assert.Equal(2, apiClient.CallCount);
|
||||
Assert.Equal(1, rotations);
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
|
||||
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_owner_mismatch_after_rotation_is_an_auth_failure()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw OwnerMismatch());
|
||||
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
|
||||
clock: SystemClock.Instance, logger: logger);
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
authFailureTracker: tracker,
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { config.SessionId = Guid.NewGuid().ToString(); return config.SessionId; });
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.AuthFailed, result.Outcome);
|
||||
Assert.Equal(403, result.HttpStatusCode);
|
||||
Assert.Equal(2, apiClient.CallCount);
|
||||
Assert.Equal(1, tracker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_mismatch_without_rotation_hook_behaves_like_any_auth_failure()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw OwnerMismatch());
|
||||
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
|
||||
clock: SystemClock.Instance, logger: logger);
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
authFailureTracker: tracker, networkLinkWait: _ => Task.CompletedTask);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.AuthFailed, result.Outcome);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
Assert.Equal(1, tracker.ConsecutiveFailures);
|
||||
Assert.Null(result.RotatedFromSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rotation_hook_failure_falls_back_to_auth_failed()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw OwnerMismatch());
|
||||
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
|
||||
clock: SystemClock.Instance, logger: logger);
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
authFailureTracker: tracker, networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => throw new IOException("disk full"));
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.AuthFailed, result.Outcome);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
Assert.Equal(1, tracker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_after_six_exceptions_without_auth_error()
|
||||
{
|
||||
|
||||
+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(
|
||||
|
||||
+37
@@ -50,5 +50,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
|
||||
Assert.Equal(a, b);
|
||||
}
|
||||
|
||||
// The branding URL is tenant config; the dialog parses arguments last-occurrence-wins, so
|
||||
// an embedded quote must never terminate the argument and inject extra flags (CWE-88).
|
||||
|
||||
private const string BaseArgs = "--status-file \"C:\\x\\final-status.json\" --timeout 30 --cleanup";
|
||||
|
||||
[Fact]
|
||||
public void BuildDialogArguments_omits_branding_when_empty()
|
||||
{
|
||||
var args = SummaryDialogLauncher.BuildDialogArguments(@"C:\x\final-status.json", 30, null, null);
|
||||
|
||||
Assert.Equal(BaseArgs, args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDialogArguments_appends_clean_https_url_as_single_quoted_argument()
|
||||
{
|
||||
var args = SummaryDialogLauncher.BuildDialogArguments(@"C:\x\final-status.json", 30, "https://cdn.example/logo.png?v=2", null);
|
||||
|
||||
Assert.Equal(BaseArgs + " --branding-url \"https://cdn.example/logo.png?v=2\"", args);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("x\" --status-file \"C:\\some\\file\" --timeout \"0")]
|
||||
[InlineData("https://cdn.example/logo.png\" --cleanup")]
|
||||
[InlineData("https://cdn.example/logo.png --timeout 0")]
|
||||
[InlineData("https://cdn.example/lo'go.png")]
|
||||
[InlineData("https://cdn.example/logo.png\r\n")]
|
||||
[InlineData("http://cdn.example/logo.png")]
|
||||
[InlineData("file:///C:/Windows/x.png")]
|
||||
[InlineData("cdn.example/logo.png")]
|
||||
public void BuildDialogArguments_drops_unsafe_branding_url(string url)
|
||||
{
|
||||
var args = SummaryDialogLauncher.BuildDialogArguments(@"C:\x\final-status.json", 30, url, null);
|
||||
|
||||
Assert.Equal(BaseArgs, args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -61,6 +61,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.Contains("The device is not authorized", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_json_403_carries_the_errorCode_when_present()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Forbidden,
|
||||
"{\"success\":false,\"message\":\"Session belongs to another device\",\"errorCode\":\"session_owner_mismatch\"}",
|
||||
"application/json");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
|
||||
|
||||
Assert.Equal(403, ex.StatusCode);
|
||||
Assert.False(ex.EndpointUnavailable);
|
||||
Assert.Equal("session_owner_mismatch", ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_json_403_without_errorCode_yields_null()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Forbidden,
|
||||
"{\"success\":false,\"message\":\"Device validation failed\"}",
|
||||
"application/json");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
|
||||
Assert.Null(ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData("not json", null)]
|
||||
[InlineData("<html><body>403</body></html>", null)]
|
||||
[InlineData("{\"errorCode\":42}", null)]
|
||||
[InlineData("{\"errorCode\":\" \"}", null)]
|
||||
[InlineData("{\"errorCode\":\"session_owner_mismatch\"}", "session_owner_mismatch")]
|
||||
[InlineData("{\"ErrorCode\":\"session_owner_mismatch\"}", "session_owner_mismatch")]
|
||||
[InlineData(" {\"success\":false,\"errorCode\":\" x \"}", "x")]
|
||||
public void TryExtractErrorCode_is_tolerant(string? body, string? expected)
|
||||
=> Assert.Equal(expected, BackendApiClient.TryExtractErrorCode(body!));
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_html_403_is_reported_as_endpoint_unavailable_with_page_title()
|
||||
{
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+176
-130
@@ -45,10 +45,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// </summary>
|
||||
public string SasUrlPrefix { get; set; }
|
||||
|
||||
/// <summary>Human-readable error code/message when upload failed. Null on success or skip.</summary>
|
||||
/// <summary>Human-readable error code/message when upload failed or was skipped. Null on success.</summary>
|
||||
public string ErrorCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when no package was built because a configuration gate said so (mode=Off,
|
||||
/// destination not configured, OnFailure + known success). <see cref="ErrorCode"/>
|
||||
/// then carries the machine-readable gate name so the emitted
|
||||
/// <c>server_action_failed</c> / <c>diagnostics_upload_failed</c> event names the
|
||||
/// actual reason instead of a generic "upload failed".
|
||||
/// </summary>
|
||||
public bool Skipped { get; set; }
|
||||
|
||||
public bool Success => BlobName != null;
|
||||
|
||||
public const string SkipModeOff = "diagnostics_skipped_mode_off";
|
||||
public const string SkipNotConfigured = "diagnostics_skipped_not_configured";
|
||||
public const string SkipOnFailureSucceeded = "diagnostics_skipped_onfailure_success";
|
||||
|
||||
public static DiagnosticsUploadResult SkippedBy(string gate) =>
|
||||
new DiagnosticsUploadResult { Skipped = true, ErrorCode = gate };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -86,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
|
||||
@@ -191,7 +212,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// <summary>
|
||||
/// Creates a diagnostics ZIP and uploads it to the configured Blob Storage container.
|
||||
/// Returns a DiagnosticsUploadResult with BlobName set on success, or ErrorCode set on failure.
|
||||
/// Returns null if the upload was skipped (mode=Off, not configured, or OnFailure+succeeded).
|
||||
/// A configuration gate (mode=Off, not configured, OnFailure+succeeded) returns a result
|
||||
/// with <see cref="DiagnosticsUploadResult.Skipped"/> and the gate name in ErrorCode.
|
||||
/// This method is non-fatal: all exceptions are caught and logged.
|
||||
/// </summary>
|
||||
/// <param name="enrollmentSucceeded">
|
||||
@@ -214,14 +236,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
|
||||
if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Debug("Diagnostics upload disabled (mode=Off)");
|
||||
return null;
|
||||
_logger.Info("Diagnostics upload skipped: DiagnosticsUploadMode=Off on the running agent.");
|
||||
return DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipModeOff);
|
||||
}
|
||||
|
||||
if (!_configuration.DiagnosticsUploadEnabled)
|
||||
{
|
||||
_logger.Debug("Diagnostics upload skipped: not configured for this tenant");
|
||||
return null;
|
||||
_logger.Info($"Diagnostics upload skipped: no upload destination configured for this tenant (mode={mode}).");
|
||||
return DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipNotConfigured);
|
||||
}
|
||||
|
||||
// Null outcome (on-demand mid-session) passes through: the OnFailure gate only
|
||||
@@ -229,7 +251,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded == true)
|
||||
{
|
||||
_logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure");
|
||||
return null;
|
||||
return DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipOnFailureSucceeded);
|
||||
}
|
||||
|
||||
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={(enrollmentSucceeded.HasValue ? enrollmentSucceeded.ToString() : "n/a")}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
|
||||
@@ -545,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
|
||||
|
||||
@@ -326,10 +326,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
endpointUnavailable: true);
|
||||
}
|
||||
|
||||
// Backend JSON bodies may carry a machine-readable errorCode (Shared
|
||||
// Constants.AgentErrorCodes) that tells the caller how to react — e.g.
|
||||
// session_owner_mismatch → rotate the session id instead of treating this as a
|
||||
// certificate rejection. Absent/unparseable → null, classic auth failure.
|
||||
var errorCode = TryExtractErrorCode(body);
|
||||
throw new BackendAuthException(
|
||||
$"Backend returned {statusText}. " +
|
||||
"The device is not authorized. Check client certificate and Autopilot device validation.",
|
||||
(int)response.StatusCode);
|
||||
(int)response.StatusCode,
|
||||
errorCode: errorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulls <c>errorCode</c> out of a backend JSON error body. Tolerant: any parse failure
|
||||
/// or a non-string value yields null, never an exception.
|
||||
/// </summary>
|
||||
internal static string TryExtractErrorCode(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body) || body.TrimStart().Length == 0 || body.TrimStart()[0] != '{')
|
||||
return null;
|
||||
try
|
||||
{
|
||||
var obj = Newtonsoft.Json.Linq.JObject.Parse(body);
|
||||
var token = obj["errorCode"] ?? obj["ErrorCode"];
|
||||
if (token == null || token.Type != Newtonsoft.Json.Linq.JTokenType.String)
|
||||
return null;
|
||||
var value = (string)token;
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -429,10 +458,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// </summary>
|
||||
public bool EndpointUnavailable { get; }
|
||||
|
||||
public BackendAuthException(string message, int statusCode = 0, bool endpointUnavailable = false) : base(message)
|
||||
/// <summary>
|
||||
/// Machine-readable code from the backend's JSON error body (see Shared
|
||||
/// <c>Constants.AgentErrorCodes</c>), or null when the body carried none.
|
||||
/// </summary>
|
||||
public string ErrorCode { get; }
|
||||
|
||||
public BackendAuthException(string message, int statusCode = 0, bool endpointUnavailable = false, string errorCode = null) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
EndpointUnavailable = endpointUnavailable;
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
private const int MinIntervalMinutes = 10;
|
||||
|
||||
private readonly BackendApiClient _apiClient;
|
||||
private readonly string _sessionId;
|
||||
private volatile string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly string _agentVersion;
|
||||
private readonly AgentLogger _logger;
|
||||
@@ -52,6 +52,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SESSION-OWNER-BINDING: the reporter is built before registration and snapshots the
|
||||
/// SessionId; a rotation during registration must be reflected here or later reports
|
||||
/// would name the refused session.
|
||||
/// </summary>
|
||||
public void UpdateSessionId(string sessionId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(sessionId))
|
||||
_sessionId = sessionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to send an emergency report. Returns immediately without awaiting
|
||||
/// (intended to be called as fire-and-forget via <c>_ = reporter.TrySendAsync(...)</c>).
|
||||
|
||||
@@ -88,9 +88,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private int _passThroughStepsSinceSnapshot; // H1a — pass-through steps skipped since last snapshot
|
||||
private bool _parkedTripwireFired; // liveness PR1 — one-shot per agent run, no state-schema touch
|
||||
|
||||
// M1 — parked-tripwire dwell machinery. The timer thread is also what fixes L8: the
|
||||
// M1 — parked-tripwire dwell machinery. The timer thread is also what fixed L8: the
|
||||
// tripwire post no longer runs on the ingress worker, so a full channel cannot
|
||||
// self-deadlock on it. Guarded by _parkedLock because the callback races ApplyStep.
|
||||
// self-deadlock on it. (Since 2026-08-29 SignalIngress.Post itself is re-entrancy-safe
|
||||
// — worker-thread posts go to a worker-local queue — so the EffectRunner's
|
||||
// ClassifierVerdictIssued post from inside ApplyStep is covered too.) Guarded by
|
||||
// _parkedLock because the callback races ApplyStep.
|
||||
private readonly object _parkedLock = new object();
|
||||
private readonly TimeSpan _parkedTripwireDwell;
|
||||
private Timer? _parkedDwellTimer;
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// Die Back-Pressure-Emission läuft NICHT durch den Signal-Channel (würde deadlocken).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Worker re-entrancy</b>: a <see cref="Post"/> issued ON the worker thread itself
|
||||
/// (EffectRunner posting a <c>ClassifierVerdictIssued</c> from inside
|
||||
/// <c>ApplyStep</c>) must never touch the bounded channel — the worker is the channel's
|
||||
/// only consumer, so a full channel would block the consumer as a producer and freeze
|
||||
/// the whole pipeline for the rest of the run. Such posts go to a worker-local
|
||||
/// follow-up queue that the worker drains (ahead of the channel) right after the
|
||||
/// current item finishes. Same processing path, same ordinal/log/reduce semantics —
|
||||
/// just never blocking.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Shutdown</b>: <see cref="Stop"/> ruft <see cref="BlockingCollection{T}.CompleteAdding"/>,
|
||||
/// wartet auf das Drain-Ende via <c>Thread.Join</c>. Nach <c>Stop</c> wirft jeder
|
||||
/// weitere <see cref="Post"/> <see cref="InvalidOperationException"/>.
|
||||
@@ -79,6 +89,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly object _backPressureLock = new object();
|
||||
|
||||
private Thread? _worker;
|
||||
// Worker-thread-only: signals posted re-entrantly from ApplyStep (EffectRunner
|
||||
// classifier verdicts). Drained by WorkerLoop after every channel item, before the
|
||||
// next channel item is taken. Only the worker enqueues and dequeues — no lock.
|
||||
private readonly Queue<IngressItem> _workerLocalQueue = new Queue<IngressItem>();
|
||||
private long _reentrantPostCount;
|
||||
private long _nextSignalOrdinal; // worker-thread-only after Start; seeded from SignalLog
|
||||
private int _started; // 0 = not started, 1 = running
|
||||
private int _stopRequested; // 0 = live, 1 = Stop called
|
||||
@@ -135,6 +150,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
public long PendingSignalCount => Interlocked.Read(ref _unprocessedCount);
|
||||
|
||||
/// <summary>
|
||||
/// Signals that were posted from the worker thread itself and routed through the
|
||||
/// worker-local follow-up queue instead of the bounded channel. Observability/tests.
|
||||
/// </summary>
|
||||
public long ReentrantPostCount => Interlocked.Read(ref _reentrantPostCount);
|
||||
|
||||
/// <summary>
|
||||
/// Fires synchronously from <see cref="Post"/> after a signal has been accepted into
|
||||
/// the channel (for both the fast-path <see cref="BlockingCollection{T}.TryAdd"/> and
|
||||
@@ -218,6 +239,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// Reicht ein Raw-/Derived-/Synthetic-Signal in den Ingress ein. Thread-safe; darf von
|
||||
/// beliebig vielen Collector-Threads (und vom <see cref="EffectRunner"/>) parallel
|
||||
/// gerufen werden. Blockiert, wenn der Channel voll ist — Plan §2.1a Back-Pressure.
|
||||
/// Ausnahme: Aufrufe vom Worker-Thread selbst blockieren nie (worker-local queue).
|
||||
/// </summary>
|
||||
public void Post(
|
||||
DecisionSignalKind kind,
|
||||
@@ -228,10 +250,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int kindSchemaVersion = 1,
|
||||
object? typedPayload = null)
|
||||
{
|
||||
if (Volatile.Read(ref _stopRequested) == 1 || _channel.IsAddingCompleted)
|
||||
{
|
||||
throw new InvalidOperationException("SignalIngress has been stopped.");
|
||||
}
|
||||
if (evidence == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(evidence));
|
||||
@@ -243,6 +261,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
var item = new IngressItem(kind, kindSchemaVersion, occurredAtUtc, sourceOrigin, evidence, payload, typedPayload);
|
||||
|
||||
// Worker re-entrancy (see class doc): a post from the worker thread itself goes to
|
||||
// the worker-local follow-up queue, never into the bounded channel. Checked before
|
||||
// the stop gate on purpose — while Stop() drains the channel the worker may still
|
||||
// produce verdicts for the items it is finishing, and those must not be lost.
|
||||
if (ReferenceEquals(Thread.CurrentThread, _worker))
|
||||
{
|
||||
Interlocked.Increment(ref _unprocessedCount);
|
||||
_workerLocalQueue.Enqueue(item);
|
||||
Interlocked.Increment(ref _reentrantPostCount);
|
||||
if (_workerLocalQueue.Count > 1)
|
||||
{
|
||||
// One follow-up per step is the expected shape (one RunClassifier effect).
|
||||
// More means effects are chaining on the worker — worth seeing in the log.
|
||||
_logger?.Debug($"SignalIngress: worker-local follow-up queue depth={_workerLocalQueue.Count} kind={kind} origin={sourceOrigin}");
|
||||
}
|
||||
RaiseSignalPosted(kind, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Volatile.Read(ref _stopRequested) == 1 || _channel.IsAddingCompleted)
|
||||
{
|
||||
throw new InvalidOperationException("SignalIngress has been stopped.");
|
||||
}
|
||||
|
||||
// Codex Finding 2 (2026-04-30): bump the pending-counter BEFORE handing the item
|
||||
// to the channel. Decrement in three places: (a) when TryAdd throws because
|
||||
// CompleteAdding raced between the post-stop check and the enqueue, (b) when
|
||||
@@ -353,6 +395,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
foreach (var item in _channel.GetConsumingEnumerable())
|
||||
{
|
||||
ProcessItem(item);
|
||||
DrainWorkerLocalQueue();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -366,6 +409,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes signals the worker posted to itself during the previous item (see class
|
||||
/// doc, worker re-entrancy). Iterative, not recursive: a follow-up that produces
|
||||
/// another follow-up is simply appended and handled in the same loop.
|
||||
/// </summary>
|
||||
private void DrainWorkerLocalQueue()
|
||||
{
|
||||
while (_workerLocalQueue.Count > 0)
|
||||
{
|
||||
ProcessItem(_workerLocalQueue.Dequeue());
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessItem(IngressItem item)
|
||||
{
|
||||
// Codex Finding 2 (2026-04-30): match the Interlocked.Increment that Post()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -175,6 +175,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the persisted SessionId with a fresh GUID and restarts the session-age clock.
|
||||
/// Used when the backend answers register-session with <c>session_owner_mismatch</c>
|
||||
/// (SESSION-OWNER-BINDING): the id on disk belongs to a session bound to a different device
|
||||
/// identity — the re-enroll-without-wipe shape — so this run must become a new session.
|
||||
/// Unlike <see cref="Delete"/> this leaves <c>whiteglove.complete</c> untouched: a Part-2
|
||||
/// resume that has to rotate is still a Part-2 resume.
|
||||
/// </summary>
|
||||
/// <returns>The new SessionId.</returns>
|
||||
public string Rotate(AgentLogger logger = null)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
var fresh = Guid.NewGuid().ToString();
|
||||
WriteAtomic(fresh, logger);
|
||||
SaveSessionCreatedAtInternal(DateTime.UtcNow);
|
||||
logger?.Info($"SessionIdPersistence: rotated SessionId to {fresh}.");
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDelete(string path, AgentLogger logger)
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); }
|
||||
|
||||
@@ -4,6 +4,7 @@ using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
@@ -33,6 +34,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// <see cref="AuthFailureTracker"/> (which fires the first-failure distress + may trip
|
||||
/// the shutdown threshold) and return <see cref="SessionRegistrationOutcome.AuthFailed"/>
|
||||
/// without retrying — the backend has definitively rejected the device cert.</item>
|
||||
/// <item>Exception to the above — a 403 carrying error code
|
||||
/// <c>session_owner_mismatch</c> (SESSION-OWNER-BINDING): the persisted SessionId names a
|
||||
/// session bound to another device identity (typically an Intune re-enrollment without a
|
||||
/// wipe, where <c>session.id</c> survived but the certificate identity changed). That is
|
||||
/// not an auth failure: the session is rotated through <c>rotateSession</c> and
|
||||
/// registration retried exactly once. The tracker is NOT fed — five of these would
|
||||
/// otherwise soft-shutdown a perfectly authorized agent.</item>
|
||||
/// <item>Any other exception on the last attempt → <see cref="EmergencyReporter.TrySendAsync"/>
|
||||
/// with <c>AgentErrorType.RegisterSessionFailed</c> so operators see the final cause.</item>
|
||||
/// </list>
|
||||
@@ -72,7 +80,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
Func<int, Task> backoffDelay = null,
|
||||
Func<Exception, Task> onTerminalTransportFailure = null,
|
||||
(string Manufacturer, string Model, string SerialNumber)? deviceHardware = null,
|
||||
Func<AgentLogger, Task> networkLinkWait = null)
|
||||
Func<AgentLogger, Task> networkLinkWait = null,
|
||||
Func<string> rotateSession = null)
|
||||
{
|
||||
if (apiClient == null) throw new ArgumentNullException(nameof(apiClient));
|
||||
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
|
||||
@@ -86,6 +95,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
catch (Exception ex) { logger.Debug($"Pre-registration network wait failed ({ex.Message}) — registering anyway."); }
|
||||
string lastError = null;
|
||||
Exception lastException = null;
|
||||
string rotatedFromSessionId = null;
|
||||
|
||||
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
|
||||
{
|
||||
@@ -97,12 +107,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
if (response != null && response.Success)
|
||||
{
|
||||
logger.Info($"Session registered successfully: {response.SessionId} (validatedBy={response.ValidatedBy}, adminAction={response.AdminAction ?? "(none)"})");
|
||||
return SessionRegistrationResult.Succeeded(response);
|
||||
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId);
|
||||
}
|
||||
|
||||
lastError = response?.Message ?? "(null response)";
|
||||
logger.Warning($"Session registration failed: {lastError}");
|
||||
}
|
||||
catch (BackendAuthException ex) when (
|
||||
ex.ErrorCode == Constants.AgentErrorCodes.SessionOwnerMismatch
|
||||
&& rotateSession != null
|
||||
&& rotatedFromSessionId == null)
|
||||
{
|
||||
// SESSION-OWNER-BINDING: the id on disk belongs to a session this device
|
||||
// identity does not own. Become a new session and register once more —
|
||||
// immediately, no backoff, no auth-failure bookkeeping.
|
||||
rotatedFromSessionId = registration.SessionId;
|
||||
string newSessionId;
|
||||
try
|
||||
{
|
||||
newSessionId = rotateSession();
|
||||
}
|
||||
catch (Exception rotateEx)
|
||||
{
|
||||
logger.Error($"Session owner mismatch reported by backend but session rotation failed: {rotateEx.Message}", rotateEx);
|
||||
authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session", ex.EndpointUnavailable);
|
||||
return SessionRegistrationResult.AuthFailed(ex.StatusCode, ex.Message);
|
||||
}
|
||||
logger.Warning(
|
||||
$"Backend refused registration with {Constants.AgentErrorCodes.SessionOwnerMismatch}: session {rotatedFromSessionId} " +
|
||||
$"is bound to another device identity (re-enrollment without wipe?). Rotated to {newSessionId}; re-registering.");
|
||||
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
|
||||
continue;
|
||||
}
|
||||
catch (BackendAuthException ex)
|
||||
{
|
||||
logger.Error($"Session registration authentication failed ({ex.StatusCode}): {ex.Message}");
|
||||
@@ -237,20 +273,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
public string AdminAction => Response?.AdminAction;
|
||||
public ValidatorType ValidatedBy => Response?.ValidatedBy ?? ValidatorType.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// SESSION-OWNER-BINDING: the SessionId this run started with when the backend refused it
|
||||
/// with <c>session_owner_mismatch</c> and the session was rotated before the successful
|
||||
/// registration. Null on the normal path.
|
||||
/// </summary>
|
||||
public string RotatedFromSessionId { get; }
|
||||
|
||||
private SessionRegistrationResult(
|
||||
SessionRegistrationOutcome outcome,
|
||||
RegisterSessionResponse response,
|
||||
int httpStatusCode,
|
||||
string errorMessage)
|
||||
string errorMessage,
|
||||
string rotatedFromSessionId = null)
|
||||
{
|
||||
Outcome = outcome;
|
||||
Response = response;
|
||||
HttpStatusCode = httpStatusCode;
|
||||
ErrorMessage = errorMessage;
|
||||
RotatedFromSessionId = rotatedFromSessionId;
|
||||
}
|
||||
|
||||
public static SessionRegistrationResult Succeeded(RegisterSessionResponse response)
|
||||
=> new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null);
|
||||
public static SessionRegistrationResult Succeeded(RegisterSessionResponse response, string rotatedFromSessionId = null)
|
||||
=> new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId);
|
||||
|
||||
public static SessionRegistrationResult AuthFailed(int statusCode, string message)
|
||||
=> new SessionRegistrationResult(SessionRegistrationOutcome.AuthFailed, null, statusCode, message);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -132,10 +132,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
}
|
||||
|
||||
// 4) Compose args. Legacy contract: --status-file <path> --timeout <s> --cleanup [--branding-url <url>].
|
||||
var tempStatusArg = Path.Combine(tempDir, FinalStatusFileName);
|
||||
var args = $"--status-file \"{tempStatusArg}\" --timeout {configuration.EnrollmentSummaryTimeoutSeconds} --cleanup";
|
||||
if (!string.IsNullOrWhiteSpace(configuration.EnrollmentSummaryBrandingImageUrl))
|
||||
args += $" --branding-url \"{configuration.EnrollmentSummaryBrandingImageUrl}\"";
|
||||
var args = BuildDialogArguments(
|
||||
Path.Combine(tempDir, FinalStatusFileName),
|
||||
configuration.EnrollmentSummaryTimeoutSeconds,
|
||||
configuration.EnrollmentSummaryBrandingImageUrl,
|
||||
logger);
|
||||
|
||||
// 5) Launch in user session with retry.
|
||||
var retrySeconds = configuration.EnrollmentSummaryLaunchRetrySeconds > 0
|
||||
@@ -151,6 +152,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
return launched;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the dialog command line. The branding URL is tenant-supplied config and the
|
||||
/// dialog parses arguments last-occurrence-wins, so a value carrying a double quote could
|
||||
/// terminate its argument and override <c>--status-file</c>/<c>--timeout</c>/<c>--cleanup</c>
|
||||
/// (CWE-88). The backend rejects such values, but the agent is the last line of defence:
|
||||
/// only a plain absolute HTTPS URL without quotes, whitespace or control characters is
|
||||
/// ever placed on the command line — anything else is dropped (the dialog then simply
|
||||
/// shows no branding image).
|
||||
/// </summary>
|
||||
internal static string BuildDialogArguments(string statusFilePath, int timeoutSeconds, string brandingImageUrl, AgentLogger logger)
|
||||
{
|
||||
var args = $"--status-file \"{statusFilePath}\" --timeout {timeoutSeconds} --cleanup";
|
||||
if (string.IsNullOrWhiteSpace(brandingImageUrl))
|
||||
return args;
|
||||
|
||||
if (IsSafeBrandingImageUrl(brandingImageUrl))
|
||||
return args + $" --branding-url \"{brandingImageUrl}\"";
|
||||
|
||||
logger?.Warning("SummaryDialogLauncher: EnrollmentSummaryBrandingImageUrl is not a plain absolute HTTPS URL — ignoring it.");
|
||||
return args;
|
||||
}
|
||||
|
||||
internal static bool IsSafeBrandingImageUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url) || url.Length > 2048)
|
||||
return false;
|
||||
|
||||
foreach (var ch in url)
|
||||
{
|
||||
if (ch == '"' || ch == '\'' || char.IsWhiteSpace(ch) || char.IsControl(ch))
|
||||
return false;
|
||||
}
|
||||
|
||||
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
||||
&& uri.Scheme == Uri.UriSchemeHttps
|
||||
&& !string.IsNullOrEmpty(uri.Host);
|
||||
}
|
||||
|
||||
private static void TryCopy(string source, string destination)
|
||||
{
|
||||
try { if (File.Exists(source)) File.Copy(source, destination, overwrite: true); }
|
||||
|
||||
@@ -269,7 +269,9 @@ namespace AutopilotMonitor.Agent.V2
|
||||
// mapping + on-failure client disposal) is encapsulated in BackendSessionRegistration.
|
||||
// Exit codes: 6 = AuthFailed, 7 = anything else. See Runtime/BackendSessionRegistration.cs.
|
||||
var registration = Runtime.BackendSessionRegistration.Register(
|
||||
bootstrap.AgentConfig, auth, telemetry.MtlsHttpClient, GetAgentVersion(), consoleMode, logger);
|
||||
bootstrap.AgentConfig, auth, telemetry.MtlsHttpClient, GetAgentVersion(), consoleMode, logger,
|
||||
rotateSession: () => Runtime.BackendSessionRegistration.RotateSession(
|
||||
bootstrap.AgentConfig, bootstrap.SessionPersistence, auth, transportDir, logger));
|
||||
if (registration.ShouldExit)
|
||||
{
|
||||
return registration.ExitCode;
|
||||
|
||||
@@ -115,10 +115,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
|
||||
/// <summary>
|
||||
/// Applies a live-fetched config to the runtime: merge, tracker ceilings, log level,
|
||||
/// integrity SHA + check, bootstrap-config cleanup. Shared by the Phase 4 startup path
|
||||
/// and the post-registration recovery path so both apply identical semantics.
|
||||
/// integrity SHA + check, bootstrap-config cleanup. Shared by the Phase 4 startup path,
|
||||
/// the post-registration recovery path and the live <c>rotate_config</c> ServerAction
|
||||
/// (<see cref="ServerControlPlane.BuildDispatcher"/>) so all three apply identical semantics.
|
||||
/// </summary>
|
||||
private static RemoteConfigMergeResult ApplyFetchedConfig(
|
||||
internal static RemoteConfigMergeResult ApplyFetchedConfig(
|
||||
AgentConfiguration agentConfig,
|
||||
AgentConfigResponse remoteConfig,
|
||||
BackendAuthBundle auth,
|
||||
|
||||
@@ -344,7 +344,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
orchestrator.Start(ingress =>
|
||||
{
|
||||
lifecyclePost = new InformationalEventPost(ingress, SystemClock.Instance, logger);
|
||||
LifecycleEmitters.EmitAgentStarted(lifecyclePost, agentConfig, previousExit, agentVersion, remoteConfigService, logger);
|
||||
LifecycleEmitters.EmitAgentStarted(lifecyclePost, agentConfig, previousExit, agentVersion, remoteConfigService, logger,
|
||||
rotatedFromSessionId: registrationResult?.RotatedFromSessionId);
|
||||
// Wire-visible signal that the agent is running on defaults/cache
|
||||
// rather than the live tenant config — closes the cold-start
|
||||
// blind spot observed in session 8f2bef72 (2026-05-19).
|
||||
@@ -420,6 +421,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
terminationHandler: terminationHandler,
|
||||
remoteConfigService: remoteConfigService,
|
||||
diagnosticsService: diagnosticsService,
|
||||
auth: auth,
|
||||
agentVersion: agentVersion,
|
||||
consoleMode: consoleMode,
|
||||
shutdown: shutdown,
|
||||
shutdownComplete: shutdownComplete,
|
||||
post: lifecyclePost,
|
||||
|
||||
@@ -27,7 +27,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
HttpClient mtlsHttpClient,
|
||||
string agentVersion,
|
||||
bool consoleMode,
|
||||
AgentLogger logger)
|
||||
AgentLogger logger,
|
||||
Func<string> rotateSession = null)
|
||||
{
|
||||
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
|
||||
if (auth == null) throw new ArgumentNullException(nameof(auth));
|
||||
@@ -43,7 +44,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
onTerminalTransportFailure: ex => DiagnoseTerminalTransportFailure(ex, auth, logger),
|
||||
// Reuse the single hardened hardware read from the auth bundle so the session row
|
||||
// matches the security headers the backend validated against (no second WMI query).
|
||||
deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber))
|
||||
deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber),
|
||||
rotateSession: rotateSession)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
if (registrationResult.Outcome != SessionRegistrationOutcome.Succeeded)
|
||||
@@ -63,6 +65,48 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
return SessionRegistrationOutcomeResult.Continue(registrationResult);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SESSION-OWNER-BINDING: the one place that turns "backend says this session id is not
|
||||
/// ours" into a new session. Rotates the persisted id, points the runtime config at it,
|
||||
/// drops the transport spool (its lines carry the refused session's partition key and
|
||||
/// would otherwise be uploaded against it after the rotation) and re-targets the
|
||||
/// emergency reporter, which snapshotted the id before registration. Everything else
|
||||
/// reads <c>agentConfig.SessionId</c> live after Phase 6.
|
||||
/// </summary>
|
||||
internal static string RotateSession(
|
||||
AgentConfiguration agentConfig,
|
||||
SessionIdPersistence sessionPersistence,
|
||||
BackendAuthBundle auth,
|
||||
string transportDir,
|
||||
AgentLogger logger)
|
||||
{
|
||||
var newSessionId = sessionPersistence.Rotate(logger);
|
||||
agentConfig.SessionId = newSessionId;
|
||||
auth?.EmergencyReporter?.UpdateSessionId(newSessionId);
|
||||
DropTransportSpool(transportDir, logger);
|
||||
return newSessionId;
|
||||
}
|
||||
|
||||
private static void DropTransportSpool(string transportDir, AgentLogger logger)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(transportDir)) return;
|
||||
foreach (var name in new[] { "spool.jsonl", "upload-cursor.json" })
|
||||
{
|
||||
var path = System.IO.Path.Combine(transportDir, name);
|
||||
try
|
||||
{
|
||||
if (!System.IO.File.Exists(path)) continue;
|
||||
var size = new System.IO.FileInfo(path).Length;
|
||||
System.IO.File.Delete(path);
|
||||
logger.Info($"Session rotation: dropped {name} ({size} bytes) — its items belonged to the refused session.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warning($"Session rotation: could not drop {name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Off-hot-path diagnostic invoked once after all session-registration retries fail.
|
||||
/// Detects whether the terminal exception was a TLS-layer <c>SecureChannelFailure</c>
|
||||
|
||||
@@ -42,7 +42,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
Program.PreviousExitSummary previousExit,
|
||||
string agentVersion,
|
||||
RemoteConfigService remoteConfigService,
|
||||
AgentLogger logger)
|
||||
AgentLogger logger,
|
||||
string rotatedFromSessionId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -67,6 +68,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{ "diagnosticsUploadMode", agentConfig.DiagnosticsUploadMode ?? "Off" },
|
||||
{ "previousExitType", previousExit?.ExitType ?? "unknown" },
|
||||
{ "unrestrictedMode", agentConfig.UnrestrictedMode },
|
||||
// SESSION-OWNER-BINDING: true when the backend refused the persisted session
|
||||
// id (session_owner_mismatch) and this run rotated to a new session before
|
||||
// registering — the re-enroll-without-wipe shape, made visible on the timeline.
|
||||
{ "sessionRotated", rotatedFromSessionId != null },
|
||||
{ "previousSessionId", rotatedFromSessionId ?? string.Empty },
|
||||
// remoteConfigFetched answers "is the agent running on a live-fetched
|
||||
// config" — true also when the post-registration recovery replaced a
|
||||
// failed startup fetch (remoteConfigOutcome then still names the startup
|
||||
|
||||
@@ -33,6 +33,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
EnrollmentTerminationHandler terminationHandler,
|
||||
RemoteConfigService remoteConfigService,
|
||||
DiagnosticsPackageService diagnosticsService,
|
||||
BackendAuthBundle auth,
|
||||
string agentVersion,
|
||||
bool consoleMode,
|
||||
ManualResetEventSlim shutdown,
|
||||
ManualResetEventSlim shutdownComplete,
|
||||
InformationalEventPost post,
|
||||
@@ -41,9 +44,31 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
return new ServerActionDispatcher(
|
||||
configuration: agentConfig,
|
||||
logger: logger,
|
||||
// rotate_config = fetch AND apply. FetchConfigAsync only stores the response
|
||||
// in RemoteConfigService.CurrentConfig; the projection onto the live
|
||||
// AgentConfiguration (DiagnosticsUploadMode, LogLevel, SelfDestruct, ...)
|
||||
// is ApplyFetchedConfig — the same path the startup fetch uses. Without it
|
||||
// the portal's "enable diagnostics, then collect" flow fetched the new
|
||||
// config and still ran the diagnostics gate against the stale mode.
|
||||
// FetchConfigAsync never throws: a failed fetch falls back to cache/defaults
|
||||
// and reports it via LastFetchOutcome — that is NOT an executed rotation.
|
||||
rotateConfigAsync: async () =>
|
||||
{
|
||||
try { var _ = await remoteConfigService.FetchConfigAsync(); return true; }
|
||||
try
|
||||
{
|
||||
var fetched = await remoteConfigService.FetchConfigAsync();
|
||||
if (remoteConfigService.LastFetchOutcome != RemoteConfigFetchOutcome.Succeeded)
|
||||
{
|
||||
logger.Warning($"ServerAction rotate_config: live fetch failed (outcome={remoteConfigService.LastFetchOutcome}, {remoteConfigService.LastFetchFailureMessage}) — runtime config unchanged.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var mergeResult = AgentRuntimeConfig.ApplyFetchedConfig(
|
||||
agentConfig, fetched, auth, agentVersion, consoleMode, logger);
|
||||
LifecycleEmitters.EmitUnrestrictedModeAuditIfChanged(post, agentConfig, mergeResult, logger);
|
||||
logger.Info($"ServerAction rotate_config: applied ConfigVersion={fetched.ConfigVersion} to the running agent.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) { logger.Warning($"ServerAction rotate_config failed: {ex.Message}"); return false; }
|
||||
},
|
||||
// On-demand request mid-enrollment — the session has no outcome yet. Null keeps
|
||||
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
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>
|
||||
/// End-to-end authorization effect of identity binding through <see cref="PolicyEnforcementMiddleware"/>:
|
||||
/// a validly-signed token from a FOREIGN Entra tenant (the API accepts every tenant) whose upn matches a
|
||||
/// GlobalAdmins / DelegatedAdmins row must resolve NO platform or delegated role — and likewise a token
|
||||
/// from the home tenant under a different object id, a token without an oid, and any UPN whose role row
|
||||
/// has no binding at all. Also pins the ordering inside <see cref="DelegatedAdminService"/>: binding is
|
||||
/// checked before the Pro entitlement gate, and an unbound caller never triggers the edition lookup.
|
||||
/// </summary>
|
||||
public class AdminIdentityBindingAuthorizationTests
|
||||
{
|
||||
private const string HomeTenant = "11111111-1111-1111-1111-111111111111";
|
||||
private const string AttackerTenant = "22222222-2222-2222-2222-222222222222";
|
||||
private const string ManagedTenant = "33333333-3333-3333-3333-333333333333";
|
||||
private const string GaUpn = "cloudadmin@vendor.example";
|
||||
private const string MspUpn = "msp@partner.example";
|
||||
private const string Oid = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
|
||||
private const string OtherOid = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb";
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public required PolicyEnforcementMiddleware Middleware { get; init; }
|
||||
public required Mock<IAdminRepository> Repo { get; init; }
|
||||
}
|
||||
|
||||
private static Harness Build(AdminIdentityBinding? binding, string? globalRole = null, string? delegatedTenant = null)
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
repo.Setup(r => r.GetGlobalRoleAsync(It.IsAny<string>())).ReturnsAsync(globalRole);
|
||||
repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((TenantMember?)null);
|
||||
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>())).ReturnsAsync(
|
||||
delegatedTenant == null
|
||||
? new List<DelegatedAdminEntry>()
|
||||
: new List<DelegatedAdminEntry>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Upn = MspUpn, TenantId = delegatedTenant.ToLowerInvariant(),
|
||||
Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true,
|
||||
Status = Constants.DelegatedStatus.Active, Source = Constants.DelegatedSource.OperatorGranted,
|
||||
},
|
||||
});
|
||||
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>())).ReturnsAsync(new List<TenantGroupAssignment>());
|
||||
repo.Setup(r => r.GetIdentityBindingAsync(It.IsAny<string>())).ReturnsAsync(() => binding);
|
||||
repo.Setup(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync((string _, string tid, string oid) =>
|
||||
{
|
||||
if (binding != null && !binding.IsObjectIdPinned && binding.TenantId == tid)
|
||||
binding.ObjectId = oid;
|
||||
return binding;
|
||||
});
|
||||
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync((TenantConfiguration?)null);
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var bindings = new AdminIdentityBindingService(repo.Object, cache, NullLogger<AdminIdentityBindingService>.Instance);
|
||||
var globalAdmin = new GlobalAdminService(repo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance);
|
||||
var delegatedAdmin = new DelegatedAdminService(
|
||||
repo.Object, bindings,
|
||||
new StubTenantEntitlementService(TenantEdition.Pro),
|
||||
cache, NullLogger<DelegatedAdminService>.Instance);
|
||||
var tenantAdmins = new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance);
|
||||
var tenantConfig = new TenantConfigurationService(configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
|
||||
return new Harness
|
||||
{
|
||||
Middleware = new PolicyEnforcementMiddleware(
|
||||
NullLogger<PolicyEnforcementMiddleware>.Instance, globalAdmin, delegatedAdmin, tenantAdmins, tenantConfig),
|
||||
Repo = repo,
|
||||
};
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal Token(string tenantId, string upn, string? oid = Oid)
|
||||
{
|
||||
var claims = new List<Claim> { new("tid", tenantId), new("upn", upn) };
|
||||
if (oid != null) claims.Add(new Claim("oid", oid));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
}
|
||||
|
||||
private static AdminIdentityBinding Bound(string upn, string tenantId, string objectId = Oid)
|
||||
=> new() { Upn = upn, TenantId = tenantId, ObjectId = objectId };
|
||||
|
||||
// ── Global Admin tier ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BoundGlobalAdmin_FromHomeTenant_IsAdmitted()
|
||||
{
|
||||
var h = Build(Bound(GaUpn, HomeTenant), globalRole: Constants.GlobalRoles.GlobalAdmin);
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn));
|
||||
Assert.True(r.Allowed);
|
||||
Assert.True(r.Context!.IsGlobalAdmin);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/global/raw/tables")] // GlobalAdminOnly — raw table dump incl. tenant secrets
|
||||
[InlineData("POST", "/api/auth/global-admins")] // GlobalAdminOnly — persist access
|
||||
[InlineData("GET", "/api/config/all")] // GlobalReadOrDelegatedSubset
|
||||
[InlineData("GET", "/api/auth/global-admins")] // GlobalReadOrAdmin
|
||||
public async Task ForeignTenantToken_WithGlobalAdminUpn_IsDenied(string method, string path)
|
||||
{
|
||||
// The finding: attacker registers the lapsed GA domain in their own tenant, creates the same UPN,
|
||||
// signs in (any tenant is accepted) — the token is genuine, only the tid differs.
|
||||
var h = Build(Bound(GaUpn, HomeTenant), globalRole: Constants.GlobalRoles.GlobalAdmin);
|
||||
var r = await h.Middleware.DecideAsync(method, path, null, Token(AttackerTenant, GaUpn));
|
||||
Assert.False(r.Allowed, $"{method} {path} must deny a foreign-tenant token even with a matching GA UPN");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForeignTenantToken_WithGlobalAdminUpn_CannotReachAnotherTenant()
|
||||
{
|
||||
// Cross-tenant read on a tenant-scoped route: no GlobalScope ⇒ the cross-tenant guard blocks.
|
||||
var h = Build(Bound(GaUpn, HomeTenant), globalRole: Constants.GlobalRoles.GlobalAdmin);
|
||||
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{ManagedTenant}", null, Token(AttackerTenant, GaUpn));
|
||||
Assert.False(r.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HomeTenantToken_DifferentObjectId_IsDenied()
|
||||
{
|
||||
// UPN recycled inside the home tenant (User Administrator re-assigns it to another account).
|
||||
var h = Build(Bound(GaUpn, HomeTenant, Oid), globalRole: Constants.GlobalRoles.GlobalAdmin);
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn, OtherOid));
|
||||
Assert.False(r.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TokenWithoutObjectId_ResolvesNoPlatformRole()
|
||||
{
|
||||
var h = Build(Bound(GaUpn, HomeTenant), globalRole: Constants.GlobalRoles.GlobalAdmin);
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn, oid: null));
|
||||
Assert.False(r.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnboundGlobalAdminRow_IsInert()
|
||||
{
|
||||
// Legacy row, no binding yet: fail-closed until the operator seeds the binding — never "first come".
|
||||
var h = Build(binding: null, globalRole: Constants.GlobalRoles.GlobalAdmin);
|
||||
Assert.False((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn))).Allowed);
|
||||
Assert.False((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(AttackerTenant, GaUpn))).Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnpinnedBinding_HomeTenantFirstSignIn_Pins_ThenForeignAndRecycledAreDenied()
|
||||
{
|
||||
var h = Build(Bound(GaUpn, HomeTenant, objectId: ""), globalRole: Constants.GlobalRoles.GlobalAdmin);
|
||||
|
||||
// First sign-in from the bound tenant pins the oid and is admitted.
|
||||
Assert.True((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn, Oid))).Allowed);
|
||||
h.Repo.Verify(r => r.TryPinIdentityObjectIdAsync(GaUpn, HomeTenant, Oid), Times.Once);
|
||||
|
||||
// From then on: same account ok, another account in the home tenant denied, foreign tenant denied.
|
||||
Assert.True((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn, Oid))).Allowed);
|
||||
Assert.False((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn, OtherOid))).Allowed);
|
||||
Assert.False((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(AttackerTenant, GaUpn, Oid))).Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnpinnedBinding_ForeignTenantCannotClaimThePin()
|
||||
{
|
||||
var h = Build(Bound(GaUpn, HomeTenant, objectId: ""), globalRole: Constants.GlobalRoles.GlobalAdmin);
|
||||
|
||||
Assert.False((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(AttackerTenant, GaUpn, OtherOid))).Allowed);
|
||||
h.Repo.Verify(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
|
||||
// The real admin still gets the pin afterwards.
|
||||
Assert.True((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn, Oid))).Allowed);
|
||||
}
|
||||
|
||||
// ── Delegated (MSP) tier ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BoundDelegatedAdmin_FromHomeTenant_IsRescuedOnManagedTenantRead()
|
||||
{
|
||||
var h = Build(Bound(MspUpn, HomeTenant), delegatedTenant: ManagedTenant);
|
||||
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{ManagedTenant}", null, Token(HomeTenant, MspUpn));
|
||||
Assert.True(r.Allowed);
|
||||
Assert.True(r.Context!.IsDelegatedReader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForeignTenantToken_WithDelegatedUpn_ResolvesEmptyScope()
|
||||
{
|
||||
// The finding's MSP variant: an attacker tenant (Pro via self-service trial) mints the delegated UPN.
|
||||
var h = Build(Bound(MspUpn, HomeTenant), delegatedTenant: ManagedTenant);
|
||||
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{ManagedTenant}", null, Token(AttackerTenant, MspUpn));
|
||||
Assert.False(r.Allowed);
|
||||
Assert.False((await h.Middleware.DecideAsync("GET", "/api/config/all", null, Token(AttackerTenant, MspUpn))).Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DelegatedService_ChecksBindingBeforeEntitlementGate()
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>())).ReturnsAsync(new List<DelegatedAdminEntry>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Upn = MspUpn, TenantId = ManagedTenant, Role = Constants.DelegatedRoles.DelegatedReader,
|
||||
IsEnabled = true, Status = Constants.DelegatedStatus.Active, Source = Constants.DelegatedSource.OperatorGranted,
|
||||
},
|
||||
});
|
||||
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>())).ReturnsAsync(new List<TenantGroupAssignment>());
|
||||
var editionLookups = 0;
|
||||
var svc = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubAdminIdentityBindingService(bound: false),
|
||||
new StubTenantEntitlementService(_ => { editionLookups++; return TenantEdition.Pro; }),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
|
||||
var scope = await svc.GetScopeAsync(new AdminIdentity(MspUpn, AttackerTenant, Oid));
|
||||
|
||||
Assert.True(scope.IsEmpty);
|
||||
Assert.Equal(0, editionLookups);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="AdminIdentityBindingService"/> — the one place that decides whether a caller IS the
|
||||
/// identity a cross-tenant-role UPN was granted for. The API accepts tokens from any Entra tenant and
|
||||
/// upn/preferred_username are mutable, so a UPN string alone must never resolve a platform or delegated
|
||||
/// role: only the (upn, tid, oid) triple matching the stored binding does. Covers the verdict matrix
|
||||
/// (no binding / tenant mismatch / first-sign-in pin / pinned mismatch / concurrent-pin loss), the cache
|
||||
/// invalidation after a pin, and the grant-time conflict rules (a grant never silently re-homes a UPN).
|
||||
/// </summary>
|
||||
public class AdminIdentityBindingServiceTests
|
||||
{
|
||||
private const string Upn = "admin@vendor.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 (AdminIdentityBindingService Svc, Mock<IAdminRepository> Repo) Build(AdminIdentityBinding? stored)
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
// Stateful stand-in for the row: reads return `stored`, a pin mutates it under the repo's contract
|
||||
// (only onto an unpinned row homed in the caller's tenant), upserts replace it.
|
||||
repo.Setup(r => r.GetIdentityBindingAsync(It.IsAny<string>())).ReturnsAsync(() => stored);
|
||||
repo.Setup(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync((string _, string tid, string oid) =>
|
||||
{
|
||||
if (stored != null && !stored.IsObjectIdPinned && stored.TenantId == tid)
|
||||
stored.ObjectId = oid;
|
||||
return stored;
|
||||
});
|
||||
repo.Setup(r => r.UpsertIdentityBindingAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>()))
|
||||
.ReturnsAsync((string upn, string tid, string? oid, string by) =>
|
||||
{
|
||||
stored = new AdminIdentityBinding { Upn = upn, TenantId = tid, ObjectId = oid ?? string.Empty, BoundBy = by };
|
||||
return true;
|
||||
});
|
||||
var svc = new AdminIdentityBindingService(
|
||||
repo.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<AdminIdentityBindingService>.Instance);
|
||||
return (svc, repo);
|
||||
}
|
||||
|
||||
private static AdminIdentityBinding Binding(string tenantId, string objectId = "") =>
|
||||
new() { Upn = Upn, TenantId = tenantId, ObjectId = objectId };
|
||||
|
||||
private static AdminIdentity Caller(string tenantId = HomeTenant, string oid = Oid) => new(Upn, tenantId, oid);
|
||||
|
||||
// ── Verdict matrix ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task NullIdentity_IsNeverBound_AndReadsNothing()
|
||||
{
|
||||
var (svc, repo) = Build(Binding(HomeTenant, Oid));
|
||||
Assert.False(await svc.IsBoundAsync(null));
|
||||
repo.Verify(r => r.GetIdentityBindingAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoBindingRow_IsNotBound()
|
||||
{
|
||||
// A legacy role row without a binding is inert — fail-closed, never "bind whoever shows up first".
|
||||
var (svc, _) = Build(stored: null);
|
||||
Assert.False(await svc.IsBoundAsync(Caller()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForeignTenant_WithMatchingUpn_IsNotBound()
|
||||
{
|
||||
// The finding's primary exploit: same UPN string minted by another Entra tenant (domain re-registered,
|
||||
// or a lookalike verified elsewhere). The tid is the immutable discriminator.
|
||||
var (svc, repo) = Build(Binding(HomeTenant, Oid));
|
||||
Assert.False(await svc.IsBoundAsync(Caller(tenantId: ForeignTenant)));
|
||||
// And it must not pin anything onto the real admin's binding.
|
||||
repo.Verify(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForeignTenant_AgainstUnpinnedBinding_DoesNotPin()
|
||||
{
|
||||
// Unpinned binding + foreign-tenant token: tenant mismatch is checked BEFORE the pin, so the
|
||||
// attacker cannot "claim" the object-id slot of an admin who has not signed in yet.
|
||||
var (svc, repo) = Build(Binding(HomeTenant));
|
||||
Assert.False(await svc.IsBoundAsync(Caller(tenantId: ForeignTenant)));
|
||||
repo.Verify(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PinnedBinding_MatchingTidAndOid_IsBound()
|
||||
{
|
||||
var (svc, _) = Build(Binding(HomeTenant, Oid));
|
||||
Assert.True(await svc.IsBoundAsync(Caller()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PinnedBinding_SameTenantDifferentOid_IsNotBound()
|
||||
{
|
||||
// UPN recycling inside the home tenant: a User Administrator re-assigns the UPN to another account.
|
||||
// Same tid, new oid — refused until an operator explicitly rebinds.
|
||||
var (svc, _) = Build(Binding(HomeTenant, Oid));
|
||||
Assert.False(await svc.IsBoundAsync(Caller(oid: OtherOid)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnpinnedBinding_FirstSignInFromHomeTenant_PinsAndIsBound_ThenEnforced()
|
||||
{
|
||||
var (svc, repo) = Build(Binding(HomeTenant));
|
||||
|
||||
Assert.True(await svc.IsBoundAsync(Caller()));
|
||||
repo.Verify(r => r.TryPinIdentityObjectIdAsync(Upn, HomeTenant, Oid), Times.Once);
|
||||
|
||||
// Pinned now: the same account stays bound, any other account in the same tenant is refused.
|
||||
Assert.True(await svc.IsBoundAsync(Caller()));
|
||||
Assert.False(await svc.IsBoundAsync(Caller(oid: OtherOid)));
|
||||
// The pin happened exactly once — later calls verify against the stored (cached) binding.
|
||||
repo.Verify(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentPin_LostRace_VerifiesAgainstStoredWinner()
|
||||
{
|
||||
// Two accounts in the home tenant race the first sign-in; the repository's conditional update lets
|
||||
// exactly one win. The loser must be judged against what was STORED, never against its own intent.
|
||||
var (svc, repo) = Build(Binding(HomeTenant));
|
||||
repo.Setup(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(Binding(HomeTenant, OtherOid)); // someone else won
|
||||
|
||||
Assert.False(await svc.IsBoundAsync(Caller()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PinResult_Null_MeansBindingVanished_IsNotBound()
|
||||
{
|
||||
// Binding removed between the cached read and the pin (operator DELETE racing the sign-in).
|
||||
var (svc, repo) = Build(Binding(HomeTenant));
|
||||
repo.Setup(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync((AdminIdentityBinding?)null);
|
||||
|
||||
Assert.False(await svc.IsBoundAsync(Caller()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verdict_IsCached_ButPinInvalidates()
|
||||
{
|
||||
var (svc, repo) = Build(Binding(HomeTenant, Oid));
|
||||
Assert.True(await svc.IsBoundAsync(Caller()));
|
||||
Assert.True(await svc.IsBoundAsync(Caller()));
|
||||
repo.Verify(r => r.GetIdentityBindingAsync(It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rebind_InvalidatesCache_SoNewTenantTakesEffectImmediately()
|
||||
{
|
||||
var (svc, _) = Build(Binding(HomeTenant, Oid));
|
||||
Assert.True(await svc.IsBoundAsync(Caller()));
|
||||
|
||||
await svc.RebindAsync(Upn, ForeignTenant, OtherOid, "ga@vendor.example");
|
||||
|
||||
Assert.False(await svc.IsBoundAsync(Caller()));
|
||||
Assert.True(await svc.IsBoundAsync(Caller(tenantId: ForeignTenant, oid: OtherOid)));
|
||||
}
|
||||
|
||||
// ── Grant-time binding (EnsureBoundAsync) ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Ensure_NoExistingBinding_CreatesIt()
|
||||
{
|
||||
var (svc, repo) = Build(stored: null);
|
||||
var b = await svc.EnsureBoundAsync(Upn, HomeTenant, null, "ga@vendor.example");
|
||||
Assert.Equal(HomeTenant, b.TenantId);
|
||||
Assert.False(b.IsObjectIdPinned);
|
||||
repo.Verify(r => r.UpsertIdentityBindingAsync(Upn, HomeTenant, null, "ga@vendor.example"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ensure_SameTenant_KeepsExistingPin_NoWrite()
|
||||
{
|
||||
// A second grant (another managed tenant / a group) for an already-bound UPN is a no-op on the binding.
|
||||
var (svc, repo) = Build(Binding(HomeTenant, Oid));
|
||||
var b = await svc.EnsureBoundAsync(Upn, HomeTenant, null, "ga@vendor.example");
|
||||
Assert.Equal(Oid, b.ObjectId);
|
||||
repo.Verify(r => r.UpsertIdentityBindingAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ensure_SameTenant_UnpinnedThenObjectIdSupplied_Upgrades()
|
||||
{
|
||||
var (svc, repo) = Build(Binding(HomeTenant));
|
||||
var b = await svc.EnsureBoundAsync(Upn, HomeTenant, Oid, "ga@vendor.example");
|
||||
Assert.Equal(Oid, b.ObjectId);
|
||||
repo.Verify(r => r.UpsertIdentityBindingAsync(Upn, HomeTenant, Oid, "ga@vendor.example"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ensure_DifferentTenant_Conflicts_NeverRehomes()
|
||||
{
|
||||
// A grant must not be a covert rebind: moving a UPN to another tenant is an explicit PUT.
|
||||
var (svc, repo) = Build(Binding(HomeTenant, Oid));
|
||||
await Assert.ThrowsAsync<IdentityBindingConflictException>(
|
||||
() => svc.EnsureBoundAsync(Upn, ForeignTenant, null, "ga@vendor.example"));
|
||||
repo.Verify(r => r.UpsertIdentityBindingAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ensure_DifferentPinnedObjectId_Conflicts()
|
||||
{
|
||||
var (svc, _) = Build(Binding(HomeTenant, Oid));
|
||||
await Assert.ThrowsAsync<IdentityBindingConflictException>(
|
||||
() => svc.EnsureBoundAsync(Upn, HomeTenant, OtherOid, "ga@vendor.example"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ensure_NormalizesCase()
|
||||
{
|
||||
var (svc, repo) = Build(stored: null);
|
||||
await svc.EnsureBoundAsync("Admin@Vendor.Example", HomeTenant.ToUpperInvariant(), Oid.ToUpperInvariant(), "ga@vendor.example");
|
||||
repo.Verify(r => r.UpsertIdentityBindingAsync(Upn, HomeTenant, Oid, "ga@vendor.example"), Times.Once);
|
||||
}
|
||||
|
||||
// ── AdminIdentity construction ────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, HomeTenant, Oid)]
|
||||
[InlineData(Upn, "", Oid)]
|
||||
[InlineData(Upn, HomeTenant, " ")]
|
||||
public void AdminIdentity_Create_RequiresAllThree(string? upn, string? tid, string? oid)
|
||||
=> Assert.Null(AdminIdentity.Create(upn, tid, oid));
|
||||
|
||||
[Fact]
|
||||
public void AdminIdentity_Create_Lowercases()
|
||||
{
|
||||
var id = AdminIdentity.Create("Admin@Vendor.Example", HomeTenant.ToUpperInvariant(), Oid.ToUpperInvariant());
|
||||
Assert.Equal(new AdminIdentity(Upn, HomeTenant, Oid), id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
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>
|
||||
/// Pins <see cref="AdminIdentityResolver"/> — what makes granting a cross-tenant role "type the UPN":
|
||||
/// sign-in history first (tid + oid, both from validated tokens), then UPN domain → onboarded tenant
|
||||
/// (tid only, oid pinned on first sign-in), and NO answer when the history is ambiguous (same UPN under
|
||||
/// two tenants — exactly the situation the binding exists for) or the domain is unknown.
|
||||
/// </summary>
|
||||
public class AdminIdentityResolverTests
|
||||
{
|
||||
private const string Upn = "msp@partner.example";
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private const string OidOld = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
|
||||
private const string OidNew = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb";
|
||||
|
||||
private static AdminIdentityResolver Build(
|
||||
IEnumerable<UserSignInIdentity>? signIns = null,
|
||||
params (string TenantId, string Domain)[] tenants)
|
||||
{
|
||||
var metrics = new Mock<IMetricsRepository>();
|
||||
metrics.Setup(m => m.GetSignInIdentitiesByUpnAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((signIns ?? Array.Empty<UserSignInIdentity>()).ToList());
|
||||
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.GetAllTenantConfigurationsAsync())
|
||||
.ReturnsAsync(tenants.Select(t => new TenantConfiguration { TenantId = t.TenantId, DomainName = t.Domain }).ToList());
|
||||
var configService = new TenantConfigurationService(
|
||||
configRepo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
return new AdminIdentityResolver(metrics.Object, configService, NullLogger<AdminIdentityResolver>.Instance);
|
||||
}
|
||||
|
||||
private static UserSignInIdentity SignIn(string tid, string oid, int daysAgo) => new()
|
||||
{
|
||||
TenantId = tid, ObjectId = oid, LastLoginAt = DateTime.UtcNow.AddDays(-daysAgo), LoginCount = 1,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task SignInHistory_SingleTenant_ResolvesTidAndOid()
|
||||
{
|
||||
var r = await Build(new[] { SignIn(TenantA, OidOld, 3) }).ResolveAsync("MSP@Partner.Example");
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal(TenantA, r!.TenantId);
|
||||
Assert.Equal(OidOld, r.ObjectId);
|
||||
Assert.Equal(ResolvedAdminIdentity.SourceSignIn, r.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignInHistory_SameTenantTwoOids_TakesMostRecent()
|
||||
{
|
||||
// Account re-created in the same tenant: the newer oid is the one that will sign in next.
|
||||
var r = await Build(new[] { SignIn(TenantA, OidOld, 30), SignIn(TenantA, OidNew, 1) }).ResolveAsync(Upn);
|
||||
Assert.Equal(OidNew, r!.ObjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignInHistory_TwoTenants_IsAmbiguous_NotResolved_EvenIfDomainMatches()
|
||||
{
|
||||
// The same UPN string seen under two tenants is the attack shape; never auto-pick, even when the
|
||||
// domain fallback would have an answer.
|
||||
var r = await Build(new[] { SignIn(TenantA, OidOld, 3), SignIn(TenantB, OidNew, 1) }, (TenantA, "partner.example"))
|
||||
.ResolveAsync(Upn);
|
||||
Assert.Null(r);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoSignIns_DomainOfOnboardedTenant_ResolvesTidOnly()
|
||||
{
|
||||
var r = await Build(null, (TenantA, "Partner.Example"), (TenantB, "other.example")).ResolveAsync(Upn);
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal(TenantA, r!.TenantId);
|
||||
Assert.Null(r.ObjectId);
|
||||
Assert.Equal(ResolvedAdminIdentity.SourceDomain, r.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoSignIns_TwoTenantsClaimTheDomain_NotResolved()
|
||||
{
|
||||
var r = await Build(null, (TenantA, "partner.example"), (TenantB, "partner.example")).ResolveAsync(Upn);
|
||||
Assert.Null(r);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoSignIns_UnknownDomain_NotResolved()
|
||||
{
|
||||
var r = await Build(null, (TenantA, "other.example")).ResolveAsync(Upn);
|
||||
Assert.Null(r);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("nodomain")]
|
||||
[InlineData("trailing@")]
|
||||
public async Task MalformedUpn_NotResolved(string upn)
|
||||
=> Assert.Null(await Build(null, (TenantA, "partner.example")).ResolveAsync(upn));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Stored-XSS guard for analyze-rule relatedDocs: the author-controlled URL is rendered as
|
||||
/// an anchor href for every viewer (including cross-tenant admins), so only absolute
|
||||
/// http(s) URLs may persist. Create and Update both reject anything else with an
|
||||
/// <see cref="ArgumentException"/> (400), and drop blank entries silently.
|
||||
/// </summary>
|
||||
public class AnalyzeRuleRelatedDocsUrlTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://learn.microsoft.com/x", true)]
|
||||
[InlineData("http://example.com/", true)]
|
||||
[InlineData(" https://example.com/a?b=c#d ", true)]
|
||||
[InlineData("javascript:alert(1)", false)]
|
||||
[InlineData("JavaScript:fetch('https://example.com/'+localStorage.token)", false)]
|
||||
[InlineData("data:text/html,<script>alert(1)</script>", false)]
|
||||
[InlineData("vbscript:msgbox(1)", false)]
|
||||
[InlineData("file:///etc/passwd", false)]
|
||||
[InlineData("/relative/path", false)]
|
||||
[InlineData("example.com", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsAbsoluteHttpUrl_allows_only_absolute_http_and_https(string? url, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, AnalyzeRuleService.IsAbsoluteHttpUrl(url));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRuleAsync_rejects_javascript_url_and_never_persists()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
|
||||
repo.Setup(r => r.AnalyzeRuleExistsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
var rule = MakeRule(new RelatedDoc { Title = "Microsoft Docs: fix this error", Url = "javascript:alert(1)" });
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ArgumentException>(() => service.CreateRuleAsync(TenantId, rule));
|
||||
Assert.Contains("javascript:alert(1)", ex.Message);
|
||||
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRuleAsync_rejects_data_url_for_custom_rule()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
var rule = MakeRule(new RelatedDoc { Title = "Docs", Url = "data:text/html,<script>alert(1)</script>" });
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.UpdateRuleAsync(TenantId, rule));
|
||||
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRuleAsync_keeps_https_docs_and_drops_blank_entries()
|
||||
{
|
||||
AnalyzeRule? stored = null;
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
|
||||
repo.Setup(r => r.AnalyzeRuleExistsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
|
||||
repo.Setup(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), TenantId))
|
||||
.Callback<AnalyzeRule, string>((r, _) => stored = r)
|
||||
.ReturnsAsync(true);
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
var rule = MakeRule(
|
||||
new RelatedDoc { Title = "Good", Url = "https://learn.microsoft.com/x" },
|
||||
new RelatedDoc { Title = "", Url = "" }, // editor "+ Add Link" default, untouched
|
||||
new RelatedDoc { Title = "Blank", Url = " " });
|
||||
|
||||
Assert.True(await service.CreateRuleAsync(TenantId, rule));
|
||||
Assert.NotNull(stored);
|
||||
var doc = Assert.Single(stored!.RelatedDocs);
|
||||
Assert.Equal("https://learn.microsoft.com/x", doc.Url);
|
||||
}
|
||||
|
||||
private static AnalyzeRule MakeRule(params RelatedDoc[] docs) => new()
|
||||
{
|
||||
RuleId = "TENANT-DOCS-URL-001",
|
||||
IsBuiltIn = false,
|
||||
IsCommunity = false,
|
||||
RelatedDocs = docs.ToList(),
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
using AutopilotMonitor.Functions.Functions.Infrastructure;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
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;
|
||||
|
||||
@@ -25,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()
|
||||
@@ -39,16 +46,18 @@ public class AuthFunctionSideEffectTests
|
||||
configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
|
||||
var bindings = new StubAdminIdentityBindingService(bound: true);
|
||||
var globalAdminMock = new Mock<GlobalAdminService>(
|
||||
adminRepo, cache, Mock.Of<ILogger<GlobalAdminService>>())
|
||||
adminRepo, bindings, cache, Mock.Of<ILogger<GlobalAdminService>>())
|
||||
{ CallBase = false };
|
||||
|
||||
var delegatedAdminMock = new Mock<DelegatedAdminService>(
|
||||
adminRepo,
|
||||
bindings,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
cache, Mock.Of<ILogger<DelegatedAdminService>>())
|
||||
{ CallBase = false };
|
||||
delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny<string>(), It.IsAny<string?>()))
|
||||
delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>()))
|
||||
.ReturnsAsync(DelegatedScope.Empty);
|
||||
|
||||
_tenantAdminsMock = new Mock<TenantAdminsService>(
|
||||
@@ -72,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,
|
||||
@@ -93,7 +103,10 @@ public class AuthFunctionSideEffectTests
|
||||
_autoApproveEnqueuerMock.Object,
|
||||
new AutopilotMonitor.Functions.Security.EntraAppRegistry(
|
||||
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
|
||||
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()));
|
||||
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()),
|
||||
new Mock<AdminIdentityResolver>(
|
||||
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
|
||||
_signalR);
|
||||
|
||||
// Default: all fire-and-forget calls succeed
|
||||
_tenantConfigMock
|
||||
@@ -118,6 +131,79 @@ 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()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = null!;
|
||||
config.OnboardedBy = null;
|
||||
|
||||
// The seed is rendered into transactional mails: anything but a strict host name is refused.
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, "user@<b>contoso.com</b>");
|
||||
|
||||
Assert.True(string.IsNullOrEmpty(config.DomainName));
|
||||
Assert.Null(config.OnboardedBy);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_WhenDomainEmpty_ExtractsAndSaves()
|
||||
{
|
||||
|
||||
+2
-2
@@ -35,7 +35,7 @@ public class AzureQueueImeMsiArchiveProducerTests
|
||||
Assert.Equal(2, sentBodies.Count);
|
||||
var roundTripped = JsonConvert.DeserializeObject<ImeMsiArchiveEnvelope>(sentBodies[0])!;
|
||||
Assert.Equal(Version, roundTripped.Version);
|
||||
Assert.Equal(ImeMsiArchiver.CanonicalMsiUrl, roundTripped.MsiDownloadUrl);
|
||||
Assert.Equal(ImeMsiArchiver.FallbackMsiUrls[1], roundTripped.MsiDownloadUrl);
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111", roundTripped.TenantId);
|
||||
// Queue existence is ensured once, then latched — not re-checked per message.
|
||||
queue.Verify(q => q.CreateIfNotExistsAsync(
|
||||
@@ -92,7 +92,7 @@ public class AzureQueueImeMsiArchiveProducerTests
|
||||
private static ImeMsiArchiveEnvelope Envelope() => new()
|
||||
{
|
||||
Version = Version,
|
||||
MsiDownloadUrl = ImeMsiArchiver.CanonicalMsiUrl,
|
||||
MsiDownloadUrl = ImeMsiArchiver.FallbackMsiUrls[1],
|
||||
MsiMatchedBy = "productVersion",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Request-derived blob names must be flat. Azure.Storage.Blobs keeps '/' in the blob URI and
|
||||
/// System.Uri collapses '..' segments, so "x/../../other-container/y" would resolve outside the
|
||||
/// intended container. The guard is the last line at every GetBlobClient sink.
|
||||
/// </summary>
|
||||
public class BlobNameGuardTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("11111111-1111-1111-1111-111111111111_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa_diag_request_20260829_120000.zip")]
|
||||
[InlineData("report.zip")]
|
||||
[InlineData("a.b.c.zip")]
|
||||
public void IsFlat_AcceptsFlatNames(string name)
|
||||
{
|
||||
Assert.True(BlobNameGuard.IsFlat(name));
|
||||
Assert.Equal(name, BlobNameGuard.EnsureFlat(name, "name"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("TA_x/../../hosted-diagnostics/victim/payload_diag_request_1.zip")]
|
||||
[InlineData("a/b.zip")]
|
||||
[InlineData("a\\b.zip")]
|
||||
[InlineData("..zip")]
|
||||
[InlineData("a%2F..%2Fb.zip")]
|
||||
[InlineData("a?b.zip")]
|
||||
[InlineData("a#b.zip")]
|
||||
public void IsFlat_RejectsPathShapedNames(string? name)
|
||||
{
|
||||
Assert.False(BlobNameGuard.IsFlat(name));
|
||||
Assert.Throws<ArgumentException>(() => BlobNameGuard.EnsureFlat(name, "name"));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -99,8 +99,9 @@ public sealed class BootstrapAuthDisabledTenantTests
|
||||
{
|
||||
// Sanity: a non-disabled tenant DOES reach the bootstrap branch (which will then
|
||||
// run its own validation against _bootstrapSessionService). With _bootstrapSessionService
|
||||
// null in this test, the bootstrap branch falls through to the cert path — proving
|
||||
// that the §0 gate did NOT block.
|
||||
// null and no request URL (= not a /api/bootstrap/* route, where that combination is
|
||||
// rejected fail-closed — see BootstrapRouteFailClosedTests), the branch falls through to
|
||||
// the platform-cert path — proving that the §0 gate did NOT block.
|
||||
var enabled = TenantConfiguration.CreateDefault(TenantId);
|
||||
enabled.Disabled = false;
|
||||
enabled.ValidateAutopilotDevice = false;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Bootstrap routes (<c>/api/bootstrap/*</c>) are excluded from the platform's TLS client-cert
|
||||
/// requirement, so nothing on those paths proves an mTLS handshake ever happened. The bootstrap
|
||||
/// token must therefore be the ONLY credential accepted there — a request that reaches the
|
||||
/// certificate stage on a bootstrap route would authenticate from header bytes alone (a public
|
||||
/// certificate is replayable without its private key). Live probe 2026-08-29: an empty
|
||||
/// <c>X-Bootstrap-Token</c> passed the function gate, skipped the token branch and had a
|
||||
/// client-supplied certificate header parsed. These tests pin the fail-closed behaviour.
|
||||
/// </summary>
|
||||
public sealed class BootstrapRouteFailClosedTests
|
||||
{
|
||||
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
private const string BootstrapRoute = "https://example.invalid/api/bootstrap/config";
|
||||
private const string AgentRoute = "https://example.invalid/api/agent/telemetry";
|
||||
|
||||
// A well-formed DER certificate is not needed: what matters is whether the certificate stage
|
||||
// ran at all, and the "No certificate provided" vs. parse-error details tell the two apart.
|
||||
private const string JunkCertBase64 = "QUJD";
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task BootstrapRoute_TokenMissingOrEmpty_Rejected401_BeforeCertificateStage(string? token)
|
||||
{
|
||||
var sut = BuildValidator(bootstrapService: null);
|
||||
var req = BuildRequest(BootstrapRoute, token, ("X-ARR-ClientCert", JunkCertBase64));
|
||||
|
||||
var result = await sut.ValidateRequestAsync(req, TenantId);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
|
||||
Assert.Equal("X-Bootstrap-Token header is required", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BootstrapRoute_ClientSuppliedCertificateHeader_NeverParsed()
|
||||
{
|
||||
var sut = BuildValidator(bootstrapService: null);
|
||||
var req = BuildRequest(BootstrapRoute, token: "", ("X-Client-Certificate", JunkCertBase64));
|
||||
|
||||
var result = await sut.ValidateRequestAsync(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
|
||||
Assert.Equal("X-Bootstrap-Token header is required", result.ErrorMessage);
|
||||
Assert.DoesNotContain("certificate", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentRoute_ClientCertificateHeader_IsIgnored()
|
||||
{
|
||||
// The removed X-Client-Certificate fallback: on a platform-enforced route the only
|
||||
// certificate source is X-ARR-ClientCert. A client-writable header must read as "no cert".
|
||||
var sut = BuildValidator(bootstrapService: null);
|
||||
var req = BuildRequest(AgentRoute, token: null, ("X-Client-Certificate", JunkCertBase64));
|
||||
|
||||
var result = await sut.ValidateRequestAsync(req, TenantId);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
|
||||
Assert.Equal("Invalid or missing client certificate", result.ErrorMessage);
|
||||
Assert.Equal("No certificate provided", result.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentRoute_ArrClientCertHeader_ReachesCertificateStage()
|
||||
{
|
||||
// Control: the platform-set header is still the one consulted (junk bytes → parse error,
|
||||
// proving the certificate stage ran on the supplied value).
|
||||
var sut = BuildValidator(bootstrapService: null);
|
||||
var req = BuildRequest(AgentRoute, token: null, ("X-ARR-ClientCert", JunkCertBase64));
|
||||
|
||||
var result = await sut.ValidateRequestAsync(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
|
||||
Assert.Equal("Invalid or missing client certificate", result.ErrorMessage);
|
||||
Assert.NotEqual("No certificate provided", result.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBootstrapToken_TreatsEmptyAsAbsent()
|
||||
{
|
||||
Assert.Null(SecurityValidator.GetBootstrapToken(BuildRequest(BootstrapRoute, token: "")));
|
||||
Assert.Null(SecurityValidator.GetBootstrapToken(BuildRequest(BootstrapRoute, token: null)));
|
||||
Assert.Equal("abc", SecurityValidator.GetBootstrapToken(BuildRequest(BootstrapRoute, token: "abc")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://example.invalid/api/bootstrap/config", true)]
|
||||
[InlineData("https://example.invalid/API/Bootstrap/register-session", true)]
|
||||
[InlineData("https://example.invalid/api/bootstrapx/config", false)]
|
||||
[InlineData("https://example.invalid/api/agent/telemetry", false)]
|
||||
public void IsBootstrapRoute_MatchesPlatformExclusionPrefix(string url, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, SecurityValidator.IsBootstrapRoute(BuildRequest(url, token: null)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
|
||||
private static SecurityValidator BuildValidator(BootstrapSessionService? bootstrapService)
|
||||
{
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
// AllowInsecureAgentRequests clears the "at least one device validator" gate so agent-route
|
||||
// requests reach the certificate stage — the stage these tests reason about.
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
config.AllowInsecureAgentRequests = true;
|
||||
|
||||
var configServiceMock = new Mock<TenantConfigurationService>(
|
||||
configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
configServiceMock
|
||||
.Setup(x => x.TryGetConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((config, true));
|
||||
|
||||
var adminConfigServiceMock = new Mock<AdminConfigurationService>(
|
||||
configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
adminConfigServiceMock
|
||||
.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration());
|
||||
|
||||
return new SecurityValidator(
|
||||
configService: configServiceMock.Object,
|
||||
adminConfigService: adminConfigServiceMock.Object,
|
||||
rateLimitService: new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
|
||||
autopilotDeviceValidator: null!,
|
||||
corporateIdentifierValidator: null!,
|
||||
logger: Mock.Of<ILogger>(),
|
||||
bootstrapSessionService: bootstrapService,
|
||||
deviceAssociationValidator: null);
|
||||
}
|
||||
|
||||
private static HttpRequestData BuildRequest(string url, string? token, params (string Name, string Value)[] extraHeaders)
|
||||
{
|
||||
var contextMock = new Mock<Microsoft.Azure.Functions.Worker.FunctionContext>();
|
||||
contextMock.SetupGet(c => c.Items).Returns(new Dictionary<object, object>());
|
||||
var reqMock = new Mock<HttpRequestData>(contextMock.Object);
|
||||
|
||||
var headers = new HttpHeadersCollection();
|
||||
if (token != null) headers.Add("X-Bootstrap-Token", token);
|
||||
foreach (var (name, value) in extraHeaders) headers.Add(name, value);
|
||||
|
||||
reqMock.SetupGet(r => r.Headers).Returns(headers);
|
||||
reqMock.SetupGet(r => r.Url).Returns(new Uri(url));
|
||||
return reqMock.Object;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// The enrollment-summary branding image URL is delivered verbatim to every enrolling device,
|
||||
/// fetched from the signed-in user's session and spliced into the summary-dialog command line.
|
||||
/// The server must therefore only ever persist a plain absolute HTTPS URL — a value with an
|
||||
/// embedded quote or a non-HTTPS scheme must fail the config PUT/PATCH.
|
||||
/// </summary>
|
||||
public class BrandingImageUrlValidationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("https://cdn.example/logo.png")]
|
||||
[InlineData("https://cdn.example:8443/assets/logo.png?v=2&x=y")]
|
||||
[InlineData("https://cdn.example/logo%20one.png")]
|
||||
public void ValidateBrandingImageUrl_WellFormed_ReturnsNull(string? url)
|
||||
{
|
||||
Assert.Null(TenantConfigValidation.ValidateBrandingImageUrl(url));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("x\" --status-file \"C:\\some\\file\" --timeout \"0", "quotes")]
|
||||
[InlineData("https://cdn.example/logo.png\"", "quotes")]
|
||||
[InlineData("https://cdn.example/lo'go.png", "quotes")]
|
||||
[InlineData("https://cdn.example/logo.png --cleanup", "whitespace")]
|
||||
[InlineData("https://cdn.example/logo.png\r\n", "whitespace")]
|
||||
[InlineData("http://cdn.example/logo.png", "HTTPS")]
|
||||
[InlineData("file:///C:/Windows/logo.png", "HTTPS")]
|
||||
[InlineData("ftp://cdn.example/logo.png", "HTTPS")]
|
||||
[InlineData("cdn.example/logo.png", "absolute URL")]
|
||||
[InlineData("https://localhost/logo.png", "localhost")]
|
||||
[InlineData("https://10.0.0.1/logo.png", "IP address")]
|
||||
public void ValidateBrandingImageUrl_Malformed_Rejected(string url, string expectedFragment)
|
||||
{
|
||||
var error = TenantConfigValidation.ValidateBrandingImageUrl(url);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains(expectedFragment, error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateBrandingImageUrl_Overlong_Rejected()
|
||||
{
|
||||
var url = "https://cdn.example/" + new string('a', TenantConfigValidation.MaxBrandingImageUrlLength);
|
||||
Assert.Contains("characters", TenantConfigValidation.ValidateBrandingImageUrl(url));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateModel_RejectsInjectedBrandingUrl()
|
||||
{
|
||||
var existing = new TenantConfiguration();
|
||||
var candidate = new TenantConfiguration
|
||||
{
|
||||
EnrollmentSummaryBrandingImageUrl = "x\" --status-file \"C:\\some\\file\" --timeout \"0",
|
||||
};
|
||||
|
||||
var error = TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.StartsWith("Invalid enrollment summary branding image URL", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,53 @@ public class CmTraceSkewTripwireTests
|
||||
Assert.Null(CmTraceSkewTripwire.Evaluate(scan));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Silent_OnBacklogReplayBurstWhoseMedianLandsOnGrid()
|
||||
{
|
||||
// Field case 2026-08-28 (session d832560a): the agent died on auth failures, the relaunch
|
||||
// re-tailed the IME log and uploaded 508 lines in one burst with a continuum of ages
|
||||
// (46…733 min, quartiles 52 / 92 / 100) — median 91 min, i.e. 6×15 by pure chance.
|
||||
// The other side was live. The per-sample grid-conformity guard must keep this silent.
|
||||
var scan = MakeScan(imeDeltaMinutes: 0.1, otherDeltaMinutes: 0.1, imeSamples: 100, imeBatches: 80);
|
||||
void AddRamp(double from, double to, int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
scan.ImeDeltaMinutes.Add(from + i * (to - from) / count);
|
||||
}
|
||||
AddRamp(46.0, 88.0, 125);
|
||||
AddRamp(88.0, 100.0, 250);
|
||||
AddRamp(100.0, 733.0, 125);
|
||||
|
||||
var medianIme = scan.ImeDeltaMinutes.OrderBy(v => v).ElementAt(scan.ImeDeltaMinutes.Count / 2);
|
||||
Assert.InRange(medianIme, 88.0, 92.0); // sanity: the median really does sit near 6×15
|
||||
|
||||
Assert.Null(CmTraceSkewTripwire.Evaluate(scan));
|
||||
Assert.True(CmTraceSkewTripwire.ComputeGridConformantFraction(scan.ImeDeltaMinutes, 0.1) < 0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fires_OnTwoWriterErasBothOnGrid()
|
||||
{
|
||||
// e9753578 shape: one file mixing PDT (−420) and CEST (+120) lines relative to the agent
|
||||
// zone. Every line is individually on the grid, so the conformity guard must NOT mask it.
|
||||
var scan = MakeScan(imeDeltaMinutes: -420.0, otherDeltaMinutes: 0.0, imeSamples: 65);
|
||||
for (int i = 0; i < 35; i++)
|
||||
scan.ImeDeltaMinutes.Add(120.0);
|
||||
|
||||
var result = CmTraceSkewTripwire.Evaluate(scan);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(-28, result!.GridSteps);
|
||||
Assert.Equal(1.0, result.GridConformantFraction, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GridConformantFraction_CountsZeroAndAnyMultiple()
|
||||
{
|
||||
var deltas = new List<double> { 0.5, 15.9, -30.1, 7.5, 37.5 }; // last two are off-grid
|
||||
Assert.Equal(0.6, CmTraceSkewTripwire.ComputeGridConformantFraction(deltas, 0.0), 3);
|
||||
Assert.Equal(0, CmTraceSkewTripwire.ComputeGridConformantFraction(new List<double>(), 0.0));
|
||||
}
|
||||
|
||||
// ── Bias suppression predicate ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
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 targeted push signal: "someone clicked Collect Logs and switched on Hosted upload
|
||||
/// through the quick-config dialog" must land as its own ops event type, while every other
|
||||
/// enable path keeps the generic type — an alert rule filters by EventType only.
|
||||
/// </summary>
|
||||
public class CollectLogsQuickConfigOpsEventTests
|
||||
{
|
||||
private static (OpsEventService Service, List<OpsEventEntry> Saved) Rig()
|
||||
{
|
||||
var saved = new List<OpsEventEntry>();
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => { lock (saved) saved.Add(e); })
|
||||
.Returns(Task.CompletedTask);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
return (new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch), saved);
|
||||
}
|
||||
|
||||
private static DiagnosticsUploadConfigChange EnableFlip() =>
|
||||
DiagnosticsUploadConfigChange.Detect(
|
||||
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Off" },
|
||||
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "OnFailure", DiagnosticsUploadDestination = "Hosted" })!;
|
||||
|
||||
[Theory]
|
||||
[InlineData("collect-logs", UpdateTenantConfigurationFunction.CollectLogsSource)]
|
||||
[InlineData("COLLECT-LOGS", UpdateTenantConfigurationFunction.CollectLogsSource)]
|
||||
[InlineData(null, "portal-put")]
|
||||
[InlineData("", "portal-put")]
|
||||
[InlineData("something-else", "portal-put")]
|
||||
public void ResolveWriteSource_only_honours_the_allow_listed_intent(string? intent, string expected)
|
||||
{
|
||||
Assert.Equal(expected, UpdateTenantConfigurationFunction.ResolveWriteSource(intent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Quick_config_enable_lands_as_its_own_event_type()
|
||||
{
|
||||
var (service, saved) = Rig();
|
||||
|
||||
await service.RecordDiagnosticsUploadConfigChangedAsync(
|
||||
"t1", "contoso.com", EnableFlip(), "admin@contoso.com", UpdateTenantConfigurationFunction.CollectLogsSource);
|
||||
|
||||
var evt = Assert.Single(saved);
|
||||
Assert.Equal("CollectLogsQuickConfigEnabled", evt.EventType);
|
||||
Assert.Equal(OpsEventCategory.Tenant, evt.Category);
|
||||
Assert.Contains("admin@contoso.com", evt.Message);
|
||||
Assert.Contains("Hosted", evt.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("portal-put")]
|
||||
[InlineData("mcp-patch")]
|
||||
public async Task Other_enable_paths_keep_the_generic_event_type(string source)
|
||||
{
|
||||
var (service, saved) = Rig();
|
||||
|
||||
await service.RecordDiagnosticsUploadConfigChangedAsync("t1", "contoso.com", EnableFlip(), "admin@contoso.com", source);
|
||||
|
||||
Assert.Equal("DiagnosticsUploadEnabled", Assert.Single(saved).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Quick_config_source_on_a_disable_is_the_generic_disabled_type()
|
||||
{
|
||||
var (service, saved) = Rig();
|
||||
var disable = DiagnosticsUploadConfigChange.Detect(
|
||||
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Always", DiagnosticsUploadDestination = "Hosted" },
|
||||
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Off", DiagnosticsUploadDestination = "Hosted" })!;
|
||||
|
||||
await service.RecordDiagnosticsUploadConfigChangedAsync("t1", null, disable, "ga@x", UpdateTenantConfigurationFunction.CollectLogsSource);
|
||||
|
||||
Assert.Equal("DiagnosticsUploadDisabled", Assert.Single(saved).EventType);
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ public class CriticalTableBackupServiceTests
|
||||
|
||||
Assert.Equal(BackupOutcome.Success, result.Outcome);
|
||||
Assert.True(store.ManifestWritten, "manifest must be written LAST");
|
||||
Assert.Equal(18, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups + SessionAnnotations)
|
||||
Assert.Equal(19, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups + SessionAnnotations + AdminIdentityBindings)
|
||||
Assert.All(result.Manifest.Tables, t =>
|
||||
Assert.True(t.Status == TableBackupStatus.Ok || t.Status == TableBackupStatus.Empty));
|
||||
}
|
||||
|
||||
@@ -68,6 +68,32 @@ public class CveIndexEntityMergeTests
|
||||
Assert.Equal("critical", e.GetString("OverallRisk"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_EpssMaxes_PriorityMaxes_AndUnscoredWritesNoEpssColumn()
|
||||
{
|
||||
var scoredLow = Vuln("CVE-1", 5.0, "MEDIUM"); scoredLow["epssScore"] = 0.02; scoredLow["priority"] = "track";
|
||||
var scoredHigh = Vuln("CVE-1", 4.0, "MEDIUM"); scoredHigh["epssScore"] = 0.35; scoredHigh["priority"] = "attend";
|
||||
var unscored = Vuln("CVE-1", 3.0, "LOW"); unscored["priority"] = "track";
|
||||
|
||||
var e = Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, new List<Dictionary<string, object>>
|
||||
{
|
||||
Finding("App A", "medium", unscored),
|
||||
Finding("App B", "medium", scoredLow),
|
||||
Finding("App C", "medium", scoredHigh),
|
||||
}));
|
||||
|
||||
Assert.Equal(0.35, e.GetDouble("EpssScore"));
|
||||
Assert.Equal("attend", e.GetString("Priority"));
|
||||
|
||||
// A CVE nobody scored must not carry a fake 0.0 — the aggregate treats absent as "unknown".
|
||||
var none = Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, new List<Dictionary<string, object>>
|
||||
{
|
||||
Finding("App A", "low", Vuln("CVE-2", 3.0, "LOW")),
|
||||
}));
|
||||
Assert.False(none.ContainsKey("EpssScore"));
|
||||
Assert.Equal("", none.GetString("Priority"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistinctCves_StayDistinct_AndInvalidEntriesAreSkipped()
|
||||
{
|
||||
|
||||
@@ -23,6 +23,10 @@ public class DelegatedAdminEditionGateTests
|
||||
private const string CommunityHomeTenant = "22222222-2222-2222-2222-222222222222";
|
||||
private const string ManagedTenantA = "33333333-3333-3333-3333-333333333333";
|
||||
private const string ManagedTenantB = "44444444-4444-4444-4444-444444444444";
|
||||
private const string Oid = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
|
||||
|
||||
/// <summary>Caller identity homed in <paramref name="homeTenantId"/>; null/blank ⇒ incomplete identity (null).</summary>
|
||||
private static AdminIdentity? Id(string? homeTenantId) => AdminIdentity.Create(Upn, homeTenantId, Oid);
|
||||
|
||||
private static DelegatedAdminEntry Row(string tenantId) => new()
|
||||
{
|
||||
@@ -43,6 +47,8 @@ public class DelegatedAdminEditionGateTests
|
||||
.ReturnsAsync(new List<TenantGroupAssignment>());
|
||||
return new DelegatedAdminService(
|
||||
repo.Object,
|
||||
// Binding stubbed to "bound": these tests pin the EDITION gate, not the identity check.
|
||||
new StubAdminIdentityBindingService(bound: true),
|
||||
new StubTenantEntitlementService(
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Pro : TenantEdition.Community),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
@@ -55,7 +61,7 @@ public class DelegatedAdminEditionGateTests
|
||||
// Managed targets resolve Community in the stub — they must NOT be dropped.
|
||||
var svc = Build(Row(ManagedTenantA), Row(ManagedTenantB));
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn, EnterpriseHomeTenant);
|
||||
var scope = await svc.GetScopeAsync(Id(EnterpriseHomeTenant));
|
||||
|
||||
Assert.True(scope.Covers(ManagedTenantA));
|
||||
Assert.True(scope.Covers(ManagedTenantB));
|
||||
@@ -66,7 +72,7 @@ public class DelegatedAdminEditionGateTests
|
||||
{
|
||||
var svc = Build(Row(ManagedTenantA), Row(ManagedTenantB));
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn, CommunityHomeTenant);
|
||||
var scope = await svc.GetScopeAsync(Id(CommunityHomeTenant));
|
||||
|
||||
Assert.True(scope.IsEmpty);
|
||||
}
|
||||
@@ -76,8 +82,8 @@ public class DelegatedAdminEditionGateTests
|
||||
{
|
||||
var svc = Build(Row(ManagedTenantA));
|
||||
|
||||
Assert.True((await svc.GetScopeAsync(Upn, null)).IsEmpty);
|
||||
Assert.True((await svc.GetScopeAsync(Upn, "")).IsEmpty);
|
||||
Assert.True((await svc.GetScopeAsync(Id(null))).IsEmpty);
|
||||
Assert.True((await svc.GetScopeAsync(Id(""))).IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -89,9 +95,9 @@ public class DelegatedAdminEditionGateTests
|
||||
// practice but the cache must stay pure either way).
|
||||
var svc = Build(Row(ManagedTenantA));
|
||||
|
||||
Assert.True((await svc.GetScopeAsync(Upn, CommunityHomeTenant)).IsEmpty);
|
||||
Assert.True((await svc.GetScopeAsync(Upn, EnterpriseHomeTenant)).Covers(ManagedTenantA));
|
||||
Assert.True((await svc.GetScopeAsync(Upn, CommunityHomeTenant)).IsEmpty);
|
||||
Assert.True((await svc.GetScopeAsync(Id(CommunityHomeTenant))).IsEmpty);
|
||||
Assert.True((await svc.GetScopeAsync(Id(EnterpriseHomeTenant))).Covers(ManagedTenantA));
|
||||
Assert.True((await svc.GetScopeAsync(Id(CommunityHomeTenant))).IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -117,14 +123,16 @@ public class DelegatedAdminEditionGateTests
|
||||
|
||||
var svc = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
// Binding stubbed to "bound": these tests pin the EDITION gate, not the identity check.
|
||||
new StubAdminIdentityBindingService(bound: true),
|
||||
new StubTenantEntitlementService(
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Pro : TenantEdition.Community),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
|
||||
Assert.True((await svc.GetScopeAsync(Upn, CommunityHomeTenant)).IsEmpty);
|
||||
Assert.True((await svc.GetScopeAsync(Id(CommunityHomeTenant))).IsEmpty);
|
||||
|
||||
var enterpriseScope = await svc.GetScopeAsync(Upn, EnterpriseHomeTenant);
|
||||
var enterpriseScope = await svc.GetScopeAsync(Id(EnterpriseHomeTenant));
|
||||
Assert.True(enterpriseScope.Covers(ManagedTenantA));
|
||||
Assert.True(enterpriseScope.Covers(ManagedTenantB));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -16,6 +17,8 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
public class DelegatedAdminServiceTests
|
||||
{
|
||||
private const string Upn = "msp-admin@partner.example";
|
||||
private const string HomeTenant = "99999999-9999-9999-9999-999999999999";
|
||||
private const string Oid = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
@@ -34,14 +37,20 @@ public class DelegatedAdminServiceTests
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
// Entitlements stubbed to Enterprise for every tenant — these tests pin ROLE resolution;
|
||||
// the edition filter has its own dedicated tests (DelegatedAdminEditionGateTests).
|
||||
// Binding stubbed to "bound" — these tests pin ROW resolution; the identity check has its own
|
||||
// tests (AdminIdentityBindingServiceTests / AdminIdentityBindingAuthorizationTests).
|
||||
var svc = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubAdminIdentityBindingService(bound: true),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
cache,
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
return (svc, repo);
|
||||
}
|
||||
|
||||
/// <summary>The caller identity for a UPN: homed in <see cref="HomeTenant"/> with <see cref="Oid"/>.</summary>
|
||||
private static AdminIdentity Id(string upn) => new(upn.ToLowerInvariant(), HomeTenant, Oid);
|
||||
|
||||
private static TenantGroupAssignment Assignment(
|
||||
string groupId,
|
||||
string role = Constants.DelegatedRoles.DelegatedReader,
|
||||
@@ -78,13 +87,17 @@ public class DelegatedAdminServiceTests
|
||||
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>())).ReturnsAsync(rows.ToList());
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task GetScope_BlankUpn_ReturnsEmpty(string? upn)
|
||||
[InlineData(null, HomeTenant, Oid)]
|
||||
[InlineData("", HomeTenant, Oid)]
|
||||
[InlineData(" ", HomeTenant, Oid)]
|
||||
[InlineData(Upn, null, Oid)]
|
||||
[InlineData(Upn, HomeTenant, null)]
|
||||
public async Task GetScope_IncompleteIdentity_ReturnsEmpty_WithoutTouchingStorage(string? upn, string? tid, string? oid)
|
||||
{
|
||||
// upn + tid + oid are all required: a token missing any of them (app-only, or a foreign IdP shape)
|
||||
// resolves no delegated scope and does not even read the assignment rows.
|
||||
var (svc, repo) = Build();
|
||||
var scope = await svc.GetScopeAsync(upn);
|
||||
var scope = await svc.GetScopeAsync(AdminIdentity.Create(upn, tid, oid));
|
||||
Assert.True(scope.IsEmpty);
|
||||
repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
@@ -93,7 +106,7 @@ public class DelegatedAdminServiceTests
|
||||
public async Task GetScope_NoRows_ReturnsEmpty()
|
||||
{
|
||||
var (svc, _) = Build();
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
Assert.True(scope.IsEmpty);
|
||||
Assert.False(scope.Covers(TenantA));
|
||||
}
|
||||
@@ -104,7 +117,7 @@ public class DelegatedAdminServiceTests
|
||||
var (svc, repo) = Build();
|
||||
Returns(repo, Row(TenantA, Constants.DelegatedRoles.DelegatedReader));
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.True(scope.Covers(TenantA));
|
||||
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantA));
|
||||
@@ -118,7 +131,7 @@ public class DelegatedAdminServiceTests
|
||||
var (svc, repo) = Build();
|
||||
Returns(repo, Row(TenantA, Constants.DelegatedRoles.DelegatedAdmin));
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.True(scope.CanWrite(TenantA));
|
||||
}
|
||||
@@ -131,7 +144,7 @@ public class DelegatedAdminServiceTests
|
||||
var (svc, repo) = Build();
|
||||
Returns(repo, Row(TenantA, status: status));
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.False(scope.Covers(TenantA));
|
||||
Assert.True(scope.IsEmpty);
|
||||
@@ -143,7 +156,7 @@ public class DelegatedAdminServiceTests
|
||||
var (svc, repo) = Build();
|
||||
Returns(repo, Row(TenantA, enabled: false));
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.False(scope.Covers(TenantA));
|
||||
}
|
||||
@@ -154,7 +167,7 @@ public class DelegatedAdminServiceTests
|
||||
var (svc, repo) = Build();
|
||||
Returns(repo, Row(TenantA, role: ""));
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantA));
|
||||
}
|
||||
@@ -165,7 +178,7 @@ public class DelegatedAdminServiceTests
|
||||
var (svc, repo) = Build();
|
||||
Returns(repo, Row(TenantA, role: "SuperRoot"));
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.False(scope.Covers(TenantA));
|
||||
Assert.True(scope.IsEmpty);
|
||||
@@ -179,7 +192,7 @@ public class DelegatedAdminServiceTests
|
||||
Row(TenantA, Constants.DelegatedRoles.DelegatedReader),
|
||||
Row(TenantB, Constants.DelegatedRoles.DelegatedAdmin));
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.Equal(2, scope.TenantIds.Count);
|
||||
Assert.False(scope.CanWrite(TenantA));
|
||||
@@ -192,7 +205,7 @@ public class DelegatedAdminServiceTests
|
||||
var (svc, repo) = Build();
|
||||
Returns(repo, Row(TenantA.ToLowerInvariant()));
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.True(scope.Covers(TenantA.ToUpperInvariant()));
|
||||
}
|
||||
@@ -203,8 +216,8 @@ public class DelegatedAdminServiceTests
|
||||
var (svc, repo) = Build();
|
||||
Returns(repo, Row(TenantA));
|
||||
|
||||
await svc.GetScopeAsync(Upn);
|
||||
await svc.GetScopeAsync(Upn);
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
@@ -219,10 +232,11 @@ public class DelegatedAdminServiceTests
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
await svc.GetScopeAsync(Upn); // primes cache
|
||||
await svc.GetScopeAsync(Id(Upn)); // primes cache
|
||||
await svc.UpsertAsync(Upn, TenantB, Constants.DelegatedRoles.DelegatedReader,
|
||||
Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, "ga@vendor.example");
|
||||
await svc.GetScopeAsync(Upn); // must re-query after invalidation
|
||||
Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, "ga@vendor.example",
|
||||
HomeTenant, null);
|
||||
await svc.GetScopeAsync(Id(Upn)); // must re-query after invalidation
|
||||
|
||||
repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()), Times.Exactly(2));
|
||||
}
|
||||
@@ -235,9 +249,9 @@ public class DelegatedAdminServiceTests
|
||||
repo.Setup(r => r.SetDelegatedAdminStatusAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
await svc.GetScopeAsync(Upn);
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
await svc.SetStatusAsync(Upn, TenantA, Constants.DelegatedStatus.Revoked);
|
||||
await svc.GetScopeAsync(Upn);
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()), Times.Exactly(2));
|
||||
}
|
||||
@@ -251,7 +265,7 @@ public class DelegatedAdminServiceTests
|
||||
ReturnsGroups(repo, Assignment(GroupId1, Constants.DelegatedRoles.DelegatedReader));
|
||||
GroupTenants(repo, GroupId1, TenantA, TenantB);
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.Equal(2, scope.TenantIds.Count);
|
||||
Assert.True(scope.Covers(TenantA));
|
||||
@@ -267,7 +281,7 @@ public class DelegatedAdminServiceTests
|
||||
ReturnsGroups(repo, Assignment(GroupId1));
|
||||
GroupTenants(repo, GroupId1, TenantB, TenantC);
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.Equal(3, scope.TenantIds.Count);
|
||||
Assert.True(scope.Covers(TenantA));
|
||||
@@ -282,7 +296,7 @@ public class DelegatedAdminServiceTests
|
||||
ReturnsGroups(repo, Assignment(GroupId1, enabled: false));
|
||||
GroupTenants(repo, GroupId1, TenantA);
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.True(scope.IsEmpty);
|
||||
Assert.False(scope.Covers(TenantA));
|
||||
@@ -295,7 +309,7 @@ public class DelegatedAdminServiceTests
|
||||
ReturnsGroups(repo, Assignment(GroupId1, role: "SuperRoot"));
|
||||
GroupTenants(repo, GroupId1, TenantA);
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.True(scope.IsEmpty);
|
||||
}
|
||||
@@ -307,7 +321,7 @@ public class DelegatedAdminServiceTests
|
||||
ReturnsGroups(repo, Assignment(GroupId1));
|
||||
GroupTenants(repo, GroupId1); // no tenants
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.True(scope.IsEmpty);
|
||||
}
|
||||
@@ -320,7 +334,7 @@ public class DelegatedAdminServiceTests
|
||||
GroupTenants(repo, GroupId1, TenantA);
|
||||
GroupTenants(repo, GroupId2, TenantA, TenantB);
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.Equal(2, scope.TenantIds.Count);
|
||||
Assert.True(scope.Covers(TenantA));
|
||||
@@ -335,7 +349,7 @@ public class DelegatedAdminServiceTests
|
||||
ReturnsGroups(repo, Assignment(GroupId1, Constants.DelegatedRoles.DelegatedAdmin));
|
||||
GroupTenants(repo, GroupId1, TenantA);
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.True(scope.CanWrite(TenantA)); // stronger DelegatedAdmin wins across sources
|
||||
}
|
||||
@@ -348,7 +362,7 @@ public class DelegatedAdminServiceTests
|
||||
ReturnsGroups(repo, Assignment(GroupId1, Constants.DelegatedRoles.DelegatedReader));
|
||||
GroupTenants(repo, GroupId1, TenantA);
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn);
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.True(scope.CanWrite(TenantA)); // direct DelegatedAdmin not downgraded by group reader
|
||||
}
|
||||
@@ -360,8 +374,8 @@ public class DelegatedAdminServiceTests
|
||||
ReturnsGroups(repo, Assignment(GroupId1));
|
||||
GroupTenants(repo, GroupId1, TenantA);
|
||||
|
||||
await svc.GetScopeAsync(Upn);
|
||||
await svc.GetScopeAsync(Upn);
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()), Times.Once);
|
||||
repo.Verify(r => r.GetGroupTenantsAsync(GroupId1), Times.Once);
|
||||
@@ -380,9 +394,9 @@ public class DelegatedAdminServiceTests
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
await svc.GetScopeAsync(Upn); // primes cache
|
||||
await svc.AssignGroupAsync(Upn, GroupId2, Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example");
|
||||
await svc.GetScopeAsync(Upn); // must re-resolve after invalidation
|
||||
await svc.GetScopeAsync(Id(Upn)); // primes cache
|
||||
await svc.AssignGroupAsync(Upn, GroupId2, Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example", HomeTenant, null);
|
||||
await svc.GetScopeAsync(Id(Upn)); // must re-resolve after invalidation
|
||||
|
||||
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()), Times.Exactly(2));
|
||||
}
|
||||
@@ -397,7 +411,7 @@ public class DelegatedAdminServiceTests
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var assigned = await svc.AssignGroupAsync(
|
||||
Upn, GroupId1, Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example");
|
||||
Upn, GroupId1, Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example", HomeTenant, null);
|
||||
|
||||
Assert.False(assigned);
|
||||
repo.Verify(r => r.AssignGroupAsync(
|
||||
@@ -412,9 +426,9 @@ public class DelegatedAdminServiceTests
|
||||
GroupTenants(repo, GroupId1, TenantA);
|
||||
repo.Setup(r => r.UnassignGroupAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
|
||||
await svc.GetScopeAsync(Upn);
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
var unassigned = await svc.UnassignGroupAsync(Upn, GroupId1);
|
||||
await svc.GetScopeAsync(Upn);
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.True(unassigned);
|
||||
// Verify re-resolution via the tenant expansion (the unassign now also reads assignments itself,
|
||||
@@ -459,11 +473,11 @@ public class DelegatedAdminServiceTests
|
||||
new() { Upn = upnB, GroupId = GroupId1, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true },
|
||||
});
|
||||
|
||||
await svc.GetScopeAsync(Upn); // primes A
|
||||
await svc.GetScopeAsync(upnB); // primes B
|
||||
await svc.GetScopeAsync(Id(Upn)); // primes A
|
||||
await svc.GetScopeAsync(Id(upnB)); // primes B
|
||||
var removed = await svc.RemoveTenantFromGroupAsync(GroupId1, TenantA);
|
||||
await svc.GetScopeAsync(Upn); // re-resolves A
|
||||
await svc.GetScopeAsync(upnB); // re-resolves B
|
||||
await svc.GetScopeAsync(Id(Upn)); // re-resolves A
|
||||
await svc.GetScopeAsync(Id(upnB)); // re-resolves B
|
||||
|
||||
Assert.True(removed);
|
||||
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(Upn), Times.Exactly(2));
|
||||
@@ -536,9 +550,9 @@ public class DelegatedAdminServiceTests
|
||||
new() { Upn = Upn, GroupId = GroupId1, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true },
|
||||
});
|
||||
|
||||
await svc.GetScopeAsync(Upn);
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
await svc.DeleteGroupAsync(GroupId1);
|
||||
await svc.GetScopeAsync(Upn);
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(Upn), Times.Exactly(2));
|
||||
}
|
||||
@@ -555,7 +569,7 @@ public class DelegatedAdminServiceTests
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var assigned = await svc.AssignGroupAsync(
|
||||
Upn, "TPL-LOWER", Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example");
|
||||
Upn, "TPL-LOWER", Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example", HomeTenant, null);
|
||||
|
||||
Assert.True(assigned);
|
||||
// Repo was looked up + written with the normalized (lowercase) id.
|
||||
@@ -572,9 +586,9 @@ public class DelegatedAdminServiceTests
|
||||
GroupTenants(repo, GroupId1, TenantA);
|
||||
repo.Setup(r => r.RenameTenantGroupAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
|
||||
await svc.GetScopeAsync(Upn);
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
await svc.RenameGroupAsync(GroupId1, "Renamed");
|
||||
await svc.GetScopeAsync(Upn); // name-only change → scope stays cached
|
||||
await svc.GetScopeAsync(Id(Upn)); // name-only change → scope stays cached
|
||||
|
||||
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Collect Logs capability flip that feeds the DiagnosticsUploadEnabled/Disabled ops
|
||||
/// events: only a change of "would an on-demand upload work" counts, never a plain edit.
|
||||
/// </summary>
|
||||
public class DiagnosticsUploadConfigChangeTests
|
||||
{
|
||||
private static TenantConfiguration Cfg(string? mode, string? destination = null, string? sas = null) => new()
|
||||
{
|
||||
TenantId = "t1",
|
||||
DiagnosticsUploadMode = mode!,
|
||||
DiagnosticsUploadDestination = destination!,
|
||||
DiagnosticsBlobSasUrl = sas!,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, null, false)]
|
||||
[InlineData("Off", "Hosted", null, false)]
|
||||
[InlineData("OnFailure", null, null, false)]
|
||||
[InlineData("OnFailure", "Hosted", null, true)]
|
||||
[InlineData("Always", "CustomerSas", "https://x.blob.core.windows.net/c?sig=1", true)]
|
||||
[InlineData("Always", null, "https://x.blob.core.windows.net/c?sig=1", true)]
|
||||
public void IsConfigured_mirrors_the_feature_flag_semantics(string? mode, string? dest, string? sas, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, DiagnosticsUploadConfigChange.IsConfigured(Cfg(mode, dest, sas)));
|
||||
Assert.False(DiagnosticsUploadConfigChange.IsConfigured(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_quick_config_flip_reports_enabled_with_destination_and_mode()
|
||||
{
|
||||
// The Collect Logs quick-config dialog: Off/unset -> Hosted + OnFailure.
|
||||
var change = DiagnosticsUploadConfigChange.Detect(Cfg("Off"), Cfg("OnFailure", "Hosted"));
|
||||
|
||||
Assert.NotNull(change);
|
||||
Assert.True(change!.Enabled);
|
||||
Assert.Equal("Hosted", change.Destination);
|
||||
Assert.Equal("OnFailure", change.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_missing_before_row_counts_as_not_configured()
|
||||
{
|
||||
var change = DiagnosticsUploadConfigChange.Detect(null, Cfg("Always", "Hosted"));
|
||||
Assert.True(change?.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_reports_disabled_when_mode_goes_off()
|
||||
{
|
||||
var change = DiagnosticsUploadConfigChange.Detect(Cfg("Always", "Hosted"), Cfg("Off", "Hosted"));
|
||||
|
||||
Assert.NotNull(change);
|
||||
Assert.False(change!.Enabled);
|
||||
Assert.Equal("Off", change.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_ignores_edits_that_keep_the_capability_state()
|
||||
{
|
||||
// Mode change while staying on: no flip.
|
||||
Assert.Null(DiagnosticsUploadConfigChange.Detect(Cfg("OnFailure", "Hosted"), Cfg("Always", "Hosted")));
|
||||
// Destination not yet usable in both states: no flip.
|
||||
Assert.Null(DiagnosticsUploadConfigChange.Detect(Cfg("Off"), Cfg("OnFailure")));
|
||||
// Unchanged.
|
||||
Assert.Null(DiagnosticsUploadConfigChange.Detect(Cfg("Off"), Cfg("Off")));
|
||||
}
|
||||
}
|
||||
@@ -75,11 +75,14 @@ public sealed class EmailServiceTests
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "");
|
||||
|
||||
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
|
||||
var sent = await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
|
||||
|
||||
Assert.False(sent);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
// Warning, not Debug: an unconfigured provider is operator misconfiguration, and worker
|
||||
// logs below Warning never reach Application Insights — Debug made it invisible.
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("Email:ApiKey not configured"));
|
||||
}
|
||||
|
||||
// ----- wire format -----
|
||||
@@ -244,6 +247,18 @@ public sealed class EmailServiceTests
|
||||
Assert.Contains("linkedin.com", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInTemplates_HtmlEncodeDomain()
|
||||
{
|
||||
const string hostile = "<a href=\"https://evil.invalid\">x</a>";
|
||||
|
||||
foreach (var html in new[] { EmailTemplates.GetOffboardingFarewellHtml(hostile), EmailTemplates.GetPreviewApprovedHtml(hostile) })
|
||||
{
|
||||
Assert.DoesNotContain(hostile, html);
|
||||
Assert.Contains("<a href="https://evil.invalid">x</a>", html);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
|
||||
{
|
||||
|
||||
@@ -29,6 +29,30 @@ public sealed class EmailTemplateServiceTests
|
||||
Assert.DoesNotContain(EmailTemplateService.DomainPlaceholder, html);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("<b>x</b>", "<b>x</b>")]
|
||||
[InlineData("a\"b&c", "a"b&c")]
|
||||
public void Render_HtmlEncodesDomain_ForOverrideTemplates(string domain, string expected)
|
||||
{
|
||||
var html = EmailTemplateService.Render("<p>{{domainName}}</p>", domain);
|
||||
|
||||
Assert.Equal($"<p>{expected}</p>", html);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EmailTemplateKind.Welcome)]
|
||||
[InlineData(EmailTemplateKind.Farewell)]
|
||||
public async Task BuiltIn_HtmlEncodesDomain_NoDoubleEncoding(EmailTemplateKind kind)
|
||||
{
|
||||
var (sut, _) = Build();
|
||||
|
||||
var html = await sut.GetHtmlAsync(kind, "<img src=x onerror=alert(1)>&\"");
|
||||
|
||||
Assert.DoesNotContain("<img", html);
|
||||
Assert.Contains("<img src=x onerror=alert(1)>&"", html);
|
||||
Assert.DoesNotContain("&lt;", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Override_WinsAndRendersPlaceholder()
|
||||
{
|
||||
|
||||
@@ -394,14 +394,91 @@ public class EnrollmentTimeoutClassifierTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_agent_timeout_failed_session_classifies_honestly()
|
||||
public void Classify_agent_timeout_terminalizes_immediately_instead_of_parking()
|
||||
{
|
||||
// WG Part-2 shape (session 1506ce9f): DeviceSetup 4/4, no user phase, watchdog fired at
|
||||
// 6h — honest verdict is AwaitingUser (within grace), never Failed.
|
||||
// The watchdog firing proves the agent is gone for good — an AwaitingUser park from this
|
||||
// state is a countdown, not a wait: all 5 observed maxlife:r5_awaiting episodes expired
|
||||
// unhealed (calibration read 2026-08-27). Still never Failed, and late straggler
|
||||
// telemetry can heal the Incomplete (TryLateTelemetryReconcileAsync).
|
||||
var timeout = Evt("enrollment_failed",
|
||||
data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
|
||||
var (status, _) = Classify(new[] { Esp(DeviceSetup44), timeout }, hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.AwaitingUser, status);
|
||||
var (status, reason) = Classify(new[] { Esp(DeviceSetup44), timeout }, hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.Incomplete, status);
|
||||
Assert.Contains("max-lifetime watchdog", reason);
|
||||
|
||||
// The V2 shutdown shape carries the same fact.
|
||||
var shutdown = Evt("agent_shutting_down",
|
||||
data: new Dictionary<string, object> { ["reason"] = "max_lifetime" });
|
||||
var (viaShutdown, _) = Classify(new[] { Esp(DeviceSetup44), shutdown }, hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.Incomplete, viaShutdown);
|
||||
|
||||
// WhiteGlove Part 2 (rule 1b) is deliberately untouched: a sealed/powered-off device
|
||||
// between technician and end user stays AwaitingUser — that park DOES heal
|
||||
// (the observed maxlife:r1b_awaiting episode resolved to Succeeded).
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(DeviceSetup44), timeout });
|
||||
var (wgStatus, _, wgRule) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, Start.AddHours(6), 72, isPreProvisioned: true, resumedAt: Start.AddHours(1));
|
||||
Assert.Equal(SessionStatus.AwaitingUser, wgStatus);
|
||||
Assert.Equal(ClassifierRules.R1bWhiteGloveAwaiting, wgRule);
|
||||
}
|
||||
|
||||
// -------- Rule 5a: completed (assumed) — calibration read 2026-08-27 --------
|
||||
|
||||
[Fact]
|
||||
public void ExtractRollup_detects_app_failures_and_maxlife_timeout()
|
||||
{
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("app_install_failed") }).HasAppInstallFailure);
|
||||
var timeout = Evt("enrollment_failed", data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { timeout }).HasAgentMaxLifetimeTimeout);
|
||||
var shutdown = Evt("agent_shutting_down", data: new Dictionary<string, object> { ["reason"] = "max_lifetime" });
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { shutdown }).HasAgentMaxLifetimeTimeout);
|
||||
// Other shutdown reasons say nothing about max lifetime.
|
||||
var ctrlC = Evt("agent_shutting_down", data: new Dictionary<string, object> { ["reason"] = "ctrl_c" });
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { ctrlC }).HasAgentMaxLifetimeTimeout);
|
||||
// A real failure event does not raise the app flag.
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("enrollment_failed") }).HasAppInstallFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_desktop_without_hello_past_grace_is_assumed_complete()
|
||||
{
|
||||
// The healthy half of the r5 population (drill-down 2026-08-27): desktop reached, apps
|
||||
// clean, agent died before the Hello terminal. Past the full grace "user phase still
|
||||
// running" is no longer a possible explanation — completed (assumed), not a red Incomplete.
|
||||
var (status, reason, rule) = ClassifyWithRule(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Esp(AccountSetup15), Evt("desktop_arrived"),
|
||||
}, hoursSinceStart: 80, grace: 72);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
Assert.Equal(ClassifierRules.R5DesktopAssumed, rule);
|
||||
Assert.Contains("treating the enrollment as completed", reason);
|
||||
Assert.Contains("Agent last reported", reason); // silence-transparency clause
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_desktop_with_app_failure_past_grace_stays_Incomplete()
|
||||
{
|
||||
// "Completed" must not overclaim a session whose ESP payload provably failed
|
||||
// (drill-down session b1edc6f8: desktop reached but .NET 3.5 install failed).
|
||||
var (status, _) = Classify(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("desktop_arrived"), Evt("app_install_failed"),
|
||||
}, hoursSinceStart: 80, grace: 72);
|
||||
Assert.Equal(SessionStatus.Incomplete, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_maxlife_with_desktop_and_clean_apps_is_assumed_complete_within_grace()
|
||||
{
|
||||
// Agent provably gone + desktop + clean apps: no reason to park — the assumption is as
|
||||
// good now as it would be after the 51h grace.
|
||||
var timeout = Evt("enrollment_failed", data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
|
||||
var (status, _, rule) = ClassifyWithRule(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("desktop_arrived"), timeout,
|
||||
}, hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
Assert.Equal(ClassifierRules.R5DesktopAssumed, rule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelationService;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the EPSS integration and the NVD throttle contract:
|
||||
/// FIRST's envelope parses with invariant-culture string numbers, a 429 opens a bounded cooldown
|
||||
/// (Retry-After honoured but capped), the NVD→cache projection carries the CVSS vector, EPSS
|
||||
/// application never erases an existing score, and the act/attend/track priority is exactly the
|
||||
/// three documented rules.
|
||||
/// </summary>
|
||||
public class EpssAndNvdThrottleTests
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// EpssApiClient
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private const string SampleEnvelope = """
|
||||
{"status":"OK","status-code":200,"version":"1.0","access":"public","total":2,"offset":0,"limit":100,
|
||||
"data":[
|
||||
{"cve":"CVE-2024-21447","epss":"0.00383","percentile":"0.73461","date":"2026-08-28"},
|
||||
{"cve":"cve-2023-4863","epss":"0.94210","percentile":"0.99891","date":"2026-08-28"},
|
||||
{"cve":"CVE-2020-0001","epss":"not-a-number","percentile":"0.5","date":"2026-08-28"},
|
||||
{"cve":"","epss":"0.1","percentile":"0.5","date":"2026-08-28"}
|
||||
]}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void ParseResponse_ParsesInvariantStrings_AndDropsUnparseableRows()
|
||||
{
|
||||
var scores = EpssApiClient.ParseResponse(SampleEnvelope);
|
||||
|
||||
Assert.Equal(2, scores.Count);
|
||||
var a = scores.Single(s => s.CveId == "CVE-2024-21447");
|
||||
Assert.Equal(0.00383, a.Score, 6);
|
||||
Assert.Equal(0.73461, a.Percentile, 6);
|
||||
Assert.Equal("2026-08-28", a.Date);
|
||||
// Ids are normalised to upper case so cache lookups are stable.
|
||||
Assert.Contains(scores, s => s.CveId == "CVE-2023-4863");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseResponse_EmptyOrMissingData_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(EpssApiClient.ParseResponse("""{"status":"OK","data":[]}"""));
|
||||
Assert.Empty(EpssApiClient.ParseResponse("""{"status":"OK"}"""));
|
||||
}
|
||||
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
public List<Uri> Requests { get; } = new();
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
|
||||
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) => _respond = respond;
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
Requests.Add(request.RequestUri!);
|
||||
return Task.FromResult(_respond(request));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScoresAsync_BatchesByHundred_DedupesIds_AndSurvivesAFailedBatch()
|
||||
{
|
||||
int call = 0;
|
||||
var handler = new StubHandler(req =>
|
||||
{
|
||||
call++;
|
||||
if (call == 2)
|
||||
return new HttpResponseMessage(HttpStatusCode.InternalServerError);
|
||||
// Echo the first id of the batch back as scored.
|
||||
var query = Uri.UnescapeDataString(req.RequestUri!.Query);
|
||||
var firstId = query.Substring("?cve=".Length).Split(',')[0];
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent($$"""{"status":"OK","data":[{"cve":"{{firstId}}","epss":"0.5","percentile":"0.9","date":"2026-08-28"}]}""")
|
||||
};
|
||||
});
|
||||
var client = new EpssApiClient(new HttpClient(handler), NullLogger<EpssApiClient>.Instance);
|
||||
|
||||
// 150 distinct ids + 10 duplicates (case-varied) ⇒ exactly two requests.
|
||||
var ids = Enumerable.Range(1, 150).Select(i => $"CVE-2024-{10000 + i}").ToList();
|
||||
ids.AddRange(ids.Take(10).Select(id => id.ToLowerInvariant()));
|
||||
|
||||
var result = await client.GetScoresAsync(ids);
|
||||
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
Assert.Single(result); // batch 1 scored one id, batch 2 failed soft
|
||||
Assert.True(result.ContainsKey("cve-2024-10001")); // case-insensitive map
|
||||
Assert.All(handler.Requests, u => Assert.StartsWith(EpssApiClient.EpssUrl, u.ToString()));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// NVD throttle cooldown
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static readonly DateTime Now = new(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void ComputeCooldown_NoHeader_UsesDefault()
|
||||
{
|
||||
Assert.Equal(NvdApiClient.ThrottleCooldown, NvdApiClient.ComputeCooldown(null, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeCooldown_RetryAfterDelta_IsHonoured()
|
||||
{
|
||||
var header = new RetryConditionHeaderValue(TimeSpan.FromSeconds(45));
|
||||
Assert.Equal(TimeSpan.FromSeconds(45), NvdApiClient.ComputeCooldown(header, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeCooldown_RetryAfterDate_IsRelativeToNow()
|
||||
{
|
||||
var header = new RetryConditionHeaderValue(new DateTimeOffset(Now.AddSeconds(20)));
|
||||
Assert.Equal(TimeSpan.FromSeconds(20), NvdApiClient.ComputeCooldown(header, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeCooldown_IsCapped_SoAServerCannotParkUsForAnHour()
|
||||
{
|
||||
var header = new RetryConditionHeaderValue(TimeSpan.FromHours(1));
|
||||
Assert.Equal(NvdApiClient.MaxRetryAfter, NvdApiClient.ComputeCooldown(header, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeCooldown_PastDateOrZero_FallsBackToDefault()
|
||||
{
|
||||
Assert.Equal(NvdApiClient.ThrottleCooldown,
|
||||
NvdApiClient.ComputeCooldown(new RetryConditionHeaderValue(new DateTimeOffset(Now.AddSeconds(-5))), Now));
|
||||
Assert.Equal(NvdApiClient.ThrottleCooldown,
|
||||
NvdApiClient.ComputeCooldown(new RetryConditionHeaderValue(TimeSpan.Zero), Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NvdFetchResult_FailureShapes_CarryEmptyDataButNeverSucceeded()
|
||||
{
|
||||
var throttled = NvdFetchResult<NvdCveResponse>.ThrottledResult();
|
||||
Assert.False(throttled.Succeeded);
|
||||
Assert.True(throttled.Throttled);
|
||||
Assert.NotNull(throttled.Data);
|
||||
Assert.Empty(throttled.Data.Vulnerabilities);
|
||||
|
||||
var failed = NvdFetchResult<NvdCveResponse>.Failed();
|
||||
Assert.False(failed.Succeeded);
|
||||
Assert.False(failed.Throttled);
|
||||
|
||||
var ok = NvdFetchResult<NvdCveResponse>.Ok(new NvdCveResponse { TotalResults = 1 });
|
||||
Assert.True(ok.Succeeded);
|
||||
Assert.Equal(1, ok.Data.TotalResults);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// NVD → CachedCve projection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ProjectNvdResponse_CarriesVector_PrefersV31_AndKeepsOnlyVulnerableRanges()
|
||||
{
|
||||
var response = new NvdCveResponse
|
||||
{
|
||||
Vulnerabilities =
|
||||
{
|
||||
new NvdVulnerabilityItem
|
||||
{
|
||||
Cve = new NvdCve
|
||||
{
|
||||
Id = "CVE-2024-1",
|
||||
Published = "2026-01-01T00:00:00.000",
|
||||
Descriptions = { new NvdDescription { Lang = "en", Value = "desc" } },
|
||||
Metrics = new NvdMetrics
|
||||
{
|
||||
CvssMetricV30 = { new NvdCvssMetricV31 { CvssData = new NvdCvssData { BaseScore = 5, BaseSeverity = "MEDIUM", VectorString = "CVSS:3.0/AV:L" } } },
|
||||
CvssMetricV31 = { new NvdCvssMetricV31 { CvssData = new NvdCvssData { BaseScore = 9.8, BaseSeverity = "CRITICAL", VectorString = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" } } },
|
||||
},
|
||||
Configurations =
|
||||
{
|
||||
new NvdConfiguration
|
||||
{
|
||||
Nodes =
|
||||
{
|
||||
new NvdNode
|
||||
{
|
||||
CpeMatch =
|
||||
{
|
||||
new NvdCpeMatch { Vulnerable = true, Criteria = "cpe:2.3:a:v:p:*:*:*:*:*:*:*:*", VersionEndExcluding = "2.0" },
|
||||
new NvdCpeMatch { Vulnerable = false, Criteria = "cpe:2.3:o:microsoft:windows:*:*:*:*:*:*:*:*" },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
new NvdVulnerabilityItem
|
||||
{
|
||||
Cve = new NvdCve { Id = "CVE-2024-2", Descriptions = { new NvdDescription { Lang = "en", Value = "no metrics" } } }
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
var cves = VulnerabilityCorrelationService.ProjectNvdResponse(response);
|
||||
|
||||
Assert.Equal(2, cves.Count);
|
||||
var first = cves[0];
|
||||
Assert.Equal(9.8, first.CvssScore);
|
||||
Assert.Equal("CRITICAL", first.CvssSeverity);
|
||||
Assert.Equal("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", first.CvssVector);
|
||||
Assert.Single(first.AffectedVersions);
|
||||
Assert.Equal("2.0", first.AffectedVersions[0].VersionEndExcluding);
|
||||
Assert.Null(first.EpssScore); // never defaulted before enrichment
|
||||
|
||||
var second = cves[1];
|
||||
Assert.Equal(0, second.CvssScore);
|
||||
Assert.Equal("UNKNOWN", second.CvssSeverity);
|
||||
Assert.Null(second.CvssVector);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectNvdResponse_Null_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(VulnerabilityCorrelationService.ProjectNvdResponse(null));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// EPSS application onto cached CVEs
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ApplyEpssScores_SetsNewScores_KeepsUnscoredUntouched_AndReportsOnlyChanges()
|
||||
{
|
||||
var cves = new List<CachedCve>
|
||||
{
|
||||
new() { CveId = "CVE-1", EpssScore = 0.2, EpssPercentile = 0.8, EpssDate = "2026-08-27" },
|
||||
new() { CveId = "CVE-2" },
|
||||
new() { CveId = "CVE-3", EpssScore = 0.5, EpssPercentile = 0.9, EpssDate = "2026-08-28" },
|
||||
};
|
||||
var scores = new Dictionary<string, EpssScore>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["cve-1"] = new EpssScore { CveId = "CVE-1", Score = 0.25, Percentile = 0.85, Date = "2026-08-28" }, // changed
|
||||
["CVE-3"] = new EpssScore { CveId = "CVE-3", Score = 0.5, Percentile = 0.9, Date = "2026-08-28" }, // unchanged
|
||||
// CVE-2 absent: FIRST did not score it (or its batch failed) ⇒ stays null
|
||||
};
|
||||
|
||||
var changed = VulnerabilityCorrelationService.ApplyEpssScores(cves, scores);
|
||||
|
||||
Assert.Equal(1, changed);
|
||||
Assert.Equal(0.25, cves[0].EpssScore);
|
||||
Assert.Equal("2026-08-28", cves[0].EpssDate);
|
||||
Assert.Null(cves[1].EpssScore);
|
||||
Assert.Equal(0.5, cves[2].EpssScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyEpssScores_NeverErasesYesterdaysScore_WhenTodayHasNone()
|
||||
{
|
||||
var cves = new List<CachedCve> { new() { CveId = "CVE-1", EpssScore = 0.3, EpssPercentile = 0.7, EpssDate = "2026-08-27" } };
|
||||
var changed = VulnerabilityCorrelationService.ApplyEpssScores(cves, new Dictionary<string, EpssScore>());
|
||||
Assert.Equal(0, changed);
|
||||
Assert.Equal(0.3, cves[0].EpssScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCveCacheRow_PassesCachedAtThrough_SoEpssRewritesDoNotLookLikeNvdFetches()
|
||||
{
|
||||
var cachedAt = new DateTime(2026, 8, 20, 3, 0, 0, DateTimeKind.Utc);
|
||||
var row = VulnerabilityCorrelationService.BuildCveCacheRow("cpe:2.3:a:v:p", new List<CachedCve> { new() { CveId = "CVE-1" } }, cachedAt);
|
||||
|
||||
Assert.Equal(cachedAt.ToString("o"), row["CachedAt"]);
|
||||
Assert.Equal("cpe:2.3:a:v:p", row["CpeUri"]);
|
||||
Assert.Equal(1, row["TotalCves"]);
|
||||
Assert.True(row.ContainsKey("CveDataJson"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CachedCve_RoundTrips_WithoutWritingNulls_SoLegacyRowsStayReadable()
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(new List<CachedCve> { new() { CveId = "CVE-1", CvssScore = 7 } }, JsonOptions);
|
||||
Assert.DoesNotContain("EpssScore", json);
|
||||
Assert.DoesNotContain("CvssVector", json);
|
||||
|
||||
// A row written before this change (no EPSS/vector properties at all) still deserialises.
|
||||
var legacy = """[{"CveId":"CVE-9","CvssScore":4.3,"CvssSeverity":"MEDIUM","Description":"","PublishedDate":"","AffectedVersions":[]}]""";
|
||||
var cves = System.Text.Json.JsonSerializer.Deserialize<List<CachedCve>>(legacy, JsonOptions)!;
|
||||
Assert.Null(cves[0].EpssScore);
|
||||
Assert.Null(cves[0].CvssVector);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Priority
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, null, 0.0, CvePriority.Act)]
|
||||
[InlineData(true, 0.001, 2.0, CvePriority.Act)]
|
||||
[InlineData(false, 0.1, 2.0, CvePriority.Attend)]
|
||||
[InlineData(false, 0.0999, 2.0, CvePriority.Track)]
|
||||
[InlineData(false, null, 9.0, CvePriority.Attend)]
|
||||
[InlineData(false, 0.0, 9.8, CvePriority.Attend)]
|
||||
[InlineData(false, null, 8.9, CvePriority.Track)]
|
||||
[InlineData(false, 0.05, 7.5, CvePriority.Track)]
|
||||
public void Compute_FollowsTheThreeRules(bool kev, double? epss, double cvss, string expected)
|
||||
{
|
||||
Assert.Equal(expected, CvePriority.Compute(kev, epss, cvss));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Max_PicksHigherBand_AndTreatsUnknownAsLowest()
|
||||
{
|
||||
Assert.Equal(CvePriority.Act, CvePriority.Max(CvePriority.Track, CvePriority.Act));
|
||||
Assert.Equal(CvePriority.Attend, CvePriority.Max(CvePriority.Attend, null));
|
||||
Assert.Equal(CvePriority.Track, CvePriority.Max(null, ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StampFindingPriority_RollsUpHighestBand_AndMaxEpss()
|
||||
{
|
||||
var finding = new Dictionary<string, object> { ["maxEpssScore"] = 0.9 /* stale value, must be recomputed */ };
|
||||
var vulns = new List<object>
|
||||
{
|
||||
new Dictionary<string, object> { ["priority"] = CvePriority.Track, ["epssScore"] = 0.02 },
|
||||
new Dictionary<string, object> { ["priority"] = CvePriority.Attend, ["epssScore"] = 0.4 },
|
||||
new Dictionary<string, object> { ["priority"] = CvePriority.Track },
|
||||
};
|
||||
|
||||
VulnerabilityCorrelationService.StampFindingPriority(finding, vulns);
|
||||
|
||||
Assert.Equal(CvePriority.Attend, finding["priority"]);
|
||||
Assert.Equal(0.4, finding["maxEpssScore"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StampFindingPriority_NoScoredCves_OmitsMaxEpss()
|
||||
{
|
||||
var finding = new Dictionary<string, object>();
|
||||
VulnerabilityCorrelationService.StampFindingPriority(finding, new List<object> { new Dictionary<string, object> { ["priority"] = CvePriority.Track } });
|
||||
Assert.Equal(CvePriority.Track, finding["priority"]);
|
||||
Assert.False(finding.ContainsKey("maxEpssScore"));
|
||||
}
|
||||
}
|
||||
+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",
|
||||
"msiArchiveBlobPath", "msiArchiveStatus", "msiBytes", "msiSha256", "msiSourceUrl",
|
||||
"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
|
||||
// =========================================================================
|
||||
@@ -211,6 +305,7 @@ public class GetImeVersionHistoryRedactionTests
|
||||
LastSeenAt = Last,
|
||||
SessionCount = 3109,
|
||||
MsiArchiveStatus = "Archived",
|
||||
MsiArchiveUpdatedAt = Last,
|
||||
MsiArchiveBlobPath = $"{version}/IntuneWindowsAgent.msi",
|
||||
MsiSha256 = "4d6b85fd3c49cf227ab4711308bca1f3c701cad9bff1799da4c6b6616066bb5e",
|
||||
MsiBytes = 13_107_200,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user