diff --git a/docs/agent/ime-process-watcher.md b/docs/agent/ime-process-watcher.md
new file mode 100644
index 00000000..785031ba
--- /dev/null
+++ b/docs/agent/ime-process-watcher.md
@@ -0,0 +1,69 @@
+---
+type: concept
+title: IME Process Watcher — Identity Before Attach, Re-Arm After Exit
+description: Why ImeProcessWatcher attaches only to a process that runs in session 0 with its image under the IME install root — a process name is not an identity — and why a reported exit re-arms discovery instead of ending it.
+resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs
+tags: [agent, ime, process, security, cwe-807, reliability]
+timestamp: 2026-08-30
+---
+
+# Problem
+
+`ImeProcessWatcher` reports `ime_process_exited` (Warning — read as an IME crash indicator in the
+timeline) by subscribing to `Process.Exited` of `IntuneManagementExtension.exe`. Until 2026-08-30 it
+attached to `Process.GetProcessesByName(...)[0]` and stopped discovery for good once attached.
+
+A process name is not a protected namespace. During AccountSetup (and on Device Preparation flows)
+a standard-user session is live and can start any binary renamed to `IntuneManagementExtension.exe`.
+Two consequences (CWE-807 — the same class as the agent instance guard and the local-admin dynamic
+allowance):
+
+* **Forged signal** — the fake exits, the SYSTEM agent emits a Warning with the fake's PID/exit
+ code, and failure diagnosis is misdirected.
+* **Muted signal** — attached to the fake, discovery is stopped; the real service is never watched,
+ and a genuine IME crash later in the enrollment is never reported. Even without an attacker the
+ one-shot design missed every IME exit after the first (an IME service restart is not rare).
+
+# Schema
+
+`ImeProcessIdentity` establishes identity by facts a standard user cannot forge:
+
+| Check | Source | Why it holds |
+|---|---|---|
+| `SessionId == 0` | `Process.SessionId` | Only services and their children run in session 0; a user session cannot start a process there. |
+| image under `%ProgramFiles(x86)%\Microsoft Intune Management Extension\` (also `%ProgramW6432%`/`%ProgramFiles%` variants) | `QueryFullProcessImageName` (cross-bitness — IME is 32-bit, the agent may be 64-bit; `MainModule` throws there), `MainModule.FileName` fallback | Program Files is admin-writable only. Segment-aware prefix (`...Extension\`), `GetFullPath`-normalised, so a sibling folder or `..` does not pass. |
+| file name exactly `IntuneManagementExtension.exe` | image path | `GetProcessesByName` matches the name without extension. |
+
+Unknown facts (session `-1`, unresolved path) are **untrusted** — a false negative only delays
+attach by one 5 s discovery tick, a false positive mutes the signal for the session.
+
+Among trusted candidates the **oldest** by `StartTime` wins (the SCM-started service predates
+anything transient). Untrusted name matches are logged once per PID as a Warning in the agent log
+(`ignoring untrusted IntuneManagementExtension.exe candidate (PID, session, image)`) and never
+attached; no timeline event is raised for them.
+
+**Re-arm:** `OnImeExited` detaches, emits the event, and restarts the discovery timer. Only the
+currently attached `Process` object is honoured (`ReferenceEquals`), so a late `Exited` from a
+replaced or disposed handle cannot produce a duplicate event. A process that exits between probe
+and subscription is handled by an explicit `HasExited` check after subscribing.
+
+The watcher owns the `Process` handles its source returns (like `GetProcessesByName`) and disposes
+them — tests must hand it fresh handles (`Process.GetProcessById`), not the ones they hold.
+
+# Examples
+
+* Fake started in session 1 from `Downloads\IntuneManagementExtension.exe` before the service is up →
+ never attached; its exit emits nothing; the service arriving later is attached normally.
+* Service exits (crash or restart) → `ime_process_exited` (PID, exit code, uptime), discovery
+ re-armed, the restarted service is attached on the next tick and its later exit is reported too.
+* Fake and service both running, fake listed first → the service is attached.
+
+Covered by `ImeProcessWatcherIdentityTests` (pure identity table + stand-in `cmd.exe` processes
+with injected identity facts).
+
+# Citations
+
+* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessIdentity.cs`
+* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs`
+* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeProcessWatcherIdentityTests.cs`
+* Related: [Local Admin Analyzer](local-admin-analyzer.md) (CWE-807 dynamic allowance).
diff --git a/docs/index.md b/docs/index.md
index 0f03a279..991c9c86 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -33,6 +33,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [IME Log Tracker — Anchored Patterns and the Bounded Read Loop](agent/ime-log-tracker-matching-budget.md) - The IME Logs folder is user-writable, so the SYSTEM tracker must stay linear on hostile input: every shipped pattern starts with `^` (a non-match is O(prefix)), no chained unbounded scans, a byte-bounded line reader (32 MB — real `Get policies` lines reach 5.6 MB) with exact bookmarks, a 2 s per-line matching budget, held-back unterminated tails, and one Warning per pass instead of silent Debug timeouts.
* [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.
* [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop.
+* [IME Process Watcher — Identity Before Attach, Re-Arm After Exit](agent/ime-process-watcher.md) - `ime_process_exited` comes only from a process that runs in session 0 with its image under the IME install root (QueryFullProcessImageName, cross-bitness) — a process name is not an identity (CWE-807: a standard user can start a renamed binary during AccountSetup); the oldest trusted candidate wins, untrusted matches are logged once per PID and never attached, and a reported exit re-arms discovery so an IME restart and a later crash are both seen.
* [Local Admin Analyzer](agent/local-admin-analyzer.md) - What local_admin_analysis asserts at enrollment start/end: every local account inventoried, disabled ones included (a /active:no backdoor is dormant at both scans), Administrators membership read from the SAM by well-known SID (NetLocalGroupGetMembers, WMI-independent), unexpected admin members scored +40, built-ins matched by well-known RID (localized Guest/Administrator never flagged), shutdown-time dynamic allowance never covers a local SAM account (CWE-807), junctions and $GUID folders are not profiles, non-local members (Entra role SIDs) listed but never flagged, built-in Administrator enabled-state reported but not scored.
* [Power-State Watcher](agent/power-state-watcher.md) - Live AC↔battery transitions and 50/30/15% downward battery-threshold crossings during enrollment via a WMI Win32_PowerManagementEvent push subscription (zero polling, never armed on battery-less devices): the power_state_change event (transition field, per-level lifetime latch, 5s debounce, emission cap) and the analyze rules ANALYZE-DEV-009 (battery <15%, high) / ANALYZE-DEV-010 (AC→battery, warning) with on_event interim triggers.
* [System Timeline Watcher](agent/system-timeline-watcher.md) - Ground truth for two timeline blind spots from one System-channel EventLogWatcher with pre-agent backfill: system_clock_changed (Kernel-General 1 — oldTime/newTime/signed timeDeltaMs/reason/setting process; 2 s micro-slew floor, ≥5 min Warning+immediate) and system_sleep_episode (Power-Troubleshooter 1 sleep/hibernate + Kernel-Power 507 Modern Standby; authoritative enteredAt/exitedAt payload instants, 60 s Modern-Standby floor). Consumed by the TimeJumpBadge clock-set cause, the SessionInfoCard standby note, time-attribution SleepSpans (AttributionVersion 3) and ANALYZE-DEV-012.
diff --git a/docs/log.md b/docs/log.md
index 544d8561..6298d240 100644
--- a/docs/log.md
+++ b/docs/log.md
@@ -1,5 +1,9 @@
# Log
+## 2026-08-30 (11)
+
+* **New**: `agent/ime-process-watcher.md` — `ImeProcessWatcher` no longer attaches to the first `IntuneManagementExtension` name match: `ImeProcessIdentity` requires session 0 + image under the IME install root (`QueryFullProcessImageName`, cross-bitness), prefers the oldest trusted candidate, logs untrusted matches once per PID, and re-arms discovery after every reported exit (previously one-shot — a fake in a user session could forge `ime_process_exited` and mute the real signal for the session; even an IME service restart was never watched again). Registered in `index.md`.
+
## 2026-08-30 (10)
* **Update**: `agent/ime-log-tracker-matching-budget.md` — the per-line budget sums only `Regex.Match` time; the previous wall-clock window also counted match handlers and scheduler stalls and broke twice on genuine lines of a stalled Hyper-V guest (session 946ccbd6). `backend/ime-pattern-health.md` — `ime_pattern_hits` is Debug severity (operator signal, hidden from the default timeline; the IME Tracker card and the ingest fold read it regardless). The session page's "IME Tracker" card moved to the right column.
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeProcessWatcherIdentityTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeProcessWatcherIdentityTests.cs
new file mode 100644
index 00000000..44a681a8
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeProcessWatcherIdentityTests.cs
@@ -0,0 +1,225 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
+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.Ime
+{
+ ///
+ /// CWE-807 hardening of : a process is only ever attached when
+ /// its identity (session 0 + image under the IME install root) says it is the service, and a
+ /// reported exit re-arms discovery so the next IME instance is watched too.
+ ///
+ [Collection("SerialThreading")] // Process.Exited is delivered on the shared ThreadPool
+ public sealed class ImeProcessWatcherIdentityTests
+ {
+ private const string Root = @"C:\Program Files (x86)\Microsoft Intune Management Extension";
+ private static readonly string[] Roots = { Root };
+ private static readonly DateTime T0 = new DateTime(2026, 8, 30, 10, 0, 0, DateTimeKind.Utc);
+
+ private static ImeProcessCandidate C(int pid, int session, string path, int startOffsetSeconds = 0) =>
+ new ImeProcessCandidate(pid, session, path, T0.AddSeconds(startOffsetSeconds));
+
+ // ---- pure identity logic ----
+
+ [Theory]
+ [InlineData(0, Root + @"\Microsoft.Management.Services.IntuneWindowsAgent\IntuneManagementExtension.exe", true)]
+ [InlineData(0, Root + @"\IntuneManagementExtension.exe", true)]
+ [InlineData(0, @"c:\program files (x86)\microsoft intune management extension\INTUNEMANAGEMENTEXTENSION.EXE", true)]
+ [InlineData(1, Root + @"\IntuneManagementExtension.exe", false)] // user session
+ [InlineData(2, @"C:\Users\user\Downloads\IntuneManagementExtension.exe", false)] // spoof
+ [InlineData(0, @"C:\Users\user\Downloads\IntuneManagementExtension.exe", false)] // wrong root
+ [InlineData(0, Root + @"2\IntuneManagementExtension.exe", false)] // sibling folder
+ [InlineData(0, Root + @"\..\Evil\IntuneManagementExtension.exe", false)] // traversal
+ [InlineData(0, Root + @"\IntuneManagementExtensionX.exe", false)] // name variant
+ [InlineData(-1, Root + @"\IntuneManagementExtension.exe", false)] // session unknown
+ [InlineData(0, "", false)] // path unresolved
+ public void IsTrusted_requires_session0_and_install_root(int session, string path, bool expected)
+ {
+ Assert.Equal(expected, ImeProcessIdentity.IsTrusted(C(1, session, path), Roots));
+ }
+
+ [Fact]
+ public void IsTrusted_rejects_null_path()
+ {
+ Assert.False(ImeProcessIdentity.IsTrusted(C(1, 0, null!), Roots));
+ }
+
+ [Fact]
+ public void SelectPreferred_skips_untrusted_and_picks_oldest_trusted()
+ {
+ var fake = C(10, 1, @"C:\Users\user\IntuneManagementExtension.exe", startOffsetSeconds: -100); // oldest, but a spoof
+ var young = C(20, 0, Root + @"\IntuneManagementExtension.exe", startOffsetSeconds: 50);
+ var service = C(30, 0, Root + @"\IntuneManagementExtension.exe", startOffsetSeconds: 0);
+
+ var chosen = ImeProcessIdentity.SelectPreferred(new[] { fake, young, service }, Roots);
+
+ Assert.Equal(30, chosen?.Pid);
+ Assert.Null(ImeProcessIdentity.SelectPreferred(new[] { fake }, Roots));
+ }
+
+ [Fact]
+ public void DefaultTrustedRoots_are_under_program_files()
+ {
+ var roots = ImeProcessIdentity.DefaultTrustedRoots();
+ Assert.NotEmpty(roots);
+ Assert.All(roots, r => Assert.EndsWith(ImeProcessIdentity.InstallFolderName, r));
+ }
+
+ [Fact]
+ public void Probe_reads_real_facts_for_the_current_process()
+ {
+ var me = ImeProcessIdentity.Probe(Process.GetCurrentProcess());
+ Assert.Equal(Process.GetCurrentProcess().Id, me.Pid);
+ Assert.True(me.SessionId >= 0);
+ Assert.False(string.IsNullOrEmpty(me.ImagePath));
+ Assert.EndsWith(".exe", me.ImagePath, StringComparison.OrdinalIgnoreCase);
+ }
+
+ // ---- watcher behaviour with real (stand-in) processes ----
+
+ private sealed class Fixture : IDisposable
+ {
+ public readonly TempDirectory Tmp = new TempDirectory();
+ public readonly FakeSignalIngressSink Ingress = new FakeSignalIngressSink();
+ public readonly ImeProcessWatcher Watcher;
+ public readonly List Running = new List();
+ public readonly Dictionary Facts = new Dictionary();
+
+ public Fixture()
+ {
+ var logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
+ Watcher = new ImeProcessWatcher("session-ime", "tenant-1", new InformationalEventPost(Ingress, new VirtualClock(T0)), logger, Roots);
+ // Fresh handles, like Process.GetProcessesByName: the watcher owns and disposes what it is given.
+ Watcher.ProcessSource = () => Running
+ .Where(p => { try { return !p.HasExited; } catch { return false; } })
+ .Select(p => Process.GetProcessById(p.Id))
+ .ToArray();
+ Watcher.IdentityProbe = p => Facts.TryGetValue(p.Id, out var f) ? f : ImeProcessIdentity.Probe(p);
+ }
+
+ /// A stand-in process that blocks on stdin until killed; identity facts are injected.
+ public Process Spawn(bool trusted)
+ {
+ var psi = new ProcessStartInfo("cmd.exe", "/c pause")
+ {
+ UseShellExecute = false,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ CreateNoWindow = true,
+ };
+ var p = Process.Start(psi);
+ Running.Add(p);
+ Facts[p.Id] = trusted
+ ? new ImeProcessCandidate(p.Id, 0, Root + @"\IntuneManagementExtension.exe", p.StartTime.ToUniversalTime())
+ : new ImeProcessCandidate(p.Id, 1, @"C:\Users\user\Downloads\IntuneManagementExtension.exe", p.StartTime.ToUniversalTime());
+ return p;
+ }
+
+ public IReadOnlyList ExitEvents() =>
+ Ingress.Posted
+ .Where(s => s.Kind == DecisionSignalKind.InformationalEvent
+ && s.Payload != null
+ && s.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
+ && et == SharedEventTypes.ImeProcessExited)
+ .ToList();
+
+ public void Dispose()
+ {
+ Watcher.Dispose();
+ foreach (var p in Running)
+ {
+ try { if (!p.HasExited) p.Kill(); } catch { }
+ try { p.Dispose(); } catch { }
+ }
+ Tmp.Dispose();
+ }
+ }
+
+ private static void WaitUntil(Func cond, int timeoutMs = 10000)
+ {
+ var sw = Stopwatch.StartNew();
+ while (!cond() && sw.ElapsedMilliseconds < timeoutMs)
+ Thread.Sleep(25);
+ Assert.True(cond(), "condition not met within timeout");
+ }
+
+ [Fact]
+ public void Spoofed_process_is_never_attached_and_its_exit_emits_nothing()
+ {
+ using var f = new Fixture();
+ var fake = f.Spawn(trusted: false);
+
+ f.Watcher.TryAttachNow();
+ f.Watcher.TryAttachNow();
+ Assert.Null(f.Watcher.AttachedProcessId);
+
+ fake.Kill();
+ fake.WaitForExit();
+ Thread.Sleep(200);
+ Assert.Empty(f.ExitEvents());
+
+ // The real service arriving later is still attached — discovery was never consumed.
+ var real = f.Spawn(trusted: true);
+ f.Watcher.TryAttachNow();
+ Assert.Equal(real.Id, f.Watcher.AttachedProcessId);
+ }
+
+ [Fact]
+ public void Real_process_exit_is_reported_and_discovery_rearms_for_the_next_instance()
+ {
+ using var f = new Fixture();
+ var first = f.Spawn(trusted: true);
+
+ f.Watcher.TryAttachNow();
+ Assert.Equal(first.Id, f.Watcher.AttachedProcessId);
+
+ first.Kill();
+ first.WaitForExit();
+ WaitUntil(() => f.ExitEvents().Count == 1);
+ Assert.Null(f.Watcher.AttachedProcessId);
+
+ // IME restarted (or a spoof took the name first — still ignored).
+ var spoof = f.Spawn(trusted: false);
+ var second = f.Spawn(trusted: true);
+ f.Watcher.TryAttachNow();
+ Assert.Equal(second.Id, f.Watcher.AttachedProcessId);
+
+ second.Kill();
+ second.WaitForExit();
+ WaitUntil(() => f.ExitEvents().Count == 2);
+
+ spoof.Kill();
+ spoof.WaitForExit();
+ Thread.Sleep(200);
+ Assert.Equal(2, f.ExitEvents().Count);
+
+ var pids = f.ExitEvents()
+ .Select(e => Assert.IsAssignableFrom>(e.TypedPayload)["pid"])
+ .ToList();
+ Assert.Equal(new object[] { first.Id, second.Id }, pids);
+ }
+
+ [Fact]
+ public void With_spoof_and_service_both_running_the_service_wins_regardless_of_order()
+ {
+ using var f = new Fixture();
+ var spoof = f.Spawn(trusted: false); // listed first
+ var real = f.Spawn(trusted: true);
+
+ f.Watcher.TryAttachNow();
+
+ Assert.Equal(real.Id, f.Watcher.AttachedProcessId);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessIdentity.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessIdentity.cs
new file mode 100644
index 00000000..394a8d40
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessIdentity.cs
@@ -0,0 +1,172 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
+{
+ ///
+ /// Identity facts about one process named IntuneManagementExtension, as observed by
+ /// before it decides whether to attach.
+ ///
+ public readonly struct ImeProcessCandidate
+ {
+ public ImeProcessCandidate(int pid, int sessionId, string imagePath, DateTime startTimeUtc)
+ {
+ Pid = pid;
+ SessionId = sessionId;
+ ImagePath = imagePath;
+ StartTimeUtc = startTimeUtc;
+ }
+
+ public int Pid { get; }
+ /// Terminal-services session; the IME service runs in session 0. -1 = unknown.
+ public int SessionId { get; }
+ /// Full image path (Win32 form), or null when it could not be resolved.
+ public string ImagePath { get; }
+ public DateTime StartTimeUtc { get; }
+ }
+
+ ///
+ /// Process-name matching is not a protected namespace: during AccountSetup / Device Preparation
+ /// a standard user session is live and can start any binary renamed to
+ /// IntuneManagementExtension.exe (CWE-807 — the same class as the agent instance guard).
+ /// A watcher that attaches to the first name match would (a) report a forged
+ /// ime_process_exited Warning into the tenant timeline and (b) stop discovering the real
+ /// service. This helper establishes identity by properties a standard user cannot forge:
+ /// the process runs in session 0 (services only) AND its image lives under the IME install
+ /// root (%ProgramFiles(x86)%\Microsoft Intune Management Extension\, admin-writable only).
+ ///
+ public static class ImeProcessIdentity
+ {
+ public const string InstallFolderName = "Microsoft Intune Management Extension";
+
+ ///
+ /// Install roots the IME image may live under. IME is a 32-bit service, so on x64/ARM64 it is
+ /// %ProgramFiles(x86)%; %ProgramFiles% is included for completeness (x86 OS,
+ /// or a 32-bit agent host whose ProgramFiles already resolves to the (x86) folder).
+ ///
+ public static IReadOnlyList DefaultTrustedRoots()
+ {
+ var roots = new List(2);
+ foreach (var env in new[] { "ProgramFiles(x86)", "ProgramW6432", "ProgramFiles" })
+ {
+ var basePath = Environment.GetEnvironmentVariable(env);
+ if (string.IsNullOrEmpty(basePath)) continue;
+ var root = Path.Combine(basePath, InstallFolderName);
+ if (!roots.Contains(root, StringComparer.OrdinalIgnoreCase))
+ roots.Add(root);
+ }
+ return roots;
+ }
+
+ ///
+ /// True when the candidate is trustworthy as the real IME service process: session 0 and an
+ /// image path directly under one of . Unknown session
+ /// (-1) or an unresolvable image path is NOT trusted — a false negative only delays
+ /// attach by one discovery tick, a false positive mutes the signal for the session.
+ /// Path comparison is segment-aware (...Extension\ prefix), so a sibling folder
+ /// like Microsoft Intune Management Extension2\ does not pass.
+ ///
+ public static bool IsTrusted(ImeProcessCandidate candidate, IReadOnlyList trustedRoots)
+ {
+ if (candidate.SessionId != 0) return false;
+ if (string.IsNullOrEmpty(candidate.ImagePath) || trustedRoots == null) return false;
+
+ string full;
+ try { full = Path.GetFullPath(candidate.ImagePath); }
+ catch { return false; }
+
+ // Only the exact on-disk name — a differently-cased extension or a trailing-space
+ // variant would still be a name match for GetProcessesByName but is not the service.
+ if (!string.Equals(Path.GetFileName(full), "IntuneManagementExtension.exe", StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ foreach (var root in trustedRoots)
+ {
+ if (string.IsNullOrEmpty(root)) continue;
+ string rootFull;
+ try { rootFull = Path.GetFullPath(root).TrimEnd('\\', '/') + Path.DirectorySeparatorChar; }
+ catch { continue; }
+ if (full.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Picks the candidate to attach to: the oldest trusted process. The service instance is
+ /// started by the SCM long before any transient tool could appear under the same trusted
+ /// root, and when IME restarts the old instance is already gone from the list.
+ /// Returns null when nothing is trusted.
+ ///
+ public static ImeProcessCandidate? SelectPreferred(IEnumerable candidates, IReadOnlyList trustedRoots)
+ {
+ ImeProcessCandidate? best = null;
+ foreach (var c in candidates)
+ {
+ if (!IsTrusted(c, trustedRoots)) continue;
+ if (best == null || c.StartTimeUtc < best.Value.StartTimeUtc)
+ best = c;
+ }
+ return best;
+ }
+
+ ///
+ /// Reads the identity facts of a live process. Uses QueryFullProcessImageName so the
+ /// path resolves across bitness (IME is 32-bit; the agent may be 64-bit) — MainModule
+ /// throws in that case. Any failure yields an untrusted candidate (session -1 / null path),
+ /// never an exception.
+ ///
+ public static ImeProcessCandidate Probe(Process process)
+ {
+ int pid = -1, session = -1;
+ string path = null;
+ DateTime start = DateTime.MaxValue;
+
+ try { pid = process.Id; } catch { }
+ try { session = process.SessionId; } catch { }
+ try { start = process.StartTime.ToUniversalTime(); } catch { }
+ try { path = QueryImagePath(pid); } catch { }
+ if (path == null)
+ {
+ try { path = process.MainModule?.FileName; } catch { }
+ }
+
+ return new ImeProcessCandidate(pid, session, path, start);
+ }
+
+ private const int PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr OpenProcess(int desiredAccess, bool inheritHandle, int processId);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CloseHandle(IntPtr handle);
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool QueryFullProcessImageName(IntPtr process, int flags, StringBuilder exeName, ref int size);
+
+ private static string QueryImagePath(int pid)
+ {
+ if (pid <= 0) return null;
+ var handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
+ if (handle == IntPtr.Zero) return null;
+ try
+ {
+ var sb = new StringBuilder(1024);
+ int size = sb.Capacity;
+ return QueryFullProcessImageName(handle, 0, sb, ref size) && size > 0 ? sb.ToString(0, size) : null;
+ }
+ finally
+ {
+ CloseHandle(handle);
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs
index 3d0a07f8..694dae5f 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs
@@ -11,9 +11,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
///
/// Watches for IntuneManagementExtension.exe process exit.
- /// Discovers the process once (with retry every 5s), then attaches Process.Exited —
- /// no continuous polling after the process is found.
- /// Emits ime_process_exited when the process terminates.
+ /// Discovers the process (retry every 5s), then attaches Process.Exited —
+ /// no continuous polling while attached. Emits ime_process_exited when the process terminates,
+ /// then re-arms discovery so an IME restart is attached again and a later crash is reported too.
+ ///
+ /// Identity is established by (session 0 + image under the IME
+ /// install root), not by process name alone: a standard user can run any binary renamed to
+ /// IntuneManagementExtension.exe during AccountSetup, and attaching to it would forge a crash
+ /// Warning and mute the real signal (CWE-807). Untrusted name matches are logged once per PID
+ /// and never attached.
+ ///
///
public class ImeProcessWatcher : IDisposable
{
@@ -24,32 +31,51 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private readonly string _tenantId;
private readonly InformationalEventPost _post;
private readonly AgentLogger _logger;
+ private readonly IReadOnlyList _trustedRoots;
+ private readonly HashSet _rejectedPids = new HashSet();
private Timer _discoveryTimer;
private Process _imeProcess;
private bool _disposed;
private readonly object _lock = new object();
+ /// Test seam: enumerates the name-matched processes (default: Process.GetProcessesByName).
+ internal Func ProcessSource { get; set; } = () => Process.GetProcessesByName(ImeProcessName);
+
+ /// Test seam: reads a candidate's identity facts (default: ).
+ internal Func IdentityProbe { get; set; } = ImeProcessIdentity.Probe;
+
public ImeProcessWatcher(
string sessionId,
string tenantId,
InformationalEventPost post,
- AgentLogger logger)
+ AgentLogger logger,
+ IReadOnlyList trustedRoots = null)
{
_sessionId = sessionId;
_tenantId = tenantId;
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _trustedRoots = trustedRoots ?? ImeProcessIdentity.DefaultTrustedRoots();
+ }
+
+ /// PID of the attached process, or null while discovering.
+ internal int? AttachedProcessId
+ {
+ get { lock (_lock) { return _imeProcess?.Id; } }
}
public void Start()
{
- _logger.Info($"ImeProcessWatcher: starting (discovery every {DiscoveryIntervalSeconds}s until {ImeProcessName}.exe is found)");
+ _logger.Info($"ImeProcessWatcher: starting (discovery every {DiscoveryIntervalSeconds}s until {ImeProcessName}.exe is found; trusted roots: {string.Join("; ", _trustedRoots)})");
_discoveryTimer = new Timer(TryAttach, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(DiscoveryIntervalSeconds));
}
+ /// Runs one discovery pass synchronously (tests).
+ internal void TryAttachNow() => TryAttach(null);
+
///
- /// Tries to find and attach to IntuneManagementExtension.exe.
+ /// Tries to find and attach to the real IntuneManagementExtension.exe.
/// Stops the discovery timer once attached — switches to pure event-driven from there.
///
private void TryAttach(object state)
@@ -60,31 +86,65 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
return;
Process[] candidates = null;
+ Process chosen = null;
try
{
- candidates = Process.GetProcessesByName(ImeProcessName);
+ candidates = ProcessSource() ?? Array.Empty();
if (candidates.Length == 0)
return;
- var proc = candidates[0];
- for (int i = 1; i < candidates.Length; i++)
- candidates[i].Dispose();
+ var facts = new List(candidates.Length);
+ foreach (var p in candidates)
+ {
+ var c = IdentityProbe(p);
+ if (ImeProcessIdentity.IsTrusted(c, _trustedRoots))
+ {
+ facts.Add(c);
+ }
+ else if (c.Pid > 0 && _rejectedPids.Add(c.Pid))
+ {
+ // One line per PID: a user-session process carrying the IME name is either
+ // a spoof attempt or a stray tool — either way not the service, never attached.
+ _logger.Warning($"ImeProcessWatcher: ignoring untrusted {ImeProcessName}.exe candidate (PID={c.Pid}, session={c.SessionId}, image={c.ImagePath ?? ""})");
+ }
+ }
- proc.EnableRaisingEvents = true;
- proc.Exited += OnImeExited;
- _imeProcess = proc;
+ var preferred = ImeProcessIdentity.SelectPreferred(facts, _trustedRoots);
+ if (preferred == null)
+ return;
+
+ foreach (var p in candidates)
+ {
+ int pid = -1;
+ try { pid = p.Id; } catch { }
+ if (chosen == null && pid == preferred.Value.Pid)
+ chosen = p;
+ else
+ p.Dispose();
+ }
+ if (chosen == null)
+ return;
+
+ chosen.EnableRaisingEvents = true;
+ chosen.Exited += OnImeExited;
+ _imeProcess = chosen;
// Suspend discovery timer — now event-driven
_discoveryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _logger.Info($"ImeProcessWatcher: attached to {ImeProcessName}.exe (PID={proc.Id})");
+ _logger.Info($"ImeProcessWatcher: attached to {ImeProcessName}.exe (PID={chosen.Id}, session={preferred.Value.SessionId}, image={preferred.Value.ImagePath})");
+
+ // The process may have exited between the probe and the subscription — Exited
+ // fires for an already-exited process only if EnableRaisingEvents was set before
+ // exit; check explicitly so a race does not leave the watcher attached to a corpse.
+ if (chosen.HasExited)
+ ThreadPool.QueueUserWorkItem(_ => OnImeExited(chosen, EventArgs.Empty));
}
catch (Exception ex)
{
_logger.Debug($"ImeProcessWatcher: could not attach to {ImeProcessName}.exe: {ex.Message}");
- // Dispose any leftover handles from the failed attempt
- if (candidates != null)
+ if (_imeProcess == null && candidates != null)
foreach (var p in candidates)
try { p.Dispose(); } catch { }
}
@@ -96,6 +156,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
if (!(sender is Process proc))
return;
+ // Detach + re-arm discovery. Only the currently attached process counts — a late
+ // Exited from a process already replaced or disposed is ignored (no duplicate event).
+ lock (_lock)
+ {
+ if (_disposed || !ReferenceEquals(proc, _imeProcess))
+ return;
+ try { proc.Exited -= OnImeExited; } catch { }
+ _imeProcess = null;
+ _discoveryTimer?.Change(TimeSpan.FromSeconds(DiscoveryIntervalSeconds), TimeSpan.FromSeconds(DiscoveryIntervalSeconds));
+ }
+
int pid = -1;
int exitCode = -1;
double uptimeSeconds = 0;
@@ -103,8 +174,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
try { pid = proc.Id; } catch { }
try { exitCode = proc.ExitCode; } catch { }
try { uptimeSeconds = (proc.ExitTime - proc.StartTime).TotalSeconds; } catch { }
+ try { proc.Dispose(); } catch { }
- _logger.Info($"ImeProcessWatcher: {ImeProcessName}.exe exited (PID={pid}, exit={exitCode}, uptime={uptimeSeconds:F0}s)");
+ _logger.Info($"ImeProcessWatcher: {ImeProcessName}.exe exited (PID={pid}, exit={exitCode}, uptime={uptimeSeconds:F0}s) — discovery re-armed");
_post.Emit(new EnrollmentEvent
{