Compare commits

...

9 Commits

Author SHA1 Message Date
dependabot[bot] beac51720f build(deps): bump the web group (#135)
Bumps the web group in /src/Web/autopilot-monitor-web with 4 updates: [next](https://github.com/vercel/next.js), [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer), [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `next` from 16.3.1 to 16.3.2
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.1...v16.3.2)

Updates `@next/bundle-analyzer` from 16.3.1 to 16.3.2
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.2/packages/next-bundle-analyzer)

Updates `eslint-config-next` from 16.3.1 to 16.3.2
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.2/packages/eslint-config-next)

Updates `vitest` from 4.1.10 to 4.1.11
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: vitest
  dependency-version: 4.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 12:32:44 +02:00
dependabot[bot] 32268ba245 build(deps-dev): bump vitest (#134)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 1 update: [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `vitest` from 4.1.10 to 4.1.11
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 12:32:17 +02:00
Oliver Kieselbach c91d8d4501 fix(agent): complete enrollments with zero user-targeted apps instead of failing them
IME never writes its "Completed user session" line when the user has no
assigned Win32 apps - its zero-app branch returns before that statement
(ApplicationPoller.cs:713-721, identical in 1.80/1.83/1.97/1.104). So
ImeUserSessionCompletedUtc stayed null, every arm of
ShouldTransitionToAwaitingHello was unsatisfiable, and the 30-min
advisory_completion backstop resolved healthy enrollments as
esp_exit_without_completion_evidence. One tenant: 10 of 30 sessions in a week,
all false positives - DAD-validated desktop, Hello provisioned, RealmJoin still
installing. Every Succeeded session there carries ime_user_session_completed;
not one of the failed ones does.

IME-USER-SESSION-ZERO-APPS reads the line IME does write there and routes it
through the same OnUserSessionCompleted callback. That is IME's own verdict
rather than a weakened gate: the zero-app branch locks the ESP in with an empty
application list, and the sibling emit site returns ProvisioningComplete right
after logging it. Guarded to the AccountSetup phase so it cannot burn the
adapter fire-once flag on a pre-sign-in timestamp, and replayed on the phase
transition when the observation predates the phase marker. AreUserEspAppsSettled
accepts an empty app list only with that explicit observation, never on
emptiness alone.

Second, independent defect: advisory_completion (30 min) overran the RealmJoin
completion gate, which deliberately holds the session and carries its own
bounded resolution (60 min from detection, activity-extended, 4 h cap). One
session installed 25 RealmJoin packages inside the window, the last 2 min before
the failure, while completion_waiting still read "waiting on:
realmjoin_resolution". The handler now re-arms while that gate is closed and its
own deadline is still armed; convergence comes from the gate itself, which
always ends with an Outcome set.

ime_user_session_completed gains an evidence field (zero_user_apps_enumerated /
user_session_completed) - no new event type, so the Constants ->
resource-catalog -> shared-manifests triple stays untouched. The pattern ships
via the config API and reaches existing agents, where an unknown action is a
single Debug line; the behaviour needs the agent release.

Build 0 warnings. DecisionCore 558/558, V2.Core 2530/2530, Backend 4634/4634,
MCP event-types-drift 6/6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 12:27:42 +02:00
Oliver Kieselbach bf6850dedc perf(security): log DevPrep association shadow once per lookup, not per request
Same gate the cert-device-binding shadow got in 35a3ed0c, applied to its
sibling. Measured on the live enrollment: 263 identical shadow lines for one
device off a handful of Graph calls, because the cache answered nearly every
request and the log sat outside it.

DeviceAssociationResult.AsCacheHit() copies rather than marking the shared
cached instance, so the first real lookup is never mistaken for a cache hit
and dropped. Transient outcomes are not cached and keep logging every time.

Backend 4633/4633, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 20:18:57 +02:00
Oliver Kieselbach 35a3ed0cfb perf(security): log device binding once per Graph lookup, not per request
Measured on the first live enrollment: 136 agent requests produced exactly
ONE Graph call, because the 30-minute positive cache absorbed the rest. So
the check is not a throttling risk (the per-request Autopilot serial
validator made 25 calls in the same window) and the positive TTL needs no
change — raising it would only delay noticing a wiped or removed device.

What the 136 requests did cost was 136 log lines all saying the same thing.
Gate the shadow log on ServedFromCache so it emits one line per real lookup.
Cached repeats carry no new information: the reported age just counts up
mechanically from the same enrolledDateTime. Outcomes that are never cached
(Transient, NoDeviceIdInCert) keep logging every time, which is what an
anomaly warrants.

AsCacheHit() copies rather than marking the shared cached instance, so the
first real lookup is never mistaken for a cache hit and dropped.

Backend 4631/4631, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:18:37 +02:00
Oliver Kieselbach ea6a375c4b feat(security): cert-to-device binding, stage 3 GA-gated shadow
Tenant binding (stage 1) proves the client certificate was issued to this
tenant. It does not prove the certificate belongs to a device the tenant
still has: a cert lifted from a decommissioned machine keeps working.

IntuneDeviceBindingValidator resolves the Intune managedDevice id from the
certificate's Subject CN against the tenant's own managedDevices inventory.
Point-GET on managedDevices/{id} rather than an OData filter, so there is no
filter literal to inject into and a 404 is unambiguous. Cache/retry/transient
contract mirrors CloudPcDeviceValidator.

Shadow only, and visible only to Global Admins (same gate as the DevPrep
association preview) because the open question is a race, not an attack: a
device object can appear after the agent's first call. Every result carries
enrolledDateTime and the log line records enrolledAgeSeconds, so a device
created seconds before the request is distinguishable from a foreign cert.
Grep marker: CERT-DEVICE-BINDING-SHADOW.

The lookup is fire-and-forget like the DevPrep shadow so a cold Graph
round-trip never sits in the agent request path. That rules out stage 1's
request-row dimension (the row is written before the task finishes), hence
the Warning log; widening beyond preview should move to inline + dimension.

Needs DeviceManagementManagedDevices.Read.All, granted per tenant via the
customer script as feature IntuneDeviceBinding. The script's catalog,
ValidateSet and help block stay pinned to GraphFeatureCatalog by
GraphFeatureCatalogSyncTests (extended with the help-block case).

Build 0 warnings, backend 4629/4629, web 874/874, tsc clean, eslint 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 18:26:33 +02:00
Oliver Kieselbach fb79c7711c fix(security): carry cert tenant binding on the request row, not a dead log line
Post-deploy verification of 249e39bc found zero AgentCertTenantBinding rows
across 32k agent requests. Cause: the Match outcome was emitted with
LogInformation, and worker-side ILogger below Warning never reaches App
Insights (provider default rule is Warning+, host forwarding of worker logs
is off). The bulk outcome was therefore invisible, which is indistinguishable
from the code never running — the shadow telemetry had no denominator.

Stamp every outcome onto the request row instead (FunctionContext.Items ->
RequestTelemetryMiddleware -> CertTenantBinding dimension). That row already
exists per request and is unsampled, so the denominator costs no extra
telemetry. Non-Match outcomes keep their Warning trace with full context.

FromCache existed only to sample the dead Match line and is removed with it.

Backend 4606/4606, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:40:04 +02:00
Oliver Kieselbach 249e39bc68 feat(security): cert tenant binding, stage 1 shadow mode
A chain-valid Intune MDM certificate only proves "issued to some tenant" —
the pinned roots are shared by every Intune tenant. Tenant scoping rested
entirely on the device serial in the downstream validators, which is not a
secret. Bind the tenant stamped into the auth cert to the requested tenant.

The Entra tenant id sits in OID 1.2.840.113556.5.14 of the cert the agent
already presents for mTLS, so this needs no Graph call, no consent and no
agent release. Not 5.6 (Intune Account id, a different GUID) and not 5.4
(device id, stored as 16 bare bytes) — the decoder handles both encodings
and now lives once in Shared, used by agent and backend.

Stage 1 observes only: ObserveCertTenantBinding returns void and swallows
its own exceptions, so it cannot fail an otherwise valid request. Match is
logged only on cert-cache miss, keeping the denominator without per-request
volume. Grep marker for stage 2: CERT-TENANT-BINDING-SHADOW.

Backend 4604/4604, Agent V2.Core 2519/2519, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:03:05 +02:00
Oliver Kieselbach 6bd9139869 fix(agent): cert GUID parser accepts bare 16-byte extension values
Intune's MDM device cert stores the DeviceId in OID 1.2.840.113556.5.4 as a
bare 16-byte GUID with no ASN.1 wrapper, so the strict OCTET-STRING parser
bailed on the tag check and returned null. Verified against a live Intune
device cert: 5.4 is 16 raw bytes, while 5.6 / 5.11 / 5.14 carry the usual
04 10 wrapper.

- ParseGuidFromAsn1OctetString -> ParseGuidFromExtension (the old name no
  longer describes what it parses); both encodings now handled, with the
  shared LE-GUID construction pulled into ToGuid(). The forms cannot
  collide: a wrapped 16-byte GUID is always >= 18 bytes.
- Document the Intune MDM cert OID arc on the class: 5.4 DeviceId,
  5.6 Intune AccountId (NOT the Entra tenant), 5.14 Entra TenantId,
  5.11 undocumented GUID, 5.15/.16/.17/.18/.19/.23 INTEGER enums.
- 13 tests pinning both encodings plus the malformed-input rejections,
  including a bare GUID whose first byte is the 0x04 OCTET STRING tag.

No behaviour change for the MS-Organization-Access probe the agent uses;
TryGetTenantIdFromCert is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:41:00 +02:00
57 changed files with 3325 additions and 502 deletions
+14
View File
@@ -128,6 +128,15 @@ user-ESP Install-intent apps are still pending (IME logs "Completed user session
*pass*, not once per session — sessions 14690fc2/6cb01530). The reducer stays free of
app-pending guards; adapters sanitize evidence before it becomes a signal.
**Zero user-targeted apps** (sessions 81daa77f/75d6ae8e): IME never writes its
"Completed user session" line at all when the user has no assigned apps — its zero-app
branch returns first — so fact 3 was unreachable by construction and every such device
fell through to the backstop. `IME-USER-SESSION-ZERO-APPS` reads the line IME *does*
write there and posts the same signal, guarded to the AccountSetup phase. The event
carries an `evidence` field (`zero_user_apps_enumerated` vs `user_session_completed`)
naming which observation carried the completion. See
[zero-user-apps-completion-gate.md](zero-user-apps-completion-gate.md).
## Completion attempt sites and sequence
Completion can be attempted from whichever fact arrives last: `HelloResolved`,
@@ -166,6 +175,11 @@ advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`:
scheduler contract) dead-ends as `realmjoin_timeout_stale_superseded_by_rearm`.
The idle case (detected, no deployment activity ever) times out at 60 min
unchanged, and phase 200/210 activity never extends.
A closed gate also outranks the 30-minute `AdvisoryCompletion` backstop: that
handler re-arms (trigger suffix `:CompletionGateHolding`) instead of failing while
the RealmJoin gate is closed *and* its own deadline is still armed, because the gate
carries a bounded resolution of its own. Before this, the shorter timer always won —
session 75d6ae8e was failed 2 min after its 25th successful RealmJoin package.
2. `Finalizing` stage + ~5 s `FinalizingGrace` deadline.
3. Grace fires → `Completed`, `EnrollmentComplete` outcome, `enrollment_complete`
timeline event with full audit payload.
+1
View File
@@ -7,6 +7,7 @@ How the on-device agent works:
* [Logs, Persistence & Crash Recovery](logs-and-persistence.md) - every persisted file and why it exists; signal log vs spool; snapshot/journal; the replay-based recovery flow and quarantine.
* [Diagnostics Package](diagnostics-package.md) - what the diagnostics ZIP contains: the built-in section catalog shared with the portal (Shared `DiagnosticsBuiltInSections`, served by GET diagnostics/paths), the RealmJoin Watcher and Device Preparation collection gates, why built-ins bypass the configured-path guards, global ∪ tenant path dedup, caps and the package manifest.
* [Hello Wizard Un-Skip](hello-wizard-unskip.md) - why a single "WHfB disabled" policy read never decides completion: the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read.
* [Zero User-Targeted Apps](zero-user-apps-completion-gate.md) - why a user with no targeted Intune apps starved every user-phase completion path (IME never writes its "Completed user session" line in the zero-app branch), and why a completion gate that is still holding the session must outrank the 30-minute backstop.
* [Autopilot ZTD Diagnostics](autopilot-ztd-diagnostics.md) - Windows' own diagnostic surfaces for the profile-download flow: ModernDeployment event IDs (807/815/164/...), the Diagnostics\Autopilot registry key, deployment-service endpoints, and the known-issue error-code map.
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated 2800 observations (neutral, flush-time watermark) + ANALYZE-ESP-005 gated on an actually-observed reboot.
* [Power-State Watcher](power-state-watcher.md) - live AC↔battery transitions and 50/30/15% downward threshold crossings via a WMI push subscription (no polling): power_state_change semantics (latch/debounce/cap, never armed without a battery) and the single-condition analyze rules ANALYZE-DEV-009/-010 on top.
@@ -0,0 +1,136 @@
---
type: Concept
title: Zero User-Targeted Apps — the starved user-phase completion gate
description: Why a device with no user-targeted Intune apps could never satisfy the IME user-session gate, how that turned into a 30-minute esp_exit_without_completion_evidence false positive on a third of one tenant's enrollments, and the two fixes — reading the line IME actually writes in its zero-app branch, and refusing to fail a session while a completion gate is deliberately holding it.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime
tags:
- agent
- ime
- esp
- completion
- realmjoin
- false-positive
timestamp: 2026-08-25
---
# Summary
A Classic user-driven enrollment whose user has **zero targeted Intune Win32 apps** could not
produce the evidence every user-phase completion path required. The session parked, and the
30-minute `advisory_completion` backstop resolved it as
`enrollment_failed: esp_exit_without_completion_evidence` — while the user was at a working
desktop, Windows Hello was provisioned, and RealmJoin was still installing packages.
One tenant hit this on **10 of 30 enrollments in a week**. Every one of them was a false
positive; the devices were healthy in Intune and RealmJoin.
# The starvation chain
The IME writes `Completed user session N, userId: …` at the end of a user-session processing
pass. `IME-USER-SESSION-COMPLETED` matches it and the adapter posts
`ImeUserSessionCompleted`, which sets `DecisionState.ImeUserSessionCompletedUtc`.
But the IME never reaches that statement when the user has no assigned apps. Decompiled IME
`1.104.102.0`, `Win32AppPlugIn/ApplicationPoller.cs`:
```csharp
if (!appPolicies.Any())
{
AppWorkloadLog.TraceInformation($"[Win32App] Get 0 apps for user session {user.RDSSessionId}, user id = {user.IntuneId}");
RegisterApplicationsInIfInESP(sender, user, delegate {
espLockInProcessor.AttemptToLockInApplications(user, new List<ProcessorSubgraph>());
});
continue; // skips the "Completed user session" statement below
}
```
Identical shape in `1.97.107.0`, `1.83.107.0`, `1.80.132.0`. With that fact missing, every door
out of the user phase is shut at once:
| Path | Requirement | Why it is dead |
|---|---|---|
| `ShouldTransitionToAwaitingHello` arm A | `AccountSetupProvisioningSucceededUtc` | ESP registry froze at `AccountSetup: 1 of 5`; neither `categorySucceeded` nor the all-subcategories fallback can ever hold |
| arm B | `SkipUserEsp` | Full ESP, not a skip-user flow |
| arm C | `IsImeUserSessionGenuine` | needs the missing IME fact |
| arm D | `EnrollmentMode.DevicePreparation` | Autopilot v1, not WDP |
| `MaybeSynthesizeAccountSetupCompleteFromSettledUserApps` | `AreUserEspAppsSettled` | returned `false` on an empty app list |
| `HandleAdvisoryCompletionDeadlineFired` conjunction | `imeUserSessionGenuine \|\| isObservationAdvisory` | both false |
The last row is the one that produced the verdict: the backstop that exists to resolve a
dead-end resolved it as a failure.
`AreUserEspAppsSettled` deserves its own note. Its "at least one required app" rule is
deliberately conservative — an empty list normally means *the phase just cleared* or *apps have
not surfaced yet*. But it made **zero apps**, the most settled state possible, indistinguishable
from *unknown*, and there was no evidence available to tell them apart.
# Fix 1 — read the line IME does write
`IME-USER-SESSION-ZERO-APPS` matches the zero-app line and routes it through the **same**
`OnUserSessionCompleted` callback. Downstream this genuinely *is* a completed user session, so
the adapter's pending-apps deferral and its fire-once flag apply unchanged.
This is not a weakened gate — it is IME's own verdict. The zero-app branch locks the ESP in with
an *empty* application list, and the sibling emit site in `AppWorkloadAbstraction.ExecuteUserCheckIn`
returns `ProviderResult.ProvisioningComplete` straight after logging it. All three emit sites are
user-session-scoped (`user.RDSSessionId`, `user.IntuneId`); none is device-scoped.
Guards that stay in force:
* **Phase guard** — the observation only fires while the tracker is in `AccountSetup`. Firing
earlier would burn the adapter's fire-once flag on a pre-sign-in (defaultuser0) timestamp that
`IsImeUserSessionGenuine` (`>= AccountSetupEnteredUtc`) can never accept. An observation made
before the phase was known is remembered and replayed on the transition into it, because IME
writes its check-in lines independently of when the agent first parses the phase marker.
* **Engine guards unchanged** — arm C still requires a post-AccountSetup final ESP exit *and* a
DAD-validated real-user desktop.
* `AreUserEspAppsSettled` returns `true` for an empty list **only** with this explicit
observation — never for a merely empty list.
# Fix 2 — do not fail while a completion gate is holding
An independent defect, and the decisive one for the affected tenant. `advisory_completion` runs
for 30 minutes. The RealmJoin completion gate blocks completion until phase 110 and carries its
*own* bounded resolution: 60 minutes from detection, re-armed on deployment activity, hard-capped
at 4 hours. The shorter timer always won.
Session `75d6ae8e` installed **25 RealmJoin packages successfully inside the window**, the last
one 2 minutes before the failure, while the engine's own `completion_waiting` still read
`waiting on: realmjoin_resolution`.
`HandleAdvisoryCompletionDeadlineFired` now re-arms instead of failing when the RealmJoin gate is
closed *and* its deadline is still armed, emitting `completion_waiting` with trigger
`DeadlineFired:advisory_completion:CompletionGateHolding`.
Convergence comes from the gate itself: `Resolved`, `FirstDeploymentIncomplete` and `Timeout` all
set `RealmJoinFacts.Outcome`, which opens `RealmJoinGateOpen` and stops the guard applying.
Requiring the gate's deadline to still be armed keeps a gate that can no longer resolve itself
from parking the session; `AgentMaxLifetimeMinutes` bounds it regardless.
# Timeline evidence
`ime_user_session_completed` carries an `evidence` field so the basis of the verdict is legible
without the diagnostics archive:
* `user_session_completed` — IME wrote its completion line and no required app was pending.
* `zero_user_apps_enumerated` — IME reported zero targeted apps; the user phase had nothing to
enforce.
No new event type: the fact is the same, only the observation behind it differs.
# Deployment
The pattern ships through the config API and reaches existing agents without a rebuild; an agent
that does not know the `userSessionZeroApps` action logs one Debug line and ignores it. The
*behaviour* needs an agent release. Sessions already stamped `Failed` are not reclassified — the
backend mirrors the agent verdict (`VerdictPaths.AgentFailed`) and has no independent judgement
here by design.
# Citations
* `rules/ime-log-patterns/IME-USER-SESSION-ZERO-APPS.json`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs` — `HandleUserSessionZeroApps`, the phase replay in `HandleEspPhaseDetected`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs` — `AreUserEspAppsSettled`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs` — `EmitUserSessionCompleted`, evidence labels
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs` — `HandleAdvisoryCompletionDeadlineFired`
* [decision-engine.md](decision-engine.md), [post-reboot-completion-recovery.md](post-reboot-completion-recovery.md)
+216
View File
@@ -0,0 +1,216 @@
---
type: Concept
title: Client-Certificate Tenant Binding — Closing the Cross-Tenant Gap
description: Why a chain-valid Intune MDM certificate does not prove the caller belongs to the tenant it claims, which certificate extension carries the Entra tenant id (and which two look like it but do not), and the shadow-then-enforce rollout that measures field coverage before it can lock devices out.
resource: src/Backend/AutopilotMonitor.Functions/Security/CertTenantBinding.cs
tags:
- backend
- security
- authentication
- certificates
- intune
- multi-tenant
timestamp: 2026-08-24T00:00:00+02:00
---
# Problem
Agent requests authenticate with mTLS. `CertificateValidator` pins the chain to the embedded
Intune roots (`X509ChainTrustMode.CustomRootTrust`), checks the validity window and requires the
Client-Authentication EKU. All of that together proves exactly one thing: **the certificate was
issued by the Microsoft Intune MDM Device CA to some tenant**.
Those roots are shared by every Intune tenant on the planet. Nothing in the certificate stage ties
the caller to the tenant in the request. The tenant scoping came entirely from the downstream
device validators — the serial number must appear in that tenant's Autopilot / imported-device /
Cloud PC inventory. Device serial numbers are not secrets; they are printed on the chassis.
So the pre-existing attack was: enroll a device in your own Intune tenant, obtain a legitimate
device certificate, learn one serial number of the victim tenant, and submit telemetry as that
tenant. Certificate validation passes, device validation passes.
# Mechanism
The Intune MDM Device CA stamps the customer's identifiers into the leaf certificate. Since the
agent presents this certificate in the TLS handshake, its contents are proof of possession and the
backend can compare them against the requested tenant — no Graph call, no additional consent, and
nothing the agent has to send.
## Which extension carries what
Verified by decoding a real field certificate (issued 2026-05-08, `Functions.Tests/device-cert-sample.pem`):
| OID | Encoding | Content |
| --- | --- | --- |
| `1.2.840.113556.5.14` | nested OCTET STRING (`04 10` + 16 bytes) | **Entra tenant id** — the one to compare |
| `1.2.840.113556.5.6` | nested OCTET STRING (`04 10` + 16 bytes) | Intune **Account** id — a *different* GUID, never equal to the tenant id |
| `1.2.840.113556.5.4` | **16 raw bytes, no wrapper** | Intune device id, identical to the certificate's Subject CN |
Two traps are worth stating explicitly, because both cost real debugging time:
1. `…5.6` is widely described as "the tenant id (AccountID)". It is not the Entra tenant id.
Comparing it against the requested tenant would reject every request in production.
2. The encodings are not uniform. `…5.4` holds the GUID bare while the other two nest it in a
second OCTET STRING, so a decoder written against one shape silently fails on the other.
`MsDeviceCertificateOids.TryParseGuid` accepts both, and all GUIDs are Microsoft
little-endian (what `new Guid(byte[])` expects).
A 16-byte input is inherently ambiguous — it is indistinguishable from a truncated wrapper and is
always read as a bare GUID. That is harmless here: the value is only ever compared for equality
against a known tenant id, never trusted as a standalone claim.
## Where it is NOT
`EntraDeviceCertHelper` reads tenant and device ids from the **MS-Organization-Access** certificate
(`1.2.840.113556.1.5.284.*`). That certificate is not part of the authentication path at all — the
agent selects the Intune MDM certificate by exact issuer match (`CN=Microsoft Intune MDM Device CA`,
see `CertificateHelper.SelectMdmCertificate`) and the backend pins only the MDM chain. The Entra
certificate is used purely as a local fallback for `TenantIdResolver`; it never leaves the device.
Sending it in a header instead would prove nothing: only the certificate used in the TLS handshake
demonstrates possession of a private key. A certificate copied into a custom header can be replayed
by anyone who has seen it once.
# Rollout
Enforcing immediately would be reckless: any device population whose certificates predate the
extension, or whose CA behaves differently (sovereign clouds), would be locked out at once. The
rollout therefore has staged consequences.
**Stage 1 — shadow (current).** `SecurityValidator.ObserveCertTenantBinding` evaluates the binding
after certificate validation and logs the outcome. It returns `void`, changes no state, and swallows
its own exceptions, so it cannot fail a request that would otherwise be accepted. The pure comparison
lives in `CertTenantBinding.Evaluate`; the outcome codes (`Match`, `Mismatch`, `ExtensionMissing`,
`Unparseable`, `RequestTenantNotAGuid`) are stable and queried by exact match in KQL.
`WouldRejectUnderEnforcement` records what stage 2 *would* have done, so the telemetry can be read
without re-deriving the rule at query time.
The telemetry uses two carriers, because the numerator and the denominator have very different
costs and answer different questions:
* **Every** outcome, `Match` included, is stamped onto the request row as the `CertTenantBinding`
dimension — `SecurityValidator` writes it into `FunctionContext.Items`
(`CertTenantBinding.RequestItemKey`) and `RequestTelemetryMiddleware` copies it onto the
`RequestTelemetry` it already emits. That row exists per request and is unsampled, so the
denominator costs no additional telemetry at all.
* Outcomes that need an enforcement decision (mismatch, missing, undecodable) are **additionally**
logged at Warning with the full context — thumbprint, certificate tenant, session.
`Match` is deliberately not a trace line. Worker-side `LogInformation` never reaches App Insights
(the ApplicationInsightsLoggerProvider default rule is Warning+ and host-forwarding of worker logs
is disabled), so a `Match` trace is dead code that silently costs the denominator. The first
deployment of this feature made exactly that mistake: it emitted zero rows across 32k requests,
which is indistinguishable from the code never running.
**Stage 2 — enforce.** Once telemetry shows the `ExtensionMissing` rate is negligible, `Mismatch`
becomes a rejection. The decision that still has to be made is the policy for certificates without
the extension: skip with a warning, or reject. Grep marker for every stage-2 site:
`CERT-TENANT-BINDING-SHADOW`.
The bootstrap-token path (`X-Bootstrap-Token`) bypasses certificate validation by design and is
unaffected by either stage.
**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
certificate's Subject CN against the tenant's own `deviceManagement/managedDevices` inventory
(point-GET on `managedDevices/{id}` — no OData filter to inject into, and a 404 is unambiguous).
It is modelled on `CloudPcDeviceValidator` and shares its cache/retry/transient contract, with one
important difference in how it is wired: the lookup is fire-and-forget, like the DevPrep
association shadow, because a cold Graph round-trip can take seconds and must never sit in the
agent's request path. That rules out the request-row dimension stage 1 uses — the row is written
the moment the function returns, before the detached task finishes — so the outcome is logged at
Warning instead. Volume stays bounded because only Global Admins can see the toggle
(`user?.isGlobalAdmin === true`, the same gate as the DevPrep preview). Widening it beyond preview
should move to an inline call plus a request dimension.
Outcomes: `Match`, `NotFound`, `NoDeviceIdInCert`, `PermissionMissing`, `Transient`. Only
`Transient` is retried and left uncached — a missing grant or a foreign device is a state, not an
outage.
Graph cost is not a concern, and that is measured rather than assumed: the first live enrollment
produced **136 requests and exactly one Graph call**, because the 30-minute positive cache absorbs
the rest. For comparison, the per-request Autopilot serial validator made 25 calls in the same
window. Lengthening the positive TTL would buy nothing and would delay noticing a device that has
been wiped or removed; shortening the 5-minute negative TTL matters more, since that is how quickly
a device object appearing late is picked up.
What the 136 requests *did* cost was log lines saying the same thing 136 times, so the shadow log is
gated on `IntuneDeviceBindingResult.ServedFromCache`: one line per real Graph lookup. Cached repeats
add nothing — the reported age just counts up mechanically from the same `enrolledDateTime`.
Outcomes that are never cached (`Transient`, `NoDeviceIdInCert`) still log every time, which is the
right behaviour for an anomaly.
The open question this preview exists to answer is a race, not an attack: a device object can in
principle appear in Intune *after* the agent's first call, and enforcing before that is understood
would reject legitimate enrollments. Every result therefore carries `enrolledDateTime`, and the log
line records `enrolledAgeSeconds` — a device whose object was created seconds before the request
looks completely different from a certificate that never belonged to the tenant. Grep marker:
`CERT-DEVICE-BINDING-SHADOW`.
Enabling it needs the optional Graph permission `DeviceManagementManagedDevices.Read.All`, granted
per tenant with the customer script — it is not in the default-consent set:
```powershell
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<client-id>" -Features IntuneDeviceBinding
```
`GraphFeatureCatalog` (feature `IntuneDeviceBinding`) and the script's catalog, `ValidateSet` and
help block are pinned to each other by `GraphFeatureCatalogSyncTests`.
# Examples
Coverage — the question stage 2 depends on ("would enforcing lock anyone out?"):
```kusto
requests
| where isnotempty(customDimensions.CertTenantBinding)
| summarize Requests = sum(itemCount)
by Outcome = tostring(customDimensions.CertTenantBinding),
Tenant = tostring(customDimensions.TenantId)
| order by Requests desc
```
Detail on the individually actionable ones:
```kusto
traces
| where message has "AgentCertTenantBinding"
| extend Outcome = tostring(customDimensions.Outcome),
Tenant = tostring(customDimensions.TenantId)
| summarize count() by Outcome, Tenant
```
Anything in `Mismatch` is a cross-tenant certificate; anything in `ExtensionMissing` is a device
that stage-2 enforcement would have to make a policy decision about. Note that the trace query
alone can never show `Match`, so it cannot tell "everything matched" apart from "nothing ran" —
use the request-row query for that.
Stage 3, including the enrollment-race measurement:
```kusto
traces
| where message has "AgentCertDeviceBinding"
| extend Outcome = tostring(customDimensions.Outcome),
AgeSecs = toint(customDimensions.EnrolledAgeSeconds)
| summarize Requests = count(),
MinAgeSecs = min(AgeSecs),
P05AgeSecs = percentile(AgeSecs, 5)
by Outcome
```
`NotFound` is the row that decides whether this can ever become a gate. A small `MinAgeSecs` on
`Match` means device objects are appearing only just in time — the race is real and enforcement
would need a grace path.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Security/MsDeviceCertificateOids.cs` — OID catalog and the
shared ASN.1 GUID decoder (one implementation for agent and backend).
* `src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs` — extension extraction
alongside chain validation, cached per thumbprint.
* `src/Backend/AutopilotMonitor.Functions/Security/CertTenantBinding.cs` — the pure comparison.
* `src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs` — shadow observation.
* [W365 Cloud PC Device Validation](cloudpc-device-validation.md) — the other consumer of this
certificate's identity fields (Subject CN = Intune device id).
+2
View File
@@ -25,6 +25,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [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.
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
* [ESP Completion Starvation — the sits-d Cloud-PC complex](agent/post-reboot-completion-recovery.md) - Why a SkipUser=true flow (no user ESP page → no Shell-Core 62407, no AccountSetup registry) left the completion gate satisfied but never evaluated — five Cloud PCs idled six hours at "waiting on: hello_resolution" — and the fix: the observed skip stands in for the final-exit evidence in the IME user-session completion attempt. Plus the two adjacent defects fixed alongside: the edge-only user-apps-settled synthesis (now level-triggered) and the never-wired Shell-Core restart replay (now Hello-wizard-only, with the reasoning why a replayed 62407 must never reach the reducer).
* [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.
@@ -76,6 +77,7 @@ 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.
# Conventions for this bundle
+15
View File
@@ -1,5 +1,20 @@
# Log
## 2026-08-25
* **New**: `agent/zero-user-apps-completion-gate.md` — a Classic enrollment whose user has zero targeted Intune Win32 apps could not produce the evidence any user-phase completion path required: IME's zero-app branch (`ApplicationPoller`, verified against decompiled 1.104.102.0 and three older builds) `continue`s before its `Completed user session` statement, so `ImeUserSessionCompletedUtc` stayed null, all four arms of `ShouldTransitionToAwaitingHello` were dead, and the 30-min `advisory_completion` backstop resolved the session as `esp_exit_without_completion_evidence` — with a DAD-validated desktop, Hello provisioned and RealmJoin still installing. One tenant: 10 of 30 enrollments in a week, all false positives. Two fixes. (1) New pattern `IME-USER-SESSION-ZERO-APPS` reads the line IME *does* write and routes it through the same `OnUserSessionCompleted` callback, guarded to the AccountSetup phase and replayed if the observation predates the phase marker; `AreUserEspAppsSettled` now accepts an empty app list only with that explicit observation. (2) `HandleAdvisoryCompletionDeadlineFired` re-arms instead of failing while the RealmJoin gate is closed and its own deadline is still armed — session 75d6ae8e installed 25 RealmJoin packages inside the window, the last 2 min before the failure. `ime_user_session_completed` gains an `evidence` field (`zero_user_apps_enumerated` / `user_session_completed`); no new event type. The pattern reaches existing agents via the config API (an unknown action is a Debug line), the behaviour needs an agent release.
## 2026-08-24
* **Change**: `DeviceAssociationValidator` / `SecurityValidator` — the DevPrep association shadow now logs one line per real Graph lookup instead of one per request (`DeviceAssociationResult.ServedFromCache`, `AsCacheHit()` copies rather than marking the shared cached instance), the same gate the cert-device-binding shadow uses. Measured: one enrollment produced 263 identical lines off a handful of calls. Status of that preview, for the record: the `/beta/deviceManagement/tenantAssociatedDevices` endpoint answers (6 calls, all HTTP 200, `transient=False`), but every result so far is `matched=False` from a single Autopilot **v1** device — which is the correct answer for a device that was never part of a Device Preparation policy. The matching itself is therefore still unproven, and the endpoint remains undocumented in both v1.0 and beta Graph reference, so it is not a basis for enforcement.
* **Update**: `backend/client-cert-tenant-binding.md` — stage 3 verified live (`ea6a375`, gktatooine): 136 requests, all `Match`, device object ~72 s older than the session start, so no enrollment race in that run. Measured Graph cost: **1 call for 136 requests** (30 min positive cache), versus 25 for the per-request Autopilot validator — the positive TTL needs no change. The shadow log is now gated on `ServedFromCache` (one line per real lookup instead of per request); never-cached outcomes still log every time.
* **Update**: `backend/client-cert-tenant-binding.md` — stage 3, the cert-to-device binding: new `IntuneDeviceBindingValidator` resolves the Subject-CN managedDevice id against the tenant's own `deviceManagement/managedDevices` (point-GET, no filter literal), modelled on `CloudPcDeviceValidator` and wired fire-and-forget like the DevPrep shadow so a cold Graph round-trip never enters the agent request path. New GA-only tenant toggle `ValidateIntuneDeviceBinding` (shadow — never touches `deviceValidated`), new optional Graph feature `IntuneDeviceBinding` → `DeviceManagementManagedDevices.Read.All` in `GraphFeatureCatalog` + the customer grant script, and `enrolledAgeSeconds` on every log line so the enrollment race can be measured before this could ever become a gate. Grep marker: `CERT-DEVICE-BINDING-SHADOW`.
* **Update**: `backend/client-cert-tenant-binding.md` — shadow telemetry moved off the dead `LogInformation` path: the `Match` outcome now rides on the request row as the `CertTenantBinding` dimension (`SecurityValidator` → `FunctionContext.Items` → `RequestTelemetryMiddleware`), which is unsampled and already exists per request, so the denominator costs nothing. Non-Match outcomes keep their Warning trace. Post-deploy verification of 249e39bc found zero rows across 32k requests — worker `LogInformation` never reaches App Insights (provider default rule Warning+), making "everything matched" indistinguishable from "never ran". `CertificateValidationResult.FromCache` removed with the sampling path it existed for.
* **New**: `backend/client-cert-tenant-binding.md` — the agent's mTLS certificate chains to Microsoft's shared Intune roots, so certificate validation alone never proved tenant membership; tenant scoping rested entirely on the (non-secret) device serial in the downstream validators. The Intune MDM leaf carries the Entra tenant id in OID `1.2.840.113556.5.14` (nested OCTET STRING), which is NOT `…5.6` (Intune Account id, a different GUID) and NOT `…5.4` (device id, stored as 16 bare bytes) — all decoded by one shared `MsDeviceCertificateOids.TryParseGuid` now used by agent and backend. Stage 1 ships as shadow only: `CertTenantBinding.Evaluate` (pure) plus `SecurityValidator.ObserveCertTenantBinding` (returns void, swallows its own exceptions, cannot change an authorization outcome), with `Match` sampled via `CertificateValidationResult.FromCache` so the denominator survives without per-request volume. Grep marker for the stage-2 enforcement change: `CERT-TENANT-BINDING-SHADOW`.
## 2026-08-23
* **New**: `backend/telemetry-ingest-shaping.md` — `RequestTelemetryMiddleware` sets `SamplingPercentage=100` on its own item (worker request rows were adaptively sampled, ItemCount 2–5 on weekdays, despite host.json `excludedTypes`), the redundant host HTTP request copy is dropped by a workspace transformation DCR (`infra/appinsights-workspace-transforms.bicep`, timer/queue rows kept), and `StorageDependencyFilterProcessor` now also drops expected storage outcomes (404/412/409, both InProc and HTTP shapes) and successful SignalR REST calls; the host-emitted SignalR binding calls (bulk of the volume, SDK `rdddsc`) are dropped by a second `AppDependencies` flow in the same DCR. Measured ≈ −1.3 GB/week.
+8
View File
@@ -675,6 +675,14 @@
"description": "Detects when IME completes a user session, indicating enrollment has finished for that user.",
"enabled": true
},
{
"patternId": "IME-USER-SESSION-ZERO-APPS",
"category": "always",
"pattern": "\\[Win32App\\] Get 0 apps for user session (?<sessionId>\\d+), user id = (?<userId>[a-f0-9\\-]+)",
"action": "userSessionZeroApps",
"description": "Detects when IME enumerated a user session and found zero targeted Win32 apps. IME then skips its 'Completed user session' line entirely (the zero-app branch in ApplicationPoller continues before that log statement), so this line is the only evidence that user-phase enforcement finished on a device with no user-targeted apps. Treated as equivalent to a completed user session.",
"enabled": true
},
{
"patternId": "IME-WIN32-STATE-1",
"category": "currentPhase",
@@ -0,0 +1,9 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-USER-SESSION-ZERO-APPS",
"category": "always",
"pattern": "\\[Win32App\\] Get 0 apps for user session (?<sessionId>\\d+), user id = (?<userId>[a-f0-9\\-]+)",
"action": "userSessionZeroApps",
"description": "Detects when IME enumerated a user session and found zero targeted Win32 apps. IME then skips its 'Completed user session' line entirely (the zero-app branch in ApplicationPoller continues before that log statement), so this line is the only evidence that user-phase enforcement finished on a device with no user-targeted apps. Treated as equivalent to a completed user session.",
"enabled": true
}
+1
View File
@@ -63,6 +63,7 @@
"imeImpersonation",
"imeTokenFailure",
"enrollmentCompleted",
"userSessionZeroApps",
"updateDoTelemetry",
"scriptStarted",
"scriptContext",
@@ -1,4 +1,4 @@
#Requires -Version 5.1
#Requires -Version 5.1
<#
.SYNOPSIS
Grants additional Microsoft Graph application permissions to the Autopilot Monitor
@@ -28,6 +28,11 @@
(validates Windows 365 Cloud PCs against the tenant's Cloud PC inventory --
Cloud PCs are never Autopilot-registered, so this enables agent monitoring
of Cloud PC first-connect enrollment)
- IntuneDeviceBinding -> DeviceManagementManagedDevices.Read.All
(cert-to-device binding: verifies that the Intune device id carried in the
agent's client certificate is a device THIS tenant actually enrolled --
currently a Global-Admin-only preview that records telemetry and does not
block enrollment)
- All -> every optional permission above, in one run
(also works with -Revoke to remove all optional grants at once)
@@ -93,7 +98,7 @@ param(
[string]$ClientId,
[Parameter(Mandatory = $true, ParameterSetName = 'ByFeatures')]
[ValidateSet('All', 'ScriptDisplayNames', 'W365CloudPcValidation')]
[ValidateSet('All', 'ScriptDisplayNames', 'W365CloudPcValidation', 'IntuneDeviceBinding')]
[string[]]$Features,
[Parameter(Mandatory = $true, ParameterSetName = 'ByPermissions')]
@@ -116,6 +121,7 @@ $ErrorActionPreference = 'Stop'
$FeatureCatalog = @{
'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All')
'W365CloudPcValidation' = @('CloudPC.Read.All')
'IntuneDeviceBinding' = @('DeviceManagementManagedDevices.Read.All')
}
# Translate Features -> Permissions when caller used the high-level form.
@@ -0,0 +1,199 @@
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// Sessions 81daa77f / 75d6ae8e (2026-08-25) — zero user-targeted apps starved every
/// user-phase completion probe. The IME zero-app branch returns before it writes the
/// "Completed user session" line, so a device whose user has no targeted Win32 apps never
/// produced the evidence <c>IsImeUserSessionGenuine</c> requires, and the AdvisoryCompletion
/// window failed the enrollment 30 minutes later — with the user at the desktop, Hello
/// provisioned and RealmJoin still installing.
/// <para>
/// IME-USER-SESSION-ZERO-APPS reads the line IME does write in that branch and routes it
/// through the same <see cref="ImeLogTracker.OnUserSessionCompleted"/> callback.
/// </para>
/// </summary>
public sealed class ImeLogTrackerZeroUserAppsTests
{
// Mirrors rules/ime-log-patterns/IME-USER-SESSION-ZERO-APPS.json. The JSON itself is
// pinned against a real IME line by BuiltInRulesTests in the backend suite.
private const string ZeroAppsPattern =
@"\[Win32App\] Get 0 apps for user session (?<sessionId>\d+), user id = (?<userId>[a-f0-9\-]+)";
private const string EspPhasePattern =
@"\[Win32App\] (?:In|The) EspPhase: (?<espPhase>\w+)";
private const string ZeroAppsLine =
"[Win32App] Get 0 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020";
private static ImeLogTracker BuildTracker(TempDirectory tmp) =>
new ImeLogTracker(
logFolder: tmp.Path,
patterns: new List<ImeLogPattern>
{
new ImeLogPattern
{
PatternId = "IME-USER-SESSION-ZERO-APPS",
Category = "always",
Pattern = ZeroAppsPattern,
Action = "userSessionZeroApps",
},
new ImeLogPattern
{
PatternId = "IME-ESP-PHASE",
Category = "always",
Pattern = EspPhasePattern,
Action = "espPhaseDetected",
},
},
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info));
[Fact]
public void ZeroApps_line_during_user_phase_fires_user_session_completed()
{
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
var fired = 0;
tracker.OnUserSessionCompleted = () => fired++;
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.Equal(1, fired);
Assert.True(tracker.UserSessionZeroAppsObserved);
}
[Fact]
public void ZeroApps_line_before_user_phase_does_not_fire()
{
// Firing here would burn the adapter fire-once flag on a pre-sign-in timestamp that
// IsImeUserSessionGenuine (>= AccountSetupEnteredUtc) can never accept.
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
var fired = 0;
tracker.OnUserSessionCompleted = () => fired++;
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: DeviceSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.Equal(0, fired);
Assert.True(tracker.UserSessionZeroAppsObserved);
}
[Fact]
public void ZeroApps_observed_early_is_replayed_on_the_user_phase_transition()
{
// IME writes its check-in lines independently of when the agent first parses the ESP
// phase marker, so the evidence can legitimately arrive first. Dropping it would
// leave a zero-app device with no completion evidence at all.
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
var fired = 0;
tracker.OnUserSessionCompleted = () => fired++;
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: DeviceSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.Equal(0, fired);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
Assert.Equal(1, fired);
}
[Fact]
public void Repeated_user_phase_matches_do_not_replay_again()
{
// IME re-emits the phase string as it re-evaluates app sets. Only a real transition
// replays; the adapter fire-once flag is the outer guard, but the tracker must not
// spam the callback either.
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
var fired = 0;
tracker.OnUserSessionCompleted = () => fired++;
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: DeviceSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
Assert.Equal(1, fired);
}
[Fact]
public void Nonzero_app_count_lines_do_not_match_the_pattern()
{
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
var fired = 0;
tracker.OnUserSessionCompleted = () => fired++;
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest(
"[Win32App] Get 3 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
tracker.ProcessLogMessageForTest(
"[Win32App] Get 10 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
Assert.Equal(0, fired);
Assert.False(tracker.UserSessionZeroAppsObserved);
}
[Fact]
public void AreUserEspAppsSettled_true_after_zero_apps_observation()
{
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.True(tracker.AreUserEspAppsSettled());
}
[Fact]
public void AreUserEspAppsSettled_stays_false_on_an_empty_list_without_the_observation()
{
// Mutation proof for the branch above: an empty live list on its own still means
// "phase just cleared / apps not surfaced yet", never vacuously settled.
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
Assert.False(tracker.AreUserEspAppsSettled());
}
[Fact]
public void AreUserEspAppsSettled_false_outside_the_user_phase_even_after_the_observation()
{
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: DeviceSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.True(tracker.UserSessionZeroAppsObserved);
Assert.False(tracker.AreUserEspAppsSettled());
}
[Fact]
public void GetPendingRequiredUserEspInstallApps_is_empty_for_a_zero_app_user_session()
{
// The adapter deferral probe must not park the signal when there is nothing to wait
// for — otherwise the zero-app path would starve exactly like the old one.
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.Empty(tracker.GetPendingRequiredUserEspInstallApps());
}
}
}
@@ -0,0 +1,146 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// Pins <see cref="EntraDeviceCertHelper.ParseGuidFromExtension"/>, the pure byte-level part of the
/// cert probe. Both encodings Microsoft actually ships are covered: the ASN.1 OCTET STRING wrapper
/// used by the MS-Organization-Access TenantId/DeviceId extensions (and Intune's
/// <c>1.2.840.113556.5.6</c> / <c>5.14</c>), and the BARE 16-byte payload used by Intune's DeviceId
/// extension <c>1.2.840.113556.5.4</c> — the latter was silently returning <c>null</c> before.
/// The store-iteration shell around it needs a real machine cert store and is not unit-tested.
/// </summary>
public sealed class EntraDeviceCertHelperParseTests
{
// Synthetic — never a real tenant/device id. ToByteArray() gives the MS little-endian layout
// that both cert encodings carry, so every expectation below is derived, not hand-transcribed.
private static readonly Guid Sample = new Guid("11223344-5566-7788-99aa-bbccddeeff00");
private static byte[] Wrap(byte[] payload, params byte[] header)
{
var buf = new byte[header.Length + payload.Length];
Buffer.BlockCopy(header, 0, buf, 0, header.Length);
Buffer.BlockCopy(payload, 0, buf, header.Length, payload.Length);
return buf;
}
[Fact]
public void OctetString_ShortForm_Parses()
{
var raw = Wrap(Sample.ToByteArray(), 0x04, 0x10);
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void OctetString_LongForm_Parses()
{
var raw = Wrap(Sample.ToByteArray(), 0x04, 0x81, 0x10);
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
/// <summary>
/// The regression this class exists for: Intune's <c>1.2.840.113556.5.4</c> extension value is
/// exactly 16 bytes with no <c>04 10</c> prefix, so the strict OCTET-STRING parser bailed on the
/// tag check and the DeviceId was unreadable.
/// </summary>
[Fact]
public void BareSixteenBytes_Parses()
{
var raw = Sample.ToByteArray();
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
/// <summary>
/// A bare GUID may legitimately start with 0x04 (the OCTET STRING tag). That must not be
/// mistaken for a wrapper: a wrapped 16-byte GUID is always >= 18 bytes, so the two forms are
/// disjoint by length and the bare path has to win here.
/// </summary>
[Fact]
public void BareSixteenBytes_StartingWithOctetStringTag_StillParses()
{
var guid = new Guid("aabbcc04-0110-4020-8030-405060708090");
var raw = guid.ToByteArray();
Assert.Equal(0x04, raw[0]); // guard: the byte layout really does start with the ASN.1 tag
Assert.Equal(16, raw.Length);
Assert.Equal(guid, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void OctetString_IsLittleEndianMsLayout()
{
// 04 10 + the canonical MS binary layout of {11223344-...} => first group byte-reversed.
var raw = new byte[]
{
0x04, 0x10,
0x44, 0x33, 0x22, 0x11,
0x66, 0x55,
0x88, 0x77,
0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00
};
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void Null_ReturnsNull()
{
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(null));
}
[Fact]
public void Empty_ReturnsNull()
{
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(new byte[0]));
}
[Theory]
[InlineData(15)]
[InlineData(17)]
public void BarePayload_OfWrongLength_ReturnsNull(int length)
{
var raw = new byte[length];
for (var i = 0; i < length; i++) raw[i] = (byte)(0x40 + i); // no 0x04 tag, no valid wrapper
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void NonOctetStringTag_ReturnsNull()
{
// e.g. Intune's 1.2.840.113556.5.15 => ASN.1 INTEGER 2.
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(new byte[] { 0x02, 0x01, 0x02 }));
}
[Fact]
public void OctetString_WithNonGuidContentLength_ReturnsNull()
{
var raw = Wrap(new byte[8], 0x04, 0x08);
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void OctetString_TruncatedContent_ReturnsNull()
{
// Declares 16 content bytes but only carries 10.
var raw = Wrap(new byte[10], 0x04, 0x10);
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void OctetString_LongFormWithOversizedLengthHeader_ReturnsNull()
{
// 0x85 => 5 length bytes, above the 4 the parser accepts.
var raw = Wrap(Sample.ToByteArray(), 0x04, 0x85, 0x00, 0x00, 0x00, 0x00, 0x10);
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
}
}
@@ -163,5 +163,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.Single(f.DecisionSignals(DecisionSignalKind.ImeUserSessionCompleted));
Assert.Single(f.InfoEvents(SharedEventTypes.ImeUserSessionCompleted));
}
// ==================================================== zero-app evidence label ====
[Fact]
public void Zero_app_user_session_labels_the_evidence_on_signal_and_timeline()
{
// Sessions 81daa77f / 75d6ae8e: with no user-targeted apps IME never writes its
// "Completed user session" line at all, so the verdict rests on a different
// observation. Whoever reads the timeline later must be able to tell which.
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.SeedCurrentPhaseForTesting("AccountSetup");
f.Tracker.SeedUserSessionZeroAppsForTesting();
adapter.TriggerUserSessionCompletedFromTest();
Assert.Single(f.DecisionSignals(DecisionSignalKind.ImeUserSessionCompleted));
var evt = Assert.Single(f.InfoEvents(SharedEventTypes.ImeUserSessionCompleted));
Assert.Equal(
ImeLogTrackerAdapter.EvidenceZeroUserApps,
evt.Payload!["evidence"]);
}
[Fact]
public void Ordinary_user_session_completion_keeps_the_default_evidence_label()
{
// Mutation proof for the branch above.
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.SeedCurrentPhaseForTesting("AccountSetup");
AddApp(f.Tracker, "app-done", AppIntent.Install, AppInstallationState.Installed);
adapter.TriggerUserSessionCompletedFromTest();
var evt = Assert.Single(f.InfoEvents(SharedEventTypes.ImeUserSessionCompleted));
Assert.Equal(
ImeLogTrackerAdapter.EvidenceUserSessionCompleted,
evt.Payload!["evidence"]);
}
}
}
@@ -1042,6 +1042,56 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// </summary>
internal string LastEspPhaseDetected => _lastEspPhaseDetected;
/// <summary>
/// True while the user-scope ESP phase is the tracker's current phase. Every user-phase
/// probe and the zero-apps evidence gate on this — outside AccountSetup the IME lines
/// belong to the device phase or the pre-sign-in (defaultuser0) frame.
/// </summary>
private bool IsUserEspPhaseActive =>
string.Equals(_lastEspPhaseDetected, "AccountSetup", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// True once IME reported zero targeted Win32 apps for a user session while the user ESP
/// phase was active. Read by the adapter to label the evidence behind
/// <c>ime_user_session_completed</c>.
/// </summary>
internal bool UserSessionZeroAppsObserved => _userSessionZeroAppsObserved;
/// <summary>
/// IME-USER-SESSION-ZERO-APPS: IME enumerated the user session and found no targeted
/// Win32 apps. Its zero-app branch then <c>continue</c>s past the "Completed user
/// session" log statement, so this line is the only completion evidence such a device
/// ever produces — and IME itself treats the same condition as finished
/// (<c>ProviderResult.ProvisioningComplete</c> in the user check-in path, and an ESP
/// lock-in with an empty application list in the poller path).
/// <para>
/// Deliberately routed through <see cref="OnUserSessionCompleted"/> rather than a
/// separate callback: downstream this IS a completed user session, and the adapter's
/// pending-apps deferral plus its fire-once flag then apply unchanged.
/// </para>
/// <para>
/// Phase guard: firing before AccountSetup would burn the adapter's fire-once flag on a
/// pre-sign-in timestamp that <c>IsImeUserSessionGenuine</c> can never accept. The
/// observation is remembered instead and replayed by
/// <see cref="HandleEspPhaseDetected"/> on the transition into the user phase.
/// </para>
/// </summary>
private void HandleUserSessionZeroApps()
{
_userSessionZeroAppsObserved = true;
if (!IsUserEspPhaseActive)
{
_logger.Debug(
"ImeLogTracker: zero-app user session observed before the user ESP phase " +
$"(current phase: {_lastEspPhaseDetected ?? "(none)"}) - deferring until AccountSetup");
return;
}
_logger.Info("ImeLogTracker: user session enumerated with zero targeted apps - treating as user session completed");
OnUserSessionCompleted?.Invoke();
}
private void HandleEspPhaseDetected(string espPhaseString)
{
// Validate phase and get its ordinal for forward-only enforcement
@@ -1120,6 +1170,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
ActivatePatterns(logPhaseIsCurrentPhase);
OnEspPhaseChanged?.Invoke(espPhaseString);
// Replay a zero-app user session observed before this phase was known. IME logs its
// check-in lines independently of when the agent first parses the ESP phase marker,
// so the evidence can legitimately arrive first; without this the only completion
// evidence a zero-app device produces would be dropped on the floor.
if (isFirstOrChangedPhase && IsUserEspPhaseActive && _userSessionZeroAppsObserved)
{
_logger.Info("ImeLogTracker: replaying zero-app user session evidence observed before the user ESP phase");
OnUserSessionCompleted?.Invoke();
}
}
private void HandleEspTrackStatus(Match match)
@@ -387,6 +387,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
OnUserSessionCompleted?.Invoke();
break;
case "usersessionzeroapps":
// IME found no targeted user apps — its zero-app branch skips the
// "Completed user session" line, so this is the equivalent evidence.
HandleUserSessionZeroApps();
break;
case "updatestateinstalled":
if (!string.IsNullOrEmpty(id))
UpdateStateWithCallback(id, AppInstallationState.Installed);
@@ -129,6 +129,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private CancellationTokenSource _cts;
private bool _allAppsCompletedFired;
// Sticky: IME enumerated a user session and reported ZERO targeted Win32 apps
// (IME-USER-SESSION-ZERO-APPS). IME's zero-app branch returns before it writes the
// "Completed user session" line, so on a device with no user-targeted apps that line
// never appears and every user-phase completion probe would starve forever. This flag
// IS the user-phase completion evidence for that shape. Set only while the user ESP
// phase is active, or replayed on the transition into it.
private bool _userSessionZeroAppsObserved;
// State persistence: saves tracker state to disk so agent restart continues
// from the exact log position without re-parsing or re-building ignore lists.
private readonly ImeTrackerStatePersistence _statePersistence;
@@ -341,6 +349,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_lastEspPhaseDetected = phaseName;
}
/// <summary>
/// Test-only seam: marks the zero-targeted-apps user session as observed without driving
/// the IME-USER-SESSION-ZERO-APPS pattern through a log line. The pattern-to-flag path
/// itself is covered end-to-end by <c>ImeLogTrackerZeroUserAppsTests</c>.
/// </summary>
internal void SeedUserSessionZeroAppsForTesting()
{
_userSessionZeroAppsObserved = true;
}
/// <summary>
/// Returns a deduped union of phase-snapshotted apps plus the live
/// <see cref="PackageStates"/> (current phase). Live entries win on Id collision.
@@ -395,11 +413,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
try
{
if (!string.Equals(_lastEspPhaseDetected, "AccountSetup", StringComparison.OrdinalIgnoreCase))
if (!IsUserEspPhaseActive)
return false;
var apps = new List<AppPackageState>(_packageStates);
if (apps.Count == 0) return false;
// Zero user-targeted apps is the MOST settled state there is, but the
// conservative "at least one required app" rule below reads it as "not
// settled" and starves the AccountSetup synthesis. Only an explicit IME
// observation lifts that — never a merely empty list, which also means
// "phase just cleared" or "apps not surfaced yet".
if (apps.Count == 0)
return _userSessionZeroAppsObserved;
var sawRequired = false;
foreach (var pkg in apps)
@@ -746,6 +771,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_currentPhaseOrder = state.CurrentPhaseOrder;
_lastEspPhaseDetected = state.LastEspPhaseDetected;
_allAppsCompletedFired = state.AllAppsCompletedFired;
_userSessionZeroAppsObserved = state.UserSessionZeroAppsObserved;
_logPhaseIsCurrentPhase = state.LogPhaseIsCurrentPhase;
// Restore seen app IDs
@@ -861,6 +887,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
CurrentPhaseOrder = _currentPhaseOrder,
LastEspPhaseDetected = _lastEspPhaseDetected,
AllAppsCompletedFired = _allAppsCompletedFired,
UserSessionZeroAppsObserved = _userSessionZeroAppsObserved,
LogPhaseIsCurrentPhase = _logPhaseIsCurrentPhase,
SeenAppIds = _seenAppIds.ToList(),
IgnoreList = _packageStates.IgnoreList.ToList(),
@@ -109,6 +109,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public int CurrentPhaseOrder { get; set; }
public string LastEspPhaseDetected { get; set; }
public bool AllAppsCompletedFired { get; set; }
/// <summary>
/// IME reported zero targeted Win32 apps for a user session. Absent in state files
/// written before this field existed — deserializing to false there is correct: the
/// tracker simply re-observes the line on the next IME check-in.
/// </summary>
public bool UserSessionZeroAppsObserved { get; set; }
public bool LogPhaseIsCurrentPhase { get; set; }
public List<string> SeenAppIds { get; set; }
public List<string> IgnoreList { get; set; }
@@ -2,6 +2,7 @@ using System;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Security;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
@@ -23,6 +24,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <item><description><c>1.2.840.113556.1.5.284.2</c> → DeviceId (same encoding)</description></item>
/// </list>
/// <para>
/// The sibling <c>Microsoft Intune MDM Device CA</c> client cert (the one the agent authenticates
/// with, see <see cref="CertificateHelper"/>) carries a DIFFERENT OID arc that identifies the same
/// device from Intune's side. Observed on a live Intune-enrolled Windows 11 device:
/// </para>
/// <list type="table">
/// <listheader><term>OID</term><description>Meaning / encoding</description></listheader>
/// <item><term><c>1.2.840.113556.5.4</c></term><description>Intune DeviceId — BARE 16-byte GUID, no ASN.1 wrapper. Equals the cert's Subject CN.</description></item>
/// <item><term><c>1.2.840.113556.5.6</c></term><description>Intune AccountId (the Intune tenant account, NOT the Entra TenantId) — OCTET STRING GUID.</description></item>
/// <item><term><c>1.2.840.113556.5.14</c></term><description>Entra TenantId — OCTET STRING GUID. This is the one to compare against an Entra tenant id.</description></item>
/// <item><term><c>1.2.840.113556.5.11</c></term><description>Further GUID, OCTET STRING; semantics not documented by Microsoft and not verified here.</description></item>
/// <item><term><c>1.2.840.113556.5.15/.16/.17/.18/.19/.23</c></term><description>ASN.1 INTEGER enums (platform / enrollment flavour); semantics undocumented.</description></item>
/// </list>
/// <para>
/// The bare-GUID encoding of <c>1.2.840.113556.5.4</c> is why
/// <see cref="ParseGuidFromExtension"/> accepts an unwrapped 16-byte payload as well —
/// a strict OCTET-STRING parser silently returns <c>null</c> for that extension.
/// </para>
/// <para>
/// All probes are best-effort: never throw, return <c>null</c> on miss, log a Warning
/// with the specific failure reason (no matching cert / extension missing / ASN.1
/// parse failed) so the agent log explains why the fallback didn't help.
@@ -31,8 +50,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
public static class EntraDeviceCertHelper
{
public const string MsOrganizationAccessIssuer = "MS-Organization-Access";
public const string TenantIdOid = "1.2.840.113556.1.5.284.5";
public const string DeviceIdOid = "1.2.840.113556.1.5.284.2";
public const string TenantIdOid = MsDeviceCertificateOids.EntraJoinCertTenantIdOid;
public const string DeviceIdOid = MsDeviceCertificateOids.EntraJoinCertDeviceIdOid;
/// <summary>
/// Reads the Entra TenantId from the MS-Organization-Access device cert.
@@ -88,7 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
continue;
}
var guid = ParseGuidFromAsn1OctetString(ext.RawData);
var guid = ParseGuidFromExtension(ext.RawData);
if (guid != null)
{
logger?.Info(
@@ -98,7 +117,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
}
logger?.Warning(
$"EntraDeviceCertHelper: failed to parse {label} ASN.1 OCTET STRING in cert thumbprint={cert.Thumbprint} " +
$"EntraDeviceCertHelper: failed to parse {label} GUID extension in cert thumbprint={cert.Thumbprint} " +
$"(raw {ext.RawData.Length} bytes); trying next candidate.");
}
@@ -116,46 +135,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <summary>
/// Parses a 16-byte MS-format binary GUID (little-endian for the first three groups,
/// per <see cref="Guid(byte[])"/>) wrapped in an ASN.1 OCTET STRING. Handles both
/// short-form (<c>04 10 ...</c>) and long-form (<c>04 81 10 ...</c>) length encoding.
/// per <see cref="Guid(byte[])"/>) out of a certificate extension's raw value.
/// Two encodings are accepted, because Microsoft uses both:
/// <list type="number">
/// <item><description>Wrapped in an ASN.1 OCTET STRING — short-form (<c>04 10 ...</c>) or
/// long-form (<c>04 81 10 ...</c>) length. Used by the MS-Organization-Access TenantId/DeviceId
/// extensions and by the Intune <c>…5.6</c> / <c>…5.14</c> extensions.</description></item>
/// <item><description>A bare, unwrapped 16-byte payload. Used by the Intune DeviceId extension
/// <c>1.2.840.113556.5.4</c> — verified against a live Intune MDM device cert, whose raw value is
/// exactly 16 bytes with no <c>04 10</c> prefix.</description></item>
/// </list>
/// A valid OCTET-STRING wrapper around 16 bytes is at least 18 bytes long, so the two forms
/// never collide for well-formed input. A 16-byte value is always read as the bare form —
/// a truncated wrapper of exactly that length is indistinguishable from it.
/// Returns <c>null</c> on any structural mismatch — never throws.
/// <para>
/// The decoding itself lives in <see cref="MsDeviceCertificateOids.TryParseGuid"/> so the agent
/// and the backend's client-cert tenant binding share one implementation; this stays as the
/// agent-side nullable-returning façade.
/// </para>
/// </summary>
public static Guid? ParseGuidFromAsn1OctetString(byte[] raw)
{
if (raw == null || raw.Length < 2) return null;
// ASN.1 OCTET STRING tag.
if (raw[0] != 0x04) return null;
int contentStart;
int contentLen;
if (raw[1] < 0x80)
{
// short-form length
contentLen = raw[1];
contentStart = 2;
}
else
{
// long-form length: lower 7 bits = number of following length bytes.
int numLenBytes = raw[1] & 0x7F;
if (numLenBytes < 1 || numLenBytes > 4 || raw.Length < 2 + numLenBytes) return null;
contentLen = 0;
for (int i = 0; i < numLenBytes; i++)
contentLen = (contentLen << 8) | raw[2 + i];
contentStart = 2 + numLenBytes;
}
if (contentLen != 16 || contentStart + 16 > raw.Length) return null;
var guidBytes = new byte[16];
Buffer.BlockCopy(raw, contentStart, guidBytes, 0, 16);
try { return new Guid(guidBytes); }
catch { return null; }
}
public static Guid? ParseGuidFromExtension(byte[] raw) =>
MsDeviceCertificateOids.TryParseGuid(raw, out var value) ? value : (Guid?)null;
}
}
@@ -56,6 +56,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
private const string SourceLabel = "ImeLogTracker";
/// <summary>Timeline label: IME wrote its "Completed user session" line and no required user-scope app was pending.</summary>
internal const string EvidenceUserSessionCompleted = "user_session_completed";
/// <summary>Timeline label: IME reported zero targeted apps for the user session, so it never wrote a
/// "Completed user session" line at all. The user phase had nothing to enforce.</summary>
internal const string EvidenceZeroUserApps = "zero_user_apps_enumerated";
private readonly ImeLogTracker _tracker;
private readonly ISignalIngressSink _ingress;
private readonly IClock _clock;
@@ -591,9 +598,24 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
// Which observation carried the completion. The zero-app variant matters to whoever
// reads the timeline later: the verdict then rests on IME reporting that this user
// had no targeted apps at all, not on watching enforcement finish.
var zeroApps = _tracker.UserSessionZeroAppsObserved;
var evidenceLabel = zeroApps ? EvidenceZeroUserApps : EvidenceUserSessionCompleted;
var summary = zeroApps
? "IME enumerated the user session with zero targeted apps"
: "IME user session completed (no required user-scope apps pending)";
var message = zeroApps
? "IME enumerated the user session with zero targeted apps - no user-phase enforcement to wait for"
: "IME user session completed";
var derivationInputs = new Dictionary<string, string>(StringComparer.Ordinal)
{
["detectionSource"] = "IME log pattern (UserSessionCompleted)",
["detectionSource"] = zeroApps
? "IME log pattern (UserSessionZeroApps)"
: "IME log pattern (UserSessionCompleted)",
["evidence"] = evidenceLabel,
};
TagDerivedTimestamp(derivationInputs, derivedFromClock, rawSourceTs);
@@ -604,12 +626,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
evidence: new Evidence(
kind: EvidenceKind.Derived,
identifier: "ime-log-tracker-v1",
summary: "IME user session completed (no required user-scope apps pending)",
summary: summary,
derivationInputs: derivationInputs));
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["detectedAt"] = now.ToString("o"),
["evidence"] = evidenceLabel,
};
var patternId = _tracker.LastMatchedPatternId;
if (!string.IsNullOrEmpty(patternId))
@@ -619,12 +642,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_post.Emit(
eventType: SharedEventTypes.ImeUserSessionCompleted,
source: SourceLabel,
message: "IME user session completed",
message: message,
data: data,
occurredAtUtc: now);
// PR3-D4: terminal-ish lifecycle marker — INFO so it's visible at the default level.
_logger?.Info($"ImeAdapter: user session completed -> posting ImeUserSessionCompleted (patternId={patternId ?? "(none)"})");
_logger?.Info($"ImeAdapter: user session completed ({evidenceLabel}) -> posting ImeUserSessionCompleted (patternId={patternId ?? "(none)"})");
}
/// <summary>
@@ -0,0 +1,242 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// Sessions 81daa77f / 75d6ae8e (2026-08-25) — the 30-min <c>advisory_completion</c> window
/// overran a completion gate that was deliberately holding the session. The RealmJoin gate
/// blocks completion until phase 110 and carries its own bounded resolution (60 min from
/// detection, re-armed on deployment activity, hard-capped at 4 h), so on every device whose
/// first deployment outlives 30 minutes the enrollment was failed while the engine own
/// <c>completion_waiting</c> still read "waiting on: realmjoin_resolution". 75d6ae8e
/// installed 25 RealmJoin packages successfully inside the window, the last one 2 min before
/// the failure; the customer tenant hit this on 10 of 30 enrollments in one week.
/// <para>
/// The guard re-arms while the gate holds. Convergence comes from the gate itself: Resolved,
/// FirstDeploymentIncomplete and Timeout all set <c>RealmJoinFacts.Outcome</c>, which opens
/// <c>RealmJoinGateOpen</c> and stops the guard applying.
/// </para>
/// </summary>
public sealed class AdvisoryCompletionGateHoldingTests
{
private static readonly DateTime T0 = new DateTime(2026, 8, 25, 6, 10, 0, DateTimeKind.Utc);
private static DecisionSignal MakeSignal(
long ordinal,
DecisionSignalKind kind,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, string>? payload = null)
{
return new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: kind,
kindSchemaVersion: 1,
occurredAtUtc: occurredAtUtc,
sourceOrigin: "test",
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
payload: payload);
}
private static DecisionSignal DeadlineFired(long ordinal, DateTime occurredAtUtc, string deadlineName) =>
MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, occurredAtUtc,
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = deadlineName });
private static ActiveDeadline? FindDeadline(DecisionState state, string name) =>
state.Deadlines.FirstOrDefault(d => d.Name == name);
private static DecisionEffect SingleTimelineEffect(DecisionStep step, string eventType) =>
step.Effects.Single(e =>
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
&& e.Parameters != null
&& e.Parameters.TryGetValue("eventType", out var et)
&& et == eventType);
/// <summary>
/// Replays the shape both field sessions shared: Classic user-driven, full ESP, Hello
/// policy enabled, AccountSetup entered, then a guard-blocked post-AccountSetup ESP exit
/// (Shell-Core 62407, "Exiting page normally", errorCode 0) that arms the 30-min window.
/// The ESP registry froze at "1 of 5" and the device had zero user-targeted Intune apps,
/// so no arm of ShouldTransitionToAwaitingHello could ever open.
/// </summary>
private static DecisionState SetupEspExitDeadEnd(DecisionEngine engine)
{
var state = DecisionState.CreateInitial("sess-81daa77f", "tenant-81daa77f", T0);
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
state = engine.Reduce(state, MakeSignal(
5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1),
new Dictionary<string, string>
{
[SignalPayloadKeys.SkipUserEsp] = "false",
[SignalPayloadKeys.SkipDeviceEsp] = "false",
[SignalPayloadKeys.EspAllowContinueAnyway] = "false",
})).NewState;
state = engine.Reduce(state, MakeSignal(
10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
state = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2.5),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
// Guard-blocked post-AccountSetup exit — arms advisory_completion at exit + 30 min.
state = engine.Reduce(state, MakeSignal(30, DecisionSignalKind.EspExiting, T0.AddMinutes(3))).NewState;
Assert.NotNull(FindDeadline(state, DeadlineNames.AdvisoryCompletion));
Assert.Null(state.Outcome);
return state;
}
/// <summary>Desktop (DAD-validated real user), RealmJoin detected, Hello provisioned.</summary>
private static DecisionState AddDesktopRealmJoinAndHello(DecisionEngine engine, DecisionState state)
{
state = engine.Reduce(state, MakeSignal(40, DecisionSignalKind.DesktopArrived, T0.AddMinutes(3.5))).NewState;
state = engine.Reduce(state, MakeSignal(
50, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(6),
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0" })).NewState;
state = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.HelloResolved, T0.AddMinutes(14),
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "Success" })).NewState;
return state;
}
// ==================================================== the gate-holding guard ====
[Fact]
public void DeadlineFired_WhileRealmJoinGateHolds_RearmsInsteadOfFailing()
{
var engine = new DecisionEngine();
var state = SetupEspExitDeadEnd(engine);
state = AddDesktopRealmJoinAndHello(engine, state);
// Preconditions: the gate is closed and owns an armed deadline of its own.
Assert.False(DecisionEngine.RealmJoinGateOpen(state));
Assert.NotNull(FindDeadline(state, DeadlineNames.RealmJoinTimeout));
var fireAt = T0.AddMinutes(33);
var step = engine.Reduce(state, DeadlineFired(70, fireAt, DeadlineNames.AdvisoryCompletion));
Assert.True(step.Transition.Taken);
Assert.Null(step.NewState.Outcome);
Assert.NotEqual(SessionStage.Failed, step.NewState.Stage);
var rearmed = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
Assert.NotNull(rearmed);
Assert.Equal(fireAt.AddMinutes(30), rearmed!.DueAtUtc);
var schedule = step.Effects.Single(e => e.Kind == DecisionEffectKind.ScheduleDeadline);
Assert.Equal(DeadlineNames.AdvisoryCompletion, schedule.Deadline!.Name);
var waiting = SingleTimelineEffect(step, "completion_waiting");
Assert.Contains("CompletionGateHolding", waiting.Parameters!["trigger"]);
Assert.Contains("realmjoin_resolution", waiting.Parameters!["missingPrerequisites"]);
Assert.Equal(rearmed.DueAtUtc.ToString("o"), waiting.Parameters!["resolutionDeadlineDueAtUtc"]);
}
[Fact]
public void Session81daa77f_RealmJoinResolvesAfterTheWindow_CompletesInsteadOfFailing()
{
// End-to-end proof against the real field shape: before this guard the session was
// stamped Failed at exit+30 min with esp_exit_without_completion_evidence, 34 min
// before the RealmJoin gate would have released it.
var engine = new DecisionEngine();
var state = SetupEspExitDeadEnd(engine);
state = AddDesktopRealmJoinAndHello(engine, state);
var fired = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(33), DeadlineNames.AdvisoryCompletion));
Assert.Null(fired.NewState.Outcome);
// RealmJoin reaches phase 110 — the gate opens and the session completes.
var resolved = engine.Reduce(fired.NewState, MakeSignal(
80, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(50),
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "110" }));
Assert.Equal(SessionStage.Finalizing, resolved.NewState.Stage);
Assert.Equal("Resolved", resolved.NewState.RealmJoinFacts.Outcome!.Value);
var grace = FindDeadline(resolved.NewState, DeadlineNames.FinalizingGrace);
Assert.NotNull(grace);
var completed = engine.Reduce(resolved.NewState,
DeadlineFired(90, grace!.DueAtUtc, DeadlineNames.FinalizingGrace));
Assert.Equal(SessionStage.Completed, completed.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentComplete, completed.NewState.Outcome);
}
// ============================================================== convergence ====
[Fact]
public void DeadlineFired_AfterTheGateOpened_ResolvesNormallyAgain()
{
// Convergence proof: the guard is not an escape hatch. Once RealmJoin timed out the
// gate is open, the guard stops applying, and a session with no completion evidence
// fails exactly as before.
var engine = new DecisionEngine();
var state = SetupEspExitDeadEnd(engine);
state = engine.Reduce(state, MakeSignal(
50, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(6),
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0" })).NewState;
var rjDeadline = FindDeadline(state, DeadlineNames.RealmJoinTimeout);
Assert.NotNull(rjDeadline);
state = engine.Reduce(state,
DeadlineFired(60, rjDeadline!.DueAtUtc, DeadlineNames.RealmJoinTimeout)).NewState;
Assert.True(DecisionEngine.RealmJoinGateOpen(state));
Assert.Null(FindDeadline(state, DeadlineNames.RealmJoinTimeout));
// No desktop, no Hello, no IME evidence — the genuine dead-end shape.
var step = engine.Reduce(state,
DeadlineFired(70, T0.AddMinutes(80), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
var failed = SingleTimelineEffect(step, "enrollment_failed");
Assert.Equal("esp_exit_without_completion_evidence", failed.Parameters!["reason"]);
}
// =========================================================== mutation proofs ====
[Fact]
public void DeadlineFired_WithoutRealmJoin_StillFails()
{
// The guard must key on an actually-held gate, never on the absence of evidence.
var engine = new DecisionEngine();
var state = SetupEspExitDeadEnd(engine);
var step = engine.Reduce(state,
DeadlineFired(70, T0.AddMinutes(33), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
var failed = SingleTimelineEffect(step, "enrollment_failed");
Assert.Equal("esp_exit_without_completion_evidence", failed.Parameters!["reason"]);
}
[Fact]
public void DeadlineFired_GateClosedButItsDeadlineNoLongerArmed_StillFails()
{
// A gate that can no longer resolve itself must not park the session forever. The
// hand-built state drops only the RealmJoin deadline, leaving the gate closed.
var engine = new DecisionEngine();
var state = SetupEspExitDeadEnd(engine);
state = AddDesktopRealmJoinAndHello(engine, state);
var builder = state.ToBuilder();
builder.CancelDeadline(DeadlineNames.RealmJoinTimeout);
state = builder.Build();
Assert.False(DecisionEngine.RealmJoinGateOpen(state));
Assert.Null(FindDeadline(state, DeadlineNames.RealmJoinTimeout));
var step = engine.Reduce(state,
DeadlineFired(70, T0.AddMinutes(33), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
}
}
}
@@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
@@ -172,7 +173,7 @@ public class BuiltInRulesTests
"policiesDiscovered", "ignoreCompletedApp", "imeAgentVersion",
"espTrackStatus", "updateName", "updateWin32AppState",
"cancelStuckAndSetCurrent", "imeSessionChange", "imeImpersonation", "imeTokenFailure",
"enrollmentCompleted", "updateDoTelemetry", "scriptStarted",
"enrollmentCompleted", "userSessionZeroApps", "updateDoTelemetry", "scriptStarted",
"scriptContext", "scriptExitCode", "scriptOutput", "scriptCompleted",
"resetPlatformScriptContext",
"healthScriptResult", "healthScriptDetectionResult",
@@ -201,4 +202,38 @@ public class BuiltInRulesTests
$"Pattern {pattern.PatternId} has invalid category '{pattern.Category}'");
}
}
/// <summary>
/// Sessions 81daa77f / 75d6ae8e (2026-08-25). IME writes this line and then returns before
/// its "Completed user session" statement, so on a device with no user-targeted Win32 apps
/// it is the ONLY evidence that user-phase enforcement finished. The exact wording is
/// pinned here against the real IME line (verified against decompiled IME 1.104.102.0,
/// ApplicationPoller and AppWorkloadAbstraction) because a silent drift would bring back
/// the 30-min esp_exit_without_completion_evidence false positive with no test failing.
/// </summary>
[Fact]
public void BuiltInImeLogPatterns_ZeroUserAppsPattern_MatchesTheRealImeLine()
{
var pattern = Assert.Single(
BuiltInImeLogPatterns.GetAll(),
p => p.PatternId == "IME-USER-SESSION-ZERO-APPS");
Assert.Equal("userSessionZeroApps", pattern.Action);
Assert.Equal("always", pattern.Category);
var regex = new Regex(pattern.Pattern, RegexOptions.CultureInvariant);
var real = "[Win32App] Get 0 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020";
var match = regex.Match(real);
Assert.True(match.Success, "zero-app user session line must match");
Assert.Equal("1", match.Groups["sessionId"].Value);
Assert.Equal("4a1ba5db-44ed-4e69-8477-47ed88e76020", match.Groups["userId"].Value);
// Must not swallow a session that DOES have apps — that would fabricate completion
// evidence while enforcement is still running.
Assert.DoesNotMatch(regex,
"[Win32App] Get 3 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
Assert.DoesNotMatch(regex,
"[Win32App] Get 10 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
}
}
@@ -0,0 +1,358 @@
using System.Net;
using System.Security.Cryptography.X509Certificates;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Security;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// CERT-TENANT-BINDING-SHADOW — stage 1 coverage.
///
/// The agent authenticates with the Intune MDM device certificate, whose chain is pinned to
/// Microsoft's Intune roots. Those roots are shared by every Intune tenant, so a valid chain alone
/// does not prove the caller belongs to the tenant it claims. The CA stamps the Entra tenant id
/// into OID 1.2.840.113556.5.14, which lets the backend bind the two.
///
/// These tests pin three things:
/// - the tenant id is actually extracted from a REAL field certificate (not a synthetic fixture),
/// - the pure comparison covers every outcome the telemetry can report,
/// - shadow mode observes without deciding: a cross-tenant certificate must still pass the
/// certificate stage in stage 1, or we would be enforcing before we measured.
/// </summary>
public class CertTenantBindingTests
{
private const string DeviceSamplePem = "device-cert-sample.pem";
/// <summary>
/// Tenant stamped into <c>device-cert-sample.pem</c> by the Intune MDM Device CA. The sample is
/// a public-key-only cert from an internal test device; the GUID is the test tenant's own id.
/// </summary>
private static readonly Guid SampleCertTenantId = Guid.Parse("b54dc1af-5320-4f60-b5d4-821e0cf2a359");
private static string SampleCertBase64()
{
var assemblyDir = Path.GetDirectoryName(typeof(CertTenantBindingTests).Assembly.Location)!;
var pem = File.ReadAllText(Path.Combine(assemblyDir, DeviceSamplePem));
using var cert = X509Certificate2.CreateFromPem(pem);
return Convert.ToBase64String(cert.Export(X509ContentType.Cert));
}
// ---------------------------------------------------------------- extraction
[Fact]
public void ValidateCertificate_RealIntuneCert_ExtractsEntraTenantId()
{
// The load-bearing test for the whole feature: if Microsoft ever stops stamping OID
// 1.2.840.113556.5.14, or changes its encoding, enforcement would lock every device out.
var result = CertificateValidator.ValidateCertificate(SampleCertBase64());
Assert.True(result.IsValid, $"Real device cert rejected: {result.ErrorMessage}");
Assert.Equal(CertTenantIdStatus.Present, result.CertTenantIdStatus);
Assert.Equal(SampleCertTenantId, result.CertTenantId);
}
[Fact]
public void ValidateCertificate_CachedResult_KeepsTenantId()
{
// The tenant id is a pure function of the cert and is cached with the validation result,
// so a cache hit must not silently drop it and turn every repeat request into
// ExtensionMissing.
var b64 = SampleCertBase64();
_ = CertificateValidator.ValidateCertificate(b64);
var second = CertificateValidator.ValidateCertificate(b64);
Assert.True(second.IsValid);
Assert.Equal(CertTenantIdStatus.Present, second.CertTenantIdStatus);
Assert.Equal(SampleCertTenantId, second.CertTenantId);
}
// ---------------------------------------------------------------- encoding
[Fact]
public void TryParseGuid_PinsRealFieldEncodings()
{
// Exact bytes lifted from the sample cert. OID 5.14 nests the GUID in an OCTET STRING,
// OID 5.4 stores the 16 bytes bare — both shapes must decode, and both are little-endian.
var wrapped = Convert.FromHexString("0410afc14db52053604fb5d4821e0cf2a359");
var bare = Convert.FromHexString("6701bc076190b743b2430bb6aeda736e");
Assert.True(MsDeviceCertificateOids.TryParseGuid(wrapped, out var tenantId));
Assert.Equal(SampleCertTenantId, tenantId);
Assert.True(MsDeviceCertificateOids.TryParseGuid(bare, out var deviceId));
// The Intune device id is also the certificate's Subject CN.
Assert.Equal(Guid.Parse("07bc0167-9061-43b7-b243-0bb6aeda736e"), deviceId);
}
[Fact]
public void TryParseGuid_AccountIdOidIsNotTheTenantId()
{
// Guards the mistake this feature was nearly built on: OID 5.6 carries the Intune ACCOUNT
// id, a different GUID. Comparing it against the tenant id would reject every request.
var accountIdExt = Convert.FromHexString("04103d85dede74e26b409d0d46b6fe65b440");
Assert.True(MsDeviceCertificateOids.TryParseGuid(accountIdExt, out var accountId));
Assert.NotEqual(SampleCertTenantId, accountId);
}
[Theory]
[InlineData("")] // empty
[InlineData("0400")] // OCTET STRING, zero length
[InlineData("020102")] // not an OCTET STRING
[InlineData("0411afc14db52053604fb5d4821e0cf2a35900")] // wrapper claims 17 bytes
[InlineData("0410afc14db52053604fb5d4821e0cf2a3")] // wrapper claims 16, only 15 present
public void TryParseGuid_RejectsMalformed(string hex)
{
Assert.False(MsDeviceCertificateOids.TryParseGuid(Convert.FromHexString(hex), out _));
}
[Fact]
public void TryParseGuid_AnySixteenBytesDecodeAsBareGuid()
{
// Documents a deliberate limit rather than a gap: the bare form (OID 5.4) is exactly 16
// bytes with no tag, so a 16-byte input is indistinguishable from a truncated wrapper and
// is always read as a GUID. Harmless here — the value is only ever compared for equality
// against a known tenant id, never trusted as a claim in its own right.
Assert.True(MsDeviceCertificateOids.TryParseGuid(
Convert.FromHexString("0410afc14db52053604fb5d4821e0cf2"), out _));
}
[Fact]
public void TryParseGuid_RejectsNull()
{
Assert.False(MsDeviceCertificateOids.TryParseGuid(null!, out _));
}
// ---------------------------------------------------------------- comparison
[Fact]
public void Evaluate_MatchingTenant_ReturnsMatch()
{
var outcome = CertTenantBinding.Evaluate(
SampleCertTenantId, CertTenantIdStatus.Present, SampleCertTenantId.ToString());
Assert.Equal(CertTenantBinding.Outcome.Match, outcome);
Assert.False(CertTenantBinding.WouldRejectUnderEnforcement(outcome));
}
[Fact]
public void Evaluate_MatchingTenant_IsCaseInsensitive()
{
// Tenant ids travel as strings in headers/routes and casing must not decide access.
var outcome = CertTenantBinding.Evaluate(
SampleCertTenantId, CertTenantIdStatus.Present, SampleCertTenantId.ToString().ToUpperInvariant());
Assert.Equal(CertTenantBinding.Outcome.Match, outcome);
}
[Fact]
public void Evaluate_ForeignTenantCert_ReturnsMismatch()
{
// The attack this feature exists for: a valid Intune cert from the attacker's own tenant,
// replayed against a victim tenant whose device serial the attacker knows.
var outcome = CertTenantBinding.Evaluate(
SampleCertTenantId, CertTenantIdStatus.Present, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
Assert.Equal(CertTenantBinding.Outcome.Mismatch, outcome);
Assert.True(CertTenantBinding.WouldRejectUnderEnforcement(outcome));
}
[Theory]
[InlineData(CertTenantIdStatus.ExtensionMissing, CertTenantBinding.Outcome.ExtensionMissing)]
[InlineData(CertTenantIdStatus.Unparseable, CertTenantBinding.Outcome.Unparseable)]
public void Evaluate_UndecodableCert_ReportsWhyWithoutClaimingMismatch(
CertTenantIdStatus status, string expected)
{
// These must stay distinguishable from Mismatch: they measure rollout readiness
// (how many field certs can be enforced against), not an attack.
var outcome = CertTenantBinding.Evaluate(null, status, SampleCertTenantId.ToString());
Assert.Equal(expected, outcome);
Assert.False(CertTenantBinding.WouldRejectUnderEnforcement(outcome));
}
[Fact]
public void Evaluate_PresentButNullGuid_FallsBackToExtensionMissing()
{
var outcome = CertTenantBinding.Evaluate(null, CertTenantIdStatus.Present, SampleCertTenantId.ToString());
Assert.Equal(CertTenantBinding.Outcome.ExtensionMissing, outcome);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not-a-guid")]
public void Evaluate_NonGuidRequestTenant_IsReportedSeparately(string? requestedTenantId)
{
var outcome = CertTenantBinding.Evaluate(
SampleCertTenantId, CertTenantIdStatus.Present, requestedTenantId);
Assert.Equal(CertTenantBinding.Outcome.RequestTenantNotAGuid, outcome);
Assert.False(CertTenantBinding.WouldRejectUnderEnforcement(outcome));
}
// ---------------------------------------------------------------- shadow mode
[Fact]
public async Task ValidateRequestAsync_ForeignTenantCert_IsObservedButNotRejected()
{
// Stage 1 contract: a cross-tenant certificate is logged and still passes the certificate
// stage. If this test ever fails, enforcement was switched on without the telemetry review.
var logger = new CapturingLogger();
var validator = BuildValidator(logger);
var result = await validator.ValidateRequestAsync(
BuildRequestWithCert(SampleCertBase64()),
tenantId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
// Deliberately accepted: stage 1 measures, it does not enforce. This assertion is the one
// that must be inverted (to a 403) when stage 2 turns enforcement on.
Assert.True(result.IsValid, $"Shadow mode changed the outcome: {result.ErrorMessage} / {result.Details}");
Assert.NotEqual(HttpStatusCode.Unauthorized, result.StatusCode);
var observed = Assert.Single(logger.Entries, e => e.Message.Contains("AgentCertTenantBinding"));
Assert.Equal(LogLevel.Warning, observed.Level);
Assert.Contains(CertTenantBinding.Outcome.Mismatch, observed.Message);
Assert.Contains("enforced=False", observed.Message);
Assert.Contains("wouldReject=True", observed.Message);
}
[Fact]
public async Task ValidateRequestAsync_MatchingTenantCert_IsAccepted()
{
var logger = new CapturingLogger();
var validator = BuildValidator(logger);
var result = await validator.ValidateRequestAsync(
BuildRequestWithCert(SampleCertBase64()),
tenantId: SampleCertTenantId.ToString());
Assert.True(result.IsValid, $"Matching-tenant request rejected: {result.ErrorMessage}");
// Match is deliberately NOT a trace line — worker-side LogInformation never reaches App
// Insights, so a Match trace would be dead code that silently costs us the denominator.
Assert.DoesNotContain(logger.Entries, e => e.Message.Contains("AgentCertTenantBinding"));
}
[Fact]
public async Task ValidateRequestAsync_StampsOutcomeOnTheRequestRow()
{
// The denominator lives here: RequestTelemetryMiddleware copies this item onto the request
// row, which already exists per request and is unsampled. Without it, "Match" is invisible
// and the shadow telemetry cannot say how often the binding held.
var items = new Dictionary<object, object>();
var validator = BuildValidator(new CapturingLogger());
var result = await validator.ValidateRequestAsync(
BuildRequestWithCert(SampleCertBase64(), items),
tenantId: SampleCertTenantId.ToString());
Assert.True(result.IsValid, $"Matching-tenant request rejected: {result.ErrorMessage}");
Assert.Equal(
CertTenantBinding.Outcome.Match,
Assert.Contains(CertTenantBinding.RequestItemKey, items));
}
[Fact]
public async Task ValidateRequestAsync_StampsMismatchOnTheRequestRowToo()
{
var items = new Dictionary<object, object>();
var validator = BuildValidator(new CapturingLogger());
await validator.ValidateRequestAsync(
BuildRequestWithCert(SampleCertBase64(), items),
tenantId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
Assert.Equal(
CertTenantBinding.Outcome.Mismatch,
Assert.Contains(CertTenantBinding.RequestItemKey, items));
}
// ---------------------------------------------------------------- harness
private static SecurityValidator BuildValidator(ILogger logger)
{
var configRepo = Mock.Of<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
// AllowInsecureAgentRequests clears the "at least one device validator" gate so the request
// reaches — and passes — the certificate stage without needing Graph mocks.
var config = new TenantConfiguration
{
AllowInsecureAgentRequests = true,
ManufacturerWhitelist = "*",
ModelWhitelist = "*",
};
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: logger,
bootstrapSessionService: null,
deviceAssociationValidator: null);
}
private static HttpRequestData BuildRequestWithCert(
string certBase64,
IDictionary<object, object>? items = null)
{
var contextMock = new Mock<Microsoft.Azure.Functions.Worker.FunctionContext>();
contextMock.SetupGet(c => c.Items).Returns(items ?? new Dictionary<object, object>());
var reqMock = new Mock<HttpRequestData>(contextMock.Object);
var headers = new HttpHeadersCollection
{
{ "X-ARR-ClientCert", certBase64 },
// The hardware whitelist stage runs after the certificate stage and rejects requests
// with no hardware headers, which would mask what these tests are actually asserting.
{ "X-Device-Manufacturer", "Contoso" },
{ "X-Device-Model", "TestBook" },
{ "X-Device-SerialNumber", "TESTSERIAL01" },
};
reqMock.SetupGet(r => r.Headers).Returns(headers);
reqMock.SetupGet(r => r.Url).Returns(new Uri("https://example.invalid/api/telemetry"));
return reqMock.Object;
}
private sealed record LogEntry(LogLevel Level, string Message);
private sealed class CapturingLogger : ILogger
{
public List<LogEntry> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
=> Entries.Add(new LogEntry(logLevel, formatter(state, exception)));
}
}
@@ -127,4 +127,47 @@ public class DeviceAssociationValidatorTests
Assert.True(result.IsValid);
}
[Fact]
public void AsCacheHit_FlagsTheCopyAndLeavesTheCachedInstanceAlone()
{
// The cached instance is shared across every request for that serial. Marking it in place
// would make the first (real) lookup look cached too, and the shadow log gate would then
// drop the one line per lookup that carries the finding.
var cached = new DeviceAssociationResult
{
IsValid = true,
SerialNumber = Serial,
AssociationState = "preassociated",
DevicePreparationPolicyId = "11111111-2222-3333-4444-555555555555",
AssignedToUserPrincipalName = "someone@example.invalid",
ManagedDeviceId = "66666666-7777-8888-9999-000000000000",
};
var hit = cached.AsCacheHit();
Assert.True(hit.ServedFromCache);
Assert.False(cached.ServedFromCache);
Assert.NotSame(cached, hit);
// Every field the shadow log reports must survive the copy.
Assert.Equal(cached.IsValid, hit.IsValid);
Assert.Equal(cached.IsTransient, hit.IsTransient);
Assert.Equal(cached.SerialNumber, hit.SerialNumber);
Assert.Equal(cached.AssociationState, hit.AssociationState);
Assert.Equal(cached.DevicePreparationPolicyId, hit.DevicePreparationPolicyId);
Assert.Equal(cached.AssignedToUserPrincipalName, hit.AssignedToUserPrincipalName);
Assert.Equal(cached.ManagedDeviceId, hit.ManagedDeviceId);
}
[Fact]
public void FreshlyParsedResult_IsNotFlaggedAsCached()
{
// A result straight from a Graph response must be loggable — that is the one line per lookup.
var body = $@"{{""value"":[{{""serialNumber"":""{Serial}"",""associationState"":""preassociated""}}]}}";
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial);
Assert.False(result.ServedFromCache);
}
}
@@ -1,4 +1,4 @@
using System.Text.RegularExpressions;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared.Models.Graph;
namespace AutopilotMonitor.Functions.Tests;
@@ -63,6 +63,18 @@ public class GraphFeatureCatalogSyncTests
scriptFeatures.Where(f => f != "All").OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
}
[Fact]
public void GrantScript_Help_DocumentsEveryFeature()
{
// The -Features help block is how an admin discovers what they can grant. A feature that
// is grantable but undocumented effectively does not exist for them.
var script = ReadGrantScript();
var help = script.Substring(0, script.IndexOf("$FeatureCatalog", StringComparison.Ordinal));
foreach (var feature in GraphFeatureCatalog.Features)
Assert.Contains(feature, help, StringComparison.Ordinal);
}
private static string ReadGrantScript()
{
var path = Path.Combine(FindRepoRoot(), "scripts", "CustomerSetup", "Grant-AutopilotMonitorAddOn.ps1");
@@ -0,0 +1,223 @@
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="IntuneDeviceBindingValidator"/> — the pure pieces: the managedDevice
/// JSON-to-DTO mapping (incl. the exact-match guard on the returned id), the enrolledDateTime
/// parsing that the enrollment-race measurement depends on, and the cache key shape.
/// The HTTP/cache/retry resilience mirrors <see cref="CloudPcDeviceValidator"/> 1:1.
/// </summary>
public class IntuneDeviceBindingValidatorTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string DeviceId = "07bc0167-9061-43b7-b243-0bb6aeda736e";
// -- BuildCacheKey --
[Fact]
public void BuildCacheKey_StableShape()
{
var key = IntuneDeviceBindingValidator.BuildCacheKey(TenantId, DeviceId);
Assert.Equal($"intune-device-binding:{TenantId}:{DeviceId}", key);
}
[Fact]
public void BuildCacheKey_DistinctFromOtherValidatorKeys()
{
// A shared prefix would let a positive hit in one validator satisfy a lookup in another.
var key = IntuneDeviceBindingValidator.BuildCacheKey(TenantId, DeviceId);
Assert.StartsWith("intune-device-binding:", key);
Assert.DoesNotContain("cloudpc", key);
Assert.DoesNotContain("autopilot", key);
}
[Fact]
public void BuildCacheKey_IsTenantScoped()
{
// The whole point of the check is that the same device id must not resolve across
// tenants — a tenant-blind cache key would defeat it.
var a = IntuneDeviceBindingValidator.BuildCacheKey(TenantId, DeviceId);
var b = IntuneDeviceBindingValidator.BuildCacheKey("22222222-2222-2222-2222-222222222222", DeviceId);
Assert.NotEqual(a, b);
}
// -- ParseManagedDeviceResponse --
[Fact]
public void Parse_MatchingDevice_ReturnsMatchWithDiagnostics()
{
var body = $$"""
{
"id": "{{DeviceId}}",
"deviceName": "TEST-DEVICE-01",
"enrolledDateTime": "2026-08-24T15:20:00Z",
"azureADDeviceId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"managementState": "managed"
}
""";
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, DeviceId);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
Assert.True(result.IsValid);
Assert.False(result.IsTransient);
Assert.Equal("TEST-DEVICE-01", result.DeviceName);
Assert.Equal("managed", result.ManagementState);
Assert.Equal(
new DateTimeOffset(2026, 8, 24, 15, 20, 0, TimeSpan.Zero),
result.EnrolledDateTime);
}
[Fact]
public void Parse_DifferentIdInBody_IsNotAMatch()
{
// Guards against a redirected or widened lookup being taken as confirmation of the id
// we actually asked about.
var body = """
{ "id": "99999999-9999-9999-9999-999999999999", "deviceName": "OTHER" }
""";
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, DeviceId);
Assert.Equal(IntuneDeviceBindingOutcome.NotFound, result.Outcome);
Assert.False(result.IsValid);
}
[Fact]
public void Parse_IdCasingDiffers_StillMatches()
{
// Graph echoes GUIDs lowercase, but casing must never decide a security outcome.
var body = $$"""
{ "id": "{{DeviceId.ToUpperInvariant()}}", "deviceName": "TEST-DEVICE-01" }
""";
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, DeviceId);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
}
[Theory]
[InlineData("")]
[InlineData("not json at all")]
[InlineData("{}")]
[InlineData("""{ "id": "" }""")]
[InlineData("""{ "deviceName": "no id field" }""")]
public void Parse_MalformedOrEmpty_IsNotFoundNotAnException(string body)
{
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, DeviceId);
Assert.Equal(IntuneDeviceBindingOutcome.NotFound, result.Outcome);
Assert.Equal(DeviceId, result.IntuneDeviceId);
}
[Fact]
public void Parse_MissingEnrolledDateTime_LeavesItNull()
{
var body = $$"""
{ "id": "{{DeviceId}}", "deviceName": "TEST-DEVICE-01" }
""";
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, DeviceId);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
Assert.Null(result.EnrolledDateTime);
}
// -- ParseEnrolledDateTime --
[Fact]
public void ParseEnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
{
// Intune returns 0001-01-01T00:00:00Z when it has no enrollment timestamp. Treating that
// as a real date would report an object age of ~2000 years and poison the race analysis.
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime("0001-01-01T00:00:00Z"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not-a-date")]
public void ParseEnrolledDateTime_UnusableInput_IsNull(string? raw)
{
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime(raw));
}
[Fact]
public void ParseEnrolledDateTime_NormalizesToUtc()
{
// A device object created minutes ago is the signature of an enrollment race, so the
// instant has to be comparable regardless of the offset Graph reports it in.
var parsed = IntuneDeviceBindingValidator.ParseEnrolledDateTime("2026-08-24T17:20:00+02:00");
Assert.NotNull(parsed);
Assert.Equal(TimeSpan.Zero, parsed!.Value.Offset);
Assert.Equal(new DateTimeOffset(2026, 8, 24, 15, 20, 0, TimeSpan.Zero), parsed.Value);
}
// -- result semantics --
[Theory]
[InlineData(IntuneDeviceBindingOutcome.NotFound)]
[InlineData(IntuneDeviceBindingOutcome.NoDeviceIdInCert)]
[InlineData(IntuneDeviceBindingOutcome.PermissionMissing)]
public void DefinitiveNegatives_AreNotTransient(IntuneDeviceBindingOutcome outcome)
{
// Only Transient may be retried / left uncached. A missing grant or a foreign device is a
// state, not an outage — retrying cannot change either.
var result = new IntuneDeviceBindingResult { Outcome = outcome };
Assert.False(result.IsTransient);
Assert.False(result.IsValid);
}
[Fact]
public void AsCacheHit_FlagsTheCopyAndLeavesTheCachedInstanceAlone()
{
// The cached instance is shared across every request for that device. Marking it in place
// would make the first (real) lookup look cached too, and the log gate would then drop the
// one line per lookup we actually want to keep.
var cached = new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.Match,
IntuneDeviceId = DeviceId,
DeviceName = "TEST-DEVICE-01",
EnrolledDateTime = new DateTimeOffset(2026, 8, 24, 16, 51, 0, TimeSpan.Zero),
};
var hit = cached.AsCacheHit();
Assert.True(hit.ServedFromCache);
Assert.False(cached.ServedFromCache);
Assert.NotSame(cached, hit);
// Everything the log line reports must survive the copy.
Assert.Equal(cached.Outcome, hit.Outcome);
Assert.Equal(cached.IntuneDeviceId, hit.IntuneDeviceId);
Assert.Equal(cached.DeviceName, hit.DeviceName);
Assert.Equal(cached.EnrolledDateTime, hit.EnrolledDateTime);
}
[Fact]
public void FreshResult_IsNotFlaggedAsCached()
{
// A freshly parsed Graph response must be loggable — that is the one line per lookup.
var body = $$"""
{ "id": "{{DeviceId}}", "deviceName": "TEST-DEVICE-01" }
""";
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, DeviceId);
Assert.False(result.ServedFromCache);
}
[Fact]
public void TransientOutcome_IsNeitherValidNorDefinitive()
{
var result = new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.Transient };
Assert.True(result.IsTransient);
Assert.False(result.IsValid);
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
@@ -652,6 +652,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "ValidateCorporateIdentifier", config.ValidateCorporateIdentifier },
{ "ValidateDeviceAssociation", config.ValidateDeviceAssociation },
{ "ValidateCloudPcDevice", config.ValidateCloudPcDevice },
{ "ValidateIntuneDeviceBinding", config.ValidateIntuneDeviceBinding },
{ "AllowInsecureAgentRequests", config.AllowInsecureAgentRequests },
{ "DataRetentionDays", config.DataRetentionDays },
{ "SessionTimeoutHours", config.SessionTimeoutHours },
@@ -757,6 +758,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
ValidateCorporateIdentifier = entity.GetBoolean("ValidateCorporateIdentifier") ?? false,
ValidateDeviceAssociation = entity.GetBoolean("ValidateDeviceAssociation") ?? false,
ValidateCloudPcDevice = entity.GetBoolean("ValidateCloudPcDevice") ?? false,
ValidateIntuneDeviceBinding = entity.GetBoolean("ValidateIntuneDeviceBinding") ?? false,
AllowInsecureAgentRequests = entity.GetBoolean("AllowInsecureAgentRequests") ?? false,
DataRetentionDays = entity.GetInt32("DataRetentionDays") ?? 90,
SessionTimeoutHours = entity.GetInt32("SessionTimeoutHours") ?? 5,
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Net;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
@@ -26,6 +26,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly KillSwitchEvaluator _killSwitchEvaluator;
@@ -40,6 +41,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
BootstrapSessionService bootstrapSessionService,
KillSwitchEvaluator killSwitchEvaluator)
{
@@ -53,6 +55,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_bootstrapSessionService = bootstrapSessionService;
_killSwitchEvaluator = killSwitchEvaluator;
}
@@ -88,7 +91,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
_logger,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator
cloudPcDeviceValidator: _cloudPcDeviceValidator,
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
);
if (errorResponse != null)
@@ -1,4 +1,4 @@
using System.Net;
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
@@ -53,6 +53,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly HostedDiagnosticsBlobService _hostedDiagnostics;
@@ -65,6 +66,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
BootstrapSessionService bootstrapSessionService,
HostedDiagnosticsBlobService hostedDiagnostics)
{
@@ -76,6 +78,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_bootstrapSessionService = bootstrapSessionService;
_hostedDiagnostics = hostedDiagnostics;
}
@@ -127,7 +130,8 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
_logger,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator
cloudPcDeviceValidator: _cloudPcDeviceValidator,
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
);
if (errorResponse != null)
@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
@@ -51,6 +51,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly KillSwitchEvaluator _killSwitchEvaluator;
private readonly SessionDeletionGuard _deletionGuard;
@@ -69,6 +70,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
BootstrapSessionService bootstrapSessionService,
KillSwitchEvaluator killSwitchEvaluator,
SessionDeletionGuard deletionGuard)
@@ -86,6 +88,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_bootstrapSessionService = bootstrapSessionService;
_killSwitchEvaluator = killSwitchEvaluator;
_deletionGuard = deletionGuard;
@@ -116,7 +119,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_logger,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator);
cloudPcDeviceValidator: _cloudPcDeviceValidator,
intuneDeviceBindingValidator: _intuneDeviceBindingValidator);
if (errorResponse != null) return AsOutput(errorResponse);
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
@@ -35,6 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly TelemetryClient _telemetryClient;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly ISessionRepository _sessionRepo;
@@ -49,6 +50,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
TelemetryClient telemetryClient,
BootstrapSessionService bootstrapSessionService,
ISessionRepository sessionRepo,
@@ -62,6 +64,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_telemetryClient = telemetryClient;
_bootstrapSessionService = bootstrapSessionService;
_sessionRepo = sessionRepo;
@@ -98,7 +101,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_logger,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator
cloudPcDeviceValidator: _cloudPcDeviceValidator,
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
);
if (errorResponse != null)
@@ -1,4 +1,4 @@
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared;
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
@@ -32,6 +32,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly WebhookNotificationService _webhookNotificationService;
private readonly SessionDeletionGuard _deletionGuard;
@@ -48,6 +49,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
BootstrapSessionService bootstrapSessionService,
WebhookNotificationService webhookNotificationService,
SessionDeletionGuard deletionGuard)
@@ -62,6 +64,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_bootstrapSessionService = bootstrapSessionService;
_webhookNotificationService = webhookNotificationService;
_deletionGuard = deletionGuard;
@@ -106,7 +109,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
registration.SessionId,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator
cloudPcDeviceValidator: _cloudPcDeviceValidator,
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
);
if (errorResponse2 != null)
@@ -5,6 +5,7 @@ using Microsoft.ApplicationInsights.DataContracts;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Middleware;
@@ -114,6 +115,14 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
if (context.Items.TryGetValue("CorrelationId", out var corrId) && corrId is string correlationId)
requestTelemetry.Properties["CorrelationId"] = correlationId;
// CERT-TENANT-BINDING-SHADOW — set by SecurityValidator.ObserveCertTenantBinding.
// Carried on the request row rather than a trace line: worker-side LogInformation never
// reaches App Insights (provider default rule is Warning+), so the bulk "Match" outcome
// would be invisible and the shadow telemetry would have no denominator. This row already
// exists per request and is unsampled, so the outcome costs no additional telemetry.
if (context.Items.TryGetValue(CertTenantBinding.RequestItemKey, out var binding) && binding is string bindingOutcome)
requestTelemetry.Properties["CertTenantBinding"] = bindingOutcome;
var reqCtx = context.GetRequestContext();
var tenantId = reqCtx.TenantId;
if (string.IsNullOrEmpty(tenantId))
@@ -1,4 +1,4 @@
using System.IO.Compression;
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
using AutopilotMonitor.Functions.DataAccess;
@@ -424,6 +424,7 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.AutopilotDevic
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CorporateIdentifierValidator>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.DeviceAssociationValidator>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CloudPcDeviceValidator>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.IntuneDeviceBindingValidator>();
// Graph add-on permission detection + script display-name resolution.
// Detector parses the `roles` claim of the SP access token; Resolver fetches
@@ -0,0 +1,93 @@
using System;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Compares the Entra TenantId stamped into the agent's mTLS client certificate against the
/// tenant the request claims to belong to.
/// <para>
/// Without this, a validated certificate only proves "issued by the Intune MDM Device CA to
/// some tenant" — the chain is pinned to Microsoft's roots, which every Intune tenant on the
/// planet shares. The tenant scoping comes solely from the downstream device validators, so an
/// attacker with their own Intune tenant and a known serial number of the victim tenant would
/// pass certificate validation. Binding the cert's tenant to the requested tenant closes that.
/// </para>
/// </summary>
/// <remarks>
/// SHADOW MODE (stage 1): the outcome is observed and logged only — it never changes an
/// authorization decision. Enforcement is stage 2, once telemetry shows how many field
/// certificates actually carry the extension. Grep marker for the stage-2 change:
/// <c>CERT-TENANT-BINDING-SHADOW</c>.
/// </remarks>
public static class CertTenantBinding
{
/// <summary>
/// <see cref="Microsoft.Azure.Functions.Worker.FunctionContext"/> item key under which
/// <c>SecurityValidator</c> hands the outcome to <c>RequestTelemetryMiddleware</c>, which
/// stamps it onto the request row as the <c>CertTenantBinding</c> dimension.
/// </summary>
/// <remarks>
/// The request row is the carrier because worker-side <c>LogInformation</c> never reaches
/// App Insights (the provider's default rule is Warning+), so the bulk "Match" outcome
/// cannot be observed as a trace and the shadow telemetry would have no denominator.
/// </remarks>
public const string RequestItemKey = "CertTenantBinding";
/// <summary>
/// Stable outcome codes emitted in the <c>AgentCertTenantBinding</c> structured log.
/// Keep these strings stable — they are queried by exact match in KQL
/// (<c>customDimensions.Outcome == "Mismatch"</c>).
/// </summary>
public static class Outcome
{
/// <summary>Certificate tenant equals the requested tenant — the expected case.</summary>
public const string Match = "Match";
/// <summary>Certificate belongs to a different tenant than the request claims.</summary>
public const string Mismatch = "Mismatch";
/// <summary>Certificate carries no tenant extension (older enrollment / unexpected CA).</summary>
public const string ExtensionMissing = "ExtensionMissing";
/// <summary>Tenant extension present but undecodable.</summary>
public const string Unparseable = "Unparseable";
/// <summary>Requested tenant id was not a GUID, so no meaningful comparison is possible.</summary>
public const string RequestTenantNotAGuid = "RequestTenantNotAGuid";
}
/// <summary>
/// Evaluates the binding. Pure — no logging, no I/O, no side effects.
/// </summary>
/// <param name="certTenantId">Tenant GUID decoded from the client certificate, if any.</param>
/// <param name="status">Whether the certificate carried a decodable tenant extension.</param>
/// <param name="requestedTenantId">The tenant id the request is scoped to.</param>
/// <returns>One of the <see cref="Outcome"/> codes.</returns>
public static string Evaluate(Guid? certTenantId, CertTenantIdStatus status, string? requestedTenantId)
{
switch (status)
{
case CertTenantIdStatus.ExtensionMissing:
return Outcome.ExtensionMissing;
case CertTenantIdStatus.Unparseable:
return Outcome.Unparseable;
}
if (certTenantId == null)
return Outcome.ExtensionMissing;
if (!Guid.TryParse(requestedTenantId, out var requested))
return Outcome.RequestTenantNotAGuid;
return certTenantId.Value == requested ? Outcome.Match : Outcome.Mismatch;
}
/// <summary>
/// Whether an outcome would block the request once stage-2 enforcement is switched on.
/// Nothing consumes this for authorization yet — it exists so the shadow telemetry can be
/// read as "would this have been rejected?" without re-deriving the rule at query time.
/// </summary>
public static bool WouldRejectUnderEnforcement(string outcome) =>
outcome == Outcome.Mismatch;
}
}
@@ -6,6 +6,7 @@ using System.Linq;
using System.Reflection;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using AutopilotMonitor.Shared.Security;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Security
@@ -253,6 +254,12 @@ namespace AutopilotMonitor.Functions.Security
};
}
// 4. Extract the Entra TenantId the CA stamped into the cert (OID 1.2.840.113556.5.14).
// Pure function of the cert, so it is cached alongside the validation result.
// Read only - the tenant binding it enables is evaluated (and currently only observed)
// by SecurityValidator; see CertTenantBinding.
var (certTenantId, certTenantIdStatus) = ExtractEntraTenantId(certificate);
// All checks passed - create and cache success result
logger?.LogInformation($"Certificate validated successfully: Thumbprint={thumbprint}");
var successResult = new CertificateValidationResult
@@ -260,7 +267,9 @@ namespace AutopilotMonitor.Functions.Security
IsValid = true,
Thumbprint = thumbprint,
Subject = certificate.Subject,
Issuer = certificate.Issuer
Issuer = certificate.Issuer,
CertTenantId = certTenantId,
CertTenantIdStatus = certTenantIdStatus
};
// Cache successful validation
@@ -288,6 +297,30 @@ namespace AutopilotMonitor.Functions.Security
}
}
/// <summary>
/// Reads the Entra TenantId the Intune MDM Device CA stamps into the client certificate
/// (OID <c>1.2.840.113556.5.14</c>). Never throws — a cert without the extension, or with an
/// undecodable one, yields the corresponding status instead of an error, because this value
/// is currently observed only and must never turn a valid cert into a rejected one.
/// </summary>
/// <remarks>
/// Deliberately NOT OID <c>1.2.840.113556.5.6</c>: that one carries the Intune <b>Account</b>
/// ID, which is a different GUID from the Entra tenant ID.
/// </remarks>
private static (Guid? TenantId, CertTenantIdStatus Status) ExtractEntraTenantId(X509Certificate2 certificate)
{
var ext = certificate.Extensions
.OfType<X509Extension>()
.FirstOrDefault(e => e.Oid?.Value == MsDeviceCertificateOids.IntuneCertEntraTenantIdOid);
if (ext?.RawData == null)
return (null, CertTenantIdStatus.ExtensionMissing);
return MsDeviceCertificateOids.TryParseGuid(ext.RawData, out var tenantId)
? (tenantId, CertTenantIdStatus.Present)
: (null, CertTenantIdStatus.Unparseable);
}
/// <summary>
/// Loads all certs whose embedded resource filename starts with the given prefix.
/// Multiple PEM blocks per file are supported (cross-signed bundles). Duplicates
@@ -378,5 +411,36 @@ namespace AutopilotMonitor.Functions.Security
/// Error message if validation failed
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Entra TenantId stamped into the certificate by the Intune MDM Device CA
/// (OID <c>1.2.840.113556.5.14</c>), or <c>null</c> when
/// <see cref="CertTenantIdStatus"/> is not <see cref="Security.CertTenantIdStatus.Present"/>.
/// </summary>
public Guid? CertTenantId { get; set; }
/// <summary>
/// Whether the certificate carried a decodable Entra TenantId extension.
/// </summary>
public CertTenantIdStatus CertTenantIdStatus { get; set; }
}
/// <summary>
/// Whether a validated client certificate carried a decodable Entra TenantId extension.
/// </summary>
public enum CertTenantIdStatus
{
/// <summary>Certificate was not validated far enough to look, or validation failed.</summary>
NotEvaluated = 0,
/// <summary>Extension present and decoded into a GUID.</summary>
Present = 1,
/// <summary>Certificate carries no such extension (expected on older enrollments).</summary>
ExtensionMissing = 2,
/// <summary>Extension present but its value could not be decoded as a GUID.</summary>
Unparseable = 3
}
}
@@ -1,4 +1,4 @@
using System;
using System;
namespace AutopilotMonitor.Functions.Security
{
@@ -55,5 +55,32 @@ namespace AutopilotMonitor.Functions.Security
public string? ManagedDeviceId { get; set; }
public string? ErrorMessage { get; set; }
/// <summary>
/// True when this result came from the in-memory cache rather than a fresh Graph lookup.
/// The shadow log uses it to emit one line per real lookup instead of one per request:
/// a single enrollment produced 263 identical lines off a handful of Graph calls.
/// </summary>
public bool ServedFromCache { get; set; }
/// <summary>
/// Copy of this result marked as a cache hit, so the shared cached instance is never
/// mutated by a caller.
/// </summary>
internal DeviceAssociationResult AsCacheHit() => new()
{
IsValid = IsValid,
IsTransient = IsTransient,
SerialNumber = SerialNumber,
AssociationState = AssociationState,
DevicePreparationPolicyId = DevicePreparationPolicyId,
PreAssociatedByUserPrincipalName = PreAssociatedByUserPrincipalName,
AssignedToUserPrincipalName = AssignedToUserPrincipalName,
PreAssociationDateTime = PreAssociationDateTime,
AssociationDateTime = AssociationDateTime,
ManagedDeviceId = ManagedDeviceId,
ErrorMessage = ErrorMessage,
ServedFromCache = true
};
}
}
@@ -1,4 +1,4 @@
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared;
using System;
using System.Linq;
using System.Net.Http;
@@ -63,7 +63,7 @@ namespace AutopilotMonitor.Functions.Security
if (_cache.TryGetValue(cacheKey, out DeviceAssociationResult? cached) && cached != null)
{
return cached;
return cached.AsCacheHit();
}
const int maxAttempts = 2;
@@ -0,0 +1,352 @@
using AutopilotMonitor.Shared;
using System;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Binds the agent's client certificate to a device object that actually exists in the tenant
/// the request claims: the certificate's Subject CN carries the Intune managedDevice id, and
/// this validator looks exactly that id up in the tenant's own
/// <c>deviceManagement/managedDevices</c> inventory.
/// <para>
/// <see cref="CertTenantBinding"/> already proves the certificate was issued to this tenant.
/// This adds the second half: that the specific device is still one of the tenant's enrolled
/// devices, so a certificate lifted from a decommissioned or wiped machine no longer resolves.
/// </para>
/// </summary>
/// <remarks>
/// Built as a near-sibling of <see cref="CloudPcDeviceValidator"/> — same DI dependencies and
/// the same cache/retry/transient contract (30 min positive, 5 min negative, 2 attempts with a
/// 2s back-off, transient on Graph 5xx / token failure / exception). Two deliberate differences:
/// <list type="bullet">
/// <item><description>A point-GET on <c>managedDevices/{id}</c> rather than an OData
/// <c>$filter</c>. There is no filter literal to inject into, and a 404 is an unambiguous
/// "not this tenant's device".</description></item>
/// <item><description><see cref="IntuneDeviceBindingResult.EnrolledDateTime"/> is carried out
/// so callers can record how old the device object was at request time. That is the open
/// question this check must answer before it could ever gate enrollment: a device whose object
/// appears only after the agent's first call is a race, not an attack.</description></item>
/// </list>
/// SHADOW ONLY (stage 3): nothing consumes the result for an authorization decision. Grep
/// marker for the enforcement change: <c>CERT-DEVICE-BINDING-SHADOW</c>.
/// </remarks>
public class IntuneDeviceBindingValidator
{
private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30);
private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5);
private readonly ILogger<IntuneDeviceBindingValidator> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly GraphTokenService _graphTokenService;
public IntuneDeviceBindingValidator(
ILogger<IntuneDeviceBindingValidator> logger,
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
GraphTokenService graphTokenService)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_cache = cache;
_graphTokenService = graphTokenService;
}
/// <summary>
/// Resolves the Intune device id (from the client certificate's Subject CN) against the
/// tenant's managedDevices inventory. Never throws.
/// </summary>
public async Task<IntuneDeviceBindingResult> ValidateAsync(
string tenantId,
string? intuneDeviceId,
string? sessionId = null)
{
// The id becomes a URL path segment. The strict GUID gate keeps it from being anything
// else - the same rule the rest of the security path applies.
if (!SecurityValidator.IsValidGuid(intuneDeviceId))
{
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.NoDeviceIdInCert,
ErrorMessage = "Certificate subject does not carry a valid Intune device id"
};
}
var normalizedId = intuneDeviceId!.Trim().ToLowerInvariant();
var cacheKey = BuildCacheKey(tenantId, normalizedId);
if (_cache.TryGetValue(cacheKey, out IntuneDeviceBindingResult? cached) && cached != null)
return cached.AsCacheHit();
const int maxAttempts = 2;
IntuneDeviceBindingResult? lastTransient = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
var result = await TryValidateViaGraphAsync(tenantId, normalizedId, sessionId, cacheKey, attempt);
if (!result.IsTransient)
return result;
lastTransient = result;
if (attempt < maxAttempts)
{
_logger.LogWarning(
"Intune device binding transient failure for tenant {TenantId}, deviceId {IntuneDeviceId} (attempt {Attempt}/{MaxAttempts}). Retrying...",
tenantId, normalizedId, attempt, maxAttempts);
await Task.Delay(TimeSpan.FromSeconds(2));
}
}
return lastTransient!;
}
private async Task<IntuneDeviceBindingResult> TryValidateViaGraphAsync(
string tenantId, string normalizedId, string? sessionId, string cacheKey, int attempt)
{
try
{
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId);
if (string.IsNullOrEmpty(tokenResult.AccessToken))
{
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.Transient,
IntuneDeviceId = normalizedId,
ErrorMessage = "Graph access token could not be acquired"
};
}
var graphClient = _httpClientFactory.CreateClient();
graphClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.AccessToken);
// Point-GET: normalizedId passed the GUID gate, so the path segment is safe.
var graphUrl = $"{Constants.GraphBaseUrl}/v1.0/deviceManagement/managedDevices/{normalizedId}"
+ "?$select=id,deviceName,enrolledDateTime,azureADDeviceId,managementState";
var response = await graphClient.GetAsync(graphUrl);
var responseBody = await response.Content.ReadAsStringAsync();
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Definitive: this tenant has no managedDevice with that id. Either the device
// belongs elsewhere, or its object has not appeared yet - the race this shadow
// pass exists to measure.
return CacheAndReturn(cacheKey, new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.NotFound,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Device '{normalizedId}' is not an enrolled Intune device in this tenant"
}, isPositive: false);
}
if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
// Configuration state, not an outage: retrying cannot fix a missing grant.
// Cached briefly so a fresh grant is picked up quickly.
return CacheAndReturn(cacheKey, new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.PermissionMissing,
IntuneDeviceId = normalizedId,
ErrorMessage = "Managed device lookup not permitted - grant the optional 'IntuneDeviceBinding' Graph feature (DeviceManagementManagedDevices.Read.All)"
}, isPositive: false);
}
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Intune device binding Graph query failed for tenant {TenantId} (attempt {Attempt}). Status: {StatusCode}. Body: {ResponseBody}",
tenantId, attempt, (int)response.StatusCode, responseBody);
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.Transient,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Graph query failed with status {(int)response.StatusCode}"
};
}
var result = ParseManagedDeviceResponse(responseBody, normalizedId);
return CacheAndReturn(cacheKey, result, isPositive: result.IsValid);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error during Intune device binding for tenant {TenantId}, session {SessionId}, deviceId {IntuneDeviceId} (attempt {Attempt})",
tenantId, sessionId ?? "<none>", normalizedId, attempt);
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.Transient,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Error during Intune device binding: {ex.Message}"
};
}
}
/// <summary>
/// Pure-function: maps a <c>managedDevices/{id}</c> Graph response body to a result.
/// Re-checks the returned <c>id</c> so a redirected or widened lookup can never produce a
/// false positive.
/// </summary>
internal static IntuneDeviceBindingResult ParseManagedDeviceResponse(string responseBody, string normalizedId)
{
var notFound = new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.NotFound,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Device '{normalizedId}' is not an enrolled Intune device in this tenant"
};
JObject? device;
try
{
device = JsonConvert.DeserializeObject<JObject>(responseBody);
}
catch (JsonException)
{
return notFound;
}
var returnedId = device?["id"]?.ToString()?.Trim();
if (string.IsNullOrEmpty(returnedId) ||
!string.Equals(returnedId, normalizedId, StringComparison.OrdinalIgnoreCase))
{
return notFound;
}
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.Match,
IntuneDeviceId = normalizedId,
DeviceName = device?["deviceName"]?.ToString(),
AzureAdDeviceId = device?["azureADDeviceId"]?.ToString(),
ManagementState = device?["managementState"]?.ToString(),
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString())
};
}
/// <summary>
/// Parses Graph's <c>enrolledDateTime</c>. Intune returns <c>0001-01-01T00:00:00Z</c> for
/// devices it has no enrollment timestamp for; that is absence, not a date, so it maps to
/// <c>null</c> rather than a bogus age of two thousand years.
/// </summary>
internal static DateTimeOffset? ParseEnrolledDateTime(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (!DateTimeOffset.TryParse(
raw,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal
| System.Globalization.DateTimeStyles.AssumeUniversal,
out var parsed))
{
return null;
}
return parsed.Year <= 1 ? null : parsed;
}
internal static string BuildCacheKey(string tenantId, string intuneDeviceId)
=> $"intune-device-binding:{tenantId}:{intuneDeviceId}";
private IntuneDeviceBindingResult CacheAndReturn(
string cacheKey, IntuneDeviceBindingResult result, bool isPositive)
{
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = isPositive ? PositiveCacheTtl : NegativeCacheTtl
});
return result;
}
}
/// <summary>
/// Stable outcome codes for the device-binding check. Emitted verbatim in the
/// <c>AgentCertDeviceBinding</c> log and as the <c>CertDeviceBinding</c> request dimension —
/// operators match on them in KQL, so keep the names stable.
/// </summary>
public enum IntuneDeviceBindingOutcome
{
/// <summary>Device id from the certificate resolves to a managedDevice in this tenant.</summary>
Match = 0,
/// <summary>No managedDevice with that id in this tenant (foreign device, or not yet created).</summary>
NotFound = 1,
/// <summary>Certificate subject carried no usable Intune device id.</summary>
NoDeviceIdInCert = 2,
/// <summary>DeviceManagementManagedDevices.Read.All not granted in this tenant.</summary>
PermissionMissing = 3,
/// <summary>Graph outage, token failure or exception — says nothing about the device.</summary>
Transient = 4
}
public class IntuneDeviceBindingResult
{
public IntuneDeviceBindingOutcome Outcome { get; set; }
/// <summary>True only for <see cref="IntuneDeviceBindingOutcome.Match"/>.</summary>
public bool IsValid => Outcome == IntuneDeviceBindingOutcome.Match;
/// <summary>
/// True when the failure says nothing about the device (Graph error, token issue).
/// Transient results are never cached.
/// </summary>
public bool IsTransient => Outcome == IntuneDeviceBindingOutcome.Transient;
/// <summary>Intune device id taken from the client certificate's Subject CN.</summary>
public string? IntuneDeviceId { get; set; }
/// <summary>Intune device name — diagnostic only, never a gate.</summary>
public string? DeviceName { get; set; }
/// <summary>Entra device id of the same device, if Intune knows one — diagnostic only.</summary>
public string? AzureAdDeviceId { get; set; }
/// <summary>Intune management state — diagnostic only.</summary>
public string? ManagementState { get; set; }
/// <summary>
/// When Intune recorded the enrollment. The age of this value at request time is what
/// tells a genuine race (object created seconds ago) apart from a foreign certificate.
/// </summary>
public DateTimeOffset? EnrolledDateTime { get; set; }
public string? ErrorMessage { get; set; }
/// <summary>
/// True when this result came from the in-memory cache rather than a fresh Graph lookup.
/// Callers use it to log once per real lookup instead of once per request: a single
/// enrollment produced 136 requests but only one Graph call, so logging per request said
/// the same thing 136 times.
/// </summary>
public bool ServedFromCache { get; set; }
/// <summary>
/// Copy of this result marked as a cache hit, so the shared cached instance is never
/// mutated by a caller.
/// </summary>
internal IntuneDeviceBindingResult AsCacheHit() => new()
{
Outcome = Outcome,
IntuneDeviceId = IntuneDeviceId,
DeviceName = DeviceName,
AzureAdDeviceId = AzureAdDeviceId,
ManagementState = ManagementState,
EnrolledDateTime = EnrolledDateTime,
ErrorMessage = ErrorMessage,
ServedFromCache = true
};
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
@@ -34,9 +34,10 @@ namespace AutopilotMonitor.Functions.Security
string? sessionId = null,
BootstrapSessionService? bootstrapSessionService = null,
DeviceAssociationValidator? deviceAssociationValidator = null,
CloudPcDeviceValidator? cloudPcDeviceValidator = null)
CloudPcDeviceValidator? cloudPcDeviceValidator = null,
IntuneDeviceBindingValidator? intuneDeviceBindingValidator = null)
{
var validator = new SecurityValidator(configService, adminConfigService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator, cloudPcDeviceValidator);
var validator = new SecurityValidator(configService, adminConfigService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator, cloudPcDeviceValidator, intuneDeviceBindingValidator);
var validation = await validator.ValidateRequestAsync(req, tenantId, sessionId);
if (!validation.IsValid)
@@ -1,4 +1,4 @@
using System;
using System;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
@@ -48,6 +48,7 @@ namespace AutopilotMonitor.Functions.Security
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator? _deviceAssociationValidator;
private readonly CloudPcDeviceValidator? _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator? _intuneDeviceBindingValidator;
private readonly BootstrapSessionService? _bootstrapSessionService;
private readonly ILogger _logger;
@@ -60,7 +61,8 @@ namespace AutopilotMonitor.Functions.Security
ILogger logger,
BootstrapSessionService? bootstrapSessionService = null,
DeviceAssociationValidator? deviceAssociationValidator = null,
CloudPcDeviceValidator? cloudPcDeviceValidator = null)
CloudPcDeviceValidator? cloudPcDeviceValidator = null,
IntuneDeviceBindingValidator? intuneDeviceBindingValidator = null)
{
_configService = configService;
_adminConfigService = adminConfigService;
@@ -69,6 +71,7 @@ namespace AutopilotMonitor.Functions.Security
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_bootstrapSessionService = bootstrapSessionService;
_logger = logger;
}
@@ -275,6 +278,12 @@ namespace AutopilotMonitor.Functions.Security
};
}
// 1b. CERT-TENANT-BINDING-SHADOW — observe only, never decide.
// The cert chain is pinned to Microsoft's Intune roots, which every Intune tenant shares,
// so a valid cert alone does not prove the caller belongs to THIS tenant. Stage 1 measures
// how reliably the certificate's own tenant stamp matches before stage 2 enforces it.
ObserveCertTenantBinding(certValidation, tenantId, req, sessionId);
// 2. Check rate limit (DoS protection)
// Effective limit = per-tenant override if set, otherwise the global default.
var rateLimitValue = await ResolveDeviceRateLimitAsync(config);
@@ -444,6 +453,14 @@ namespace AutopilotMonitor.Functions.Security
try
{
var devPrepResult = await validator.LookupAsync(capturedTenant, capturedSerial, capturedSession);
// One line per real Graph lookup, not per request — the same gate the
// cert-device-binding shadow uses. Measured 2026-08-24: a single enrollment
// produced 263 identical lines because the cache answered nearly every
// request. Uncached outcomes (transient failures) still log every time.
if (devPrepResult.ServedFromCache)
return;
logger.LogWarning(
"DevPrep association lookup (shadow) for tenant {TenantId}, session {SessionId}, serial {SerialNumber}: matched={Matched}, transient={Transient}, state={State}, policy={PolicyId}",
capturedTenant, capturedSession ?? "<none>", capturedSerial,
@@ -457,6 +474,69 @@ namespace AutopilotMonitor.Functions.Security
});
}
// 6. CERT-DEVICE-BINDING-SHADOW - cert-to-device binding (Global-Admin-only preview).
// Stage 1 (CertTenantBinding) proves the certificate was issued to THIS tenant; this
// proves the specific device is one the tenant actually enrolled, so a certificate
// lifted from a decommissioned machine stops resolving. Observation only: it never
// touches deviceValidated or the returned result.
//
// Fire-and-forget for the same reason as the DevPrep lookup above - a cold Graph
// round-trip can take seconds and must never sit in the agent's request path. That
// rules out the request-row dimension stage 1 uses (the row is written the moment the
// function returns, before this task finishes), so the outcome is logged at Warning
// like the DevPrep shadow. Volume is bounded: only Global Admins can enable the
// toggle. Widening it beyond preview should move to an inline call plus the
// CertTenantBinding-style request dimension.
if (config.ValidateIntuneDeviceBinding && _intuneDeviceBindingValidator != null)
{
var bindingValidator = _intuneDeviceBindingValidator;
var bindingTenant = tenantId;
var bindingSession = sessionId;
var bindingSubject = certValidation.Subject;
var bindingThumbprint = certValidation.Thumbprint;
var bindingLogger = _logger;
_ = Task.Run(async () =>
{
try
{
TryGetIntuneDeviceIdFromCertSubject(bindingSubject, out var certDeviceId);
var binding = await bindingValidator.ValidateAsync(bindingTenant, certDeviceId, bindingSession);
// One line per real Graph lookup, not per request. Measured on the first
// live enrollment: 136 requests produced exactly ONE Graph call (the 30 min
// positive cache did its job), so per-request logging repeated the same
// finding 136 times. Cached repeats add nothing - the age just counts up
// mechanically from the same enrolledDateTime. Uncached outcomes
// (Transient, NoDeviceIdInCert) still log every time, which is what we want
// for anomalies.
if (binding.ServedFromCache)
return;
// Age of the device object at request time: the discriminator between a
// genuine enrollment race (object created moments ago, or not yet) and a
// certificate that never belonged to this tenant.
var enrolledAgeSeconds = binding.EnrolledDateTime.HasValue
? (long)Math.Round((DateTimeOffset.UtcNow - binding.EnrolledDateTime.Value).TotalSeconds)
: -1;
bindingLogger.LogWarning(
"AgentCertDeviceBinding outcome={Outcome} enforced={Enforced} tenant={TenantId} "
+ "certDeviceId={CertDeviceId} device={DeviceName} enrolledAgeSeconds={EnrolledAgeSeconds} "
+ "mgmtState={ManagementState} thumbprint={Thumbprint} session={SessionId} detail={Detail}",
binding.Outcome, false, bindingTenant,
certDeviceId ?? "n/a", binding.DeviceName ?? "n/a", enrolledAgeSeconds,
binding.ManagementState ?? "n/a", bindingThumbprint ?? "n/a",
bindingSession ?? "n/a", binding.ErrorMessage ?? "n/a");
}
catch (Exception ex)
{
bindingLogger.LogWarning(ex,
"AgentCertDeviceBinding (shadow) failed for tenant {TenantId} - ignored.", bindingTenant);
}
});
}
// All checks passed
return new SecurityValidationResult
{
@@ -514,6 +594,73 @@ namespace AutopilotMonitor.Functions.Security
return false;
}
/// <summary>
/// CERT-TENANT-BINDING-SHADOW — stage 1 observability for the client-certificate tenant
/// binding. Evaluates whether the tenant stamped into the agent's certificate matches the
/// tenant the request claims, and logs the outcome. It deliberately returns nothing and
/// changes no state: enforcement is stage 2, after this telemetry shows the miss rate.
/// </summary>
/// <remarks>
/// Two carriers, because they answer different questions and have different costs:
/// <list type="bullet">
/// <item><description>Every outcome is stamped onto the request row via
/// <see cref="CertTenantBinding.RequestItemKey"/>. That row already exists per request and is
/// unsampled, so the denominator ("how many requests matched") costs no extra telemetry.
/// A trace line could not do this: worker-side <c>LogInformation</c> never reaches App
/// Insights, so the bulk "Match" case would simply be invisible.</description></item>
/// <item><description>Outcomes that need an enforcement decision (mismatch, missing or
/// undecodable extension) are additionally logged at Warning with the full context —
/// thumbprint, cert tenant, session — because each one is individually actionable.</description></item>
/// </list>
/// </remarks>
private void ObserveCertTenantBinding(
CertificateValidationResult certValidation,
string tenantId,
HttpRequestData req,
string? sessionId)
{
try
{
var outcome = CertTenantBinding.Evaluate(
certValidation.CertTenantId,
certValidation.CertTenantIdStatus,
tenantId);
// Denominator: rides along on the request row for every outcome, including Match.
var items = req.FunctionContext?.Items;
if (items != null)
items[CertTenantBinding.RequestItemKey] = outcome;
// Everything below is the detail record for outcomes someone has to act on.
if (outcome == CertTenantBinding.Outcome.Match)
return;
var agentVersion = req.Headers.Contains("X-Agent-Version")
? req.Headers.GetValues("X-Agent-Version").FirstOrDefault() ?? "n/a"
: "n/a";
// Only the cert's tenant is logged, never the raw certificate. On a mismatch this is
// the attacker-controlled side of the comparison and the single most useful field.
const string template =
"AgentCertTenantBinding outcome={Outcome} enforced={Enforced} wouldReject={WouldReject} "
+ "tenant={TenantId} certTenant={CertTenantId} status={Status} thumbprint={Thumbprint} "
+ "session={SessionId} ver={AgentVersion}";
var certTenant = certValidation.CertTenantId?.ToString() ?? "n/a";
var wouldReject = CertTenantBinding.WouldRejectUnderEnforcement(outcome);
_logger.LogWarning(template,
outcome, false, wouldReject, tenantId, certTenant, certValidation.CertTenantIdStatus,
certValidation.Thumbprint ?? "n/a", sessionId ?? "n/a", agentVersion);
}
catch (Exception ex)
{
// Shadow-mode observability must never be able to fail a request that would
// otherwise have been accepted.
_logger.LogWarning(ex, "AgentCertTenantBinding observation failed for tenant {TenantId}", tenantId);
}
}
/// <summary>
/// Emits a structured warning with request-side context (path, hardware headers, agent
/// version) at every fail-fast rejection point. Pairs with the cert-side warnings emitted
+173 -209
View File
@@ -26,46 +26,12 @@
"@types/node": "^22.20.1",
"tsx": "^4.23.12",
"typescript": "^5.0.0",
"vitest": "^4.1.10"
"vitest": "^4.1.11"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@@ -1110,29 +1076,10 @@
}
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"version": "0.146.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz",
"integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1196,10 +1143,27 @@
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz",
"integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz",
"integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==",
"cpu": [
"arm64"
],
@@ -1214,9 +1178,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz",
"integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==",
"cpu": [
"arm64"
],
@@ -1231,9 +1195,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz",
"integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==",
"cpu": [
"x64"
],
@@ -1248,9 +1212,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz",
"integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==",
"cpu": [
"x64"
],
@@ -1265,9 +1229,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz",
"integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==",
"cpu": [
"arm"
],
@@ -1282,13 +1246,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz",
"integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1299,13 +1266,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz",
"integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1316,13 +1286,16 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz",
"integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1333,13 +1306,16 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz",
"integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1350,13 +1326,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz",
"integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1367,13 +1346,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz",
"integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1384,9 +1366,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz",
"integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==",
"cpu": [
"arm64"
],
@@ -1400,29 +1382,10 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz",
"integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==",
"cpu": [
"arm64"
],
@@ -1437,9 +1400,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz",
"integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==",
"cpu": [
"x64"
],
@@ -1467,17 +1430,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -1612,16 +1564,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -1630,13 +1582,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -1657,9 +1609,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1670,13 +1622,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@@ -1684,14 +1636,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -1700,9 +1652,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1710,13 +1662,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -2828,6 +2780,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2849,6 +2804,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2870,6 +2828,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2891,6 +2852,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3057,9 +3021,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
@@ -3269,9 +3233,9 @@
"license": "MIT"
},
"node_modules/postcss": {
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
@@ -3289,7 +3253,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.16",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -3399,13 +3363,13 @@
}
},
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz",
"integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.139.0",
"@oxc-project/types": "=0.146.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -3415,21 +3379,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.1.5",
"@rolldown/binding-darwin-arm64": "1.1.5",
"@rolldown/binding-darwin-x64": "1.1.5",
"@rolldown/binding-freebsd-x64": "1.1.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
"@rolldown/binding-linux-arm64-musl": "1.1.5",
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
"@rolldown/binding-linux-x64-gnu": "1.1.5",
"@rolldown/binding-linux-x64-musl": "1.1.5",
"@rolldown/binding-openharmony-arm64": "1.1.5",
"@rolldown/binding-wasm32-wasi": "1.1.5",
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
"@rolldown/binding-win32-x64-msvc": "1.1.5"
"@rolldown/binding-android-arm-eabi": "1.2.5",
"@rolldown/binding-android-arm64": "1.2.5",
"@rolldown/binding-darwin-arm64": "1.2.5",
"@rolldown/binding-darwin-x64": "1.2.5",
"@rolldown/binding-freebsd-x64": "1.2.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.5",
"@rolldown/binding-linux-arm64-gnu": "1.2.5",
"@rolldown/binding-linux-arm64-musl": "1.2.5",
"@rolldown/binding-linux-ppc64-gnu": "1.2.5",
"@rolldown/binding-linux-s390x-gnu": "1.2.5",
"@rolldown/binding-linux-x64-gnu": "1.2.5",
"@rolldown/binding-linux-x64-musl": "1.2.5",
"@rolldown/binding-openharmony-arm64": "1.2.5",
"@rolldown/binding-win32-arm64-msvc": "1.2.5",
"@rolldown/binding-win32-x64-msvc": "1.2.5"
}
},
"node_modules/router": {
@@ -3797,9 +3761,9 @@
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3923,16 +3887,16 @@
}
},
"node_modules/vite": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.17",
"rolldown": "~1.1.5",
"postcss": "^8.5.26",
"rolldown": "~1.2.4",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -3949,7 +3913,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.3.0",
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -4001,19 +3965,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -4041,12 +4005,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -34,7 +34,7 @@
"@types/node": "^22.20.1",
"tsx": "^4.23.12",
"typescript": "^5.0.0",
"vitest": "^4.1.10"
"vitest": "^4.1.11"
},
"overrides": {
"sharp": "^0.35.0",
@@ -431,6 +431,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
internal static bool HasAdvisoryCompletionDeadline(DecisionState state)
=> FindAdvisoryCompletionDeadline(state) != null;
/// <summary>True when a deadline with the given name is currently armed.</summary>
internal static bool HasDeadline(DecisionState state, string name)
{
foreach (var d in state.Deadlines)
{
if (d.Name == name) return true;
}
return false;
}
/// <summary>The armed <see cref="DeadlineNames.AdvisoryCompletion"/> deadline, or null.</summary>
internal static ActiveDeadline? FindAdvisoryCompletionDeadline(DecisionState state)
{
@@ -912,6 +922,61 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(neverObservedState, neverObservedTransition, neverObservedEffects.ToArray());
}
// Completion-gate guard (sessions 81daa77f / 75d6ae8e, 2026-08-25) — a completion
// gate that is deliberately holding this session must not be overrun by this
// window. The RealmJoin gate blocks completion until phase 110, and it carries its
// own bounded resolution: 60 min from detection, re-armed on deployment activity,
// hard-capped at 4 h. This 30-min window is simply shorter, so on every device
// whose RealmJoin first deployment outlives it the session was failed while the
// engine's own `completion_waiting` still read "waiting on: realmjoin_resolution"
// — 75d6ae8e installed 25 RealmJoin packages successfully inside the window, the
// last one 2 min before the failure. Re-arm and let the gate's own deadline decide.
//
// Convergent: the gate resolves itself either way (Resolved / FirstDeploymentIncomplete
// / Timeout all set RealmJoinFacts.Outcome, which opens RealmJoinGateOpen), after
// which this guard stops applying and the next fire resolves the session normally.
// Requiring the gate's deadline to still be armed keeps a gate that can no longer
// resolve itself from parking the session forever; AgentMaxLifetimeMinutes bounds
// it regardless.
if (!RealmJoinGateOpen(state) && HasDeadline(state, DeadlineNames.RealmJoinTimeout))
{
var gateRearmedDeadline = BuildAdvisoryCompletionDeadline(state, signal);
builder.AddDeadline(gateRearmedDeadline);
var gateTrigger = $"DeadlineFired:{DeadlineNames.AdvisoryCompletion}:CompletionGateHolding";
var gateRearmedState = builder.Build();
var gateRearmedTransition = BuildTakenTransition(
before: state,
signal: signal,
toStage: state.Stage,
nextStepIndex: nextStep,
trigger: gateTrigger);
// Same deliberate dedupe bypass as the enforcement-progress re-arm: the missing
// set is unchanged since arming, but the new due-time is exactly what an
// operator debugging the session needs. Bounded to one per window.
var gateRearmedEffects = new[]
{
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: gateRearmedDeadline),
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
parameters: new Dictionary<string, string>
{
["eventType"] = SharedConstants.EventTypes.CompletionWaiting,
["source"] = "DecisionEngine",
["severity"] = "Info",
["immediateUpload"] = "false",
["message"] = "Completion resolution window re-armed: a completion gate is still holding the session",
["missingPrerequisites"] = string.Join(",", BuildMissingCompletionPrerequisites(state)),
["trigger"] = gateTrigger,
["stage"] = state.Stage.ToString(),
["resolutionDeadlineDueAtUtc"] = gateRearmedDeadline.DueAtUtc.ToString("o"),
}),
};
return new DecisionStep(gateRearmedState, gateRearmedTransition, gateRearmedEffects);
}
// Conjunction not met — the session is failed. The two arming variants carry
// distinct context (see method doc): the advisory variant un-defangs the original
// ESP failure; the esp-exit variant never had an ESP failure, so it gets its own
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
@@ -226,6 +226,18 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool ValidateCloudPcDevice { get; set; } = false;
/// <summary>
/// Cert-to-device binding check (Global-Admin-only preview, SHADOW mode).
/// Resolves the Intune managedDevice id carried in the agent client certificate's Subject
/// CN against this tenant's own managedDevices inventory, proving the certificate belongs
/// to a device the tenant actually enrolled. The result is recorded as telemetry only and
/// never blocks enrollment, because a device object can in principle appear later than the
/// agent's first call - measuring that race is the point of the shadow pass. Requires the
/// optional Graph permission DeviceManagementManagedDevices.Read.All (feature
/// "IntuneDeviceBinding" in the grant script).
/// </summary>
public bool ValidateIntuneDeviceBinding { get; set; } = false;
/// <summary>
/// Emergency bypass for agent security gate (Global Admin use only).
/// If true, agent requests are accepted even when ValidateAutopilotDevice is false.
@@ -1,4 +1,4 @@
namespace AutopilotMonitor.Shared.Models.Graph
namespace AutopilotMonitor.Shared.Models.Graph
{
/// <summary>
/// Constant catalog of Microsoft Graph application permission names referenced by the
@@ -26,6 +26,14 @@ namespace AutopilotMonitor.Shared.Models.Graph
/// <summary>Read Intune managed apps (future use — Win32 app display names not yet covered).</summary>
public const string DeviceManagementAppsReadAll = "DeviceManagementApps.Read.All";
/// <summary>
/// Read Intune managed device objects (<c>deviceManagement/managedDevices</c>). Optional
/// add-on permission backing the Intune device-binding check, which verifies that the
/// managedDevice id carried in the agent's client certificate actually exists in the
/// tenant it claims. Not part of the default-consent set — must be granted per tenant.
/// </summary>
public const string DeviceManagementManagedDevicesReadAll = "DeviceManagementManagedDevices.Read.All";
/// <summary>
/// Read Windows 365 Cloud PC objects (<c>virtualEndpoint/cloudPCs</c>). Optional add-on
/// permission backing the W365 Cloud PC device validator — Cloud PCs are never
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models.Graph
@@ -17,12 +17,19 @@ namespace AutopilotMonitor.Shared.Models.Graph
/// <summary>Validates Windows 365 Cloud PCs via the service-side Cloud PC inventory (cert-CN bound).</summary>
public const string FeatureW365CloudPcValidation = "W365CloudPcValidation";
/// <summary>
/// Verifies that the Intune managedDevice id carried in the agent's client certificate
/// actually exists in the tenant the request claims (cert-to-device binding).
/// </summary>
public const string FeatureIntuneDeviceBinding = "IntuneDeviceBinding";
// Backed by a plain dictionary (netstandard2.0 + C# 9, no Immutable types).
private static readonly Dictionary<string, string[]> _byFeature
= new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
[FeatureScriptDisplayNames] = new[] { GraphAppPermissions.DeviceManagementScriptsReadAll },
[FeatureW365CloudPcValidation] = new[] { GraphAppPermissions.CloudPCReadAll },
[FeatureIntuneDeviceBinding] = new[] { GraphAppPermissions.DeviceManagementManagedDevicesReadAll },
};
/// <summary>All registered feature identifiers.</summary>
@@ -0,0 +1,130 @@
using System;
namespace AutopilotMonitor.Shared.Security
{
/// <summary>
/// Microsoft device-certificate extension OIDs and the shared ASN.1 decoding for the
/// GUID-bearing ones. Canonical for both the agent (local identity probing) and the
/// backend (client-cert tenant binding) so the two cannot drift apart.
/// </summary>
/// <remarks>
/// Two certificate families carry these, and they are NOT interchangeable:
/// <list type="bullet">
/// <item><description>
/// <b>MS-Organization-Access</b> (Entra/AAD-Join, <c>1.2.840.113556.1.5.284.*</c>) — installed
/// on AAD-join. The agent reads it locally to learn its own TenantId; it is never used for
/// backend authentication.
/// </description></item>
/// <item><description>
/// <b>Microsoft Intune MDM Device CA</b> (<c>1.2.840.113556.5.*</c>) — this is the cert the
/// agent presents as the mTLS client certificate, so it is the only one whose contents the
/// backend can trust as proof of possession.
/// </description></item>
/// </list>
/// Values verified against a real field certificate (issued 2026-05-08) — see
/// <c>Functions.Tests/device-cert-sample.pem</c>. Note that the Intune <b>Account</b> ID
/// (<c>5.6</c>) is a DIFFERENT GUID from the Entra tenant ID (<c>5.14</c>); they must never
/// be compared against each other.
/// </remarks>
public static class MsDeviceCertificateOids
{
/// <summary>Issuer CN of the Intune MDM device certificate used for mTLS client auth.</summary>
public const string IntuneMdmIssuer = "CN=Microsoft Intune MDM Device CA";
/// <summary>
/// Intune MDM device certificate → Entra (Azure AD) TenantId. Encoded as a nested
/// ASN.1 OCTET STRING wrapping a 16-byte little-endian GUID.
/// </summary>
public const string IntuneCertEntraTenantIdOid = "1.2.840.113556.5.14";
/// <summary>
/// Intune MDM device certificate → Intune <b>Account</b> ID. Distinct from the Entra
/// tenant ID — do not use it for tenant comparison.
/// </summary>
public const string IntuneCertAccountIdOid = "1.2.840.113556.5.6";
/// <summary>
/// Intune MDM device certificate → Intune device ID (matches the certificate's Subject CN).
/// Unlike the other two this extension holds the 16 GUID bytes <b>raw</b>, without the
/// nested OCTET STRING wrapper — decode it with <see cref="TryParseGuid"/>, which accepts
/// both shapes.
/// </summary>
public const string IntuneCertDeviceIdOid = "1.2.840.113556.5.4";
/// <summary>MS-Organization-Access (Entra join) certificate → TenantId.</summary>
public const string EntraJoinCertTenantIdOid = "1.2.840.113556.1.5.284.5";
/// <summary>MS-Organization-Access (Entra join) certificate → DeviceId.</summary>
public const string EntraJoinCertDeviceIdOid = "1.2.840.113556.1.5.284.2";
/// <summary>
/// Decodes a 16-byte Microsoft binary GUID out of an X.509 extension value
/// (<c>X509Extension.RawData</c>).
/// <para>
/// Accepts both encodings seen in the wild: the GUID wrapped in a nested ASN.1 OCTET
/// STRING (short form <c>04 10 …</c> and long form <c>04 81 10 …</c>), and the 16 raw
/// bytes with no wrapper at all (Intune OID <c>5.4</c>).
/// </para>
/// The byte order is Microsoft's — little-endian for the first three groups, i.e. exactly
/// what <see cref="Guid(byte[])"/> expects.
/// </summary>
/// <returns><c>true</c> and the decoded GUID, or <c>false</c> on any structural mismatch. Never throws.</returns>
public static bool TryParseGuid(byte[] raw, out Guid value)
{
value = Guid.Empty;
if (raw == null) return false;
// Unwrapped form: exactly the 16 GUID bytes (Intune OID 5.4).
if (raw.Length == 16)
return TryFromBytes(raw, 0, out value);
if (raw.Length < 2) return false;
// ASN.1 OCTET STRING tag.
if (raw[0] != 0x04) return false;
int contentStart;
int contentLen;
if (raw[1] < 0x80)
{
// short-form length
contentLen = raw[1];
contentStart = 2;
}
else
{
// long-form length: lower 7 bits = number of following length bytes.
int numLenBytes = raw[1] & 0x7F;
if (numLenBytes < 1 || numLenBytes > 4 || raw.Length < 2 + numLenBytes) return false;
contentLen = 0;
for (int i = 0; i < numLenBytes; i++)
contentLen = (contentLen << 8) | raw[2 + i];
contentStart = 2 + numLenBytes;
}
if (contentLen != 16 || contentStart + 16 > raw.Length) return false;
return TryFromBytes(raw, contentStart, out value);
}
private static bool TryFromBytes(byte[] source, int offset, out Guid value)
{
value = Guid.Empty;
var guidBytes = new byte[16];
Buffer.BlockCopy(source, offset, guidBytes, 0, 16);
try
{
value = new Guid(guidBytes);
return true;
}
catch
{
return false;
}
}
}
}
@@ -133,11 +133,14 @@ interface TenantConfigContextValue {
handleToggleDeviceAssociationValidation: (newValue: boolean) => Promise<void>;
validateCloudPcDevice: boolean;
setValidateCloudPcDevice: (v: boolean) => void;
validateIntuneDeviceBinding: boolean;
setValidateIntuneDeviceBinding: (v: boolean) => void;
/**
* Toggle + persist W365 Cloud PC validation in one shot. No consent flow — the backing
* CloudPC.Read.All permission is granted via the Optional Graph capabilities add-on script.
*/
handleToggleCloudPcValidation: (newValue: boolean) => Promise<void>;
handleToggleIntuneDeviceBinding: (newValue: boolean) => Promise<void>;
/**
* Persist a validation-gate change immediately. The validation section has NO save bar —
* every gate change that doesn't run the consent flow (disable, and enabling the second
@@ -394,6 +397,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const [validateCorporateIdentifier, setValidateCorporateIdentifier] = useState(false);
const [validateDeviceAssociation, setValidateDeviceAssociation] = useState(false);
const [validateCloudPcDevice, setValidateCloudPcDevice] = useState(false);
const [validateIntuneDeviceBinding, setValidateIntuneDeviceBinding] = useState(false);
const [dataRetentionDays, setDataRetentionDays] = useState(90);
const [sessionTimeoutHours, setSessionTimeoutHours] = useState(5);
@@ -535,6 +539,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setValidateCorporateIdentifier(data.validateCorporateIdentifier ?? false);
setValidateDeviceAssociation(data.validateDeviceAssociation ?? false);
setValidateCloudPcDevice(data.validateCloudPcDevice ?? false);
setValidateIntuneDeviceBinding(data.validateIntuneDeviceBinding ?? false);
setDataRetentionDays(data.dataRetentionDays ?? 90);
setSessionTimeoutHours(data.sessionTimeoutHours ?? 5);
setEnablePerformanceCollector(data.enablePerformanceCollector ?? true);
@@ -703,7 +708,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// -----------------------------------------------------------------------
// Save configuration (shared by all sections)
// -----------------------------------------------------------------------
const saveConfiguration = useCallback(async (sectionName: SettingsSectionName, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; unrestrictedMode?: boolean }): Promise<boolean> => {
const saveConfiguration = useCallback(async (sectionName: SettingsSectionName, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; validateIntuneDeviceBinding?: boolean; unrestrictedMode?: boolean }): Promise<boolean> => {
// Read-only viewers (Operators) have no save affordances; this guard covers any path
// that still reaches a save (the backend would 403 the PATCH regardless).
if (!tenantId || !config || !canEditConfig) return false;
@@ -717,6 +722,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const corporateIdentifierValidationValue = overrides?.validateCorporateIdentifier ?? validateCorporateIdentifier;
const deviceAssociationValidationValue = overrides?.validateDeviceAssociation ?? validateDeviceAssociation;
const cloudPcValidationValue = overrides?.validateCloudPcDevice ?? validateCloudPcDevice;
const intuneDeviceBindingValue = overrides?.validateIntuneDeviceBinding ?? validateIntuneDeviceBinding;
const unrestrictedModeValue = overrides?.unrestrictedMode ?? unrestrictedMode;
const updatedConfig: TenantConfiguration = {
@@ -728,6 +734,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
validateCorporateIdentifier: corporateIdentifierValidationValue,
validateDeviceAssociation: deviceAssociationValidationValue,
validateCloudPcDevice: cloudPcValidationValue,
validateIntuneDeviceBinding: intuneDeviceBindingValue,
dataRetentionDays,
sessionTimeoutHours,
enablePerformanceCollector,
@@ -822,6 +829,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setValidateCorporateIdentifier(persisted.validateCorporateIdentifier ?? false);
setValidateDeviceAssociation(persisted.validateDeviceAssociation ?? false);
setValidateCloudPcDevice(persisted.validateCloudPcDevice ?? false);
setValidateIntuneDeviceBinding(persisted.validateIntuneDeviceBinding ?? false);
setUnrestrictedMode(persisted.unrestrictedMode ?? false);
trackEvent("settings_saved", { section: sectionName, fieldCount: Object.keys(patchFields).length });
setSuccessMessage("Configuration saved successfully!");
@@ -841,7 +849,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
}
}, [
tenantId, config, canEditConfig, getAccessToken, addNotification,
manufacturerWhitelist, modelWhitelist, webhookNotifyOnHardwareRejection, validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, validateCloudPcDevice,
manufacturerWhitelist, modelWhitelist, webhookNotifyOnHardwareRejection, validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, validateCloudPcDevice, validateIntuneDeviceBinding,
dataRetentionDays, sessionTimeoutHours, enablePerformanceCollector, performanceCollectorInterval,
helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
contactEmail, enableGeoLocation, enableTimezoneAutoSet, enableDoGroupIdAutoSet, enableImeMatchLog, enableGatherRuleDebugLog, logLevel, showScriptOutput, showEnrollmentSummary,
@@ -1333,6 +1341,17 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
await saveConfiguration("autopilotValidation", { validateCloudPcDevice: newValue });
}, [saveConfiguration]);
/**
* Toggle the cert-to-device binding check (Global-Admin-only preview). No consent flow -
* DeviceManagementManagedDevices.Read.All is an Optional Graph capabilities add-on granted
* with the script; without it the backend records "PermissionMissing" and nothing else, and
* because the check runs in shadow mode it never affects an enrollment either way.
*/
const handleToggleIntuneDeviceBinding = useCallback(async (newValue: boolean) => {
setValidateIntuneDeviceBinding(newValue);
await saveConfiguration("autopilotValidation", { validateIntuneDeviceBinding: newValue });
}, [saveConfiguration]);
// -----------------------------------------------------------------------
// Admin management handlers
// -----------------------------------------------------------------------
@@ -1660,7 +1679,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
validateDeviceAssociation, setValidateDeviceAssociation,
handleToggleDeviceAssociationValidation,
validateCloudPcDevice, setValidateCloudPcDevice,
validateIntuneDeviceBinding, setValidateIntuneDeviceBinding,
handleToggleCloudPcValidation,
handleToggleIntuneDeviceBinding,
saveValidationGate,
autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
@@ -1767,6 +1788,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
editionInfo, startingTrial, startTrial, appHomingFunnelActive, homingFlipped,
validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation,
handleToggleDeviceAssociationValidation, validateCloudPcDevice, handleToggleCloudPcValidation,
validateIntuneDeviceBinding, handleToggleIntuneDeviceBinding,
saveValidationGate, autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
manufacturerWhitelist, modelWhitelist, effectiveHwRejectionNotify, setHwRejectionNotifyWriteThrough,
handleSaveHardwareWhitelist, handleResetHardwareWhitelist,
@@ -23,6 +23,7 @@ const EXPECTED_OWNED_FIELDS = [
"manufacturerWhitelist", "modelWhitelist", "webhookNotifyOnHardwareRejection",
// autopilotValidation
"validateAutopilotDevice", "validateCorporateIdentifier", "validateDeviceAssociation", "validateCloudPcDevice",
"validateIntuneDeviceBinding",
// notifications
"notificationChannelsJson",
// slaTargets
@@ -13,6 +13,10 @@ interface AutopilotValidationSectionProps {
onToggleDeviceAssociation?: (newValue: boolean) => void | Promise<void>;
/** Render the DevPrep Device Association toggle. Gated to Global Admins by the parent. */
showDeviceAssociationToggle?: boolean;
/** Cert-to-device binding - Global-Admin-only preview. Provided when `showIntuneDeviceBindingToggle=true`. */
validateIntuneDeviceBinding?: boolean;
onToggleIntuneDeviceBinding?: (v: boolean) => void | Promise<void>;
showIntuneDeviceBindingToggle?: boolean;
/** Windows 365 Cloud PC validation — fallback gate for Cloud PCs (never Autopilot-registered). */
validateCloudPcDevice?: boolean;
/** Toggle + persist Cloud PC validation in one shot (permission comes via the Optional Graph capabilities add-on, no consent dialog). */
@@ -35,6 +39,9 @@ export default function AutopilotValidationSection({
validateDeviceAssociation = false,
onToggleDeviceAssociation,
showDeviceAssociationToggle = false,
validateIntuneDeviceBinding = false,
onToggleIntuneDeviceBinding,
showIntuneDeviceBindingToggle = false,
validateCloudPcDevice = false,
onToggleCloudPc,
autopilotConsentInProgress,
@@ -237,6 +244,35 @@ export default function AutopilotValidationSection({
</div>
)}
{/* Cert-to-device binding - GA-gated preview, shadow-mode (no enrollment block) */}
{showIntuneDeviceBindingToggle && onToggleIntuneDeviceBinding && (
<div className="border-t border-gray-100 pt-5 space-y-3" data-testid="intune-device-binding-toggle">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-gray-700 tracking-wide">Certificate Device Binding</p>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider bg-purple-100 text-purple-800">
Preview · GA only
</span>
</div>
<label className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900">Enable Intune Device Binding Validation</p>
<p className="text-sm text-gray-500">
Checks that the Intune device id in the agent{"'"}s client certificate belongs to a device this tenant actually enrolled. Currently runs in <strong>shadow mode</strong> - the result is recorded as telemetry only and does NOT block enrollment. Requires the optional <strong>DeviceManagementManagedDevices.Read.All</strong> permission - grant the{" "}
<strong>IntuneDeviceBinding</strong> add-on under <em>Optional Graph capabilities</em>.
</p>
</div>
<button
onClick={() => { void onToggleIntuneDeviceBinding(!validateIntuneDeviceBinding); }}
disabled={saving}
aria-label="Toggle Intune device binding validation"
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${validateIntuneDeviceBinding ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${validateIntuneDeviceBinding ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</label>
</div>
)}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-900">
<strong>Important:</strong>{" "}
@@ -40,6 +40,7 @@ export const SECTION_FIELD_MAP = {
"validateCorporateIdentifier",
"validateDeviceAssociation",
"validateCloudPcDevice",
"validateIntuneDeviceBinding",
],
},
notifications: {
@@ -17,6 +17,8 @@ export function SectionAutopilotValidation() {
validateDeviceAssociation,
handleToggleDeviceAssociationValidation,
validateCloudPcDevice,
validateIntuneDeviceBinding,
handleToggleIntuneDeviceBinding,
handleToggleCloudPcValidation,
saveValidationGate,
autopilotConsentInProgress, savingSection,
@@ -42,6 +44,9 @@ export function SectionAutopilotValidation() {
// to the web test setup (vitest.config.ts currently only matches *.test.ts, no JSX).
// Tracked in memory/project_devprep_followups.md.
const showDeviceAssociationToggle = user?.isGlobalAdmin === true;
// Same GA gate as the DevPrep preview: only the operator can turn the binding check on
// while its enrollment-race behaviour is still being measured.
const showIntuneDeviceBindingToggle = user?.isGlobalAdmin === true;
// Dual app-reg window: tenants homed on the previous app registration (homedAppClientId
// null/absent) keep working unchanged — this is a purely informational nudge, part of the
@@ -145,6 +150,9 @@ export function SectionAutopilotValidation() {
onToggleDeviceAssociation={handleToggleDeviceAssociationValidation}
showDeviceAssociationToggle={showDeviceAssociationToggle}
validateCloudPcDevice={validateCloudPcDevice}
validateIntuneDeviceBinding={validateIntuneDeviceBinding}
onToggleIntuneDeviceBinding={handleToggleIntuneDeviceBinding}
showIntuneDeviceBindingToggle={showIntuneDeviceBindingToggle}
onToggleCloudPc={handleToggleCloudPcValidation}
autopilotConsentInProgress={autopilotConsentInProgress}
saving={savingSection === "autopilotValidation"}
@@ -53,6 +53,7 @@ export interface TenantConfiguration {
* permission ("W365CloudPcValidation" in Optional Graph capabilities).
*/
validateCloudPcDevice?: boolean;
validateIntuneDeviceBinding?: boolean;
allowInsecureAgentRequests?: boolean;
/**
* Dual app-reg window: client id of the Entra app registration this tenant is homed on.
+230 -209
View File
@@ -18,25 +18,25 @@
"fflate": "^0.8.3",
"jspdf": "^4.2.1",
"leaflet": "^1.9.4",
"next": "^16.3.1",
"next": "^16.3.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-leaflet": "^5.0.0",
"recharts": "^2.15.4"
},
"devDependencies": {
"@next/bundle-analyzer": "^16.3.1",
"@next/bundle-analyzer": "^16.3.2",
"@types/dagre": "^0.7.54",
"@types/node": "^22.20.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.5.4",
"eslint": "^9.39.5",
"eslint-config-next": "^16.3.1",
"eslint-config-next": "^16.3.2",
"postcss": "^8.5.12",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
"vitest": "^4.1.11"
},
"engines": {
"node": "22.x"
@@ -1359,9 +1359,9 @@
"license": "MIT"
},
"node_modules/@next/bundle-analyzer": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.3.1.tgz",
"integrity": "sha512-/6XQeYPHM6jF1gTeJ82Mu6yXOHQIFVxzAn3DkPtHDp5v6SDXoCicBMTHloN+2h4WKFCQujSLCgLZHItJ3jN1uw==",
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.3.2.tgz",
"integrity": "sha512-1WSK3fjvlZzA3cpP6XsM9JW2/LDWWlldlYN5sL/h2kvAQCu2KeXLxNkqfI/0ac+ikOYFzWuuBpL9O0Cu3mrfww==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1369,15 +1369,15 @@
}
},
"node_modules/@next/env": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.1.tgz",
"integrity": "sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==",
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.2.tgz",
"integrity": "sha512-8k4YoG8cM7LWlkfzGNYCRBbFNlernLiMw4s0btVl+CmmWqn3VpYypA72/5Feb1UWdxe6tHqr5KHP4p4Y4m9luA==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.1.tgz",
"integrity": "sha512-B4SznlXwVpaLDa7Tbi6zLuueria2d/PmFDhXyDymPGrk2r1n/RMJmcn5FZq1L64k+Jsyte1lKvOr7lP9Xo80mQ==",
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.2.tgz",
"integrity": "sha512-z+HW1cZgt8QhByw8p2EbxF94AImgsKIYUbtSkA7Zld2T9yrKAlys4jNOcAOCtv6csX2CoA/5qCVyesL5pHmJ0A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1386,9 +1386,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.1.tgz",
"integrity": "sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==",
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.2.tgz",
"integrity": "sha512-ib5Llm93YCKoKWDh6ZaHq6QWTuOZ2bRkSnUwMmX8dsRIOkBNL1vVlSiUKSfixPL9SSh9pvukzqajk/klkn5vqg==",
"cpu": [
"arm64"
],
@@ -1402,9 +1402,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.1.tgz",
"integrity": "sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==",
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.2.tgz",
"integrity": "sha512-qd98fX2+I5nYJDioW2o7nSjoxM5KvWdeDefM80igia4+C/qSIEhH4MhTE+hO/7qKM7W37/Mq+dOWp8UePSyLHw==",
"cpu": [
"x64"
],
@@ -1418,12 +1418,15 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.1.tgz",
"integrity": "sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==",
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.2.tgz",
"integrity": "sha512-vqsgb6FAOzcrCccsLXiKtAy5t8EzO+uOazuFaSkQxeY0tNONG3vpHYy8pyBafcI5SNFPTeyard6yTr6SzNGo2A==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1434,12 +1437,15 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.1.tgz",
"integrity": "sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==",
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.2.tgz",
"integrity": "sha512-xIe1eujfHUB2XcxHGddxJyu6TJRPjC5NpIkQYB/32ESkt5VkQyIAjmLRS38c+s6QY+qjtY/4KarVDzXRuD7lZQ==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1450,12 +1456,15 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.1.tgz",
"integrity": "sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==",
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.2.tgz",
"integrity": "sha512-Fe0SA2j8X0kmc3aveuHD7UktO3AE2+mH3LguP60vGbz7u0z+MrDXbeb5iZFYAwR7EzzzXJ2Yk966w9mGTFMqfA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1466,12 +1475,15 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.1.tgz",
"integrity": "sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==",
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.2.tgz",
"integrity": "sha512-TFBipb+gyesI/2Ve4zVu7kGltBWN/R466G5/1gtt2lECfc22G1pjkTxu68Q9aFcOaXiRGTQfvDbQQFe7mYgxiQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1482,9 +1494,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.1.tgz",
"integrity": "sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==",
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.2.tgz",
"integrity": "sha512-rVtmnNpBYIosDnKD/96dKxFsJnwnn1WRGG/HioSe8XCm2ksSHNrd2R6+hSjvTBxeMNhJ9pYeu/90cWB1nQLuNA==",
"cpu": [
"arm64"
],
@@ -1554,9 +1566,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"version": "0.146.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz",
"integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1581,10 +1593,27 @@
"react-dom": "^19.0.0"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz",
"integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz",
"integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==",
"cpu": [
"arm64"
],
@@ -1599,9 +1628,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz",
"integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==",
"cpu": [
"arm64"
],
@@ -1616,9 +1645,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz",
"integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==",
"cpu": [
"x64"
],
@@ -1633,9 +1662,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz",
"integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==",
"cpu": [
"x64"
],
@@ -1650,9 +1679,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz",
"integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==",
"cpu": [
"arm"
],
@@ -1667,13 +1696,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz",
"integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1684,13 +1716,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz",
"integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1701,13 +1736,16 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz",
"integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1718,13 +1756,16 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz",
"integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1735,13 +1776,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz",
"integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1752,13 +1796,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz",
"integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1769,9 +1816,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz",
"integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==",
"cpu": [
"arm64"
],
@@ -1785,48 +1832,10 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz",
"integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==",
"cpu": [
"arm64"
],
@@ -1841,9 +1850,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz",
"integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==",
"cpu": [
"x64"
],
@@ -2658,16 +2667,16 @@
]
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -2676,13 +2685,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -2703,9 +2712,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2716,13 +2725,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@@ -2730,14 +2739,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -2746,9 +2755,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2756,13 +2765,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -4290,13 +4299,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.1.tgz",
"integrity": "sha512-0vtrpwFVHFEkycUgV/DyrG29OS+HSRdah5Yu8YuZoiBMtlAT6NIiWzaLwDkJZxr2kGfx+9LIvfQ7KHAlEs0VsA==",
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.2.tgz",
"integrity": "sha512-gTABOJmyc6pEgSX1Z1VOjBxkSmo5Hkdj+ePclDf8HLGTsnVWzgtDdrOeQrAnUkf0JNJJhwPuXrsIwmFZRKJLoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "16.3.1",
"@next/eslint-plugin-next": "16.3.2",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -6103,6 +6112,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6124,6 +6136,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6145,6 +6160,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6166,6 +6184,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6430,12 +6451,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/next/-/next-16.3.1.tgz",
"integrity": "sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==",
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/next/-/next-16.3.2.tgz",
"integrity": "sha512-/ZCaubUy17Lld1SiPWxuPbCk2ihqAxF2QNQaPZeEaEb7t1I58qhsJN187D7AfpapHAqUPXH0f/thtdW9dWgWFg==",
"license": "MIT",
"dependencies": {
"@next/env": "16.3.1",
"@next/env": "16.3.2",
"@swc/helpers": "0.5.23",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -6449,14 +6470,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.3.1",
"@next/swc-darwin-x64": "16.3.1",
"@next/swc-linux-arm64-gnu": "16.3.1",
"@next/swc-linux-arm64-musl": "16.3.1",
"@next/swc-linux-x64-gnu": "16.3.1",
"@next/swc-linux-x64-musl": "16.3.1",
"@next/swc-win32-arm64-msvc": "16.3.1",
"@next/swc-win32-x64-msvc": "16.3.1",
"@next/swc-darwin-arm64": "16.3.2",
"@next/swc-darwin-x64": "16.3.2",
"@next/swc-linux-arm64-gnu": "16.3.2",
"@next/swc-linux-arm64-musl": "16.3.2",
"@next/swc-linux-x64-gnu": "16.3.2",
"@next/swc-linux-x64-musl": "16.3.2",
"@next/swc-win32-arm64-msvc": "16.3.2",
"@next/swc-win32-x64-msvc": "16.3.2",
"sharp": "^0.35.3"
},
"peerDependencies": {
@@ -6483,9 +6504,9 @@
}
},
"node_modules/next/node_modules/@next/swc-win32-x64-msvc": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.1.tgz",
"integrity": "sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==",
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.2.tgz",
"integrity": "sha512-H4Y2o2/JcHu8LtwzD5CXfHhwxwz8gfsx2HXDEw46Mtev5xHnEmB7HNtZtmriw5ReUOjRtcDqo7XSbU01FT9NlA==",
"cpu": [
"x64"
],
@@ -7372,13 +7393,13 @@
}
},
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz",
"integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.139.0",
"@oxc-project/types": "=0.146.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -7388,21 +7409,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.1.5",
"@rolldown/binding-darwin-arm64": "1.1.5",
"@rolldown/binding-darwin-x64": "1.1.5",
"@rolldown/binding-freebsd-x64": "1.1.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
"@rolldown/binding-linux-arm64-musl": "1.1.5",
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
"@rolldown/binding-linux-x64-gnu": "1.1.5",
"@rolldown/binding-linux-x64-musl": "1.1.5",
"@rolldown/binding-openharmony-arm64": "1.1.5",
"@rolldown/binding-wasm32-wasi": "1.1.5",
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
"@rolldown/binding-win32-x64-msvc": "1.1.5"
"@rolldown/binding-android-arm-eabi": "1.2.5",
"@rolldown/binding-android-arm64": "1.2.5",
"@rolldown/binding-darwin-arm64": "1.2.5",
"@rolldown/binding-darwin-x64": "1.2.5",
"@rolldown/binding-freebsd-x64": "1.2.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.5",
"@rolldown/binding-linux-arm64-gnu": "1.2.5",
"@rolldown/binding-linux-arm64-musl": "1.2.5",
"@rolldown/binding-linux-ppc64-gnu": "1.2.5",
"@rolldown/binding-linux-s390x-gnu": "1.2.5",
"@rolldown/binding-linux-x64-gnu": "1.2.5",
"@rolldown/binding-linux-x64-musl": "1.2.5",
"@rolldown/binding-openharmony-arm64": "1.2.5",
"@rolldown/binding-win32-arm64-msvc": "1.2.5",
"@rolldown/binding-win32-x64-msvc": "1.2.5"
}
},
"node_modules/run-parallel": {
@@ -8174,9 +8195,9 @@
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8565,16 +8586,16 @@
}
},
"node_modules/vite": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.17",
"rolldown": "~1.1.5",
"postcss": "^8.5.26",
"rolldown": "~1.2.4",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -8591,7 +8612,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.3.0",
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -8656,19 +8677,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -8696,12 +8717,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+4 -4
View File
@@ -25,25 +25,25 @@
"fflate": "^0.8.3",
"jspdf": "^4.2.1",
"leaflet": "^1.9.4",
"next": "^16.3.1",
"next": "^16.3.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-leaflet": "^5.0.0",
"recharts": "^2.15.4"
},
"devDependencies": {
"@next/bundle-analyzer": "^16.3.1",
"@next/bundle-analyzer": "^16.3.2",
"@types/dagre": "^0.7.54",
"@types/node": "^22.20.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.5.4",
"eslint": "^9.39.5",
"eslint-config-next": "^16.3.1",
"eslint-config-next": "^16.3.2",
"postcss": "^8.5.12",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
"vitest": "^4.1.11"
},
"optionalDependencies": {
"@next/swc-win32-x64-msvc": "npm:null@*"
@@ -87,6 +87,7 @@ export const SHARED_MANIFEST = {
"validateCorporateIdentifier",
"validateDeviceAssociation",
"validateCloudPcDevice",
"validateIntuneDeviceBinding",
"allowInsecureAgentRequests",
"dataRetentionDays",
"sessionTimeoutHours",
@@ -83,6 +83,7 @@
"validateCorporateIdentifier",
"validateDeviceAssociation",
"validateCloudPcDevice",
"validateIntuneDeviceBinding",
"allowInsecureAgentRequests",
"dataRetentionDays",
"sessionTimeoutHours",