mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44dd8161df | |||
| c84a1c23eb | |||
| 4a7eb2749d | |||
| a536553ee3 | |||
| ce202c6d47 | |||
| 74902bf185 |
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+19
@@ -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()
|
||||
{
|
||||
|
||||
+225
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-9
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+172
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
-17
@@ -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
|
||||
{
|
||||
|
||||
+5
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
+105
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user