Compare commits

...

6 Commits

Author SHA1 Message Date
Oliver Kieselbach 44dd8161df fix(agent): ImeProcessWatcher verifies process identity before attaching and re-arms discovery after an exit — it attached to the first IntuneManagementExtension name match, so a standard user starting a renamed binary during AccountSetup could forge an ime_process_exited Warning and mute the real signal for the rest of the session (CWE-807), and even a legitimate IME service restart was never watched again; 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 OnImeExited restarts the discovery timer (ReferenceEquals guard against duplicate events)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:07:18 +02:00
Oliver Kieselbach c84a1c23eb fix(backend): registration StartedAt is clamped to the event skew window (+24h / -168h around server time) in the shared register core — the device-supplied anchor drives the SessionsIndex RowKey, the retention filter, TotalToday and the supersede-predecessor comparison, so a far-future value pinned the row at the head of every list (incl. the Global Admin cross-tenant list), inflated today's counters forever and never became a retention candidate; both cert-auth and bootstrap entries flow through ProcessRegisterAsync, re-registration keeps the earlier stored anchor (min-merge)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:03:05 +02:00
Oliver Kieselbach 4a7eb2749d fix(backend): bootstrap revoke enforces code ownership — DELETE /api/bootstrap/sessions/{code} only scoped the caller's ?tenantId while the repository resolved the owning tenant from CodeLookup and revoked any tenant's code; RevokeBootstrapSessionAsync now takes the caller tenant and refuses a mismatch as not-found (no owner leak, foreign partition never read), with regression tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:59:32 +02:00
Oliver Kieselbach a536553ee3 fix(web): IME Tracker card shows skip counters without the ime_tracker_degraded pointer — a tenant admin cannot act on it; a tooltip explains that pattern coverage may be incomplete and points to Report Session
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:38:07 +02:00
Oliver Kieselbach ce202c6d47 fix(ops): SlaBreachNotification ops event prints Duration breaches in minutes — the P95 value carried the % unit of the rate breach types ("Duration 864.0% (target 60.0%)"); the unit now follows SlaBreachType like the customer-facing alert builder
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:19:59 +02:00
Oliver Kieselbach 74902bf185 fix(agent): IME per-line matching budget counts Regex.Match time only — the wall-clock window also held the match handlers and scheduler stalls, and session 946ccbd6 (Hyper-V guest, 5–10 s process-wide stalls, 1 MB log, no line over 5 KB) broke it twice on genuine lines and silently skipped their remaining patterns; ime_pattern_hits is Debug severity (operator/backend signal, hidden from the default timeline — the IME Tracker card and the ingest fold read it regardless); the session page's IME Tracker card moves to the right column under Hardware
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:00:28 +02:00
20 changed files with 934 additions and 78 deletions
@@ -73,10 +73,15 @@ completion) queue behind that work — a monitoring blackout during the enrollme
capped prefix opens a CMTrace entry without closing it, the existing `skippingDroppedEntry`
mechanism skips the entry's continuation lines instead of raw-matching them; a line that
opens a *new* entry always ends the skip and is processed itself.
* **Per-line matching budget.** `MatchLine` runs all active patterns under a 2 s Stopwatch
budget; a pattern that times out is skipped and counted, and once the budget is spent the
remaining patterns are skipped for that line (matches already handled stand). No genuine line
comes near it — the budget exists for unanchored custom patterns and hostile input.
* **Per-line matching budget.** `MatchLine` runs all active patterns under a 2 s budget that
sums only the time spent inside `Regex.Match` on that line; a pattern that times out is
skipped and counted, and once the budget is spent the remaining patterns are skipped for that
line (matches already handled stand). No genuine line comes near it — the budget exists for
unanchored custom patterns and hostile input. It is deliberately not the wall-clock across
the pattern loop: that window also holds the match handlers (signal ingress, match-log
append) and scheduler stalls, and session 946ccbd6 (2026-08-30, Hyper-V guest with 5–10 s
process-wide stalls, 1 MB log, no line over 5 KB) broke the wall-clock budget twice on
genuine lines and skipped their remaining patterns.
* **Held-back tails.** An unterminated tail at EOF (a physical line without `\n`, or a
multiline entry without `]LOG]!>`) is normally the writer mid-write. Instead of dropping it
(and raw-matching its remainder next pass) the bookmark stays at the entry start. The hold
+69
View File
@@ -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).
+3 -1
View File
@@ -41,7 +41,9 @@ idle/stall clocks (`SignalActivityClassifier`).
the whole session via the persisted histogram) the handler emits `ime_pattern_hits`:
`hits` = every ENABLED pattern ID → match count, zeros included, plus the health counters and
`imeVersion` (IME's own "Agent version is:" line). Only sessions with a terminal run report —
crashes/kills are outside the denominator by construction.
crashes/kills are outside the denominator by construction. Severity is Debug: the event is an
operator/backend signal, hidden from the default session timeline; the session page's
"IME Tracker" card and the ingest fold read it regardless of severity.
* **Ingest.** `EventIngestProcessor` hands the event to `ImePatternHealthService` fire-and-forget:
IDs are filtered against `BuiltInImeLogPatterns.BuiltInPatternIds` (a device may only claim an
ID; tenant custom IDs never reach the global table), the version must pass
+1
View File
@@ -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.
+8
View File
@@ -1,5 +1,13 @@
# 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.
## 2026-08-30 (9)
* **New**: `backend/ime-pattern-health.md` — the pattern-drift loop: `ImeTrackerHealth` counters + per-pattern histogram in the agent (restart-safe via `ImeTrackerState`), `ime_*` keys in `agent_metrics_snapshot`, one-shot `ime_tracker_degraded` Warning, session-end `ime_pattern_hits` (every enabled pattern, zeros included, `imeVersion`), backend `ImePatternStats` (PK = IME version, batch upsert, permanent), `ImePatternDriftEvaluator` (baseline = biggest version ≥100 sessions, expected = ≥0.8 hit rate, drift = 0 hits in ≥25 sessions, once per cell) → `ImePatternDriftSuspected` ops event, `GET metrics/ime-pattern-health` (GlobalReadOrAdmin), web section `admin/metrics/ime-pattern-health` + session-page "IME Tracker" rows, MCP `get_ime_pattern_health`. Registered in `index.md`.
@@ -139,6 +139,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
Assert.Equal(1, fired); // the persisted flag suppressed the second emission
}
[Fact]
public async Task Handler_time_does_not_count_against_the_line_budget()
{
// Session 946ccbd6: a stalled Hyper-V guest spent >2 s between two patterns of one
// genuine line and the wall-clock budget skipped the rest of the line. The budget
// bounds Regex.Match time only — a slow handler on an early pattern must leave the
// later patterns of the same line running and must not count as skipped work.
using var h = new Harness();
h.Tracker.OnPatternMatched = id => { if (id == "T-MARK") Thread.Sleep(2300); };
h.Append(Entry("marker 1 loose 1") + "\n");
await h.Pass();
var health = h.Tracker.GetHealthSnapshot();
Assert.Equal(1, health.PatternHits["T-MARK"]);
Assert.Equal(1, health.PatternHits["T-LOOSE"]); // ordered after T-MARK — still matched
Assert.Equal(0, health.BudgetBreaks);
Assert.False(health.HasSkippedWork);
}
[Fact]
public void Ime_agent_version_is_captured_from_the_pattern_action()
{
@@ -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
{
/// <summary>
/// CWE-807 hardening of <see cref="ImeProcessWatcher"/>: 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.
/// </summary>
[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<Process> Running = new List<Process>();
public readonly Dictionary<int, ImeProcessCandidate> Facts = new Dictionary<int, ImeProcessCandidate>();
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);
}
/// <summary>A stand-in process that blocks on stdin until killed; identity facts are injected.</summary>
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<FakeSignalIngressSink.PostedSignal> 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<bool> 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<IReadOnlyDictionary<string, object>>(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);
}
}
}
@@ -341,14 +341,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// pattern that times out is skipped (counted); once the aggregate budget is spent the
/// remaining patterns are skipped for this line (counted, first skipped ID recorded) —
/// matches already handled on this line stand.
/// <para>
/// The budget is the SUM of the time spent inside <c>Regex.Match</c> on this line — the
/// cost the budget exists to bound (hostile input × unanchored custom patterns). It is
/// deliberately not the wall-clock from the first to the last pattern: that window also
/// contains the match handlers (signal ingress, match-log append, state writes) and
/// whatever the scheduler does to this thread in between. Session 946ccbd6 (2026-08-30)
/// recorded two budget breaks on a 1 MB `AppWorkload.log` with no line over 5 KB — the
/// Hyper-V guest had stalled the whole process for 5–10 s mid-line — and each break
/// silently skipped the remaining patterns of a genuine line.
/// </para>
/// </summary>
private void MatchLine(string filePath, string line, string messageToMatch, CmTraceLogEntry entry, PassStats stats)
{
var started = Stopwatch.GetTimestamp();
long matchTicks = 0;
for (var i = 0; i < _activePatterns.Count; i++)
{
var pattern = _activePatterns[i];
if (Stopwatch.GetTimestamp() - started > LineMatchBudgetTicks)
if (matchTicks > LineMatchBudgetTicks)
{
stats.BudgetBreaks++;
_budgetBreaks++;
@@ -356,22 +366,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
return;
}
Match match;
var matchStarted = Stopwatch.GetTimestamp();
try
{
var match = pattern.Regex.Match(messageToMatch);
if (match.Success)
{
RecordPatternHit(pattern.PatternId);
WriteMatchLog(filePath, line, pattern.PatternId);
HandlePatternMatch(pattern, match, messageToMatch, entry);
}
match = pattern.Regex.Match(messageToMatch);
}
catch (RegexMatchTimeoutException)
{
matchTicks += Stopwatch.GetTimestamp() - matchStarted;
stats.RegexTimeouts++;
_regexTimeouts++;
if (_timeoutLoggedPatternIds.Add(pattern.PatternId))
_logger.Debug($"ImeLogTracker: regex timeout for pattern '{pattern.PatternId}' (message length {messageToMatch.Length}) — skipped; further timeouts of this pattern are only counted");
continue;
}
matchTicks += Stopwatch.GetTimestamp() - matchStarted;
if (match.Success)
{
RecordPatternHit(pattern.PatternId);
WriteMatchLog(filePath, line, pattern.PatternId);
HandlePatternMatch(pattern, match, messageToMatch, entry);
}
}
}
@@ -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
{
/// <summary>
/// Identity facts about one process named <c>IntuneManagementExtension</c>, as observed by
/// <see cref="ImeProcessWatcher"/> before it decides whether to attach.
/// </summary>
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; }
/// <summary>Terminal-services session; the IME service runs in session 0. -1 = unknown.</summary>
public int SessionId { get; }
/// <summary>Full image path (Win32 form), or null when it could not be resolved.</summary>
public string ImagePath { get; }
public DateTime StartTimeUtc { get; }
}
/// <summary>
/// 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
/// <c>IntuneManagementExtension.exe</c> (CWE-807 — the same class as the agent instance guard).
/// A watcher that attaches to the first name match would (a) report a forged
/// <c>ime_process_exited</c> 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 (<c>%ProgramFiles(x86)%\Microsoft Intune Management Extension\</c>, admin-writable only).
/// </summary>
public static class ImeProcessIdentity
{
public const string InstallFolderName = "Microsoft Intune Management Extension";
/// <summary>
/// Install roots the IME image may live under. IME is a 32-bit service, so on x64/ARM64 it is
/// <c>%ProgramFiles(x86)%</c>; <c>%ProgramFiles%</c> is included for completeness (x86 OS,
/// or a 32-bit agent host whose ProgramFiles already resolves to the (x86) folder).
/// </summary>
public static IReadOnlyList<string> DefaultTrustedRoots()
{
var roots = new List<string>(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;
}
/// <summary>
/// True when the candidate is trustworthy as the real IME service process: session 0 and an
/// image path directly under one of <paramref name="trustedRoots"/>. 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 (<c>...Extension\</c> prefix), so a sibling folder
/// like <c>Microsoft Intune Management Extension2\</c> does not pass.
/// </summary>
public static bool IsTrusted(ImeProcessCandidate candidate, IReadOnlyList<string> 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;
}
/// <summary>
/// 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.
/// </summary>
public static ImeProcessCandidate? SelectPreferred(IEnumerable<ImeProcessCandidate> candidates, IReadOnlyList<string> 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;
}
/// <summary>
/// Reads the identity facts of a live process. Uses <c>QueryFullProcessImageName</c> so the
/// path resolves across bitness (IME is 32-bit; the agent may be 64-bit) — <c>MainModule</c>
/// throws in that case. Any failure yields an untrusted candidate (session -1 / null path),
/// never an exception.
/// </summary>
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);
}
}
}
}
@@ -11,9 +11,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// 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.
/// <para>
/// Identity is established by <see cref="ImeProcessIdentity"/> (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.
/// </para>
/// </summary>
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<string> _trustedRoots;
private readonly HashSet<int> _rejectedPids = new HashSet<int>();
private Timer _discoveryTimer;
private Process _imeProcess;
private bool _disposed;
private readonly object _lock = new object();
/// <summary>Test seam: enumerates the name-matched processes (default: Process.GetProcessesByName).</summary>
internal Func<Process[]> ProcessSource { get; set; } = () => Process.GetProcessesByName(ImeProcessName);
/// <summary>Test seam: reads a candidate's identity facts (default: <see cref="ImeProcessIdentity.Probe"/>).</summary>
internal Func<Process, ImeProcessCandidate> IdentityProbe { get; set; } = ImeProcessIdentity.Probe;
public ImeProcessWatcher(
string sessionId,
string tenantId,
InformationalEventPost post,
AgentLogger logger)
AgentLogger logger,
IReadOnlyList<string> trustedRoots = null)
{
_sessionId = sessionId;
_tenantId = tenantId;
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_trustedRoots = trustedRoots ?? ImeProcessIdentity.DefaultTrustedRoots();
}
/// <summary>PID of the attached process, or null while discovering.</summary>
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));
}
/// <summary>Runs one discovery pass synchronously (tests).</summary>
internal void TryAttachNow() => TryAttach(null);
/// <summary>
/// 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.
/// </summary>
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<Process>();
if (candidates.Length == 0)
return;
var proc = candidates[0];
for (int i = 1; i < candidates.Length; i++)
candidates[i].Dispose();
var facts = new List<ImeProcessCandidate>(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 ?? "<unresolved>"})");
}
}
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
{
@@ -544,7 +544,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
/// <summary>
/// Emits <c>ime_pattern_hits</c>: per-pattern match counts for every enabled IME log
/// pattern (zeros included — "active but never matched" IS the drift signal) plus the
/// tracker's cumulative health counters. Best-effort like the app summary.
/// tracker's cumulative health counters. Best-effort like the app summary. Debug
/// severity: it is an operator/backend signal, not something a tenant admin acts on —
/// the default timeline hides it, the session page's IME Tracker card and the backend
/// fold still read it.
/// </summary>
private void EmitImePatternHits()
{
@@ -590,7 +593,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = Constants.EventTypes.ImePatternHits,
Severity = EventSeverity.Info,
Severity = EventSeverity.Debug,
Source = "EnrollmentTerminationHandler",
Phase = EnrollmentPhase.Unknown,
Message = $"IME pattern hits: {matchedPatterns}/{health.PatternHits.Count} patterns matched, {health.EntriesMatched} matches over {health.LinesRead} lines" +
@@ -0,0 +1,103 @@
using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Regression guard: the device-supplied registration <c>StartedAt</c> anchors the SessionsIndex
/// RowKey, the retention filter ("StartedAt lt cutoff"), the TotalToday window and the
/// supersede-predecessor comparison. Without a clamp a far-future value (year 2999) would pin
/// the row at the head of every list — including the Global Admin cross-tenant list — inflate
/// today's counters forever and never become a retention candidate. The clamp lives in the
/// shared register core (<see cref="RegisterSessionFunction.ProcessRegisterAsync"/>) and mirrors
/// <see cref="EventTimestampValidator"/> so registration and events share one skew policy.
/// </summary>
public class RegisterSessionStartedAtClampTests
{
private static readonly DateTime FixedUtcNow = new(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc);
private static SessionRegistration Registration(DateTime startedAt) => new()
{
TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901",
StartedAt = startedAt,
};
[Fact]
public void FarFuture_StartedAt_is_clamped_to_server_time()
{
var registration = Registration(new DateTime(2999, 1, 1, 0, 0, 0, DateTimeKind.Utc));
RegisterSessionFunction.ClampStartedAt(registration, FixedUtcNow, NullLogger.Instance);
Assert.Equal(FixedUtcNow, registration.StartedAt);
Assert.Equal(DateTimeKind.Utc, registration.StartedAt.Kind);
}
[Fact]
public void Future_beyond_event_tolerance_is_clamped_but_within_tolerance_is_kept()
{
var justInside = FixedUtcNow.AddHours(EventTimestampValidator.MaxFutureToleranceHours).AddMinutes(-1);
var justOutside = FixedUtcNow.AddHours(EventTimestampValidator.MaxFutureToleranceHours).AddMinutes(1);
var kept = Registration(justInside);
var clamped = Registration(justOutside);
RegisterSessionFunction.ClampStartedAt(kept, FixedUtcNow, NullLogger.Instance);
RegisterSessionFunction.ClampStartedAt(clamped, FixedUtcNow, NullLogger.Instance);
Assert.Equal(justInside, kept.StartedAt);
Assert.Equal(FixedUtcNow, clamped.StartedAt);
}
[Fact]
public void Catastrophic_past_StartedAt_is_clamped_to_server_time()
{
var registration = Registration(DateTime.MinValue);
RegisterSessionFunction.ClampStartedAt(registration, FixedUtcNow, NullLogger.Instance);
Assert.Equal(FixedUtcNow, registration.StartedAt);
}
[Fact]
public void Plausible_StartedAt_is_preserved_and_normalized_to_Utc()
{
var unspecified = new DateTime(2026, 8, 30, 11, 30, 0, DateTimeKind.Unspecified);
var registration = Registration(unspecified);
RegisterSessionFunction.ClampStartedAt(registration, FixedUtcNow, NullLogger.Instance);
Assert.Equal(DateTime.SpecifyKind(unspecified, DateTimeKind.Utc), registration.StartedAt);
Assert.Equal(DateTimeKind.Utc, registration.StartedAt.Kind);
}
[Fact]
public void ProcessRegisterAsync_body_calls_the_clamp_before_storing()
{
// Source-level wiring pin: the clamp must precede SupersedeOrphanedPredecessorsAsync and
// StoreSessionAsync inside the shared core so both HTTP entries (cert-auth + bootstrap)
// are covered. RegisterSessionGuardWiringTests already pins that bootstrap delegates here.
var source = File.ReadAllText(FindSource("Functions/Sessions/RegisterSessionFunction.cs"));
var core = source.IndexOf("internal async Task<RegisterSessionOutput> ProcessRegisterAsync(", StringComparison.Ordinal);
var clamp = source.IndexOf("ClampStartedAt(registration, DateTime.UtcNow, _logger);", StringComparison.Ordinal);
var store = source.IndexOf("_sessionRepo.StoreSessionAsync(registration", StringComparison.Ordinal);
Assert.True(core >= 0 && clamp > core && store > clamp,
"ClampStartedAt must be invoked inside ProcessRegisterAsync before StoreSessionAsync.");
}
private static string FindSource(string relative)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
var candidate = Path.Combine(dir.FullName, "AutopilotMonitor.Functions", relative);
if (File.Exists(candidate)) return candidate;
dir = dir.Parent;
}
throw new FileNotFoundException(relative);
}
}
@@ -0,0 +1,105 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Services;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Object-level authorization for bootstrap revoke: the short code is a platform-global
/// identifier, so the repository must refuse to revoke a code owned by a tenant other than
/// the caller's authorized tenant — and must do so without leaking who owns it (same
/// "not found" result as an unknown code).
/// </summary>
public sealed class TableBootstrapRepositoryRevokeOwnershipTests
{
private const string TenantA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
private const string TenantB = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
private const string Code = "k7m3pq";
private sealed class Harness
{
public TableBootstrapRepository Sut { get; }
public Mock<TableClient> Table { get; } = new();
public List<TableEntity> Updates { get; } = new();
public Harness(string owningTenantId)
{
var lookup = new TableEntity("CodeLookup", Code)
{
{ "TenantId", owningTenantId },
{ "Token", "token-1" },
{ "IsRevoked", false }
};
var main = new TableEntity(owningTenantId, Code)
{
{ "Token", "token-1" },
{ "IsRevoked", false }
};
Table.Setup(c => c.GetEntityAsync<TableEntity>("CodeLookup", Code, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(lookup, Mock.Of<Response>()));
Table.Setup(c => c.GetEntityAsync<TableEntity>(owningTenantId, Code, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(main, Mock.Of<Response>()));
Table.Setup(c => c.UpdateEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
{
Updates.Add(e);
return Task.FromResult(Mock.Of<Response>());
});
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
Sut = new TableBootstrapRepository(
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
NullLogger<TableBootstrapRepository>.Instance);
}
}
[Fact]
public async Task Revoke_ForeignTenantCode_IsRefusedWithoutStateChange()
{
var h = new Harness(owningTenantId: TenantB);
var result = await h.Sut.RevokeBootstrapSessionAsync(TenantA, Code);
Assert.False(result);
Assert.Empty(h.Updates);
// The owning tenant's main partition must not even be read on a foreign-tenant attempt.
h.Table.Verify(c => c.GetEntityAsync<TableEntity>(TenantB, Code, null, It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Revoke_OwnTenantCode_RevokesMainAndLookupEntities()
{
var h = new Harness(owningTenantId: TenantA);
var result = await h.Sut.RevokeBootstrapSessionAsync(TenantA, Code);
Assert.True(result);
Assert.Equal(2, h.Updates.Count);
Assert.All(h.Updates, e => Assert.True(e.GetBoolean("IsRevoked")));
Assert.Contains(h.Updates, e => e.PartitionKey == TenantA && e.RowKey == Code);
Assert.Contains(h.Updates, e => e.PartitionKey == "CodeLookup" && e.RowKey == Code);
}
[Fact]
public async Task Revoke_UnknownCode_ReturnsFalse_SameAsForeignTenant()
{
var h = new Harness(owningTenantId: TenantA);
h.Table.Setup(c => c.GetEntityAsync<TableEntity>("CodeLookup", "zzzzzz", null, It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(404, "not found"));
var result = await h.Sut.RevokeBootstrapSessionAsync(TenantA, "zzzzzz");
Assert.False(result);
Assert.Empty(h.Updates);
}
}
@@ -167,16 +167,26 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
/// <summary>
/// Revokes a bootstrap session. Looks up the tenant via CodeLookup partition first.
/// Revokes a bootstrap session. Resolves the owning tenant via the CodeLookup partition
/// and refuses (returns false, same as not-found) when it differs from the caller's tenant:
/// short codes are platform-global, so tenant scope must be checked against the object itself.
/// </summary>
public async Task<bool> RevokeBootstrapSessionAsync(string shortCode)
public async Task<bool> RevokeBootstrapSessionAsync(string tenantId, string shortCode)
{
try
{
// Look up the tenant from the CodeLookup partition
// Look up the owning tenant from the CodeLookup partition
var lookupResult = await _tableClient.GetEntityAsync<TableEntity>(CodeLookupPartition, shortCode);
var lookup = lookupResult.Value;
var tenantId = lookup.GetString("TenantId") ?? "";
var owningTenantId = lookup.GetString("TenantId") ?? "";
if (!string.Equals(owningTenantId, tenantId, StringComparison.Ordinal))
{
// Do not leak the owner; the caller sees the same 404 as for an unknown code.
_logger.LogWarning("Bootstrap session {ShortCode} revoke refused: code is not owned by tenant {TenantId}",
shortCode, tenantId);
return false;
}
// Update main entity
var mainResult = await _tableClient.GetEntityAsync<TableEntity>(tenantId, shortCode);
@@ -145,6 +145,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
// agent-sent value unconditionally — this is the server's verdict, not input.
registration.ValidatedBy = validation.ValidatedBy;
// Device-supplied session anchor: clamp before ANY consumer sees it. StartedAt drives the
// SessionsIndex RowKey (list order), the retention filter ("StartedAt lt cutoff"), the
// TotalToday window and the supersede-predecessor comparison below — a far-future value
// would pin the row at the head of every list, inflate today's counters forever and
// never become a retention candidate. Same skew policy as event timestamps
// (EventTimestampValidator: +24h / -168h, clamped to server time, not rejected).
ClampStartedAt(registration, DateTime.UtcNow, _logger);
// Cascade-delete guard (Codex F2/F3 + bootstrap follow-up): live HERE in the shared
// core so BOTH the cert-auth /agent/register-session entry AND the bootstrap wrapper
// (BootstrapRegisterSessionFunction) hit it before the StoreSessionAsync below.
@@ -408,6 +416,30 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
/// 410 Gone response for the cascade-delete guard. Mirrors the V2 ingest 410 contract so
/// the agent treats register and telemetry uniformly when a session is being deleted.
/// </summary>
/// <summary>
/// Clamps <see cref="SessionRegistration.StartedAt"/> to the same plausibility window
/// events get (<see cref="EventTimestampValidator.SanitizeTimestamp"/>): outside
/// [utcNow − 168h, utcNow + 24h] the anchor becomes the server receive time. Runs in the
/// shared core so the cert-auth and the bootstrap entry are covered alike. A re-registration
/// of an existing session keeps the earlier stored anchor regardless (StoreSessionAsync
/// takes the minimum), so a legitimately old WhiteGlove Part-2 resume is not moved.
/// </summary>
internal static void ClampStartedAt(SessionRegistration registration, DateTime utcNow, ILogger logger)
{
var original = registration.StartedAt;
var sanitized = EventTimestampValidator.SanitizeTimestamp(original, utcNow);
if (sanitized == EventTimestampValidator.EnsureUtc(original))
{
registration.StartedAt = sanitized;
return;
}
logger.LogWarning(
"Session {SessionId} (tenant {TenantId}): registration StartedAt {OriginalStartedAt:o} is outside the plausible window around server time {UtcNow:o} — clamped to server time",
registration.SessionId, registration.TenantId, original, utcNow);
registration.StartedAt = sanitized;
}
private static async Task<HttpResponseData> WriteSessionLockedAsync(HttpRequestData req, SessionDeletionLockedException locked)
{
var response = req.CreateResponse(HttpStatusCode.Gone);
@@ -92,7 +92,9 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Revokes a bootstrap session. Already-running agents with the token will be rejected on next request.
/// Revokes a bootstrap session owned by <paramref name="tenantId"/>. Already-running agents with
/// the token will be rejected on next request. Codes owned by another tenant are reported as
/// not found (false) — the repository enforces ownership against the CodeLookup partition.
/// </summary>
public async Task<bool> RevokeAsync(string tenantId, string shortCode)
{
@@ -105,7 +107,7 @@ namespace AutopilotMonitor.Functions.Services
_cache.Remove($"bootstrap-token:{session.Token}");
}
var result = await _bootstrapRepo.RevokeBootstrapSessionAsync(shortCode);
var result = await _bootstrapRepo.RevokeBootstrapSessionAsync(tenantId, shortCode);
if (result)
{
_logger.LogInformation("Revoked bootstrap session {ShortCode} for tenant {TenantId}", shortCode, tenantId);
@@ -878,10 +878,15 @@ namespace AutopilotMonitor.Functions.Services
public Task RecordSlaBreachNotificationAsync(string tenantId, string breachType,
double currentRate, double targetRate, int totalSessions, int failedSessions)
=> WriteAsync(OpsEventCategory.Sla, "SlaBreachNotification", OpsEventSeverity.Warning,
$"SLA breach notification sent for tenant {tenantId}: {breachType} {currentRate:F1}% (target {targetRate:F1}%)",
{
// Duration breaches carry the P95 in minutes, every other type a percentage — same units the
// customer-facing alert (NotificationAlertBuilder.BuildSlaBreachAlert) prints.
var unit = breachType == Shared.Models.SlaBreachType.Duration ? " min" : "%";
return WriteAsync(OpsEventCategory.Sla, "SlaBreachNotification", OpsEventSeverity.Warning,
$"SLA breach notification sent for tenant {tenantId}: {breachType} {currentRate:F1}{unit} (target {targetRate:F1}{unit})",
tenantId, "System.SlaEvaluation",
new { breachType, currentRate, targetRate, totalSessions, failedSessions });
}
public Task RecordSlaConsecutiveFailuresAsync(string tenantId, int count, string? lastDevice, string? lastReason)
=> WriteAsync(OpsEventCategory.Sla, "SlaConsecutiveFailures", OpsEventSeverity.Error,
@@ -439,7 +439,7 @@ namespace AutopilotMonitor.Shared
public const string RegistryAppBaseline = "registry_app_baseline"; // Info, one-shot at agent start (only when non-empty): summary of the Win32Apps state that PREDATES this agent run. Closes the WDP pre-agent gap — DPP-policy apps install in Batch 1 before the bootstrap-script agent exists, so their outcomes live only in this baseline (typed payload capped at 50 apps).
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 ImeTrackerDegraded = "ime_tracker_degraded"; // Warning, one-shot per session, immediate upload: a log-tracker pass skipped work (oversized lines dropped, regex timeouts, per-line budget breaks). Expected value in the fleet is "never" — any occurrence means hostile or malformed input in the IME Logs folder.
public const string ImePatternHits = "ime_pattern_hits"; // Info, once per session at termination: per-pattern match counts for every enabled IME log pattern (zeros included) + tracker health counters. Backend aggregates per IME version (ImePatternStats) — the pattern-drift signal.
public const string ImePatternHits = "ime_pattern_hits"; // Debug (operator signal, hidden from the default timeline), once per session at termination: per-pattern match counts for every enabled IME log pattern (zeros included) + tracker health counters. Backend aggregates per IME version (ImePatternStats) — the pattern-drift signal.
public const string ImeUserSessionCompleted = "ime_user_session_completed";
public const string DoTelemetry = "do_telemetry";
public const string AllAppsCompleted = "all_apps_completed";
@@ -14,7 +14,13 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<BootstrapSession?> GetBootstrapSessionByCodeAsync(string shortCode);
Task<BootstrapSession?> ValidateBootstrapTokenAsync(string token);
Task<List<BootstrapSession>> GetBootstrapSessionsAsync(string tenantId);
Task<bool> RevokeBootstrapSessionAsync(string shortCode);
/// <summary>
/// Revokes a bootstrap session only when the code belongs to <paramref name="tenantId"/>.
/// Returns false (indistinguishable from "not found") when the code does not exist
/// or is owned by another tenant — the short code is a platform-global identifier
/// and the caller's tenant scope must be enforced here, not just at the route.
/// </summary>
Task<bool> RevokeBootstrapSessionAsync(string tenantId, string shortCode);
Task<bool> IncrementBootstrapUsageAsync(string shortCode);
Task<int> CleanupExpiredAsync();
}
@@ -387,38 +387,6 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
</DetailSection>
)}
{/* IME log tracker health: queue depth + skip counters from the periodic snapshot,
session totals from the terminal ime_pattern_hits event. Skip counters are
expected to be 0 — any other value is a reason to look at the client log. */}
{(imeTracker || patternHits) && (
<DetailSection title="IME Tracker">
{imeTracker?.ime_files_tailed !== undefined && (
<DetailRow label="Log files tailed" value={String(imeTracker.ime_files_tailed)} />
)}
{imeTracker?.ime_backlog_bytes !== undefined && (
<DetailRow label="Unread backlog" value={Number(imeTracker.ime_backlog_bytes) > 0 ? formatBytesCompact(Number(imeTracker.ime_backlog_bytes)) : "0 (caught up)"} />
)}
{(patternHits?.linesRead ?? imeTracker?.ime_lines_read) !== undefined && (
<DetailRow label="Lines read" value={Number(patternHits?.linesRead ?? imeTracker?.ime_lines_read).toLocaleString()} />
)}
{(patternHits?.entriesMatched ?? imeTracker?.ime_entries_matched) !== undefined && (
<DetailRow label="Pattern matches" value={Number(patternHits?.entriesMatched ?? imeTracker?.ime_entries_matched).toLocaleString()} />
)}
{patternHits?.matchedPatterns !== undefined && patternHits?.patternCount !== undefined && (
<DetailRow label="Patterns matched" value={`${patternHits.matchedPatterns} / ${patternHits.patternCount}`} />
)}
<DetailRow
label="Skipped work"
value={imeSkips > 0
? `${imeSkipParts.join(", ")} — see ime_tracker_degraded`
: "none"}
/>
{(patternHits?.unanchoredPatterns ?? imeTracker?.ime_unanchored_patterns) !== undefined && Number(patternHits?.unanchoredPatterns ?? imeTracker?.ime_unanchored_patterns) > 0 && (
<DetailRow label="Unanchored patterns" value={String(patternHits?.unanchoredPatterns ?? imeTracker?.ime_unanchored_patterns)} />
)}
</DetailSection>
)}
{/* Security */}
{(bitLockerStatus || secureBootStatus || tpmStatus) && (
<DetailSection title="Security">
@@ -475,7 +443,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
)}
</div>
{/* Right Column: System, Autopilot Profile, Hardware */}
{/* Right Column: System, Autopilot Profile, Hardware, IME Tracker */}
<div className="flex-1 flex flex-col gap-6">
{/* System */}
{(estimatedBootTime || agentStarted?.agentVersion || imeVersion || realmJoinInfo?.productVersion || aadJoinStatus?.joinType || deviceLocation?.country || deviceLocation?.Country || deviceLocation?.timezone || deviceLocation?.Timezone) && (
@@ -668,6 +636,39 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
})}
</DetailSection>
)}
{/* IME log tracker health: queue depth + skip counters from the periodic snapshot,
session totals from the terminal ime_pattern_hits event. Skip counters are
expected to be 0 — any other value is a reason to look at the client log. */}
{(imeTracker || patternHits) && (
<DetailSection title="IME Tracker">
{imeTracker?.ime_files_tailed !== undefined && (
<DetailRow label="Log files tailed" value={String(imeTracker.ime_files_tailed)} />
)}
{imeTracker?.ime_backlog_bytes !== undefined && (
<DetailRow label="Unread backlog" value={Number(imeTracker.ime_backlog_bytes) > 0 ? formatBytesCompact(Number(imeTracker.ime_backlog_bytes)) : "0 (caught up)"} />
)}
{(patternHits?.linesRead ?? imeTracker?.ime_lines_read) !== undefined && (
<DetailRow label="Lines read" value={Number(patternHits?.linesRead ?? imeTracker?.ime_lines_read).toLocaleString()} />
)}
{(patternHits?.entriesMatched ?? imeTracker?.ime_entries_matched) !== undefined && (
<DetailRow label="Pattern matches" value={Number(patternHits?.entriesMatched ?? imeTracker?.ime_entries_matched).toLocaleString()} />
)}
{patternHits?.matchedPatterns !== undefined && patternHits?.patternCount !== undefined && (
<DetailRow label="Patterns matched" value={`${patternHits.matchedPatterns} / ${patternHits.patternCount}`} />
)}
<DetailRow
label="Skipped work"
value={imeSkips > 0 ? imeSkipParts.join(", ") : "none"}
title={imeSkips > 0
? "Matching was cut short on some log lines — pattern coverage may be incomplete for this session. Nothing to fix on your side; use Report Session if the stats look wrong."
: undefined}
/>
{(patternHits?.unanchoredPatterns ?? imeTracker?.ime_unanchored_patterns) !== undefined && Number(patternHits?.unanchoredPatterns ?? imeTracker?.ime_unanchored_patterns) > 0 && (
<DetailRow label="Unanchored patterns" value={String(patternHits?.unanchoredPatterns ?? imeTracker?.ime_unanchored_patterns)} />
)}
</DetailSection>
)}
</div>
</div>
)}
@@ -691,11 +692,11 @@ function formatBytesCompact(bytes: number): string {
return `${(bytes / 1024).toFixed(0)} KB`;
}
function DetailRow({ label, value }: { label: string; value: string }) {
function DetailRow({ label, value, title }: { label: string; value: string; title?: string }) {
return (
<div className="flex justify-between text-xs py-0.5">
<span className="text-gray-500">{label}</span>
<span className="text-gray-900 font-mono ml-2 text-right break-all" title={value}>{value}</span>
<span className="text-gray-900 font-mono ml-2 text-right break-all" title={title ?? value}>{value}</span>
</div>
);
}