mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| beac51720f | |||
| 32268ba245 | |||
| c91d8d4501 | |||
| bf6850dedc | |||
| 35a3ed0cfb | |||
| ea6a375c4b | |||
| fb79c7711c | |||
| 249e39bc68 | |||
| 6bd9139869 | |||
| 73373a2c9e | |||
| 6d607c9045 |
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the compiled-in fallback rule catalogs (<see cref="BuiltInConfigCatalog"/>):
|
||||
/// the agent embeds the SAME CI-combined rules/dist artifacts the backend serves, so a
|
||||
/// no-config/no-cache session still runs full IME tracking. These tests guard the
|
||||
/// embedding + deserialization end-to-end — a broken LogicalName, a moved resource or a
|
||||
/// shape change in the combined JSON must fail HERE, not silently strip the fallback
|
||||
/// back down to zero patterns in the field.
|
||||
/// </summary>
|
||||
public class BuiltInConfigCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void ImeLogPatterns_LoadFromEmbeddedCatalog_EnabledOnly()
|
||||
{
|
||||
var patterns = BuiltInConfigCatalog.GetEnabledImeLogPatterns(logger: null);
|
||||
|
||||
Assert.NotEmpty(patterns);
|
||||
Assert.All(patterns, p =>
|
||||
{
|
||||
Assert.True(p.Enabled);
|
||||
Assert.False(string.IsNullOrWhiteSpace(p.PatternId));
|
||||
Assert.False(string.IsNullOrWhiteSpace(p.Pattern));
|
||||
Assert.False(string.IsNullOrWhiteSpace(p.Action));
|
||||
Assert.False(string.IsNullOrWhiteSpace(p.Category));
|
||||
});
|
||||
Assert.Equal(patterns.Count, patterns.Select(p => p.PatternId).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeLogPatterns_CarryCoreTrackingActions()
|
||||
{
|
||||
// The app-tracking backbone: without these two actions the ImeLogTracker cannot
|
||||
// attribute installs to apps or follow ESP phases. Their presence proves the
|
||||
// deserialization actually populated the action field (a shape drift in the
|
||||
// combined JSON would otherwise still pass the structural checks above).
|
||||
var actions = BuiltInConfigCatalog.GetEnabledImeLogPatterns(logger: null)
|
||||
.Select(p => p.Action).ToHashSet();
|
||||
|
||||
Assert.Contains("setCurrentApp", actions);
|
||||
Assert.Contains("espPhaseDetected", actions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GatherRules_LoadFromEmbeddedCatalog_EnabledOnly()
|
||||
{
|
||||
// The built-in gather-rule catalog ships mostly disabled rules — only the
|
||||
// default-enabled ones may reach the fallback config.
|
||||
var rules = BuiltInConfigCatalog.GetEnabledGatherRules(logger: null);
|
||||
|
||||
Assert.All(rules, r =>
|
||||
{
|
||||
Assert.True(r.Enabled);
|
||||
Assert.False(string.IsNullOrWhiteSpace(r.RuleId));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -114,6 +114,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
Assert.Equal(1, svc.LastFetchAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchConfig_DefaultsFallback_CarriesBuiltInCatalogs()
|
||||
{
|
||||
// A no-config/no-cache session must still get the compiled-in rule catalogs —
|
||||
// an empty ImeLogPatterns list kills the entire IME tracking pipeline (the
|
||||
// pre-catalog behaviour this pins against regressing to).
|
||||
var svc = NewService(BackendBehaviour.AlwaysTransientFailure);
|
||||
|
||||
var result = await svc.FetchConfigAsync(retryOnTransientErrors: true);
|
||||
|
||||
Assert.Equal(0, result.ConfigVersion);
|
||||
Assert.NotEmpty(result.ImeLogPatterns);
|
||||
Assert.All(result.ImeLogPatterns, p => Assert.True(p.Enabled));
|
||||
Assert.All(result.GatherRules, r => Assert.True(r.Enabled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchConfig_EmptyResponse_TreatedAsTransient()
|
||||
{
|
||||
@@ -128,6 +144,94 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
Assert.Equal(4, svc.LastFetchAttempts);
|
||||
}
|
||||
|
||||
// ── Network-link gate (tenant aebdce78 audit 2026-08-24) ────────────
|
||||
|
||||
[Fact]
|
||||
public async Task FetchConfig_InitialFetch_WaitsForNetworkLink()
|
||||
{
|
||||
// The initial startup fetch must gate on the link-level signal so the retry
|
||||
// budget isn't burned into a NIC that is provably still down (agent relaunched
|
||||
// at boot, Wi-Fi still associating).
|
||||
var svc = NewService(BackendBehaviour.SuccessImmediately);
|
||||
|
||||
await svc.FetchConfigAsync(retryOnTransientErrors: true);
|
||||
|
||||
Assert.Equal(1, svc.NetworkLinkWaits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchConfig_RotatePath_SkipsNetworkLinkGate()
|
||||
{
|
||||
// rotate_config runs mid-session on a proven link and must stay latency-bounded.
|
||||
var svc = NewService(BackendBehaviour.SuccessImmediately);
|
||||
|
||||
await svc.FetchConfigAsync(retryOnTransientErrors: false);
|
||||
|
||||
Assert.Equal(0, svc.NetworkLinkWaits);
|
||||
}
|
||||
|
||||
// ── Post-registration recovery ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task TryRecover_AfterFailedFetch_AppliesLiveConfig_KeepsFailureRecord()
|
||||
{
|
||||
// The core self-heal contract: registration proved the backend reachable, the
|
||||
// recovery swaps in the live config — but the LastFetch* observability keeps
|
||||
// describing the failed startup fetch so the wire event can report both.
|
||||
var svc = NewService(BackendBehaviour.FailAllThenSucceed);
|
||||
await svc.FetchConfigAsync(retryOnTransientErrors: true);
|
||||
Assert.Equal(RemoteConfigFetchOutcome.UsedDefaults, svc.LastFetchOutcome);
|
||||
|
||||
var recovered = await svc.TryRecoverConfigAsync();
|
||||
|
||||
Assert.NotNull(recovered);
|
||||
Assert.Equal(42, recovered.ConfigVersion);
|
||||
Assert.Equal(42, svc.CurrentConfig.ConfigVersion);
|
||||
Assert.True(svc.RecoveredAfterFetchFailure);
|
||||
// Startup-failure record intact:
|
||||
Assert.Equal(RemoteConfigFetchOutcome.UsedDefaults, svc.LastFetchOutcome);
|
||||
Assert.Equal(1 + RemoteConfigService.InitialFetchRetryBackoff.Length, svc.LastFetchAttempts);
|
||||
Assert.Equal(nameof(HttpRequestException), svc.LastFetchFailureType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryRecover_AfterSuccessfulFetch_IsNoOp()
|
||||
{
|
||||
var svc = NewService(BackendBehaviour.SuccessImmediately);
|
||||
await svc.FetchConfigAsync(retryOnTransientErrors: true);
|
||||
|
||||
var recovered = await svc.TryRecoverConfigAsync();
|
||||
|
||||
Assert.Null(recovered);
|
||||
Assert.False(svc.RecoveredAfterFetchFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryRecover_BeforeAnyFetch_IsNoOp()
|
||||
{
|
||||
var svc = NewService(BackendBehaviour.SuccessImmediately);
|
||||
|
||||
var recovered = await svc.TryRecoverConfigAsync();
|
||||
|
||||
Assert.Null(recovered);
|
||||
Assert.False(svc.RecoveredAfterFetchFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryRecover_WhenBackendStillFailing_KeepsFallback_NeverThrows()
|
||||
{
|
||||
var svc = NewService(BackendBehaviour.AlwaysTransientFailure);
|
||||
var fallback = await svc.FetchConfigAsync(retryOnTransientErrors: true);
|
||||
Assert.Equal(0, fallback.ConfigVersion);
|
||||
|
||||
var recovered = await svc.TryRecoverConfigAsync();
|
||||
|
||||
Assert.Null(recovered);
|
||||
Assert.False(svc.RecoveredAfterFetchFailure);
|
||||
Assert.Equal(0, svc.CurrentConfig.ConfigVersion); // fallback config untouched
|
||||
Assert.Equal(RemoteConfigFetchOutcome.UsedDefaults, svc.LastFetchOutcome);
|
||||
}
|
||||
|
||||
// ── Outcome state defaults ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -145,6 +249,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
SuccessImmediately,
|
||||
AlwaysTransientFailure,
|
||||
FailTwiceThenSucceed,
|
||||
/// <summary>All 4 initial attempts fail; the NEXT call (the recovery fetch) succeeds.</summary>
|
||||
FailAllThenSucceed,
|
||||
AlwaysAuthFailure401,
|
||||
AlwaysReturnNull,
|
||||
}
|
||||
@@ -177,6 +283,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
_behaviour = behaviour;
|
||||
}
|
||||
|
||||
/// <summary>Number of times the network-link gate seam was awaited.</summary>
|
||||
public int NetworkLinkWaits { get; private set; }
|
||||
|
||||
protected override Task<AgentConfigResponse> CallBackendAsync()
|
||||
{
|
||||
_calls++;
|
||||
@@ -188,6 +297,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
if (_calls < 3)
|
||||
throw new HttpRequestException("simulated cold-start timeout");
|
||||
return Task.FromResult(new AgentConfigResponse { ConfigVersion = 42 });
|
||||
case BackendBehaviour.FailAllThenSucceed:
|
||||
if (_calls <= 1 + RemoteConfigService.InitialFetchRetryBackoff.Length)
|
||||
throw new HttpRequestException("simulated dead network link");
|
||||
return Task.FromResult(new AgentConfigResponse { ConfigVersion = 42 });
|
||||
case BackendBehaviour.AlwaysTransientFailure:
|
||||
throw new HttpRequestException("simulated network failure");
|
||||
case BackendBehaviour.AlwaysAuthFailure401:
|
||||
@@ -202,6 +315,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
// Zero-delay so the suite finishes in ms instead of the live 10/30/60 s schedule.
|
||||
protected override Task DelayBetweenAttemptsAsync(TimeSpan delay) => Task.CompletedTask;
|
||||
|
||||
// Record + skip the real link poll so the suite is deterministic on offline CI.
|
||||
protected override Task WaitForNetworkLinkAsync()
|
||||
{
|
||||
NetworkLinkWaits++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// In-memory + per-instance cache so a prior test's successful fetch can't
|
||||
// bleed into a later test that expects UsedDefaults.
|
||||
private AgentConfigResponse? _cached;
|
||||
|
||||
+199
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
-1
@@ -182,6 +182,73 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.Equal("FromCache", data["outcome"]);
|
||||
}
|
||||
|
||||
// ── Post-registration recovery (Phase 6.5, tenant aebdce78 audit 2026-08-24) ─
|
||||
|
||||
[Fact]
|
||||
public void EmitFetchFailedIfAny_AfterRecovery_MarksRecoveredAndDemotesToInfo()
|
||||
{
|
||||
using var rig = new EmitterRig();
|
||||
// Startup fetch failed (network link not up yet), then the post-registration
|
||||
// recovery fetched ConfigVersion 39. The event must keep the original failure
|
||||
// details AND mark the heal.
|
||||
rig.RemoteConfig.SetFakeOutcome(
|
||||
RemoteConfigFetchOutcome.UsedDefaults,
|
||||
configVersion: 39, // CurrentConfig now holds the recovered config
|
||||
attempts: 4,
|
||||
failureType: "HttpRequestException",
|
||||
failureMessage: "simulated dead network link",
|
||||
recovered: true);
|
||||
|
||||
LifecycleEmitters.EmitRemoteConfigFetchFailedIfAny(
|
||||
rig.Post, rig.AgentConfig, rig.RemoteConfig, rig.Logger);
|
||||
|
||||
var data = AssertSingleEmit(rig.Sink, expectedEventType: "remote_config_fetch_failed");
|
||||
Assert.Equal("UsedDefaults", data["outcome"]); // startup failure record intact
|
||||
Assert.Equal(4, data["attempts"]);
|
||||
Assert.Equal(true, data["recovered"]);
|
||||
Assert.Equal(39, data["recoveredConfigVersion"]);
|
||||
Assert.Equal(nameof(EventSeverity.Info), rig.Sink.Posted[0].Payload!["severity"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitFetchFailedIfAny_WithoutRecovery_StaysWarning()
|
||||
{
|
||||
using var rig = new EmitterRig();
|
||||
rig.RemoteConfig.SetFakeOutcome(
|
||||
RemoteConfigFetchOutcome.UsedDefaults,
|
||||
configVersion: 0,
|
||||
attempts: 4,
|
||||
failureType: "HttpRequestException",
|
||||
failureMessage: "still down");
|
||||
|
||||
LifecycleEmitters.EmitRemoteConfigFetchFailedIfAny(
|
||||
rig.Post, rig.AgentConfig, rig.RemoteConfig, rig.Logger);
|
||||
|
||||
var data = AssertSingleEmit(rig.Sink, expectedEventType: "remote_config_fetch_failed");
|
||||
Assert.False(data.ContainsKey("recovered"));
|
||||
Assert.False(data.ContainsKey("recoveredConfigVersion"));
|
||||
Assert.Equal(nameof(EventSeverity.Warning), rig.Sink.Posted[0].Payload!["severity"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitAgentStarted_AfterRecovery_FlagsLiveConfig()
|
||||
{
|
||||
using var rig = new EmitterRig();
|
||||
rig.RemoteConfig.SetFakeOutcome(
|
||||
RemoteConfigFetchOutcome.UsedDefaults,
|
||||
configVersion: 39,
|
||||
recovered: true);
|
||||
|
||||
LifecycleEmitters.EmitAgentStarted(
|
||||
rig.Post, rig.AgentConfig, rig.PreviousExit, "2.0.806", rig.RemoteConfig, rig.Logger);
|
||||
|
||||
var data = AssertSingleEmit(rig.Sink, expectedEventType: "agent_started");
|
||||
Assert.Equal(39, data["configVersion"]);
|
||||
Assert.Equal(true, data["remoteConfigFetched"]); // running on a live-fetched config
|
||||
Assert.Equal("UsedDefaults", data["remoteConfigOutcome"]); // startup outcome stays honest
|
||||
Assert.Equal(true, data["remoteConfigRecovered"]);
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private static Dictionary<string, object> AssertSingleEmit(
|
||||
@@ -258,8 +325,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
int attempts = 1,
|
||||
string? failureType = null,
|
||||
string? failureMessage = null,
|
||||
int? authStatusCode = null)
|
||||
int? authStatusCode = null,
|
||||
bool recovered = false)
|
||||
{
|
||||
typeof(RemoteConfigService).GetProperty(nameof(RecoveredAfterFetchFailure))?.SetValue(this, recovered);
|
||||
// CurrentConfig is set via the protected SetConfig path — easiest:
|
||||
// a real FetchConfigAsync replacement that just stashes a canned config.
|
||||
typeof(RemoteConfigService).GetMethod("SetConfig",
|
||||
|
||||
+146
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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"]);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,11 @@
|
||||
carries the V2 assembly namespace so GatherRuleGuards locates it under
|
||||
"AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json". -->
|
||||
<EmbeddedResource Include="..\..\..\rules\guardrails.json" LogicalName="AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json" />
|
||||
<!-- Built-in fallback rule catalogs (BuiltInConfigCatalog): the SAME CI-combined
|
||||
rules/dist artifacts the backend embeds and serves. Snapshotted per agent build so
|
||||
the no-config/no-cache fallback still runs full IME tracking instead of nothing. -->
|
||||
<EmbeddedResource Include="..\..\..\rules\dist\ime-log-patterns.json" LogicalName="AutopilotMonitor.Agent.V2.Core.Resources.ime-log-patterns.json" />
|
||||
<EmbeddedResource Include="..\..\..\rules\dist\gather-rules.json" LogicalName="AutopilotMonitor.Agent.V2.Core.Resources.gather-rules.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Compiled-in fallback copies of the global rule catalogs, embedded from the same
|
||||
/// CI-generated <c>rules/dist</c> artifacts the backend seeds and serves — single
|
||||
/// source by construction: each agent release snapshots the catalog state of its own
|
||||
/// build, so the offline fallback can never drift further than the running agent
|
||||
/// version itself (no manually maintained default to forget).
|
||||
/// <para>
|
||||
/// Consumed only by <see cref="RemoteConfigService"/>'s built-in default config,
|
||||
/// i.e. when a session has NO live config and NO cache. Before this existed, that
|
||||
/// path shipped an empty <c>ImeLogPatterns</c> list, which left the entire IME
|
||||
/// tracking pipeline (app installs, platform/remediation scripts, health-script
|
||||
/// compliance, IME ESP-phase signals) dead for the whole session — the core of
|
||||
/// enrollment monitoring. A live fetch (startup, post-registration recovery or
|
||||
/// rotate_config) always wins over this catalog.
|
||||
/// </para>
|
||||
/// Fail-soft: any load/parse error returns an empty list (the pre-catalog behaviour).
|
||||
/// </summary>
|
||||
internal static class BuiltInConfigCatalog
|
||||
{
|
||||
/// <summary>Enabled built-in IME log patterns from the embedded catalog.</summary>
|
||||
public static List<ImeLogPattern> GetEnabledImeLogPatterns(AgentLogger logger)
|
||||
=> LoadEnabled<ImeLogPattern>("ime-log-patterns.json", p => p.Enabled, logger);
|
||||
|
||||
/// <summary>Enabled built-in gather rules from the embedded catalog.</summary>
|
||||
public static List<GatherRule> GetEnabledGatherRules(AgentLogger logger)
|
||||
=> LoadEnabled<GatherRule>("gather-rules.json", r => r.Enabled, logger);
|
||||
|
||||
private static List<T> LoadEnabled<T>(string name, Func<T, bool> isEnabled, AgentLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = typeof(BuiltInConfigCatalog).Assembly;
|
||||
using (var stream = assembly.GetManifestResourceStream(
|
||||
"AutopilotMonitor.Agent.V2.Core.Resources." + name))
|
||||
{
|
||||
if (stream == null)
|
||||
{
|
||||
logger?.Warning($"Built-in config catalog resource '{name}' not found — fallback runs without it.");
|
||||
return new List<T>();
|
||||
}
|
||||
|
||||
using (var reader = new StreamReader(stream))
|
||||
{
|
||||
var wrapper = JsonConvert.DeserializeObject<RulesFile<T>>(reader.ReadToEnd());
|
||||
var all = wrapper?.Rules ?? new List<T>();
|
||||
return all.Where(isEnabled).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Warning($"Built-in config catalog '{name}' failed to load ({ex.Message}) — fallback runs without it.");
|
||||
return new List<T>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shape of the CI-combined catalog files: <c>{ "rules": [...] }</c>.</summary>
|
||||
private sealed class RulesFile<T>
|
||||
{
|
||||
[JsonProperty("rules")]
|
||||
public List<T> Rules { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
TimeSpan.FromSeconds(60),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound for the pre-fetch network-link wait on the initial startup fetch.
|
||||
/// Deliberately longer than the registration gate's 15s: the fetch runs FIRST after a
|
||||
/// boot-time relaunch, when Wi-Fi may still be associating (802.1X, OOBE), and a fetch
|
||||
/// burned into a dead link strands the whole session on built-in defaults — tenant aebdce78
|
||||
/// audit 2026-08-24: ~50% of sessions lost their tenant config exactly this way.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan InitialFetchNetworkLinkWaitMax = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current configuration (thread-safe)
|
||||
/// </summary>
|
||||
@@ -76,6 +85,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// <summary>HTTP status for auth failures (401/403), null otherwise.</summary>
|
||||
public int? LastFetchAuthStatusCode { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// True once <see cref="TryRecoverConfigAsync"/> replaced a failed startup fetch with a
|
||||
/// live config. The <c>LastFetch*</c> properties intentionally keep describing the failed
|
||||
/// startup fetch so the wire event can report both the original failure and the recovery.
|
||||
/// </summary>
|
||||
public bool RecoveredAfterFetchFailure { get; private set; }
|
||||
|
||||
public RemoteConfigService(BackendApiClient apiClient, string tenantId, AgentLogger logger, EmergencyReporter emergencyReporter = null, DistressReporter distressReporter = null, AuthFailureTracker authFailureTracker = null)
|
||||
{
|
||||
_apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
|
||||
@@ -116,6 +132,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
LastFetchFailureMessage = null;
|
||||
LastFetchAuthStatusCode = null;
|
||||
|
||||
// Initial startup fetch only: give a still-associating NIC a chance to come up
|
||||
// before the first attempt. Without this the retry budget (10/30/60s) is burned
|
||||
// into a provably dead link — the agent starts at boot, Wi-Fi associates minutes
|
||||
// later, and the session runs on defaults although the backend was never the
|
||||
// problem. The rotate_config path (retryOnTransientErrors:false) runs mid-session
|
||||
// on a proven link and must stay latency-bounded, so it skips the gate.
|
||||
if (retryOnTransientErrors)
|
||||
{
|
||||
try { await WaitForNetworkLinkAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.Debug($"Pre-fetch network wait failed ({ex.Message}) — fetching anyway."); }
|
||||
}
|
||||
|
||||
var maxAttempts = retryOnTransientErrors ? InitialFetchRetryBackoff.Length + 1 : 1;
|
||||
Exception lastException = null;
|
||||
|
||||
@@ -203,14 +231,63 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fall back to defaults
|
||||
_logger.Info("No cached config available, using defaults (all optional collectors disabled)");
|
||||
// Fall back to built-in defaults (collector/analyzer defaults from the shared
|
||||
// model initializers + the compiled-in rule catalogs).
|
||||
var defaults = CreateDefaultConfig();
|
||||
_logger.Info($"No cached config available, using built-in defaults (ConfigVersion=0, " +
|
||||
$"{defaults.ImeLogPatterns?.Count ?? 0} built-in IME patterns, " +
|
||||
$"{defaults.GatherRules?.Count ?? 0} built-in gather rules)");
|
||||
SetConfig(defaults);
|
||||
LastFetchOutcome = RemoteConfigFetchOutcome.UsedDefaults;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-attempt recovery fetch for a session whose startup fetch failed. Called right
|
||||
/// after session registration succeeded — registration proves the backend is reachable,
|
||||
/// so the config that Phase 4 could not get is one cheap call away instead of the whole
|
||||
/// session being stranded on cache/defaults (only a manual <c>rotate_config</c> action
|
||||
/// could rescue it before this existed).
|
||||
/// <para>
|
||||
/// Unlike <see cref="FetchConfigAsync"/> this does NOT reset the <c>LastFetch*</c>
|
||||
/// observability state — those keep describing the failed startup fetch so
|
||||
/// <c>remote_config_fetch_failed</c> can report the original failure AND the recovery.
|
||||
/// Returns the recovered config, or null when there is nothing to recover (startup fetch
|
||||
/// succeeded / never ran) or the recovery attempt itself failed. Never throws.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<AgentConfigResponse> TryRecoverConfigAsync()
|
||||
{
|
||||
if (LastFetchOutcome != RemoteConfigFetchOutcome.FromCache &&
|
||||
LastFetchOutcome != RemoteConfigFetchOutcome.UsedDefaults)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.Info("Retrying remote config fetch after successful session registration...");
|
||||
var config = await CallBackendAsync().ConfigureAwait(false);
|
||||
if (config == null)
|
||||
{
|
||||
_logger.Warning("Config recovery fetch returned empty response — keeping fallback config.");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.Info($"Remote config recovered: ConfigVersion={config.ConfigVersion}, " +
|
||||
$"GatherRules={config.GatherRules?.Count ?? 0}");
|
||||
LogCollectorSettings(config.Collectors);
|
||||
SetConfig(config);
|
||||
CacheConfig(config);
|
||||
_authFailureTracker?.RecordSuccess();
|
||||
RecoveredAfterFetchFailure = true;
|
||||
return config;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Config recovery fetch failed ({ex.Message}) — keeping fallback config.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetConfig(AgentConfigResponse config)
|
||||
{
|
||||
lock (_configLock)
|
||||
@@ -319,6 +396,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
|
||||
private AgentConfigResponse CreateDefaultConfig()
|
||||
{
|
||||
// Everything not set here falls to the shared AgentConfigResponse property
|
||||
// initializers — the same values the backend uses as its `??` fallbacks, so the
|
||||
// scalar knobs cannot drift. The rule catalogs are the one thing initializers
|
||||
// cannot supply: without them the whole IME tracking pipeline is dead, so they
|
||||
// come from the compiled-in snapshot of the served catalogs (BuiltInConfigCatalog).
|
||||
return new AgentConfigResponse
|
||||
{
|
||||
ConfigVersion = 0,
|
||||
@@ -330,8 +412,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
EnableGatherRuleDebugLog = false,
|
||||
EnableEspContinueAnywayObservation = false,
|
||||
Collectors = CollectorConfiguration.CreateDefault(),
|
||||
GatherRules = new System.Collections.Generic.List<GatherRule>(),
|
||||
ImeLogPatterns = new System.Collections.Generic.List<ImeLogPattern>()
|
||||
GatherRules = BuiltInConfigCatalog.GetEnabledGatherRules(_logger),
|
||||
ImeLogPatterns = BuiltInConfigCatalog.GetEnabledImeLogPatterns(_logger)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -358,6 +440,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
protected virtual Task DelayBetweenAttemptsAsync(TimeSpan delay)
|
||||
=> Task.Delay(delay);
|
||||
|
||||
/// <summary>
|
||||
/// Awaits the pre-fetch network-link gate (initial startup fetch only). Production
|
||||
/// polls the link-level signal via <see cref="NetworkLinkWait"/> bounded by
|
||||
/// <see cref="InitialFetchNetworkLinkWaitMax"/>; tests subclass to record the call
|
||||
/// and return immediately.
|
||||
/// </summary>
|
||||
protected virtual Task WaitForNetworkLinkAsync()
|
||||
=> NetworkLinkWait.WaitAsync(_logger, InitialFetchNetworkLinkWaitMax, "Remote config fetch");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Nothing to dispose
|
||||
|
||||
+60
@@ -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)
|
||||
|
||||
+6
@@ -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);
|
||||
|
||||
+29
-2
@@ -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(),
|
||||
|
||||
+7
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// Boot-time NIC grace shared by every first-contact backend call (remote-config fetch,
|
||||
/// session registration, emergency-break reporter pattern): polls the cheap link-level
|
||||
/// signal once per second for at most <paramref name="maxWait"/>. It cannot prove backend
|
||||
/// reachability — the callers' retry loops handle that — it only keeps retry budgets from
|
||||
/// being burned into a link that is provably still down (BootTrigger relaunch after a
|
||||
/// mid-enrollment reboot, Wi-Fi still associating; tenant aebdce78 audits 2026-08-23/24).
|
||||
/// Free on the normal path — a live link returns immediately. Probe errors end the wait,
|
||||
/// never the caller's operation.
|
||||
/// </summary>
|
||||
internal static class NetworkLinkWait
|
||||
{
|
||||
internal static async Task WaitAsync(AgentLogger logger, TimeSpan maxWait, string context)
|
||||
{
|
||||
if (NetworkInterface.GetIsNetworkAvailable()) return;
|
||||
|
||||
logger?.Info($"{context}: no network link yet — waiting up to {maxWait.TotalSeconds:F0}s.");
|
||||
var deadline = DateTime.UtcNow + maxWait;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(1000).ConfigureAwait(false);
|
||||
if (NetworkInterface.GetIsNetworkAvailable())
|
||||
{
|
||||
logger?.Info($"{context}: network link is up.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
logger?.Warning($"{context}: still no network link after {maxWait.TotalSeconds:F0}s — attempting anyway.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
@@ -165,29 +164,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
=> Task.Delay(((int)Math.Pow(2, attempt)) * 1000);
|
||||
|
||||
/// <summary>
|
||||
/// Boot-time NIC grace (same pattern as the emergency-break reporter): polls the cheap
|
||||
/// link-level signal once per second for at most <see cref="NetworkLinkWaitMax"/>. It
|
||||
/// cannot prove backend reachability — the retry loop handles that — it only keeps the
|
||||
/// first attempts from being burned into a link that is provably still down. Probe
|
||||
/// Boot-time NIC grace before the first registration attempt — shared polling loop in
|
||||
/// <see cref="NetworkLinkWait"/>, bounded by <see cref="NetworkLinkWaitMax"/>. Probe
|
||||
/// errors end the wait, never the registration.
|
||||
/// </summary>
|
||||
internal static async Task WaitForNetworkLinkAsync(AgentLogger logger)
|
||||
{
|
||||
if (NetworkInterface.GetIsNetworkAvailable()) return;
|
||||
|
||||
logger?.Info($"Session registration: no network link yet — waiting up to {NetworkLinkWaitMax.TotalSeconds:F0}s.");
|
||||
var deadline = DateTime.UtcNow + NetworkLinkWaitMax;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(1000).ConfigureAwait(false);
|
||||
if (NetworkInterface.GetIsNetworkAvailable())
|
||||
{
|
||||
logger?.Info("Session registration: network link is up.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
logger?.Warning($"Session registration: still no network link after {NetworkLinkWaitMax.TotalSeconds:F0}s — attempting anyway.");
|
||||
}
|
||||
internal static Task WaitForNetworkLinkAsync(AgentLogger logger)
|
||||
=> NetworkLinkWait.WaitAsync(logger, NetworkLinkWaitMax, "Session registration");
|
||||
|
||||
private static SessionRegistration BuildRegistration(
|
||||
AgentConfiguration agentConfig,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -274,6 +274,15 @@ namespace AutopilotMonitor.Agent.V2
|
||||
{
|
||||
return registration.ExitCode;
|
||||
}
|
||||
|
||||
// Phase 6.5 (config self-heal): when the Phase 4 fetch ran before the network link
|
||||
// was up, registration has just proven the backend reachable — retry the fetch once
|
||||
// so the session runs on the live tenant config instead of cache/defaults.
|
||||
// See AgentRuntimeConfig.TryRecoverAfterRegistration for what is deliberately
|
||||
// NOT re-run here (kill-signal check, endpoint migration).
|
||||
Runtime.AgentRuntimeConfig.TryRecoverAfterRegistration(
|
||||
bootstrap.AgentConfig, auth, runtimeConfig, GetAgentVersion(), consoleMode, logger);
|
||||
|
||||
// Phase 7+8 (orchestrator + shutdown lifecycle + onIngressReady wiring +
|
||||
// initial signal posts + analyzer/probes startup + shutdown.Wait + finally)
|
||||
// is encapsulated in AgentRuntimeHost. See Runtime/AgentRuntimeHost.cs.
|
||||
|
||||
@@ -69,6 +69,63 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
var configMergeResult = ApplyFetchedConfig(agentConfig, remoteConfig, auth, agentVersion, consoleMode, logger);
|
||||
|
||||
return new RuntimeConfigBundle(remoteConfigService, remoteConfig, configMergeResult, pendingMigrationTarget: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 — config self-heal. When the Phase 4 fetch ran before the network link was
|
||||
/// up (agent relaunched at boot, Wi-Fi still associating — tenant aebdce78 audit 2026-08-24:
|
||||
/// ~50% of sessions ran on ConfigVersion=0 exactly this way), a successful session
|
||||
/// registration has just proven the backend reachable. Retry the fetch once and apply
|
||||
/// the live tenant config instead of stranding the whole session on cache/defaults.
|
||||
/// <para>
|
||||
/// Deliberately NOT re-run here: the kill-signal check (post-registration kills arrive
|
||||
/// via the telemetry-channel control signals on every upload response) and endpoint
|
||||
/// migration (registration already succeeded against the current base URL; a migration
|
||||
/// signal is honoured at the next agent start). Never throws — recovery is strictly
|
||||
/// best-effort on top of an already-degraded-but-working fallback.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static void TryRecoverAfterRegistration(
|
||||
AgentConfiguration agentConfig,
|
||||
BackendAuthBundle auth,
|
||||
RuntimeConfigBundle bundle,
|
||||
string agentVersion,
|
||||
bool consoleMode,
|
||||
AgentLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var service = bundle?.RemoteConfigService;
|
||||
if (service == null) return;
|
||||
|
||||
var recovered = service.TryRecoverConfigAsync().GetAwaiter().GetResult();
|
||||
if (recovered == null) return;
|
||||
|
||||
var mergeResult = ApplyFetchedConfig(agentConfig, recovered, auth, agentVersion, consoleMode, logger);
|
||||
bundle.ApplyRecovered(recovered, mergeResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warning($"Post-registration config recovery failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a live-fetched config to the runtime: merge, tracker ceilings, log level,
|
||||
/// integrity SHA + check, bootstrap-config cleanup. Shared by the Phase 4 startup path
|
||||
/// and the post-registration recovery path so both apply identical semantics.
|
||||
/// </summary>
|
||||
private static RemoteConfigMergeResult ApplyFetchedConfig(
|
||||
AgentConfiguration agentConfig,
|
||||
AgentConfigResponse remoteConfig,
|
||||
BackendAuthBundle auth,
|
||||
string agentVersion,
|
||||
bool consoleMode,
|
||||
AgentLogger logger)
|
||||
{
|
||||
// Project remote tenant-controlled knobs onto the runtime AgentConfiguration so that
|
||||
// downstream consumers (CleanupService, SummaryDialogLauncher, StartupEnvironmentProbes,
|
||||
// DiagnosticsPackageService, EnrollmentTerminationHandler, the logger, the watchdog)
|
||||
@@ -91,7 +148,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
|
||||
TryCleanupBootstrapConfig(agentConfig, agentVersion, logger);
|
||||
|
||||
return new RuntimeConfigBundle(remoteConfigService, remoteConfig, configMergeResult, pendingMigrationTarget: null);
|
||||
return configMergeResult;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -175,10 +232,21 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
internal sealed class RuntimeConfigBundle
|
||||
{
|
||||
public RemoteConfigService RemoteConfigService { get; }
|
||||
public AgentConfigResponse RemoteConfig { get; }
|
||||
public RemoteConfigMergeResult MergeResult { get; }
|
||||
public AgentConfigResponse RemoteConfig { get; private set; }
|
||||
public RemoteConfigMergeResult MergeResult { get; private set; }
|
||||
public string PendingMigrationTarget { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Swaps in the config recovered by <see cref="AgentRuntimeConfig.TryRecoverAfterRegistration"/>
|
||||
/// so Phase 7+8 (orchestrator, collectors, gather rules, IME patterns, the
|
||||
/// unrestricted-mode audit) run on the live tenant config instead of the fallback.
|
||||
/// </summary>
|
||||
public void ApplyRecovered(AgentConfigResponse recoveredConfig, RemoteConfigMergeResult mergeResult)
|
||||
{
|
||||
RemoteConfig = recoveredConfig;
|
||||
MergeResult = mergeResult;
|
||||
}
|
||||
|
||||
public RuntimeConfigBundle(
|
||||
RemoteConfigService remoteConfigService,
|
||||
AgentConfigResponse remoteConfig,
|
||||
|
||||
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// prior to this, ConfigVersion=0 was only deducible by inferring from
|
||||
// the absence of tenant-controlled knobs in the DataJson.
|
||||
var outcome = remoteConfigService?.LastFetchOutcome ?? RemoteConfigFetchOutcome.NotAttempted;
|
||||
var recovered = remoteConfigService?.RecoveredAfterFetchFailure == true;
|
||||
var configVersion = remoteConfigService?.CurrentConfig?.ConfigVersion ?? 0;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
@@ -66,8 +67,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{ "diagnosticsUploadMode", agentConfig.DiagnosticsUploadMode ?? "Off" },
|
||||
{ "previousExitType", previousExit?.ExitType ?? "unknown" },
|
||||
{ "unrestrictedMode", agentConfig.UnrestrictedMode },
|
||||
// remoteConfigFetched answers "is the agent running on a live-fetched
|
||||
// config" — true also when the post-registration recovery replaced a
|
||||
// failed startup fetch (remoteConfigOutcome then still names the startup
|
||||
// failure; remoteConfigRecovered marks the heal).
|
||||
{ "configVersion", configVersion },
|
||||
{ "remoteConfigFetched", outcome == RemoteConfigFetchOutcome.Succeeded },
|
||||
{ "remoteConfigFetched", outcome == RemoteConfigFetchOutcome.Succeeded || recovered },
|
||||
{ "remoteConfigOutcome", outcome.ToString() },
|
||||
// WinRT OOBE state sampled at this start. Together with previousExitType
|
||||
// this makes late starts provable: "completed" on a first_run means OOBE
|
||||
@@ -75,6 +80,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{ "oobeStateAtAgentStart", OobeStateReader.Read() },
|
||||
};
|
||||
|
||||
if (recovered)
|
||||
data["remoteConfigRecovered"] = true;
|
||||
|
||||
if (!string.IsNullOrEmpty(previousExit?.CrashExceptionType))
|
||||
data["previousCrashException"] = previousExit.CrashExceptionType;
|
||||
|
||||
@@ -135,12 +143,25 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
? $"Remote config fetch failed after {remoteConfigService.LastFetchAttempts} attempt(s); using cached config."
|
||||
: $"Remote config fetch failed after {remoteConfigService.LastFetchAttempts} attempt(s); running with built-in defaults (ConfigVersion=0).";
|
||||
|
||||
// Post-registration recovery (Phase 6.5) replaced the fallback with a live
|
||||
// fetch before this emitter ran: keep the failure details (the network-late
|
||||
// condition is worth surfacing) but mark the heal and demote to Info — the
|
||||
// session is NOT degraded.
|
||||
var recovered = remoteConfigService.RecoveredAfterFetchFailure;
|
||||
if (recovered)
|
||||
{
|
||||
var recoveredVersion = remoteConfigService.CurrentConfig?.ConfigVersion ?? 0;
|
||||
data["recovered"] = true;
|
||||
data["recoveredConfigVersion"] = recoveredVersion;
|
||||
msg += $" Recovered after session registration (ConfigVersion={recoveredVersion}).";
|
||||
}
|
||||
|
||||
post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = agentConfig.SessionId,
|
||||
TenantId = agentConfig.TenantId,
|
||||
EventType = SharedConstants.EventTypes.RemoteConfigFetchFailed,
|
||||
Severity = EventSeverity.Warning,
|
||||
Severity = recovered ? EventSeverity.Info : EventSeverity.Warning,
|
||||
Source = "Agent",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = msg,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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)
|
||||
|
||||
+6
-2
@@ -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)
|
||||
|
||||
+6
-2
@@ -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
@@ -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: {
|
||||
|
||||
+8
@@ -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
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user