Compare commits

...

9 Commits

Author SHA1 Message Date
Oliver Kieselbach 51f2dabc8c feat(web): explain the blank WiFi signal in the network timeline
The timeline showed WiFi segments with an SSID and no signal, and an "Avg WiFi
signal" stat that simply vanished — on a location-gated device that reads as a
product defect rather than the Windows 24H2 gate that caused it.

No agent change was needed: buildSegments already merges every wifi_signal_info
into the WiFi segment covering its timestamp, so wifiDataLimitedReason rides in
with the sample that was already being read.

- NetSegment.dataLimitedReason, filled from the sample.
- Segments that never got a sample of their own (a mid-session reconnect emits
  no wifi_signal_info) inherit it: the gate is a device-level setting and cannot
  flip during an enrollment. A segment WITH a real reading is never overwritten
  — pinned by a test.
- Tooltip: "Signal: unavailable — Location services off on this device" where
  the percentage would be.
- Stats row: "Avg WiFi signal: n/a (Location services off)" instead of dropping
  the row.

network_state_change is deliberately left alone. That event has never carried
signalPercent or radioType — NetworkChangeDetector collects them into its
snapshot for change detection, but only before_/after_wifiSsid reaches the
payload — so there is no absent field there for a reason to explain, and the
SSID it does carry is unaffected by the gate. Adding it would ship a field
nothing reads.

Verified: web tsc + eslint (0 warnings) + vitest 893/893.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:06:49 +02:00
Oliver Kieselbach 0f9a77c848 feat(agent,web,mcp): surface the 24H2 location gate instead of only logging it
A missing WiFi signal reading is indistinguishable from a bug unless the payload
says why, and the device-level location setting is not something an admin would
think to check. Since 058248d9 the SSID survives the gate via the WinRT tier —
but signal, radio and channel silently vanish with no explanation anywhere.

When the WinRT tier wins AND the native tier reported rc=5, wifi_signal_info now
carries wifiDataLimitedReason = "location_services_off" and the event message
gains "— signal unavailable, Location services off", so the reason reads straight
off the timeline. The portal's WiFi card matches that literal and names the
Intune setting that restores the full payload.

The claim is evidence-only: any other native failure (no WLAN service, no
connected interface) leaves the field null rather than blaming a privacy setting
we did not observe. IsLocationGateDenied is the single predicate, pinned at its
boundaries (rc=5 yes; rc=50, rc=5023, rc=1062 no), and the literal is pinned as
the published agent/portal/MCP contract.

New wifi_signal_info group in the MCP DEVICE_PROPERTIES_CATALOG, so a fleet can
be sized before anyone changes a policy:
search_sessions(deviceProperties: {
  "wifi_signal_info.wifiDataLimitedReason": "location_services_off" })

Backend is untouched — the event payload is opaque DataJson with no projection.
network_state_change and the network timeline deliberately do not carry the
reason yet; that is a second payload plus web change for no extra insight.

Verified: build 0 warnings, agent suite 2553/2553, web tsc + eslint (0 warnings)
+ vitest 889/889, MCP tsc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:58:31 +02:00
Oliver Kieselbach d89e3f0b48 fix(backend): cap vulnerability_report severity at Warning
A completed scan is a finding about the software inventory, not an
enrollment failure. Mapping the CVE risk onto Error/Critical made the
report read as a failed enrollment: the diagnosis page collected it as
failure evidence, MCP search lifted it on failure-intent queries, and the
timeline's Error filter filled up with clean sessions. critical/high now
map to Warning, everything else to Info; the CVE severity itself stays in
scan_summary and on the report panel's own scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:54:58 +02:00
Oliver Kieselbach 058248d95c fix(agent): read WiFi SSID via ungated WinRT API — 24H2 location gate
wifi_signal_info was missing on most Windows 11 24H2+ WiFi enrollments
(build 22631 3/3, 26100 37/178, 26200 43/95 over n=276 sessions since 08-01).
Not a parser or language problem: every event that exists carries a full SSID,
so the failure is binary at the reader level.

Since 24H2 Windows gates WlanQueryInterface with
wlan_intf_opcode_current_connection — the only opcode we use — behind
precise-location consent and returns ERROR_ACCESS_DENIED. The consent prompt
only fires for a process in the user's context outside System32, so the agent
(SYSTEM, session 0, before the OOBE privacy page) is denied silently, and it
resolves consent from the HKLM ConsentStore because the S-1-5-18 hive has none
— while the interactive user on the same machine reads the SSID fine via the
per-user NonPackaged value. That is why it tracks the image's location setting,
not hardware, driver or language.

netsh wlan hangs off the same gate, so the 2026-07 netsh fallback and its
multi-language labels never covered the case they were built for; more labels
cannot fix it.

- New WinRtWifiSsidReader: Microsoft's documented replacement
  WlanConnectionProfileDetails.GetConnectedSsid (ungated, SSID only), bound via
  the CLR WinRT projection — no winmd, no NuGet, no csproj change — the same
  mechanism OobeStateReader already uses from the SYSTEM context.
- New WifiInfoProvider owns the tier order (native -> WinRT -> netsh) that both
  collectors had duplicated.
- The step diagnostics added in 2026-07 were logged at Debug while the default
  level is Info, so they never reached a field log. The all-tiers-failed case is
  now a Warning that decodes rc=5 into the location-gate explanation.

Signal, PHY and channel have no ungated source and stay null on tier 2;
deriving a percentage from GetSignalBars would fabricate precision.

Verified: build 0 warnings, agent suite 2542/2542, interop struct sizes measured
live (532/604), and the full WinRT chain resolves and returns the SSID under
net48.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:27:36 +02:00
Oliver Kieselbach aab0a0c52c feat(admin): make report ID one-click copyable in report details
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:45:41 +02:00
Oliver Kieselbach f22feb71ff feat(web): exclude events from the session timeline search
The timeline search box now tokenizes the query instead of treating it as a
single substring: terms are AND-ed and a leading minus hides its matches
(e.g. -app_install_progress), following the convention every other search box
uses. Quoting keeps a term literal, so an exit code like "-1" stays searchable.

The negation is decided at a token's first character, before a quote is
consumed, and terms match the same fields as the positive search (event type,
message, source) joined by a newline so a term can never match across a field
boundary. The event counter follows the search and an active exclusion is
named in a pill next to the severity filters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 14:33:05 +02:00
Oliver Kieselbach c92fe7c2a0 commands: bump to 3061dca (cert tenant binding queries in backend-logs + session-debug) 2026-08-25 12:56:29 +02:00
Oliver Kieselbach b7cb75e442 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-25 12:50:12 +02:00
Oliver Kieselbach d6895f2c7e feat(security): enforce cert tenant binding — reject cross-tenant certificates
The Intune roots the chain is pinned to are shared by every Intune tenant, so
a valid certificate only ever proved "issued by Intune to someone". Tenant
scoping rested on the device serial, which is printed on the chassis. A
Mismatch now returns 403.

Switched on against measured data, not a hunch: 35,684 requests across 32
tenants since the shadow telemetry landed, every one a Match, with the
Warning channel independently verified to be carrying other traffic so the
zero could not be a telemetry artefact.

Only Mismatch blocks, and the rule lives in one function so "what rejects a
request" has a single answer. ExtensionMissing, Unparseable and
RequestTenantNotAGuid mean "cannot tell" and are tolerated: the sample only
covers devices active in the window, so an older certificate or a
sovereign-cloud CA could still lack the extension, and rejecting on absence
would lock those devices out for something outside their control.
ExtensionMissing is the candidate for later tightening and has its own test
pinning the tolerance, so removing it must be a decision.

Evaluation fails open on its own exception — a defect on our side is not
evidence of a cross-tenant certificate. The 403 body does not echo which
tenant the certificate belongs to; that pair goes to the rejection log.

Device binding (stage 3) is untouched: still shadow, still Global-Admin-only.

