mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| beac51720f | |||
| 32268ba245 | |||
| c91d8d4501 | |||
| bf6850dedc | |||
| 35a3ed0cfb | |||
| ea6a375c4b | |||
| fb79c7711c |
@@ -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)
|
||||
@@ -85,12 +85,22 @@ lives in `CertTenantBinding.Evaluate`; the outcome codes (`Match`, `Mismatch`, `
|
||||
`WouldRejectUnderEnforcement` records what stage 2 *would* have done, so the telemetry can be read
|
||||
without re-deriving the rule at query time.
|
||||
|
||||
Volume control: the outcomes that inform the decision (mismatch, missing, undecodable) are always
|
||||
logged, because they are expected to be rare and each is individually interesting. The bulk `Match`
|
||||
case is logged only when the certificate was freshly validated rather than served from the 5-minute
|
||||
thumbprint cache (`CertificateValidationResult.FromCache`). Both numerator and denominator are
|
||||
therefore counted per certificate-window rather than per request, which keeps the ratio meaningful
|
||||
while cutting the volume by roughly the request-per-device factor.
|
||||
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
|
||||
@@ -100,9 +110,69 @@ the extension: skip with a warning, or reject. Grep marker for every stage-2 sit
|
||||
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
|
||||
|
||||
Read the shadow telemetry:
|
||||
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
|
||||
@@ -113,7 +183,26 @@ traces
|
||||
```
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+11
@@ -1,7 +1,18 @@
|
||||
# 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
|
||||
|
||||
+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.
|
||||
|
||||
+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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+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"]);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+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; }
|
||||
|
||||
@@ -56,6 +56,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
{
|
||||
private const string SourceLabel = "ImeLogTracker";
|
||||
|
||||
/// <summary>Timeline label: IME wrote its "Completed user session" line and no required user-scope app was pending.</summary>
|
||||
internal const string EvidenceUserSessionCompleted = "user_session_completed";
|
||||
|
||||
/// <summary>Timeline label: IME reported zero targeted apps for the user session, so it never wrote a
|
||||
/// "Completed user session" line at all. The user phase had nothing to enforce.</summary>
|
||||
internal const string EvidenceZeroUserApps = "zero_user_apps_enumerated";
|
||||
|
||||
private readonly ImeLogTracker _tracker;
|
||||
private readonly ISignalIngressSink _ingress;
|
||||
private readonly IClock _clock;
|
||||
@@ -591,9 +598,24 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
|
||||
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
|
||||
|
||||
// Which observation carried the completion. The zero-app variant matters to whoever
|
||||
// reads the timeline later: the verdict then rests on IME reporting that this user
|
||||
// had no targeted apps at all, not on watching enforcement finish.
|
||||
var zeroApps = _tracker.UserSessionZeroAppsObserved;
|
||||
var evidenceLabel = zeroApps ? EvidenceZeroUserApps : EvidenceUserSessionCompleted;
|
||||
var summary = zeroApps
|
||||
? "IME enumerated the user session with zero targeted apps"
|
||||
: "IME user session completed (no required user-scope apps pending)";
|
||||
var message = zeroApps
|
||||
? "IME enumerated the user session with zero targeted apps - no user-phase enforcement to wait for"
|
||||
: "IME user session completed";
|
||||
|
||||
var derivationInputs = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["detectionSource"] = "IME log pattern (UserSessionCompleted)",
|
||||
["detectionSource"] = zeroApps
|
||||
? "IME log pattern (UserSessionZeroApps)"
|
||||
: "IME log pattern (UserSessionCompleted)",
|
||||
["evidence"] = evidenceLabel,
|
||||
};
|
||||
TagDerivedTimestamp(derivationInputs, derivedFromClock, rawSourceTs);
|
||||
|
||||
@@ -604,12 +626,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
evidence: new Evidence(
|
||||
kind: EvidenceKind.Derived,
|
||||
identifier: "ime-log-tracker-v1",
|
||||
summary: "IME user session completed (no required user-scope apps pending)",
|
||||
summary: summary,
|
||||
derivationInputs: derivationInputs));
|
||||
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["detectedAt"] = now.ToString("o"),
|
||||
["evidence"] = evidenceLabel,
|
||||
};
|
||||
var patternId = _tracker.LastMatchedPatternId;
|
||||
if (!string.IsNullOrEmpty(patternId))
|
||||
@@ -619,12 +642,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.ImeUserSessionCompleted,
|
||||
source: SourceLabel,
|
||||
message: "IME user session completed",
|
||||
message: message,
|
||||
data: data,
|
||||
occurredAtUtc: now);
|
||||
|
||||
// PR3-D4: terminal-ish lifecycle marker — INFO so it's visible at the default level.
|
||||
_logger?.Info($"ImeAdapter: user session completed -> posting ImeUserSessionCompleted (patternId={patternId ?? "(none)"})");
|
||||
_logger?.Info($"ImeAdapter: user session completed ({evidenceLabel}) -> posting ImeUserSessionCompleted (patternId={patternId ?? "(none)"})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Sessions 81daa77f / 75d6ae8e (2026-08-25) — the 30-min <c>advisory_completion</c> window
|
||||
/// overran a completion gate that was deliberately holding the session. The RealmJoin gate
|
||||
/// blocks completion until phase 110 and carries its own bounded resolution (60 min from
|
||||
/// detection, re-armed on deployment activity, hard-capped at 4 h), so on every device whose
|
||||
/// first deployment outlives 30 minutes the enrollment was failed while the engine own
|
||||
/// <c>completion_waiting</c> still read "waiting on: realmjoin_resolution". 75d6ae8e
|
||||
/// installed 25 RealmJoin packages successfully inside the window, the last one 2 min before
|
||||
/// the failure; the customer tenant hit this on 10 of 30 enrollments in one week.
|
||||
/// <para>
|
||||
/// The guard re-arms while the gate holds. Convergence comes from the gate itself: Resolved,
|
||||
/// FirstDeploymentIncomplete and Timeout all set <c>RealmJoinFacts.Outcome</c>, which opens
|
||||
/// <c>RealmJoinGateOpen</c> and stops the guard applying.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AdvisoryCompletionGateHoldingTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 8, 25, 6, 10, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null)
|
||||
{
|
||||
return new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
|
||||
payload: payload);
|
||||
}
|
||||
|
||||
private static DecisionSignal DeadlineFired(long ordinal, DateTime occurredAtUtc, string deadlineName) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, occurredAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = deadlineName });
|
||||
|
||||
private static ActiveDeadline? FindDeadline(DecisionState state, string name) =>
|
||||
state.Deadlines.FirstOrDefault(d => d.Name == name);
|
||||
|
||||
private static DecisionEffect SingleTimelineEffect(DecisionStep step, string eventType) =>
|
||||
step.Effects.Single(e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == eventType);
|
||||
|
||||
/// <summary>
|
||||
/// Replays the shape both field sessions shared: Classic user-driven, full ESP, Hello
|
||||
/// policy enabled, AccountSetup entered, then a guard-blocked post-AccountSetup ESP exit
|
||||
/// (Shell-Core 62407, "Exiting page normally", errorCode 0) that arms the 30-min window.
|
||||
/// The ESP registry froze at "1 of 5" and the device had zero user-targeted Intune apps,
|
||||
/// so no arm of ShouldTransitionToAwaitingHello could ever open.
|
||||
/// </summary>
|
||||
private static DecisionState SetupEspExitDeadEnd(DecisionEngine engine)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-81daa77f", "tenant-81daa77f", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.SkipUserEsp] = "false",
|
||||
[SignalPayloadKeys.SkipDeviceEsp] = "false",
|
||||
[SignalPayloadKeys.EspAllowContinueAnyway] = "false",
|
||||
})).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2.5),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
|
||||
// Guard-blocked post-AccountSetup exit — arms advisory_completion at exit + 30 min.
|
||||
state = engine.Reduce(state, MakeSignal(30, DecisionSignalKind.EspExiting, T0.AddMinutes(3))).NewState;
|
||||
Assert.NotNull(FindDeadline(state, DeadlineNames.AdvisoryCompletion));
|
||||
Assert.Null(state.Outcome);
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>Desktop (DAD-validated real user), RealmJoin detected, Hello provisioned.</summary>
|
||||
private static DecisionState AddDesktopRealmJoinAndHello(DecisionEngine engine, DecisionState state)
|
||||
{
|
||||
state = engine.Reduce(state, MakeSignal(40, DecisionSignalKind.DesktopArrived, T0.AddMinutes(3.5))).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
50, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(6),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
60, DecisionSignalKind.HelloResolved, T0.AddMinutes(14),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "Success" })).NewState;
|
||||
return state;
|
||||
}
|
||||
|
||||
// ==================================================== the gate-holding guard ====
|
||||
|
||||
[Fact]
|
||||
public void DeadlineFired_WhileRealmJoinGateHolds_RearmsInsteadOfFailing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupEspExitDeadEnd(engine);
|
||||
state = AddDesktopRealmJoinAndHello(engine, state);
|
||||
|
||||
// Preconditions: the gate is closed and owns an armed deadline of its own.
|
||||
Assert.False(DecisionEngine.RealmJoinGateOpen(state));
|
||||
Assert.NotNull(FindDeadline(state, DeadlineNames.RealmJoinTimeout));
|
||||
|
||||
var fireAt = T0.AddMinutes(33);
|
||||
var step = engine.Reduce(state, DeadlineFired(70, fireAt, DeadlineNames.AdvisoryCompletion));
|
||||
|
||||
Assert.True(step.Transition.Taken);
|
||||
Assert.Null(step.NewState.Outcome);
|
||||
Assert.NotEqual(SessionStage.Failed, step.NewState.Stage);
|
||||
|
||||
var rearmed = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(rearmed);
|
||||
Assert.Equal(fireAt.AddMinutes(30), rearmed!.DueAtUtc);
|
||||
|
||||
var schedule = step.Effects.Single(e => e.Kind == DecisionEffectKind.ScheduleDeadline);
|
||||
Assert.Equal(DeadlineNames.AdvisoryCompletion, schedule.Deadline!.Name);
|
||||
|
||||
var waiting = SingleTimelineEffect(step, "completion_waiting");
|
||||
Assert.Contains("CompletionGateHolding", waiting.Parameters!["trigger"]);
|
||||
Assert.Contains("realmjoin_resolution", waiting.Parameters!["missingPrerequisites"]);
|
||||
Assert.Equal(rearmed.DueAtUtc.ToString("o"), waiting.Parameters!["resolutionDeadlineDueAtUtc"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session81daa77f_RealmJoinResolvesAfterTheWindow_CompletesInsteadOfFailing()
|
||||
{
|
||||
// End-to-end proof against the real field shape: before this guard the session was
|
||||
// stamped Failed at exit+30 min with esp_exit_without_completion_evidence, 34 min
|
||||
// before the RealmJoin gate would have released it.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupEspExitDeadEnd(engine);
|
||||
state = AddDesktopRealmJoinAndHello(engine, state);
|
||||
|
||||
var fired = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(33), DeadlineNames.AdvisoryCompletion));
|
||||
Assert.Null(fired.NewState.Outcome);
|
||||
|
||||
// RealmJoin reaches phase 110 — the gate opens and the session completes.
|
||||
var resolved = engine.Reduce(fired.NewState, MakeSignal(
|
||||
80, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(50),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "110" }));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, resolved.NewState.Stage);
|
||||
Assert.Equal("Resolved", resolved.NewState.RealmJoinFacts.Outcome!.Value);
|
||||
|
||||
var grace = FindDeadline(resolved.NewState, DeadlineNames.FinalizingGrace);
|
||||
Assert.NotNull(grace);
|
||||
var completed = engine.Reduce(resolved.NewState,
|
||||
DeadlineFired(90, grace!.DueAtUtc, DeadlineNames.FinalizingGrace));
|
||||
Assert.Equal(SessionStage.Completed, completed.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentComplete, completed.NewState.Outcome);
|
||||
}
|
||||
|
||||
// ============================================================== convergence ====
|
||||
|
||||
[Fact]
|
||||
public void DeadlineFired_AfterTheGateOpened_ResolvesNormallyAgain()
|
||||
{
|
||||
// Convergence proof: the guard is not an escape hatch. Once RealmJoin timed out the
|
||||
// gate is open, the guard stops applying, and a session with no completion evidence
|
||||
// fails exactly as before.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupEspExitDeadEnd(engine);
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
50, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(6),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0" })).NewState;
|
||||
|
||||
var rjDeadline = FindDeadline(state, DeadlineNames.RealmJoinTimeout);
|
||||
Assert.NotNull(rjDeadline);
|
||||
state = engine.Reduce(state,
|
||||
DeadlineFired(60, rjDeadline!.DueAtUtc, DeadlineNames.RealmJoinTimeout)).NewState;
|
||||
|
||||
Assert.True(DecisionEngine.RealmJoinGateOpen(state));
|
||||
Assert.Null(FindDeadline(state, DeadlineNames.RealmJoinTimeout));
|
||||
|
||||
// No desktop, no Hello, no IME evidence — the genuine dead-end shape.
|
||||
var step = engine.Reduce(state,
|
||||
DeadlineFired(70, T0.AddMinutes(80), DeadlineNames.AdvisoryCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
|
||||
var failed = SingleTimelineEffect(step, "enrollment_failed");
|
||||
Assert.Equal("esp_exit_without_completion_evidence", failed.Parameters!["reason"]);
|
||||
}
|
||||
|
||||
// =========================================================== mutation proofs ====
|
||||
|
||||
[Fact]
|
||||
public void DeadlineFired_WithoutRealmJoin_StillFails()
|
||||
{
|
||||
// The guard must key on an actually-held gate, never on the absence of evidence.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupEspExitDeadEnd(engine);
|
||||
|
||||
var step = engine.Reduce(state,
|
||||
DeadlineFired(70, T0.AddMinutes(33), DeadlineNames.AdvisoryCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
|
||||
var failed = SingleTimelineEffect(step, "enrollment_failed");
|
||||
Assert.Equal("esp_exit_without_completion_evidence", failed.Parameters!["reason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeadlineFired_GateClosedButItsDeadlineNoLongerArmed_StillFails()
|
||||
{
|
||||
// A gate that can no longer resolve itself must not park the session forever. The
|
||||
// hand-built state drops only the RealmJoin deadline, leaving the gate closed.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupEspExitDeadEnd(engine);
|
||||
state = AddDesktopRealmJoinAndHello(engine, state);
|
||||
|
||||
var builder = state.ToBuilder();
|
||||
builder.CancelDeadline(DeadlineNames.RealmJoinTimeout);
|
||||
state = builder.Build();
|
||||
|
||||
Assert.False(DecisionEngine.RealmJoinGateOpen(state));
|
||||
Assert.Null(FindDeadline(state, DeadlineNames.RealmJoinTimeout));
|
||||
|
||||
var step = engine.Reduce(state,
|
||||
DeadlineFired(70, T0.AddMinutes(33), DeadlineNames.AdvisoryCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
@@ -172,7 +173,7 @@ public class BuiltInRulesTests
|
||||
"policiesDiscovered", "ignoreCompletedApp", "imeAgentVersion",
|
||||
"espTrackStatus", "updateName", "updateWin32AppState",
|
||||
"cancelStuckAndSetCurrent", "imeSessionChange", "imeImpersonation", "imeTokenFailure",
|
||||
"enrollmentCompleted", "updateDoTelemetry", "scriptStarted",
|
||||
"enrollmentCompleted", "userSessionZeroApps", "updateDoTelemetry", "scriptStarted",
|
||||
"scriptContext", "scriptExitCode", "scriptOutput", "scriptCompleted",
|
||||
"resetPlatformScriptContext",
|
||||
"healthScriptResult", "healthScriptDetectionResult",
|
||||
@@ -201,4 +202,38 @@ public class BuiltInRulesTests
|
||||
$"Pattern {pattern.PatternId} has invalid category '{pattern.Category}'");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sessions 81daa77f / 75d6ae8e (2026-08-25). IME writes this line and then returns before
|
||||
/// its "Completed user session" statement, so on a device with no user-targeted Win32 apps
|
||||
/// it is the ONLY evidence that user-phase enforcement finished. The exact wording is
|
||||
/// pinned here against the real IME line (verified against decompiled IME 1.104.102.0,
|
||||
/// ApplicationPoller and AppWorkloadAbstraction) because a silent drift would bring back
|
||||
/// the 30-min esp_exit_without_completion_evidence false positive with no test failing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuiltInImeLogPatterns_ZeroUserAppsPattern_MatchesTheRealImeLine()
|
||||
{
|
||||
var pattern = Assert.Single(
|
||||
BuiltInImeLogPatterns.GetAll(),
|
||||
p => p.PatternId == "IME-USER-SESSION-ZERO-APPS");
|
||||
|
||||
Assert.Equal("userSessionZeroApps", pattern.Action);
|
||||
Assert.Equal("always", pattern.Category);
|
||||
|
||||
var regex = new Regex(pattern.Pattern, RegexOptions.CultureInvariant);
|
||||
|
||||
var real = "[Win32App] Get 0 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020";
|
||||
var match = regex.Match(real);
|
||||
Assert.True(match.Success, "zero-app user session line must match");
|
||||
Assert.Equal("1", match.Groups["sessionId"].Value);
|
||||
Assert.Equal("4a1ba5db-44ed-4e69-8477-47ed88e76020", match.Groups["userId"].Value);
|
||||
|
||||
// Must not swallow a session that DOES have apps — that would fabricate completion
|
||||
// evidence while enforcement is still running.
|
||||
Assert.DoesNotMatch(regex,
|
||||
"[Win32App] Get 3 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
|
||||
Assert.DoesNotMatch(regex,
|
||||
"[Win32App] Get 10 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,23 +59,19 @@ public class CertTenantBindingTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCertificate_CachedResult_KeepsTenantIdAndFlagsCacheHit()
|
||||
public void ValidateCertificate_CachedResult_KeepsTenantId()
|
||||
{
|
||||
// FromCache is what samples the high-volume "Match" telemetry down to ~one line per cert
|
||||
// per cache window, so it has to survive the cache and not corrupt the cached entry.
|
||||
// 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);
|
||||
var third = CertificateValidator.ValidateCertificate(b64);
|
||||
|
||||
Assert.True(second.FromCache);
|
||||
Assert.True(second.IsValid);
|
||||
Assert.Equal(CertTenantIdStatus.Present, second.CertTenantIdStatus);
|
||||
Assert.Equal(SampleCertTenantId, second.CertTenantId);
|
||||
|
||||
// The cache-hit copy must not have mutated the shared cached instance.
|
||||
Assert.True(third.FromCache);
|
||||
Assert.Equal(SampleCertTenantId, third.CertTenantId);
|
||||
Assert.True(third.IsValid);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- encoding
|
||||
@@ -242,14 +238,43 @@ public class CertTenantBindingTests
|
||||
|
||||
Assert.True(result.IsValid, $"Matching-tenant request rejected: {result.ErrorMessage}");
|
||||
|
||||
// Match is only logged on a fresh validation; the cache is process-wide and shared with the
|
||||
// other tests here, so assert the outcome only when a line was actually emitted.
|
||||
var observed = logger.Entries.FirstOrDefault(e => e.Message.Contains("AgentCertTenantBinding"));
|
||||
if (observed != null)
|
||||
{
|
||||
Assert.Equal(LogLevel.Information, observed.Level);
|
||||
Assert.Contains(CertTenantBinding.Outcome.Match, observed.Message);
|
||||
}
|
||||
// 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
|
||||
@@ -293,9 +318,12 @@ public class CertTenantBindingTests
|
||||
deviceAssociationValidator: null);
|
||||
}
|
||||
|
||||
private static HttpRequestData BuildRequestWithCert(string certBase64)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,18 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// </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
|
||||
|
||||
@@ -139,8 +139,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
if (cached.ExpiresAt > DateTime.UtcNow)
|
||||
{
|
||||
logger?.LogDebug($"Certificate validation cache HIT for thumbprint {thumbprint}");
|
||||
// Copy so FromCache is per-call state and never mutates the shared cached instance.
|
||||
return cached.Result.AsCacheHit();
|
||||
return cached.Result;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -425,28 +424,6 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// </summary>
|
||||
public CertTenantIdStatus CertTenantIdStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when this result was served from the 5-minute thumbprint cache rather than freshly
|
||||
/// validated. Used to sample per-request observability down to roughly one line per
|
||||
/// certificate per cache window.
|
||||
/// </summary>
|
||||
public bool FromCache { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy of this result marked as a cache hit, so the shared cached instance is
|
||||
/// never mutated by a caller.
|
||||
/// </summary>
|
||||
internal CertificateValidationResult AsCacheHit() => new()
|
||||
{
|
||||
IsValid = IsValid,
|
||||
Thumbprint = Thumbprint,
|
||||
Subject = Subject,
|
||||
Issuer = Issuer,
|
||||
ErrorMessage = ErrorMessage,
|
||||
CertTenantId = CertTenantId,
|
||||
CertTenantIdStatus = CertTenantIdStatus,
|
||||
FromCache = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -450,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,
|
||||
@@ -463,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
|
||||
{
|
||||
@@ -527,12 +601,17 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// changes no state: enforcement is stage 2, after this telemetry shows the miss rate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Volume control: outcomes that need enforcement decisions (mismatch, missing or
|
||||
/// undecodable extension) are always logged — they are expected to be rare and each one is
|
||||
/// individually interesting. The bulk "Match" case is logged only when the certificate was
|
||||
/// freshly validated rather than served from the 5-minute thumbprint cache, which samples it
|
||||
/// down to roughly one line per certificate per cache window while keeping the ratio between
|
||||
/// numerator and denominator meaningful (both are counted per cert-window, not per request).
|
||||
/// 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,
|
||||
@@ -547,7 +626,13 @@ namespace AutopilotMonitor.Functions.Security
|
||||
certValidation.CertTenantIdStatus,
|
||||
tenantId);
|
||||
|
||||
if (outcome == CertTenantBinding.Outcome.Match && certValidation.FromCache)
|
||||
// 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")
|
||||
@@ -564,18 +649,9 @@ namespace AutopilotMonitor.Functions.Security
|
||||
var certTenant = certValidation.CertTenantId?.ToString() ?? "n/a";
|
||||
var wouldReject = CertTenantBinding.WouldRejectUnderEnforcement(outcome);
|
||||
|
||||
if (outcome == CertTenantBinding.Outcome.Match)
|
||||
{
|
||||
_logger.LogInformation(template,
|
||||
outcome, false, wouldReject, tenantId, certTenant, certValidation.CertTenantIdStatus,
|
||||
certValidation.Thumbprint ?? "n/a", sessionId ?? "n/a", agentVersion);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(template,
|
||||
outcome, false, wouldReject, tenantId, certTenant, certValidation.CertTenantIdStatus,
|
||||
certValidation.Thumbprint ?? "n/a", sessionId ?? "n/a", agentVersion);
|
||||
}
|
||||
_logger.LogWarning(template,
|
||||
outcome, false, wouldReject, tenantId, certTenant, certValidation.CertTenantIdStatus,
|
||||
certValidation.Thumbprint ?? "n/a", sessionId ?? "n/a", agentVersion);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+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>
|
||||
|
||||
@@ -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