mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41898d745a | |||
| c05ea4d63b | |||
| 68eb2da283 | |||
| ab0f27b5ab | |||
| 32d8151185 | |||
| f3749ccc16 | |||
| ca26cb4f7c | |||
| 8ceab7a064 | |||
| ae85dc325c | |||
| aeb2be6d64 | |||
| 0474223290 | |||
| 1bdb71be59 | |||
| ccf4f2e054 | |||
| d192a8e0fa | |||
| 06d92460ed | |||
| fc3900429c |
+1
-1
Submodule .claude/commands updated: 282e90206a...72383edb7c
@@ -158,3 +158,4 @@ docs/~$Introduction-AutopilotMonitor.pptx
|
||||
# Web build stamp: generated by scripts/generate-version.js on every build (buildUtc
|
||||
# changes each run) and served at /version.json from the static export.
|
||||
src/Web/autopilot-monitor-web/public/version.json
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
type: concept
|
||||
title: Registry App-State Second Pillar
|
||||
description: Why the agent reads IME's authoritative per-app state straight from the registry (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), how the snapshot-and-diff observer works, and how registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
|
||||
resource: agent
|
||||
tags: [ime, registry, win32apps, observability, reconciliation, pattern-drift, wdp]
|
||||
timestamp: 2026-08-18
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
## Why a second pillar
|
||||
|
||||
The 2026-08-17 audit of the decompiled IME (1.97.107 / 1.104.102) proved that IME log
|
||||
wording is an unstable contract: Microsoft removed the old V1/V2 processing pipeline
|
||||
(killing 9 patterns silently), reworded the post-remediation line, changed the
|
||||
report-delta quoting between 1.97 and 1.104, and deleted `[DO TEL]` entirely. The
|
||||
registry, by contrast, is IME's *internal product contract* — the ESP page and Intune
|
||||
reporting consume it — and cannot drift casually.
|
||||
|
||||
Architecture decision (Oliver, 2026-08-17): **hybrid**. Logs stay the narrative
|
||||
(downloads, retries, exit codes, scripts — the timeline). The registry is the stable
|
||||
truth for terminal state. Divergence between the two IS the alarm that log patterns
|
||||
drifted — turning silent data loss into a Warning event.
|
||||
|
||||
## Observed surfaces
|
||||
|
||||
All under `HKLM\SOFTWARE\Microsoft\IntuneManagementExtension` (64-bit view), verified
|
||||
against decompiled IME source:
|
||||
|
||||
| Surface | Content |
|
||||
|---|---|
|
||||
| `Win32Apps\<userGuid>\<appGuid>_<rev>` | `EnforcementStateMessage` JSON (EnforcementState + ErrorCode), `ExitCode`, `Intent`. Device context = `Guid.Empty` user. Non-GUID subkeys (GRS, OperationalState, Reporting, ProvisioningProgress) are skipped. |
|
||||
| `EspTrackingWin32Apps\<userGuid>\<appGuid>_<rev>` | Which apps IME registered for ESP tracking, `EspTrackingWin32AppPhase`. |
|
||||
| `SideCarPolicies\StatusServiceReports\<userGuid>\<appId>` | The `AppInstallStatusReport` the ESP page renders (`Status`: 1000=Installed, 1001=Installing, 3000=Failed, 2000er=NotApplicable). |
|
||||
|
||||
## Mechanism
|
||||
|
||||
`ImeRegistryAppStateObserver` + `ImeRegistryAppStateHost` (always-on, no config gate —
|
||||
precedent `EspPolicyProviderStallHost`):
|
||||
|
||||
- **One recursive `RegistryWatcher`** on the IME root covers all three surfaces;
|
||||
changes are debounced (2 s trailing edge) into a tick. A 60-s periodic tick is both
|
||||
the reconciliation-settle evaluator and the poll-only fallback when the watcher
|
||||
fails to arm (`collector_degraded`, once).
|
||||
- **Snapshot-and-diff**: `RegNotifyChangeKeyValue` gives coalesced key-scope edges
|
||||
only, so every tick re-reads the surfaces and diffs against the previous snapshot.
|
||||
- **Silent baseline**: the state present at agent start is captured without events.
|
||||
Win32Apps keys survive re-enrollments — replaying them would be the registry twin
|
||||
of the historic-IME-replay bug. Only apps that *change during the session* are
|
||||
reported or judged.
|
||||
- **`registry_app_state`** (Info): emitted per real field change
|
||||
(enforcementState/errorCode/exitCode/statusServiceStatus/espTracked/espPhase),
|
||||
capped at 200/session (cap announced once as Warning).
|
||||
- **`app_state_reconciliation`** (Warning, once per app, immediate upload): after a
|
||||
changed entry has been terminal (EnforcementState 1000er=success / 5000er=error,
|
||||
or StatusService 1000er/3000) for ≥ 90 s, its outcome is compared against the
|
||||
log-derived `AppPackageState` from `ImeLogHost.AllKnownPackageStates`. Divergence
|
||||
reasons: `registry_error_log_installed`, `registry_success_log_error`,
|
||||
`app_unknown_to_log_tracking` (only when the tracker is actively tracking apps —
|
||||
an idle tracker is not judgeable).
|
||||
- **Pure observability**: nothing feeds the DecisionEngine (same rule as
|
||||
oobe-state observability).
|
||||
|
||||
## WDP reuse (Paket 3)
|
||||
|
||||
The same host archetype (RegistryWatcher subtree + debounce + snapshot-diff) is the
|
||||
intended vehicle for WDP live progress: watch
|
||||
`HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\DevicePreparation\BootstrapperAgent`
|
||||
(`ProgressUpdates` rows, `ResumeContext.ProvisioningResult` terminal marker) with the
|
||||
RealmJoinWatcher appearance-watcher pattern for not-yet-existing keys, plus an
|
||||
Event-142 channel watcher (`Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider`,
|
||||
channel handling precedent in `DiagnosticsPackageService`). Win32Apps keys are written
|
||||
on WDP devices too — the pillar works on both rails unchanged.
|
||||
|
||||
# Examples
|
||||
|
||||
Timeline evidence of a drifted pattern set: apps install fine (registry says
|
||||
EnforcementState 1000) but the timeline shows no `app_install_completed` — each app
|
||||
raises one `app_state_reconciliation` with `reason=app_unknown_to_log_tracking`.
|
||||
Operator response: run `/ime-pattern-validate` against the session, fix patterns,
|
||||
backend-deploy (no agent release needed for pattern fixes).
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeRegistryAppStateObserver.cs`
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ImeRegistryAppStateHost.cs`
|
||||
- `tasks/ime-v1-code-audit-2026-08-17.md` (decompiled-source audit; registry surface catalog)
|
||||
- Decompile archive: `okieselbach/ime-decompiles` (private), `C:\Code\GitHubRepos\ime-decompiles\<version>\decompiled`
|
||||
@@ -25,6 +25,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [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.
|
||||
* [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.
|
||||
* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
|
||||
* [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.
|
||||
|
||||
# Rules
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-18
|
||||
|
||||
* **New**: `agent/registry-app-state-pillar.md` — the registry second pillar for IME app state (audit follow-up): snapshot-and-diff watcher on `HKLM\...\IntuneManagementExtension` (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), `registry_app_state` + `app_state_reconciliation` events, silent startup baseline, WDP reuse notes.
|
||||
|
||||
## 2026-08-17
|
||||
|
||||
* **Update**: `mcp/tool-telemetry.md` — the `tool_call` stderr line now carries `errorMessage` (capped at 300 chars; thrown message or the leading `toolError` text, absent on success), so error drilldowns name the failure class instead of guessing from args. Same change: `get_rule_stats` omits the per-rule daily `trend` rows by default (they drove 50% of calls over the response cap at platform scope); `includeTrends=true` opts back in, the response carries a `trendsNote` marker when stripped.
|
||||
|
||||
+38
-14
@@ -169,9 +169,9 @@
|
||||
{
|
||||
"patternId": "IME-DETECTED-INSTALLED-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DetectionState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"Installed\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format).",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -236,9 +236,9 @@
|
||||
{
|
||||
"patternId": "IME-ENFORCEMENT-SUCCESS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":(?:\"\"\\w+\"\"|null),\"\"NewValue\"\":\"\"Success\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17).",
|
||||
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -255,9 +255,9 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-DOWNLOAD",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgress\"\",\"\"NewValue\"\":\"\"ErrorDownloadingContent\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent.",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent. Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -285,12 +285,12 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":(?:\"\"(?<from>\\w+)\"\"|null),\"\"NewValue\"\":\"\"(?<to>\\w+)\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
},
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17).",
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -358,6 +358,22 @@
|
||||
"description": "Marks the current app as failed when its exit code maps to Failed via the admin-defined return-code table ('[Win32App] lpExitCode is defined as <Failed|Success|SoftReboot|HardReboot|Retry>', Win32AppInstaller.cs). This is the NORMAL mapped-exit-code outcome; the previously matched 'Setting enforcementState as ... Error' line only fires on the rare no-mapping fallback path. The numeric exit code is captured separately by IME-EXITCODE, which precedes this line.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-GRS-CLEARED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ExecutionActionHandler\\] Encountered early subgraph termination\\. Clearing GRS for app with id: {GUID} so it can be retried during the next check-in\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "IME aborted the app's subgraph before enforcement finished and cleared its GRS entry so the app retries on the next check-in (ExecutionActionHandler, AppWorkload.log). Maps to Postponed: the install did not complete this pass but is scheduled to run again - previously this looked like an unexplained gap. Wording verified in IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-GRS-SKIP",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID} to (?:install|uninstall) is in GRS\\. The app will not be enforced\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "GRS (Global Retry Schedule) skip: the app failed recently and IME will not re-enforce it this check-in ('App with id: <guid> to install is in GRS. The app will not be enforced.', EnforcementActionProcessor / MixedEnforcementActionProcessor). THE canonical reason an app silently does nothing for up to 24h - previously invisible. Lives in AppActionProcessor.log, which the agent tails since the 2026-08-18 release; wording verified in IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-HRESULT",
|
||||
"category": "currentPhase",
|
||||
@@ -415,25 +431,25 @@
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"ApplicabilityState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"ScriptRequirementRuleNotMet\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false).",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-3",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"None\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable).",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-4",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"NotPresent\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required).",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -608,6 +624,14 @@
|
||||
"description": "Detects when V3Processor starts a new subgraph, cancelling any stuck app and setting the new app as current.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-TOKEN-FAILURE",
|
||||
"category": "always",
|
||||
"pattern": "Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). Complements IME-TOKEN-SUCCESS: an auth outage (WAM broken, conditional access, network) previously left every check-in silently doing nothing with no timeline evidence. errorCode can be empty when IME logged none; the agent dedups to one Warning per distinct code. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-TOKEN-SUCCESS",
|
||||
"category": "always",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-DETECTED-INSTALLED-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DetectionState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"Installed\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format).",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ENFORCEMENT-SUCCESS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":(?:\"\"\\w+\"\"|null),\"\"NewValue\"\":\"\"Success\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17).",
|
||||
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-DOWNLOAD",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgress\"\",\"\"NewValue\"\":\"\"ErrorDownloadingContent\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent.",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent. Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":(?:\"\"(?<from>\\w+)\"\"|null),\"\"NewValue\"\":\"\"(?<to>\\w+)\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"parameters": { "checkTo": "true" },
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17).",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
},
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-GRS-CLEARED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ExecutionActionHandler\\] Encountered early subgraph termination\\. Clearing GRS for app with id: {GUID} so it can be retried during the next check-in\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "IME aborted the app's subgraph before enforcement finished and cleared its GRS entry so the app retries on the next check-in (ExecutionActionHandler, AppWorkload.log). Maps to Postponed: the install did not complete this pass but is scheduled to run again - previously this looked like an unexplained gap. Wording verified in IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-GRS-SKIP",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID} to (?:install|uninstall) is in GRS\\. The app will not be enforced\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "GRS (Global Retry Schedule) skip: the app failed recently and IME will not re-enforce it this check-in ('App with id: <guid> to install is in GRS. The app will not be enforced.', EnforcementActionProcessor / MixedEnforcementActionProcessor). THE canonical reason an app silently does nothing for up to 24h - previously invisible. Lives in AppActionProcessor.log, which the agent tails since the 2026-08-18 release; wording verified in IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NOT-APPLICABLE-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"ApplicabilityState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"ScriptRequirementRuleNotMet\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false).",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NOT-APPLICABLE-3",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"None\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable).",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NOT-APPLICABLE-4",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"NotPresent\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required).",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-TOKEN-FAILURE",
|
||||
"category": "always",
|
||||
"pattern": "Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). Complements IME-TOKEN-SUCCESS: an auth outage (WAM broken, conditional access, network) previously left every check-in silently doing nothing with no timeline evidence. errorCode can be empty when IME logged none; the agent dedups to one Warning per distinct code. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -61,6 +61,7 @@
|
||||
"cancelStuckAndSetCurrent",
|
||||
"imeSessionChange",
|
||||
"imeImpersonation",
|
||||
"imeTokenFailure",
|
||||
"enrollmentCompleted",
|
||||
"updateDoTelemetry",
|
||||
"scriptStarted",
|
||||
|
||||
@@ -36,6 +36,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
EspSkipConfigurationProbe.TestOverride = _ => (null, null);
|
||||
EspSkipConfigurationProbe.FullTestOverride = _ => EspFirstSyncSnapshot.Empty;
|
||||
// Same reasoning for the IME-install-source probe: a dev machine with real
|
||||
// EnterpriseDesktopAppManagement CSP state would nondeterministically enrich
|
||||
// ime_agent_version payloads in adapter tests.
|
||||
ImeMsiInstallSourceProbe.TestOverride = (_, _) => ImeMsiInstallSource.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+70
-2
@@ -46,7 +46,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
Directory.CreateDirectory(SpoolFolder);
|
||||
}
|
||||
|
||||
public DiagnosticsPackageService Build()
|
||||
public DiagnosticsPackageService Build(System.Action<AgentConfiguration>? mutateConfig = null)
|
||||
{
|
||||
// BackendApiClient is required by the public ctor but BuildArchiveBytes
|
||||
// never touches it — construct with a throwaway HttpClient. Tests only
|
||||
@@ -61,8 +61,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
bootstrapToken: null,
|
||||
agentVersion: "0.0.0",
|
||||
logger: Logger);
|
||||
var cfg = Cfg();
|
||||
mutateConfig?.Invoke(cfg);
|
||||
return new DiagnosticsPackageService(
|
||||
Cfg(),
|
||||
cfg,
|
||||
Logger,
|
||||
apiClient,
|
||||
agentLogFolderOverride: LogsTmp.Path,
|
||||
@@ -131,6 +133,72 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
Assert.Contains("AgentMarkers/agent_clean_exit.marker", entries);
|
||||
}
|
||||
|
||||
// ============================================================ package manifest ====
|
||||
// The agent-log snapshot is zipped BEFORE packaging finishes, so packaging problems
|
||||
// are invisible in the uploaded archive's own log (field case: missing evtx in
|
||||
// sessions a11102f4/3ae7528b). The manifest travels INSIDE the ZIP instead.
|
||||
|
||||
private static string? ReadZipEntryText(byte[] bytes, string entryName)
|
||||
{
|
||||
using var ms = new MemoryStream(bytes);
|
||||
using var archive = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Read);
|
||||
var entry = archive.GetEntry(entryName);
|
||||
if (entry == null) return null;
|
||||
using var reader = new StreamReader(entry.Open());
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_always_writes_package_manifest_with_added_entries()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
File.WriteAllText(Path.Combine(rig.StateFolder, "snapshot.json"), "{}");
|
||||
|
||||
var bytes = rig.Build().BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Contains("ADDED: AgentState/snapshot.json", manifest);
|
||||
// Empty standard folders (e.g. ImeLogs) surface as NO MATCH lines — the silent
|
||||
// nothing-found case is exactly what the manifest exists to make visible.
|
||||
Assert.Contains("NO MATCH:", manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_manifest_records_guard_blocked_additional_path()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var blockedPath = @"C:\Windows\System32\config\SYSTEM";
|
||||
|
||||
var bytes = rig.Build(cfg => cfg.DiagnosticsLogPaths.Add(
|
||||
new AutopilotMonitor.Shared.Models.DiagnosticsLogPath { Path = blockedPath }))
|
||||
.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Contains($"BLOCKED (path guard): {blockedPath}", manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_manifest_records_users_path_blocked_even_in_unrestricted()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
// The test temp dir lives under C:\Users — blocked by the always-on privacy guard
|
||||
// even in unrestricted mode. The manifest must say so instead of staying silent.
|
||||
var configuredPath = Path.Combine(rig.DataFolder, "does-not-exist.evtx");
|
||||
|
||||
var bytes = rig.Build(cfg =>
|
||||
{
|
||||
cfg.UnrestrictedMode = true;
|
||||
cfg.DiagnosticsLogPaths.Add(new AutopilotMonitor.Shared.Models.DiagnosticsLogPath { Path = configuredPath });
|
||||
})
|
||||
.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Contains($"BLOCKED (path guard): {configuredPath}", manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_excludes_top_level_session_id_and_bootstrap_config()
|
||||
{
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Selection/normalization logic of <see cref="ImeMsiInstallSourceProbe"/> — the registry
|
||||
/// walk itself is not unit-testable (live HKLM), so tests cover the pure pieces:
|
||||
/// <c>SelectBestMatch</c>, <c>VersionsEqual</c>, <c>IsImeMsiUrl</c>.
|
||||
/// </summary>
|
||||
public class ImeMsiInstallSourceProbeTests
|
||||
{
|
||||
private const string ImeProductCode = "{6A7DFC50-0395-4E1E-BF84-ED1404E72051}";
|
||||
private const string ImeUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi";
|
||||
|
||||
private static ImeMsiInstallSourceProbe.MsiEntry Entry(
|
||||
string productCode = ImeProductCode,
|
||||
string version = "1.104.102.0",
|
||||
string url = ImeUrl)
|
||||
=> new ImeMsiInstallSourceProbe.MsiEntry(productCode, version, url);
|
||||
|
||||
// -- SelectBestMatch -----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_exact_product_version_wins()
|
||||
{
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>
|
||||
{
|
||||
Entry(productCode: "{OTHER}", version: "9.9.9.9", url: "https://x/Other.msi"),
|
||||
Entry(),
|
||||
},
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.True(result.HasData);
|
||||
Assert.True(result.MatchedByVersion);
|
||||
Assert.Equal(ImeProductCode, result.ProductCode);
|
||||
Assert.Equal("1.104.102.0", result.ProductVersion);
|
||||
Assert.Equal(ImeUrl, result.DownloadUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_normalized_version_matches_trailing_zero()
|
||||
{
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry> { Entry(version: "1.104.102") },
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.True(result.HasData);
|
||||
Assert.True(result.MatchedByVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_version_mismatch_falls_back_to_ime_msi_filename()
|
||||
{
|
||||
// IME self-updated between MSI enforcement and log line — versions drift, but the
|
||||
// enforced IntuneWindowsAgent.msi entry is still the install source.
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>
|
||||
{
|
||||
Entry(productCode: "{OTHER}", version: "5.0.0.0", url: "https://x/SomeLobApp.msi"),
|
||||
Entry(version: "1.103.100.0"),
|
||||
},
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.True(result.HasData);
|
||||
Assert.False(result.MatchedByVersion);
|
||||
Assert.Equal("1.103.100.0", result.ProductVersion);
|
||||
Assert.Equal(ImeUrl, result.DownloadUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_version_match_beats_filename_match_on_other_entry()
|
||||
{
|
||||
// A stale IntuneWindowsAgent.msi row must not shadow the entry whose
|
||||
// ProductVersion equals the running build.
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>
|
||||
{
|
||||
Entry(productCode: "{STALE}", version: "1.103.100.0"),
|
||||
Entry(productCode: "{CURRENT}", version: "1.104.102.0", url: "https://y/IntuneWindowsAgent.msi"),
|
||||
},
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.True(result.MatchedByVersion);
|
||||
Assert.Equal("{CURRENT}", result.ProductCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_no_plausible_entry_returns_empty()
|
||||
{
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>
|
||||
{
|
||||
Entry(productCode: "{OTHER}", version: "5.0.0.0", url: "https://x/SomeLobApp.msi"),
|
||||
},
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.False(result.HasData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_empty_or_null_candidates_return_empty()
|
||||
{
|
||||
Assert.False(ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>(), "1.104.102.0").HasData);
|
||||
Assert.False(ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
null, "1.104.102.0").HasData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_null_registry_values_do_not_throw()
|
||||
{
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>
|
||||
{
|
||||
new ImeMsiInstallSourceProbe.MsiEntry("{PARTIAL}", null, null),
|
||||
Entry(),
|
||||
},
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.True(result.HasData);
|
||||
Assert.Equal(ImeProductCode, result.ProductCode);
|
||||
}
|
||||
|
||||
// -- VersionsEqual ---------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.104.102.0", "1.104.102.0", true)]
|
||||
[InlineData("1.104.102", "1.104.102.0", true)]
|
||||
[InlineData("1.104.102.0", "1.104.102", true)]
|
||||
[InlineData(" 1.104.102.0 ", "1.104.102.0", true)]
|
||||
[InlineData("1.104.102.0", "1.104.103.0", false)]
|
||||
[InlineData("1.104.102.0", "1.104.102.1", false)]
|
||||
[InlineData("1.104", "1.104.102.0", false)]
|
||||
[InlineData("abc", "abc", true)] // non-numeric: whole-string equality
|
||||
[InlineData("abc", "abd", false)] // non-numeric components never fuzzy-match
|
||||
[InlineData(null, "1.0", false)]
|
||||
[InlineData("1.0", null, false)]
|
||||
[InlineData("", "", false)]
|
||||
public void VersionsEqual_cases(string? a, string? b, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ImeMsiInstallSourceProbe.VersionsEqual(a, b));
|
||||
}
|
||||
|
||||
// -- IsImeMsiUrl -------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(ImeUrl, true)]
|
||||
[InlineData("https://x/intunewindowsagent.MSI", true)] // case-insensitive
|
||||
[InlineData("https://x/IntuneWindowsAgent.msi?sv=abc&sig=def", true)] // query ignored
|
||||
[InlineData("IntuneWindowsAgent.msi", true)] // bare filename
|
||||
[InlineData("https://x/NotTheAgent.msi", false)]
|
||||
[InlineData("https://x/IntuneWindowsAgent.msi.bak", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsImeMsiUrl_cases(string? url, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ImeMsiInstallSourceProbe.IsImeMsiUrl(url));
|
||||
}
|
||||
|
||||
// -- ScopedOverride ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ScopedOverride_restores_previous_override_on_dispose()
|
||||
{
|
||||
// Assembly default (TestAssemblyInit) forces Empty; a nested scope must restore it.
|
||||
Func<string, AgentLogger, ImeMsiInstallSource> before =
|
||||
ImeMsiInstallSourceProbe.TestOverride;
|
||||
using (new ImeMsiInstallSourceProbe.ScopedOverride((_, _) =>
|
||||
ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry> { Entry() }, "1.104.102.0")))
|
||||
{
|
||||
Assert.True(ImeMsiInstallSourceProbe.Read("1.104.102.0").HasData);
|
||||
}
|
||||
Assert.Same(before, ImeMsiInstallSourceProbe.TestOverride);
|
||||
}
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry second pillar (audit 2026-08-17): pure parsing/diff logic plus the
|
||||
/// tick flow (baseline silence, state-change emission, reconciliation dwell)
|
||||
/// driven through the snapshot override — no live registry.
|
||||
/// </summary>
|
||||
public sealed class ImeRegistryAppStateObserverTests
|
||||
{
|
||||
private const string App1 = "11111111-1111-1111-1111-111111111111";
|
||||
private const string App2 = "22222222-2222-2222-2222-222222222222";
|
||||
private static readonly DateTime T0 = new DateTime(2026, 8, 18, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ── pure helpers ────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("11111111-1111-1111-1111-111111111111_1", "11111111-1111-1111-1111-111111111111")]
|
||||
[InlineData("11111111-1111-1111-1111-111111111111", "11111111-1111-1111-1111-111111111111")]
|
||||
[InlineData("GRS", null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData("not-a-guid_1", null)]
|
||||
public void ExtractAppId_parses_app_keys_and_rejects_non_app_keys(string keyName, string? expected)
|
||||
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ExtractAppId(keyName));
|
||||
|
||||
[Fact]
|
||||
public void ParseEnforcementStateMessage_reads_state_and_error()
|
||||
{
|
||||
var (state, error) = ImeRegistryAppStateObserver.ParseEnforcementStateMessage(
|
||||
"{\"EnforcementState\":1000,\"ErrorCode\":0}");
|
||||
Assert.Equal(1000, state);
|
||||
Assert.Equal(0L, error);
|
||||
|
||||
Assert.Equal((null, null), ImeRegistryAppStateObserver.ParseEnforcementStateMessage("not json"));
|
||||
Assert.Equal((null, null), ImeRegistryAppStateObserver.ParseEnforcementStateMessage("[]"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1000, "success")]
|
||||
[InlineData(1004, "success")]
|
||||
[InlineData(2009, "inProgress")]
|
||||
[InlineData(3000, "requirementsNotMet")]
|
||||
[InlineData(5003, "error")]
|
||||
[InlineData(6001, "notAttempted")]
|
||||
[InlineData(42, "unknown")]
|
||||
public void ClassifyEnforcementState_bands(int state, string expected)
|
||||
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyEnforcementState(state));
|
||||
|
||||
[Fact]
|
||||
public void DiffSnapshots_reports_only_changed_fields()
|
||||
{
|
||||
var prev = Snapshot((App1, 2009, null));
|
||||
var next = Snapshot((App1, 1000, 0), (App2, 2000, null));
|
||||
|
||||
var changes = ImeRegistryAppStateObserver.DiffSnapshots(prev, next);
|
||||
|
||||
Assert.Equal(2, changes.Count);
|
||||
var app1Change = changes.Single(c => c.Entry.AppId == App1);
|
||||
Assert.Contains("enforcementState", app1Change.ChangedFields);
|
||||
Assert.Contains("errorCode", app1Change.ChangedFields);
|
||||
Assert.False(app1Change.IsNew);
|
||||
Assert.True(changes.Single(c => c.Entry.AppId == App2).IsNew);
|
||||
|
||||
Assert.Empty(ImeRegistryAppStateObserver.DiffSnapshots(next, next));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDivergent_rules()
|
||||
{
|
||||
var installed = PackageState(App1, AppInstallationState.Installed);
|
||||
var errored = PackageState(App1, AppInstallationState.Error);
|
||||
|
||||
Assert.True(ImeRegistryAppStateObserver.IsDivergent("error", installed, true, out var r1));
|
||||
Assert.Equal("registry_error_log_installed", r1);
|
||||
Assert.True(ImeRegistryAppStateObserver.IsDivergent("success", errored, true, out var r2));
|
||||
Assert.Equal("registry_success_log_error", r2);
|
||||
Assert.True(ImeRegistryAppStateObserver.IsDivergent("success", null, true, out var r3));
|
||||
Assert.Equal("app_unknown_to_log_tracking", r3);
|
||||
// Tracker idle (no apps at all) — not judgeable, no false alarm.
|
||||
Assert.False(ImeRegistryAppStateObserver.IsDivergent("success", null, false, out _));
|
||||
// Agreement.
|
||||
Assert.False(ImeRegistryAppStateObserver.IsDivergent("success", installed, true, out _));
|
||||
Assert.False(ImeRegistryAppStateObserver.IsDivergent("error", errored, true, out _));
|
||||
}
|
||||
|
||||
// ── tick flow ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Baseline_is_silent_then_changes_emit_state_events()
|
||||
{
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: null);
|
||||
var current = Snapshot((App1, 2000, null));
|
||||
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
Assert.Empty(Events(sink, SharedEventTypes.RegistryAppState));
|
||||
|
||||
current = Snapshot((App1, 1000, 0));
|
||||
observer.Tick("registry_change");
|
||||
}
|
||||
|
||||
var evt = Assert.Single(Events(sink, SharedEventTypes.RegistryAppState));
|
||||
Assert.Equal(App1, evt.Payload!["appId"]);
|
||||
Assert.Equal("1000", evt.Payload["enforcementState"]);
|
||||
Assert.Equal("success", evt.Payload["enforcementClass"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unchanged_snapshot_emits_nothing()
|
||||
{
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: null);
|
||||
var current = Snapshot((App1, 2000, null));
|
||||
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
observer.Tick("periodic");
|
||||
observer.Tick("periodic");
|
||||
}
|
||||
|
||||
Assert.Empty(Events(sink, SharedEventTypes.RegistryAppState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconciliation_fires_once_after_settle_delay_on_divergence()
|
||||
{
|
||||
var tracker = new List<AppPackageState> { PackageState(App1, AppInstallationState.Installed) };
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: tracker);
|
||||
var current = Snapshot((App1, 2000, null));
|
||||
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
current = Snapshot((App1, 5003, 101)); // registry error, log says Installed
|
||||
observer.Tick("registry_change");
|
||||
|
||||
// Before the settle delay: no reconciliation yet.
|
||||
Assert.Empty(Events(sink, SharedEventTypes.AppStateReconciliation));
|
||||
|
||||
clock.Advance(ImeRegistryAppStateObserver.ReconcileSettleDelay + TimeSpan.FromSeconds(1));
|
||||
observer.Tick("periodic");
|
||||
observer.Tick("periodic"); // second pass must not re-emit
|
||||
}
|
||||
|
||||
var rec = Assert.Single(Events(sink, SharedEventTypes.AppStateReconciliation));
|
||||
Assert.Equal("error", rec.Payload!["registryOutcome"]);
|
||||
Assert.Equal("registry_error_log_installed", rec.Payload["reason"]);
|
||||
Assert.Equal("Installed", rec.Payload["logState"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconciliation_skips_agreeing_and_baseline_only_apps()
|
||||
{
|
||||
var tracker = new List<AppPackageState> { PackageState(App1, AppInstallationState.Installed) };
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: tracker);
|
||||
|
||||
// App2 is terminal in the BASELINE (pre-existing from an earlier enrollment) and
|
||||
// never changes — it must never be judged. App1 changes and agrees with the log.
|
||||
var current = Snapshot((App1, 2000, null), (App2, 5000, 1));
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
current = Snapshot((App1, 1000, 0), (App2, 5000, 1));
|
||||
observer.Tick("registry_change");
|
||||
|
||||
clock.Advance(ImeRegistryAppStateObserver.ReconcileSettleDelay + TimeSpan.FromSeconds(1));
|
||||
observer.Tick("periodic");
|
||||
}
|
||||
|
||||
Assert.Empty(Events(sink, SharedEventTypes.AppStateReconciliation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_event_cap_emits_single_warning_then_suppresses()
|
||||
{
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: null);
|
||||
|
||||
var baseline = new ImeRegistrySnapshot();
|
||||
var flooded = new ImeRegistrySnapshot();
|
||||
for (var i = 0; i < ImeRegistryAppStateObserver.MaxStateEventsPerSession + 25; i++)
|
||||
{
|
||||
var appId = Guid.NewGuid().ToString("D");
|
||||
baseline.GetOrAdd(ImeRegistrySnapshot.DeviceContext, appId).EnforcementState = 2000;
|
||||
flooded.GetOrAdd(ImeRegistrySnapshot.DeviceContext, appId).EnforcementState = 1000;
|
||||
}
|
||||
|
||||
var current = baseline;
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
current = flooded;
|
||||
observer.Tick("registry_change");
|
||||
}
|
||||
|
||||
var events = Events(sink, SharedEventTypes.RegistryAppState);
|
||||
// Cap payload-carrying events + exactly one cap-notice Warning.
|
||||
Assert.Equal(ImeRegistryAppStateObserver.MaxStateEventsPerSession + 1, events.Count);
|
||||
Assert.Single(events, e => e.Payload != null
|
||||
&& e.Payload.TryGetValue(SignalPayloadKeys.Message, out var m)
|
||||
&& m.Contains("cap reached"));
|
||||
}
|
||||
|
||||
// ── harness ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static (FakeSignalIngressSink sink, VirtualClock clock, ImeRegistryAppStateObserver observer)
|
||||
CreateObserver(IReadOnlyList<AppPackageState>? trackerApps)
|
||||
{
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(T0);
|
||||
var post = new InformationalEventPost(sink, clock);
|
||||
var observer = new ImeRegistryAppStateObserver(
|
||||
post,
|
||||
logger: null,
|
||||
clock: clock,
|
||||
trackerStateProbe: trackerApps == null ? null : () => trackerApps);
|
||||
return (sink, clock, observer);
|
||||
}
|
||||
|
||||
private static ImeRegistrySnapshot Snapshot(params (string appId, int enforcementState, long? errorCode)[] apps)
|
||||
{
|
||||
var snapshot = new ImeRegistrySnapshot();
|
||||
foreach (var (appId, state, error) in apps)
|
||||
{
|
||||
var entry = snapshot.GetOrAdd(ImeRegistrySnapshot.DeviceContext, appId);
|
||||
entry.EnforcementState = state;
|
||||
entry.ErrorCode = error;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static AppPackageState PackageState(string id, AppInstallationState state)
|
||||
{
|
||||
var pkg = new AppPackageState(id, listPos: 0);
|
||||
if (state == AppInstallationState.Installed)
|
||||
{
|
||||
// Route through Installing first — a bare Installed would trip the
|
||||
// inverse-detection auto-downgrade heuristic (Installed w/o activity -> Skipped).
|
||||
pkg.UpdateState(AppInstallationState.Installing);
|
||||
}
|
||||
pkg.UpdateState(state);
|
||||
return pkg;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> Events(FakeSignalIngressSink sink, string eventType) =>
|
||||
sink.Posted.Where(p =>
|
||||
p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType).ToList();
|
||||
}
|
||||
}
|
||||
+113
@@ -131,4 +131,117 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
trySchtasks: null!));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins the restricted-token launch ordering introduced 2026-08-17: MDM LOB MSI installs
|
||||
/// (EnterpriseDesktopAppManagement CSP → msiexec chain) carry a SYSTEM token with
|
||||
/// SeTimeZonePrivilege / SeSystemEnvironmentPrivilege REMOVED, and WMI Win32_Process.Create
|
||||
/// duplicates the caller token onto the runtime — so on a stripped token the Scheduled Task
|
||||
/// (fresh full SYSTEM token) must launch first and WMI is only the degraded fallback. These
|
||||
/// tests fail loudly if a refactor silently restores WMI-first for the stripped-token case
|
||||
/// or drops the process verification after schtasks /Run.
|
||||
/// </summary>
|
||||
public sealed class InstallModeRestrictedTokenLaunchTests
|
||||
{
|
||||
[Fact]
|
||||
public void Schtasks_success_with_verified_pid_returns_schtasks_and_never_touches_wmi()
|
||||
{
|
||||
var wmiInvocations = 0;
|
||||
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 0,
|
||||
verifyRuntimePid: () => 4321,
|
||||
tryWmi: () => { wmiInvocations++; return new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 9999); });
|
||||
|
||||
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Schtasks, result.Method);
|
||||
Assert.Equal(4321, result.Pid);
|
||||
Assert.Equal(0, result.SchtasksExitCode);
|
||||
Assert.Equal(0, wmiInvocations);
|
||||
Assert.Contains("full SYSTEM token", result.Diagnostic);
|
||||
Assert.Contains("PID=4321", result.Diagnostic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Schtasks_queued_but_no_process_appears_falls_back_to_wmi()
|
||||
{
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 0,
|
||||
verifyRuntimePid: () => 0,
|
||||
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 777));
|
||||
|
||||
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Wmi, result.Method);
|
||||
Assert.Equal(777, result.Pid);
|
||||
Assert.Contains("no runtime process appeared", result.Diagnostic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Schtasks_failure_skips_verification_and_falls_back_to_wmi()
|
||||
{
|
||||
var verifyInvocations = 0;
|
||||
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 1,
|
||||
verifyRuntimePid: () => { verifyInvocations++; return 0; },
|
||||
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 555));
|
||||
|
||||
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Wmi, result.Method);
|
||||
Assert.Equal(0, verifyInvocations);
|
||||
Assert.Equal(1, result.SchtasksExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wmi_fallback_diagnostic_warns_that_the_runtime_stays_degraded()
|
||||
{
|
||||
// Support line: a restricted-token WMI launch means timezone auto-set / firmware
|
||||
// reads will fail for this runtime instance — the log must say so explicitly.
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 1,
|
||||
verifyRuntimePid: () => 0,
|
||||
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 555));
|
||||
|
||||
Assert.Contains("RESTRICTED TOKEN", result.Diagnostic);
|
||||
Assert.Contains("timezone", result.Diagnostic, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Both_paths_fail_returns_deferred_with_both_codes_captured()
|
||||
{
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 1,
|
||||
verifyRuntimePid: () => 0,
|
||||
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(2u, 0));
|
||||
|
||||
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Deferred, result.Method);
|
||||
Assert.Equal(0, result.Pid);
|
||||
Assert.Equal(2u, result.WmiReturnValue);
|
||||
Assert.Equal(1, result.SchtasksExitCode);
|
||||
Assert.Contains("BootTrigger", result.Diagnostic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wmi_returnvalue_zero_but_pid_zero_does_not_count_as_success()
|
||||
{
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 1,
|
||||
verifyRuntimePid: () => 0,
|
||||
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 0));
|
||||
|
||||
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Deferred, result.Method);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_delegates_are_rejected()
|
||||
{
|
||||
Func<int> zero = () => 0;
|
||||
Func<AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt> wmi =
|
||||
() => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 1);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(null!, zero, wmi));
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(zero, null!, wmi));
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(zero, zero, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using AutopilotMonitor.Agent.V2;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Environment-stable pins for the token probe. Whether SeTimeZonePrivilege itself is
|
||||
/// held depends on the test runner's token, so it is NOT asserted here — only the two
|
||||
/// contracts that never vary: a privilege present in every Windows token reports held,
|
||||
/// and an unknown privilege name fails soft to "held" (the probe must never flip the
|
||||
/// launch ordering because of its own defect).
|
||||
/// </summary>
|
||||
public sealed class TokenPrivilegeProbeTests
|
||||
{
|
||||
[Fact]
|
||||
public void SeChangeNotifyPrivilege_is_held_in_every_token()
|
||||
{
|
||||
Assert.True(TokenPrivilegeProbe.IsPrivilegeHeld("SeChangeNotifyPrivilege"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_privilege_name_fails_soft_to_held()
|
||||
{
|
||||
Assert.True(TokenPrivilegeProbe.IsPrivilegeHeld("SeDefinitelyNotARealPrivilege"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
@@ -461,6 +462,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Equal("IME Agent version: 1.101.109.0", info.Payload[SignalPayloadKeys.Message]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeTokenFailure_emits_warning_event_with_error_code()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerImeTokenFailureFromTest("3399614467",
|
||||
"Failed to get AAD token. len = 0 using client id x and resource id y, errorCode = 3399614467");
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
Assert.Equal("ImeLogTracker", info.Payload![SignalPayloadKeys.Source]);
|
||||
Assert.Equal("3399614467", info.Payload["errorCode"]);
|
||||
Assert.Contains("errorCode=3399614467", info.Payload[SignalPayloadKeys.Message]);
|
||||
Assert.Contains("Failed to get AAD token", info.Payload["logLine"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeTokenFailure_tracker_dedups_per_error_code()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.HandleImeTokenFailureForTest("100", "line a");
|
||||
f.Tracker.HandleImeTokenFailureForTest("100", "line b"); // same code — suppressed
|
||||
f.Tracker.HandleImeTokenFailureForTest("200", "line c"); // new code — emitted
|
||||
|
||||
Assert.Equal(2, f.InfoEvents(SharedEventTypes.ImeTokenFailure).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeTokenFailure_empty_error_code_reports_unknown()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerImeTokenFailureFromTest("", null!);
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
Assert.Equal("unknown", info.Payload!["errorCode"]);
|
||||
Assert.False(info.Payload.ContainsKey("logLine"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeAgentVersion_null_or_empty_is_skipped()
|
||||
{
|
||||
@@ -487,6 +530,70 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Equal(2, f.InfoEvents(SharedEventTypes.ImeAgentVersion).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeAgentVersion_enriches_with_csp_install_source_when_probe_matches()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
using var _probe = new ImeMsiInstallSourceProbe.ScopedOverride((v, _) =>
|
||||
ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new[]
|
||||
{
|
||||
new ImeMsiInstallSourceProbe.MsiEntry(
|
||||
"{6A7DFC50-0395-4E1E-BF84-ED1404E72051}",
|
||||
"1.104.102.0",
|
||||
"https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi"),
|
||||
},
|
||||
v));
|
||||
|
||||
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeAgentVersion));
|
||||
Assert.Equal("1.104.102.0", info.Payload!["agentVersion"]);
|
||||
Assert.Equal(
|
||||
"https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi",
|
||||
info.Payload["msiDownloadUrl"]);
|
||||
Assert.Equal("{6A7DFC50-0395-4E1E-BF84-ED1404E72051}", info.Payload["msiProductCode"]);
|
||||
Assert.Equal("1.104.102.0", info.Payload["msiProductVersion"]);
|
||||
Assert.Equal("productVersion", info.Payload["msiMatchedBy"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeAgentVersion_without_csp_match_omits_msi_keys()
|
||||
{
|
||||
// Assembly default (TestAssemblyInit) forces the probe to Empty.
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeAgentVersion));
|
||||
Assert.False(info.Payload!.ContainsKey("msiDownloadUrl"));
|
||||
Assert.False(info.Payload.ContainsKey("msiProductCode"));
|
||||
Assert.False(info.Payload.ContainsKey("msiProductVersion"));
|
||||
Assert.False(info.Payload.ContainsKey("msiMatchedBy"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeAgentVersion_probe_result_is_cached_per_version()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
int probeCalls = 0;
|
||||
using var _probe = new ImeMsiInstallSourceProbe.ScopedOverride((_, _) =>
|
||||
{
|
||||
probeCalls++;
|
||||
return ImeMsiInstallSource.Empty;
|
||||
});
|
||||
|
||||
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
|
||||
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
|
||||
adapter.TriggerImeAgentVersionFromTest("1.105.0.0");
|
||||
|
||||
Assert.Equal(2, probeCalls); // one read per distinct version, not per event
|
||||
Assert.Equal(3, f.InfoEvents(SharedEventTypes.ImeAgentVersion).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoTelemetry_emits_do_telemetry_info_event_with_do_fields()
|
||||
{
|
||||
|
||||
+20
@@ -25,6 +25,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
private DateTime _impersonationLastRollupUtc = DateTime.MinValue;
|
||||
private static readonly TimeSpan ImpersonationRollupInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
// IME retries token acquisition on every check-in — dedup per distinct error
|
||||
// code so an outage yields one Warning, not a timeline flood.
|
||||
private readonly HashSet<string> _reportedTokenFailureCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private void HandleImeTokenFailure(string errorCode, string message)
|
||||
{
|
||||
var code = string.IsNullOrEmpty(errorCode) ? "unknown" : errorCode.Trim();
|
||||
if (!_reportedTokenFailureCodes.Add(code)) return;
|
||||
|
||||
var excerpt = message ?? string.Empty;
|
||||
if (excerpt.Length > 300) excerpt = excerpt.Substring(0, 300);
|
||||
|
||||
_logger.Warning($"ImeLogTracker: IME token acquisition failure (errorCode={code})");
|
||||
OnImeTokenFailure?.Invoke(code, excerpt);
|
||||
}
|
||||
|
||||
// Test seam: drives HandleImeTokenFailure without the regex pipeline.
|
||||
internal void HandleImeTokenFailureForTest(string errorCode, string message)
|
||||
=> HandleImeTokenFailure(errorCode, message);
|
||||
|
||||
private void HandleImeImpersonation(string user)
|
||||
{
|
||||
var current = string.IsNullOrEmpty(user) ? "(unknown)" : user;
|
||||
|
||||
+7
@@ -214,6 +214,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
"capturedetectionresult", "esptrackstatus", "policiesdiscovered",
|
||||
"ignorecompletedapp", "updatename", "updatewin32appstate",
|
||||
"cancelstuckandsetcurrent", "updatedotelemetry",
|
||||
// Not app-mutating, but a stale token-failure replayed from a previous
|
||||
// enrollment's log would emit a misleading Warning for THIS session.
|
||||
"imetokenfailure",
|
||||
};
|
||||
|
||||
private void HandlePatternMatch(CompiledPattern pattern, Match match, string message, CmTraceLogEntry entry)
|
||||
@@ -296,6 +299,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
OnImeAgentVersion?.Invoke(version);
|
||||
break;
|
||||
|
||||
case "imetokenfailure":
|
||||
HandleImeTokenFailure(match.Groups["errorCode"]?.Value, message);
|
||||
break;
|
||||
|
||||
case "imeimpersonation":
|
||||
// PR3-A2: dedup. The same user triggers ~24 identical lines per session;
|
||||
// log on first/changed user, otherwise count and emit a single rollup
|
||||
|
||||
@@ -119,6 +119,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
"IntuneManagementExtension-????????-??????.log",
|
||||
"AppWorkload.log",
|
||||
"AppWorkload-????????-??????.log",
|
||||
// Enforcement decision engine (V3 ActionProcessor). IME routes every
|
||||
// [Win32App][ActionProcessor] line here (app.config TraceSource mapping) —
|
||||
// NO-ACTION / NOT-TARGETED classifications and the GRS "will not be
|
||||
// enforced" skips are ONLY visible in this file (audit 2026-08-17).
|
||||
"AppActionProcessor.log",
|
||||
"AppActionProcessor-????????-??????.log",
|
||||
// PowerShell script tracking
|
||||
"AgentExecutor.log",
|
||||
"AgentExecutor-????????-??????.log",
|
||||
@@ -194,6 +200,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public Action OnAllAppsCompleted { get; set; }
|
||||
public Action OnUserSessionCompleted { get; set; }
|
||||
public Action<string> OnImeSessionChange { get; set; }
|
||||
// Args: errorCode (may be empty when IME logged none), raw failure line excerpt.
|
||||
// Fired once per distinct errorCode per tracker lifetime (see HandleImeTokenFailure).
|
||||
public Action<string, string> OnImeTokenFailure { get; set; }
|
||||
// Second arg: the [DO TEL] source log line's timestamp when the DO data came from a
|
||||
// log line, null when the DO collector observed the completion live via polling. The
|
||||
// consumer must stamp poll-driven telemetry with the clock — inheriting an unrelated
|
||||
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Session 2026-08-17 — resolves where the running IME build was downloaded from.
|
||||
/// The IME is installed through the EnterpriseDesktopAppManagement CSP, which documents
|
||||
/// every MSI it enforces under
|
||||
/// <c>HKLM\SOFTWARE\Microsoft\EnterpriseDesktopAppManagement\<SID>\MSI\{ProductCode}</c>
|
||||
/// (values: <c>CurrentDownloadUrl</c>, <c>ProductVersion</c>, <c>ProductCode</c>, …).
|
||||
/// Given the IME version parsed from the IME log, this probe finds the matching MSI entry
|
||||
/// and returns its <c>CurrentDownloadUrl</c> so the <c>ime_agent_version</c> event can say
|
||||
/// which CDN/AFD endpoint delivered exactly that build (e.g.
|
||||
/// <c>imeswdb-afd-secondary.manage.microsoft.com</c>).
|
||||
/// <para>
|
||||
/// Matching: first by normalized <c>ProductVersion</c> equality (trailing <c>.0</c>
|
||||
/// components ignored), then — because the version line in the log and the enforced MSI can
|
||||
/// drift apart around an IME self-update — by download-URL filename
|
||||
/// <c>IntuneWindowsAgent.msi</c>. The match kind is surfaced so consumers know whether the
|
||||
/// URL is version-authoritative.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fail-soft like <see cref="EspTrackingInfoProbe"/>: any failure is Debug-logged and yields
|
||||
/// <see cref="ImeMsiInstallSource.Empty"/>; a missing CSP key (no MDM-enforced MSIs yet) is
|
||||
/// not an error.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class ImeMsiInstallSourceProbe
|
||||
{
|
||||
internal const string CspRootKeyPath = @"SOFTWARE\Microsoft\EnterpriseDesktopAppManagement";
|
||||
|
||||
private const string ImeMsiFileName = "IntuneWindowsAgent.msi";
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — when non-null, <see cref="Read"/> delegates to this func instead of
|
||||
/// touching the live registry. Use <see cref="ScopedOverride"/> to guarantee cleanup.
|
||||
/// </summary>
|
||||
internal static Func<string, AgentLogger, ImeMsiInstallSource> TestOverride;
|
||||
|
||||
/// <summary>
|
||||
/// Finds the CSP MSI entry for <paramref name="imeVersion"/> (the version string parsed
|
||||
/// from the IME log). Returns <see cref="ImeMsiInstallSource.Empty"/> when the CSP key is
|
||||
/// missing, unreadable, or holds no plausible entry.
|
||||
/// </summary>
|
||||
/// <param name="imeVersion">IME version from the log, e.g. <c>1.104.102.0</c>.</param>
|
||||
/// <param name="logger">Optional logger — Debug-level trace only; no warn/error.</param>
|
||||
public static ImeMsiInstallSource Read(string imeVersion, AgentLogger logger = null)
|
||||
{
|
||||
var probe = TestOverride;
|
||||
if (probe != null) return probe(imeVersion, logger);
|
||||
|
||||
try
|
||||
{
|
||||
var candidates = new List<MsiEntry>();
|
||||
using (var rootKey = Registry.LocalMachine.OpenSubKey(CspRootKeyPath))
|
||||
{
|
||||
if (rootKey == null)
|
||||
{
|
||||
logger?.Debug("ImeMsiInstallSourceProbe: EnterpriseDesktopAppManagement registry key not found");
|
||||
return ImeMsiInstallSource.Empty;
|
||||
}
|
||||
|
||||
// Layout: <SID>\MSI\{ProductCode}. The IME sits under the device-scope
|
||||
// zeros-SID, but enumerate all SIDs — layout is the same for user scope.
|
||||
foreach (var sid in rootKey.GetSubKeyNames())
|
||||
{
|
||||
using (var msiKey = rootKey.OpenSubKey(sid + @"\MSI"))
|
||||
{
|
||||
if (msiKey == null) continue;
|
||||
foreach (var productCode in msiKey.GetSubKeyNames())
|
||||
{
|
||||
using (var productKey = msiKey.OpenSubKey(productCode))
|
||||
{
|
||||
if (productKey == null) continue;
|
||||
candidates.Add(new MsiEntry(
|
||||
productCode: productCode,
|
||||
productVersion: productKey.GetValue("ProductVersion") as string,
|
||||
downloadUrl: productKey.GetValue("CurrentDownloadUrl") as string));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SelectBestMatch(candidates, imeVersion, logger);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Debug($"ImeMsiInstallSourceProbe: read threw: {ex.Message}");
|
||||
return ImeMsiInstallSource.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure selection logic (internal for unit tests): exact/normalized ProductVersion match
|
||||
/// wins; otherwise the first entry whose download URL points at
|
||||
/// <c>IntuneWindowsAgent.msi</c>.
|
||||
/// </summary>
|
||||
internal static ImeMsiInstallSource SelectBestMatch(
|
||||
IReadOnlyList<MsiEntry> candidates, string imeVersion, AgentLogger logger = null)
|
||||
{
|
||||
if (candidates == null || candidates.Count == 0) return ImeMsiInstallSource.Empty;
|
||||
|
||||
foreach (var entry in candidates)
|
||||
{
|
||||
if (VersionsEqual(entry.ProductVersion, imeVersion))
|
||||
{
|
||||
logger?.Debug($"ImeMsiInstallSourceProbe: matched by ProductVersion {entry.ProductVersion} ({entry.ProductCode})");
|
||||
return new ImeMsiInstallSource(entry, matchedByVersion: true);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entry in candidates)
|
||||
{
|
||||
if (IsImeMsiUrl(entry.DownloadUrl))
|
||||
{
|
||||
logger?.Debug($"ImeMsiInstallSourceProbe: matched by URL filename, registry version {entry.ProductVersion ?? "?"} vs log {imeVersion ?? "?"} ({entry.ProductCode})");
|
||||
return new ImeMsiInstallSource(entry, matchedByVersion: false);
|
||||
}
|
||||
}
|
||||
|
||||
logger?.Debug($"ImeMsiInstallSourceProbe: no CSP MSI entry matches IME version {imeVersion ?? "?"} ({candidates.Count} candidates)");
|
||||
return ImeMsiInstallSource.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component-wise numeric compare with trailing-zero tolerance, so
|
||||
/// <c>1.104.102</c> == <c>1.104.102.0</c>. Non-numeric components fall back to an
|
||||
/// ordinal-ignore-case string compare of the whole version.
|
||||
/// </summary>
|
||||
internal static bool VersionsEqual(string a, string b)
|
||||
{
|
||||
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
|
||||
if (string.Equals(a.Trim(), b.Trim(), StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
var pa = a.Trim().Split('.');
|
||||
var pb = b.Trim().Split('.');
|
||||
var len = Math.Max(pa.Length, pb.Length);
|
||||
for (var i = 0; i < len; i++)
|
||||
{
|
||||
var sa = i < pa.Length ? pa[i] : "0";
|
||||
var sb = i < pb.Length ? pb[i] : "0";
|
||||
if (!int.TryParse(sa, out var na) || !int.TryParse(sb, out var nb)) return false;
|
||||
if (na != nb) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>URL filename equals <c>IntuneWindowsAgent.msi</c> (query string ignored).</summary>
|
||||
internal static bool IsImeMsiUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return false;
|
||||
var path = url;
|
||||
var query = path.IndexOf('?');
|
||||
if (query >= 0) path = path.Substring(0, query);
|
||||
var slash = path.LastIndexOf('/');
|
||||
var fileName = slash >= 0 ? path.Substring(slash + 1) : path;
|
||||
return string.Equals(fileName, ImeMsiFileName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Raw candidate row from one <c>MSI\{ProductCode}</c> subkey.</summary>
|
||||
internal readonly struct MsiEntry
|
||||
{
|
||||
public MsiEntry(string productCode, string productVersion, string downloadUrl)
|
||||
{
|
||||
ProductCode = productCode;
|
||||
ProductVersion = productVersion;
|
||||
DownloadUrl = downloadUrl;
|
||||
}
|
||||
|
||||
public string ProductCode { get; }
|
||||
public string ProductVersion { get; }
|
||||
public string DownloadUrl { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposable scope that sets <see cref="TestOverride"/> for the lifetime of the scope
|
||||
/// and restores the previous value on Dispose. Nestable. Internal test-only helper.
|
||||
/// </summary>
|
||||
internal sealed class ScopedOverride : IDisposable
|
||||
{
|
||||
private readonly Func<string, AgentLogger, ImeMsiInstallSource> _previous;
|
||||
private int _disposed;
|
||||
|
||||
public ScopedOverride(Func<string, AgentLogger, ImeMsiInstallSource> probe)
|
||||
{
|
||||
_previous = TestOverride;
|
||||
TestOverride = probe;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
TestOverride = _previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolved install source of the running IME build. <see cref="HasData"/> is <c>false</c>
|
||||
/// when no CSP MSI entry matched. <see cref="MatchedByVersion"/> distinguishes a
|
||||
/// ProductVersion match (URL is authoritative for the reported build) from the
|
||||
/// filename fallback (URL is the currently enforced IME MSI, version may have drifted).
|
||||
/// </summary>
|
||||
internal readonly struct ImeMsiInstallSource
|
||||
{
|
||||
public static readonly ImeMsiInstallSource Empty = default;
|
||||
|
||||
internal ImeMsiInstallSource(ImeMsiInstallSourceProbe.MsiEntry entry, bool matchedByVersion)
|
||||
{
|
||||
HasData = true;
|
||||
ProductCode = entry.ProductCode;
|
||||
ProductVersion = entry.ProductVersion;
|
||||
DownloadUrl = entry.DownloadUrl;
|
||||
MatchedByVersion = matchedByVersion;
|
||||
}
|
||||
|
||||
public bool HasData { get; }
|
||||
|
||||
/// <summary>Braced MSI ProductCode subkey name, e.g. <c>{6a7dfc50-0395-4e1e-bf84-ed1404e72051}</c>.</summary>
|
||||
public string ProductCode { get; }
|
||||
|
||||
/// <summary><c>ProductVersion</c> registry value (may be null on partial writes).</summary>
|
||||
public string ProductVersion { get; }
|
||||
|
||||
/// <summary><c>CurrentDownloadUrl</c> registry value (may be null on partial writes).</summary>
|
||||
public string DownloadUrl { get; }
|
||||
|
||||
/// <summary><c>true</c> = matched via ProductVersion; <c>false</c> = URL-filename fallback.</summary>
|
||||
public bool MatchedByVersion { get; }
|
||||
}
|
||||
}
|
||||
+577
@@ -0,0 +1,577 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry "second pillar" for IME app state (audit 2026-08-17, architecture decision:
|
||||
/// registry = stable truth, IME logs = narrative). IME persists its authoritative per-app
|
||||
/// state under <c>HKLM\SOFTWARE\Microsoft\IntuneManagementExtension</c>:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Win32Apps\<userGuid>\<appGuid>_<rev></c> — EnforcementStateMessage JSON
|
||||
/// (EnforcementState + ErrorCode), ExitCode, Intent. Device context = Guid.Empty user.</item>
|
||||
/// <item><c>EspTrackingWin32Apps\<userGuid>\<appGuid>_<rev></c> — which apps IME
|
||||
/// registered for ESP tracking and in which phase.</item>
|
||||
/// <item><c>SideCarPolicies\StatusServiceReports\<userGuid>\<appId></c> — the exact
|
||||
/// AppInstallStatusReport the ESP page renders (Status 1000=Installed .. 3000=Failed).</item>
|
||||
/// </list>
|
||||
/// The observer is snapshot-and-diff driven (RegistryWatcher gives key-scope edges only,
|
||||
/// coalesced — a per-write parse is impossible by design): every tick re-reads the three
|
||||
/// surfaces and emits <c>registry_app_state</c> on real field changes. Pre-existing state at
|
||||
/// agent start is the silent baseline — Win32Apps keys survive re-enrollments, so replaying
|
||||
/// them as fresh events would be the registry twin of the historic-IME-replay bug.
|
||||
///
|
||||
/// Reconciliation (= built-in log-pattern drift alarm): once a registry entry that changed
|
||||
/// during THIS session has been terminal for <see cref="ReconcileSettleDelay"/>, its outcome
|
||||
/// is compared against the log-derived <see cref="AppPackageState"/>. A divergence means the
|
||||
/// IME log patterns missed or mis-classified the app — emitted once per app as
|
||||
/// <c>app_state_reconciliation</c> (Warning). Pure observability: nothing here feeds the
|
||||
/// DecisionEngine.
|
||||
/// </summary>
|
||||
internal sealed class ImeRegistryAppStateObserver
|
||||
{
|
||||
public const string SourceLabel = "RegistryAppState";
|
||||
|
||||
internal const string ImeRootKeyPath = @"SOFTWARE\Microsoft\IntuneManagementExtension";
|
||||
|
||||
// Safety cap: a pathological churn (or a wrong diff) must not flood the timeline.
|
||||
// 200 covers ~25 apps x 8 state transitions; the cap itself is announced once.
|
||||
internal const int MaxStateEventsPerSession = 200;
|
||||
|
||||
// The log tracker legitimately lags the registry (log write + 100 ms poll + phase
|
||||
// gating), so divergence is only evaluated after the registry state has been stable
|
||||
// terminal for this long.
|
||||
internal static readonly TimeSpan ReconcileSettleDelay = TimeSpan.FromSeconds(90);
|
||||
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger? _logger;
|
||||
private readonly IClock _clock;
|
||||
private readonly Func<IReadOnlyList<AppPackageState>>? _trackerStateProbe;
|
||||
private readonly object _sync = new object();
|
||||
|
||||
private ImeRegistrySnapshot? _last;
|
||||
private readonly Dictionary<string, DateTime> _terminalSinceUtc =
|
||||
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _changedDuringSession =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _reconciliationEmitted =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
private int _stateEventsEmitted;
|
||||
private bool _capNoticeEmitted;
|
||||
|
||||
// Test seam: replaces the live registry walk (same convention as EspTrackingInfoProbe).
|
||||
internal static Func<ImeRegistrySnapshot>? SnapshotOverride;
|
||||
|
||||
internal sealed class ScopedSnapshotOverride : IDisposable
|
||||
{
|
||||
private readonly Func<ImeRegistrySnapshot>? _previous;
|
||||
private int _disposed;
|
||||
|
||||
public ScopedSnapshotOverride(Func<ImeRegistrySnapshot> replacement)
|
||||
{
|
||||
_previous = SnapshotOverride;
|
||||
SnapshotOverride = replacement;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
SnapshotOverride = _previous;
|
||||
}
|
||||
}
|
||||
|
||||
public ImeRegistryAppStateObserver(
|
||||
InformationalEventPost post,
|
||||
AgentLogger? logger,
|
||||
IClock clock,
|
||||
Func<IReadOnlyList<AppPackageState>>? trackerStateProbe)
|
||||
{
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger;
|
||||
_trackerStateProbe = trackerStateProbe;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One observation pass: snapshot → diff → emit → reconcile. Called from the debounced
|
||||
/// registry-change edge AND a periodic fallback tick (the settle-delay evaluation must
|
||||
/// happen even when the registry goes quiet). Fail-soft: never throws.
|
||||
/// </summary>
|
||||
public void Tick(string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var snapshot = SnapshotOverride?.Invoke() ?? ReadSnapshot();
|
||||
var nowUtc = _clock.UtcNow;
|
||||
|
||||
if (_last == null)
|
||||
{
|
||||
// Silent baseline — see class doc.
|
||||
_last = snapshot;
|
||||
_logger?.Info($"RegistryAppState: baseline captured ({snapshot.Entries.Count} app entries, trigger={reason})");
|
||||
return;
|
||||
}
|
||||
|
||||
var changes = DiffSnapshots(_last, snapshot);
|
||||
foreach (var change in changes)
|
||||
{
|
||||
_changedDuringSession.Add(change.Entry.Key);
|
||||
EmitStateEvent(change, nowUtc);
|
||||
}
|
||||
|
||||
TrackTerminalTransitions(snapshot, nowUtc);
|
||||
EvaluateReconciliations(snapshot, nowUtc);
|
||||
|
||||
_last = snapshot;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Debug($"RegistryAppState: tick failed (fail-soft): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── snapshot ────────────────────────────────────────────────────────────
|
||||
|
||||
internal static ImeRegistrySnapshot ReadSnapshot()
|
||||
{
|
||||
var snapshot = new ImeRegistrySnapshot();
|
||||
using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
using var root = baseKey.OpenSubKey(ImeRootKeyPath);
|
||||
if (root == null) return snapshot;
|
||||
|
||||
ReadWin32Apps(root, snapshot);
|
||||
ReadEspTracking(root, snapshot);
|
||||
ReadStatusServiceReports(root, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static void ReadWin32Apps(RegistryKey root, ImeRegistrySnapshot snapshot)
|
||||
{
|
||||
using var win32Apps = root.OpenSubKey("Win32Apps");
|
||||
if (win32Apps == null) return;
|
||||
|
||||
foreach (var userName in win32Apps.GetSubKeyNames())
|
||||
{
|
||||
if (!LooksLikeGuid(userName)) continue; // skips OperationalState / Reporting / ProvisioningProgress
|
||||
using var userKey = win32Apps.OpenSubKey(userName);
|
||||
if (userKey == null) continue;
|
||||
|
||||
foreach (var appKeyName in userKey.GetSubKeyNames())
|
||||
{
|
||||
var appId = ExtractAppId(appKeyName);
|
||||
if (appId == null) continue; // skips GRS + non-app keys
|
||||
|
||||
using var appKey = userKey.OpenSubKey(appKeyName);
|
||||
if (appKey == null) continue;
|
||||
|
||||
var entry = snapshot.GetOrAdd(userName, appId);
|
||||
entry.ExitCode = TryReadInt(appKey.GetValue("ExitCode"));
|
||||
entry.Intent = TryReadInt(appKey.GetValue("Intent"));
|
||||
|
||||
using var esm = appKey.OpenSubKey("EnforcementStateMessage");
|
||||
var json = esm?.GetValue("EnforcementStateMessage") as string;
|
||||
if (!string.IsNullOrEmpty(json))
|
||||
{
|
||||
var (state, error) = ParseEnforcementStateMessage(json!);
|
||||
entry.EnforcementState = state;
|
||||
entry.ErrorCode = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadEspTracking(RegistryKey root, ImeRegistrySnapshot snapshot)
|
||||
{
|
||||
using var esp = root.OpenSubKey("EspTrackingWin32Apps");
|
||||
if (esp == null) return;
|
||||
|
||||
foreach (var userName in esp.GetSubKeyNames())
|
||||
{
|
||||
if (!LooksLikeGuid(userName)) continue;
|
||||
using var userKey = esp.OpenSubKey(userName);
|
||||
if (userKey == null) continue;
|
||||
|
||||
foreach (var appKeyName in userKey.GetSubKeyNames())
|
||||
{
|
||||
var appId = ExtractAppId(appKeyName);
|
||||
if (appId == null) continue;
|
||||
|
||||
using var appKey = userKey.OpenSubKey(appKeyName);
|
||||
var entry = snapshot.GetOrAdd(userName, appId);
|
||||
entry.EspTracked = true;
|
||||
entry.EspPhase = appKey?.GetValue("EspTrackingWin32AppPhase") as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadStatusServiceReports(RegistryKey root, ImeRegistrySnapshot snapshot)
|
||||
{
|
||||
using var reports = root.OpenSubKey(@"SideCarPolicies\StatusServiceReports");
|
||||
if (reports == null) return;
|
||||
|
||||
foreach (var userName in reports.GetSubKeyNames())
|
||||
{
|
||||
if (!LooksLikeGuid(userName)) continue;
|
||||
using var userKey = reports.OpenSubKey(userName);
|
||||
if (userKey == null) continue;
|
||||
|
||||
foreach (var appKeyName in userKey.GetSubKeyNames())
|
||||
{
|
||||
var appId = ExtractAppId(appKeyName);
|
||||
if (appId == null) continue;
|
||||
|
||||
using var appKey = userKey.OpenSubKey(appKeyName);
|
||||
if (appKey == null) continue;
|
||||
|
||||
var entry = snapshot.GetOrAdd(userName, appId);
|
||||
entry.StatusServiceStatus = TryReadInt(appKey.GetValue("Status"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── pure helpers (unit-tested directly) ─────────────────────────────────
|
||||
|
||||
internal static bool LooksLikeGuid(string name) =>
|
||||
!string.IsNullOrEmpty(name) && Guid.TryParse(name, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Win32Apps app keys are <c><appGuid>_<revision></c>; StatusServiceReports keys
|
||||
/// are the bare GUID. Returns the lowercase app GUID or null for non-app keys (GRS, ...).
|
||||
/// </summary>
|
||||
internal static string? ExtractAppId(string keyName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(keyName)) return null;
|
||||
var candidate = keyName.Length >= 36 ? keyName.Substring(0, 36) : keyName;
|
||||
return Guid.TryParse(candidate, out var g) ? g.ToString("D") : null;
|
||||
}
|
||||
|
||||
internal static (int? state, long? errorCode) ParseEnforcementStateMessage(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
int? state = null;
|
||||
long? error = null;
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty("EnforcementState", out var s) && s.ValueKind == JsonValueKind.Number)
|
||||
state = s.GetInt32();
|
||||
if (doc.RootElement.TryGetProperty("ErrorCode", out var e) && e.ValueKind == JsonValueKind.Number)
|
||||
error = e.GetInt64();
|
||||
}
|
||||
return (state, error);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
internal static int? TryReadInt(object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null: return null;
|
||||
case int i: return i;
|
||||
case long l: return unchecked((int)l);
|
||||
case string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed):
|
||||
return parsed;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>StateMessageEnforcementState bands (verified against decompiled IME 1.97/1.104).</summary>
|
||||
internal static string ClassifyEnforcementState(int state)
|
||||
{
|
||||
if (state >= 1000 && state < 2000) return "success";
|
||||
if (state >= 2000 && state < 3000) return "inProgress";
|
||||
if (state >= 3000 && state < 4000) return "requirementsNotMet";
|
||||
if (state >= 5000 && state < 6000) return "error";
|
||||
if (state >= 6000 && state < 7000) return "notAttempted";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/// <summary>AppInstallStatus (StatusService/ESP page): 1000er = installed, 3000 = failed.</summary>
|
||||
internal static string? ClassifyStatusServiceStatus(int status)
|
||||
{
|
||||
if (status >= 1000 && status < 2000) return "installed";
|
||||
if (status == 3000) return "failed";
|
||||
if (status >= 2000 && status < 3000) return "notApplicable";
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static List<AppRegistryChange> DiffSnapshots(ImeRegistrySnapshot previous, ImeRegistrySnapshot next)
|
||||
{
|
||||
var changes = new List<AppRegistryChange>();
|
||||
foreach (var kv in next.Entries)
|
||||
{
|
||||
previous.Entries.TryGetValue(kv.Key, out var prev);
|
||||
var changed = ChangedFields(prev, kv.Value);
|
||||
if (changed.Count > 0)
|
||||
changes.Add(new AppRegistryChange(kv.Value, changed, isNew: prev == null));
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
internal static List<string> ChangedFields(AppRegistryEntry? prev, AppRegistryEntry next)
|
||||
{
|
||||
var changed = new List<string>();
|
||||
if (prev?.EnforcementState != next.EnforcementState) changed.Add("enforcementState");
|
||||
if (prev?.ErrorCode != next.ErrorCode) changed.Add("errorCode");
|
||||
if (prev?.ExitCode != next.ExitCode) changed.Add("exitCode");
|
||||
if (prev?.StatusServiceStatus != next.StatusServiceStatus) changed.Add("statusServiceStatus");
|
||||
if ((prev?.EspTracked ?? false) != next.EspTracked) changed.Add("espTracked");
|
||||
if (!string.Equals(prev?.EspPhase, next.EspPhase, StringComparison.OrdinalIgnoreCase)) changed.Add("espPhase");
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>"success" / "error" when the registry outcome is terminal, else null.</summary>
|
||||
internal static string? TerminalOutcome(AppRegistryEntry entry)
|
||||
{
|
||||
if (entry.EnforcementState is int es)
|
||||
{
|
||||
var cls = ClassifyEnforcementState(es);
|
||||
if (cls == "success") return "success";
|
||||
if (cls == "error") return "error";
|
||||
}
|
||||
if (entry.StatusServiceStatus is int ss)
|
||||
{
|
||||
var cls = ClassifyStatusServiceStatus(ss);
|
||||
if (cls == "installed") return "success";
|
||||
if (cls == "failed") return "error";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Divergence rules (observability, conservative — prefer false-negative over noise):
|
||||
/// registry error vs log Installed, registry success vs log Error, or registry terminal
|
||||
/// while the tracker (actively tracking other apps) never saw the app at all.
|
||||
/// </summary>
|
||||
internal static bool IsDivergent(
|
||||
string registryOutcome,
|
||||
AppPackageState? logState,
|
||||
bool trackerHasAnyApps,
|
||||
out string reasonCode)
|
||||
{
|
||||
if (logState == null)
|
||||
{
|
||||
if (trackerHasAnyApps)
|
||||
{
|
||||
reasonCode = "app_unknown_to_log_tracking";
|
||||
return true;
|
||||
}
|
||||
reasonCode = string.Empty; // tracker idle (e.g. pre-ESP) — not judgeable
|
||||
return false;
|
||||
}
|
||||
|
||||
if (registryOutcome == "error" && logState.InstallationState == AppInstallationState.Installed)
|
||||
{
|
||||
reasonCode = "registry_error_log_installed";
|
||||
return true;
|
||||
}
|
||||
if (registryOutcome == "success" && logState.InstallationState == AppInstallationState.Error)
|
||||
{
|
||||
reasonCode = "registry_success_log_error";
|
||||
return true;
|
||||
}
|
||||
|
||||
reasonCode = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── emission ────────────────────────────────────────────────────────────
|
||||
|
||||
private void EmitStateEvent(AppRegistryChange change, DateTime nowUtc)
|
||||
{
|
||||
if (_stateEventsEmitted >= MaxStateEventsPerSession)
|
||||
{
|
||||
if (!_capNoticeEmitted)
|
||||
{
|
||||
_capNoticeEmitted = true;
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.RegistryAppState,
|
||||
source: SourceLabel,
|
||||
message: $"registry_app_state cap reached ({MaxStateEventsPerSession}) — further registry app-state changes are logged locally only",
|
||||
severity: EventSeverity.Warning,
|
||||
occurredAtUtc: nowUtc);
|
||||
}
|
||||
_logger?.Debug($"RegistryAppState: cap reached, suppressing {change.Entry.AppId}");
|
||||
return;
|
||||
}
|
||||
_stateEventsEmitted++;
|
||||
|
||||
var e = change.Entry;
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["appId"] = e.AppId,
|
||||
["userContext"] = e.UserContext,
|
||||
["changedFields"] = string.Join(",", change.ChangedFields),
|
||||
};
|
||||
if (e.EnforcementState is int es)
|
||||
{
|
||||
data["enforcementState"] = es.ToString(CultureInfo.InvariantCulture);
|
||||
data["enforcementClass"] = ClassifyEnforcementState(es);
|
||||
}
|
||||
if (e.ErrorCode is long ec) data["errorCode"] = ec.ToString(CultureInfo.InvariantCulture);
|
||||
if (e.ExitCode is int xc) data["exitCode"] = xc.ToString(CultureInfo.InvariantCulture);
|
||||
if (e.Intent is int intent) data["intent"] = intent.ToString(CultureInfo.InvariantCulture);
|
||||
if (e.StatusServiceStatus is int ss)
|
||||
{
|
||||
data["statusServiceStatus"] = ss.ToString(CultureInfo.InvariantCulture);
|
||||
var cls = ClassifyStatusServiceStatus(ss);
|
||||
if (cls != null) data["statusServiceClass"] = cls;
|
||||
}
|
||||
if (e.EspTracked) data["espTracked"] = "true";
|
||||
if (!string.IsNullOrEmpty(e.EspPhase)) data["espPhase"] = e.EspPhase!;
|
||||
|
||||
var summary = data.TryGetValue("enforcementClass", out var encls) ? encls
|
||||
: data.TryGetValue("statusServiceClass", out var sscls) ? sscls
|
||||
: "updated";
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.RegistryAppState,
|
||||
source: SourceLabel,
|
||||
message: $"Registry app state: {e.AppId} ({(e.UserContext == ImeRegistrySnapshot.DeviceContext ? "device" : "user")}) -> {summary}",
|
||||
severity: EventSeverity.Info,
|
||||
data: data,
|
||||
occurredAtUtc: nowUtc);
|
||||
}
|
||||
|
||||
private void TrackTerminalTransitions(ImeRegistrySnapshot snapshot, DateTime nowUtc)
|
||||
{
|
||||
foreach (var kv in snapshot.Entries)
|
||||
{
|
||||
if (!_changedDuringSession.Contains(kv.Key)) continue; // baseline state — not ours to judge
|
||||
var outcome = TerminalOutcome(kv.Value);
|
||||
if (outcome == null)
|
||||
{
|
||||
_terminalSinceUtc.Remove(kv.Key); // regressed to non-terminal — reset dwell
|
||||
continue;
|
||||
}
|
||||
if (!_terminalSinceUtc.ContainsKey(kv.Key))
|
||||
_terminalSinceUtc[kv.Key] = nowUtc;
|
||||
}
|
||||
}
|
||||
|
||||
private void EvaluateReconciliations(ImeRegistrySnapshot snapshot, DateTime nowUtc)
|
||||
{
|
||||
if (_trackerStateProbe == null) return;
|
||||
|
||||
List<AppPackageState>? trackerStates = null;
|
||||
foreach (var kv in _terminalSinceUtc.ToList())
|
||||
{
|
||||
if (_reconciliationEmitted.Contains(kv.Key)) continue;
|
||||
if (nowUtc - kv.Value < ReconcileSettleDelay) continue;
|
||||
if (!snapshot.Entries.TryGetValue(kv.Key, out var entry)) continue;
|
||||
|
||||
var outcome = TerminalOutcome(entry);
|
||||
if (outcome == null) continue;
|
||||
|
||||
trackerStates ??= (_trackerStateProbe() ?? Array.Empty<AppPackageState>()).ToList();
|
||||
var logState = trackerStates.FirstOrDefault(p =>
|
||||
string.Equals(p.Id, entry.AppId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!IsDivergent(outcome, logState, trackerStates.Count > 0, out var reasonCode))
|
||||
{
|
||||
_reconciliationEmitted.Add(kv.Key); // agreed (or not judgeable) — done with this app
|
||||
continue;
|
||||
}
|
||||
|
||||
_reconciliationEmitted.Add(kv.Key);
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["appId"] = entry.AppId,
|
||||
["userContext"] = entry.UserContext,
|
||||
["registryOutcome"] = outcome,
|
||||
["reason"] = reasonCode,
|
||||
["logState"] = logState?.InstallationState.ToString() ?? "unknown",
|
||||
};
|
||||
if (logState != null && !string.IsNullOrEmpty(logState.Name)) data["appName"] = logState.Name;
|
||||
if (entry.EnforcementState is int es) data["enforcementState"] = es.ToString(CultureInfo.InvariantCulture);
|
||||
if (entry.ErrorCode is long ec) data["errorCode"] = ec.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.AppStateReconciliation,
|
||||
source: SourceLabel,
|
||||
message: $"Registry/log divergence for app {entry.AppId}: registry={outcome}, log={data["logState"]} ({reasonCode}) — IME log patterns may have drifted",
|
||||
severity: EventSeverity.Warning,
|
||||
immediateUpload: true,
|
||||
data: data,
|
||||
occurredAtUtc: nowUtc);
|
||||
|
||||
_logger?.Warning($"RegistryAppState: divergence for {entry.AppId} — registry={outcome}, log={data["logState"]} ({reasonCode})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Immutable-ish snapshot of the three IME registry surfaces, keyed per user-context + app.</summary>
|
||||
internal sealed class ImeRegistrySnapshot
|
||||
{
|
||||
public const string DeviceContext = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
public Dictionary<string, AppRegistryEntry> Entries { get; } =
|
||||
new Dictionary<string, AppRegistryEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public AppRegistryEntry GetOrAdd(string userContext, string appId)
|
||||
{
|
||||
var normalizedUser = Guid.TryParse(userContext, out var g) ? g.ToString("D") : userContext;
|
||||
var key = normalizedUser + "|" + appId;
|
||||
if (!Entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
entry = new AppRegistryEntry(key, normalizedUser, appId);
|
||||
Entries[key] = entry;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AppRegistryEntry
|
||||
{
|
||||
public AppRegistryEntry(string key, string userContext, string appId)
|
||||
{
|
||||
Key = key;
|
||||
UserContext = userContext;
|
||||
AppId = appId;
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
public string UserContext { get; }
|
||||
public string AppId { get; }
|
||||
public int? EnforcementState { get; set; }
|
||||
public long? ErrorCode { get; set; }
|
||||
public int? ExitCode { get; set; }
|
||||
public int? Intent { get; set; }
|
||||
public int? StatusServiceStatus { get; set; }
|
||||
public bool EspTracked { get; set; }
|
||||
public string? EspPhase { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class AppRegistryChange
|
||||
{
|
||||
public AppRegistryChange(AppRegistryEntry entry, List<string> changedFields, bool isNew)
|
||||
{
|
||||
Entry = entry;
|
||||
ChangedFields = changedFields;
|
||||
IsNew = isNew;
|
||||
}
|
||||
|
||||
public AppRegistryEntry Entry { get; }
|
||||
public List<string> ChangedFields { get; }
|
||||
public bool IsNew { get; }
|
||||
}
|
||||
}
|
||||
+100
-12
@@ -346,11 +346,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
// Chicken-and-egg guard: the agent-log snapshot is zipped BEFORE packaging finishes, so
|
||||
// packaging problems (blocked paths, missing files, failed copies) are invisible in the
|
||||
// uploaded archive's own log. This manifest records every packaging decision and travels
|
||||
// INSIDE the ZIP (field case: sessions a11102f4 / 3ae7528b, missing evtx undiagnosable).
|
||||
private StringBuilder _manifest;
|
||||
|
||||
private void ManifestLine(string text)
|
||||
{
|
||||
_manifest?.AppendLine($"[{DateTime.UtcNow:HH:mm:ss.fff}Z] {text}");
|
||||
}
|
||||
|
||||
// Builds the diagnostics ZIP body in-memory. Extracted from CreateAndUploadAsync so
|
||||
// tests can assert archive contents without going through the upload path.
|
||||
internal virtual byte[] BuildArchiveBytes(bool? enrollmentSucceeded)
|
||||
{
|
||||
var tracker = new BudgetTracker(Budget);
|
||||
_manifest = new StringBuilder();
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
@@ -394,12 +406,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!DiagnosticsPathGuards.IsDiagnosticsPathAllowed(entry.Path, _configuration.UnrestrictedMode, userProfilePath))
|
||||
{
|
||||
_logger.Warning($"Diagnostics path blocked by guard: {entry.Path}");
|
||||
ManifestLine($"BLOCKED (path guard): {entry.Path}");
|
||||
continue;
|
||||
}
|
||||
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path);
|
||||
if (expandedPath == null)
|
||||
{
|
||||
_logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}");
|
||||
ManifestLine($"SKIPPED (no user session for token): {entry.Path}");
|
||||
continue;
|
||||
}
|
||||
var folder = Path.GetDirectoryName(expandedPath);
|
||||
@@ -407,9 +421,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (string.IsNullOrEmpty(folder)) continue;
|
||||
if (string.IsNullOrEmpty(pattern) || !pattern.Contains(".")) pattern = "*";
|
||||
var zipFolder = $"AdditionalLogs/{Path.GetFileName(folder)}";
|
||||
ManifestLine($"CONFIGURED PATH: '{entry.Path}' -> folder='{folder}' pattern='{pattern}'");
|
||||
AddLogFiles(archive, folder, zipFolder, pattern, tracker, entry.IncludeSubfolders);
|
||||
}
|
||||
|
||||
// Packaging manifest — always written, even when empty of problems, so its
|
||||
// absence itself is meaningful (archive predates the feature).
|
||||
try
|
||||
{
|
||||
var manifestEntry = archive.CreateEntry("package-manifest.txt", CompressionLevel.Optimal);
|
||||
using (var writer = new StreamWriter(manifestEntry.Open()))
|
||||
writer.Write(_manifest.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to write package manifest: {ex.Message}");
|
||||
}
|
||||
|
||||
// Always last: emit truncation report only if any file was skipped.
|
||||
if (tracker.HasSkips)
|
||||
WriteTruncatedMarker(archive, tracker);
|
||||
@@ -483,6 +511,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!Directory.Exists(sourceFolder))
|
||||
{
|
||||
_logger.Debug($"Log folder not found, skipping: {sourceFolder}");
|
||||
ManifestLine($"FOLDER MISSING: {sourceFolder} (pattern '{searchPattern}')");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -494,9 +523,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}");
|
||||
ManifestLine($"ENUMERATION FAILED: {sourceFolder} (pattern '{searchPattern}'): {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.Count == 0)
|
||||
ManifestLine($"NO MATCH: {sourceFolder} pattern '{searchPattern}'");
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
@@ -514,6 +547,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Skipping reparse-point file: {file}");
|
||||
tracker.RecordSkip(file, "reparse", 0);
|
||||
ManifestLine($"SKIPPED (reparse point): {file}");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -534,14 +568,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (file.EndsWith(".evtx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
tempExport = TryExportEventLogChannel(file);
|
||||
if (tempExport == null)
|
||||
if (tempExport != null)
|
||||
{
|
||||
tracker.RecordSkip(file, "evtx-export", length);
|
||||
continue;
|
||||
streamSource = tempExport;
|
||||
try { length = new FileInfo(tempExport).Length; }
|
||||
catch { /* keep original length estimate */ }
|
||||
}
|
||||
else
|
||||
{
|
||||
// Export failed (channel not resolvable / wevtutil error) — fall through
|
||||
// to the raw copy. Inactive or archived evtx files are not locked, so
|
||||
// the plain FileStream often still succeeds; if the channel IS active
|
||||
// and locked, the existing per-file catch logs the failure.
|
||||
_logger.Warning($"Event log export unavailable for {file} — attempting raw copy.");
|
||||
ManifestLine($"EVTX EXPORT FAILED (falling back to raw copy): {file}");
|
||||
}
|
||||
streamSource = tempExport;
|
||||
try { length = new FileInfo(tempExport).Length; }
|
||||
catch { /* keep original length estimate */ }
|
||||
}
|
||||
|
||||
try
|
||||
@@ -550,18 +591,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Skipping oversized file ({length} bytes > {tracker.Budget.MaxSingleFileBytes} cap): {file}");
|
||||
tracker.RecordSkip(file, "size", length);
|
||||
ManifestLine($"SKIPPED (single-file cap, {length} bytes): {file}");
|
||||
continue;
|
||||
}
|
||||
if (tracker.WouldExceedCount())
|
||||
{
|
||||
_logger.Warning($"Skipping file (file-count cap {tracker.Budget.MaxFileCount} reached): {file}");
|
||||
tracker.RecordSkip(file, "count", length);
|
||||
ManifestLine($"SKIPPED (file-count cap): {file}");
|
||||
continue;
|
||||
}
|
||||
if (tracker.WouldExceedTotal(length))
|
||||
{
|
||||
_logger.Warning($"Skipping file (total-bytes cap {tracker.Budget.MaxTotalUncompressedBytes} reached): {file}");
|
||||
tracker.RecordSkip(file, "total", length);
|
||||
ManifestLine($"SKIPPED (total-bytes cap): {file}");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -580,6 +624,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
tracker.RecordIncluded(length);
|
||||
_logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
|
||||
ManifestLine($"ADDED: {entryName} ({length} bytes)");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -592,23 +637,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}");
|
||||
ManifestLine($"FAILED (copy): {file}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the event-log CHANNEL that writes to the given .evtx file by matching the
|
||||
/// registered channels' LogFilePath — never by guessing a naming convention: most
|
||||
/// channels follow the "%4 encodes '/'" file naming, but not all do (field case
|
||||
/// 2026-08-17: Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider
|
||||
/// writes plain "BootstrapperAgentServiceLogProvider.evtx"). Falls back to the %4
|
||||
/// convention only when no registered channel claims the file.
|
||||
/// </summary>
|
||||
internal static string ResolveChannelForEvtxFile(string evtxPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = Path.GetFullPath(evtxPath);
|
||||
using (var session = new System.Diagnostics.Eventing.Reader.EventLogSession())
|
||||
{
|
||||
foreach (var logName in session.GetLogNames())
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = new System.Diagnostics.Eventing.Reader.EventLogConfiguration(logName, session);
|
||||
var logFile = config.LogFilePath;
|
||||
if (string.IsNullOrEmpty(logFile)) continue;
|
||||
var expanded = Path.GetFullPath(Environment.ExpandEnvironmentVariables(logFile));
|
||||
if (string.Equals(expanded, target, StringComparison.OrdinalIgnoreCase))
|
||||
return logName;
|
||||
}
|
||||
catch { /* individual channel unreadable — keep scanning */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* enumeration unavailable — fall back to convention */ }
|
||||
|
||||
return Path.GetFileNameWithoutExtension(evtxPath).Replace("%4", "/");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports the event-log channel behind a winevt .evtx file to a temp copy via
|
||||
/// <c>wevtutil epl</c>. Channel name is derived from the file name (%4 decodes to '/',
|
||||
/// e.g. "Microsoft-Autopilot-BootstrapperAgent%4BootstrapperAgentServiceLogProvider.evtx"
|
||||
/// → "Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider").
|
||||
/// Returns the temp file path (caller deletes) or null when the export failed.
|
||||
/// <c>wevtutil epl</c> (active channels hold their file exclusively locked, so a raw
|
||||
/// copy fails). Channel resolution happens against the registered channels — see
|
||||
/// <see cref="ResolveChannelForEvtxFile"/>. Returns the temp file path (caller deletes)
|
||||
/// or null when the export failed.
|
||||
/// </summary>
|
||||
private string TryExportEventLogChannel(string evtxPath)
|
||||
{
|
||||
string tempPath = null;
|
||||
try
|
||||
{
|
||||
var channel = Path.GetFileNameWithoutExtension(evtxPath).Replace("%4", "/");
|
||||
var channel = ResolveChannelForEvtxFile(evtxPath);
|
||||
tempPath = Path.Combine(Path.GetTempPath(), $"am-evtx-{Guid.NewGuid():N}.evtx");
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
@@ -625,6 +706,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
try { process.Kill(); } catch { }
|
||||
_logger.Warning($"wevtutil export timed out for channel '{channel}'.");
|
||||
ManifestLine($"EVTX EXPORT TIMEOUT: channel '{channel}' ({evtxPath})");
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
return null;
|
||||
}
|
||||
@@ -632,11 +714,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
var stderr = process.StandardError.ReadToEnd();
|
||||
_logger.Warning($"wevtutil export failed for channel '{channel}' (exit {process.ExitCode}): {stderr}");
|
||||
ManifestLine($"EVTX EXPORT ERROR: channel '{channel}' exit {process.ExitCode}: {stderr}");
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return File.Exists(tempPath) ? tempPath : null;
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
ManifestLine($"EVTX EXPORTED: channel '{channel}' -> {evtxPath}");
|
||||
return tempPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -326,6 +326,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
clock: clock,
|
||||
startupGate: _startupEventGate));
|
||||
|
||||
// Registry second pillar (audit 2026-08-17): IME's authoritative per-app state from
|
||||
// HKLM\...\IntuneManagementExtension (Win32Apps EnforcementStateMessage,
|
||||
// EspTrackingWin32Apps, StatusServiceReports) via snapshot-and-diff. Emits
|
||||
// registry_app_state on real changes and app_state_reconciliation when the
|
||||
// registry outcome diverges from the IME-log-derived state (= built-in pattern-drift
|
||||
// alarm). Always-on observability host; the tracker probe is read-only.
|
||||
hosts.Add(new ImeRegistryAppStateHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
trackerStateProbe: () => imeLogHost.AllKnownPackageStates));
|
||||
|
||||
// Deterministic update corroboration — compares the persisted OS build (CurrentBuild.UBR)
|
||||
// across agent restarts and emits os_build_changed when it differs (session 7443317c:
|
||||
// build jump proved an OOBE quality update the WU channel never showed). Must be added
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Hosts the <see cref="ImeRegistryAppStateObserver"/> (registry second pillar, audit
|
||||
/// 2026-08-17): one recursive <see cref="RegistryWatcher"/> on the IME root — all three
|
||||
/// observed surfaces (Win32Apps, EspTrackingWin32Apps, SideCarPolicies\StatusServiceReports)
|
||||
/// live under it — debounced into snapshot-and-diff ticks, plus a 60-s periodic fallback
|
||||
/// tick. The fallback both evaluates the reconciliation settle-delay when the registry goes
|
||||
/// quiet AND keeps the pillar alive if the watcher fails to arm (poll-only degraded mode,
|
||||
/// announced once via <c>collector_degraded</c>). Always-on observability host, no config
|
||||
/// gate — precedent: <see cref="EspPolicyProviderStallHost"/>.
|
||||
/// </summary>
|
||||
internal sealed class ImeRegistryAppStateHost : ICollectorHost
|
||||
{
|
||||
public string Name => "ImeRegistryAppState";
|
||||
|
||||
private static readonly TimeSpan DebounceDelay = TimeSpan.FromSeconds(2);
|
||||
private static readonly TimeSpan PeriodicInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly ImeRegistryAppStateObserver _observer;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger? _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
|
||||
private RegistryWatcher? _watcher;
|
||||
private Timer? _debounceTimer;
|
||||
private Timer? _periodicTimer;
|
||||
private int _stopped;
|
||||
|
||||
public ImeRegistryAppStateHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger? logger,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
Func<IReadOnlyList<AppPackageState>>? trackerStateProbe)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
_logger = logger;
|
||||
_post = new InformationalEventPost(ingress, clock);
|
||||
_observer = new ImeRegistryAppStateObserver(_post, logger, clock, trackerStateProbe);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
// Baseline immediately: pre-existing registry state must be captured silently
|
||||
// BEFORE the first change edge, or stale entries would replay as fresh events.
|
||||
_observer.Tick("baseline");
|
||||
|
||||
try
|
||||
{
|
||||
_watcher = new RegistryWatcher(
|
||||
RegistryHive.LocalMachine,
|
||||
ImeRegistryAppStateObserver.ImeRootKeyPath,
|
||||
watchSubtree: true,
|
||||
view: RegistryView.Registry64,
|
||||
filter: RegistryNativeMethods.RegChangeNotifyFilter.Name
|
||||
| RegistryNativeMethods.RegChangeNotifyFilter.LastSet
|
||||
| RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic,
|
||||
trace: msg => _logger?.Debug($"ImeRegistryAppState watcher: {msg}"));
|
||||
_watcher.Changed += (_, __) => ScheduleDebouncedTick();
|
||||
_watcher.Error += (_, ex) =>
|
||||
{
|
||||
_logger?.Warning($"ImeRegistryAppState watcher error (poll-only fallback continues): {ex.Message}");
|
||||
};
|
||||
_watcher.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Watcher arm failure degrades to poll-only (periodic tick below) — announce once.
|
||||
_logger?.Warning($"ImeRegistryAppState: watcher arm failed, running poll-only: {ex.Message}");
|
||||
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId, Name, "watcher_arm_failed", ex);
|
||||
_watcher?.Dispose();
|
||||
_watcher = null;
|
||||
}
|
||||
|
||||
_periodicTimer = new Timer(_ => SafeTick("periodic"), null, PeriodicInterval, PeriodicInterval);
|
||||
_logger?.Info($"ImeRegistryAppStateHost started (watcher={(_watcher != null ? "armed" : "poll-only")})");
|
||||
}
|
||||
|
||||
private void ScheduleDebouncedTick()
|
||||
{
|
||||
if (Volatile.Read(ref _stopped) != 0) return;
|
||||
// One trailing-edge tick per change burst; RegistryWatcher coalesces anyway and the
|
||||
// observer snapshot-diffs, so collapsing bursts loses nothing.
|
||||
var timer = _debounceTimer;
|
||||
if (timer == null)
|
||||
{
|
||||
var created = new Timer(_ => SafeTick("registry_change"), null, DebounceDelay, Timeout.InfiniteTimeSpan);
|
||||
if (Interlocked.CompareExchange(ref _debounceTimer, created, null) != null)
|
||||
created.Dispose();
|
||||
else
|
||||
return;
|
||||
timer = _debounceTimer;
|
||||
}
|
||||
try { timer?.Change(DebounceDelay, Timeout.InfiniteTimeSpan); }
|
||||
catch (ObjectDisposedException) { /* stopping */ }
|
||||
}
|
||||
|
||||
private void SafeTick(string reason)
|
||||
{
|
||||
if (Volatile.Read(ref _stopped) != 0) return;
|
||||
try
|
||||
{
|
||||
_observer.Tick(reason);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Observer.Tick is itself fail-soft; this is the last-resort belt.
|
||||
_logger?.Debug($"ImeRegistryAppState tick failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _stopped, 1) != 0) return;
|
||||
|
||||
// Never Stop() the watcher from inside its own callback — we're outside here,
|
||||
// but RequestStop first keeps the contract obvious.
|
||||
try { _watcher?.RequestStop(); } catch { /* fail-soft */ }
|
||||
try { _watcher?.Dispose(); } catch { /* fail-soft */ }
|
||||
_watcher = null;
|
||||
|
||||
_debounceTimer?.Dispose();
|
||||
_debounceTimer = null;
|
||||
_periodicTimer?.Dispose();
|
||||
_periodicTimer = null;
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
@@ -75,6 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, DateTime?>? _prevOnDoTelemetryReceived;
|
||||
private readonly Action<ScriptExecutionState>? _prevOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo>? _prevOnScriptStarted;
|
||||
private readonly Action<string, string>? _prevOnImeTokenFailure;
|
||||
|
||||
// Our own delegate instances — stored once so Dispose can compare by reference.
|
||||
private readonly Action<string> _ourOnEspPhaseChanged;
|
||||
@@ -85,6 +87,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, DateTime?> _ourOnDoTelemetryReceived;
|
||||
private readonly Action<ScriptExecutionState> _ourOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo> _ourOnScriptStarted;
|
||||
private readonly Action<string, string> _ourOnImeTokenFailure;
|
||||
|
||||
// Dedup state for DecisionSignals.
|
||||
private string? _lastEspPhase;
|
||||
@@ -97,6 +100,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
// bootstrap marker line. Lets MCP report "which device runs which bootstrap version".
|
||||
private bool _bootstrapDetectedPosted;
|
||||
|
||||
// Cached EnterpriseDesktopAppManagement-CSP lookup for the IME install source — the
|
||||
// version event fires more than once per session (V1 parity, no dedup) and the registry
|
||||
// answer is stable per version, so the probe runs once per distinct version string.
|
||||
private string? _msiSourceCachedForVersion;
|
||||
private ImeMsiInstallSource _msiSourceCache;
|
||||
|
||||
// One-shot: historic script replay detected (a previous enrollment's IME log content
|
||||
// surviving on disk) — its script events are suppressed, and a single
|
||||
// historic_ime_replay_detected Info event explains the gap in the timeline.
|
||||
@@ -168,6 +177,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_prevOnDoTelemetryReceived = _tracker.OnDoTelemetryReceived;
|
||||
_prevOnScriptCompleted = _tracker.OnScriptCompleted;
|
||||
_prevOnScriptStarted = _tracker.OnScriptStarted;
|
||||
_prevOnImeTokenFailure = _tracker.OnImeTokenFailure;
|
||||
|
||||
// Store our delegate instances once — implicit method-group conversions
|
||||
// create a new delegate each time, which would break Dispose's reference check.
|
||||
@@ -179,6 +189,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_ourOnDoTelemetryReceived = OnDoTelemetryReceived;
|
||||
_ourOnScriptCompleted = OnScriptCompleted;
|
||||
_ourOnScriptStarted = OnScriptStarted;
|
||||
_ourOnImeTokenFailure = OnImeTokenFailure;
|
||||
|
||||
_tracker.OnEspPhaseChanged = _ourOnEspPhaseChanged;
|
||||
_tracker.OnUserSessionCompleted = _ourOnUserSessionCompleted;
|
||||
@@ -188,6 +199,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_tracker.OnDoTelemetryReceived = _ourOnDoTelemetryReceived;
|
||||
_tracker.OnScriptCompleted = _ourOnScriptCompleted;
|
||||
_tracker.OnScriptStarted = _ourOnScriptStarted;
|
||||
_tracker.OnImeTokenFailure = _ourOnImeTokenFailure;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -210,6 +222,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_tracker.OnScriptCompleted = _prevOnScriptCompleted;
|
||||
if (ReferenceEquals(_tracker.OnScriptStarted, _ourOnScriptStarted))
|
||||
_tracker.OnScriptStarted = _prevOnScriptStarted;
|
||||
if (ReferenceEquals(_tracker.OnImeTokenFailure, _ourOnImeTokenFailure))
|
||||
_tracker.OnImeTokenFailure = _prevOnImeTokenFailure;
|
||||
}
|
||||
|
||||
private void OnEspPhaseChanged(string phase)
|
||||
@@ -260,6 +274,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitScriptStarted(info);
|
||||
}
|
||||
|
||||
private void OnImeTokenFailure(string errorCode, string message)
|
||||
{
|
||||
_prevOnImeTokenFailure?.Invoke(errorCode, message);
|
||||
EmitImeTokenFailure(errorCode, message);
|
||||
}
|
||||
|
||||
internal void TriggerEspPhaseFromTest(string phase) => EmitEspPhase(phase);
|
||||
internal void TriggerUserSessionCompletedFromTest() => EmitUserSessionCompleted();
|
||||
internal void TriggerAppStateFromTest(AppPackageState app, AppInstallationState oldState, AppInstallationState newState) =>
|
||||
@@ -270,6 +290,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitDoTelemetry(app, sourceTimestampUtc);
|
||||
internal void TriggerScriptCompletedFromTest(ScriptExecutionState script) => EmitScriptCompleted(script);
|
||||
internal void TriggerScriptStartedFromTest(ScriptStartedInfo info) => EmitScriptStarted(info);
|
||||
internal void TriggerImeTokenFailureFromTest(string errorCode, string message) => EmitImeTokenFailure(errorCode, message);
|
||||
|
||||
/// <summary>
|
||||
/// Prefer the CMTrace log entry timestamp recorded by the most recent pattern match
|
||||
@@ -864,6 +885,19 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
var patternId = _tracker.LastMatchedPatternId;
|
||||
if (!string.IsNullOrEmpty(patternId))
|
||||
data["patternId"] = patternId!;
|
||||
|
||||
// The EnterpriseDesktopAppManagement CSP records where it downloaded the IME MSI
|
||||
// from — attach that install source so the event answers "which endpoint delivered
|
||||
// exactly this build" (helps attribute bad builds to a CDN/AFD rollout ring).
|
||||
var source = ResolveMsiInstallSource(version);
|
||||
if (source.HasData)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(source.DownloadUrl)) data["msiDownloadUrl"] = source.DownloadUrl;
|
||||
if (!string.IsNullOrEmpty(source.ProductCode)) data["msiProductCode"] = source.ProductCode;
|
||||
if (!string.IsNullOrEmpty(source.ProductVersion)) data["msiProductVersion"] = source.ProductVersion;
|
||||
data["msiMatchedBy"] = source.MatchedByVersion ? "productVersion" : "fileName";
|
||||
}
|
||||
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
|
||||
_post.Emit(
|
||||
@@ -874,7 +908,45 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
occurredAtUtc: now);
|
||||
|
||||
// PR3-D4: fire-once and useful for compatibility-debugging — INFO level.
|
||||
_logger?.Info($"ImeAdapter: IME agent version detected: {version}");
|
||||
_logger?.Info(source.HasData && !string.IsNullOrEmpty(source.DownloadUrl)
|
||||
? $"ImeAdapter: IME agent version detected: {version} (CSP install source: {source.DownloadUrl})"
|
||||
: $"ImeAdapter: IME agent version detected: {version}");
|
||||
}
|
||||
|
||||
// IME token-acquisition failure ("Failed to get AAD token ... errorCode = N",
|
||||
// IntuneTokenManager). Previously we only matched the success line — an outage
|
||||
// (WAM broken, conditional access, network) was invisible while every check-in
|
||||
// silently did nothing. Tracker-side dedup: once per distinct errorCode.
|
||||
private void EmitImeTokenFailure(string errorCode, string message)
|
||||
{
|
||||
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["errorCode"] = string.IsNullOrEmpty(errorCode) ? "unknown" : errorCode,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(message))
|
||||
data["logLine"] = message;
|
||||
var patternId = _tracker.LastMatchedPatternId;
|
||||
if (!string.IsNullOrEmpty(patternId))
|
||||
data["patternId"] = patternId!;
|
||||
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.ImeTokenFailure,
|
||||
source: SourceLabel,
|
||||
message: $"IME token acquisition failed (errorCode={data["errorCode"]})",
|
||||
data: data,
|
||||
severity: EventSeverity.Warning,
|
||||
occurredAtUtc: now);
|
||||
}
|
||||
|
||||
private ImeMsiInstallSource ResolveMsiInstallSource(string version)
|
||||
{
|
||||
if (_msiSourceCachedForVersion == version) return _msiSourceCache;
|
||||
_msiSourceCache = ImeMsiInstallSourceProbe.Read(version, _logger);
|
||||
_msiSourceCachedForVersion = version;
|
||||
return _msiSourceCache;
|
||||
}
|
||||
|
||||
private void EmitDoTelemetry(AppPackageState app, DateTime? sourceTimestampUtc)
|
||||
|
||||
@@ -30,6 +30,8 @@ namespace AutopilotMonitor.Agent.V2
|
||||
"IntuneManagementExtension-????????-??????.log",
|
||||
"AppWorkload.log",
|
||||
"AppWorkload-????????-??????.log",
|
||||
"AppActionProcessor.log",
|
||||
"AppActionProcessor-????????-??????.log",
|
||||
"AgentExecutor.log",
|
||||
"AgentExecutor-????????-??????.log",
|
||||
"HealthScripts.log",
|
||||
|
||||
@@ -172,12 +172,13 @@ namespace AutopilotMonitor.Agent.V2
|
||||
|
||||
logger.Info($"Scheduled Task '{taskName}' created/updated successfully.");
|
||||
|
||||
// Two-tier runtime-launch fallback chain:
|
||||
// Two-tier runtime-launch fallback chain (order is token-aware, see below):
|
||||
//
|
||||
// 1. WMI Win32_Process.Create (first choice). Spawns the process from
|
||||
// the WBEM service context, outside the install-mode process tree
|
||||
// and outside Task Scheduler's queue (which defers /Run on battery
|
||||
// / OOBE — observed WinPE 2026-05-04, project_v2_install_runtime_handoff.md).
|
||||
// 1. WMI Win32_Process.Create (first choice on a full token). Spawns the
|
||||
// process from the WBEM service context, outside the install-mode
|
||||
// process tree and outside Task Scheduler's queue (which defers /Run
|
||||
// on battery / OOBE — observed WinPE 2026-05-04,
|
||||
// project_v2_install_runtime_handoff.md).
|
||||
// 2. schtasks /Run on the registered BootTrigger task (fallback).
|
||||
// Used when WMI is blocked, typically Defender ASR rule
|
||||
// d1e49aac-8f56-4280-b9ba-993a6d77406c ('Block process creations
|
||||
@@ -190,6 +191,18 @@ namespace AutopilotMonitor.Agent.V2
|
||||
// the runtime via the Task Scheduler service — different
|
||||
// ATT&CK signature than either WMI or a manual schtasks /Run.
|
||||
//
|
||||
// Token-aware ordering (2026-08-17): WMI duplicates the CALLER's token onto
|
||||
// the new process. MDM-originated install chains (Bootstrap MSI via
|
||||
// EnterpriseDesktopAppManagement CSP → msiexec) carry a SYSTEM token with
|
||||
// privileges REMOVED (SeTimeZonePrivilege, SeSystemEnvironmentPrivilege) —
|
||||
// a WMI-launched runtime then runs degraded for its whole life (tzutil
|
||||
// exit 5, firmware reads privilege_denied). The Task Scheduler service
|
||||
// starts the task with a fresh full SYSTEM token, so on a stripped token
|
||||
// the order flips: schtasks /Run first (verified via process poll, since
|
||||
// /Run only reports "queued"), WMI as degraded-but-alive fallback. The
|
||||
// battery/OOBE queue-defer that motivated WMI-first is mitigated by the
|
||||
// hardened task XML (PR2: battery limits off, StartWhenAvailable, PT0S).
|
||||
//
|
||||
// Critical contract: --install returns 0 if deployment + task creation
|
||||
// succeeded, even if both immediate launch paths failed. Returning 1
|
||||
// here would make the bootstrap script throw, the next IME run would
|
||||
@@ -197,11 +210,27 @@ namespace AutopilotMonitor.Agent.V2
|
||||
// sit without a runtime until manual intervention. The bootstrap
|
||||
// script's Get-Process probe is the canonical "no runtime came up"
|
||||
// signal — see Install-AutopilotMonitor.ps1 ~line 280.
|
||||
var wmi = TryStartRuntimeViaWmi(targetExePath, logger);
|
||||
var launch = DecideRuntimeLaunchOutcome(
|
||||
wmiReturnValue: wmi.ReturnValue,
|
||||
wmiPid: wmi.Pid,
|
||||
trySchtasks: () => TryStartRuntimeViaSchtasks(taskName, logger));
|
||||
RuntimeLaunchResult launch;
|
||||
if (!TokenPrivilegeProbe.IsPrivilegeHeld(TokenPrivilegeProbe.TimeZonePrivilege))
|
||||
{
|
||||
logger.Warning(
|
||||
"Install-mode process token does not hold SeTimeZonePrivilege — privilege-stripped " +
|
||||
"caller chain (typical: MDM LOB MSI install). WMI Win32_Process.Create would duplicate " +
|
||||
"this token onto the runtime, so the Scheduled Task launch (fresh full SYSTEM token from " +
|
||||
"the Task Scheduler service) is preferred for this install.");
|
||||
launch = DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => TryStartRuntimeViaSchtasks(taskName, logger),
|
||||
verifyRuntimePid: () => WaitForDetachedRuntimePid(targetExePath, logger),
|
||||
tryWmi: () => TryStartRuntimeViaWmi(targetExePath, logger));
|
||||
}
|
||||
else
|
||||
{
|
||||
var wmi = TryStartRuntimeViaWmi(targetExePath, logger);
|
||||
launch = DecideRuntimeLaunchOutcome(
|
||||
wmiReturnValue: wmi.ReturnValue,
|
||||
wmiPid: wmi.Pid,
|
||||
trySchtasks: () => TryStartRuntimeViaSchtasks(taskName, logger));
|
||||
}
|
||||
|
||||
switch (launch.Method)
|
||||
{
|
||||
@@ -211,7 +240,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
break;
|
||||
case RuntimeLaunchMethod.Schtasks:
|
||||
logger.Info(
|
||||
$"Runtime process queued via schtasks /Run fallback on task '{taskName}'. exe={targetExePath}. {launch.Diagnostic}");
|
||||
$"Runtime start via schtasks /Run on task '{taskName}'. exe={targetExePath}. {launch.Diagnostic}");
|
||||
break;
|
||||
case RuntimeLaunchMethod.Deferred:
|
||||
logger.Warning(
|
||||
@@ -629,11 +658,113 @@ namespace AutopilotMonitor.Agent.V2
|
||||
diagnostic: $"Both immediate-launch paths failed; deferring runtime start to BootTrigger Scheduled Task on next reboot. {wmiHint} schtasks /Run exit={schtasksExit}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restricted-token variant of the launch orchestration — used when the install-mode
|
||||
/// process token does not hold <c>SeTimeZonePrivilege</c> (privilege-stripped MDM/MSI
|
||||
/// caller chain, observed 2026-08-17). WMI would duplicate the stripped token onto the
|
||||
/// runtime, so the order flips: Scheduled Task first (fresh full SYSTEM token from the
|
||||
/// Task Scheduler service), WMI only as degraded-but-alive fallback.
|
||||
/// <para>
|
||||
/// Contract:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Invoke <paramref name="trySchtasks"/>; on exit 0 invoke <paramref name="verifyRuntimePid"/> (schtasks /Run only reports "queued", never "launched"). A verified pid returns <see cref="RuntimeLaunchMethod.Schtasks"/> without touching WMI.</description></item>
|
||||
/// <item><description>schtasks failed or no process appeared → invoke <paramref name="tryWmi"/>; a valid pid returns <see cref="RuntimeLaunchMethod.Wmi"/> with a diagnostic warning that the runtime runs on the restricted token.</description></item>
|
||||
/// <item><description>Both paths fail → <see cref="RuntimeLaunchMethod.Deferred"/>; the BootTrigger start on next reboot gets a full token anyway.</description></item>
|
||||
/// <item><description><paramref name="verifyRuntimePid"/> is only invoked after a successful schtasks queue — it costs up to the full poll window.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static RuntimeLaunchResult DecideRestrictedTokenLaunchOutcome(
|
||||
Func<int> trySchtasks,
|
||||
Func<int> verifyRuntimePid,
|
||||
Func<WmiLaunchAttempt> tryWmi)
|
||||
{
|
||||
if (trySchtasks == null) throw new ArgumentNullException(nameof(trySchtasks));
|
||||
if (verifyRuntimePid == null) throw new ArgumentNullException(nameof(verifyRuntimePid));
|
||||
if (tryWmi == null) throw new ArgumentNullException(nameof(tryWmi));
|
||||
|
||||
var schtasksExit = trySchtasks();
|
||||
if (schtasksExit == 0)
|
||||
{
|
||||
var pid = verifyRuntimePid();
|
||||
if (pid > 0)
|
||||
{
|
||||
return new RuntimeLaunchResult(
|
||||
method: RuntimeLaunchMethod.Schtasks,
|
||||
pid: pid,
|
||||
wmiReturnValue: 0u,
|
||||
schtasksExitCode: 0,
|
||||
diagnostic: $"Runtime launched via Scheduled Task (restricted install token — task provides a full SYSTEM token). Verified PID={pid}.");
|
||||
}
|
||||
}
|
||||
|
||||
var schtasksHint = schtasksExit == 0
|
||||
? "schtasks /Run was queued but no runtime process appeared within the verification window."
|
||||
: $"schtasks /Run exit={schtasksExit}.";
|
||||
|
||||
var wmi = tryWmi();
|
||||
if (wmi.ReturnValue == 0u && wmi.Pid > 0)
|
||||
{
|
||||
return new RuntimeLaunchResult(
|
||||
method: RuntimeLaunchMethod.Wmi,
|
||||
pid: wmi.Pid,
|
||||
wmiReturnValue: 0u,
|
||||
schtasksExitCode: schtasksExit,
|
||||
diagnostic: "Runtime launched via WMI fallback ON THE RESTRICTED TOKEN — privileged features " +
|
||||
"(timezone auto-set, firmware reads) stay degraded until the next BootTrigger start. " +
|
||||
$"{schtasksHint} PID={wmi.Pid}.");
|
||||
}
|
||||
|
||||
return new RuntimeLaunchResult(
|
||||
method: RuntimeLaunchMethod.Deferred,
|
||||
pid: 0,
|
||||
wmiReturnValue: wmi.ReturnValue,
|
||||
schtasksExitCode: schtasksExit,
|
||||
diagnostic: "Restricted-token launch: both Scheduled Task and WMI paths failed; deferring to " +
|
||||
$"BootTrigger on next reboot (full token there). {schtasksHint} WMI returnValue={wmi.ReturnValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls for the detached runtime process after a <c>schtasks /Run</c> queue — same
|
||||
/// 10s / 500ms contract as the bootstrap script's Get-Process probe. The install-mode
|
||||
/// process shares the runtime's exe name, so the own pid is excluded; a pre-existing
|
||||
/// foreign runtime cannot be running here because install mode bails earlier on the
|
||||
/// Deployed marker.
|
||||
/// </summary>
|
||||
private static int WaitForDetachedRuntimePid(string exePath, AgentLogger logger)
|
||||
{
|
||||
var processName = Path.GetFileNameWithoutExtension(exePath);
|
||||
var ownPid = Process.GetCurrentProcess().Id;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
var candidates = Process.GetProcessesByName(processName);
|
||||
try
|
||||
{
|
||||
var runtime = candidates.FirstOrDefault(p => p.Id != ownPid);
|
||||
if (runtime != null) return runtime.Id;
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var p in candidates) p.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Debug($"Runtime verification poll failed transiently: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
System.Threading.Thread.Sleep(500);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single WMI <c>Win32_Process.Create</c> attempt. <see cref="Pid"/>
|
||||
/// is <c>0</c> on any non-zero <see cref="ReturnValue"/>.
|
||||
/// </summary>
|
||||
private readonly struct WmiLaunchAttempt
|
||||
internal readonly struct WmiLaunchAttempt
|
||||
{
|
||||
public WmiLaunchAttempt(uint returnValue, int pid)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks whether the current process token HOLDS a privilege — present in the token at
|
||||
/// all, enabled or disabled. This is different from the enable-on-demand pattern in
|
||||
/// <c>UefiSecureBootCertReader</c>: a privilege that was REMOVED from the token cannot be
|
||||
/// re-enabled with <c>AdjustTokenPrivileges</c> by anyone, including SYSTEM.
|
||||
/// <para>
|
||||
/// Why install mode needs this: MDM-originated install chains (EnterpriseDesktopAppManagement
|
||||
/// CSP → msiexec → bootstrap runner) carry a SYSTEM token with several privileges removed —
|
||||
/// observed 2026-08-17 with the Bootstrap MSI: SeTimeZonePrivilege and
|
||||
/// SeSystemEnvironmentPrivilege were gone, so tzutil exited 5 (ACCESS_DENIED) and firmware
|
||||
/// reads reported privilege_denied. WMI <c>Win32_Process.Create</c> duplicates the CALLER's
|
||||
/// token onto the new process, so the restriction propagates to the runtime; the Task
|
||||
/// Scheduler service, by contrast, starts the task with a fresh full SYSTEM token. The
|
||||
/// launch orchestration therefore needs to know up front whether its own token is stripped.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fail-soft: every API failure reports "held" so the default (WMI-first) launch path is
|
||||
/// kept — a probe defect must never change behavior for the healthy platform-script chain.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class TokenPrivilegeProbe
|
||||
{
|
||||
public const string TimeZonePrivilege = "SeTimeZonePrivilege";
|
||||
|
||||
public static bool IsPrivilegeHeld(string privilegeName)
|
||||
{
|
||||
try
|
||||
{
|
||||
NativeMethods.LUID target;
|
||||
if (!NativeMethods.LookupPrivilegeValueW(null, privilegeName, out target))
|
||||
return true;
|
||||
|
||||
SafeTokenHandle token;
|
||||
if (!NativeMethods.OpenProcessToken(NativeMethods.GetCurrentProcess(),
|
||||
NativeMethods.TOKEN_QUERY, out token))
|
||||
{
|
||||
if (token != null) token.Dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
using (token)
|
||||
{
|
||||
uint required;
|
||||
NativeMethods.GetTokenInformation(token, NativeMethods.TokenPrivileges,
|
||||
IntPtr.Zero, 0, out required);
|
||||
if (required == 0 || required > 64 * 1024)
|
||||
return true;
|
||||
|
||||
var buffer = Marshal.AllocHGlobal((int)required);
|
||||
try
|
||||
{
|
||||
if (!NativeMethods.GetTokenInformation(token, NativeMethods.TokenPrivileges,
|
||||
buffer, required, out required))
|
||||
return true;
|
||||
|
||||
// TOKEN_PRIVILEGES layout: DWORD PrivilegeCount, then PrivilegeCount ×
|
||||
// LUID_AND_ATTRIBUTES { LUID (uint Low, int High), DWORD Attributes } = 12 bytes.
|
||||
var count = Marshal.ReadInt32(buffer);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var entry = 4 + i * 12;
|
||||
var low = unchecked((uint)Marshal.ReadInt32(buffer, entry));
|
||||
var high = Marshal.ReadInt32(buffer, entry + 4);
|
||||
if (low == target.LowPart && high == target.HighPart)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SafeTokenHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
public SafeTokenHandle() : base(true) { }
|
||||
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
return NativeMethods.CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
|
||||
private static class NativeMethods
|
||||
{
|
||||
public const uint TOKEN_QUERY = 0x0008;
|
||||
public const int TokenPrivileges = 3; // TOKEN_INFORMATION_CLASS.TokenPrivileges
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct LUID
|
||||
{
|
||||
public uint LowPart;
|
||||
public int HighPart;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern IntPtr GetCurrentProcess();
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out SafeTokenHandle tokenHandle);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool LookupPrivilegeValueW(string lpSystemName, string lpName, out LUID lpLuid);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool GetTokenInformation(SafeTokenHandle tokenHandle, int tokenInformationClass,
|
||||
IntPtr tokenInformation, uint tokenInformationLength, out uint returnLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool CloseHandle(IntPtr handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,9 @@ try {
|
||||
}
|
||||
|
||||
Write-Log "Bootstrap script downloaded to $dest - executing."
|
||||
& $dest *>> $log
|
||||
# PS 5.1 file redirection (*>>) writes UTF-16LE, which garbles a log otherwise
|
||||
# written via Add-Content (ANSI). Route through Add-Content for one encoding.
|
||||
& $dest *>&1 | ForEach-Object { $_.ToString() } | Add-Content -Path $log
|
||||
Write-Log "Bootstrap script finished (exit code $LASTEXITCODE)."
|
||||
exit 0
|
||||
} catch {
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Default-value tests for the IME-installer archiving settings. The flag gates the
|
||||
/// ime-msi-archive queue worker (pause, not drop); the size cap guards the download.
|
||||
/// Defaulting the flag to TRUE is deliberate: archiving is operator infrastructure with
|
||||
/// ~one small download per Microsoft IME release, and a version missed while the flag is
|
||||
/// off is gone for good (the versionless CDN moves on).
|
||||
/// </summary>
|
||||
public class AdminConfigurationImeMsiArchivingTests
|
||||
{
|
||||
[Fact]
|
||||
public void ImeMsiArchivingEnabled_defaults_to_true_on_new_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration();
|
||||
Assert.True(cfg.ImeMsiArchivingEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeMsiArchivingEnabled_defaults_to_true_on_CreateDefault()
|
||||
{
|
||||
var cfg = AdminConfiguration.CreateDefault();
|
||||
Assert.True(cfg.ImeMsiArchivingEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxImeMsiDownloadSizeMB_defaults_to_250()
|
||||
{
|
||||
// 250 MB: ~20x today's MSI so a legitimately grown installer is never missed,
|
||||
// while a tampered URL still cannot stream gigabytes (Oliver, 2026-08-18).
|
||||
Assert.Equal(250, new AdminConfiguration().MaxImeMsiDownloadSizeMB);
|
||||
Assert.Equal(250, AdminConfiguration.CreateDefault().MaxImeMsiDownloadSizeMB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_persist_on_the_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration { ImeMsiArchivingEnabled = false, MaxImeMsiDownloadSizeMB = 50 };
|
||||
Assert.False(cfg.ImeMsiArchivingEnabled);
|
||||
Assert.Equal(50, cfg.MaxImeMsiDownloadSizeMB);
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,7 @@ public class BuiltInRulesTests
|
||||
"updateStatePostponed", "espPhaseDetected", "imeStarted",
|
||||
"policiesDiscovered", "ignoreCompletedApp", "imeAgentVersion",
|
||||
"espTrackStatus", "updateName", "updateWin32AppState",
|
||||
"cancelStuckAndSetCurrent", "imeSessionChange", "imeImpersonation",
|
||||
"cancelStuckAndSetCurrent", "imeSessionChange", "imeImpersonation", "imeTokenFailure",
|
||||
"enrollmentCompleted", "updateDoTelemetry", "scriptStarted",
|
||||
"scriptContext", "scriptExitCode", "scriptOutput", "scriptCompleted",
|
||||
"resetPlatformScriptContext",
|
||||
|
||||
+17
@@ -174,6 +174,23 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
Assert.Equal(EndpointPolicy.TenantAdminOrGA, postRules.Policy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IME log pattern edits write GLOBAL patterns (all tenants) and the handler has no GA
|
||||
/// re-gate, so the PUT must stay GlobalAdminOnly — a downgrade to TenantAdminOrGA would let
|
||||
/// any customer tenant admin mutate platform-wide patterns via ?global=true.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ImeLogPatternWrites_AreGlobalAdminOnly()
|
||||
{
|
||||
var putPattern = EndpointAccessPolicyCatalog.FindPolicy("PUT", "/api/rules/ime-log-patterns/IME-001");
|
||||
var reseed = EndpointAccessPolicyCatalog.FindPolicy("POST", "/api/rules/ime-log-patterns/reseed");
|
||||
|
||||
Assert.NotNull(putPattern);
|
||||
Assert.NotNull(reseed);
|
||||
Assert.Equal(EndpointPolicy.GlobalAdminOnly, putPattern.Policy);
|
||||
Assert.Equal(EndpointPolicy.GlobalAdminOnly, reseed.Policy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregistered routes must return null (fail-closed).
|
||||
/// </summary>
|
||||
|
||||
@@ -39,6 +39,26 @@ public class GetImeVersionHistoryRedactionTests
|
||||
Assert.DoesNotContain("firstSeenSessionId", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The MSI-archive columns (ImeMsiArchiver) are operator infrastructure — blob paths,
|
||||
/// hashes and source URLs of the platform's own archive have no business in a tenant
|
||||
/// member's payload. Pinned like the identifiers so the boundary survives refactors.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NonGlobalCaller_DropsMsiArchiveFields()
|
||||
{
|
||||
var json = SerializeFor(hasGlobalScope: false, MakeEntry());
|
||||
|
||||
Assert.DoesNotContain("msiArchiveStatus", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("msiArchiveBlobPath", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("msiSha256", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("msiBytes", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("msiSourceUrl", json, StringComparison.OrdinalIgnoreCase);
|
||||
// Values too, not just keys (same rationale as the identifier-value test below).
|
||||
Assert.DoesNotContain("4d6b85fd3c49cf227ab4711308bca1f3c701cad9bff1799da4c6b6616066bb5e", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("imeswdb-afd-secondary", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The values, not just the property names: a rename of the model property must not let the
|
||||
/// identifier survive under a different key. Asserting on the GUIDs catches that.
|
||||
@@ -101,7 +121,12 @@ public class GetImeVersionHistoryRedactionTests
|
||||
var keys = doc.RootElement[0].EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "firstSeenAt", "firstSeenSessionId", "firstSeenTenantId", "lastSeenAt", "sessionCount", "version" },
|
||||
new[]
|
||||
{
|
||||
"firstSeenAt", "firstSeenSessionId", "firstSeenTenantId", "lastSeenAt",
|
||||
"msiArchiveBlobPath", "msiArchiveStatus", "msiBytes", "msiSha256", "msiSourceUrl",
|
||||
"sessionCount", "version",
|
||||
},
|
||||
keys);
|
||||
}
|
||||
|
||||
@@ -185,5 +210,10 @@ public class GetImeVersionHistoryRedactionTests
|
||||
FirstSeenTenantId = "57f34dd1-6a42-47f9-9bc0-b921fa6caa30",
|
||||
LastSeenAt = Last,
|
||||
SessionCount = 3109,
|
||||
MsiArchiveStatus = "Archived",
|
||||
MsiArchiveBlobPath = $"{version}/IntuneWindowsAgent.msi",
|
||||
MsiSha256 = "4d6b85fd3c49cf227ab4711308bca1f3c701cad9bff1799da4c6b6616066bb5e",
|
||||
MsiBytes = 13_107_200,
|
||||
MsiSourceUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.Ime;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The IME-installer archiver downloads a newly sighted IME build into the permanent
|
||||
/// ime-archive container. Its contract: NEVER throw (worker decides retry from the result),
|
||||
/// treat the agent-supplied URL as untrusted (allowlist, else canonical fallback), enforce
|
||||
/// the size cap both at Content-Length preflight and mid-stream, and stay idempotent under
|
||||
/// queue re-delivery (409 on the write-once upload == archived). Tests pin every status
|
||||
/// branch via a recording subclass — no Azurite, no HTTP.
|
||||
/// </summary>
|
||||
public class ImeMsiArchiverTests
|
||||
{
|
||||
private const string Version = "1.104.102.0";
|
||||
private const string EventUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi";
|
||||
|
||||
// =========================================================================
|
||||
// URL allowlist (static — the SSRF boundary)
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData(EventUrl, true)]
|
||||
[InlineData("https://swda01-mscdn.manage.microsoft.com/IntuneWindowsAgent.msi", true)]
|
||||
[InlineData("https://manage.microsoft.com/IntuneWindowsAgent.msi", true)] // apex host
|
||||
[InlineData("https://x.manage.microsoft.com/path/IntuneWindowsAgent.msi?sv=1&sig=2", true)] // query ignored
|
||||
[InlineData("https://x.manage.microsoft.com/intunewindowsagent.MSI", true)] // case-insensitive
|
||||
[InlineData("http://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi", false)] // https only
|
||||
[InlineData("https://evil.example.com/IntuneWindowsAgent.msi", false)]
|
||||
[InlineData("https://manage.microsoft.com.evil.com/IntuneWindowsAgent.msi", false)] // suffix spoof
|
||||
[InlineData("https://xmanage.microsoft.com/IntuneWindowsAgent.msi", false)] // needs the dot
|
||||
[InlineData("https://x.manage.microsoft.com/SomethingElse.msi", false)] // wrong filename
|
||||
[InlineData("https://x.manage.microsoft.com/IntuneWindowsAgent.msi.exe", false)]
|
||||
[InlineData("not a url", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsAllowedMsiUrl_cases(string? url, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ImeMsiArchiver.IsAllowedMsiUrl(url));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Version guard (blob path is built from untrusted input)
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("1.104.102.0.1")] // too many components
|
||||
[InlineData("1.104../escape")]
|
||||
[InlineData("latest")]
|
||||
[InlineData("1.104.102.0 ")]
|
||||
public async Task ArchiveAsync_BadVersion_PermanentFailure_NoDownload(string version)
|
||||
{
|
||||
var archiver = new RecordingArchiver();
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope(version: version));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.FailedBadVersion, result.Status);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.Equal(0, archiver.DownloadCalls + archiver.UploadCalls);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// URL selection
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_AllowedEventUrl_IsUsed()
|
||||
{
|
||||
var archiver = new RecordingArchiver();
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope(url: EventUrl));
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(EventUrl, archiver.DownloadLastUrl);
|
||||
Assert.Equal(EventUrl, result.SourceUrl);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://evil.example.com/IntuneWindowsAgent.msi")] // allowlist reject
|
||||
[InlineData(null)] // pre-enrichment agent
|
||||
public async Task ArchiveAsync_MissingOrRejectedEventUrl_FallsBackToCanonical(string? url)
|
||||
{
|
||||
var archiver = new RecordingArchiver();
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope(url: url));
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.CanonicalMsiUrl, archiver.DownloadLastUrl);
|
||||
Assert.Equal(ImeMsiArchiver.CanonicalMsiUrl, result.SourceUrl);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Happy path
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_HappyPath_UploadsMsiAndProvenance_WithHashAndSize()
|
||||
{
|
||||
var payload = new byte[3 * 1024 * 1024];
|
||||
new Random(42).NextBytes(payload);
|
||||
var expectedSha = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant();
|
||||
var archiver = new RecordingArchiver { Payload = payload };
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.Archived, result.Status);
|
||||
Assert.Equal($"{Version}/IntuneWindowsAgent.msi", result.BlobPath);
|
||||
Assert.Equal(expectedSha, result.Sha256);
|
||||
Assert.Equal(payload.Length, result.SizeBytes);
|
||||
Assert.Equal(1, archiver.UploadCalls);
|
||||
Assert.Equal($"{Version}/IntuneWindowsAgent.msi", archiver.UploadLastPath);
|
||||
Assert.Equal(payload.Length, archiver.UploadedByteCount);
|
||||
Assert.Equal(1, archiver.ProvenanceCalls);
|
||||
Assert.Equal($"{Version}/provenance.json", archiver.ProvenanceLastPath);
|
||||
Assert.Contains(expectedSha, archiver.ProvenanceLastJson);
|
||||
Assert.Contains(Version, archiver.ProvenanceLastJson);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Size cap — preflight and mid-stream
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_ContentLengthAboveCap_Rejected_NoUpload()
|
||||
{
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
MaxDownloadSizeMB = 1,
|
||||
Payload = new byte[2 * 1024 * 1024],
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.FailedTooLarge, result.Status);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.Equal(0, archiver.UploadCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_UnknownContentLength_CapStillEnforcedMidStream()
|
||||
{
|
||||
// A lying/absent Content-Length must not bypass the cap — the hashing wrapper
|
||||
// aborts the stream as soon as the byte count crosses it.
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
MaxDownloadSizeMB = 1,
|
||||
Payload = new byte[2 * 1024 * 1024],
|
||||
ReportContentLength = false,
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.FailedTooLarge, result.Status);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.Equal(0, archiver.ProvenanceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_CapZero_MeansUnlimited()
|
||||
{
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
MaxDownloadSizeMB = 0,
|
||||
Payload = new byte[2 * 1024 * 1024],
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Idempotency under queue re-delivery
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_BlobAlreadyExists_TreatedAsArchived_NotRetryable()
|
||||
{
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
UploadException = new RequestFailedException(409, "BlobAlreadyExists"),
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.Archived, result.Status);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.Equal($"{Version}/IntuneWindowsAgent.msi", result.BlobPath);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Transient failures — retryable, never thrown
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_DownloadFails_RetryableDownloadStatus()
|
||||
{
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
DownloadException = new HttpRequestException("boom"),
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.FailedDownload, result.Status);
|
||||
Assert.True(result.Retryable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_Timeout_RetryableTimeoutStatus()
|
||||
{
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
DownloadException = new OperationCanceledException(),
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.FailedTimeout, result.Status);
|
||||
Assert.True(result.Retryable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_UnexpectedUploadError_RetryableErrorStatus()
|
||||
{
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
UploadException = new InvalidOperationException("storage hiccup"),
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.FailedError, result.Status);
|
||||
Assert.True(result.Retryable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_CallerCancellation_Propagates()
|
||||
{
|
||||
// Host shutdown must reach the worker's dispatch loop so the message stays queued.
|
||||
var archiver = new RecordingArchiver();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
archiver.DownloadException = new OperationCanceledException(cts.Token);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => archiver.ArchiveAsync(Envelope(), cts.Token));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private static ImeMsiArchiveEnvelope Envelope(string version = Version, string? url = EventUrl) =>
|
||||
new()
|
||||
{
|
||||
Version = version,
|
||||
MsiDownloadUrl = url,
|
||||
MsiMatchedBy = "productVersion",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private sealed class RecordingArchiver : ImeMsiArchiver
|
||||
{
|
||||
public byte[] Payload { get; set; } = new byte[2048];
|
||||
public bool ReportContentLength { get; set; } = true;
|
||||
public int MaxDownloadSizeMB { get; set; } = 250;
|
||||
public Exception? DownloadException { get; set; }
|
||||
public Exception? UploadException { get; set; }
|
||||
|
||||
public int DownloadCalls { get; private set; }
|
||||
public string? DownloadLastUrl { get; private set; }
|
||||
public int UploadCalls { get; private set; }
|
||||
public string? UploadLastPath { get; private set; }
|
||||
public long UploadedByteCount { get; private set; }
|
||||
public int ProvenanceCalls { get; private set; }
|
||||
public string? ProvenanceLastPath { get; private set; }
|
||||
public string ProvenanceLastJson { get; private set; } = string.Empty;
|
||||
|
||||
protected override Task<AdminConfiguration> GetAdminConfigurationAsync()
|
||||
=> Task.FromResult(new AdminConfiguration { MaxImeMsiDownloadSizeMB = MaxDownloadSizeMB });
|
||||
|
||||
protected override Task<(Stream Content, long ContentLength)> OpenDownloadAsync(
|
||||
string url, CancellationToken cancellationToken)
|
||||
{
|
||||
DownloadCalls++;
|
||||
DownloadLastUrl = url;
|
||||
if (DownloadException is not null) throw DownloadException;
|
||||
return Task.FromResult(
|
||||
((Stream)new MemoryStream(Payload), ReportContentLength ? Payload.LongLength : -1L));
|
||||
}
|
||||
|
||||
protected override async Task UploadMsiAsync(string blobPath, Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
UploadCalls++;
|
||||
UploadLastPath = blobPath;
|
||||
// Drain the stream like the real blob upload would — this is what drives the
|
||||
// hashing/cap wrapper (and mid-stream aborts surface here as exceptions).
|
||||
var buffer = new byte[64 * 1024];
|
||||
int read;
|
||||
while ((read = await content.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
UploadedByteCount += read;
|
||||
if (UploadException is not null) throw UploadException;
|
||||
}
|
||||
|
||||
protected override Task UploadProvenanceAsync(string blobPath, string json, CancellationToken cancellationToken)
|
||||
{
|
||||
ProvenanceCalls++;
|
||||
ProvenanceLastPath = blobPath;
|
||||
ProvenanceLastJson = json;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -818,6 +818,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "VulnerabilityCorrelationEnabled", config.VulnerabilityCorrelationEnabled },
|
||||
{ "EnableIndexDualWrite", config.EnableIndexDualWrite },
|
||||
{ "AutoApproveNewTenants", config.AutoApproveNewTenants },
|
||||
{ "ImeMsiArchivingEnabled", config.ImeMsiArchivingEnabled },
|
||||
{ "MaxImeMsiDownloadSizeMB", config.MaxImeMsiDownloadSizeMB },
|
||||
{ "SelfServiceAppHomingEnabled", config.SelfServiceAppHomingEnabled },
|
||||
{ "SessionDeletionKillSwitch", config.SessionDeletionKillSwitch },
|
||||
{ "VulnerabilityDataLastSyncUtc", config.VulnerabilityDataLastSyncUtc },
|
||||
@@ -885,6 +887,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
VulnerabilityCorrelationEnabled = entity.GetBoolean("VulnerabilityCorrelationEnabled") ?? true,
|
||||
EnableIndexDualWrite = entity.GetBoolean("EnableIndexDualWrite") ?? false,
|
||||
AutoApproveNewTenants = entity.GetBoolean("AutoApproveNewTenants") ?? false,
|
||||
ImeMsiArchivingEnabled = entity.GetBoolean("ImeMsiArchivingEnabled") ?? true,
|
||||
MaxImeMsiDownloadSizeMB = entity.GetInt32("MaxImeMsiDownloadSizeMB") ?? 250,
|
||||
SelfServiceAppHomingEnabled = entity.GetBoolean("SelfServiceAppHomingEnabled") ?? false,
|
||||
SessionDeletionKillSwitch = entity.GetBoolean("SessionDeletionKillSwitch") ?? false,
|
||||
VulnerabilityDataLastSyncUtc = entity.GetString("VulnerabilityDataLastSyncUtc"),
|
||||
|
||||
+4
@@ -172,6 +172,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<List<ImeVersionHistoryEntry>> GetImeVersionHistoryAsync()
|
||||
=> _storage.GetImeVersionHistoryAsync();
|
||||
|
||||
public Task UpdateImeVersionArchiveInfoAsync(
|
||||
string version, string status, string? blobPath, string? sha256, long? sizeBytes, string? sourceUrl)
|
||||
=> _storage.UpdateImeVersionArchiveInfoAsync(version, status, blobPath, sha256, sizeBytes, sourceUrl);
|
||||
|
||||
public async Task<bool> StoreEventAsync(EnrollmentEvent evt)
|
||||
{
|
||||
var result = await _storage.StoreEventAsync(evt);
|
||||
|
||||
@@ -334,6 +334,26 @@ builder.Services.AddSingleton<
|
||||
builder.Services.AddHostedService<
|
||||
AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelateQueueWorker>();
|
||||
|
||||
// IME-installer archiving on first fleet-wide sighting of a new IME version (~monthly).
|
||||
// Producer sits in EventIngestProcessor's new-version continuation; the worker downloads
|
||||
// the MSI from the CSP-reported (allowlisted) URL into the permanent ime-archive container
|
||||
// and merges the outcome onto the ImeVersionHistory row. Typed HttpClient: no redirects
|
||||
// (SSRF posture — the allowlisted host must serve directly), long timeout owned by the
|
||||
// archiver's own CTS, transient retry via the shared external-data policy.
|
||||
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Ime.ImeMsiArchiver>()
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(10),
|
||||
})
|
||||
.ConfigureHttpClient(client => client.Timeout = Timeout.InfiniteTimeSpan)
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
|
||||
builder.Services.AddSingleton<
|
||||
AutopilotMonitor.Functions.Services.Ime.IImeMsiArchiveProducer,
|
||||
AutopilotMonitor.Functions.Services.Ime.AzureQueueImeMsiArchiveProducer>();
|
||||
builder.Services.AddHostedService<
|
||||
AutopilotMonitor.Functions.Services.Ime.ImeMsiArchiveQueueWorker>();
|
||||
|
||||
// Delayed tenant auto-approve (public availability). Producer fires at first-login signup
|
||||
// with a ~1-minute visibility delay; the worker activates the tenant via the shared
|
||||
// TenantApprovalService only if AdminConfiguration.AutoApproveNewTenants is enabled at
|
||||
|
||||
@@ -311,7 +311,6 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("POST", "rules/analyze/{ruleId}/create-from-template", EndpointPolicy.TenantAdminOrGA),
|
||||
new("PUT", "rules/analyze/{ruleId}", EndpointPolicy.TenantAdminOrGA),
|
||||
new("DELETE", "rules/analyze/{ruleId}", EndpointPolicy.TenantAdminOrGA),
|
||||
new("PUT", "rules/ime-log-patterns/{patternId}", EndpointPolicy.TenantAdminOrGA),
|
||||
new("POST", "sessions/{sessionId}/mark-failed", EndpointPolicy.TenantAdminOrGA),
|
||||
new("POST", "sessions/{sessionId}/mark-succeeded", EndpointPolicy.TenantAdminOrGA),
|
||||
// Operator tier so troubleshooting staff can queue request_diagnostics ("Collect Logs" in
|
||||
@@ -541,6 +540,9 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "vulnerability/ignored-software", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("POST", "vulnerability/ignored-software", EndpointPolicy.GlobalAdminOnly),
|
||||
new("DELETE", "vulnerability/ignored-software", EndpointPolicy.GlobalAdminOnly),
|
||||
// Both routes mutate GLOBAL patterns (all tenants); there is no tenant-scoped variant yet,
|
||||
// so PUT must not sit on the TenantAdminOrGA tier — the handler has no GA re-gate.
|
||||
new("PUT", "rules/ime-log-patterns/{patternId}", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "rules/ime-log-patterns/reseed", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "global/mcp-users", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("POST", "global/mcp-users", EndpointPolicy.GlobalAdminOnly),
|
||||
|
||||
@@ -43,6 +43,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private readonly AutopilotMonitor.Functions.Services.Analyze.IAnalyzeOnEnrollmentEndProducer _analyzeProducer;
|
||||
private readonly AutopilotMonitor.Functions.Services.Analyze.InterimTriggerRegistry _interimTriggerRegistry;
|
||||
private readonly IVulnerabilityCorrelateProducer _vulnProducer;
|
||||
private readonly Ime.IImeMsiArchiveProducer _imeMsiArchiveProducer;
|
||||
|
||||
public EventIngestProcessor(
|
||||
ILogger<EventIngestProcessor> logger,
|
||||
@@ -58,7 +59,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
TelemetryClient telemetryClient,
|
||||
AutopilotMonitor.Functions.Services.Analyze.IAnalyzeOnEnrollmentEndProducer analyzeProducer,
|
||||
AutopilotMonitor.Functions.Services.Analyze.InterimTriggerRegistry interimTriggerRegistry,
|
||||
IVulnerabilityCorrelateProducer vulnProducer)
|
||||
IVulnerabilityCorrelateProducer vulnProducer,
|
||||
Ime.IImeMsiArchiveProducer imeMsiArchiveProducer)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessionRepo = sessionRepo;
|
||||
@@ -74,6 +76,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_analyzeProducer = analyzeProducer;
|
||||
_interimTriggerRegistry = interimTriggerRegistry;
|
||||
_vulnProducer = vulnProducer;
|
||||
_imeMsiArchiveProducer = imeMsiArchiveProducer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -129,6 +132,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"ImeAgentVersion update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted);
|
||||
|
||||
// Captured here (not inside the continuation) purely for clarity — the agent's
|
||||
// CSP-registry enrichment; absent on agent builds before 2026-08-17.
|
||||
string? msiDownloadUrl = null, msiMatchedBy = null;
|
||||
if (imeVersionEvent.Data.TryGetValue("msiDownloadUrl", out var urlObj))
|
||||
msiDownloadUrl = urlObj?.ToString();
|
||||
if (imeVersionEvent.Data.TryGetValue("msiMatchedBy", out var matchedByObj))
|
||||
msiMatchedBy = matchedByObj?.ToString();
|
||||
|
||||
_ = _sessionRepo.RecordImeVersionAsync(imeVersion, request.TenantId, request.SessionId)
|
||||
.ContinueWith(async t =>
|
||||
{
|
||||
@@ -141,6 +152,19 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
await _opsEventService.RecordNewImeVersionDetectedAsync(
|
||||
imeVersion, request.TenantId, request.SessionId);
|
||||
|
||||
// First fleet-wide sighting → archive the installer binary while
|
||||
// Microsoft's versionless CDN still serves exactly this build.
|
||||
// Producer is fail-soft; worker pauses on ImeMsiArchivingEnabled=false.
|
||||
await _imeMsiArchiveProducer.EnqueueAsync(new ImeMsiArchiveEnvelope
|
||||
{
|
||||
Version = imeVersion,
|
||||
MsiDownloadUrl = msiDownloadUrl,
|
||||
MsiMatchedBy = msiMatchedBy,
|
||||
TenantId = request.TenantId,
|
||||
SessionId = request.SessionId,
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Storage.Queues;
|
||||
using AutopilotMonitor.Functions.Services.Queueing;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Azure Storage Queue implementation of <see cref="IImeMsiArchiveProducer"/>. Mirrors
|
||||
/// <see cref="Analyze.AzureQueueAnalyzeOnEnrollmentEndProducer"/>: Managed Identity
|
||||
/// preferred, connection-string fallback, Base64 encoding (matches the consumer worker).
|
||||
/// <para>
|
||||
/// <b>Fault tolerance:</b> queue-side exceptions are logged but never rethrown — the
|
||||
/// caller is the ingest path's fire-and-forget continuation. A missed enqueue means the
|
||||
/// version's installer is not auto-archived; the /ime-decompile skill's blob backfill
|
||||
/// is the manual fallback.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AzureQueueImeMsiArchiveProducer : IImeMsiArchiveProducer
|
||||
{
|
||||
private readonly QueueClient _queueClient;
|
||||
private readonly ILogger<AzureQueueImeMsiArchiveProducer> _logger;
|
||||
|
||||
private int _queueEnsured; // 0 = not yet ensured, 1 = CreateIfNotExistsAsync has run
|
||||
|
||||
public AzureQueueImeMsiArchiveProducer(
|
||||
QueueClientFactory queueFactory,
|
||||
ILogger<AzureQueueImeMsiArchiveProducer> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
// Base64 encoding matches the consumer (BackgroundService) so messages round-trip.
|
||||
_queueClient = queueFactory.Create(Constants.QueueNames.ImeMsiArchive);
|
||||
}
|
||||
|
||||
public async Task EnqueueAsync(ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (envelope is null) return;
|
||||
if (string.IsNullOrEmpty(envelope.Version))
|
||||
{
|
||||
_logger.LogWarning("ImeMsiArchive enqueue skipped — missing Version");
|
||||
return;
|
||||
}
|
||||
|
||||
await EnsureQueueExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var body = JsonConvert.SerializeObject(envelope);
|
||||
await _queueClient.SendMessageAsync(body, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"ImeMsiArchive enqueued (version={Version} urlPresent={UrlPresent} tenant={Tenant} session={Session})",
|
||||
envelope.Version, !string.IsNullOrEmpty(envelope.MsiDownloadUrl), envelope.TenantId, envelope.SessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fail-soft — ingest continuation. Skill-side blob backfill is the fallback.
|
||||
_logger.LogWarning(ex,
|
||||
"ImeMsiArchive enqueue failed (version={Version}) — /ime-decompile blob backfill is the fallback",
|
||||
envelope.Version);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureQueueExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_queueEnsured == 1) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _queueClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
Interlocked.Exchange(ref _queueEnsured, 1);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Leave _queueEnsured=0 so a later call retries.
|
||||
_logger.LogWarning(ex, "ImeMsiArchive queue CreateIfNotExists failed — will retry next enqueue");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Enqueues an IME-installer archive job when a new IME version is first sighted.
|
||||
/// Implementations must be fail-soft: the caller sits on the ingest hot path's
|
||||
/// fire-and-forget continuation and must never observe an exception.
|
||||
/// </summary>
|
||||
public interface IImeMsiArchiveProducer
|
||||
{
|
||||
Task EnqueueAsync(ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using AutopilotMonitor.Functions.Services.Queueing;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Background worker for the <c>ime-msi-archive</c> queue: downloads a newly sighted IME
|
||||
/// installer into the permanent <c>ime-archive</c> container and merges the outcome onto
|
||||
/// the ImeVersionHistory row. Volume is ~one message per Microsoft IME release.
|
||||
/// <para>
|
||||
/// Retry semantics: the archiver never throws — it classifies. Permanent rejections
|
||||
/// (bad version, size cap) complete the message with their <c>Failed:*</c> status
|
||||
/// persisted; transient failures (download/timeout) rethrow so the visibility-timeout
|
||||
/// retry → poison ladder applies, with the last status visible on the row.
|
||||
/// <see cref="UseHeartbeat"/> keeps the message invisible across the up-to-10-minute
|
||||
/// download window. The status row-merge happens on EVERY attempt so operators can see
|
||||
/// a stuck download as <c>Failed:Download</c> instead of nothing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ImeMsiArchiveQueueWorker : QueuePollingWorker<ImeMsiArchiveEnvelope>
|
||||
{
|
||||
private readonly ImeMsiArchiver _archiver;
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
|
||||
public ImeMsiArchiveQueueWorker(
|
||||
QueueClientFactory queueFactory,
|
||||
ImeMsiArchiver archiver,
|
||||
ISessionRepository sessionRepo,
|
||||
AdminConfigurationService adminConfigService,
|
||||
ILogger<ImeMsiArchiveQueueWorker> logger)
|
||||
: base(queueFactory, Constants.QueueNames.ImeMsiArchive, logger)
|
||||
{
|
||||
_archiver = archiver ?? throw new ArgumentNullException(nameof(archiver));
|
||||
_sessionRepo = sessionRepo ?? throw new ArgumentNullException(nameof(sessionRepo));
|
||||
_adminConfigService = adminConfigService ?? throw new ArgumentNullException(nameof(adminConfigService));
|
||||
}
|
||||
|
||||
/// <summary>Download can legitimately take minutes — extend visibility while working.</summary>
|
||||
protected override bool UseHeartbeat => true;
|
||||
|
||||
/// <summary>
|
||||
/// Paused while <see cref="AdminConfiguration.ImeMsiArchivingEnabled"/> is off —
|
||||
/// messages stay parked (not dropped) and are processed when the flag returns.
|
||||
/// Uses the cached config read: this is a pause, not a kill-switch, and the worker
|
||||
/// only wakes for rare messages anyway.
|
||||
/// </summary>
|
||||
protected override async ValueTask<bool> ShouldPauseAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = await _adminConfigService.GetConfigurationAsync();
|
||||
return !config.ImeMsiArchivingEnabled;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false; // config unreadable → keep working (archive is fail-soft anyway)
|
||||
}
|
||||
}
|
||||
|
||||
protected override bool TryValidate(ImeMsiArchiveEnvelope envelope)
|
||||
=> !string.IsNullOrEmpty(envelope.Version);
|
||||
|
||||
protected override string DescribeForLog(ImeMsiArchiveEnvelope envelope)
|
||||
=> $"version {envelope.Version}";
|
||||
|
||||
protected override async Task HandleAsync(ImeMsiArchiveEnvelope envelope, CancellationToken ct)
|
||||
{
|
||||
var result = await _archiver.ArchiveAsync(envelope, ct);
|
||||
|
||||
// Merge the outcome first — also for failures, so the row always tells the truth.
|
||||
await _sessionRepo.UpdateImeVersionArchiveInfoAsync(
|
||||
envelope.Version, result.Status, result.BlobPath, result.Sha256, result.SizeBytes, result.SourceUrl);
|
||||
|
||||
if (!result.Success && result.Retryable)
|
||||
{
|
||||
// Visibility-timeout retry → poison after MaxDequeueCount. The thrown message
|
||||
// lands in the worker's handler-failure log line.
|
||||
throw new InvalidOperationException(
|
||||
$"IME MSI archive attempt failed with {result.Status} for version {envelope.Version}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Outcome of an IME-installer archive attempt. <see cref="Status"/> is one of the
|
||||
/// <see cref="ImeMsiArchiver.Statuses"/> constants and is merged verbatim onto the
|
||||
/// ImeVersionHistory row. <see cref="Retryable"/> tells the queue worker whether to
|
||||
/// trigger a visibility-timeout retry (transient download problems) or complete the
|
||||
/// message (permanent rejections).
|
||||
/// </summary>
|
||||
public sealed record ImeMsiArchiveResult(
|
||||
bool Success, string Status, bool Retryable,
|
||||
string? BlobPath, string? Sha256, long? SizeBytes, string? SourceUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a newly sighted IME installer and archives it into the permanent
|
||||
/// <see cref="Constants.BlobContainers.ImeArchive"/> container
|
||||
/// (<c>{version}/IntuneWindowsAgent.msi</c> + <c>{version}/provenance.json</c>).
|
||||
/// Modeled on <see cref="Diagnostics.SessionReportDiagnosticsArchiveCopier"/>:
|
||||
/// fail-soft by contract (never throws), Statuses constants persisted verbatim,
|
||||
/// admin-configured size cap, typed catch ladder, <c>protected virtual</c> test seams.
|
||||
/// <para>
|
||||
/// The download URL comes from agent telemetry and is therefore untrusted: only HTTPS
|
||||
/// URLs on <c>*.manage.microsoft.com</c> whose path filename is exactly
|
||||
/// <c>IntuneWindowsAgent.msi</c> are accepted; anything else falls back to the canonical
|
||||
/// versionless CDN URL — safe for a NEW version, because "new" is by definition what
|
||||
/// that URL currently serves. The blob write uses If-None-Match:* so a queue re-delivery
|
||||
/// can never overwrite an existing archive entry.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class ImeMsiArchiver
|
||||
{
|
||||
/// <summary>Persisted MsiArchiveStatus values.</summary>
|
||||
public static class Statuses
|
||||
{
|
||||
public const string Archived = "Archived";
|
||||
public const string FailedBadVersion = "Failed:BadVersion";
|
||||
public const string FailedTooLarge = "Failed:TooLarge";
|
||||
public const string FailedDownload = "Failed:Download";
|
||||
public const string FailedTimeout = "Failed:Timeout";
|
||||
public const string FailedError = "Failed:Error";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The CSP's versionless distribution endpoint — always serves the currently rolled-out
|
||||
/// build. Fallback when the event carried no (acceptable) URL.
|
||||
/// </summary>
|
||||
public const string CanonicalMsiUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi";
|
||||
|
||||
private const string MsiFileName = "IntuneWindowsAgent.msi";
|
||||
private const string AllowedHostSuffix = ".manage.microsoft.com";
|
||||
|
||||
/// <summary>Hard ceiling for one download attempt; the worker's heartbeat covers it.</summary>
|
||||
private static readonly TimeSpan DownloadTimeout = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>Blob folder name = version — must be plain dotted digits (untrusted input).</summary>
|
||||
internal static readonly Regex VersionRegex = new(@"^\d+(\.\d+){1,3}$", RegexOptions.Compiled);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly BlobStorageService _blobStorage;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly ILogger<ImeMsiArchiver> _logger;
|
||||
|
||||
public ImeMsiArchiver(
|
||||
HttpClient httpClient,
|
||||
BlobStorageService blobStorage,
|
||||
AdminConfigurationService adminConfigService,
|
||||
ILogger<ImeMsiArchiver> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_blobStorage = blobStorage;
|
||||
_adminConfigService = adminConfigService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: Azure-free construction for recording subclasses that override the
|
||||
/// virtual download/upload/config seams below.
|
||||
/// </summary>
|
||||
protected ImeMsiArchiver()
|
||||
{
|
||||
_httpClient = null!;
|
||||
_blobStorage = null!;
|
||||
_adminConfigService = null!;
|
||||
_logger = NullLogger<ImeMsiArchiver>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="url"/> is an HTTPS URL on <c>*.manage.microsoft.com</c>
|
||||
/// whose path filename is exactly <c>IntuneWindowsAgent.msi</c> (query ignored).
|
||||
/// </summary>
|
||||
public static bool IsAllowedMsiUrl(string? url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return false;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
|
||||
if (uri.Scheme != Uri.UriSchemeHttps) return false;
|
||||
|
||||
var host = uri.Host;
|
||||
var hostAllowed =
|
||||
host.EndsWith(AllowedHostSuffix, StringComparison.OrdinalIgnoreCase) ||
|
||||
host.Equals(AllowedHostSuffix.TrimStart('.'), StringComparison.OrdinalIgnoreCase);
|
||||
if (!hostAllowed) return false;
|
||||
|
||||
var fileName = uri.Segments.Length > 0 ? uri.Segments[^1] : string.Empty;
|
||||
return fileName.Equals(MsiFileName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public virtual async Task<ImeMsiArchiveResult> ArchiveAsync(
|
||||
ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (envelope is null || !VersionRegex.IsMatch(envelope.Version ?? string.Empty))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ImeMsiArchiver: rejecting version {Version} — not a plain dotted version string",
|
||||
envelope?.Version ?? "(null)");
|
||||
return new ImeMsiArchiveResult(false, Statuses.FailedBadVersion, Retryable: false,
|
||||
null, null, null, null);
|
||||
}
|
||||
|
||||
var version = envelope.Version!;
|
||||
var blobPath = $"{version}/{MsiFileName}";
|
||||
|
||||
string sourceUrl;
|
||||
if (IsAllowedMsiUrl(envelope.MsiDownloadUrl))
|
||||
{
|
||||
sourceUrl = envelope.MsiDownloadUrl!;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrEmpty(envelope.MsiDownloadUrl))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ImeMsiArchiver: event URL rejected by allowlist for version {Version} — using canonical URL",
|
||||
version);
|
||||
}
|
||||
sourceUrl = CanonicalMsiUrl;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var adminConfig = await GetAdminConfigurationAsync();
|
||||
var maxSizeBytes = (long)adminConfig.MaxImeMsiDownloadSizeMB * 1024 * 1024;
|
||||
|
||||
using var cts = new CancellationTokenSource(DownloadTimeout);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
|
||||
|
||||
var (content, contentLength) = await OpenDownloadAsync(sourceUrl, linked.Token);
|
||||
|
||||
string sha256;
|
||||
long totalBytes;
|
||||
using (content)
|
||||
{
|
||||
// Same fast-reject as the diagnostics proxy: cap of 0 means unlimited.
|
||||
if (maxSizeBytes > 0 && contentLength > maxSizeBytes)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ImeMsiArchiver: download for {Version} rejected — Content-Length {SizeBytes} exceeds limit {MaxSizeBytes}",
|
||||
version, contentLength, maxSizeBytes);
|
||||
return new ImeMsiArchiveResult(false, Statuses.FailedTooLarge, Retryable: false,
|
||||
null, null, contentLength, sourceUrl);
|
||||
}
|
||||
|
||||
// Hash + count while streaming into the blob; the wrapper enforces the cap
|
||||
// mid-stream too (a lying/absent Content-Length cannot bypass it).
|
||||
using var guarded = new HashingCappedReadStream(content, maxSizeBytes);
|
||||
try
|
||||
{
|
||||
await UploadMsiAsync(blobPath, guarded, linked.Token);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 409)
|
||||
{
|
||||
// Queue re-delivery after a successful upload: the archive already has
|
||||
// this version — that IS the desired end state.
|
||||
_logger.LogInformation(
|
||||
"ImeMsiArchiver: blob {BlobPath} already exists — treating re-delivery as archived",
|
||||
blobPath);
|
||||
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
|
||||
blobPath, null, null, sourceUrl);
|
||||
}
|
||||
sha256 = guarded.Sha256Hex;
|
||||
totalBytes = guarded.TotalBytes;
|
||||
}
|
||||
|
||||
var provenance = new
|
||||
{
|
||||
version,
|
||||
url = sourceUrl,
|
||||
urlFromEvent = sourceUrl != CanonicalMsiUrl,
|
||||
msiMatchedBy = envelope.MsiMatchedBy,
|
||||
sha256,
|
||||
msiBytes = totalBytes,
|
||||
firstSeenTenantId = envelope.TenantId,
|
||||
firstSeenSessionId = envelope.SessionId,
|
||||
archivedAtUtc = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
};
|
||||
await UploadProvenanceAsync(
|
||||
$"{version}/provenance.json",
|
||||
System.Text.Json.JsonSerializer.Serialize(provenance,
|
||||
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }),
|
||||
linked.Token);
|
||||
|
||||
_logger.LogInformation(
|
||||
"ImeMsiArchiver: archived {Version} ({SizeBytes} bytes, sha256 {Sha256}) from {SourceUrl}",
|
||||
version, totalBytes, sha256, sourceUrl);
|
||||
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
|
||||
blobPath, sha256, totalBytes, sourceUrl);
|
||||
}
|
||||
catch (SizeCapExceededException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ImeMsiArchiver: download for {Version} exceeded the size cap mid-stream — aborted",
|
||||
version);
|
||||
return new ImeMsiArchiveResult(false, Statuses.FailedTooLarge, Retryable: false,
|
||||
null, null, null, sourceUrl);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("ImeMsiArchiver: download for {Version} timed out", version);
|
||||
return new ImeMsiArchiveResult(false, Statuses.FailedTimeout, Retryable: true,
|
||||
null, null, null, sourceUrl);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ImeMsiArchiver: download for {Version} failed", version);
|
||||
return new ImeMsiArchiveResult(false, Statuses.FailedDownload, Retryable: true,
|
||||
null, null, null, sourceUrl);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Host shutdown — let the worker's dispatch loop see it and leave the message.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ImeMsiArchiver: archiving {Version} failed", version);
|
||||
return new ImeMsiArchiveResult(false, Statuses.FailedError, Retryable: true,
|
||||
null, null, null, sourceUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Test seams (production defaults hit Azure / the network) --------
|
||||
|
||||
/// <summary>Fetches the platform admin configuration (size cap).</summary>
|
||||
protected virtual Task<AdminConfiguration> GetAdminConfigurationAsync()
|
||||
=> _adminConfigService.GetConfigurationAsync();
|
||||
|
||||
/// <summary>Opens the installer download stream (headers-read, no buffering).</summary>
|
||||
protected virtual async Task<(Stream Content, long ContentLength)> OpenDownloadAsync(
|
||||
string url, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await _httpClient.GetAsync(
|
||||
url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var content = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
return (content, response.Content.Headers.ContentLength ?? -1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the installer stream write-once (If-None-Match:*). Implementations must
|
||||
/// surface a conflict as <see cref="RequestFailedException"/> with status 409.
|
||||
/// </summary>
|
||||
protected virtual async Task UploadMsiAsync(string blobPath, Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
var containerClient = _blobStorage.GetContainerClient(Constants.BlobContainers.ImeArchive);
|
||||
await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken);
|
||||
var blobClient = containerClient.GetBlobClient(blobPath);
|
||||
await blobClient.UploadAsync(content, new BlobUploadOptions
|
||||
{
|
||||
HttpHeaders = new BlobHttpHeaders { ContentType = "application/octet-stream" },
|
||||
Conditions = new BlobRequestConditions { IfNoneMatch = ETag.All }
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Uploads the provenance sidecar (overwrite allowed — it is derived data).</summary>
|
||||
protected virtual async Task UploadProvenanceAsync(string blobPath, string json, CancellationToken cancellationToken)
|
||||
{
|
||||
var containerClient = _blobStorage.GetContainerClient(Constants.BlobContainers.ImeArchive);
|
||||
var blobClient = containerClient.GetBlobClient(blobPath);
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
await blobClient.UploadAsync(stream, new BlobUploadOptions
|
||||
{
|
||||
HttpHeaders = new BlobHttpHeaders { ContentType = "application/json" }
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
// -------- Streaming helpers --------
|
||||
|
||||
private sealed class SizeCapExceededException : Exception
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only pass-through stream that SHA-256-hashes and counts everything read and
|
||||
/// throws once the byte count exceeds the cap (0 = unlimited). Non-seekable on
|
||||
/// purpose — the blob SDK then stages blocks instead of demanding Length upfront.
|
||||
/// </summary>
|
||||
private sealed class HashingCappedReadStream : Stream
|
||||
{
|
||||
private readonly Stream _inner;
|
||||
private readonly long _maxBytes;
|
||||
private readonly IncrementalHash _hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
private string? _sha256Hex;
|
||||
|
||||
public HashingCappedReadStream(Stream inner, long maxBytes)
|
||||
{
|
||||
_inner = inner;
|
||||
_maxBytes = maxBytes;
|
||||
}
|
||||
|
||||
public long TotalBytes { get; private set; }
|
||||
|
||||
public string Sha256Hex => _sha256Hex ??=
|
||||
Convert.ToHexString(_hash.GetHashAndReset()).ToLowerInvariant();
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position
|
||||
{
|
||||
get => TotalBytes;
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
var read = _inner.Read(buffer, offset, count);
|
||||
Account(buffer.AsSpan(offset, read));
|
||||
return read;
|
||||
}
|
||||
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var read = await _inner.ReadAsync(buffer, cancellationToken);
|
||||
Account(buffer.Span[..read]);
|
||||
return read;
|
||||
}
|
||||
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
=> ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
|
||||
|
||||
private void Account(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.IsEmpty) return;
|
||||
TotalBytes += data.Length;
|
||||
if (_maxBytes > 0 && TotalBytes > _maxBytes)
|
||||
throw new SizeCapExceededException();
|
||||
_hash.AppendData(data);
|
||||
}
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) _hash.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3316,7 +3316,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
FirstSeenSessionId = entity.GetString("FirstSeenSessionId") ?? string.Empty,
|
||||
FirstSeenTenantId = entity.GetString("FirstSeenTenantId") ?? string.Empty,
|
||||
LastSeenAt = entity.GetDateTime("LastSeenAt") ?? DateTime.MinValue,
|
||||
SessionCount = entity.GetInt32("SessionCount") ?? 0
|
||||
SessionCount = entity.GetInt32("SessionCount") ?? 0,
|
||||
MsiArchiveStatus = entity.GetString("MsiArchiveStatus"),
|
||||
MsiArchiveBlobPath = entity.GetString("MsiArchiveBlobPath"),
|
||||
MsiSha256 = entity.GetString("MsiSha256"),
|
||||
MsiBytes = entity.GetInt64("MsiBytes"),
|
||||
MsiSourceUrl = entity.GetString("MsiSourceUrl")
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3328,6 +3333,38 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return results.OrderByDescending(e => e.FirstSeenAt).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges the installer-archiving outcome into the version's ImeVersionHistory row
|
||||
/// (same Merge idiom as the LastSeen update above). Fail-soft: the archive job
|
||||
/// already logged its own result; a lost status column must never fail the worker.
|
||||
/// </summary>
|
||||
public async Task UpdateImeVersionArchiveInfoAsync(
|
||||
string version, string status, string? blobPath, string? sha256, long? sizeBytes, string? sourceUrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(version) || string.IsNullOrEmpty(status)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeVersionHistory);
|
||||
var mergeEntity = new TableEntity("Global", version)
|
||||
{
|
||||
["MsiArchiveStatus"] = status
|
||||
};
|
||||
if (!string.IsNullOrEmpty(blobPath)) mergeEntity["MsiArchiveBlobPath"] = blobPath;
|
||||
if (!string.IsNullOrEmpty(sha256)) mergeEntity["MsiSha256"] = sha256;
|
||||
if (sizeBytes.HasValue) mergeEntity["MsiBytes"] = sizeBytes.Value;
|
||||
if (!string.IsNullOrEmpty(sourceUrl)) mergeEntity["MsiSourceUrl"] = sourceUrl;
|
||||
|
||||
await tableClient.UpsertEntityAsync(mergeEntity, TableUpdateMode.Merge);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to update ImeVersionHistory archive info for version {Version} (status {Status})",
|
||||
version, status);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Excessive-Event Detection
|
||||
|
||||
@@ -146,6 +146,9 @@ export const EVENT_TYPES_CATALOG = {
|
||||
'agent_shutdown',
|
||||
'agent_version_check',
|
||||
'ime_agent_version',
|
||||
'ime_token_failure',
|
||||
'registry_app_state',
|
||||
'app_state_reconciliation',
|
||||
'ime_user_session_completed',
|
||||
'ime_session_change',
|
||||
'ime_process_exited',
|
||||
|
||||
@@ -416,6 +416,12 @@ namespace AutopilotMonitor.Shared
|
||||
// IME log tracker — app / device / script telemetry (V2 single-rail plan §5.9)
|
||||
public const string EspPhaseChanged = "esp_phase_changed";
|
||||
public const string ImeAgentVersion = "ime_agent_version";
|
||||
public const string ImeTokenFailure = "ime_token_failure"; // Warning, once per distinct errorCode: IME token acquisition failed (IntuneTokenManager "Failed to get AAD token ... errorCode = N"). Complements IME-TOKEN-SUCCESS: an auth outage previously left every check-in silently doing nothing with no timeline evidence.
|
||||
// Registry second pillar (ImeRegistryAppStateObserver, audit 2026-08-17): IME's authoritative
|
||||
// per-app state read straight from HKLM\...\IntuneManagementExtension. Observability only —
|
||||
// never consumed by the DecisionEngine.
|
||||
public const string RegistryAppState = "registry_app_state"; // Info, state-change-only (snapshot-diff vs silent startup baseline; capped 200/session, cap announced once as Warning): per-app EnforcementState/ErrorCode/ExitCode/StatusService status/ESP-tracking from the registry.
|
||||
public const string AppStateReconciliation = "app_state_reconciliation"; // Warning, once per app, immediate upload: registry terminal outcome (settled >= 90 s) diverges from the IME-log-derived app state — the built-in log-pattern drift alarm (registry = truth, logs = narrative).
|
||||
public const string ImeUserSessionCompleted = "ime_user_session_completed";
|
||||
public const string DoTelemetry = "do_telemetry";
|
||||
public const string AllAppsCompleted = "all_apps_completed";
|
||||
@@ -1295,6 +1301,19 @@ namespace AutopilotMonitor.Shared
|
||||
/// settings (Soft-Delete + Versioning) provide defence-in-depth.
|
||||
/// </summary>
|
||||
public const string CriticalTableBackups = "critical-table-backups";
|
||||
|
||||
/// <summary>
|
||||
/// Permanent platform archive of IME (Intune Management Extension) installer
|
||||
/// binaries, one virtual folder per version:
|
||||
/// <c>{version}/IntuneWindowsAgent.msi</c> + <c>{version}/provenance.json</c>.
|
||||
/// Written by <c>ImeMsiArchiver</c> when a new IME version is first seen across
|
||||
/// the fleet (companion of <see cref="TableNames.ImeVersionHistory"/>, which is
|
||||
/// equally KeptByDesign). NOT tenant-scoped — blob paths are version-keyed, so
|
||||
/// tenant offboarding (Phase 2.E) deliberately never touches this container.
|
||||
/// No lifecycle rule: the archive is the point (build-to-build diffs of
|
||||
/// Microsoft's rollouts). Private by default (SDK CreateIfNotExists).
|
||||
/// </summary>
|
||||
public const string ImeArchive = "ime-archive";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1392,6 +1411,18 @@ namespace AutopilotMonitor.Shared
|
||||
|
||||
/// <summary>Poison sibling of <see cref="SessionDeletionMaintenance"/>.</summary>
|
||||
public const string SessionDeletionMaintenancePoison = "session-deletion-maintenance-poison";
|
||||
|
||||
/// <summary>
|
||||
/// IME-MSI archive fan-out. Producer = <c>EventIngestProcessor</c> when
|
||||
/// <c>RecordImeVersionAsync</c> reports a genuinely NEW IME version (first sighting
|
||||
/// across the fleet — roughly monthly); consumer = <c>ImeMsiArchiveQueueWorker</c>,
|
||||
/// which downloads the installer from the CSP-reported
|
||||
/// <c>msiDownloadUrl</c> (host-allowlisted, size-capped) into
|
||||
/// <see cref="BlobContainers.ImeArchive"/> and merges the outcome into the
|
||||
/// <see cref="TableNames.ImeVersionHistory"/> row. Poison suffix <c>-poison</c>,
|
||||
/// max-dequeue 5.
|
||||
/// </summary>
|
||||
public const string ImeMsiArchive = "ime-msi-archive";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -176,6 +176,14 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task<bool> RecordImeVersionAsync(string version, string tenantId, string sessionId);
|
||||
Task<List<ImeVersionHistoryEntry>> GetImeVersionHistoryAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Merges the installer-archiving outcome (<c>ImeMsiArchiver</c>) into the version's
|
||||
/// <c>ImeVersionHistory</c> row. Fail-soft: storage errors are logged, never thrown.
|
||||
/// Blob path / hash / size / URL are only written on success (null leaves them absent).
|
||||
/// </summary>
|
||||
Task UpdateImeVersionArchiveInfoAsync(
|
||||
string version, string status, string? blobPath, string? sha256, long? sizeBytes, string? sourceUrl);
|
||||
|
||||
// --- Events ---
|
||||
Task<bool> StoreEventAsync(EnrollmentEvent evt);
|
||||
Task<List<EnrollmentEvent>> StoreEventsBatchAsync(List<EnrollmentEvent> events);
|
||||
|
||||
@@ -460,6 +460,24 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool AutoApproveNewTenants { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the first fleet-wide sighting of a new IME version enqueues an
|
||||
/// archive job (<c>ime-msi-archive</c> queue) that downloads the installer from the
|
||||
/// CSP-reported URL into the <c>ime-archive</c> blob container. Roughly one download
|
||||
/// per Microsoft IME release (~monthly). Default true — flip off to pause archiving
|
||||
/// (queued messages then wait, they are not dropped). Round-tripped via the 4-file
|
||||
/// web chain (memory <c>feedback_admin_config_ui_roundtrip</c>).
|
||||
/// </summary>
|
||||
public bool ImeMsiArchivingEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Size cap for the IME installer download in MB (Content-Length preflight AND
|
||||
/// streamed-byte guard). The MSI is ~12 MB today; the generous cap exists only so a
|
||||
/// tampered/wrong URL cannot stream gigabytes into the archive while still never
|
||||
/// missing a legitimately grown installer. 0 = no limit. Default: 250 MB.
|
||||
/// </summary>
|
||||
public int MaxImeMsiDownloadSizeMB { get; set; } = 250;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the dual app-registration self-service homing flip is active: the Graph
|
||||
/// consent flow funnels legacy-homed tenants to the primary (new) app registration and
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Queue-message envelope for the <c>ime-msi-archive</c> queue. Enqueued by
|
||||
/// <c>EventIngestProcessor</c> when an <c>ime_agent_version</c> event carries a version
|
||||
/// the platform has never seen before (RecordImeVersionAsync insert succeeded). The
|
||||
/// worker downloads the IME installer and archives it into the <c>ime-archive</c> blob
|
||||
/// container so every fleet-observed IME build stays available for later decompilation
|
||||
/// and build-to-build diffing, even after Microsoft's versionless CDN URL has moved on.
|
||||
/// </summary>
|
||||
public sealed class ImeMsiArchiveEnvelope
|
||||
{
|
||||
/// <summary>Schema version — bump on breaking envelope changes so consumers can reject or migrate.</summary>
|
||||
public string EnvelopeVersion { get; set; } = "1";
|
||||
|
||||
/// <summary>The newly sighted IME version, e.g. <c>1.104.102.0</c> (blob folder name).</summary>
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The EnterpriseDesktopAppManagement-CSP <c>CurrentDownloadUrl</c> from the event's
|
||||
/// <c>msiDownloadUrl</c> enrichment. Null when the reporting agent predates the
|
||||
/// enrichment — the worker then falls back to the canonical versionless CDN URL
|
||||
/// (safe: a brand-new version is by definition what that URL currently serves).
|
||||
/// </summary>
|
||||
public string? MsiDownloadUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The event's <c>msiMatchedBy</c> companion (<c>productVersion</c> = URL is
|
||||
/// version-authoritative, <c>fileName</c> = registry drift). Provenance only.
|
||||
/// </summary>
|
||||
public string? MsiMatchedBy { get; set; }
|
||||
|
||||
/// <summary>First-seen tenant — provenance only, never used for auth or paths.</summary>
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>First-seen session — provenance only.</summary>
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>UTC time the producer enqueued the message; useful for measuring queue lag.</summary>
|
||||
public DateTime EnqueuedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -575,5 +575,25 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string FirstSeenTenantId { get; set; } = string.Empty;
|
||||
public DateTime LastSeenAt { get; set; }
|
||||
public int SessionCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the automatic installer archiving (<c>ImeMsiArchiver</c>):
|
||||
/// <c>Archived</c> or a <c>Failed:*</c> status; null for versions sighted before the
|
||||
/// feature existed or while the archive job is still queued. Like the FirstSeen*
|
||||
/// identifiers, the archive fields are only serialized for Global Admin callers.
|
||||
/// </summary>
|
||||
public string? MsiArchiveStatus { get; set; }
|
||||
|
||||
/// <summary>Blob path inside the <c>ime-archive</c> container, e.g. <c>1.104.102.0/IntuneWindowsAgent.msi</c>.</summary>
|
||||
public string? MsiArchiveBlobPath { get; set; }
|
||||
|
||||
/// <summary>SHA-256 of the archived installer (hex, lowercase).</summary>
|
||||
public string? MsiSha256 { get; set; }
|
||||
|
||||
/// <summary>Size of the archived installer in bytes.</summary>
|
||||
public long? MsiBytes { get; set; }
|
||||
|
||||
/// <summary>The URL the installer was downloaded from (CSP-reported or canonical fallback).</summary>
|
||||
public string? MsiSourceUrl { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ interface AdminConfigContextValue {
|
||||
setAutoApproveNewTenants: (value: boolean) => void;
|
||||
selfServiceAppHomingEnabled: boolean;
|
||||
setSelfServiceAppHomingEnabled: (value: boolean) => void;
|
||||
imeMsiArchivingEnabled: boolean;
|
||||
setImeMsiArchivingEnabled: (value: boolean) => void;
|
||||
maxImeMsiDownloadSizeMB: number;
|
||||
setMaxImeMsiDownloadSizeMB: (value: number) => void;
|
||||
|
||||
// Diagnostics log paths
|
||||
globalDiagPaths: DiagnosticsLogPath[];
|
||||
@@ -152,6 +156,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
const [sessionDeletionKillSwitch, setSessionDeletionKillSwitch] = useState(false);
|
||||
const [autoApproveNewTenants, setAutoApproveNewTenants] = useState(false);
|
||||
const [selfServiceAppHomingEnabled, setSelfServiceAppHomingEnabled] = useState(false);
|
||||
const [imeMsiArchivingEnabled, setImeMsiArchivingEnabled] = useState(true);
|
||||
const [maxImeMsiDownloadSizeMB, setMaxImeMsiDownloadSizeMB] = useState(250);
|
||||
|
||||
// Diagnostics Log Paths state
|
||||
const [globalDiagPaths, setGlobalDiagPaths] = useState<DiagnosticsLogPath[]>([]);
|
||||
@@ -215,6 +221,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
setSessionDeletionKillSwitch(data.sessionDeletionKillSwitch ?? false);
|
||||
setAutoApproveNewTenants(data.autoApproveNewTenants ?? false);
|
||||
setSelfServiceAppHomingEnabled(data.selfServiceAppHomingEnabled ?? false);
|
||||
setImeMsiArchivingEnabled(data.imeMsiArchivingEnabled ?? true);
|
||||
setMaxImeMsiDownloadSizeMB(data.maxImeMsiDownloadSizeMB ?? 250);
|
||||
try {
|
||||
setGlobalDiagPaths(data.diagnosticsGlobalLogPathsJson ? JSON.parse(data.diagnosticsGlobalLogPathsJson) : []);
|
||||
} catch {
|
||||
@@ -332,6 +340,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
sessionDeletionKillSwitch,
|
||||
autoApproveNewTenants,
|
||||
selfServiceAppHomingEnabled,
|
||||
imeMsiArchivingEnabled,
|
||||
maxImeMsiDownloadSizeMB,
|
||||
};
|
||||
|
||||
const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, {
|
||||
@@ -358,7 +368,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
} finally {
|
||||
setSavingConfig(false);
|
||||
}
|
||||
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, autoApproveNewTenants, selfServiceAppHomingEnabled, getAccessToken]);
|
||||
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, autoApproveNewTenants, selfServiceAppHomingEnabled, imeMsiArchivingEnabled, maxImeMsiDownloadSizeMB, getAccessToken]);
|
||||
|
||||
// Reset admin config
|
||||
const handleResetAdminConfig = useCallback(() => {
|
||||
@@ -379,6 +389,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
setSessionDeletionKillSwitch(adminConfig.sessionDeletionKillSwitch ?? false);
|
||||
setAutoApproveNewTenants(adminConfig.autoApproveNewTenants ?? false);
|
||||
setSelfServiceAppHomingEnabled(adminConfig.selfServiceAppHomingEnabled ?? false);
|
||||
setImeMsiArchivingEnabled(adminConfig.imeMsiArchivingEnabled ?? true);
|
||||
setMaxImeMsiDownloadSizeMB(adminConfig.maxImeMsiDownloadSizeMB ?? 250);
|
||||
try {
|
||||
setGlobalDiagPaths(adminConfig.diagnosticsGlobalLogPathsJson ? JSON.parse(adminConfig.diagnosticsGlobalLogPathsJson) : []);
|
||||
} catch {
|
||||
@@ -525,6 +537,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
sessionDeletionKillSwitch, setSessionDeletionKillSwitch,
|
||||
autoApproveNewTenants, setAutoApproveNewTenants,
|
||||
selfServiceAppHomingEnabled, setSelfServiceAppHomingEnabled,
|
||||
imeMsiArchivingEnabled, setImeMsiArchivingEnabled,
|
||||
maxImeMsiDownloadSizeMB, setMaxImeMsiDownloadSizeMB,
|
||||
globalDiagPaths, setGlobalDiagPaths, savingDiagPaths,
|
||||
opsAlertRules, setOpsAlertRules,
|
||||
opsAlertTelegramEnabled, setOpsAlertTelegramEnabled,
|
||||
|
||||
@@ -36,6 +36,10 @@ interface AdminConfigSettingsSectionProps {
|
||||
setAutoApproveNewTenants: (value: boolean) => void;
|
||||
selfServiceAppHomingEnabled: boolean;
|
||||
setSelfServiceAppHomingEnabled: (value: boolean) => void;
|
||||
imeMsiArchivingEnabled: boolean;
|
||||
setImeMsiArchivingEnabled: (value: boolean) => void;
|
||||
maxImeMsiDownloadSizeMB: number;
|
||||
setMaxImeMsiDownloadSizeMB: (value: number) => void;
|
||||
onSave: () => Promise<void>;
|
||||
onReset: () => void;
|
||||
}
|
||||
@@ -74,6 +78,10 @@ export function AdminConfigSettingsSection({
|
||||
setAutoApproveNewTenants,
|
||||
selfServiceAppHomingEnabled,
|
||||
setSelfServiceAppHomingEnabled,
|
||||
imeMsiArchivingEnabled,
|
||||
setImeMsiArchivingEnabled,
|
||||
maxImeMsiDownloadSizeMB,
|
||||
setMaxImeMsiDownloadSizeMB,
|
||||
onSave,
|
||||
onReset,
|
||||
}: AdminConfigSettingsSectionProps) {
|
||||
@@ -375,6 +383,47 @@ export function AdminConfigSettingsSection({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-start space-x-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={imeMsiArchivingEnabled}
|
||||
onChange={(e) => setImeMsiArchivingEnabled(e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-indigo-600 accent-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="text-indigo-900 dark:text-indigo-100 font-medium">IME installer archiving</span>
|
||||
<p className="text-sm text-indigo-800 dark:text-gray-300 mt-1">
|
||||
When <strong>on</strong>, the first fleet-wide sighting of a new IME version downloads
|
||||
the installer (from the CSP-reported, allowlisted URL) into the permanent
|
||||
<code className="ml-1 text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">ime-archive</code> blob
|
||||
container — roughly one small download per Microsoft IME release. The outcome is
|
||||
visible per version in the IME version history. When <strong>off</strong>, queued
|
||||
archive jobs wait (they are not dropped). Default <strong>on</strong>.
|
||||
</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="text-indigo-900 dark:text-indigo-100 font-medium">IME installer download size cap (MB)</span>
|
||||
<p className="text-sm text-indigo-800 dark:text-gray-300 mb-2">
|
||||
Safety cap for the IME installer download (the MSI is ~12 MB today). Generous on
|
||||
purpose so a legitimately grown installer is never missed, while a wrong URL can
|
||||
still not stream gigabytes into the archive. 0 = unlimited. Default 250.
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10000"
|
||||
value={maxImeMsiDownloadSizeMB}
|
||||
onChange={(e) => setMaxImeMsiDownloadSizeMB(parseInt(e.target.value) || 0)}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-start space-x-3 cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -24,6 +24,8 @@ export function SectionGlobalSettings() {
|
||||
sessionDeletionKillSwitch, setSessionDeletionKillSwitch,
|
||||
autoApproveNewTenants, setAutoApproveNewTenants,
|
||||
selfServiceAppHomingEnabled, setSelfServiceAppHomingEnabled,
|
||||
imeMsiArchivingEnabled, setImeMsiArchivingEnabled,
|
||||
maxImeMsiDownloadSizeMB, setMaxImeMsiDownloadSizeMB,
|
||||
handleSaveAdminConfig, handleResetAdminConfig,
|
||||
} = useAdminConfig();
|
||||
|
||||
@@ -66,6 +68,10 @@ export function SectionGlobalSettings() {
|
||||
setAutoApproveNewTenants={setAutoApproveNewTenants}
|
||||
selfServiceAppHomingEnabled={selfServiceAppHomingEnabled}
|
||||
setSelfServiceAppHomingEnabled={setSelfServiceAppHomingEnabled}
|
||||
imeMsiArchivingEnabled={imeMsiArchivingEnabled}
|
||||
setImeMsiArchivingEnabled={setImeMsiArchivingEnabled}
|
||||
maxImeMsiDownloadSizeMB={maxImeMsiDownloadSizeMB}
|
||||
setMaxImeMsiDownloadSizeMB={setMaxImeMsiDownloadSizeMB}
|
||||
onSave={handleSaveAdminConfig}
|
||||
onReset={handleResetAdminConfig}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { sessionUrl } from "@/lib/routes";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { useState, useEffect, useRef, useMemo, useDeferredValue } from "react";
|
||||
import { Session } from "../types";
|
||||
@@ -709,6 +710,7 @@ export function SessionTable({
|
||||
key={col.key}
|
||||
columnKey={col.key}
|
||||
session={session}
|
||||
href={linkFor(session)}
|
||||
adminMode={adminMode}
|
||||
globalAdminMode={globalAdminMode}
|
||||
blockedDevicesSet={blockedDevicesSet}
|
||||
@@ -782,9 +784,35 @@ export function SessionTable({
|
||||
);
|
||||
}
|
||||
|
||||
/** Invisible cell-filling anchor so the browser's native context menu (open in new
|
||||
* tab, copy link) and middle/ctrl+click work on session rows. Visually nothing — the
|
||||
* row keeps its exact current look; navigation UX is unchanged because plain clicks
|
||||
* still do an SPA push via Link. Cells with their own interactive content or title
|
||||
* tooltips (status badges, tenant copy button, actions) must NOT render this overlay
|
||||
* as it would sit above them and swallow their hover/click. aria-hidden + tabIndex -1
|
||||
* keep it out of tab order and screen readers — the row's click target is unchanged. */
|
||||
function RowLinkOverlay({ href, session }: { href: Route; session: Session }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
prefetch={false}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className="absolute inset-0"
|
||||
onClick={(e) => {
|
||||
// Link handles the navigation itself; stop the row's onClick from double-pushing.
|
||||
e.stopPropagation();
|
||||
trackEvent("session_opened", { sessionId: session.sessionId, status: session.status ?? "" });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionCell({
|
||||
columnKey,
|
||||
session,
|
||||
href,
|
||||
globalAdminMode,
|
||||
blockedDevicesSet,
|
||||
user,
|
||||
@@ -795,6 +823,7 @@ function SessionCell({
|
||||
}: {
|
||||
columnKey: string;
|
||||
session: Session;
|
||||
href: Route;
|
||||
adminMode: boolean;
|
||||
globalAdminMode: boolean;
|
||||
blockedDevicesSet: Set<string>;
|
||||
@@ -808,7 +837,8 @@ function SessionCell({
|
||||
switch (columnKey) {
|
||||
case "device":
|
||||
return (
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="relative px-6 py-4 whitespace-nowrap">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{session.deviceName || session.serialNumber}
|
||||
</div>
|
||||
@@ -849,7 +879,8 @@ function SessionCell({
|
||||
|
||||
case "model":
|
||||
return (
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="relative px-6 py-4 whitespace-nowrap">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{session.manufacturer || "Unknown manufacturer"}
|
||||
</div>
|
||||
@@ -907,14 +938,16 @@ function SessionCell({
|
||||
|
||||
case "eventCount":
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
{session.eventCount}
|
||||
</td>
|
||||
);
|
||||
|
||||
case "duration":
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
{Math.round(session.durationSeconds / 60)} min
|
||||
</td>
|
||||
);
|
||||
@@ -923,7 +956,8 @@ function SessionCell({
|
||||
// Two-line date/time keeps the column narrow so more columns fit on one page.
|
||||
const started = new Date(session.startedAt);
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
<div className="text-sm font-medium text-gray-900">{started.toLocaleDateString()}</div>
|
||||
<div className="text-sm text-gray-500">{started.toLocaleTimeString()}</div>
|
||||
</td>
|
||||
@@ -985,35 +1019,40 @@ function SessionCell({
|
||||
|
||||
case "osName":
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
{session.osName || <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
);
|
||||
|
||||
case "osBuild":
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
{session.osBuild || <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
);
|
||||
|
||||
case "osDisplayVersion":
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
{session.osDisplayVersion || <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
);
|
||||
|
||||
case "osEdition":
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
{session.osEdition || <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
);
|
||||
|
||||
case "osLanguage":
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
{session.osLanguage || <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
);
|
||||
|
||||
@@ -83,6 +83,8 @@ interface DownloadItem {
|
||||
downloadRateBps: number;
|
||||
lastUpdated: string;
|
||||
lastUpdatedMs: number;
|
||||
startedMs: number;
|
||||
completedMs: number | null;
|
||||
isComplete: boolean;
|
||||
isSkipped: boolean;
|
||||
firstSeenIndex: number;
|
||||
@@ -118,6 +120,19 @@ function formatDoDuration(duration: string): string {
|
||||
return formatDuration(Math.floor(ms / 1000));
|
||||
}
|
||||
|
||||
// Per-app download duration. Prefers the DO-reported duration ([DO TEL] JSON,
|
||||
// present up to IME ~1.97); IME 1.104 removed that log line entirely, so fall
|
||||
// back to the observed event window: first download event -> first event that
|
||||
// marked the download complete.
|
||||
function effectiveDurationMs(dl: DownloadItem): number {
|
||||
const doMs = dl.doStats?.downloadDuration ? parseDoDurationMs(dl.doStats.downloadDuration) : 0;
|
||||
if (doMs > 0) return doMs;
|
||||
if (dl.completedMs != null && Number.isFinite(dl.startedMs) && dl.completedMs > dl.startedMs) {
|
||||
return dl.completedMs - dl.startedMs;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export default function DownloadProgress({ events, summaryStats }: DownloadProgressProps) {
|
||||
// Legacy-agent guard: drop download events replayed from a previous enrollment's IME log.
|
||||
// Silent (empty finals set) — the InstallProgress panel already reports the hidden count
|
||||
@@ -219,6 +234,8 @@ export default function DownloadProgress({ events, summaryStats }: DownloadProgr
|
||||
// memo-recompute would be an arbitrary base for event-time deltas
|
||||
// anyway (render must also stay pure — react-hooks/purity).
|
||||
lastUpdatedMs: Number.isFinite(eventTs) ? eventTs : (existing?.lastUpdatedMs ?? NaN),
|
||||
startedMs: existing?.startedMs ?? (Number.isFinite(eventTs) ? eventTs : NaN),
|
||||
completedMs: existing?.completedMs ?? (isComplete && Number.isFinite(eventTs) ? eventTs : null),
|
||||
isComplete,
|
||||
isSkipped: isSkippedEvent || (existing?.isSkipped ?? false),
|
||||
firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++,
|
||||
@@ -237,18 +254,17 @@ export default function DownloadProgress({ events, summaryStats }: DownloadProgr
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [showSkipped, setShowSkipped] = useState(false);
|
||||
|
||||
// Sum of individual download durations (from DO telemetry per app)
|
||||
// Sum of individual download durations (DO telemetry when present, otherwise
|
||||
// the observed start->complete event window per app).
|
||||
// Must be before the early return to keep hooks in stable order across renders.
|
||||
const totalDuration = useMemo(() => {
|
||||
let sum = 0;
|
||||
let hasAny = false;
|
||||
for (const dl of downloads) {
|
||||
if (dl.doStats?.downloadDuration) {
|
||||
const ms = parseDoDurationMs(dl.doStats.downloadDuration);
|
||||
if (ms > 0) {
|
||||
sum += ms;
|
||||
hasAny = true;
|
||||
}
|
||||
const ms = effectiveDurationMs(dl);
|
||||
if (ms > 0) {
|
||||
sum += ms;
|
||||
hasAny = true;
|
||||
}
|
||||
}
|
||||
return hasAny ? sum : null;
|
||||
@@ -365,6 +381,7 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showDoStats, setShowDoStats] = useState(false);
|
||||
const hasKnownTotal = dl.bytesTotal > 0;
|
||||
const durationMs = effectiveDurationMs(dl);
|
||||
const showProgressBar = !dl.isSkipped && (hasKnownTotal || dl.isComplete || !dl.isComplete);
|
||||
|
||||
// Determine container styling
|
||||
@@ -429,6 +446,9 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
|
||||
: dl.isComplete
|
||||
? "Completed"
|
||||
: `${formatBytes(dl.bytesDownloaded)} downloaded`}
|
||||
{dl.isComplete && durationMs > 0 && (
|
||||
<span className="text-gray-400"> in {formatDuration(Math.floor(durationMs / 1000))}</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{progressPercent > 0 ? `${progressPercent.toFixed(0)}%` : "started"}</span>
|
||||
</div>
|
||||
@@ -479,7 +499,9 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-gray-600">
|
||||
<div>
|
||||
<span className="text-gray-500">Duration:</span>{" "}
|
||||
<span className="font-medium">{formatDoDuration(dl.doStats.downloadDuration)}</span>
|
||||
<span className="font-medium">
|
||||
{durationMs > 0 ? formatDuration(Math.floor(durationMs / 1000)) : formatDoDuration(dl.doStats.downloadDuration)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">File Size:</span>{" "}
|
||||
|
||||
@@ -99,6 +99,17 @@ export interface AdminConfiguration {
|
||||
* only Global Admins can flip while it is off. Default false.
|
||||
*/
|
||||
selfServiceAppHomingEnabled?: boolean;
|
||||
/**
|
||||
* When true, the first fleet-wide sighting of a new IME version downloads the
|
||||
* installer into the permanent ime-archive blob container (ime-msi-archive queue
|
||||
* worker). When false, queued archive jobs wait — they are not dropped. Default true.
|
||||
*/
|
||||
imeMsiArchivingEnabled?: boolean;
|
||||
/**
|
||||
* Size cap for the IME installer download in MB (preflight + mid-stream guard).
|
||||
* 0 = unlimited. Default 250.
|
||||
*/
|
||||
maxImeMsiDownloadSizeMB?: number;
|
||||
}
|
||||
|
||||
export interface OpsAlertRule {
|
||||
|
||||
@@ -50,6 +50,8 @@ export const SHARED_MANIFEST = {
|
||||
"vulnerabilityCorrelationEnabled",
|
||||
"enableIndexDualWrite",
|
||||
"autoApproveNewTenants",
|
||||
"imeMsiArchivingEnabled",
|
||||
"maxImeMsiDownloadSizeMB",
|
||||
"selfServiceAppHomingEnabled",
|
||||
"sessionDeletionKillSwitch",
|
||||
"vulnerabilityDataLastSyncUtc",
|
||||
@@ -486,6 +488,7 @@ export const SHARED_MANIFEST = {
|
||||
"app_install_skipped",
|
||||
"app_install_started",
|
||||
"app_install_starved",
|
||||
"app_state_reconciliation",
|
||||
"app_tracking_summary",
|
||||
"autologon_analysis",
|
||||
"autopilot_profile",
|
||||
@@ -557,6 +560,7 @@ export const SHARED_MANIFEST = {
|
||||
"ime_agent_version",
|
||||
"ime_process_exited",
|
||||
"ime_session_change",
|
||||
"ime_token_failure",
|
||||
"ime_user_session_completed",
|
||||
"ingress_backpressure",
|
||||
"integrity_bypass_analysis",
|
||||
@@ -600,6 +604,7 @@ export const SHARED_MANIFEST = {
|
||||
"realmjoin_resolved",
|
||||
"realmjoin_timeout",
|
||||
"reboot_triggered",
|
||||
"registry_app_state",
|
||||
"remote_config_fetch_failed",
|
||||
"script_completed",
|
||||
"script_failed",
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
"vulnerabilityCorrelationEnabled",
|
||||
"enableIndexDualWrite",
|
||||
"autoApproveNewTenants",
|
||||
"imeMsiArchivingEnabled",
|
||||
"maxImeMsiDownloadSizeMB",
|
||||
"selfServiceAppHomingEnabled",
|
||||
"sessionDeletionKillSwitch",
|
||||
"vulnerabilityDataLastSyncUtc",
|
||||
@@ -482,6 +484,7 @@
|
||||
"app_install_skipped",
|
||||
"app_install_started",
|
||||
"app_install_starved",
|
||||
"app_state_reconciliation",
|
||||
"app_tracking_summary",
|
||||
"autologon_analysis",
|
||||
"autopilot_profile",
|
||||
@@ -553,6 +556,7 @@
|
||||
"ime_agent_version",
|
||||
"ime_process_exited",
|
||||
"ime_session_change",
|
||||
"ime_token_failure",
|
||||
"ime_user_session_completed",
|
||||
"ingress_backpressure",
|
||||
"integrity_bypass_analysis",
|
||||
@@ -596,6 +600,7 @@
|
||||
"realmjoin_resolved",
|
||||
"realmjoin_timeout",
|
||||
"reboot_triggered",
|
||||
"registry_app_state",
|
||||
"remote_config_fetch_failed",
|
||||
"script_completed",
|
||||
"script_failed",
|
||||
|
||||
Reference in New Issue
Block a user