Backend 4638/4638, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 12:45:21 +02:00
27 changed files with 1291 additions and 84 deletions
+185
View File
@@ -0,0 +1,185 @@
---
type: Concept
title: WiFi SSID and the Windows 24H2 Location Gate
description: Why wifi_signal_info silently disappeared on Windows 11 24H2+ devices, why the netsh fallback could never fix it, and the tiered reader that restores the SSID from the SYSTEM context.
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs
tags:
- agent
- interop
- wifi
- telemetry
timestamp: 2026-08-26T14:05:00+02:00
---
# Summary
The agent reports the enrollment WiFi as a `wifi_signal_info` event (SSID, signal, PHY, channel).
On Windows 11 24H2 and later the event went missing on a large share of devices — no error, no
partial payload, simply no event. The cause is a Windows privacy change, not a bug in the reader:
**`WlanQueryInterface` with `wlan_intf_opcode_current_connection` is gated behind precise-location
consent**, and the agent runs as SYSTEM in session 0 where that consent usually does not exist.
The fix is a tiered reader ([WifiInfoProvider](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs))
whose middle tier uses the one SSID API Microsoft left ungated.
# The Gate
Microsoft restricted the Win32 WLAN APIs that expose BSSIDs, because nearby-BSSID lists are a
precise-location oracle. Without consent these return `ERROR_ACCESS_DENIED` (5):
* `WlanGetAvailableNetworkList`
* `WlanGetNetworkBssList`
* `WlanScan`
* **`WlanQueryInterface` when `OpCode` is `wlan_intf_opcode_current_connection`** — the only opcode
the agent needs, and the one that carries SSID, signal quality, PHY type.
Two properties of the gate decide the agent's fate:
1. **The consent prompt only appears for a process running in the user's context and outside
`C:\Windows\System32`.** The agent is neither during ESP: it is SYSTEM, in session 0, before any
user has seen the OOBE privacy page. It is therefore denied *silently* — no prompt, no event
log, nothing that surfaces without asking the API for its return code.
2. **Consent resolves per user hive, with the device-level setting as the floor.** An interactive
user typically has
`HKCU\...\CapabilityAccessManager\ConsentStore\location\NonPackaged\Value = Allow`
("Let desktop apps access your location"). The SYSTEM account (`HKU\S-1-5-18`) normally has
**nothing** set there and falls back to
`HKLM\...\ConsentStore\location\Value`. Where that device-level value is `Deny`, SYSTEM is
denied even though the logged-on user on the very same machine reads the SSID fine.
That asymmetry is why the failure looks random from the outside: it tracks the **image's
device-level location setting**, which varies per fleet, not per hardware, driver, or language.
# Why netsh Was Never the Answer
`NetshWifiFallback` was added in 2026-07 on the theory that inbox `netsh` still reported the
connection where the WLAN API did not, and it was later extended with localized label variants for
every enrollment-geography language. Field data says otherwise: **`netsh wlan` hangs off the same
location gate** ("Network shell commands need location permission to access WLAN information"), so
it fails in lockstep with tier 1 rather than covering for it. In the reported session the agent
build already contained the netsh fallback and still produced no event.
Consequence for future work: **adding more netsh language labels cannot fix a missing SSID.** Every
`wifi_signal_info` event that does exist carries a fully populated SSID — the failure is binary at
the reader level, never a half-parsed or mis-decoded payload. A missing SSID is an access problem,
not a parsing problem.
# The Tiered Reader
`WifiInfoProvider.TryRead` owns the order so the two collectors (`DeviceInfoCollector`,
`NetworkChangeDetector`) cannot drift apart. First non-null wins:
| # | Reader | Yields | Location-gated |
|---|---|---|---|
| 1 | `WifiInfoReader` (native `wlanapi.dll`) | SSID, signal %, PHY, channel | yes |
| 2 | `WinRtWifiSsidReader` (WinRT `GetConnectedSsid`) | SSID only | **no** |
| 3 | `NetshWifiFallback` (process spawn, localized parse) | SSID, signal %, radio type, channel | yes |
Tier 2 is Microsoft's own documented replacement for this exact scenario:
`Windows.Networking.Connectivity.WlanConnectionProfileDetails.GetConnectedSsid()`. It is not part of
the BSSID-bearing surface, so it needs no consent, and it has existed since Windows 10 1507.
It is bound through the CLR's built-in WinRT projection with
`Type.GetType("…, ContentType=WindowsRuntime")` — no NuGet package, no winmd reference, no csproj
change — the same mechanism [OobeStateReader](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/OobeStateReader.cs)
already uses from the SYSTEM service context. Every step degrades to null rather than throwing,
because the caller runs fire-and-forget inside `Task.Run`.
# Telling the Operator
A missing signal reading is indistinguishable from a bug unless the payload says why, and the
device-level location setting is not something an admin would think to check. When tier 2 wins
*and* the native tier reported `rc=5`, the `wifi_signal_info` payload therefore carries
```
wifiDataLimitedReason = "location_services_off"
```
and the event message gains `- signal unavailable, Location services off`, so the reason is visible
in the timeline itself. The portal's WiFi card (`DeviceDetailsCard`) matches that literal and
renders the explanation plus the Intune setting that fixes it. The value is a published contract —
agent, portal and the MCP device-property catalog all match the same string.
The claim is only made on observed evidence: any other native failure (no WLAN service, no
connected interface) leaves the field null rather than blaming a privacy setting we did not
measure. `WifiInfoProvider.IsLocationGateDenied` is the single predicate, pinned by tests at its
boundaries (`rc=5` yes; `rc=50`, `rc=5023`, `rc=1062` no).
The network timeline reads the same field. It needs no payload of its own: `buildSegments`
already merges every `wifi_signal_info` into the WiFi segment covering its timestamp, so the
reason rides in with the sample. A segment that never got a sample (a mid-session reconnect emits
no `wifi_signal_info`) inherits it — the gate is a device-level setting and cannot flip during an
enrollment — while a segment that *did* get a real reading is never overwritten. The segment
tooltip then reads `Signal: unavailable - Location services off on this device` where the
percentage would be, and the "Avg WiFi signal" stat says `n/a (Location services off)` instead of
disappearing.
`network_state_change` deliberately does NOT carry the reason: that event has never carried
`signalPercent` or `radioType` at all (`NetworkChangeDetector` collects them into its snapshot for
change detection but only `before_/after_wifiSsid` reaches the payload), so there is no absent
field there for a reason to explain. Adding it would ship a field nothing reads. The SSID it does
carry is unaffected by the gate.
Fleet-wide the same field is queryable through
`search_sessions(deviceProperties: { "wifi_signal_info.wifiDataLimitedReason": "location_services_off" })`,
which sizes how much of a fleet is affected before anyone changes a policy.
Tier 2 returns the SSID alone. Signal quality, PHY type and channel have no ungated source; they
stay null when the native tier is denied. That is deliberate — deriving a percentage from
`ConnectionProfile.GetSignalBars()` (0–5) would fabricate precision the API does not have.
# Diagnostics
The step-level native return codes were already collected, but they were logged with
`_logger.Debug` — and the agent's default log level is `Info`, so the diagnostic built to explain
this failure never reached a single field log. Both collectors now log at **Warning** when all tiers
fail, which they only reach when the active NIC really is WiFi. A native `rc=5` is spelled out as
`ERROR_ACCESS_DENIED` with the location-gate explanation, so the log line diagnoses itself:
```
EnrollmentTracker: no wifi_signal_info — no WiFi info from any reader — native: [<guid>]
query(current_connection) rc=5; ; WinRT: no SSID; netsh: no parsable output. rc=5 is
ERROR_ACCESS_DENIED — Windows 11 24H2+ gates the current-connection opcode (and netsh wlan)
behind precise-location consent, which is off in this image; enable Location services to
restore signal/PHY/channel.
```
# Evidence
Session `7305e0ad` (HP EliteBook X Flip G1i, build 26200.9168, agent 2.0.1407, reported as
`WIFI SSID fehlt`): `network_interface_info` shows the active NIC as `Intel(R) Wi-Fi 7 BE201`,
`Wireless80211`, `connectionType=WiFi`, so the `isWifi` gate passed and collection ran — yet zero
`wifi_signal_info` events across 214 events and 22 minutes, with the netsh fallback already shipped
in that build.
Fleet correlation over WiFi sessions from 2026-08-01 (n=276), share with a `wifi_signal_info` event:
| OS build | WiFi sessions | with SSID |
|---|---|---|
| 22631 (23H2, pre-gate) | 3 | 100% |
| 26100 (24H2) | 178 | 21% |
| 26200 (25H2) | 95 | 45% |
Within a single model the outcome splits by fleet, not by hardware — ThinkPad T14 Gen 6: 7/91;
ThinkPad E14 Gen 7: 9/9 — which is what a per-image privacy setting looks like.
Local verification (build 26220.9022): `HKLM\…\ConsentStore\location = Deny` while
`HKCU\…\ConsentStore\location\NonPackaged = Allow`; the interactive user reads the SSID through
both the native API and netsh, `HKU\S-1-5-18` has no consent value at all, and the WinRT chain
resolves and returns the SSID under net48.
# Operator Guidance
A device that reports no SSID is not misconfigured for enrollment — only the telemetry field is
missing. To get the full payload (signal, PHY, channel) back, the **device-level** location setting
must be on: *Settings → Privacy & security → Location → Location services*, or via Intune
(`Privacy/LetAppsAccessLocation`). Without it the SSID still arrives via tier 2.
# Citations
* [Changes to API behavior for Wi-Fi access and location](https://learn.microsoft.com/en-us/windows/win32/nativewifi/wi-fi-access-location-changes)
* [WlanConnectionProfileDetails.GetConnectedSsid](https://learn.microsoft.com/en-us/uwp/api/windows.networking.connectivity.wlanconnectionprofiledetails.getconnectedssid)
* [WifiInfoProvider.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs)
* [WinRtWifiSsidReader.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WinRtWifiSsidReader.cs)
* [WifiInfoReader.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoReader.cs)
* [NetshWifiFallback.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/NetshWifiFallback.cs)
+24 -5
View File
@@ -77,7 +77,7 @@ Enforcing immediately would be reckless: any device population whose certificate
extension, or whose CA behaves differently (sovereign clouds), would be locked out at once. The
rollout therefore has staged consequences.
**Stage 1 — shadow (current).** `SecurityValidator.ObserveCertTenantBinding` evaluates the binding
**Stage 1 — shadow (completed, kept as the measurement that justified stage 2).** `SecurityValidator.ObserveCertTenantBinding` evaluates the binding
after certificate validation and logs the outcome. It returns `void`, changes no state, and swallows
its own exceptions, so it cannot fail a request that would otherwise be accepted. The pure comparison
lives in `CertTenantBinding.Evaluate`; the outcome codes (`Match`, `Mismatch`, `ExtensionMissing`,
@@ -102,10 +102,29 @@ is disabled), so a `Match` trace is dead code that silently costs the denominato
deployment of this feature made exactly that mistake: it emitted zero rows across 32k requests,
which is indistinguishable from the code never running.
**Stage 2 — enforce.** Once telemetry shows the `ExtensionMissing` rate is negligible, `Mismatch`
becomes a rejection. The decision that still has to be made is the policy for certificates without
the extension: skip with a warning, or reject. Grep marker for every stage-2 site:
`CERT-TENANT-BINDING-SHADOW`.
**Stage 2 — enforced since 2026-08-25.** `Mismatch` returns 403 and is recorded through the shared
`LogRequestRejection` path (`stage=certtenant`) so it lands in the same KQL raster as every other
rejection reason. The switch was made against measured data: 35,684 requests across 32 tenants,
every one a `Match`, with the Warning channel independently verified to be carrying other traffic so
the zero could not be a telemetry artefact.
The rule lives in one place, `CertTenantBinding.Rejects`, and only `Mismatch` blocks. Everything
else means "cannot tell" and is deliberately tolerated:
* `ExtensionMissing` — the sample only covers devices active in the measurement window, so older
certificates or a sovereign-cloud CA could still lack the extension. Rejecting on absence would
lock those devices out for something they cannot control. **This is the one to tighten later**,
once the `ExtensionMissing` rate is provably zero across a wider window; a test pins the current
tolerance so removing it has to be a deliberate decision rather than a drive-by edit.
* `Unparseable` — a decoder or encoding surprise is our problem, not the device's.
* `RequestTenantNotAGuid` — nothing to compare against, and such a request fails the tenant lookup
in §0 long before reaching this point.
The evaluation fails open on its own exception: this gate exists to stop a cross-tenant certificate,
and a defect on our side is not evidence of one. The 403 body deliberately does not echo which
tenant the certificate belongs to — that pair is in the rejection log, not in the response.
Grep marker for every site involved: `CERT-TENANT-BINDING`.
The bootstrap-token path (`X-Bootstrap-Token`) bypasses certificate validation by design and is
unaffected by either stage.
+1
View File
@@ -35,6 +35,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [System Timeline Watcher](agent/system-timeline-watcher.md) - Ground truth for two timeline blind spots from one System-channel EventLogWatcher with pre-agent backfill: system_clock_changed (Kernel-General 1 — oldTime/newTime/signed timeDeltaMs/reason/setting process; 2 s micro-slew floor, ≥5 min Warning+immediate) and system_sleep_episode (Power-Troubleshooter 1 sleep/hibernate + Kernel-Power 507 Modern Standby; authoritative enteredAt/exitedAt payload instants, 60 s Modern-Standby floor). Consumed by the TimeJumpBadge clock-set cause, the SessionInfoCard standby note, time-attribution SleepSpans (AttributionVersion 3) and ANALYZE-DEV-012.
* [DO GroupId Auto-Set](agent/do-group-id-auto-set.md) - EnableDoGroupIdAutoSet (ConfigVersion 40): the agent seeds the Delivery Optimization DOGroupId policy with a deterministic network-fingerprint GUID (default gateway IPv4 + SendARP MAC, RealmJoin-compatible byte layout) so same-network devices form one DO peering group during ESP; ownership-tracked via the StartupEventGate fingerprint, foreign DOGroupId/DOGroupIdSource policies always win, no random-GUID fallback.
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
* [WiFi SSID and the Windows 24H2 Location Gate](agent/wifi-ssid-location-gate.md) - Why wifi_signal_info vanished on Windows 11 24H2+: WlanQueryInterface(wlan_intf_opcode_current_connection) is gated behind precise-location consent, the prompt only fires in a user context outside System32, and SYSTEM in session 0 falls back to the device-level ConsentStore value - so netsh (same gate) could never cover for it. The tiered WifiInfoProvider (native, then ungated WinRT GetConnectedSsid, then netsh), SSID-only semantics on tier 2, and the Warning-level rc=5 diagnostic that names the gate.
# Rules
+19
View File
@@ -1,5 +1,24 @@
# Log
## 2026-08-26
* **Update**: `agent/wifi-ssid-location-gate.md` - the network timeline now explains its blank signal too, with no agent change: `buildSegments` already merges `wifi_signal_info` into the covering WiFi segment, so `wifiDataLimitedReason` rides in with the sample. Segments that never got a sample (a mid-session reconnect emits none) inherit it - the gate is a device-level setting and cannot flip mid-enrollment - while a segment with a real reading is never overwritten. Tooltip shows `Signal: unavailable - Location services off on this device`; the "Avg WiFi signal" stat reads `n/a (Location services off)` instead of vanishing. `network_state_change` is deliberately left alone: it has never carried `signalPercent` or `radioType` (only `before_/after_wifiSsid` reaches its payload), so there is no absent field there for a reason to explain, and the SSID it does carry is unaffected by the gate. Web-only change.
## 2026-08-26
* **Update**: `agent/wifi-ssid-location-gate.md` - the gate is now surfaced instead of only logged. When the WinRT tier wins and the native tier reported `rc=5`, `wifi_signal_info` carries `wifiDataLimitedReason = "location_services_off"` and the message gains `- signal unavailable, Location services off`, so the reason is readable straight from the timeline; the portal WiFi card (`DeviceDetailsCard`) matches that literal and names the Intune setting that restores the full payload. The claim is evidence-only - any other native failure leaves the field null rather than blaming a privacy setting, with `IsLocationGateDenied` pinned at its boundaries. New `wifi_signal_info` group in the MCP `DEVICE_PROPERTIES_CATALOG` so a fleet can be sized via `search_sessions` before any policy changes. Agent release + web deploy + MCP redeploy; backend untouched (the payload is opaque DataJson).
## 2026-08-26
* **New**: `agent/wifi-ssid-location-gate.md` - `wifi_signal_info` was missing on most Windows 11 24H2+ WiFi enrollments (build 22631 3/3, 26100 37/178, 26200 43/95 over n=276 sessions from 08-01). Not a parser or language problem: every event that exists carries a full SSID, so the failure is binary at the reader. Cause is the 24H2 location gate on `WlanQueryInterface` with `wlan_intf_opcode_current_connection` - the only opcode the agent uses - which returns `ERROR_ACCESS_DENIED`; the consent prompt only fires for a process in the user context outside `System32`, so the agent (SYSTEM, session 0, pre-privacy-page) is denied silently, and it resolves consent from the HKLM ConsentStore because the `S-1-5-18` hive has none - while the interactive user on the same box reads the SSID fine via the per-user NonPackaged value. `netsh wlan` hangs off the same gate, so the 2026-07 netsh fallback (and its multi-language labels) never covered the case it was built for; more labels cannot fix it. New `WinRtWifiSsidReader` uses the documented replacement `WlanConnectionProfileDetails.GetConnectedSsid` (ungated, SSID only), bound via the CLR WinRT projection with no winmd/NuGet like `OobeStateReader`; new `WifiInfoProvider` owns the tier order (native, WinRT, netsh) that both collectors had duplicated. The step diagnostics added in 2026-07 were logged at `Debug` while the default level is `Info`, so they never reached a field log - the all-tiers-failed case is now a Warning that decodes `rc=5` into the location-gate explanation. Agent-only change; needs an agent release.
## 2026-08-25
* **Update**: `backend/client-cert-tenant-binding.md` — stage 2 enforced: `Mismatch` now returns 403 via `LogRequestRejection(stage=certtenant)`. Switched on against 35,684 requests / 32 tenants at 100 % `Match`, with the Warning channel verified to be carrying other traffic so the zero was not an artefact. The rule is one function (`CertTenantBinding.Rejects`) and only `Mismatch` blocks; `ExtensionMissing` / `Unparseable` / `RequestTenantNotAGuid` are tolerated on purpose (the first is the candidate for later tightening and has a test pinning the tolerance). Evaluation fails open on its own exception, and the 403 body does not echo the certificate's tenant. Device binding (stage 3) stays shadow + Global-Admin-only and is unaffected.
## 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.
@@ -0,0 +1,140 @@
#nullable enable
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
{
/// <summary>
/// Tests for the tiered WiFi reader. The WLAN state of the test machine is unknown (CI
/// runners have no WLAN service, dev boxes may be on WiFi with or without location consent),
/// so the live path only pins the fail-soft contract. The diagnostics composer is pinned
/// exactly — it is the only field evidence for a missing wifi_signal_info event, and the
/// ERROR_ACCESS_DENIED hint is what tells an operator that Windows 11 24H2's location gate,
/// not the hardware, swallowed the SSID.
/// </summary>
public sealed class WifiInfoProviderTests
{
[Fact]
public void TryRead_never_throws_and_reports_a_reason_when_it_yields_nothing()
{
var result = WifiInfoProvider.TryRead(null, out var diagnostics);
if (result == null)
{
Assert.False(string.IsNullOrWhiteSpace(diagnostics),
"a null result must always explain itself — that string is the only field evidence");
Assert.Contains("native:", diagnostics);
Assert.Contains("WinRT:", diagnostics);
Assert.Contains("netsh:", diagnostics);
}
else
{
Assert.Null(diagnostics);
// Every tier either carries an SSID or was not supposed to return at all.
Assert.False(result.Ssid == null && result.SignalPercent == null
&& result.RadioType == null && result.Channel == null,
"an all-null payload must surface as null, not as an empty connection");
}
}
// ----- BuildDiagnostics ---------------------------------------------
[Fact]
public void BuildDiagnostics_names_every_tier()
{
var text = WifiInfoProvider.BuildDiagnostics(null, null);
Assert.Contains("no WiFi info from any reader", text);
Assert.Contains("native: no connected WLAN interface", text);
Assert.Contains("WinRT: no SSID", text);
Assert.Contains("netsh: no parsable output", text);
}
[Fact]
public void BuildDiagnostics_passes_through_tier_reasons()
{
var text = WifiInfoProvider.BuildDiagnostics("WlanOpenHandle rc=1062", "WinRT NetworkInformation unavailable");
Assert.Contains("native: WlanOpenHandle rc=1062", text);
Assert.Contains("WinRT: WinRT NetworkInformation unavailable", text);
}
[Fact]
public void BuildDiagnostics_explains_access_denied_as_the_location_gate()
{
var text = WifiInfoProvider.BuildDiagnostics(
"[1cbc79c3-2e31-468a-b949-17fafa6e23ed] query(current_connection) rc=5; ", null);
Assert.Contains("ERROR_ACCESS_DENIED", text);
Assert.Contains("24H2", text);
Assert.Contains("Location services", text);
}
[Fact]
public void BuildDiagnostics_explains_access_denied_without_a_trailing_separator()
{
var text = WifiInfoProvider.BuildDiagnostics("WlanOpenHandle rc=5", null);
Assert.Contains("ERROR_ACCESS_DENIED", text);
}
// ----- IsLocationGateDenied -----------------------------------------
//
// This predicate decides whether the wifi_signal_info payload claims
// "location_services_off" to the operator. A false positive blames a privacy setting
// that is actually fine, so the boundaries are pinned exactly.
[Theory]
[InlineData("[guid] query(current_connection) rc=5; ")]
[InlineData("WlanOpenHandle rc=5")]
public void IsLocationGateDenied_true_only_for_error_access_denied(string nativeDiag)
{
Assert.True(WifiInfoProvider.IsLocationGateDenied(nativeDiag));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("query(current_connection) rc=50; ")]
[InlineData("query(current_connection) rc=5023; ")]
[InlineData("WlanEnumInterfaces rc=1062")]
[InlineData("no WLAN interfaces")]
[InlineData("[guid] state=wlan_interface_state_disconnected; ")]
public void IsLocationGateDenied_false_for_every_other_reason(string? nativeDiag)
{
Assert.False(WifiInfoProvider.IsLocationGateDenied(nativeDiag));
}
[Fact]
public void LocationServicesOff_literal_is_the_published_payload_contract()
{
// Matched verbatim by the portal's WiFi card (wifiDataLimitedReason) — renaming it
// silently drops the hint instead of breaking a build.
Assert.Equal("location_services_off", WifiInfoProvider.LocationServicesOff);
}
[Fact]
public void TryRead_never_claims_the_location_gate_on_the_native_tier()
{
// The native tier only returns when it succeeded, so a full payload must never carry
// a "data limited" reason.
var result = WifiInfoProvider.TryRead(null, out _);
if (result?.SignalPercent != null)
Assert.Null(result.DataLimitedReason);
}
[Theory]
[InlineData("query(current_connection) rc=50; ")]
[InlineData("query(current_connection) rc=5023; ")]
[InlineData("WlanEnumInterfaces rc=1062")]
[InlineData("[guid] state=wlan_interface_state_disconnected; ")]
public void BuildDiagnostics_does_not_blame_the_location_gate_for_other_codes(string nativeDiag)
{
var text = WifiInfoProvider.BuildDiagnostics(nativeDiag, null);
Assert.DoesNotContain("ERROR_ACCESS_DENIED", text);
Assert.Contains($"native: {nativeDiag}", text);
}
}
}
@@ -0,0 +1,48 @@
#nullable enable
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
{
/// <summary>
/// Tests for the WinRT SSID reader. Its whole value is that it keeps working where the
/// native WLAN API is denied, so the contract that matters here is fail-soft resolution:
/// the type is bound by name at runtime (no winmd), and every step must degrade to null
/// with a reason instead of throwing — a throw would take the Task.Run collector down
/// silently, which is precisely the failure mode this tier exists to end.
/// </summary>
public sealed class WinRtWifiSsidReaderTests
{
[Fact]
public void TryGetConnectedSsid_never_throws()
{
var ssid = WinRtWifiSsidReader.TryGetConnectedSsid(out var diagnostics);
if (ssid == null)
Assert.False(string.IsNullOrWhiteSpace(diagnostics), "a null SSID must carry a reason");
else
Assert.Null(diagnostics);
}
[Fact]
public void TryGetConnectedSsid_never_returns_an_empty_ssid()
{
// An AP broadcasting an empty SSID must surface as null, never as "" — the caller
// maps a non-null SSID straight into the wifi_signal_info payload.
var ssid = WinRtWifiSsidReader.TryGetConnectedSsid(out _);
Assert.True(ssid == null || ssid.Length > 0);
}
[Fact]
public void TryGetConnectedSsid_is_stable_across_calls()
{
// Reflection members are resolved once and cached; a second call must take the
// cached path and behave identically.
var first = WinRtWifiSsidReader.TryGetConnectedSsid(out _);
var second = WinRtWifiSsidReader.TryGetConnectedSsid(out _);
Assert.Equal(first, second);
}
}
}
@@ -0,0 +1,106 @@
using System;
using System.Text;
using System.Text.RegularExpressions;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// The single entry point every collector uses to read the current WiFi connection. Owns the
/// tier order so the two callers (<c>DeviceInfoCollector</c>, <c>NetworkChangeDetector</c>)
/// cannot drift apart:
/// <list type="number">
/// <item><see cref="WifiInfoReader"/> — native WLAN API. Full payload (SSID, signal, PHY,
/// channel), but Windows 11 24H2+ requires precise-location consent for it.</item>
/// <item><see cref="WinRtWifiSsidReader"/> — WinRT <c>GetConnectedSsid</c>. SSID only, but
/// NOT location-gated; this is the tier that carries 24H2+ devices whose image has location
/// services off (the common OOBE case — see that class for the mechanism).</item>
/// <item><see cref="NetshWifiFallback"/> — process spawn, localized output parsing. Last
/// resort: it hangs off the same location gate as tier 1, so it only helps when the native
/// API failed for an unrelated reason.</item>
/// </list>
/// Never throws. Returns null when the device has no readable WiFi connection.
/// </summary>
internal static class WifiInfoProvider
{
/// <summary>
/// <see cref="WifiConnectionInfo.DataLimitedReason"/> value for a payload cut down to the
/// SSID by the Windows 11 24H2 location gate. Travels verbatim into the
/// <c>wifi_signal_info</c> payload as <c>wifiDataLimitedReason</c> and is matched by the
/// portal's WiFi card — a published contract, so do not rename it.
/// </summary>
internal const string LocationServicesOff = "location_services_off";
/// <summary>
/// True when the native tier reported <c>ERROR_ACCESS_DENIED</c> — "rc=5" but not
/// "rc=50"/"rc=5023"; the diagnostics string ends the code with ';' or end-of-line.
/// That is the 24H2 location gate, not a driver or hardware fault.
/// Internal for direct unit-testing via InternalsVisibleTo.
/// </summary>
internal static bool IsLocationGateDenied(string nativeDiag)
=> !string.IsNullOrEmpty(nativeDiag) && Regex.IsMatch(nativeDiag, @"rc=5(?!\d)");
/// <summary>
/// Reads the current WiFi connection, walking the tiers until one yields something.
/// <paramref name="preferredInterfaceId"/> is the adapter GUID from
/// <c>NetworkInterface.Id</c> (tier 1 only). <paramref name="diagnostics"/> is null on
/// success and otherwise explains what every tier reported — log it at Warning: this is
/// the only field evidence for a missing <c>wifi_signal_info</c> event.
/// </summary>
internal static WifiConnectionInfo TryRead(Guid? preferredInterfaceId, out string diagnostics)
{
var native = WifiInfoReader.TryGetCurrentConnection(preferredInterfaceId, out var nativeDiag);
if (native != null)
{
diagnostics = null;
return native;
}
var ssid = WinRtWifiSsidReader.TryGetConnectedSsid(out var winRtDiag);
if (ssid != null)
{
diagnostics = null;
return new WifiConnectionInfo
{
Ssid = ssid,
// Only claim the location gate when the native tier actually said so. Any
// other native failure (no WLAN service, no connected interface) leaves this
// null rather than blaming a setting we did not observe.
DataLimitedReason = IsLocationGateDenied(nativeDiag) ? LocationServicesOff : null,
};
}
var netsh = NetshWifiFallback.TryRead();
if (netsh != null)
{
diagnostics = null;
return netsh;
}
diagnostics = BuildDiagnostics(nativeDiag, winRtDiag);
return null;
}
/// <summary>
/// Composes the all-tiers-failed message. A native <c>rc=5</c> (ERROR_ACCESS_DENIED) is
/// spelled out: that is the 24H2 location gate, not a driver or hardware fault, and it
/// takes the netsh tier down with it — without this hint the log reads like a mystery.
/// Internal for direct unit-testing via InternalsVisibleTo.
/// </summary>
internal static string BuildDiagnostics(string nativeDiag, string winRtDiag)
{
var text = new StringBuilder("no WiFi info from any reader");
text.Append(" — native: ").Append(string.IsNullOrEmpty(nativeDiag) ? "no connected WLAN interface" : nativeDiag);
text.Append("; WinRT: ").Append(string.IsNullOrEmpty(winRtDiag) ? "no SSID" : winRtDiag);
text.Append("; netsh: no parsable output");
if (IsLocationGateDenied(nativeDiag))
{
text.Append(". rc=5 is ERROR_ACCESS_DENIED — Windows 11 24H2+ gates the current-connection "
+ "opcode (and netsh wlan) behind precise-location consent, which is off in this image; "
+ "enable Location services to restore signal/PHY/channel.");
}
return text.ToString();
}
}
}
@@ -15,7 +15,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
/// <summary>
/// Signal quality 0–100 (same semantics as the netsh "Signal" percentage). Always set
/// on the native WLAN API path; null only when the netsh fallback could not parse it.
/// on the native WLAN API path; null when the netsh fallback could not parse it, and
/// always null on the WinRT tier (<see cref="WinRtWifiSsidReader"/> yields SSID only).
/// </summary>
public int? SignalPercent { get; internal set; }
@@ -24,6 +25,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
/// <summary>Current 802.11 channel number; null when the channel query fails.</summary>
public int? Channel { get; internal set; }
/// <summary>
/// Why the payload is incomplete, when that is known — currently only
/// <c>location_services_off</c>: the SSID came from the ungated WinRT tier because the
/// native API was denied by the Windows 11 24H2 location gate, so signal, PHY and
/// channel have no source. Null whenever the reason is unknown; never a guess.
/// </summary>
public string DataLimitedReason { get; internal set; }
}
/// <summary>
@@ -0,0 +1,146 @@
using System;
using System.Collections;
using System.Reflection;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Reads the SSID of the current WLAN connection via WinRT
/// <c>Windows.Networking.Connectivity.WlanConnectionProfileDetails.GetConnectedSsid</c>.
/// <para>
/// <b>Why this exists.</b> Windows 11 24H2 put the native WLAN API behind precise-location
/// consent: <c>WlanQueryInterface</c> with <c>wlan_intf_opcode_current_connection</c> — the
/// exact opcode <see cref="WifiInfoReader"/> needs — returns <c>ERROR_ACCESS_DENIED</c>
/// without it, and <c>netsh wlan</c> is gated by the same rule, so
/// <see cref="NetshWifiFallback"/> fails in lockstep. The consent prompt only appears for
/// processes in the user's context outside <c>C:\Windows\System32</c>, so the agent — SYSTEM,
/// during OOBE, before the user ever sees the privacy page — is denied silently. Microsoft
/// names <c>GetConnectedSsid</c> as the replacement for exactly this case; it is not
/// location-gated.
/// </para>
/// <para>
/// Resolved through the CLR's built-in WinRT projection — no NuGet package, no winmd
/// reference — the same mechanism <see cref="OobeStateReader"/> uses (validated from the
/// SYSTEM service context). Available since Windows 10 1507. SSID only: signal quality,
/// PHY type and channel have no ungated source, so they stay null on this path.
/// </para>
/// </summary>
internal static class WinRtWifiSsidReader
{
// Resolved once per process. Benign race: concurrent first calls resolve the same
// members and the assignments are idempotent — no lock needed.
private static MethodInfo _getInternetConnectionProfile;
private static MethodInfo _getConnectionProfiles;
private static bool _initTried;
/// <summary>
/// Returns the SSID of the connected WLAN, or null when the device is not on WiFi, the
/// WinRT contract is unavailable, or any part of the read fails. Never throws.
/// <paramref name="diagnostics"/> carries the reason when the result is null.
/// </summary>
internal static string TryGetConnectedSsid(out string diagnostics)
{
try
{
EnsureInitialized();
if (_getInternetConnectionProfile == null)
{
diagnostics = "WinRT NetworkInformation unavailable";
return null;
}
// The internet-bearing profile is the one the device is actually enrolling over.
var ssid = ReadSsid(_getInternetConnectionProfile.Invoke(null, null));
if (ssid != null)
{
diagnostics = null;
return ssid;
}
// No internet profile (captive portal, limited connectivity) or it is not WLAN —
// fall back to the first connected profile that carries WLAN details.
ssid = ScanConnectionProfiles();
diagnostics = ssid == null ? "no connected WLAN profile" : null;
return ssid;
}
catch (Exception ex)
{
diagnostics = $"WinRT exception {ex.GetType().Name}: {ex.Message}";
return null;
}
}
private static void EnsureInitialized()
{
if (_initTried) return;
var type = Type.GetType(
"Windows.Networking.Connectivity.NetworkInformation, Windows.Networking.Connectivity, ContentType=WindowsRuntime",
throwOnError: false);
_getInternetConnectionProfile = type?.GetMethod(
"GetInternetConnectionProfile", BindingFlags.Public | BindingFlags.Static, null, Type.EmptyTypes, null);
_getConnectionProfiles = type?.GetMethod(
"GetConnectionProfiles", BindingFlags.Public | BindingFlags.Static, null, Type.EmptyTypes, null);
_initTried = true;
}
/// <summary>
/// Walks every known connection profile and returns the SSID of the first one that is
/// both connected and WLAN. Returns null when none qualifies.
/// </summary>
private static string ScanConnectionProfiles()
{
if (_getConnectionProfiles == null)
return null;
if (!(_getConnectionProfiles.Invoke(null, null) is IEnumerable profiles))
return null;
foreach (var profile in profiles)
{
if (!IsConnected(profile))
continue;
var ssid = ReadSsid(profile);
if (ssid != null)
return ssid;
}
return null;
}
/// <summary>
/// <c>ConnectionProfile.GetNetworkConnectivityLevel() != NetworkConnectivityLevel.None</c>.
/// A profile the device is not currently on would otherwise leak a stale SSID.
/// Unreadable level counts as not connected.
/// </summary>
private static bool IsConnected(object profile)
{
var level = profile?.GetType()
.GetMethod("GetNetworkConnectivityLevel", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null)
?.Invoke(profile, null);
return level != null && Convert.ToInt32(level) != 0; // NetworkConnectivityLevel.None
}
/// <summary>
/// <c>ConnectionProfile.WlanConnectionProfileDetails.GetConnectedSsid()</c>, or null when
/// the profile is not a WLAN profile or the SSID is empty (hidden network).
/// </summary>
private static string ReadSsid(object profile)
{
var details = profile?.GetType()
.GetProperty("WlanConnectionProfileDetails", BindingFlags.Public | BindingFlags.Instance)
?.GetValue(profile);
var ssid = details?.GetType()
.GetMethod("GetConnectedSsid", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null)
?.Invoke(details, null) as string;
return string.IsNullOrEmpty(ssid) ? null : ssid;
}
}
}
@@ -926,22 +926,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
Monitoring.NetworkInterfaceLocator.FindActiveNetworkInterface();
/// <summary>
/// Collects WiFi signal info via the native WLAN API (<see cref="WifiInfoReader"/>,
/// language-neutral, no process spawn) and emits a separate wifi_signal_info event.
/// Designed to run as fire-and-forget via Task.Run — never blocks other collection.
/// VMs without WiFi service simply get no event.
/// Collects WiFi signal info via <see cref="WifiInfoProvider"/> (native WLAN API → WinRT
/// SSID → netsh, all language-neutral bar the last) and emits a separate
/// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run — never blocks
/// other collection. VMs without WiFi service simply get no event.
/// </summary>
private void CollectWiFiSignalInfo(Guid? interfaceGuid)
{
try
{
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
var wifi = WifiInfoProvider.TryRead(interfaceGuid, out var wifiDiag);
if (wifi == null)
{
_logger.Debug($"EnrollmentTracker: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
wifi = NetshWifiFallback.TryRead();
if (wifi == null)
return;
// Warning, not Debug: the caller only reaches here when the active NIC IS
// WiFi, so a silent miss is a real gap — and Debug is off at the default
// log level, which is what made this invisible in the field.
_logger.Warning($"EnrollmentTracker: no wifi_signal_info — {wifiDiag}");
return;
}
var data = new Dictionary<string, object>();
@@ -953,12 +954,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
data["wifiRadioType"] = wifi.RadioType;
if (wifi.Channel.HasValue)
data["wifiChannel"] = wifi.Channel.Value;
if (wifi.DataLimitedReason != null)
data["wifiDataLimitedReason"] = wifi.DataLimitedReason;
var message = wifi.Ssid != null ? $"WiFi: {wifi.Ssid}" : "WiFi signal info";
if (wifi.SignalPercent.HasValue)
message += $", Signal: {wifi.SignalPercent.Value}%";
if (wifi.RadioType != null)
message += $" ({wifi.RadioType})";
// Say it in the timeline too — a missing signal reads like a bug otherwise, and
// this is the only place an admin ever learns that a privacy setting caused it.
if (wifi.DataLimitedReason == WifiInfoProvider.LocationServicesOff)
message += " — signal unavailable, Location services off";
EmitDeviceInfoEvent(Constants.EventTypes.WifiSignalInfo, message, data);
}
@@ -390,23 +390,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
}
/// <summary>
/// Collects WiFi SSID, signal, and radio type via the native WLAN API
/// (<see cref="WifiInfoReader"/> — language-neutral, no process spawn).
/// Synchronous — needed before emitting the event.
/// Collects WiFi SSID, signal, and radio type via <see cref="WifiInfoProvider"/>
/// (native WLAN API → WinRT SSID → netsh). Synchronous — needed before emitting the event.
/// </summary>
private (string ssid, int? signal, string radioType) CollectWiFiInfo(Guid? interfaceGuid)
{
try
{
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
if (wifi == null)
{
_logger.Debug($"NetworkChangeDetector: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
wifi = NetshWifiFallback.TryRead();
}
var wifi = WifiInfoProvider.TryRead(interfaceGuid, out var wifiDiag);
if (wifi != null)
return (wifi.Ssid, wifi.SignalPercent, wifi.RadioType);
// Warning, not Debug: reached only when the active NIC IS WiFi, and Debug is off
// at the default log level — which is what hid this failure in the field.
_logger.Warning($"NetworkChangeDetector: no WiFi info for the active WLAN adapter — {wifiDiag}");
}
catch (Exception ex)
{
@@ -13,18 +13,21 @@ using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// CERT-TENANT-BINDING-SHADOW — stage 1 coverage.
/// CERT-TENANT-BINDING — enforced since 2026-08-25.
///
/// The agent authenticates with the Intune MDM device certificate, whose chain is pinned to
/// Microsoft's Intune roots. Those roots are shared by every Intune tenant, so a valid chain alone
/// does not prove the caller belongs to the tenant it claims. The CA stamps the Entra tenant id
/// into OID 1.2.840.113556.5.14, which lets the backend bind the two.
///
/// These tests pin three things:
/// These tests pin four things:
/// - the tenant id is actually extracted from a REAL field certificate (not a synthetic fixture),
/// because if Microsoft ever changes that stamp, enforcement locks out the whole fleet,
/// - the pure comparison covers every outcome the telemetry can report,
/// - shadow mode observes without deciding: a cross-tenant certificate must still pass the
/// certificate stage in stage 1, or we would be enforcing before we measured.
/// - the enforcement rule rejects Mismatch and ONLY Mismatch — ExtensionMissing is tolerated on
/// purpose, and that tolerance has its own test so removing it has to be a decision,
/// - a rejected request still carries its telemetry and does not leak the certificate's tenant
/// back to the caller.
/// </summary>
public class CertTenantBindingTests
{
@@ -200,13 +203,58 @@ public class CertTenantBindingTests
Assert.False(CertTenantBinding.WouldRejectUnderEnforcement(outcome));
}
// ---------------------------------------------------------------- shadow mode
// ---------------------------------------------------------------- enforcement rule
[Fact]
public async Task ValidateRequestAsync_ForeignTenantCert_IsObservedButNotRejected()
public void Rejects_OnlyMismatch()
{
// Stage 1 contract: a cross-tenant certificate is logged and still passes the certificate
// stage. If this test ever fails, enforcement was switched on without the telemetry review.
// Mismatch is the only outcome that is evidence of a foreign certificate. The rest mean
// "cannot tell", and rejecting on those would cost legitimate devices their enrollment for
// something outside their control.
Assert.True(CertTenantBinding.Rejects(CertTenantBinding.Outcome.Mismatch));
Assert.False(CertTenantBinding.Rejects(CertTenantBinding.Outcome.Match));
Assert.False(CertTenantBinding.Rejects(CertTenantBinding.Outcome.ExtensionMissing));
Assert.False(CertTenantBinding.Rejects(CertTenantBinding.Outcome.Unparseable));
Assert.False(CertTenantBinding.Rejects(CertTenantBinding.Outcome.RequestTenantNotAGuid));
}
[Fact]
public void Rejects_ExtensionMissingIsDeliberatelyTolerated()
{
// Pinned on its own because it is the one that is expected to tighten later: certificates
// predating the extension, or a sovereign-cloud CA, must not be locked out today. Changing
// this is a decision, and this test is where that decision has to be made explicitly.
Assert.False(CertTenantBinding.Rejects(CertTenantBinding.Outcome.ExtensionMissing));
}
[Fact]
public void Rejects_AgreesWithTheTelemetryField()
{
// WouldRejectUnderEnforcement is what the log line reports. If the two ever diverge, the
// telemetry stops describing what actually happened.
foreach (var outcome in new[]
{
CertTenantBinding.Outcome.Match,
CertTenantBinding.Outcome.Mismatch,
CertTenantBinding.Outcome.ExtensionMissing,
CertTenantBinding.Outcome.Unparseable,
CertTenantBinding.Outcome.RequestTenantNotAGuid,
})
{
Assert.Equal(CertTenantBinding.Rejects(outcome),
CertTenantBinding.WouldRejectUnderEnforcement(outcome));
}
}
// ---------------------------------------------------------------- end-to-end
[Fact]
public async Task ValidateRequestAsync_ForeignTenantCert_IsRejected()
{
// The attack this whole feature exists for: a valid Intune certificate from the attacker's
// own tenant, replayed against a victim tenant whose device serial they know. Serials are
// printed on the chassis, so before this gate that was the entire barrier.
var logger = new CapturingLogger();
var validator = BuildValidator(logger);
@@ -214,16 +262,40 @@ public class CertTenantBindingTests
BuildRequestWithCert(SampleCertBase64()),
tenantId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
// Deliberately accepted: stage 1 measures, it does not enforce. This assertion is the one
// that must be inverted (to a 403) when stage 2 turns enforcement on.
Assert.True(result.IsValid, $"Shadow mode changed the outcome: {result.ErrorMessage} / {result.Details}");
Assert.NotEqual(HttpStatusCode.Unauthorized, result.StatusCode);
Assert.False(result.IsValid);
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
// The response must not reveal which tenant the certificate actually belongs to.
Assert.DoesNotContain(SampleCertTenantId.ToString(), result.Details ?? "", StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(SampleCertTenantId.ToString(), result.ErrorMessage ?? "", StringComparison.OrdinalIgnoreCase);
var observed = Assert.Single(logger.Entries, e => e.Message.Contains("AgentCertTenantBinding"));
Assert.Equal(LogLevel.Warning, observed.Level);
Assert.Contains(CertTenantBinding.Outcome.Mismatch, observed.Message);
Assert.Contains("enforced=False", observed.Message);
Assert.Contains("enforced=True", observed.Message);
Assert.Contains("wouldReject=True", observed.Message);
// The rejection must also land in the shared rejection log, so it shows up in the same KQL
// raster as every other reason a request was turned away.
Assert.Contains(logger.Entries, e => e.Message.Contains("AgentRequestRejected")
&& e.Message.Contains("stage=certtenant"));
}
[Fact]
public async Task ValidateRequestAsync_MismatchStillStampsTheRequestRow()
{
// Enforcement must not cost us the telemetry: a blocked request is exactly the one an
// operator will go looking for afterwards.
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));
}
[Fact]
@@ -115,7 +115,7 @@ public class VulnerabilityReportEventSequenceTests
Assert.Equal("vulnerability_report", evt.EventType);
Assert.Equal(AutopilotMonitor.Shared.Constants.EventTypes.VulnerabilityReport, evt.EventType);
Assert.Equal("VulnerabilityCorrelationService", evt.Source);
Assert.Equal(EventSeverity.Error, evt.Severity); // risk=high → Error
Assert.Equal(EventSeverity.Warning, evt.Severity); // risk=high → Warning (severity is capped)
// Non-phase event: must stay Unknown so the UI timeline doesn't render a phantom phase boundary.
Assert.Equal(EnrollmentPhase.Unknown, evt.Phase);
Assert.Equal(NowUtc, evt.Timestamp);
@@ -127,6 +127,35 @@ public class VulnerabilityReportEventSequenceTests
Assert.IsType<Dictionary<string, object>>(evt.Data["scan_summary"]);
}
[Theory]
// The scan reports on the software inventory, not on the enrollment: a finding is never an
// enrollment failure, so severity is capped at Warning. Error/Critical would put the report
// into the diagnosis page's failure evidence and into MCP's failure-intent ranking.
[InlineData("critical", EventSeverity.Warning)]
[InlineData("high", EventSeverity.Warning)]
[InlineData("medium", EventSeverity.Info)]
[InlineData("low", EventSeverity.Info)]
[InlineData("none", EventSeverity.Info)]
[InlineData("HIGH", EventSeverity.Warning)] // case-insensitive
[InlineData("", EventSeverity.Info)]
public void Severity_IsCappedAtWarning(string risk, EventSeverity expected)
{
var evt = Build(null, new List<EnrollmentEvent>(), Report(risk));
Assert.Equal(expected, evt.Severity);
}
[Fact]
public void Severity_IsInfo_WhenRiskIsMissing()
{
var report = new Dictionary<string, object>
{
["scan_summary"] = new Dictionary<string, object>(),
};
Assert.Equal(EventSeverity.Info, Build(null, new List<EnrollmentEvent>(), report).Severity);
}
[Fact]
public void Data_Phase_CarriesTheLabel()
{
@@ -1,4 +1,4 @@
using System;
using System;
namespace AutopilotMonitor.Functions.Security
{
@@ -14,10 +14,15 @@ namespace AutopilotMonitor.Functions.Security
/// </para>
/// </summary>
/// <remarks>
/// SHADOW MODE (stage 1): the outcome is observed and logged only — it never changes an
/// authorization decision. Enforcement is stage 2, once telemetry shows how many field
/// certificates actually carry the extension. Grep marker for the stage-2 change:
/// <c>CERT-TENANT-BINDING-SHADOW</c>.
/// ENFORCED since 2026-08-25 (stage 2): a <see cref="Outcome.Mismatch"/> is rejected with 403.
/// It shipped as shadow-only first and was switched on against measured data — 35,684 requests
/// across 32 tenants, every one of them a Match, with the Warning channel verified to be
/// carrying other traffic so the zero was not a telemetry artefact.
/// <para>
/// <see cref="Rejects"/> is the whole rule and the only thing that blocks; the outcome is still
/// recorded on every request. Grep marker for the sites involved:
/// <c>CERT-TENANT-BINDING</c>.
/// </para>
/// </remarks>
public static class CertTenantBinding
{
@@ -83,11 +88,36 @@ namespace AutopilotMonitor.Functions.Security
}
/// <summary>
/// Whether an outcome would block the request once stage-2 enforcement is switched on.
/// Nothing consumes this for authorization yet — it exists so the shadow telemetry can be
/// read as "would this have been rejected?" without re-deriving the rule at query time.
/// Whether an outcome blocks the request. This is the enforcement rule, in one place.
/// </summary>
public static bool WouldRejectUnderEnforcement(string outcome) =>
outcome == Outcome.Mismatch;
/// <remarks>
/// Only <see cref="Outcome.Mismatch"/> rejects, and only Mismatch is actual evidence of the
/// attack: a certificate the Intune CA issued to a different tenant than the one the request
/// claims. Everything else means "cannot tell" and must not cost a legitimate device its
/// enrollment:
/// <list type="bullet">
/// <item><description><see cref="Outcome.ExtensionMissing"/> — the certificate carries no
/// tenant stamp. Measured at 0 of 35,684 requests across 32 tenants before enforcement was
/// switched on, but that sample only covers devices that were active in the window; older
/// certificates or a sovereign-cloud CA could still lack it. Rejecting on absence would lock
/// those devices out for something they cannot control.
/// <para>
/// FUTURE TIGHTENING: once the fleet is provably all-5.14 (watch the ExtensionMissing rate
/// in the telemetry below), this can become a rejection too. That is a deliberate follow-up
/// decision, not an oversight.
/// </para></description></item>
/// <item><description><see cref="Outcome.Unparseable"/> — extension present but undecodable.
/// Same reasoning: a decoder or encoding surprise is our problem, not the device's.</description></item>
/// <item><description><see cref="Outcome.RequestTenantNotAGuid"/> — nothing to compare
/// against. A non-GUID tenant fails the tenant lookup in §0 long before this point anyway.</description></item>
/// </list>
/// </remarks>
public static bool Rejects(string outcome) => outcome == Outcome.Mismatch;
/// <summary>
/// Retained name for the telemetry field that records what the rule decided. Identical to
/// <see cref="Rejects"/> — kept so KQL written during the shadow phase keeps working.
/// </summary>
public static bool WouldRejectUnderEnforcement(string outcome) => Rejects(outcome);
}
}
@@ -278,11 +278,29 @@ namespace AutopilotMonitor.Functions.Security
};
}
// 1b. CERT-TENANT-BINDING-SHADOW — observe only, never decide.
// The cert chain is pinned to Microsoft's Intune roots, which every Intune tenant shares,
// so a valid cert alone does not prove the caller belongs to THIS tenant. Stage 1 measures
// how reliably the certificate's own tenant stamp matches before stage 2 enforces it.
ObserveCertTenantBinding(certValidation, tenantId, req, sessionId);
// 1b. CERT-TENANT-BINDING — the certificate must belong to THIS tenant.
// The chain is pinned to Microsoft's Intune roots, which every Intune tenant shares, so a
// valid cert alone only proves "issued to some tenant". Without this gate an attacker
// with their own Intune tenant and a known serial number of the victim tenant walks
// straight through, because device serials are printed on the chassis.
var certTenantOutcome = EvaluateCertTenantBinding(certValidation, tenantId, req, sessionId);
if (CertTenantBinding.Rejects(certTenantOutcome))
{
LogRequestRejection("certtenant", tenantId, req, sessionId,
extraReason: $"certificate belongs to tenant {certValidation.CertTenantId}",
thumbprint: certValidation.Thumbprint);
return new SecurityValidationResult
{
IsValid = false,
StatusCode = HttpStatusCode.Forbidden,
ErrorMessage = "Certificate does not belong to this tenant",
// Deliberately does not echo which tenant the certificate belongs to: that is
// the caller's own value on a genuine mismatch, and telling an attacker how the
// comparison landed helps them. The full pair is in the rejection log.
Details = "The client certificate was issued to a different Microsoft Entra tenant than this request targets."
};
}
// 2. Check rate limit (DoS protection)
// Effective limit = per-tenant override if set, otherwise the global default.
@@ -595,10 +613,10 @@ namespace AutopilotMonitor.Functions.Security
}
/// <summary>
/// CERT-TENANT-BINDING-SHADOW — stage 1 observability for the client-certificate tenant
/// binding. Evaluates whether the tenant stamped into the agent's certificate matches the
/// tenant the request claims, and logs the outcome. It deliberately returns nothing and
/// changes no state: enforcement is stage 2, after this telemetry shows the miss rate.
/// CERT-TENANT-BINDING — evaluates whether the tenant stamped into the agent's certificate
/// matches the tenant the request claims, records the outcome, and returns it so the caller
/// can enforce. The enforcement rule itself lives in <see cref="CertTenantBinding.Rejects"/>,
/// not here, so "what blocks a request" is answerable from one place.
/// </summary>
/// <remarks>
/// Two carriers, because they answer different questions and have different costs:
@@ -613,15 +631,16 @@ namespace AutopilotMonitor.Functions.Security
/// thumbprint, cert tenant, session — because each one is individually actionable.</description></item>
/// </list>
/// </remarks>
private void ObserveCertTenantBinding(
private string EvaluateCertTenantBinding(
CertificateValidationResult certValidation,
string tenantId,
HttpRequestData req,
string? sessionId)
{
var outcome = CertTenantBinding.Outcome.Match;
try
{
var outcome = CertTenantBinding.Evaluate(
outcome = CertTenantBinding.Evaluate(
certValidation.CertTenantId,
certValidation.CertTenantIdStatus,
tenantId);
@@ -633,7 +652,7 @@ namespace AutopilotMonitor.Functions.Security
// Everything below is the detail record for outcomes someone has to act on.
if (outcome == CertTenantBinding.Outcome.Match)
return;
return outcome;
var agentVersion = req.Headers.Contains("X-Agent-Version")
? req.Headers.GetValues("X-Agent-Version").FirstOrDefault() ?? "n/a"
@@ -650,15 +669,19 @@ namespace AutopilotMonitor.Functions.Security
var wouldReject = CertTenantBinding.WouldRejectUnderEnforcement(outcome);
_logger.LogWarning(template,
outcome, false, wouldReject, tenantId, certTenant, certValidation.CertTenantIdStatus,
outcome, true, wouldReject, tenantId, certTenant, certValidation.CertTenantIdStatus,
certValidation.Thumbprint ?? "n/a", sessionId ?? "n/a", agentVersion);
}
catch (Exception ex)
{
// Shadow-mode observability must never be able to fail a request that would
// otherwise have been accepted.
_logger.LogWarning(ex, "AgentCertTenantBinding observation failed for tenant {TenantId}", tenantId);
// Fail open on our own bug. This gate exists to stop a cross-tenant certificate, and
// an exception in the evaluation is not evidence of one - turning it into a rejection
// would take the whole fleet down over a defect on our side.
_logger.LogWarning(ex, "AgentCertTenantBinding evaluation failed for tenant {TenantId} - request allowed", tenantId);
return CertTenantBinding.Outcome.Match;
}
return outcome;
}
/// <summary>
@@ -541,12 +541,26 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
string.Equals(phase, "user_enrollment", StringComparison.Ordinal)
|| string.Equals(phase, "both", StringComparison.Ordinal);
/// <summary>
/// Maps the scan's overall CVE risk onto the event severity, capped at
/// <see cref="EventSeverity.Warning"/>.
/// <para>
/// <b>Why the cap:</b> <c>EventSeverity</c> on the timeline states how badly the
/// <i>enrollment</i> went, not how severe a CVE is. A completed scan is a finding about
/// the software inventory — nothing failed. Emitting Error/Critical made the report read
/// as an enrollment failure: the diagnosis page collects Error/Critical events as failure
/// evidence, MCP search lifts high-severity events on failure-intent queries, and the
/// timeline's Error filter — the one used to hunt for problems — filled up with clean
/// sessions. The CVE severity itself is not lost: it rides in <c>scan_summary</c>
/// (<c>overall_risk</c>, <c>critical_cves</c>, …) and the report panel renders it on its
/// own colour scale. Warning keeps the report visible without claiming a failure.
/// </para>
/// </summary>
private static EventSeverity MapVulnerabilityRiskToSeverity(string? overallRisk) =>
(overallRisk ?? string.Empty).ToLowerInvariant() switch
{
"critical" => EventSeverity.Critical,
"high" => EventSeverity.Error,
"medium" => EventSeverity.Warning,
"critical" => EventSeverity.Warning,
"high" => EventSeverity.Warning,
_ => EventSeverity.Info,
};
}
@@ -312,6 +312,17 @@ export const DEVICE_PROPERTIES_CATALOG = {
'network_interface_info.adapterName': { type: 'string', description: 'Network adapter name' },
'network_interface_info.macAddress': { type: 'string', description: 'MAC address of active adapter' },
},
wifi_signal_info: {
'wifi_signal_info.wifiSsid': { type: 'string', description: 'SSID of the WiFi network the device enrolled over' },
'wifi_signal_info.wifiSignalPercent': { type: 'number', description: 'WiFi signal quality 0-100. Use <=N to find weak-signal enrollments (e.g. "<=40")' },
'wifi_signal_info.wifiRadioType': { type: 'string', description: 'PHY generation, e.g. "802.11ac", "802.11ax", "802.11be"' },
'wifi_signal_info.wifiChannel': { type: 'number', description: '802.11 channel number' },
'wifi_signal_info.wifiDataLimitedReason': {
type: 'string',
description:
'Why the WiFi payload is SSID-only. Currently one value: "location_services_off" — Windows 11 24H2+ gates the native WLAN API behind precise-location consent, which is off in that device image, so signal/radio/channel could not be read. Filter on it to size how much of a fleet is affected.',
},
},
aad_join_status: {
'aad_join_status.joinType': { type: 'string', description: 'Azure AD join type' },
'aad_join_status.tenantId': { type: 'string', description: 'Entra ID tenant' },
@@ -538,9 +538,12 @@ function SessionReportsSectionInner({
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Report Details</h3>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300">
{selectedReport.reportId}
</span>
<div className="flex items-center">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300">
{selectedReport.reportId}
</span>
<CopyButton value={selectedReport.reportId} />
</div>
</div>
<dl className="space-y-3 text-sm">
@@ -16,7 +16,7 @@ interface OsInfoData { [key: string]: unknown; osVersion?: string; displayVersio
interface NetworkAdaptersData { [key: string]: unknown; adapters?: unknown[] }
interface DnsConfigData { [key: string]: unknown; dnsEntries?: unknown[] }
interface NetworkInterfaceInfoData { [key: string]: unknown; status?: string; connectionType?: string; adapterDescription?: string; linkSpeedMbps?: number; gateways?: string }
interface WifiSignalInfoData { [key: string]: unknown; wifiSsid?: string; wifiSignalPercent?: number; wifiRadioType?: string; wifiChannel?: number }
interface WifiSignalInfoData { [key: string]: unknown; wifiSsid?: string; wifiSignalPercent?: number; wifiRadioType?: string; wifiChannel?: number; wifiDataLimitedReason?: string }
interface ProxyConfigData { [key: string]: unknown; proxyType?: string; type?: string; proxyServer?: string; autoConfigUrl?: string; winHttpProxy?: string }
interface AutopilotProfileData { [key: string]: unknown; PolicyDownloadDate?: string; AutopilotCreationDate?: string; autopilotModeLabel?: string; domainJoinMethodLabel?: string }
interface ImeVersionData { [key: string]: unknown; version?: string; agentVersion?: string }
@@ -330,6 +330,11 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
)}
{wifiSignalInfo.wifiRadioType && <DetailRow label="Radio" value={wifiSignalInfo.wifiRadioType} />}
{wifiSignalInfo.wifiChannel !== undefined && <DetailRow label="Channel" value={`${wifiSignalInfo.wifiChannel}`} />}
{wifiSignalInfo.wifiDataLimitedReason === "location_services_off" && (
<div className="mt-2 text-xs text-gray-500">
{"Signal, radio and channel are unavailable because Location services are off on this device. Since Windows 11 24H2 those Wi-Fi details require location access, and the agent runs as SYSTEM with no way to ask for it. The SSID is unaffected. To collect the full details, enable Location services via Intune (Privacy → Let Apps Access Location → Force Allow)."}
</div>
)}
</div>
)}
@@ -3,10 +3,16 @@
import { useState, useMemo } from "react";
import { EnrollmentEvent, Session } from "@/types";
import { normalizeEventDataForDisplay, shortenBuildHashInMessage } from "../utils/eventHelpers";
import { buildEventSearchMatcher, parseEventSearchQuery } from "../utils/eventSearchQuery";
import { getEnrichedOrLookup, formatErrorCode, type ErrorCodeEntry } from "@/utils/errorCodeMap";
import { readTimeProvenance, classifyTimeJump, readClockChangeDeltaMs } from "@/lib/timeProvenance";
import { formatDuration, formatUtcOffset } from "@/lib/formatting";
const SEARCH_SYNTAX_HINT =
"Searches event type, message and source. Several terms are combined with AND. " +
"A leading minus hides matches, e.g. -app_install_progress. " +
'Quote a term to take it literally, e.g. "-1".';
interface EventTimelineProps {
filteredEvents: EnrollmentEvent[];
events: EnrollmentEvent[];
@@ -60,14 +66,16 @@ export default function EventTimeline({
const [searchQuery, setSearchQuery] = useState("");
const [rawMode, setRawMode] = useState(false);
const matchesSearch = useMemo(() => {
if (!searchQuery.trim()) return null;
const q = searchQuery.toLowerCase();
return (event: EnrollmentEvent) =>
event.eventType?.toLowerCase().includes(q) ||
event.message?.toLowerCase().includes(q) ||
event.source?.toLowerCase().includes(q);
}, [searchQuery]);
// Terms are AND-ed, a leading minus excludes — see utils/eventSearchQuery.ts.
const matchesSearch = useMemo(() => buildEventSearchMatcher(searchQuery), [searchQuery]);
const excludedTerms = useMemo(() => parseEventSearchQuery(searchQuery).exclude, [searchQuery]);
// The counter has to follow the search, otherwise excluding event types leaves it
// reporting a number of rows the timeline no longer shows.
const searchVisibleCount = useMemo(
() => (matchesSearch ? filteredEvents.filter(matchesSearch).length : filteredEvents.length),
[filteredEvents, matchesSearch],
);
const sortedBySequence = useMemo(() => {
let filtered = events.filter(e => severityFilters.has(e.severity));
@@ -102,7 +110,8 @@ export default function EventTimeline({
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search events..."
placeholder="Search or -exclude..."
title={SEARCH_SYNTAX_HINT}
className="w-full pl-7 pr-7 py-1 text-xs border border-gray-300 rounded-full focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-green-500"
/>
{searchQuery && (
@@ -139,7 +148,15 @@ export default function EventTimeline({
</button>
);
})}
<span className="text-xs text-gray-400">({filteredEvents.length}/{events.length})</span>
<span className="text-xs text-gray-400">({searchVisibleCount}/{events.length})</span>
{excludedTerms.length > 0 && (
<span
className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 border border-gray-300 text-gray-600 max-w-[16rem] truncate"
title={`Events matching ${excludedTerms.map(t => `"${t}"`).join(", ")} are hidden.`}
>
hiding {excludedTerms.join(", ")}
</span>
)}
<div className="flex gap-1.5 ml-auto items-center">
<button
onClick={() => setRawMode(!rawMode)}
@@ -209,6 +209,9 @@ export default function NetworkBand({ model }: { model: NetworkModel }) {
Signal: {seg.signalPercent}%{seg.radioType ? ` (${seg.radioType})` : ''}
</div>
)}
{seg.signalPercent == null && seg.dataLimitedReason === 'location_services_off' && (
<div>{'Signal: unavailable — Location services off on this device'}</div>
)}
{seg.adapterDescription && <div>{seg.adapterDescription}</div>}
{seg.kind === 'asleep' && (
<div>
@@ -199,3 +199,78 @@ describe('hotspot confidence', () => {
expect(ssidOnly.confident).toBe(true);
});
});
describe('location-gated WiFi payloads', () => {
it('carries the reason onto the segment that saw the sample', () => {
const model = buildNetworkModel(session, [
wifiInfo(1, 0),
ev(2, 'wifi_signal_info', 1 * MIN, {
wifiSsid: 'CorpNet',
wifiDataLimitedReason: 'location_services_off',
}),
ev(99, 'log_entry', 30 * MIN),
])!;
const wifi = model.segments.filter((s) => s.kind === 'wifi');
expect(wifi.length).toBeGreaterThan(0);
expect(wifi[0].ssid).toBe('CorpNet');
expect(wifi[0].signalPercent).toBeUndefined();
expect(wifi[0].dataLimitedReason).toBe('location_services_off');
});
it('extends the reason to WiFi segments that never got their own sample', () => {
// A mid-session reconnect opens a new segment but emits no wifi_signal_info of its
// own. The gate is a device setting and cannot flip mid-enrollment, so the later
// segment must explain its blank signal too.
const model = buildNetworkModel(session, [
wifiInfo(1, 0),
ev(2, 'wifi_signal_info', 1 * MIN, {
wifiSsid: 'CorpNet',
wifiDataLimitedReason: 'location_services_off',
}),
ev(3, 'network_state_change', 5 * MIN, {
hasNetwork: true,
after_connectionType: 'WiFi',
after_wifiSsid: 'GuestNet',
after_gateway: '10.0.1.1',
}),
ev(99, 'log_entry', 30 * MIN),
])!;
const wifi = model.segments.filter((s) => s.kind === 'wifi');
expect(wifi.length).toBe(2);
expect(wifi.every((s) => s.dataLimitedReason === 'location_services_off')).toBe(true);
});
it('never claims the gate when a signal reading actually arrived', () => {
const model = buildNetworkModel(session, [
wifiInfo(1, 0),
ev(2, 'wifi_signal_info', 1 * MIN, { wifiSsid: 'CorpNet', wifiSignalPercent: 80 }),
ev(99, 'log_entry', 30 * MIN),
])!;
for (const seg of model.segments) expect(seg.dataLimitedReason).toBeUndefined();
});
it('leaves a segment with its own reading untouched when another was gated', () => {
// Defensive: the session-wide fill must only touch segments WITHOUT a reading.
const model = buildNetworkModel(session, [
wifiInfo(1, 0),
ev(2, 'wifi_signal_info', 1 * MIN, { wifiSsid: 'CorpNet', wifiSignalPercent: 74 }),
ev(3, 'network_state_change', 5 * MIN, {
hasNetwork: true,
after_connectionType: 'WiFi',
after_wifiSsid: 'GuestNet',
after_gateway: '10.0.1.1',
}),
ev(4, 'wifi_signal_info', 6 * MIN, {
wifiSsid: 'GuestNet',
wifiDataLimitedReason: 'location_services_off',
}),
ev(99, 'log_entry', 30 * MIN),
])!;
const withReading = model.segments.find((s) => s.signalPercent === 74);
expect(withReading?.dataLimitedReason).toBeUndefined();
});
});
@@ -46,6 +46,12 @@ export interface NetSegment {
adapterDescription?: string;
signalPercent?: number;
radioType?: string;
/**
* Why signal/radio are absent, when the agent could tell — currently only
* 'location_services_off' (Windows 11 24H2+ gates the native WLAN API behind
* precise-location consent). Set from wifi_signal_info; never guessed.
*/
dataLimitedReason?: string;
hotspot?: HotspotHint;
/** 'asleep' segments only: sleep/hibernate/modern_standby + wake details. */
sleepKind?: string;
@@ -252,6 +258,18 @@ export function buildSegments(events: EnrollmentEvent[], t0: number, t1: number)
if (!seg.ssid && str(data.wifiSsid)) seg.ssid = str(data.wifiSsid);
if (typeof data.wifiSignalPercent === 'number') seg.signalPercent = data.wifiSignalPercent;
if (str(data.wifiRadioType)) seg.radioType = str(data.wifiRadioType);
if (str(data.wifiDataLimitedReason)) seg.dataLimitedReason = str(data.wifiDataLimitedReason);
}
// The location gate is a device-level setting, not a per-connection one: it cannot flip
// during an enrollment. So once any sample reports it, WiFi segments that never got a
// sample of their own (a mid-session reconnect emits no wifi_signal_info) carry the same
// explanation instead of showing an unexplained blank where the signal belongs.
const observedLimit = segs.find((s) => s.dataLimitedReason)?.dataLimitedReason;
if (observedLimit) {
for (const seg of segs) {
if (seg.kind === 'wifi' && seg.signalPercent == null) seg.dataLimitedReason = observedLimit;
}
}
// Infer missing SSIDs from a sibling WiFi segment on the same gateway
@@ -168,7 +168,13 @@ function StatsRow({ model }: { model: NetworkModel }) {
];
if (model.asleepMs > 0) items.push({ label: 'Asleep total', value: fmtDuration(model.asleepMs) });
if (model.clockChangeCount > 0) items.push({ label: 'Clock changes', value: String(model.clockChangeCount) });
if (avgSignal != null) items.push({ label: 'Avg WiFi signal', value: `${avgSignal}%` });
if (avgSignal != null) {
items.push({ label: 'Avg WiFi signal', value: `${avgSignal}%` });
} else if (model.segments.some((s) => s.dataLimitedReason === 'location_services_off')) {
// Say why the stat is empty. Otherwise a fleet-wide blank reads as a product defect
// rather than the Windows 24H2 location gate that actually caused it.
items.push({ label: 'Avg WiFi signal', value: 'n/a (Location services off)' });
}
if (lastCheck)
items.push({
label: 'Last connectivity check',
@@ -0,0 +1,119 @@
import { describe, it, expect } from "vitest";
import {
buildEventSearchMatcher,
parseEventSearchQuery,
type EventSearchFields,
} from "../eventSearchQuery";
const ev = (eventType: string, message = "", source = "Agent"): EventSearchFields => ({
eventType,
message,
source,
});
const timeline: EventSearchFields[] = [
ev("app_install_started", "Installing Contoso Reader"),
ev("app_install_progress", "Downloading 42%"),
ev("app_install_completed", "Contoso Reader installed"),
ev("esp_provisioning_status", "DeviceSetup: Apps 3/7"),
ev("enrollment_failed", "Apps failed with exit code -1", "DecisionEngine"),
];
const visible = (query: string) => {
const matcher = buildEventSearchMatcher(query);
const rows = matcher ? timeline.filter(matcher) : timeline;
return rows.map(e => e.eventType);
};
describe("parseEventSearchQuery", () => {
it("splits whitespace-separated terms and lowercases them", () => {
expect(parseEventSearchQuery("ESP Provisioning")).toEqual({
include: ["esp", "provisioning"],
exclude: [],
});
});
it("reads a leading minus as an exclusion", () => {
expect(parseEventSearchQuery("error -app_install_progress -perf")).toEqual({
include: ["error"],
exclude: ["app_install_progress", "perf"],
});
});
it("keeps a quoted minus literal — otherwise an exit code could not be searched", () => {
expect(parseEventSearchQuery('"-1"')).toEqual({ include: ["-1"], exclude: [] });
});
it("excludes a quoted phrase as one term", () => {
expect(parseEventSearchQuery('-"exit code 1"')).toEqual({
include: [],
exclude: ["exit code 1"],
});
});
it("drops a lone minus so the timeline does not blank out mid-typing", () => {
expect(parseEventSearchQuery("-")).toEqual({ include: [], exclude: [] });
});
it("de-duplicates repeated terms", () => {
expect(parseEventSearchQuery("app app -perf -perf")).toEqual({
include: ["app"],
exclude: ["perf"],
});
});
});
describe("buildEventSearchMatcher", () => {
it("returns null when there is nothing to filter on", () => {
expect(buildEventSearchMatcher("")).toBeNull();
expect(buildEventSearchMatcher(" ")).toBeNull();
expect(buildEventSearchMatcher("-")).toBeNull();
});
it("matches event type, message and source", () => {
const matcher = buildEventSearchMatcher("decisionengine")!;
expect(timeline.filter(matcher).map(e => e.eventType)).toEqual(["enrollment_failed"]);
expect(visible("contoso")).toEqual(["app_install_started", "app_install_completed"]);
});
it("hides every match of an excluded term", () => {
expect(visible("-app_install_progress")).toEqual([
"app_install_started",
"app_install_completed",
"esp_provisioning_status",
"enrollment_failed",
]);
});
it("treats a partial type name as a prefix filter over the whole family", () => {
expect(visible("-app_install")).toEqual(["esp_provisioning_status", "enrollment_failed"]);
});
it("combines a search term with exclusions", () => {
expect(visible("apps -esp")).toEqual(["enrollment_failed"]);
});
it("ANDs multiple search terms across different fields", () => {
expect(visible("app_install contoso")).toEqual([
"app_install_started",
"app_install_completed",
]);
});
it("never matches a term across a field boundary", () => {
// "started" ends eventType, "Installing" opens message — a naive concatenation
// would let the joined string match.
expect(visible("startedinstalling")).toEqual([]);
});
it("is case-insensitive on both sides", () => {
expect(visible("-APP_INSTALL_PROGRESS")).not.toContain("app_install_progress");
});
it("lets an exclusion win over a search term that also matches", () => {
expect(visible("app_install -progress")).toEqual([
"app_install_started",
"app_install_completed",
]);
});
});
@@ -0,0 +1,105 @@
// Search syntax for the Event Timeline search box.
//
// Follows the convention every search box shares (Google, GitHub, Gmail, Jira):
// whitespace-separated terms are AND-ed, a leading minus turns a term into an
// exclusion. Both sides match the same fields — eventType, message, source — so
// `-x` is exactly the negation of searching for `x`, with no second rule to learn.
//
// error events matching "error"
// esp provisioning events matching BOTH terms (in any of the three fields)
// -app_install_progress everything EXCEPT matches of that term
// error -heartbeat combined
// "-1" quoted: a literal minus, not an exclusion
// -"exit code 1" excludes the whole phrase
//
// Matching is case-insensitive substring, so a partial type name such as
// `-app_install` hides every app_install_* event.
/** The fields a search term is matched against. Structural subset of EnrollmentEvent. */
export interface EventSearchFields {
eventType?: string | null;
message?: string | null;
source?: string | null;
}
export interface ParsedEventSearchQuery {
/** Terms that must all match. Lowercased. */
include: string[];
/** Terms that must not match. Lowercased. */
exclude: string[];
}
interface Token {
text: string;
negated: boolean;
}
// Hand-rolled so a quote can protect a leading minus: the negation is decided at
// the first character of a token, before any quote is consumed, which makes `"-1"`
// a literal search term while `-"foo bar"` excludes the phrase.
function tokenize(query: string): Token[] {
const tokens: Token[] = [];
let current: Token | null = null;
let inQuotes = false;
const flush = () => {
if (current && current.text !== "") tokens.push(current);
current = null;
};
for (const ch of query) {
if (!inQuotes && /\s/.test(ch)) {
flush();
continue;
}
if (ch === '"') {
current ??= { text: "", negated: false };
inQuotes = !inQuotes;
continue;
}
if (current === null) {
// First character of a token: a bare minus opens an exclusion.
if (ch === "-") {
current = { text: "", negated: true };
continue;
}
current = { text: "", negated: false };
}
current.text += ch;
}
flush();
return tokens;
}
export function parseEventSearchQuery(query: string): ParsedEventSearchQuery {
const include: string[] = [];
const exclude: string[] = [];
for (const token of tokenize(query)) {
const text = token.text.toLowerCase();
const target = token.negated ? exclude : include;
if (!target.includes(text)) target.push(text);
}
return { include, exclude };
}
/**
* Predicate for the parsed query, or null when the query carries no filter at all
* (empty, whitespace, or a lone minus while the user is still typing) — callers use
* null to skip filtering entirely rather than filtering with an always-true predicate.
*/
export function buildEventSearchMatcher(
query: string,
): ((event: EventSearchFields) => boolean) | null {
const { include, exclude } = parseEventSearchQuery(query);
if (include.length === 0 && exclude.length === 0) return null;
return (event: EventSearchFields) => {
// Newline-joined so a term can never match across a field boundary, while a
// multi-term query is still free to satisfy its terms from different fields.
const haystack = `${event.eventType ?? ""}\n${event.message ?? ""}\n${event.source ?? ""}`.toLowerCase();
return include.every(t => haystack.includes(t)) && !exclude.some(t => haystack.includes(t));
};
}