Compare commits

..

40 Commits

Author SHA1 Message Date
Oliver Kieselbach 2b6565b3cb fix(agent): IME matcher line budget charges thread CPU cycles, regex timeouts get one starvation-gated retry - the wall-clock budget and the 1s .NET regex timeout both measured guest-scheduler starvation as match cost on CPU-saturated Hyper-V VMs (session b9f1d134: PS-AGENT-OUTPUT timed out on a line it genuinely matches in 5.8us, factor ~170,000, with 2.5s of process-wide log silence and CPU pinned at 100% during the DeviceSetup script burst; six spurious budget breaks the same pass); MatchLine now charges the per-line budget in cycles the poll thread actually executed (QueryThreadCycleTime, probed once, Stopwatch wall-tick fallback preserving the old behaviour exactly), and a RegexMatchTimeoutException whose attempt consumed < 0.5s of match cost (starvation, not spinning) is retried exactly once - counted into the new regexTimeoutRetries (recovered, NOT skipped work, no degraded event) while an attempt at/over the gate is skipped without a second helping of CPU, so regexTimeouts now means "really burned CPU"; the gate is unreachable in the wall-clock fallback by construction (no retry there); retries surfaced through health snapshot, restart-safe persistence, ime_tracker_degraded, ime_pattern_hits and agent_metrics_snapshot (ime_regex_timeout_retries); known limit: hypervisor-level vCPU steal stays invisible to the guest kernel and still charges cycles - strict improvement over wall clock, not perfect steal accounting
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 21:49:15 +02:00
Oliver Kieselbach 5f3ae2d06b fix(backend): SessionTenantLookup is claimed first-writer-wins at registration — the sessionId→tenantId row that every Global Admin/Reader cross-tenant session resolve trusts was an unconditional last-writer-wins upsert AFTER the session write keyed by the client-supplied sessionId, so any tenant's device (self-service onboarding) that knew a victim session GUID (portal deep links, Teams/Slack notifications, progress-portal URLs) could re-point the mapping at its own tenant and have every no-tenantId GA read and GA write (globaladmin annotation lane, restore fallback) silently served from a forged session; StoreSessionAsync now claims the row create-only BEFORE any Sessions/SessionsIndex write (409 → read owner: same tenant = re-registration, foreign tenant = SessionTenantConflictException → 409 session_owner_mismatch without disclosing the owner + SessionTenantConflict ops event, nothing written, not fail-soft), the legacy self-heal write is Add-only, ResolveSessionScopeAsync(requireGlobalAdmin) corroborates the resolved tenant against its Sessions row (silent, no operator confirmation), and scripts/Migration/Backfill-SessionTenantLookup.ps1 closed the pre-table window (13509 sessions, 8795 rows added, verify dry-run added=0); no tombstone on deletion by design
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:34:50 +02:00
Oliver Kieselbach 83e15e49de fix(core,backend): every payload-derived string stored into DecisionState is bounded (FactStringBounds, 256) and the reducer-verification replay streams signals — RealmJoinFacts.WithPackageStarted/Completed left PackageId/Version/Scope unbounded and deduped via a linear ordinal compare over up to 200 rows, so a forged 5000-signal stream with ~400 KB near-identical package ids cost minutes of CPU per GET sessions/{id}/reducer-verification while ReducerVerifier held all decoded payloads (~GBs) live; keys are now bounded BEFORE the dedupe scan (forged near-duplicates collapse to one row), Scope normalised to machine/user, and the same bound applied to failed app ids, HelloOutcome, ImeMatchedPatternId, EspAdvisoryFailureCategory and RJ product version/channel incl. WithVersionOverride — never truncating real telemetry (registry key names ≤ 255, GUIDs, enum-like strings); the agent adapter bounds packageId/version at the source; ReducerVerifier replays decode-fold-discard (head-signal check before the journal so stubbed streams stay silent, mid-stream truncation and count-imbalance semantics unchanged, pinned by tests); ISignalRepository.QueryBySessionAsync carries a cumulative PayloadJson budget (SignalQueryLimits, 32 M chars) reflected in the truncated flag of sessions/{id}/signals and reducer-verification; no ingest cap — stored signals stay verbatim
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:06:51 +02:00
Oliver Kieselbach 3c8ef02191 feat(agent): RealmJoin self-update is observed and reported as realmjoin_autoupdate_detected — realmjoin_detected read the version once from RealmJoin.exe, but the tray AppUpdater often replaces the MSI-shipped binary on its first start minutes after detection (gktatooine 946ccbd6: 4.21.4 -> 4.21.18, whole deployment ran on the new build while the timeline and RealmJoinFacts kept 4.21.4); the updater's only registry write is HKLM\...\Uninstall\RealmJoin (DisplayVersion/InstallDate), so RealmJoinWatcher arms a RegNotifyChangeKeyValue watcher there once Detected fired (appearance-watch on the Uninstall parent until the tray creates the entry), re-reads the binary version after a 2 s debounce (DisplayVersion fallback while the file is locked, channel from baseline) and emits the Info event {previousVersion,newVersion,releaseChannel} plus the typed RealmJoinAutoUpdateDetected signal, whose reducer overrides RealmJoinFacts.ProductVersion/ReleaseChannel (WithVersionOverride) and touches neither gate, deadline nor LastActivityUtc; unreadable baseline at detection is seeded silently; portal device card prefers the updated version ("auto-updated from X"); event registered in Constants/resource-catalog/shared-manifests with replay fixture and watcher/adapter/reducer tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:02:39 +02:00
Oliver Kieselbach 251e82a45a fix(mcp): tool-call logging never records tenant-config secret values — withToolTelemetry/summarizeArgs gain a per-argument ArgPolicy ('keys' | 'drop') declared at the call site, update_tenant_config logs only the NAMES of args.fields (webhook/SAS URLs and custom headers previously went verbatim, up to 200 chars, into the container log stream under the IaC-default MCP_TOOL_LOGGING=true, readable by log consumers below Global Admin); values remain in the GA-gated backup snapshot and audit log. Backend: TenantConfigPatchService no longer returns the Newtonsoft ex.Message on a JSON type mismatch (it quotes the offending value, so a secret sent into a numeric field echoed into the tool response and log line) — only the field path is named. Tests for keys-only/drop rendering, log-line content on success and soft-error paths, and the backend error text
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:58:23 +02:00
Oliver Kieselbach 01311898a8 fix(ci): no third-party action holds release/deploy credentials by mutable tag — build-agent creates releases with first-party gh release create (job holds contents/id-token/attestations:write), combine-rules pushes generated outputs with plain git (contents:write on main), deploy-web builds the static export in CI (setup-node 22, npm ci, next build — same path ci.yml tests) and uploads it with the exactly pinned SWA CLI 2.0.10 instead of Azure/static-web-apps-deploy (Oryx in-container build, deploy token as action input), attests portal-<sha>.tar.gz and drops the unusable per-PR preview environments; azure/login and Azure/functions-action are SHA-pinned with version comments for Dependabot
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:21:19 +02:00
Oliver Kieselbach a5bca8a730 fix(web): CSV exports neutralize spreadsheet formula triggers in device-controlled cells (CWE-1236) — generateCsvExport/generateSessionCsvExport/generateRuleResultsCsvExport only doubled quotes, so an event Message, EventType, Source, DataJson, DeviceName, SerialNumber, FailureReason or rule explanation starting with =, +, -, @, tab or CR was evaluated as a formula (HYPERLINK/WEBSERVICE exfiltration, DDE) when a tenant admin opened the exported CSV or the operator opened the problem-report attachment; shared csvCell() now prefixes such cells with an apostrophe in addition to quoting, with regression tests for the DDE/HYPERLINK payloads and all three exporters
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:54:40 +02:00
Oliver Kieselbach 87cb1f1cea fix(backend): mcp-usage/user/{oid} filters usage rows to the caller's tenant and never signals foreign existence — UsageCrossTenantGuard skipped rows with an empty TenantId (legacy rows without the column, tokens without a resolvable tid), so a self-service tenant admin of any tenant could read another tenant's user usage (UPN, endpoints/tool names, per-day counts) by oid; and the distinct 403 CrossTenantAccessDenied vs 200-empty made the route a cross-tenant user-existence oracle; FilterForCaller now returns only rows whose TenantId equals the caller's tid for non-global callers (empty TenantId or empty caller tid ⇒ nothing), the handler always answers 200 with the filtered set (foreign oid ≡ unknown oid), dropped rows are logged as an ops warning only; tests rewritten for the filter semantics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:38:16 +02:00
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
Oliver Kieselbach 4f021852ee updated command 2026-08-30 15:14:26 +02:00
Oliver Kieselbach 7a3516e570 feat(observability): IME pattern-drift loop — nothing the log tracker skips or stops matching stays silent: the agent keeps cumulative tracker health counters and a per-pattern match histogram (restart-safe in ImeTrackerState), reports ime_* keys in agent_metrics_snapshot (files tailed, backlog bytes, lines read, matches, oversized/timeouts/budget breaks/held tails/unanchored), raises a one-shot immediate-upload ime_tracker_degraded Warning on the first pass that skipped work, and emits ime_pattern_hits at termination (every enabled pattern, zeros included, IME version); the backend folds the histogram into the permanent per-IME-version ImePatternStats table (built-in IDs only, plausible version only, one partition read + one transactional batch) and evaluates drift without a hard-coded list — baseline = the version with the most reporting sessions (≥100), expected = ≥80 % hit rate there, drift = an expected pattern matched in none of ≥25 sessions on a newer version — raising ImePatternDriftSuspected once per version×pattern (ETag-stamped, dual-registered in the web ops-event list with a new guard test); GET metrics/ime-pattern-health (GlobalReadOrAdmin) + Global Admin → Metrics → IME Pattern Health matrix (drift/silent/low/few cells, open alerts, retired patterns) + session-page "IME Tracker" rows (latest snapshot by max sequence) + MCP get_ime_pattern_health (GA/Reader)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 15:12:38 +02:00
Oliver Kieselbach 6907a41a84 updated command 2026-08-30 14:40:27 +02:00
Oliver Kieselbach f4a5eac539 chore(rules): IME pattern-pack hygiene against the IME decompiles (1.97.107.0 / 1.104.102.0 / 1.105.103.0) — no wording drift in any shipped pattern; the 11 disabled legacy patterns whose literals last existed in IME 1.50 (fleet minimum 1.97) are deleted instead of left as re-enable traps (IME-DETECTED-OLD, IME-SET-CURRENT-1/2/3, IME-SKIP-APPLICABILITY-NOTMET, IME-SKIP-DEPENDENCY-DETECT, IME-SKIP-DETECTED, IME-SKIP-UNINSTALL-NOTDETECTED-2, IME-UPDATE-NAME-1/2/3); IME-DO-TIMEOUT-1/2 match the exact source literal "DO downloading is not finished within timeout, jobId = …, fileId = …" instead of two lookaheads; IME-DO-TEL documented as emitter-less from 1.104 on and kept only for the 1.101/1.103 fleet share
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:23:14 +02:00
Oliver Kieselbach 09492e908c fix(security): IME log-pattern pack is anchored and linear (all 90 patterns start with ^, IME-APP-VERSION spells the report-delta JSON out instead of a chained [^}]*? scan) and the ImeLogTracker read loop is bounded — BoundedLineReader (32 MB per line, evidence: real 5.6 MB "Get policies" lines; exact byte bookmarks, no decoder read-ahead loss on cancel), 2 s per-line matching budget across all patterns, held-back unterminated EOF tails (≤1 MB, 1 s settle) instead of dropping them and raw-matching fragments, a complete entry after a dropped one is no longer swallowed as its close, one Warning per pass (oversizedLines/regexTimeouts/lineBudgetBreaks) instead of silent Debug timeouts; unanchored PS-SCRIPT-CONTEXT false positives on [Win32App] SideCarScript… lines (which overwrote the pending platform script's RunContext) are gone; --run-ime-matching assembles multiline entries like the tracker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:03:50 +02:00
Oliver Kieselbach f432353efa fix(security): startup self-update no longer trusts the on-disk remote-config.json cache — Program.LoadCachedSelfUpdateContext (raw deserialize, bypassed the read-side strip) is removed, the boot-time check runs with allowDowngrade:false and the version-v2.json manifest hash as its only integrity gate; LatestAgentSha256 (update-ZIP hash, ranked above the manifest hash in SelfUpdater) joins the CacheConfig/LoadCachedConfig strip lists so neither the startup nor the FromCache runtime path can take a planted ZIP hash or downgrade flag; verify+extract already share one exclusive handle (1daf631a)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 13:53:00 +02:00
Oliver Kieselbach 1daf631a02 fix(security): self-update verifies and extracts the ZIP through one exclusive FileShare.None handle — SHA-256 is computed on the same stream the ZipArchive reads, so the file can no longer be swapped between verification and extraction (TOCTOU); handle is released before cleanup, open failure aborts with integrity_mismatch marker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 13:03:50 +02:00
Oliver Kieselbach d0190f443d fix(ci): build-agent workflow_dispatch build_number can no longer inject PowerShell — the input and the agent blob SAS reach the version step only via env (BUILD_NUMBER_OVERRIDE, AGENT_SAS_TOKEN) instead of ${{ }} in the script body, and the override is validated against ^\d+$ before the [int] cast and the counter-blob CAS; counter semantics unchanged
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 12:37:36 +02:00
Oliver Kieselbach e96f49799f fix(security): McpUsers whitelist rows are honoured only for the identity the UPN is bound to (AdminIdentityBindings tid+oid) — grant, UsagePlan override and Disabled kill-switch no longer follow a same-UPN token from another tenant or a recycled account; POST global/mcp-users binds first (resolver, 422 HomeTenantUnresolved, 409 conflict) and the settings UI shows the home-tenant picker on 422; unbound legacy rows are inert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 12:13:03 +02:00
Oliver Kieselbach 4864b2d21d fix(security): gather RuleStats resolve the device-claimed ruleId against the tenant's active gather catalog — title/category/severity come from the catalog rule, unknown or inactive IDs are dropped, and the cross-tenant global_* row is written only for IsBuiltIn/IsCommunity catalog rules (same design as the analyze path); a forged GatherRuleExecutor event can no longer inflate fleet-wide counters, respell a built-in rule's title or mint global rows for retired IDs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 12:13:00 +02:00
Oliver Kieselbach 56c37866d1 fix(security): ImeVersionHistory no longer takes a device-reported IME version as a platform fact — RecordImeVersionAsync rejects strings outside MSI ProductVersion bounds (no row/ops event/archive job), a session counts once (UpdateSessionImeAgentVersionAsync returns whether the version changed), a second tenant's sighting stamps CorroboratedAt, and the MemberRead view lists only fleet-confirmed versions (Archived, corroborated, or pre-2026-08-31) while Global scope still sees every row
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 11:38:27 +02:00
Oliver Kieselbach 2852418413 fix(web): progress portal Cloud PC hint becomes a native input tooltip
The extra hint line under the search field made the header say the same
thing three times (subheadline, placeholder, hint). The Cloud PC guidance
now rides on the input's title attribute — the plain browser tooltip on
hover, no cursor change — and the subheadline no longer repeats the input
format. Same guidance is documented in the customer docs (Windows 365
Cloud PCs, Progress Portal).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:37:52 +02:00
Oliver Kieselbach 268d8652f1 fix(web): progress portal serial input no longer hides its Cloud PC hint
The placeholder carried the Cloud PC instruction ("Cloud PC: the name shown
in the Windows App") and was clipped by the input width, so end users saw a
truncated sentence they could not read. Placeholder is now short ("Serial
number or device name..."), the Cloud PC guidance moved to a permanent hint
line below the field (wired via aria-describedby), the subheadline mentions
the device name as an accepted input, and the button no longer wraps on
narrow viewports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:29:58 +02:00
Oliver Kieselbach 66f5543fb7 fix(security): ConsoleBypassWatcher mirrors cmd.exe first-switch-wins parsing — only a leading /c classifies as scripted; a /c inside a /k payload (cmd /k rem /c, cmd /k "title /c") no longer silences a persistent interactive console, which is surfaced as interactive_console_with_command (low)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 02:05:55 +02:00
Oliver Kieselbach 21d200a1e4 fix(security): config PUT derives its preserved system-owned fields from the PATCH deny-list (BaseDeniedFields) so ProDowngradedUtc/OnboardedAt can no longer be written by a tenant admin — a client-supplied ProDowngradedUtc refreshed the retention-downgrade grace anchor indefinitely, keeping the Pro retention cap on a Community tenant
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 02:01:32 +02:00
Oliver Kieselbach 8c0cb2c423 fix(security): RemoveGlobalAdmin, RemoveTenantAdmin, DisableTenantAdmin and sub-Admin UpdateMemberPermissions now call DisconnectUserAsync(upn) so a revoked/demoted user loses already-joined global-admins / tenant-{tid} / notify / session SignalR streams instead of keeping them until the socket drops (group authz is join-time only)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 01:59:51 +02:00
Oliver Kieselbach 84d06e1cf6 fix(security): LocalAdminAnalyzer never exempts a logged-in local SAM account from the shutdown scan (CWE-807) and matches built-ins by well-known RID so localized Guest/Administrator are not flagged
The shutdown run allowed every logged-in user (explorer.exe owner) dynamically, so a backdoor account created after the startup scan and signed in before enrollment completed was scored 0. The WMI inventory is now read first and a logged-in name that is a local account is rejected for the account and the profile check (reported as logged_in_local_accounts); only Entra/domain identities — the only ones seen in real sessions — remain dynamically allowed for their profile folder.

Built-ins are recognised by RID 500/501/503/504 on the machine SID in addition to their English names: since disabled accounts are inventoried (88dfd331), localized Gast/Invité/Administrateur would otherwise flag every non-English device. kioskUser0 (Assigned Access autologon) joins the built-in list; the profile check skips junctions and $<32 hex> folders (cannot be an account name, pure payload noise). ANALYZE-ID-002 1.3.0 explanation mentions the new field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 01:42:51 +02:00
Oliver Kieselbach 88dfd331dd fix(security): LocalAdminAnalyzer inventories disabled accounts and reads Administrators-group membership from the SAM (NetLocalGroupGetMembers on S-1-5-32-544); a dormant /active:no admin backdoor now surfaces as unexpected_admin_members (+40, Error), built-in Administrator enabled-state reported for evidence
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 01:00:29 +02:00
Oliver Kieselbach d294415d29 fix(security): CmTraceLogParser parses in linear time (first open / last parseable trailer, timeout-backed trailer regex) instead of a greedy .* regex that backtracked quadratically on hostile lines without a timeout; agent multiline buffer gains a 1 MB char cap next to the 100-line cap, both logged at Warning, and the tail of a dropped entry is skipped instead of raw-matched
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 00:48:29 +02:00
Oliver Kieselbach 76af1234ed fix(security): BlockedDevices rows are keyed by the canonicalized serial (trim + upper-invariant) so case-variant serials from a rogue agent or an admin's inventory copy still hit the block/kill row; legacy verbatim-case rows are dual-read and lazily re-keyed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 00:27:07 +02:00
Oliver Kieselbach 0515523e49 fix(security): guardrails.json values are emitted into generated TS through a complete encoder (JSON.stringify + U+2028/29) and rejected up-front if they contain control or line-terminator characters; new guardrails schema validated in combine-rules before any bot commit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 00:19:52 +02:00
Oliver Kieselbach a47e94f9a5 fix(security): diagnostics packager reads every file through a handle validated against its pinned source folder; a subdirectory swapped for a junction between enumeration and open is skipped instead of copied
The path guards, the enumeration's reparse-point skip and the per-file attribute check all judged path strings, while the FileStream feeding the ZIP opened that string later (after earlier files were compressed). Under any recursive source folder a local user can write to, a subdirectory turned into a junction in that window made SYSTEM copy the junction target into the tenant-uploaded package.

PinnedSourceFolder holds each section folder open for the whole section (final path must equal the validated path, 8.3-expanded; FILE_LIST_DIRECTORY without delete share, so NTFS refuses renaming it or any ancestor) and opens each candidate with FILE_FLAG_OPEN_REPARSE_POINT, requiring the handle's final path to be exactly <canonical folder>\<relative path>; the archive is fed from that same handle. Manifest: FOLDER REJECTED (...) / SKIPPED (resolved outside validated folder) / FAILED (open). The race is pinned by a test that swaps the directory from the BeforeSourceFileOpen seam; on the previous code the same test copies the junction target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 00:06:50 +02:00
Oliver Kieselbach ecfa59d049 fix(security): PUT preview/notification-email requires a member role once the tenant has members; roleless writes only in the signup window
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:57:35 +02:00
Oliver Kieselbach 6c50a54249 fix(security): escape tenantId in metrics-summary and device-snapshot OData filters; GUID-gate tenantId query param on global metrics/search routes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:55:15 +02:00
Oliver Kieselbach 6e3487756a fix(security): gather-rule on_event feedback loop — timeline feed carries the event source and never dispatches on_event for gather-emitted events; per-rule on_event cap (100/session); backend rejects rules triggering on their own output type / gather_result
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:54:44 +02:00
Oliver Kieselbach dbb154bae9 fix(security): percent-encode every path-position id in lib/api.ts; session pages gate URL-derived id on GUID before any fetch
A crafted /sessions?id=x%2F..%2F..%2Fglobal%2Fy%23 deep link let the victim's browser resolve
dot-segments into a different API route and truncate builder suffixes via '#', turning UI
actions into attacker-chosen authenticated same-origin requests. Hydration now also requires
the returned sessionId to match the requested one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:45:34 +02:00
288 changed files with 11643 additions and 1378 deletions
+43 -13
View File
@@ -76,12 +76,23 @@ jobs:
- name: Reserve build number (counter blob CAS)
id: version
shell: pwsh
# Input and secret reach the script only as environment variables, never as script
# text: ${{ }} is substituted before pwsh parses the block, so a crafted
# build_number could otherwise break out of the quotes and run arbitrary code.
env:
BUILD_NUMBER_OVERRIDE: ${{ inputs.build_number }}
AGENT_SAS_TOKEN: ${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}
run: |
$override = 0
if ('${{ inputs.build_number }}' -ne '') { $override = [int]'${{ inputs.build_number }}' }
if ($env:BUILD_NUMBER_OVERRIDE -ne '') {
if ($env:BUILD_NUMBER_OVERRIDE -notmatch '^\d+$') {
throw "build_number must be a positive integer, got '$($env:BUILD_NUMBER_OVERRIDE)'"
}
$override = [int]$env:BUILD_NUMBER_OVERRIDE
}
$counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v$($env:LINE_MAJOR).txt"
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' -Override $override
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -SasToken $env:AGENT_SAS_TOKEN -Override $override
$csproj = Get-Content $env:CSPROJ_PATH -Raw
if ($csproj -match '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
@@ -313,13 +324,19 @@ jobs:
name: version-metadata
path: ${{ env.MANIFEST_NAME }}
# First-party `gh` instead of a third-party action: this job holds contents:write,
# id-token:write and attestations:write, so no code we do not control runs here.
# --generate-notes appends the auto-generated notes below the body.
- name: Create GitHub Release
if: inputs.create_release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Autopilot Monitor Agent ${{ steps.version.outputs.version }}
body: |
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
RELEASE_NAME: Autopilot Monitor Agent ${{ steps.version.outputs.version }}
RELEASE_PRERELEASE: ${{ inputs.prerelease }}
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
RELEASE_BODY: |
## Autopilot Monitor Agent ${{ steps.version.outputs.version }}
### Installation
@@ -344,9 +361,22 @@ jobs:
```
gh attestation verify AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
```
draft: false
prerelease: ${{ inputs.prerelease }}
files: |
${{ steps.artifact.outputs.versioned_zip_release }}
${{ env.MANIFEST_NAME }}
generate_release_notes: true
run: |
$ErrorActionPreference = 'Stop'
$notesFile = Join-Path $env:RUNNER_TEMP 'release-notes.md'
Set-Content -Path $notesFile -Value $env:RELEASE_BODY -Encoding utf8 -NoNewline
$ghArgs = @(
'release', 'create', $env:RELEASE_TAG,
$env:RELEASE_ZIP, $env:MANIFEST_NAME,
'--repo', $env:GITHUB_REPOSITORY,
'--target', $env:GITHUB_SHA,
'--title', $env:RELEASE_NAME,
'--notes-file', $notesFile,
'--generate-notes'
)
if ($env:RELEASE_PRERELEASE -eq 'true') { $ghArgs += '--prerelease' }
& gh @ghArgs
if ($LASTEXITCODE -ne 0) { throw "gh release create failed with exit code $LASTEXITCODE" }
Write-Host "Release $env:RELEASE_TAG created"
+24 -6
View File
@@ -54,14 +54,32 @@ jobs:
ajv validate -s rules/schema/ime-log-pattern.schema.json -d "$f" --spec=draft2020 -c ajv-formats
done
# guardrails.json values are baked verbatim into generated TypeScript that is
# auto-committed to main and shipped to the portal - the schema forbids control
# and line-terminator characters so a "data" PR can never become code.
- name: Validate Guardrails
run: ajv validate -s rules/schema/guardrails.schema.json -d rules/guardrails.json --spec=draft2020 -c ajv-formats
- name: Combine rules into dist/
run: node rules/scripts/combine.js
# Plain git instead of a third-party action: this step runs with contents:write on
# main, so no code we do not control gets to push. Checkout leaves the job's
# GITHUB_TOKEN in the remote credential helper, which is all `git push` needs.
# NOTE: combine.js writes THREE outputs; committing only dist/ would silently discard
# the two generated TS modules and leave the guardrails-in-sync CI check red.
- name: Commit generated outputs
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "chore: regenerate rules/dist + generated TS from individual files"
# combine.js writes THREE outputs; committing only dist/ would silently discard
# the two generated TS modules and leave the guardrails-in-sync CI check red.
file_pattern: "rules/dist/* src/Web/autopilot-monitor-web/utils/guardrails.generated.ts src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts"
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -- rules/dist/ \
src/Web/autopilot-monitor-web/utils/guardrails.generated.ts \
src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts
if git diff --cached --quiet; then
echo "Generated outputs unchanged - nothing to commit"
exit 0
fi
git commit -m "chore: regenerate rules/dist + generated TS from individual files"
git push origin HEAD:main
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
- uses: actions/checkout@v7
- name: Login to Azure (OIDC)
uses: azure/login@v3
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -65,7 +65,7 @@ jobs:
run: dotnet publish ${{ env.CSPROJ_PATH }} --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }}
- name: Deploy to Azure Functions
uses: Azure/functions-action@v1
uses: Azure/functions-action@c5060b3b8bb1ebbcb531abd00c822ecbaa8ea656 # v1
with:
app-name: ${{ env.FUNCTION_APP }}
package: ./publish
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
run: echo "sha=$(git -C mcp-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Login to Azure (OIDC)
uses: azure/login@v3
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+75 -60
View File
@@ -1,5 +1,13 @@
name: Deploy Web (Static Web App)
# Build here, upload with the pinned SWA CLI. No third-party action sees the deploy token
# (the CLI is Microsoft-published npm code at an exact version), the build runs on the same
# path ci.yml tests (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the
# action's container, and the exported site is attested like the agent ZIP and the MSI.
#
# PR preview environments were dropped with the action: their per-PR URLs are never
# registered as redirect URIs, so nobody could log in on them anyway.
on:
push:
branches:
@@ -8,39 +16,44 @@ on:
- 'src/Web/autopilot-monitor-web/**'
- '.github/workflows/deploy-web.yml'
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened, closed]
branches:
- main
paths:
- 'src/Web/autopilot-monitor-web/**'
- '.github/workflows/deploy-web.yml'
permissions:
contents: read
id-token: write # keyless Sigstore signing (artifact attestation)
attestations: write # store the provenance attestation
env:
PORTAL_VERSION_URL: https://portal.autopilotmonitor.com/version.json
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
WEB_DIR: src/Web/autopilot-monitor-web
# Exact version: `npx --package` resolves from the registry at run time, so the pin is the
# only thing that keeps the deploy step's code fixed. Bump deliberately.
SWA_CLI_VERSION: 2.0.10
jobs:
build_and_deploy_job:
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.action != 'closed')
runs-on: ubuntu-latest
name: Build and Deploy Job
# Dependabot PRs run without repo secrets, so the deploy token is absent.
# Surface it as an env var so the deploy step can gate on it — secrets
# cannot be referenced directly in an `if:` expression.
env:
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
steps:
- uses: actions/checkout@v7
with:
lfs: false
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
- name: Build And Deploy
id: builddeploy
# Skip (green) instead of failing when the token is missing (Dependabot
# PRs); real deploys on main and internal PRs still have it.
if: env.DEPLOY_TOKEN != ''
uses: Azure/static-web-apps-deploy@v1
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
cache-dependency-path: ${{ env.WEB_DIR }}/package-lock.json
- name: Install dependencies
working-directory: ${{ env.WEB_DIR }}
run: npm ci
# next.config output:'export' -- `next build` writes the static site to out/
# (prebuild: sync-error-codes, generate-lastmod, generate-version).
- name: Build
working-directory: ${{ env.WEB_DIR }}
env:
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
# Dual app-reg window: previous app registration (unset = single-app behaviour)
@@ -55,28 +68,50 @@ jobs:
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
# the verify step below polls the deployed file for exactly this commit.
GITHUB_SHA: ${{ github.sha }}
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
action: "upload"
###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
app_location: "src/Web/autopilot-monitor-web" # App source code path
api_location: "" # Api source code path - optional
# Static export (next.config output:'export'): Oryx runs `npm run build`
# and uploads the exported out/ directory as plain static files — no
# managed SSR runtime is provisioned anymore. (No is_static_export
# input on this action version; Oryx detects the export from
# next.config + output_location.)
output_location: "out" # Built app content directory - optional
###### End of Repository/Build Configurations ######
run: |
npm run build
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
# The SWA action reporting success proves the upload, not that the production host
# serves the new build — verify against the live /version.json (Cache-Control:
# no-store via staticwebapp.config.json) before publishing the version manifest.
# PR builds deploy to per-PR preview environments, so only main deploys are verified.
# One archive of the exact bytes that get uploaded, so the attestation has a single subject.
- name: Package export
id: package
run: |
archive="portal-${GITHUB_SHA::7}.tar.gz"
tar -C "$WEB_DIR/out" -czf "$archive" .
echo "archive=$archive" >> "$GITHUB_OUTPUT"
sha256sum "$archive"
# Sigstore keyless provenance: binds the export digest to this workflow run (repo, ref,
# commit, workflow file) in a public transparency log.
# Verify: gh attestation verify portal-<sha>.tar.gz --repo okieselbach/AutopilotMonitor
- name: Attest build provenance (portal export)
uses: actions/attest-build-provenance@v4
with:
subject-path: ${{ steps.package.outputs.archive }}
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: portal-${{ github.sha }}
path: ${{ steps.package.outputs.archive }}
# Upload only -- out/ is already built. --swa-config-location points at the app root,
# where staticwebapp.config.json lives (next export does not copy it into out/).
- name: Deploy to Static Web App
env:
SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
run: |
npx --yes --package "@azure/static-web-apps-cli@${SWA_CLI_VERSION}" swa deploy \
--app-location "$WEB_DIR" \
--output-location out \
--swa-config-location "$WEB_DIR" \
--env production \
--no-use-keychain
# The CLI reporting success proves the upload, not that the production host serves the
# new build -- verify against the live /version.json (Cache-Control: no-store via
# staticwebapp.config.json) before publishing the version manifest.
- name: Verify deployed portal build is live
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
shell: pwsh
run: |
$expectedCommit = '${{ github.sha }}'.Substring(0, 7)
@@ -100,10 +135,9 @@ jobs:
throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
# Cross-component deploy record, written last and only on a verified deploy —
# Cross-component deploy record, written last and only on a verified deploy --
# same contract as versions/backend.json and versions/mcp.json.
- name: Publish version manifest
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
shell: pwsh
run: |
$manifest = [ordered]@{
@@ -119,22 +153,3 @@ jobs:
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
Write-Host "Published versions/web.json"
Write-Host $manifest
close_pull_request_job:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
name: Close Pull Request Job
env:
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
steps:
- name: Close Pull Request
id: closepullrequest
if: env.DEPLOY_TOKEN != ''
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
action: "close"
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
name: Federated login + read check
steps:
- name: Login to Azure (OIDC)
uses: azure/login@v3
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+38 -1
View File
@@ -4,7 +4,7 @@ title: CMTrace Time Resolution — Per-Line Self-Anchoring
description: How the agent converts bias-less CMTrace local times to UTC without trusting any process's timezone belief — each provably fresh line anchors its own offset against the agent clock on the 15-minute grid; everything not provably fresh falls back uniformly and says so.
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime
tags: [agent, ime, cmtrace, timestamps, timezone, calibration, provenance]
timestamp: 2026-08-20
timestamp: 2026-08-30
---
# Problem
@@ -86,6 +86,40 @@ with the process zone).
* Mixed pairs remain possible across a restart (start resolved from backlog fallback,
completion line-anchored) and are recognizable via provenance.
# Line parsing (bounded cost)
`CmTraceLogParser.TryParseLine` (Shared) is the single CMTrace line parser for the agent
(`ImeLogTracker`, `LogParserCollector`, `StallProbeCollector`) and the backend
(`TestLogPatternFunction`). Both feed it input an outsider influences: tenant-supplied
sample lines (200 x 8 KB per request) on the backend, and on the agent every tailed log
file, including entries that IME assembles from installer or script output. It is
therefore not one regex. The former shape — a greedy `(?<message>.*)` in front of the
`]LOG]!><time="` literal, unanchored, no match timeout — re-tried every `<![LOG[`
occurrence as a fresh start and backtracked the message group across every later character
for each: quadratic in line length (an 8 KB line of repeated prefixes and `]` filler cost
~7 million engine steps; a 1 MB assembled entry would have spun the SYSTEM agent for
hours and blacked out IME tracking for the rest of the enrollment).
The parser reproduces exactly what that regex returned, in linear time: the message begins
after the first `<![LOG[` (a later start could only match when the first one does, since
`.*` spans the difference) and ends at the last `]LOG]!><time="` occurrence whose trailer
parses — earlier occurrences are tried in turn, which is the order greedy backtracking
visited them. The trailer is checked with a `\G`-anchored regex that has no ambiguous
quantifier and a 1-second match timeout as a backstop; a timeout means "not a CMTrace
line" (callers already have that path: raw-text matching in the tracker, `parse_failed`
on the backend), never an exception. `CmTraceLogParserTests` pins the equivalence against
the legacy regex over the shapes that matter (multiline messages, bias suffix, trailer
literal inside the message, a last trailer that fails to parse, BOM prefix) and the
reported worst case at 200 lines.
On the agent, `ImeLogTracker` additionally caps an assembled multiline entry at 100 raw
lines **and** 1 MB of characters (`MaxMultiLineBufferChars`). A capped entry is dropped
and logged at **Warning** — visible at the default `Info` level, so a real IME entry that
large shows up in the client log and can be acted on. The remaining physical lines of a
dropped entry are skipped until it closes or a new `<![LOG[` entry begins; previously they
fell through to raw-text pattern matching, so fragments of a discarded entry could fire
patterns without CMTrace context.
# Backend regression tripwire
The backend watches for the failure mode this design eliminates. On every terminal ingest
@@ -129,3 +163,6 @@ precisely so an anchoring regression can never surface as a customer finding.
* `tasks/todo.md` (2026-08-20) — full evidence trail: field measurements, the revert,
the two-writer-era fixture, and the disproven foreign-binary hypothesis (the SHA
mismatch was the known release race against the 5-minute AdminConfiguration cache).
* `CmTraceLogParser.TryParseLine` + `CmTraceLogParserTests` — the linear parser and its
legacy-regex equivalence oracle; `ImeLogTracker.LogProcessing.cs` multiline buffering
(`MaxMultiLineBufferLines` / `MaxMultiLineBufferChars`, `skippingDroppedEntry`).
+24 -1
View File
@@ -8,7 +8,7 @@ tags:
- decision-engine
- reducer
- completion
timestamp: 2026-07-11T00:00:00+02:00
timestamp: 2026-08-30T00:00:00+02:00
---
# V2 Agent — Decision Engine (DecisionCore)
@@ -175,6 +175,20 @@ advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`:
scheduler contract) dead-ends as `realmjoin_timeout_stale_superseded_by_rearm`.
The idle case (detected, no deployment activity ever) times out at 60 min
unchanged, and phase 200/210 activity never extends.
**Self-update observation** (gktatooine session 946ccbd6): the version on
`RealmJoinDetected` is read once from `RealmJoin.exe`, but the tray's `AppUpdater`
often replaces the MSI-shipped binary on its first start — minutes after detection,
before the deployment begins. `RealmJoinWatcher` therefore arms a
`RegNotifyChangeKeyValue` watcher on `HKLM\...\Uninstall\RealmJoin` (the only
registry write of the updater; appearance-watch on the `Uninstall` parent when the
entry does not exist yet) once Detected fired, re-reads the binary version after a
2 s debounce (registry `DisplayVersion` as fallback while the file is locked) and
posts the typed `RealmJoinAutoUpdateDetected` signal plus the
`realmjoin_autoupdate_detected` timeline event when the version differs from the
baseline. The reducer overrides `RealmJoinFacts.ProductVersion` / `ReleaseChannel`
(`WithVersionOverride` — the one legitimate later write past the set-once helpers);
it never touches the gate, the deadline, or `LastActivityUtc`. An unreadable
baseline at detection is seeded silently by the first successful re-read.
A closed gate also outranks the 30-minute `AdvisoryCompletion` backstop: that
handler re-arms (trigger suffix `:CompletionGateHolding`) instead of failing while
the RealmJoin gate is closed *and* its own deadline is still armed, because the gate
@@ -208,6 +222,15 @@ IME false-positive AccountSetup entries.
- **No silent parking**: a non-terminal post-AccountSetup session must always hold a
resolution-capable deadline; a 10-min dwell tripwire emits
`session_parked_without_deadline`.
- **Every payload-derived string stored into state is bounded** (`FactStringBounds`,
256 chars): RealmJoin package id/version/scope (scope normalised to `machine`/`user`),
failed app ids, `HelloOutcome`, `ImeMatchedPatternId`, `EspAdvisoryFailureCategory`,
RealmJoin product version/channel. The bound is applied *before* any dedupe comparison
so a forged multi-hundred-KB key neither costs a full-length compare per stored row nor
lands in snapshots/audit trails. It never truncates real telemetry — the sources are
registry key names (Windows caps them at 255), GUIDs and enum-like strings. The same
engine replays device-uploaded signals server-side (`ReducerVerifier`), which is why
the bound lives in the reducer, not in the agent adapter alone.
# Hosting & threading (inside the agent)
+50 -6
View File
@@ -1,10 +1,10 @@
---
type: concept
title: Diagnostics Package
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, caps, and the package manifest.
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, the handle-validated reads that close the enumerate-then-open junction race, caps, and the package manifest.
resource: agent
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal]
timestamp: 2026-08-23
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal, security]
timestamp: 2026-08-30
---
# Schema
@@ -90,10 +90,50 @@ validates **admin-typed** paths: allowlisted prefixes, wildcard only in the last
`C:\Users` blocked except the token's `AppData\Local|Roaming`. The catalog is reviewed code,
exactly like the five sections that never went through the guard before — and
`%ProgramData%\RealmJoin` is not on the allowlist at all. What does apply to every section:
the reparse-point skip (files and directories), the `.evtx` channel export, and the caps
the handle-validated read (next section), the `.evtx` channel export, and the caps
(100 MB per file, 500 MB total, 5000 files) tracked globally across sections by
`BudgetTracker`, with every skip recorded in the manifest and `_TRUNCATED.txt`.
## Reads are validated on the handle, not the path
Every guard above judges a path string. Reading the bytes from that string later —
`File.GetAttributes` on the candidate, then a `FileStream` open after earlier files have been
compressed — leaves a window: under any folder a local user can write to (the user-profile
token paths, `ProgramData` subtrees, `C:\Install\Log`) that user can turn a subdirectory into
a junction in between. An attribute check on the file resolves through a junction in a
*parent* component, and SYSTEM would copy the junction target into the package.
`PinnedSourceFolder` (`Monitoring/Runtime/PinnedSourceFolder.cs`, Win32 declarations in
`Monitoring/Interop/FileNativeMethods.cs`) closes the window by deciding on the handle that
is read:
* `AddLogFiles` pins the section folder once. It is opened with
`FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS`, must not be a reparse point
itself, and its `GetFinalPathNameByHandle` result — every reparse point resolved — must
equal the validated lexical path (8.3 names expanded through `GetLongPathName`, so a
`%TEMP%`-style short name is not mistaken for a junction). Anything else skips the section
with `FOLDER REJECTED (<reason>)`; a missing folder stays `FOLDER MISSING`. The handle is
held, with `FILE_LIST_DIRECTORY` access and no delete share, until the section is done:
NTFS refuses to rename or delete a directory while it or anything beneath it is open, so
the validated chain cannot be swapped underneath the build either.
* Every candidate file is opened by `PinnedSourceFolder.TryOpenFile` with
`FILE_FLAG_OPEN_REPARSE_POINT` — a symlink at the final component is opened as itself and
refused (`SKIPPED (reparse point)`) — and the handle's final path must be exactly
`<canonical folder>\<relative path>`. A subdirectory that became a junction after
enumeration resolves elsewhere: `SKIPPED (resolved outside validated folder)`, naming only
the requested path in the manifest, never the resolved one. The length charged against the
caps and the stream copied into the archive come from that same handle; any other open
failure is `FAILED (open): <path>: <reason>`.
* `CollectFilesNoReparseDirs` still skips reparse-point directories while enumerating — as a
candidate filter and to keep the walk out of junction loops — but nothing it decides is
trusted at copy time.
* The `.evtx` channel export is the agent's own file under an unguessable name in SYSTEM's
temp folder and is streamed as-is. Hard links are out of scope: Windows refuses to create
one to a file the caller cannot write.
The race itself is pinned by `DiagnosticsPackageServiceTests`: the `BeforeSourceFileOpen`
seam swaps a subdirectory for a junction after enumeration and before the open, and the
archive must not carry the file.
## Configured paths: global ∪ tenant
`GetAgentConfigFunction.MergeDiagnosticsLogPaths` sends global paths (Global Admin,
@@ -120,6 +160,8 @@ same-named folder share one ZIP folder.
catalog ids; `sectionFolderOverrides` covers any other section; `devicePreparationProbe`
replaces the registry read (seam callers default to "not WDP", so existing rigs never touch
the registry). `UserProfileResolver.SetForTesting(path|null)` / `Reset()` drive the token.
`BeforeSourceFileOpen` fires with each candidate path after enumeration and before its open
— the point at which the junction-race test swaps the directory.
* Catalog invariants live in the backend test project (`DiagnosticsBuiltInSectionsTests`),
because Shared has no test project.
@@ -139,9 +181,11 @@ entry is removed.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsBuiltInSections.cs` — the catalog
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, caps, evtx export
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, `AddLogFiles` / `AddLogFile` / `OpenSource`, caps, evtx export
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/PinnedSourceFolder.cs` — pinned folder handle, `TryOpenFile`, `SourceFile`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/FileNativeMethods.cs` — `CreateFileW`, `GetFileInformationByHandle`, `GetFinalPathNameByHandleW`, `GetLongPathNameW`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs` — `EnableRealmJoinWatcher` mirror
* `src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs` — `GET diagnostics/paths`
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs` — `MergeDiagnosticsLogPaths`
* `src/Web/autopilot-monitor-web/components/diagnostics/` — `DiagnosticsPathRow`, `BuiltInSectionsList`, `builtInSectionDisplay`
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure, junction swapped in after enumeration, junction as section folder), `PinnedSourceFolderTests` (final-path equality, short names, junction in the chain, symlink at the final component, rename refused while pinned), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
@@ -0,0 +1,141 @@
---
type: concept
title: IME Log Tracker — Anchored Patterns and the Bounded Read Loop
description: Why every shipped IME log pattern starts with '^', why the on-device matcher reads lines through a byte-bounded reader with a per-line matching budget, and what an operator sees when a hostile log file is tailed — the IME Logs folder is writable by standard users and the SYSTEM tracker must stay linear on whatever lands there.
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime
tags: [agent, ime, regex, redos, log-tracker, reliability, security]
timestamp: 2026-08-30
---
# Problem
`ImeLogTracker` tails every file in `%ProgramData%\Microsoft\IntuneManagementExtension\Logs`
whose name matches the IME log families and matches each entry against every active pattern of
the shipped pack (`rules/ime-log-patterns/*.json` → `rules/dist/ime-log-patterns.json`, embedded
in agent and backend, delivered live through the config channel). That folder is writable by a
standard user, so the input is untrusted: anyone signed in during enrollment can append lines.
Before 2026-08-30 three things multiplied:
* **Unanchored patterns with unbounded lazy gaps.** `\[Win32App\]\[EspManager\] … registered.*?App name: (?<name>.*?)$`
re-scans its `.*?` tail at every offset where the literal prefix occurs; repeating the prefix
*m* times in one line of *n* chars costs O(m·n). `IME-APP-VERSION` chained two lazy scans
(`.*?"DetectedIdentityVersion":\{[^}]*?"NewValue"`) and was quadratic even when anchored.
* **Per-pattern timeout, no per-line budget.** Each regex had a 1 s timeout, the timeout was
swallowed at Debug, and the loop tried all ~80 active patterns for every line — one crafted
line concatenating several trigger prefixes cost several seconds of SYSTEM CPU.
* **Unbounded line length.** `StreamReader.ReadLineAsync` materializes the whole line before
anyone can look at its size; a multi-hundred-MB line is an OutOfMemory in the poll loop, which
retries from the same bookmark every second.
Genuine IME decision signals (app failures, enforcement states, ESP phases, user-session
completion) queue behind that work — a monitoring blackout during the enrollment window.
# Design
## Anchored, linear pattern pack
* **Every shipped pattern starts with `^`.** The tracker compiles with
`IgnoreCase | Compiled | Singleline` and no `Multiline`, so `^` is the start of the matched
string — `CmTraceLogEntry.Message`, i.e. the text after `<![LOG[` (for multiline entries the
assembled entry, still offset 0). .NET treats a leading `^` as a beginning anchor: a
non-matching pattern costs O(prefix), never a scan. Evidence for the invariant: across eight
real diagnostics sets (~9,000 entries, all six log families) every match of the old pack began
at offset 0 — except one class of mid-line hits that were false positives (below).
* **No chained unbounded scans.** `IME-APP-VERSION` now spells the report-delta JSON out
(`\{(?:"OldValue":[^,}]{0,64},)?"NewValue":"`) instead of scanning `[^}]*?` at every
occurrence. The test `ImeLogPatternPackLinearityTests` rejects `[^x]*` shapes outright.
* **Raw lines.** A line that fails CMTrace parsing (orphaned continuation lines, lines after a
dropped entry) is still matched raw; with `^` it matches only if it *begins* with the pattern.
Field data: the unanchored `PS-SCRIPT-CONTEXT` fired 122 times on
`[Win32App] SideCarScriptDetectionManager Launch powershell executor in machine session`
(Win32 detection scripts in AppWorkload.log), each time overwriting the pending *platform*
script's `RunContext`. Anchoring removed that contamination; it is asserted by test.
* **Custom patterns are not rewritten.** Tenant-authored patterns (and cached pre-anchor
configs) keep their semantics; `CompilePatterns` names the enabled patterns without `^` once
at Info so a pass summary's budget breaks can be attributed.
## Bounded read loop (`CheckLogFilesAsync`)
* **`BoundedLineReader`** replaces `StreamReader`: byte-oriented, splits on `\n`, strips one
`\r`, decodes UTF-8 per line, honours a BOM only at absolute file offset 0. A line beyond
`MaxEntryBytes` (32 MB) is never materialized — the reader keeps the capped prefix, discards
the rest while scanning for the terminator and flags `LastLineTruncated`. Its `Position` is
the exact offset of the next unread byte, so bookmarks are exact (the StreamReader's decoder
read-ahead used to lose up to a buffer of lines on a cancelled pass).
* **Why 32 MB.** Real logs carry single lines of ~110 KB routinely
(`IntuneManagementExtension.log` policy dumps) and one large tenant's `AppWorkload.log`
writes a 5.6 MB `Get policies = […]` line that `IME-POLICIES` legitimately captures (app
names for the dashboard). A "few KB" cap would drop genuine entries; with anchored patterns
the CPU cost of a large line is O(n) for the handful of patterns whose prefix matches, so the
cap only has to bound memory. The same constant caps an assembled multiline entry.
* **Oversized lines are dropped, not matched.** Captures on a cut line would be wrong. If the
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 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
lasts until the entry completes or the file has stood still for `HeldTailSettle` (1 s), and
only for tails up to 1 MB — a never-closing hostile tail costs a handful of re-reads, then is
processed as-is and the bookmark advances.
* **One Warning per pass and file.** `oversizedLines`, `regexTimeouts`, `lineBudgetBreaks` and
the first skipped pattern ID are logged in a single line after the pass when any is non-zero
(fail-soft with a trace); the first timeout per pattern per process additionally names the
pattern at Debug. No per-line logging, so a hostile file cannot flood the agent log.
* **Deliberately not done: per-file preemption.** The first pass over a legitimate
`AppWorkload.log` can be hundreds of MB and runs in one pass by design; after the changes
above the work per byte is linear and small, so a writer can only delay processing in
proportion to the bytes it writes.
## Pack hygiene against the decompiles (2026-08-30)
Every shipped pattern's distinctive phrase was checked in the IME decompiles 1.97.107.0,
1.104.102.0 and 1.105.103.0 (`ime-decompiles` repo): no wording changed in any of them. The 11
disabled legacy patterns whose literals last existed in IME 1.50 (fleet minimum 1.97) were
deleted rather than left as re-enable traps; `IME-DO-TIMEOUT-1/2` now match the exact source
literal (`[Win32App DO] DO downloading is not finished within timeout, jobId = …, fileId = …`)
instead of two lookaheads; `IME-DO-TEL` has no emitter from 1.104 on and stays only while
1.101/1.103 devices are in the fleet (`get_ime_version_history`).
## Observability (2026-08-30)
Everything the loop skips is counted (`ImeTrackerHealth`: lines read, entries matched, oversized
lines, regex timeouts, budget breaks, held tails, unanchored patterns, files tailed, backlog
bytes) and, together with a per-pattern match histogram, persisted in `ImeTrackerState`. The
periodic `agent_metrics_snapshot` carries the counters as `ime_*` keys, the first pass that
skipped work raises the one-shot `ime_tracker_degraded` Warning (immediate upload), and the
termination handler emits `ime_pattern_hits` — the input of the backend's pattern-drift loop
([IME Pattern Health](../backend/ime-pattern-health.md)).
# Verification
* `ImeLogPatternPackLinearityTests` loads the shipped pack, asserts the `^` invariant, runs four
hostile constructions (repeated prefix, the reported `"DetectedIdentityVersion":{x`
repetition, long word runs, prefix+keywords with newlines; 512 KB each) against every pattern
with the agent's exact regex options and fails on a timeout or > 150 ms (best of three),
and re-matches representative real lines with their captures.
* `BoundedLineReaderTests`, `ImeLogTrackerReadLoopHardeningTests` (oversized single line,
oversized multiline opener, held-back tail, settle, cancelled pass bookmark, single Warning).
* Parity: the old and new packs were run over eight real diagnostics sets with the tracker's
assembly semantics; the match sets are identical apart from the removed `PS-SCRIPT-CONTEXT`
false positives. `--run-ime-matching` (the agent's offline matcher) now assembles multiline
entries like the tracker so it reproduces the same result.
# Citations
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs` — `CheckLogFilesAsync`, `MatchLine`, `HoldBackTail`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/BoundedLineReader.cs`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs` — `MaxEntryBytes`, `HeldTailSettle`, `CompilePatterns`
* `rules/ime-log-patterns/*.json`, `rules/scripts/combine.js`
* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeLogPatternPackLinearityTests.cs`
* [CMTrace Time Resolution](cmtrace-time-resolution.md) — the linear CMTrace line parser this loop feeds
+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).
+139
View File
@@ -0,0 +1,139 @@
---
type: Concept
title: Local Admin Analyzer — account inventory, dormant accounts and Administrators membership
description: What the local_admin_analysis event asserts about a device at enrollment start and completion, why disabled accounts are inventoried (a /active:no backdoor re-enabled after enrollment is dormant at both scans), how Administrators-group membership is read from the SAM by well-known SID (NetLocalGroupGetMembers, independent of WMI and account state), why built-ins are matched by well-known RID (localized Guest/Administrator), why the shutdown-time dynamic allowance never covers a local SAM account (CWE-807 — the login state is attacker-controlled), which folders are not profiles, which members are scored versus only listed, and why the built-in Administrator's enabled state is reported but not scored.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs
tags:
- agent
- security
- local-admin
- analyze-rules
- bypass-detection
timestamp: 2026-08-30T00:00:00+02:00
---
# Local Admin Analyzer
`LocalAdminAnalyzer` emits one `local_admin_analysis` event at agent startup and one at
shutdown. The pair is a before/after inventory of local accounts on the device; the backend
rule `ANALYZE-ID-002` alerts when the finding is `unexpected_local_admins_detected`.
The threat model is the classic Autopilot bypass: a person with a Shift+F10 SYSTEM console
during OOBE/ESP who creates a persistent local administrator.
# Schema
## Checks
| Check | Source | What it yields |
|---|---|---|
| `bypass_nro` | `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE\BypassNRO` | value + `flagged` |
| Local accounts | WMI `Win32_UserAccount WHERE LocalAccount = True` (Name, Disabled, SID) | `accounts_checked`, `account_details` (name / disabled / administrators_member), `unexpected_accounts`, `builtin_administrator_enabled` |
| Administrators membership | `NetLocalGroupGetMembers` level 2 on the group resolved from `S-1-5-32-544` (`LocalGroupNativeMethods`) | `administrators_group.{enumerated,error_code,members}`, `unexpected_admin_members` |
| Profile folders | `C:\Users` top-level directories, junctions and `$<32 hex>` folders skipped | `profiles_found`, `unexpected_profiles` |
## Allowed list
Built-in names (`Administrator`, `Guest`, `DefaultAccount`, `WDAGUtilityAccount`,
`defaultuser0/1/2`, `kioskUser0` — the Assigned Access autologon account of kiosk profiles —
and the non-account folders `Public`, `Default`, `Default User`, `All Users`) plus the
tenant's *Allowed Local Accounts* (glob `*`/`?`, case-insensitive).
`defaultuser1/2` stay allowlisted deliberately: they do appear during OOBE enrollment.
**Well-known RIDs.** Built-in accounts are additionally recognised by RID on the machine SID
(`S-1-5-21-…-500` Administrator, `-501` Guest, `-503` DefaultAccount, `-504`
WDAGUtilityAccount), for the WMI inventory and for the SAM-member fallback alike. Localized
installs name them `Gast`, `Invité`, `Гость`, `Administrateur`, … — disabled by default and
therefore invisible before disabled accounts were inventoried; without the RID match every
non-English device would flag its own Guest. Name matching is unchanged (an ordinary-RID
account named `Gast` is still unexpected).
## Dynamic allowance at shutdown
The shutdown run adds the logged-in users (explorer.exe owners outside session 0) to the
allowed list — but **only names that are not a local SAM account**. The WMI inventory is read
first; a logged-in name found in it is rejected for both the account and the profile check
and reported as `logged_in_local_accounts` (Warning in the agent log).
Why this is safe for false positives: in real sessions the logged-in identity is an Entra or
domain user (user-driven, hybrid, or an Entra user signing in to a self-deploying device
after ESP). Those never appear in `Win32_UserAccount`; the allowance only ever mattered for
their `C:\Users` folder — and `unexpected_profiles` alone scores 0. A logged-in *local*
account is the one case the allowance changed the score, and that is the attack: an account
created after the startup scan that signs itself in before the shutdown scan (CWE-807 —
exempting on a condition the adversary controls). Kiosk autologon (`kioskUser0`) is covered
by the built-in list instead.
## Profile folders that are not profiles
Junctions (`All Users`, `Default User` and localized variants such as `Все пользователи`,
all pointing at ProgramData / Default) and `$<32 hex>` folders are skipped entirely — neither
can be the profile of a local account (a SAM account name is at most 20 characters), so they
can never contribute to the profile-overlap score; listing them was payload noise.
## Evaluation (`EvaluateAccounts`, pure)
* Every local account is evaluated, **disabled ones included**; the disabled state is reported
per account instead of excluding the account.
* An account is an Administrators member when its SID matches a group member, or — SID
unavailable — its name matches a member of the local machine domain.
* `unexpected_accounts` = accounts not matching the allowed list (any state).
* `unexpected_admin_members` = unexpected accounts holding Administrators membership, plus
local-domain members not on the allowed list that WMI did not return at all (WMI failure
or provider gap — the SAM is authoritative for membership).
* Members outside the machine domain (Entra role SIDs such as `S-1-12-1-…` on Entra-joined
devices, domain groups on hybrid devices, unresolved/deleted SIDs) are **listed for delta
comparison but never scored** — they are expected on joined devices.
* `builtin_administrator_enabled` = `!Disabled` of the RID-500 account (null when absent).
Reported, not scored: whether OOBE enables it transiently is not established; the field
collects evidence first.
## Scoring
| Signal | Weight |
|---|---|
| `BypassNRO = 1` | +20 |
| any `unexpected_accounts` | +40 |
| any `unexpected_admin_members` | +40 |
| unexpected account with a matching `C:\Users` folder | +40 |
Capped at 100. `0` → Info `no_unexpected_admins_detected`; `<40` → Info
`bypass_nro_flag_only`; `40–79` → Warning, `≥80` → Error, both
`unexpected_local_admins_detected`. A dormant backdoor with admin membership therefore lands
at 80 (Error) without ever having a profile folder.
## Failure behaviour
WMI and the SAM read are independent; either failing is logged at Warning with the native
status (`NET_API_STATUS=…`) and the other source still evaluates. The event carries
`administrators_group.enumerated=false` + `error_code` so a "no members" result is never
mistaken for an empty group.
# Examples
`net user backdoor P@ss /add /active:no` + `net localgroup Administrators backdoor /add`
during OOBE, re-enabled by a scheduled task after enrollment: at both scans the account is
disabled; before this change it was skipped entirely. Now `unexpected_accounts=[backdoor]`,
`unexpected_admin_members=[backdoor]`, `account_details[backdoor].disabled=true` →
confidence 80, Error.
`net user backdoor P@ss /add` + `net localgroup Administrators backdoor /add` during ESP,
then an interactive sign-in as `backdoor` (fast user switching) before enrollment completes:
the shutdown scan finds `backdoor` in the SAM inventory → not allowed dynamically →
`unexpected_accounts=[backdoor]`, `unexpected_admin_members=[backdoor]`, profile overlap →
confidence 100, Error, `logged_in_local_accounts=[backdoor]`. Before this change the login
alone silenced the finding to 0.
German device, tenant allowed list empty: `Gast` (RID 501, disabled) and `Administrator`
are inventoried and matched by RID → `unexpected_accounts=[]`, confidence 0.
`net localgroup Administrators adm-helpdesk /add` where `adm-*` is allowlisted: not flagged
(the allowed list is the tenant's statement of intent), but `administrators_group.members`
shows the new membership in the shutdown event for delta review.
# Citations
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/LocalGroupNativeMethods.cs`
* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerEvaluateAccountsTests.cs`
* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerDynamicAllowanceTests.cs`
* `rules/analyze/ANALYZE-ID-002.json`
+25 -6
View File
@@ -1,7 +1,7 @@
---
type: Concept
title: Admin Identity Binding — tid + oid behind every cross-tenant-role UPN
description: Why a GlobalAdmins or DelegatedAdmins row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant, the AdminIdentityBindings table that pins each such UPN to its immutable home tenant id and object id, the fail-closed resolution order in both role services, the first-sign-in object-id pin, and the explicit rebind surface.
description: Why a GlobalAdmins, DelegatedAdmins or McpUsers row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant, the AdminIdentityBindings table that pins each such UPN to its immutable home tenant id and object id, the fail-closed resolution order in the role services and the MCP whitelist, the first-sign-in object-id pin, and the explicit rebind surface.
resource: src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs
tags:
- backend
@@ -10,7 +10,8 @@ tags:
- multi-tenant
- global-admin
- delegated-admin
timestamp: 2026-08-29T00:00:00+02:00
- mcp
timestamp: 2026-08-30T00:00:00+02:00
---
# Problem
@@ -83,10 +84,28 @@ winner). Two accounts racing the first sign-in inside the home tenant therefore
Row lookups stay cached by UPN; the binding has its own 30 s cache keyed by UPN and is invalidated on
pin, rebind and removal. A rebind on one scaled-out instance converges on the others within seconds.
## The MCP whitelist is a third UPN-keyed grant
`McpUsers` (PK `McpUsers`, RK = lowercase UPN) is the WhitelistOnly allow-list consulted by
`GET auth/mcp`, which runs under the AuthenticatedUser policy — a token from any Entra tenant reaches it.
Before 2026-08-30 the row was honoured by UPN alone, so a same-UPN identity from another tenant
received `allowed=true` / `accessGrant=McpUser`, inherited the row's `UsagePlan` (a paid quota tier
provisioned for another organization's user), and was also hit by the row's Disabled kill-switch. The
data reach stayed tenant-bound (the MCP server routes a non-global caller on its own JWT `tid`), but the
GA-curated whitelist and the per-user entitlement were bypassable.
`McpUserService` now resolves the whitelist exactly like the role services: row by UPN (cached 30 s) →
no row ⇒ `NotPresent` without touching the binding → `IsBoundAsync(identity)` false ⇒ `NotPresent`. Grant,
usage-plan override (`GetBoundMcpUserAsync`, used by `McpQuotaService.ResolvePlanAsync` and the
self-service usage view) and the Disabled kill-switch all belong to the bound identity only; an unbound
legacy row is inert. `POST global/mcp-users` binds first via the same resolver / 422 / 409 contract as the
other grant endpoints, and the MCP users settings section shows the same home-tenant picker on 422. The
AllMembers path is unaffected — it is tenant-bound by the auth middleware itself.
## Grants bind first — and resolve the identity themselves
Granting is "type the UPN". The three grant endpoints (`POST auth/global-admins`,
`POST global/delegated-admins`, `POST global/tenant-groups/{id}/assignees`) accept `homeTenantId` /
Granting is "type the UPN". The four grant endpoints (`POST auth/global-admins`,
`POST global/delegated-admins`, `POST global/tenant-groups/{id}/assignees`, `POST global/mcp-users`) accept `homeTenantId` /
`objectId` only as optional overrides; normally `AdminIdentityResolver` supplies them:
1. **Sign-in history** (`UserActivity`, filtered on the UPN): the person has signed in before ⇒ tid + oid
@@ -98,7 +117,7 @@ Granting is "type the UPN". The three grant endpoints (`POST auth/global-admins`
tenants) and retries with `homeTenantId`. This is the only manual step, and only for a UPN whose
domain the platform has never seen.
`AddGlobalAdminAsync`, `DelegatedAdminService.UpsertAsync` and `AssignGroupAsync` then take the resolved
`AddGlobalAdminAsync`, `DelegatedAdminService.UpsertAsync`, `AssignGroupAsync` and `AddMcpUserAsync` then take the resolved
`homeTenantId` / `objectId` and call `EnsureBoundAsync` **before** writing the role row. `EnsureBoundAsync` creates the binding, keeps a compatible one (same tenant; the
supplied object id equals the pin or upgrades an unpinned one), and throws
`IdentityBindingConflictException` for a different tenant or a different pinned object id — the
@@ -158,7 +177,7 @@ Same row, binding without an object id: the first `tid=1111…` sign-in pins its
* `src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs` — verdict, pin, grant-time binding, rebind; the table entity.
* `src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityResolver.cs` — sign-in-history / UPN-domain resolution at grant time (`AdminIdentityResolverTests`).
* `src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx` — the per-tenant inspect/correct panel.
* `src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs`, `DelegatedAdminService.cs` — resolution order.
* `src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs`, `DelegatedAdminService.cs`, `McpUserService.cs` — resolution order (`McpUserServiceIdentityBindingTests` for the whitelist matrix).
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs` — `TryPinIdentityObjectIdAsync` and the binding CRUD.
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/IdentityBindingManagementFunction.cs` — the rebind surface and the shared `IdentityBindingRequest.Validate`.
* `src/Backend/AutopilotMonitor.Functions.Tests/AdminIdentityBindingServiceTests.cs`, `AdminIdentityBindingAuthorizationTests.cs` — verdict matrix, race semantics, and the middleware-level denials for foreign-tenant and recycled-UPN tokens.
+21 -2
View File
@@ -9,7 +9,7 @@ tags:
- archive
- blob
- queue
timestamp: 2026-08-29T17:30:00+02:00
timestamp: 2026-08-30T12:00:00+02:00
---
# IME Installer Archive
@@ -22,7 +22,7 @@ on. Two stores:
| Store | Content | Written by |
| --- | --- | --- |
| Blob container `ime-archive` (private, kept by design, not tenant-scoped) | `{version}/IntuneWindowsAgent.msi` (write-once) + `{version}/provenance.json` (derived, overwritable) | `ImeMsiArchiver` via the `ime-msi-archive` queue; `/ime-decompile` skill as manual backfill |
| Table `ImeVersionHistory` (partition `Global`, RowKey = version) | first/last seen, session count, `MsiArchiveStatus`, `MsiArchiveUpdatedAt`, `MsiArchiveBlobPath`, `MsiSha256`, `MsiBytes`, `MsiSourceUrl` | `RecordImeVersionAsync` (sighting), `UpdateImeVersionArchiveInfoAsync` (archive outcome / queued stamp) |
| Table `ImeVersionHistory` (partition `Global`, RowKey = version) | first/last seen, session count, `CorroboratedAt`, `MsiArchiveStatus`, `MsiArchiveUpdatedAt`, `MsiArchiveBlobPath`, `MsiSha256`, `MsiBytes`, `MsiSourceUrl` | `RecordImeVersionAsync` (sighting), `UpdateImeVersionArchiveInfoAsync` (archive outcome / queued stamp) |
# Schema
@@ -45,6 +45,25 @@ older than 24 h (`RequeueBackoff`) or never happened. The ingest path then merge
sessions of that version see "in flight" and do not pile on. Worst case for a version no
host serves any more: one walk over the hosts per day, not one per enrollment.
## Trust boundary of a sighting
The version string is **device-supplied** (any device holding a tenant agent credential can
send an `ime_agent_version` event) and becomes the RowKey of a caller-independent GLOBAL
partition that every tenant reads. Four layers keep one tenant's device from turning a
claim into a platform fact:
| Layer | Where | Effect |
| --- | --- | --- |
| Plausibility guard | `ImeMsiArchiver.IsPlausibleVersion`, called first in `RecordImeVersionAsync` (and again by the archiver) | plain dotted digits, 2–4 components, inside Windows Installer `ProductVersion` bounds (major/minor ≤ 255, build ≤ 65535). Anything else → `ImeVersionSighting.Rejected`: no row, no ops event, no archive job, no re-queue. |
| One sighting per session | `UpdateSessionImeAgentVersionAsync` returns whether the session's stored version changed; only then does the ingest path call `RecordImeVersionAsync` | `SessionCount` moves once per session and version; a re-sent or replayed event, a tombstoned or unregistered session, count nothing. |
| Corroboration | merge path of `RecordImeVersionAsync` stamps `CorroboratedAt` on the first sighting from a tenant other than `FirstSeenTenantId` | the first-seen tenant's own devices can never corroborate their own claim. |
| Fleet confirmation on read | `GetImeVersionHistoryFunction.IsFleetConfirmed` (MemberRead projection only) | tenant members see a version once it is `Archived` (ProductVersion matched a real package), or corroborated, or first seen before `LegacyTrustCutoffUtc` (2026-08-31 — the 16 rows reviewed on 2026-08-30 are all genuine builds). Global scope sees every row, unconfirmed ones included. |
What remains possible for a hostile device: a plausible, never-shipped version creates a
row visible to Global Admins plus one `NewImeVersionDetected` ops event and one archive walk
that ends `Failed:VersionMismatch` (no blob). It is never listed to other tenants and can
never occupy the archive folder of a real build.
## Candidate hosts and the version check
Microsoft distributes IME from several versionless URLs that carry **different rollout
+100
View File
@@ -0,0 +1,100 @@
---
type: concept
title: IME Pattern Health — the pattern-drift loop
description: How a Microsoft change to IME log wording becomes visible before it breaks timelines — the agent's session-end pattern histogram, the global per-IME-version statistics, the data-driven drift alarm, and the operator page/MCP tool that turn the alarm into a repair workflow.
resource: src/Backend/AutopilotMonitor.Functions/Services/Ime
tags: [backend, agent, ime, log-patterns, drift, ops-events, metrics, mcp]
timestamp: 2026-08-30
---
# Problem
The IME log-pattern pack is the product's core signal source, and Microsoft changes IME log
wording without notice. Until now a changed line was invisible until sessions looked wrong:
the agent kept matching nothing, the client log said nothing, and the only alarm was the
registry/log reconciliation for app states. Tracker problems (skipped lines, regex timeouts)
were a client-log Warning nobody sees without pulling diagnostics.
# Design
Three layers, one loop:
## 1. Tracker health in `agent_metrics_snapshot`
`ImeLogTracker` keeps cumulative counters (`ImeTrackerHealth`: lines read, entries matched,
oversized lines, regex timeouts, per-line budget breaks, held tails, unanchored patterns,
files tailed, backlog bytes = Σ file length − bookmark). `AgentSelfMetricsCollector` reads them
through a read-only probe (`ImeLogHost.GetTrackerHealth`) and adds `ime_*` keys to the periodic
snapshot next to the spool queue. Fleet expectation for every skip counter: **0**. The session
page's "IME Tracker" section shows queue depth and skips.
## 2. `ime_tracker_degraded` (Warning, one-shot, immediate upload)
The first pass that skipped work raises `ImeLogTracker.OnTrackerDegraded`; the adapter emits
the event with the counters, file and first skipped pattern. Persisted flag (`ImeTrackerState`)
so a restart cannot re-emit. Listed in the MCP `health_events` catalog; informational for the
idle/stall clocks (`SignalActivityClassifier`).
## 3. `ime_pattern_hits` → `ImePatternStats` → `ImePatternDriftSuspected`
* **Agent.** At termination (same slot as `app_tracking_summary`, so WhiteGlove Part 2 reports
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. 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
`ImeMsiArchiver.IsPlausibleVersion` (event value first, session row as fallback), then one
partition read + one transactional batch upsert into `ImePatternStats`
(PK = version, RK = patternId: `Sessions`, `SessionsWithHit`, `Hits`, `LastHitAt`,
`DriftFlaggedAt`). Permanent table (`TableLifecycleBucketTests` KeptByDesign) — the baseline
needs the established versions.
* **Drift statistic (`ImePatternDriftEvaluator`, pure).** No hard-coded must-hit list.
Baseline = the version with the most reporting sessions (≥ 100, never the candidate).
A pattern is *expected* when its baseline hit rate (`SessionsWithHit / Sessions`) is ≥ 0.8.
Drift is suspected when the candidate has ≥ 25 reporting sessions and an expected pattern
matched in none of them. Conditional patterns (platform/remediation scripts, WinGet, errors)
sit below 0.8 on the baseline and cannot alarm. One alarm per version × pattern:
`TryMarkImePatternDriftFlaggedAsync` (ETag-conditional) then
`OpsEventService.RecordImePatternDriftSuspectedAsync` (Warning, Agent, `System.Ingest`;
dual-registered in the web `OPS_EVENT_TYPES`). The stats snapshot used for evaluation is
cached 10 minutes and patched with each batch, so a version crossing the threshold is judged
promptly.
* **Read side.** `GET metrics/ime-pattern-health` (`GlobalReadOrAdmin`) returns baseline,
thresholds, versions (with `ImeVersionHistory` first/last seen and fleet session count),
patterns (catalog + retired IDs still in the statistics), cells and open alerts. Web:
Global Admin → Metrics → IME Pattern Health (matrix, legend: drift / silent / low / few).
MCP: `get_ime_pattern_health` (GA + Global Reader).
# Workflow on an alert
1. `ImePatternDriftSuspected` names version + pattern; the matrix shows the cell.
2. `search_sessions imeAgentVersion=<version>` → `get_session_diagnostics` on a session with a
package (upload mode must not be Off).
3. `/ime-pattern-validate` against the real log; `/ime-decompile` for the version's sources.
4. Fix the pattern under `rules/ime-log-patterns/` (anchored, linear —
[IME Log Tracker](../agent/ime-log-tracker-matching-budget.md)); Backend-Deploy delivers it
through the config channel without an agent release.
# Limits
* Denominator = sessions that reached a terminal run; a version that only crashes never gets
a histogram and therefore never alarms here (it alarms elsewhere).
* The baseline is fleet-wide: a pattern that only some tenants can produce stays "conditional"
even if it is 100 % within those tenants.
* Thresholds are constants (`ImePatternDriftEvaluator`), tuned for the current fleet size
(~25k sessions/month); revisit when versions roll out faster than 25 sessions per day.
# Citations
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerHealth.cs`, `ImeLogTracker.cs` (`GetHealthSnapshot`, `OnTrackerDegraded`), `ImeTrackerStatePersistence.cs`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs` (`EmitImePatternHits`)
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs`
* `src/Backend/AutopilotMonitor.Functions/Services/Ime/{ImePatternHealthService,ImePatternDriftEvaluator}.cs`
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.ImePatternStats.cs`
* `src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetImePatternHealthFunction.cs`
* `src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionImePatternHealth.tsx`, `imePatternHealthLogic.ts`
* `src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts` (`get_ime_pattern_health`)
@@ -39,7 +39,9 @@ Every table in `Constants.TableNames` must belong to at least one deletion lifec
- Unknown session → `TargetTenantId`, leaving each endpoint's own not-found semantics (404 / empty 200 / 400) in charge.
- Delegated (MSP) callers deliberately get NO fallback — extending it to `AllowedTenantIds` is a pending user decision.
The lookup row is written at session registration (fail-soft), deleted by the cascade one step before the tombstone, and rebuildable from Sessions (not in the critical-backup set). The former lazy triggers (`events.Count == 0` → scan) are gone: empty-but-owned results no longer pay a table scan, and endpoints resolve upfront.
The lookup row is **claimed at registration, first-writer-wins** (`ClaimSessionTenantLookupAsync`, create-only `AddEntity` BEFORE any Sessions/SessionsIndex write): on 409 the existing owner is read back — same tenant ⇒ normal re-registration, different tenant ⇒ `SessionTenantConflictException` ⇒ `409 Conflict` with `ErrorCode=session_owner_mismatch` (owning tenant not disclosed) plus a `SessionTenantConflict` ops event, and nothing is written. The claim is deliberately NOT fail-soft. Before this, the row was an unconditional last-writer-wins upsert after the session write, so any tenant's device (self-service onboarding) that knew a victim session GUID (portal deep links, Teams/Slack notifications, progress-portal URLs) could re-point the mapping at its own tenant and have every no-`tenantId` Global Admin/Reader read — and GA writes such as the `globaladmin` annotation lane — silently served from a forged session. Session ids are 122-bit random, so a genuine cross-tenant collision does not occur; a conflict is an attack or an agent bug and fails loudly. The legacy self-heal write is Add-only too (an existing claim is the authority), and `scripts/Migration/Backfill-SessionTenantLookup.ps1` closes the pre-table window once (idempotent, Add-only) so no legacy session id is claimable. The row is deleted by the cascade one step before the tombstone (no tombstone is kept — a freed id gives a foreign tenant nothing a fresh GUID would not) and is rebuildable from Sessions (not in the critical-backup set). The former lazy triggers (`events.Count == 0` → scan) are gone: empty-but-owned results no longer pay a table scan, and endpoints resolve upfront.
`requireGlobalAdmin: true` additionally corroborates the resolved tenant against its Sessions row (one point-read): a mapping whose session does not exist in the resolved tenant keeps `TargetTenantId`, so a stale or poisoned row can never steer a GA write — silently, without any operator confirmation step (a "confirm tenant GUID" dialog was rejected as unusable). Read paths keep the single point-read; their own session read is the corroboration (404).
Related enforcement added with the migration: `RecomputeTriggerGate` — `?reanalyze=true` and `?rescan=true` on the MemberRead session routes are actions (delete + rewrite stored results) and now 403 for Viewer / Global Reader / cross-tenant tenant roles (Global Admin or own-tenant Admin/Operator only).
@@ -36,6 +36,14 @@ same table) — the only reliable address: it is typed by the user on the activa
page (`PUT /api/preview/notification-email`). The signup admin UPN may have no mailbox and
`ContactEmail` is merely seeded from the notification email.
The PUT is `AuthenticatedUserWithRole`, not a member tier, because the signup user is still
roleless until approval auto-promotes them to Admin. The function re-gates on the resolved
role (`PreviewWhitelistFunction.MayWriteNotificationEmailAsync`): a member role or platform
scope always may write; a roleless caller may write only while the tenant has **no enabled
member** at all. Once any member exists the window is closed for good — the address feeds the
welcome and farewell mails and is shown to operators as the tenant contact, so an ordinary
employee with a JWT but no product role must not be able to redirect or clear it (403).
That makes activation and address entry concurrent writers. With auto-approve, activation
usually wins (observed in production: approve at T+64 s, address saved at T+69 s → the
approval path found no address and the mail was silently skipped).
+7 -3
View File
@@ -19,7 +19,7 @@ https://docs.autopilotmonitor.com (separate repository).
# Agent
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [diagnostics package](agent/diagnostics-package.md) (built-in section catalog shared with the portal, RealmJoin/Device Preparation collection gates, caps, manifest), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [diagnostics package](agent/diagnostics-package.md) (built-in section catalog shared with the portal, RealmJoin/Device Preparation collection gates, handle-validated reads against the junction race, caps, manifest), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
@@ -28,9 +28,13 @@ https://docs.autopilotmonitor.com (separate repository).
* [Zero User-Targeted Apps — the starved user-phase completion gate](agent/zero-user-apps-completion-gate.md) - Why a device with no user-targeted Intune apps could never satisfy the IME user-session gate — IME skips its "Completed user session" line entirely in the zero-app branch — turning the 30-minute completion backstop into `esp_exit_without_completion_evidence` on a third of one tenant's enrollments, with the user at the desktop and RealmJoin still installing. The two fixes: read the line IME does write (IME-USER-SESSION-ZERO-APPS), and never fail a session while a completion gate with its own armed deadline is deliberately holding it.
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
* [CMTrace Time Resolution](agent/cmtrace-time-resolution.md) - Converting bias-less CMTrace local times to UTC without trusting any process's timezone belief: per-line self-anchoring on the 15-minute offset grid for provably fresh lines, uniform marked reader-zone fallback for backlog, and the provenance vocabulary (bias / line-anchored / reader-zone-fallback) that keeps every event re-derivable.
* [CMTrace Time Resolution](agent/cmtrace-time-resolution.md) - Converting bias-less CMTrace local times to UTC without trusting any process's timezone belief: per-line self-anchoring on the 15-minute offset grid for provably fresh lines, uniform marked reader-zone fallback for backlog, and the provenance vocabulary (bias / line-anchored / reader-zone-fallback) that keeps every event re-derivable; plus the linear-cost CMTrace line parser (first open / last parseable trailer, timeout-backed) and the agent multiline caps.
* [IME Pattern Health — the pattern-drift loop](backend/ime-pattern-health.md) - How a Microsoft change to IME log wording becomes visible before it breaks timelines: tracker health counters in `agent_metrics_snapshot`, the one-shot `ime_tracker_degraded` Warning, the session-end `ime_pattern_hits` histogram folded into the global per-IME-version `ImePatternStats` table, the data-driven drift alarm (`ImePatternDriftSuspected`: an expected pattern — ≥80 % on the fleet baseline — matched in none of ≥25 sessions on a newer version), the Global Admin → Metrics → IME Pattern Health matrix and the `get_ime_pattern_health` MCP tool.
* [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.
* [DO GroupId Auto-Set](agent/do-group-id-auto-set.md) - EnableDoGroupIdAutoSet (ConfigVersion 40): the agent seeds the Delivery Optimization DOGroupId policy with a deterministic network-fingerprint GUID (default gateway IPv4 + SendARP MAC, RealmJoin-compatible byte layout) so same-network devices form one DO peering group during ESP; ownership-tracked via the StartupEventGate fingerprint, foreign DOGroupId/DOGroupIdSource policies always win, no random-GUID fallback.
@@ -83,7 +87,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [Vulnerability Correlation Sources](backend/vulnerability-correlation-sources.md) - How the software inventory becomes CVE findings from four cached primaries (NVD per-CPE cache with budget and TTLs, CISA KEV, MSRC, FIRST EPSS stored on the cached CVE and re-scored nightly), the NVD throttle contract (a refused answer is never cached as "0 CVEs"; bounded process-wide cooldown honouring Retry-After), the persisted CVSS vector, and the act/attend/track priority that keeps severity and likelihood as two visible signals instead of one composite score.
* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, the shadow-then-enforce rollout with its volume-controlled telemetry, and why `X-ARR-ClientCert` is the only certificate transport (TLS handshake = proof of possession; bootstrap routes token-only, fail-closed).
* [Admin Identity Binding](backend/admin-identity-binding.md) - Why a GlobalAdmins / DelegatedAdmins row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant (domain re-registration, UPN recycling), the AdminIdentityBindings table pinning each cross-tenant-role UPN to its immutable home tid + oid, the fail-closed resolution order in both role services (row → binding → entitlement), the first-sign-in object-id pin with its race semantics, the 409 on grant-time conflicts, and the explicit GET/PUT/DELETE rebind surface.
* [IME Installer Archive](backend/ime-msi-archive.md) - How every fleet-observed IME build is archived as its MSI: the ime_agent_version sighting that queues the job (first sighting, or a later sighting with a version-authoritative CSP URL after a 24 h backoff), the primary/secondary/hotfix candidate walk, the managed MSI ProductVersion check that keeps another ring's build out of the version folder (Failed:VersionMismatch), provenance with the full candidate walk, and the skill-side overwrite path for corrections.
* [IME Installer Archive](backend/ime-msi-archive.md) - How every fleet-observed IME build is archived as its MSI: the ime_agent_version sighting that queues the job (first sighting, or a later sighting with a version-authoritative CSP URL after a 24 h backoff), the primary/secondary/hotfix candidate walk, the managed MSI ProductVersion check that keeps another ring's build out of the version folder (Failed:VersionMismatch), provenance with the full candidate walk, the skill-side overwrite path for corrections, and the trust boundary of a device-reported version (plausibility guard, one sighting per session, second-tenant corroboration, fleet-confirmed tenant view).
# Conventions for this bundle
+70
View File
@@ -1,5 +1,75 @@
# Log
## 2026-08-30 (15)
* **Update**: `backend/lifecycle-manifests-and-session-scope.md` — SessionTenantLookup is now claimed first-writer-wins at registration (create-only Add BEFORE any session write; foreign owner ⇒ 409 `session_owner_mismatch` + `SessionTenantConflict` ops event, nothing written; not fail-soft). Closes the poisoning path where any tenant's device could re-point a victim sessionId's mapping at its own tenant and hijack every no-tenantId Global Admin/Reader read and GA write (annotation lane). `ResolveSessionScopeAsync(requireGlobalAdmin: true)` corroborates the resolved tenant against its Sessions row (silent — no operator confirmation). Self-heal write is Add-only; `scripts/Migration/Backfill-SessionTenantLookup.ps1` closes the legacy window once. No tombstone on deletion (deliberate: unbounded growth for no gain).
## 2026-08-30 (14)
* **Update**: `agent/decision-engine.md` — new invariant: every payload-derived string stored into `DecisionState` is bounded to 256 chars by `FactStringBounds` before any dedupe comparison (RealmJoin package id/version/scope, failed app ids, `HelloOutcome`, `ImeMatchedPatternId`, `EspAdvisoryFailureCategory`, RJ product version/channel). Backend `ReducerVerifier` now replays decode-fold-discard instead of materialising all decoded signals, and `ISignalRepository.QueryBySessionAsync` carries a cumulative `PayloadJson` budget (`SignalQueryLimits`, 32 M chars) reflected in the `truncated` flag of `sessions/{id}/signals` and `sessions/{id}/reducer-verification`.
## 2026-08-30 (13)
* **Update**: `agent/decision-engine.md` — RealmJoin self-update observation. `realmjoin_detected` reported the MSI-shipped build (4.21.4) although the tray auto-updated to 4.21.18 nine minutes later and ran the whole deployment on it (gktatooine session 946ccbd6). `RealmJoinWatcher` now watches `HKLM\...\Uninstall\RealmJoin` (the updater's only registry write) after detection, re-reads the binary version on change and emits `realmjoin_autoupdate_detected` (`previousVersion`, `newVersion`, `releaseChannel`); the typed `RealmJoinAutoUpdateDetected` signal overrides `RealmJoinFacts.ProductVersion`/`ReleaseChannel` and nothing else. Portal device card shows the updated version with an "auto-updated from" note.
## 2026-08-30 (12)
* **Update**: `web/portal-navigation-prefetch.md` — hosting citation now points at `deploy-web.yml`: the portal is built in CI (setup-node 22, `npm ci`, `next build`) and uploaded with the pinned SWA CLI; the Azure/static-web-apps-deploy action, its Oryx in-container build and the per-PR preview environments are gone. All non-GitHub-owned actions are SHA-pinned; the release and rules auto-commit steps use first-party `gh`/`git`.
## 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`.
## 2026-08-30 (8)
* **Update**: `agent/ime-log-tracker-matching-budget.md` — pattern-pack hygiene against the IME decompiles (1.97.107.0 / 1.104.102.0 / 1.105.103.0, spot checks 1.50 / 1.83): no wording drift in any shipped pattern; the 11 disabled legacy patterns (IME-DETECTED-OLD, IME-SET-CURRENT-1/2/3, IME-SKIP-APPLICABILITY-NOTMET, IME-SKIP-DEPENDENCY-DETECT, IME-SKIP-DETECTED, IME-SKIP-UNINSTALL-NOTDETECTED-2, IME-UPDATE-NAME-1/2/3) are deleted — their literals last existed in IME 1.50, the fleet starts at 1.97; IME-DO-TIMEOUT-1/2 match the exact source literal (`DO downloading is not finished within timeout, jobId = …, fileId = …`) instead of two lookaheads; IME-DO-TEL is documented as dead from 1.104 on and kept only for the 1.101/1.103 fleet share.
## 2026-08-30 (7)
* **New**: `agent/ime-log-tracker-matching-budget.md` — the shipped IME pattern pack is anchored (`^` on all 90 patterns, `IME-APP-VERSION` without the chained `[^}]*?` scan) and the tracker's read loop is bounded: `BoundedLineReader` (32 MB per line, exact byte bookmarks), 2 s per-line matching budget, held-back unterminated EOF tails with a 1 s settle, one Warning per pass (`oversizedLines` / `regexTimeouts` / `lineBudgetBreaks`); the unanchored `PS-SCRIPT-CONTEXT` false positives on `[Win32App] SideCarScript…` lines are gone. `--run-ime-matching` assembles multiline entries like the tracker. Registered in `index.md`.
## 2026-08-30 (6)
* **Update**: `rules/rule-id-namespace.md` — guard 1 rewritten: gather RuleStats (tenant + `global_*` rows) are now resolved against the tenant's active gather catalog (`EventIngestProcessor.ResolveFiredGatherRules`), metadata catalog-sourced, unknown IDs dropped — same design as the analyze path; the `IsReservedBuiltInId` pattern gate on device-supplied IDs is gone.
## 2026-08-30 (5)
* **Update**: `backend/admin-identity-binding.md` — new "The MCP whitelist is a third UPN-keyed grant" section: `McpUsers` rows (grant, `UsagePlan` override and Disabled kill-switch) are honoured only for the identity the UPN is bound to (`McpUserService` row → `IsBoundAsync`, `GetBoundMcpUserAsync` for the quota plan); `POST global/mcp-users` binds first with the shared resolver / 422 / 409 contract; unbound legacy rows are inert.
## 2026-08-30 (4)
* **Update**: `backend/ime-msi-archive.md` — new "Trust boundary of a sighting" section: the device-supplied `ime_agent_version` string is gated by `ImeMsiArchiver.IsPlausibleVersion` (dotted digits within MSI ProductVersion bounds) before it can become a GLOBAL `ImeVersionHistory` RowKey (`ImeVersionSighting.Rejected` → no row/ops event/archive job); a session counts once (`UpdateSessionImeAgentVersionAsync` returns whether the version changed); a second tenant's sighting stamps `CorroboratedAt`; the MemberRead projection lists only fleet-confirmed versions (Archived, corroborated, or first seen before 2026-08-31), Global scope sees every row.
## 2026-08-30 (3)
* **Update**: `agent/local-admin-analyzer.md` — built-ins matched by well-known RID (500/501/503/504) so localized Guest/Administrator are not flagged now that disabled accounts are inventoried; `kioskUser0` built-in; shutdown-time dynamic allowance restricted to non-SAM identities (a logged-in local account is never exempted — CWE-807) with new `logged_in_local_accounts` field; junctions and `$<32 hex>` folders skipped by the profile check.
* **New**: `agent/local-admin-analyzer.md` — `LocalAdminAnalyzer` no longer skips disabled accounts (a `/active:no` backdoor re-enabled after enrollment was invisible at both scans) and reads Administrators-group membership from the SAM via `NetLocalGroupGetMembers` on `S-1-5-32-544` (`LocalGroupNativeMethods`, WMI-independent). New event fields `unexpected_admin_members` (+40), `account_details`, `administrators_group.{enumerated,error_code,members}`, `builtin_administrator_enabled` (reported, not scored). Non-local members (Entra role SIDs, domain groups) are listed, never flagged. `ANALYZE-ID-002` 1.2.0 explanation names the new fields. Registered in `index.md`.
## 2026-08-30 (2)
* **Update**: `agent/cmtrace-time-resolution.md` — new "Line parsing (bounded cost)" section: `CmTraceLogParser.TryParseLine` is no longer one greedy-`.*` regex (quadratic on hostile lines, no timeout) but a linear first-open / last-parseable-trailer search with a `\G`-anchored, timeout-backed trailer regex that returns exactly what the old regex did; the agent's multiline buffer gains a 1 MB char cap next to the 100-line cap, both logged at Warning, and the tail of a dropped entry is skipped instead of raw-matched.
## 2026-08-30 (1)
* **Update**: `agent/diagnostics-package.md` — reads are validated on the handle, not the path: `PinnedSourceFolder` pins each section folder (its final path must equal the validated path; held open so the chain cannot be renamed) and opens every file with `FILE_FLAG_OPEN_REPARSE_POINT`, requiring the handle's final path to be `<canonical folder>\<relative path>`. A subdirectory swapped for a junction between enumeration and open is skipped (`SKIPPED (resolved outside validated folder)`) instead of copied into the package.
## 2026-08-29 (12)
* **Update**: `backend/tenant-activation-welcome-mail.md` — `PUT preview/notification-email` moved to `AuthenticatedUserWithRole`; roleless callers may write the address only while the tenant has no enabled member (signup window), afterwards a member role is required.
## 2026-08-29 (11)
* **Update**: `rules/gather-rule-phase-scoping.md` — `on_event` feedback-loop guard: `TimelineEventStream` publishes the event source and `GatherRuleExecutorHost` never dispatches `on_event` for gather-emitted events (a rule triggering on its own output type looped for the whole session); per-rule on_event execution cap (100/session); backend rejects `on_event` rules whose trigger equals their output type or `gather_result`.
## 2026-08-29 (10)
* **Update**: `backend/tenant-activation-welcome-mail.md` — welcome/farewell mails HTML-encode the domain at substitution; `DomainName` is frozen on the full-model config PUT (was persisted verbatim) and the first-login seed only accepts a strict DNS host name.
+16 -3
View File
@@ -8,7 +8,7 @@ tags:
- backend
- gather-rules
- config
timestamp: 2026-08-04T00:00:00+02:00
timestamp: 2026-08-29T00:00:00+02:00
---
# Problem
@@ -89,6 +89,18 @@ Consequences of the emitted-event feed:
the raw signal stream — the documented "On Event with `enrollment_complete`" end-of-enrollment
pattern only works because of this. This restores V1 semantics (V1 fired triggers from every
emitted `EnrollmentEvent`).
* **Feedback-loop guard** (2026-08-29): gather output travels the same post → ingress →
reducer → emitter → stream path, so the stream would hand the executor's own events straight
back to `OnEvent`. A rule with `triggerEventType == outputEventType` (or two rules chained
A→B→A) then executed and emitted without bound for the whole session — `emitMode:
on_change` does not help when the collected result varies. `TimelineEventStream` therefore
publishes the event **source**, and `GatherRuleExecutorHost` never dispatches `on_event` for
`Source == GatherRuleExecutor.SourceName` (phase declarations on such events are still
honoured). Second layer: `GatherRuleExecutor.MaxOnEventExecutionsPerRule` (100 per rule and
session) bounds any cycle that reaches the executor through a non-gather event; the boundary
is logged once (agent log Warning + debug-trace `trigger` line). Backend validation rejects
`on_event` rules whose trigger equals their own output type or `gather_result` — such a rule
could never fire.
* Other hosts (`DeviceInfoHost`, `UserEspKeepAwakeHost`, `ProvisioningPackageHost`) still key
off the raw signal stream deliberately — their collections are about OS state at the raw
boundary (ESP exit, keep-awake), not about timeline agreement.
@@ -103,7 +115,8 @@ Consequences of the emitted-event feed:
`_startupRulesExecuted` dedup; it is not part of the `WaitForStartupRules` latch).
* `phase_change` — evaluated against the **new** phase; the scope gate runs before the
per-(rule, phase) dedup so an out-of-scope pass does not consume the execution slot.
* `on_event` — gated by the current phase.
* `on_event` — gated by the current phase; never fires on gather output; capped at
`MaxOnEventExecutionsPerRule` per rule and session.
* **Before the first phase signal** of a session (`_currentPhase == Unknown`) scoped rules are
inactive. Sessions that never produce phase signals never run them (documented behavior).
* **From-phase latch**: activates when `phase != Unknown && phase != Failed && (int)phase >=
@@ -164,4 +177,4 @@ Consequences of the emitted-event feed:
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/GatherRulesFunction.cs` — `ValidateScopeAndEmitMode`
* `src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs` — partial-PUT merge, `ContentEquivalent`
* `src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs` — field contracts
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests`, `GatherRuleExecutorHostTimelineFeedTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests`, `GatherRuleExecutorHostTimelineFeedTests`, `GatherRuleExecutorOnEventCapTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
+1 -1
View File
@@ -31,7 +31,7 @@ Namespace split (`RuleIdPolicy.cs`, shared model):
# Guards (all enforced, 2026-08-21)
1. **Global RuleStats aggregate is catalog-only.** The `global_{ruleId}` row would otherwise sum same-ID custom rules of unrelated tenants (title/severity last-writer-wins). Analyze gates on `rule.IsBuiltIn || rule.IsCommunity` (`AnalyzeOnEnrollmentEndHandler`), gather gates on `RuleIdPolicy.IsReservedBuiltInId` (`EventIngestProcessor.RuleStats`) because agent events carry no flag — valid because custom rules can never occupy the reserved pattern.
1. **Global RuleStats aggregate is catalog-only.** The `global_{ruleId}` row would otherwise sum same-ID custom rules of unrelated tenants (title/severity last-writer-wins). Both paths gate on the server-side catalog rule object: analyze on `rule.IsBuiltIn || rule.IsCommunity` of the evaluated rule (`AnalyzeOnEnrollmentEndHandler`), gather on the same flags of the rule resolved from the tenant's active gather catalog (`EventIngestProcessor.ResolveFiredGatherRules`). The agent event contributes only the `ruleId`; title/category/severity are taken from the catalog, never from event `Data`, and a claimed ID that is not an active rule for the tenant is dropped — so a device cannot mint `global_*` rows for retired/unshipped IDs, inflate a built-in rule's counters under a foreign ID, or respell its title. (Earlier the gather path selected the global row by `RuleIdPolicy.IsReservedBuiltInId` on the device-supplied ID and wrote the device-supplied title — pattern membership is not catalog membership.)
2. **Catalog build fails on namespace violations.** `rules/scripts/combine.js` rejects duplicate IDs (case-insensitive) and any gather/analyze built-in outside the reserved pattern; `BuiltInRuleCatalogPolicyTests` pins the same invariants on the embedded resource actually deployed.
3. **GitHub reseed rejects non-reserved IDs** before writing to the `global` partition (`ReseedFromGitHubFunction`) — a seeded `*-CUSTOM-*` rule would silently shadow every tenant's same-ID custom rule at merge time.
4. **Cross-tenant RuleState GC is namespace-gated.** `DeleteRuleStatesForRuleIdAcrossTenantsAsync` deletes by bare RowKey across all partitions; it now refuses non-reserved IDs with `failed=-1` (callers then skip the global catalog delete, same contract as enumeration failure).
+1 -1
View File
@@ -123,4 +123,4 @@ Application Insights resource — the prefetch is cheap only while the runtime i
* `src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx` — nav link rendering, rationale comment
* `src/Web/autopilot-monitor-web/components/Navbar.tsx` — top-bar and notification links
* [SWA and Oryx node pinning](../architecture.md) — hosting model for the web app
* `.github/workflows/deploy-web.yml` — hosting model: static export built in CI (Node 22 via setup-node, `engines` in package.json), uploaded with the pinned SWA CLI
+3 -3
View File
@@ -5,7 +5,7 @@
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
"severity": "high",
"category": "identity",
"version": "1.1.0",
"version": "1.3.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -56,7 +56,7 @@
}
],
"confidenceThreshold": 50,
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list), unexpected_profiles (C:\\Users profile directories not on the allowed list), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
"remediation": [
{
"title": "Review the unexpected accounts",
@@ -79,7 +79,7 @@
"steps": [
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
"Check whether the unexpected account has admin group membership",
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
"Consider re-imaging the device if unauthorized admin access is confirmed"
]
}
+3 -3
View File
@@ -2766,7 +2766,7 @@
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
"severity": "high",
"category": "identity",
"version": "1.1.0",
"version": "1.3.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -2817,7 +2817,7 @@
}
],
"confidenceThreshold": 50,
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list), unexpected_profiles (C:\\Users profile directories not on the allowed list), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
"remediation": [
{
"title": "Review the unexpected accounts",
@@ -2840,7 +2840,7 @@
"steps": [
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
"Check whether the unexpected account has admin group membership",
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
"Consider re-imaging the device if unauthorized admin access is confirmed"
]
}
+81 -169
View File
@@ -4,7 +4,7 @@
{
"patternId": "HS-COMPLIANCE",
"category": "always",
"pattern": "\\[HS\\] the (?<part>pre|post)-rem(?:e)?diation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"pattern": "^\\[HS\\] the (?<part>pre|post)-rem(?:e)?diation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"action": "healthScriptDetectionResult",
"parameters": {
"scriptType": "remediation"
@@ -15,7 +15,7 @@
{
"patternId": "HS-COMPLIANCE-POST",
"category": "always",
"pattern": "\\[HS\\] the (?<part>post) detection script result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"pattern": "^\\[HS\\] the (?<part>post) detection script result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"action": "healthScriptDetectionResult",
"parameters": {
"scriptType": "remediation"
@@ -26,7 +26,7 @@
{
"patternId": "HS-EXITCODE",
"category": "always",
"pattern": "\\[HS\\] exit code of the script is (?<exitCode>-?\\d+)",
"pattern": "^\\[HS\\] exit code of the script is (?<exitCode>-?\\d+)",
"action": "scriptExitCode",
"parameters": {
"scriptType": "remediation"
@@ -37,7 +37,7 @@
{
"patternId": "HS-NEW-RESULT",
"category": "always",
"pattern": "\\[HS\\] new result = (?<json>\\{[\\s\\S]*\\})\\s*$",
"pattern": "^\\[HS\\] new result = (?<json>\\{[\\s\\S]*\\})\\s*$",
"action": "healthScriptResult",
"parameters": {
"scriptType": "remediation"
@@ -48,7 +48,7 @@
{
"patternId": "HS-RUN-CONTEXT",
"category": "always",
"pattern": "\\[HS\\] The policy needs be run as (?<context>System|User)",
"pattern": "^\\[HS\\] The policy needs be run as (?<context>System|User)",
"action": "scriptContext",
"parameters": {
"scriptType": "remediation"
@@ -59,7 +59,7 @@
{
"patternId": "HS-SCRIPT-START",
"category": "always",
"pattern": "\\[HS\\] ProcessScript PolicyId: (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) PolicyType: (?<policyType>\\d+)",
"pattern": "^\\[HS\\] ProcessScript PolicyId: (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) PolicyType: (?<policyType>\\d+)",
"action": "scriptStarted",
"parameters": {
"scriptType": "remediation"
@@ -70,7 +70,7 @@
{
"patternId": "HS-STDERR",
"category": "always",
"pattern": "\\[HS\\] err output = (?<output>.*)",
"pattern": "^\\[HS\\] err output = (?<output>.*)",
"action": "scriptOutput",
"parameters": {
"scriptType": "remediation",
@@ -82,7 +82,7 @@
{
"patternId": "HS-STDOUT",
"category": "always",
"pattern": "\\[HS\\] std output = (?<output>.*)",
"pattern": "^\\[HS\\] std output = (?<output>.*)",
"action": "scriptOutput",
"parameters": {
"scriptType": "remediation",
@@ -94,7 +94,7 @@
{
"patternId": "IME-AGENT-VERSION",
"category": "always",
"pattern": "Agent version is: (?<agentVersion>[\\d.]+)",
"pattern": "^Agent version is: (?<agentVersion>[\\d.]+)",
"action": "imeAgentVersion",
"description": "Captures the Intune Management Extension agent version number.",
"enabled": true
@@ -102,7 +102,7 @@
{
"patternId": "IME-ALREADY-DETECTED-1",
"category": "otherPhases",
"pattern": "\\[Win32App\\] Completed detectionManager SideCar\\w+?DetectionManager, applicationDetectedByCurrentRule: True",
"pattern": "^\\[Win32App\\] Completed detectionManager SideCar\\w+?DetectionManager, applicationDetectedByCurrentRule: True",
"action": "ignoreCompletedApp",
"description": "Detects when an app from a previous ESP phase is detected as already installed, so it should be ignored in the current phase.",
"enabled": true
@@ -110,7 +110,7 @@
{
"patternId": "IME-ALREADY-DETECTED-2",
"category": "otherPhases",
"pattern": "\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
"pattern": "^\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
"action": "ignoreCompletedApp",
"description": "Detects enforcement success in a non-current phase, indicating an app completed in a previous phase should be ignored.",
"enabled": true
@@ -118,7 +118,7 @@
{
"patternId": "IME-APP-TYPE-MSI",
"category": "always",
"pattern": "\\[Win32App\\].*?\\bMSI App (?<id>{GUID})",
"pattern": "^\\[Win32App\\].*?\\bMSI App (?<id>{GUID})",
"action": "captureAppTypeMsi",
"description": "Detects an MSI-based Win32 app via the 'MSI App <guid>' phrase in the ESP retry configuration line. Marks AppType as 'MSI' for that app id.",
"enabled": true
@@ -126,7 +126,7 @@
{
"patternId": "IME-APP-TYPE-WINGET",
"category": "always",
"pattern": "\\[Win32App\\]\\[WinGetApp\\].*?app with id: (?<id>{GUID})",
"pattern": "^\\[Win32App\\]\\[WinGetApp\\].*?app with id: (?<id>{GUID})",
"action": "captureAppTypeWinGet",
"description": "Detects a WinGet-backed Store app by any [Win32App][WinGetApp] handler log line (Detection/Applicability/Operation). Marks AppType as 'WinGet' for that app id.",
"enabled": true
@@ -134,7 +134,7 @@
{
"patternId": "IME-APP-VERSION",
"category": "always",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: (?<id>{GUID}).*?\"DetectedIdentityVersion\":\\{[^}]*?\"NewValue\":\"(?<appVersion>[^\"]+)\"",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: (?<id>{GUID}).*?\"DetectedIdentityVersion\":\\{(?:\"OldValue\":[^,}]{0,64},)?\"NewValue\":\"(?<appVersion>[^\"]+)\"",
"action": "captureAppVersion",
"description": "Captures the product version IME detected after an app install (e.g. \"11.2.1787.0\"). Source: [ReportingManager] Detection state update with DetectedIdentityVersion.NewValue. Used by App Dashboard to show version-level failure breakdowns.",
"enabled": true
@@ -142,7 +142,7 @@
{
"patternId": "IME-ATTEMPT-NUMBER",
"category": "currentPhase",
"pattern": "\\[Win32App\\] ===Step=== Execute retry (?<attempt>\\d+)$",
"pattern": "^\\[Win32App\\] ===Step=== Execute retry (?<attempt>\\d+)$",
"action": "captureAttemptNumber",
"parameters": {
"useCurrentApp": "true"
@@ -153,7 +153,7 @@
{
"patternId": "IME-CACHE-MISS",
"category": "always",
"pattern": "\\[Win32App\\] Content cache miss for app \\(id = {GUID}, name = (?<name>.*?)\\), start downloading",
"pattern": "^\\[Win32App\\] Content cache miss for app \\(id = {GUID}, name = (?<name>.*?)\\), start downloading",
"action": "updateName",
"description": "Captures 'Content cache miss for app (id = <guid>, name = <name>), start downloading...' (ContentManager.cs) as a secondary app-name source. Fires right before a real download begins, so it resolves names for exactly the apps that will produce download/install activity — a redundancy for IME-POLICIES-based naming after IME retired the old name-bearing processing lines.",
"enabled": true
@@ -161,7 +161,7 @@
{
"patternId": "IME-DECRYPT-FAILED",
"category": "currentPhase",
"pattern": "Decryption is failed for appId {GUID}",
"pattern": "^Decryption is failed for appId {GUID}",
"action": "updateStateError",
"description": "Detects when app content decryption fails, indicating a corrupted or tampered download.",
"enabled": true
@@ -169,7 +169,7 @@
{
"patternId": "IME-DETECTED-INSTALLED-1",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
"action": "updateStateInstalled",
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
@@ -177,23 +177,15 @@
{
"patternId": "IME-DETECTED-INSTALLED-2",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: {GUID} resulted in action status: Success and detection state: Detected",
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: {GUID} resulted in action status: Success and detection state: Detected",
"action": "updateStateInstalled",
"description": "Detects when an app's detection action handler reports successful detection (app is installed).",
"enabled": true
},
{
"patternId": "IME-DETECTED-OLD",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Detected app {GUID} ",
"action": "updateStateInstalled",
"description": "Detects when an app is detected as installed (old log format). DISABLED 2026-08-17: the standalone 'Detected app <guid> ' line no longer exists in IME 1.97.107; this regex instead matched the 'Detected app ... and intent is to install, skip applicability check' line, colliding with IME-SKIP-INSTALL-DETECTED and firing updateStateInstalled and updateStateSkipped on the same physical line (see tasks/ime-v1-code-audit-2026-08-17.md).",
"enabled": false
},
{
"patternId": "IME-DETECTION-RESULT",
"category": "always",
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
"action": "captureDetectionResult",
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
"enabled": true
@@ -201,23 +193,23 @@
{
"patternId": "IME-DO-TEL",
"category": "currentPhase",
"pattern": "\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
"pattern": "^\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
"action": "updateDoTelemetry",
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration.",
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent in 1.104.102.0 and 1.105.103.0 decompiles, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
"enabled": true
},
{
"patternId": "IME-DO-TIMEOUT-1",
"category": "currentPhase",
"pattern": "\\[Win32App DO\\] DO download(?=.*?\\bnot finished\\b)(?=.*?\\btimeout\\b).*?intunewin-bin_{GUID}",
"pattern": "^\\[Win32App DO\\] DO downloading is not finished within timeout, jobId = [^,]{0,64}, fileId =.*?intunewin-bin_{GUID}",
"action": "updateStatePostponed",
"description": "Detects when a Delivery Optimization download times out for a specific app (identified by GUID in filename).",
"description": "Detects when a Delivery Optimization download times out for a specific app (identified by GUID in the DO fileId). Source literal (ContentDownloaderDeliveryOptimization, identical IME 1.97-1.105): \"[Win32App DO] DO downloading is not finished within timeout, jobId = {0}, fileId = {1}\".",
"enabled": true
},
{
"patternId": "IME-DO-TIMEOUT-2",
"category": "currentPhase",
"pattern": "\\[Win32App DO\\] DO download(?=.*?\\bnot finished\\b)(?=.*?\\btimeout\\b)",
"pattern": "^\\[Win32App DO\\] DO downloading is not finished within timeout",
"action": "updateStatePostponed",
"parameters": {
"useCurrentApp": "true"
@@ -228,7 +220,7 @@
{
"patternId": "IME-DOWNLOADING",
"category": "currentPhase",
"pattern": "\\[StatusService\\] Downloading app \\(id = {GUID}.*?\\) via (?<tech>\\w+), bytes (?<bytes>\\w+)/(?<ofbytes>\\w+) for user",
"pattern": "^\\[StatusService\\] Downloading app \\(id = {GUID}.*?\\) via (?<tech>\\w+), bytes (?<bytes>\\w+)/(?<ofbytes>\\w+) for user",
"action": "updateStateDownloading",
"description": "Captures app download progress including technology (DO/CDN), bytes downloaded, and total bytes.",
"enabled": true
@@ -236,7 +228,7 @@
{
"patternId": "IME-ENFORCEMENT-SUCCESS",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
"action": "updateStateInstalled",
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
@@ -244,7 +236,7 @@
{
"patternId": "IME-ENFORCEMENT-SUCCESS-OLD",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
"pattern": "^\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
"action": "updateStateInstalled",
"parameters": {
"useCurrentApp": "true"
@@ -255,7 +247,7 @@
{
"patternId": "IME-ERROR-DOWNLOAD",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
"action": "updateStateError",
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent. Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
@@ -263,7 +255,7 @@
{
"patternId": "IME-ERROR-ENFORCEMENT",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Setting enforcementState as\\W+Error\\b",
"pattern": "^\\[Win32App\\] Setting enforcementState as\\W+Error\\b",
"action": "updateStateError",
"parameters": {
"useCurrentApp": "true"
@@ -274,7 +266,7 @@
{
"patternId": "IME-ERROR-NO-CONTENT",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Didn't get content info from service",
"pattern": "^\\[Win32App\\] Didn't get content info from service",
"action": "updateStateError",
"parameters": {
"useCurrentApp": "true"
@@ -285,7 +277,7 @@
{
"patternId": "IME-ERROR-REPORT",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
"action": "updateStateError",
"parameters": {
"checkTo": "true"
@@ -296,7 +288,7 @@
{
"patternId": "IME-ERROR-TIMEOUT",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Installation is timeout",
"pattern": "^\\[Win32App\\] Installation is timeout",
"action": "updateStateError",
"parameters": {
"useCurrentApp": "true"
@@ -307,7 +299,7 @@
{
"patternId": "IME-ERROR-UNMAPPED-EXIT",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Admin did NOT set mapping for lpExitCode\\W+(?<exitCode>\\d+) of app\\W+{GUID}",
"pattern": "^\\[Win32App\\] Admin did NOT set mapping for lpExitCode\\W+(?<exitCode>\\d+) of app\\W+{GUID}",
"action": "updateStateError",
"description": "Detects when an app installer returned an exit code that has no mapping configured in Intune.",
"enabled": true
@@ -315,7 +307,7 @@
{
"patternId": "IME-ESP-APP-REGISTERED",
"category": "always",
"pattern": "\\[Win32App\\]\\[EspManager\\] In EspPhase: \\w+\\. App {GUID} has been registered.*?App name: (?<name>.*?)$",
"pattern": "^\\[Win32App\\]\\[EspManager\\] In EspPhase: \\w+\\. App {GUID} has been registered.*?App name: (?<name>.*?)$",
"action": "updateName",
"description": "Captures the ESP tracked-app registration line ('App <guid> has been registered[ for user <id>]. App name: <name>', EspManager.cs) as an app-name source. Primary replacement for the retired IME-UPDATE-NAME-1/2/3 whose emitters were removed with IME's old V1/V2 processing pipeline; also documents exactly which apps IME registered for ESP tracking. Category 'always' so names resolve even before the first ApplicationPoller EspPhase line is seen.",
"enabled": true
@@ -323,7 +315,7 @@
{
"patternId": "IME-ESP-PHASE",
"category": "always",
"pattern": "\\[Win32App\\] (?:In|The) EspPhase: (?<espPhase>\\w+)",
"pattern": "^\\[Win32App\\] (?:In|The) EspPhase: (?<espPhase>\\w+)",
"action": "espPhaseDetected",
"description": "Detects ESP phase transitions reported by IME Win32App processing (e.g., DeviceSetup, AccountSetup).",
"enabled": true
@@ -331,7 +323,7 @@
{
"patternId": "IME-ESP-TRACK-STATUS",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}",
"pattern": "^\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}",
"action": "espTrackStatus",
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app. No trailing anchor: IME 1.97 (Dec 2025) appends ' with name: <appName>.' after the GUID (EspManager.cs), which broke the previous '\\.' suffix. 'from' can be the literal string 'null' on first tracking.",
"enabled": true
@@ -339,7 +331,7 @@
{
"patternId": "IME-EXITCODE",
"category": "currentPhase",
"pattern": "\\[Win32App\\] lpExitCode (?<exitCode>-?\\d+)$",
"pattern": "^\\[Win32App\\] lpExitCode (?<exitCode>-?\\d+)$",
"action": "captureExitCode",
"parameters": {
"useCurrentApp": "true"
@@ -350,7 +342,7 @@
{
"patternId": "IME-EXITCODE-MAPPED-FAILED",
"category": "currentPhase",
"pattern": "\\[Win32App\\] lpExitCode is defined as Failed$",
"pattern": "^\\[Win32App\\] lpExitCode is defined as Failed$",
"action": "updateStateError",
"parameters": {
"useCurrentApp": "true"
@@ -361,7 +353,7 @@
{
"patternId": "IME-GRS-CLEARED",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ExecutionActionHandler\\] Encountered early subgraph termination\\. Clearing GRS for app with id: {GUID} so it can be retried during the next check-in\\.",
"pattern": "^\\[Win32App\\]\\[ExecutionActionHandler\\] Encountered early subgraph termination\\. Clearing GRS for app with id: {GUID} so it can be retried during the next check-in\\.",
"action": "updateStatePostponed",
"description": "IME aborted the app's subgraph before enforcement finished and cleared its GRS entry so the app retries on the next check-in (ExecutionActionHandler, AppWorkload.log). Maps to Postponed: the install did not complete this pass but is scheduled to run again - previously this looked like an unexplained gap. Wording verified in IME 1.97 and 1.104.",
"enabled": true
@@ -369,7 +361,7 @@
{
"patternId": "IME-GRS-SKIP",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID} to (?:install|uninstall) is in GRS\\. The app will not be enforced\\.",
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID} to (?:install|uninstall) is in GRS\\. The app will not be enforced\\.",
"action": "updateStatePostponed",
"description": "GRS (Global Retry Schedule) skip: the app failed recently and IME will not re-enforce it this check-in ('App with id: <guid> to install is in GRS. The app will not be enforced.', EnforcementActionProcessor / MixedEnforcementActionProcessor). THE canonical reason an app silently does nothing for up to 24h - previously invisible. Lives in AppActionProcessor.log, which the agent tails since the 2026-08-18 release; wording verified in IME 1.97 and 1.104.",
"enabled": true
@@ -377,7 +369,7 @@
{
"patternId": "IME-HRESULT",
"category": "currentPhase",
"pattern": "\\[Win32App\\] hResultFromWin32 (?<hresult>-?\\d+)$",
"pattern": "^\\[Win32App\\] hResultFromWin32 (?<hresult>-?\\d+)$",
"action": "captureHResult",
"parameters": {
"useCurrentApp": "true"
@@ -388,7 +380,7 @@
{
"patternId": "IME-IMPERSONATION",
"category": "always",
"pattern": "After impersonation: (?<user>.*?)$",
"pattern": "^After impersonation: (?<user>.*?)$",
"action": "imeImpersonation",
"description": "Detects when IME impersonates a user context for app installation.",
"enabled": true
@@ -396,7 +388,7 @@
{
"patternId": "IME-INSTALL-HANDLER",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ExecutionActionHandler\\] Handler invoked with execution type: Install for policy with id: {GUID}",
"pattern": "^\\[Win32App\\]\\[ExecutionActionHandler\\] Handler invoked with execution type: Install for policy with id: {GUID}",
"action": "updateStateInstalling",
"description": "Detects when the IME execution handler starts installing an app.",
"enabled": true
@@ -404,7 +396,7 @@
{
"patternId": "IME-LAUNCH-INSTALLER",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Launch Win32AppInstaller in \\w+? session",
"pattern": "^\\[Win32App\\] Launch Win32AppInstaller in \\w+? session",
"action": "updateStateInstalling",
"parameters": {
"useCurrentApp": "true"
@@ -415,7 +407,7 @@
{
"patternId": "IME-NO-ACTION",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] No action required for app with id: {GUID}",
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] No action required for app with id: {GUID}",
"action": "updateStateSkipped",
"description": "Detects when IME determines no action is required for an app (already in desired state).",
"enabled": true
@@ -423,7 +415,7 @@
{
"patternId": "IME-NOT-APPLICABLE-1",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ApplicabilityActionHandler\\] Applicability check for policy with id: {GUID} resulted in action status: Success and applicability state: NotApplicable",
"pattern": "^\\[Win32App\\]\\[ApplicabilityActionHandler\\] Applicability check for policy with id: {GUID} resulted in action status: Success and applicability state: NotApplicable",
"action": "updateStateSkipped",
"description": "Detects when an app's applicability check returns NotApplicable (requirements not met).",
"enabled": true
@@ -431,7 +423,7 @@
{
"patternId": "IME-NOT-APPLICABLE-2",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
"action": "updateStateSkipped",
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
@@ -439,7 +431,7 @@
{
"patternId": "IME-NOT-APPLICABLE-3",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
"action": "updateStateSkipped",
"description": "Detects when an app's desired state is set to None (not targeted or not applicable). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
@@ -447,7 +439,7 @@
{
"patternId": "IME-NOT-APPLICABLE-4",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
"action": "updateStateSkipped",
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
@@ -455,7 +447,7 @@
{
"patternId": "IME-NOT-TARGETED-1",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, targeted intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, targeted intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
"action": "updateStateSkipped",
"description": "Detects when an app with RequiredInstall intent is classified as NotApplicableOrNotTargeted (new log format).",
"enabled": true
@@ -463,7 +455,7 @@
{
"patternId": "IME-NOT-TARGETED-2",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, effective intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, effective intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
"action": "updateStateSkipped",
"description": "Detects when an app with effective RequiredInstall intent is classified as NotApplicableOrNotTargeted.",
"enabled": true
@@ -471,7 +463,7 @@
{
"patternId": "IME-POLICIES",
"category": "currentPhase",
"pattern": "Get policies =\\s*(?<policies>.*)",
"pattern": "^Get policies =\\s*(?<policies>.*)",
"action": "policiesDiscovered",
"description": "Captures the JSON policies list discovered by IME, used to build the app inventory.",
"enabled": true
@@ -479,7 +471,7 @@
{
"patternId": "IME-RESTART-STOP-PROCESSING",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[V3Processor\\] A restart is required to continue processing apps\\. Stop processing\\.",
"pattern": "^\\[Win32App\\]\\[V3Processor\\] A restart is required to continue processing apps\\. Stop processing\\.",
"action": "updateStatePostponed",
"parameters": {
"useCurrentApp": "true"
@@ -490,39 +482,15 @@
{
"patternId": "IME-SESSION-CHANGE",
"category": "always",
"pattern": "OnSessionChange:? (?<change>.*?)$",
"pattern": "^OnSessionChange:? (?<change>.*?)$",
"action": "imeSessionChange",
"description": "Detects Windows session change events (logon, logoff, lock, unlock) reported by IME.",
"enabled": true
},
{
"patternId": "IME-SET-CURRENT-1",
"category": "always",
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name",
"action": "setCurrentApp",
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-SET-CURRENT-2",
"category": "always",
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?\\bid\\W+{GUID}",
"action": "setCurrentApp",
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-SET-CURRENT-3",
"category": "always",
"pattern": "\\[Win32App\\] ===Step=== Start to Present app {GUID}",
"action": "setCurrentApp",
"description": "Detects the start of app presentation step in IME, setting the app as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-SET-CURRENT-4",
"category": "always",
"pattern": "\\[Win32App\\] SetCurrentDirectory:.*?\\\\{GUID}_\\d+",
"pattern": "^\\[Win32App\\] SetCurrentDirectory:.*?\\\\{GUID}_\\d+",
"action": "setCurrentApp",
"description": "Detects when IME sets the working directory for an app's staging folder, setting it as current.",
"enabled": true
@@ -530,7 +498,7 @@
{
"patternId": "IME-SHUTDOWN",
"category": "currentPhase",
"pattern": "EMS Agent received shutdown signal",
"pattern": "^EMS Agent received shutdown signal",
"action": "updateStatePostponed",
"parameters": {
"useCurrentApp": "true"
@@ -538,34 +506,10 @@
"description": "Detects when the IME agent receives a shutdown signal, postponing the current app installation.",
"enabled": true
},
{
"patternId": "IME-SKIP-APPLICABILITY-NOTMET",
"category": "currentPhase",
"pattern": "\\[Win32App\\] (?=.*?\\bapplicability\\b)(?=.*?NotMet\\b).*?\\bapp\\W+{GUID}\\b",
"action": "updateStateSkipped",
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-SKIP-DEPENDENCY-DETECT",
"category": "currentPhase",
"pattern": "\\[Win32App\\] app\\W+{GUID}\\b.*?\\bis a dependency\\b.*?\\bDetect Only mode\\b.*?\\bfinished processing",
"action": "updateStateSkipped",
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-SKIP-DETECTED",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Detected app {GUID}.*?skip next step",
"action": "updateStateSkipped",
"description": "Detects when an already-detected app is skipped for further processing steps (old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-SKIP-FILTERS",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[V3Processor\\] All apps in the subgraph are not applicable due to assignment filters\\. Skipping processing\\.",
"pattern": "^\\[Win32App\\]\\[V3Processor\\] All apps in the subgraph are not applicable due to assignment filters\\. Skipping processing\\.",
"action": "updateStateSkipped",
"parameters": {
"useCurrentApp": "true"
@@ -576,7 +520,7 @@
{
"patternId": "IME-SKIP-INSTALL-DETECTED",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Detected app {GUID} and intent is to install, skip applicability check",
"pattern": "^\\[Win32App\\] Detected app {GUID} and intent is to install, skip applicability check",
"action": "updateStateSkipped",
"description": "Detects when an app targeted for install is already detected/installed, so the install is skipped (old log format).",
"enabled": true
@@ -584,23 +528,15 @@
{
"patternId": "IME-SKIP-UNINSTALL-NOTDETECTED",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Not detected app {GUID} and intent is to un-install, skip applicability check",
"pattern": "^\\[Win32App\\] Not detected app {GUID} and intent is to un-install, skip applicability check",
"action": "updateStateSkipped",
"description": "Detects when an app targeted for uninstall is not detected, so the uninstall is skipped (old log format).",
"enabled": true
},
{
"patternId": "IME-SKIP-UNINSTALL-NOTDETECTED-2",
"category": "currentPhase",
"pattern": "\\[Win32App\\] NOT detected app {GUID}.*?skip un-installation",
"action": "updateStateSkipped",
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-STARTED",
"category": "always",
"pattern": "EMS Agent Started",
"pattern": "^EMS Agent Started",
"action": "imeStarted",
"description": "Detects when the Intune Management Extension (IME) agent starts up.",
"enabled": true
@@ -608,7 +544,7 @@
{
"patternId": "IME-STOP-SIGNAL",
"category": "currentPhase",
"pattern": "EMS Agent received stop signal",
"pattern": "^EMS Agent received stop signal",
"action": "updateStatePostponed",
"parameters": {
"useCurrentApp": "true"
@@ -619,7 +555,7 @@
{
"patternId": "IME-SUBGRAPH",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[V3Processor\\] Processing subgraph with app ids: {GUID}",
"pattern": "^\\[Win32App\\]\\[V3Processor\\] Processing subgraph with app ids: {GUID}",
"action": "cancelStuckAndSetCurrent",
"description": "Detects when V3Processor starts a new subgraph, cancelling any stuck app and setting the new app as current.",
"enabled": true
@@ -627,7 +563,7 @@
{
"patternId": "IME-TOKEN-FAILURE",
"category": "always",
"pattern": "Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
"pattern": "^Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
"action": "imeTokenFailure",
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
"enabled": true
@@ -635,7 +571,7 @@
{
"patternId": "IME-TOKEN-SUCCESS",
"category": "always",
"pattern": "Successfully get the token",
"pattern": "^Successfully get the token",
"action": "espPhaseDetected",
"parameters": {
"phase": "AccountSetup"
@@ -643,34 +579,10 @@
"description": "Detects successful token acquisition, indicating the AccountSetup ESP phase has started.",
"enabled": true
},
{
"patternId": "IME-UPDATE-NAME-1",
"category": "currentPhase",
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?name\\W+(?<name>.*?)\\W+id\\W+{GUID}",
"action": "updateName",
"description": "Captures the app name from ExecManager processing log (old format: name before id). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-UPDATE-NAME-2",
"category": "currentPhase",
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name (?<name>.*?)$",
"action": "updateName",
"description": "Captures the app name from ProcessAppWithDependencies log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-UPDATE-NAME-3",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Processing app \\(id={GUID}, name = (?<name>.*?)\\)",
"action": "updateName",
"description": "Captures the app name from the Processing app log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-USER-SESSION-COMPLETED",
"category": "always",
"pattern": "\\[Win32App\\] \\.+ Completed user session \\d+, userId: (?<userId>[a-f0-9\\-]+), userSID: (?<userSid>S-[\\d\\-]+) \\.+",
"pattern": "^\\[Win32App\\] \\.+ Completed user session \\d+, userId: (?<userId>[a-f0-9\\-]+), userSID: (?<userSid>S-[\\d\\-]+) \\.+",
"action": "enrollmentCompleted",
"description": "Detects when IME completes a user session, indicating enrollment has finished for that user.",
"enabled": true
@@ -678,7 +590,7 @@
{
"patternId": "IME-USER-SESSION-ZERO-APPS",
"category": "always",
"pattern": "\\[Win32App\\] Get 0 apps for user session (?<sessionId>\\d+), user id = (?<userId>[a-f0-9\\-]+)",
"pattern": "^\\[Win32App\\] Get 0 apps for user session (?<sessionId>\\d+), user id = (?<userId>[a-f0-9\\-]+)",
"action": "userSessionZeroApps",
"description": "Detects when IME enumerated a user session and found zero targeted Win32 apps. IME then skips its 'Completed user session' line entirely (the zero-app branch in ApplicationPoller continues before that log statement), so this line is the only evidence that user-phase enforcement finished on a device with no user-targeted apps. Treated as equivalent to a completed user session.",
"enabled": true
@@ -686,7 +598,7 @@
{
"patternId": "IME-WIN32-STATE-1",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Got InstanceID: Win32App_{GUID}_\\d+, installationStateString: (?<state>\\d)",
"pattern": "^\\[Win32App\\] Got InstanceID: Win32App_{GUID}_\\d+, installationStateString: (?<state>\\d)",
"action": "updateWin32AppState",
"description": "Captures the Win32 app installation state string from instance ID tracking (old log format).",
"enabled": true
@@ -694,7 +606,7 @@
{
"patternId": "IME-WIN32-STATE-2",
"category": "currentPhase",
"pattern": "\\[Win32App\\] (?:Got|Set) (?:ESP )?win32App state (?<state>\\w+) for {GUID}",
"pattern": "^\\[Win32App\\] (?:Got|Set) (?:ESP )?win32App state (?<state>\\w+) for {GUID}",
"action": "updateWin32AppState",
"description": "Captures the Win32 app ESP state (e.g., Installed, InProgress) for a specific app.",
"enabled": true
@@ -702,7 +614,7 @@
{
"patternId": "IME-WINGET-ERROR",
"category": "currentPhase",
"pattern": "\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID}.*?- Operation Phase = Error",
"pattern": "^\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID}.*?- Operation Phase = Error",
"action": "updateStateError",
"description": "Marks a WinGet (new Store) app as failed when its operation progress reports phase Error (WinGetOperationInstallPhase: Downloading/Installing/Queued/Finished/Error). Before this pattern only the Installing phase was matched, so WinGet failures never surfaced as app errors (IME 1.97 audit 2026-08-17).",
"enabled": true
@@ -710,7 +622,7 @@
{
"patternId": "IME-WINGET-INSTALLING-1",
"category": "currentPhase",
"pattern": "\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID} and user = .*?- Operation Phase = Installing",
"pattern": "^\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID} and user = .*?- Operation Phase = Installing",
"action": "updateStateInstalling",
"description": "Detects WinGet app installation progress with Operation Phase = Installing (format variant 1).",
"enabled": true
@@ -718,7 +630,7 @@
{
"patternId": "IME-WINGET-INSTALLING-2",
"category": "currentPhase",
"pattern": "\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID}.*?- Operation Phase = Installing",
"pattern": "^\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID}.*?- Operation Phase = Installing",
"action": "updateStateInstalling",
"description": "Detects WinGet app installation progress with Operation Phase = Installing (format variant 2).",
"enabled": true
@@ -726,7 +638,7 @@
{
"patternId": "PS-AGENT-ERROR",
"category": "always",
"pattern": "error from script =(?<output>[\\s\\S]*)",
"pattern": "^error from script =(?<output>[\\s\\S]*)",
"action": "scriptOutput",
"parameters": {
"scriptType": "platform",
@@ -738,7 +650,7 @@
{
"patternId": "PS-AGENT-EXITCODE",
"category": "always",
"pattern": "Powershell exit code is (?<exitCode>-?\\d+)",
"pattern": "^Powershell exit code is (?<exitCode>-?\\d+)",
"action": "scriptExitCode",
"parameters": {
"scriptType": "platform"
@@ -749,7 +661,7 @@
{
"patternId": "PS-AGENT-INVOCATION",
"category": "always",
"pattern": "ExecutorLog AgentExecutor gets invoked",
"pattern": "^ExecutorLog AgentExecutor gets invoked",
"action": "resetPlatformScriptContext",
"description": "Marks the start of a new AgentExecutor.exe invocation. AgentExecutor hosts BOTH platform scripts (Policies\\Scripts\\<id>.ps1) and proactive-remediation scripts (IMECache\\HealthScripts\\<id>\\detect.ps1) and writes their lines interleaved into the shared AgentExecutor.log. The policyId-less 'Powershell exit code is N' / 'write output done' lines are otherwise routed to the last-started platform script via a sticky pointer, so a remediation invocation (whose start line does not match PS-AGENT-SCRIPT-START) leaks its exit/output into that platform script's result. This pattern resets the platform-script line-capture context at each invocation boundary so only the matching platform invocation captures its own exit/output.",
"enabled": true
@@ -757,7 +669,7 @@
{
"patternId": "PS-AGENT-OUTPUT",
"category": "always",
"pattern": "write output done\\. output = (?<output>[\\s\\S]*?), error = (?<error>[\\s\\S]*)",
"pattern": "^write output done\\. output = (?<output>[\\s\\S]*?), error = (?<error>[\\s\\S]*)",
"action": "scriptOutput",
"parameters": {
"scriptType": "platform"
@@ -768,7 +680,7 @@
{
"patternId": "PS-AGENT-SCRIPT-START",
"category": "always",
"pattern": "Adding argument powershell with value C:\\\\.*\\\\Policies\\\\Scripts\\\\[a-z0-9\\-]+_(?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})\\.ps1",
"pattern": "^Adding argument powershell with value C:\\\\.*\\\\Policies\\\\Scripts\\\\[a-z0-9\\-]+_(?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})\\.ps1",
"action": "scriptStarted",
"parameters": {
"scriptType": "platform",
@@ -780,7 +692,7 @@
{
"patternId": "PS-SCRIPT-CONTEXT",
"category": "always",
"pattern": "Launch powershell executor in (?<context>machine|user) session",
"pattern": "^Launch powershell executor in (?<context>machine|user) session",
"action": "scriptContext",
"description": "Captures whether a PowerShell script runs in machine (System) or user session context.",
"enabled": true
@@ -788,7 +700,7 @@
{
"patternId": "PS-SCRIPT-EXITCODE",
"category": "always",
"pattern": "Powershell execution is done, exitCode = (?<exitCode>-?\\d+)",
"pattern": "^Powershell execution is done, exitCode = (?<exitCode>-?\\d+)",
"action": "scriptExitCode",
"description": "Captures the PowerShell exit code when script execution completes in the IME log.",
"enabled": true
@@ -796,7 +708,7 @@
{
"patternId": "PS-SCRIPT-GENERATED",
"category": "always",
"pattern": "Script file C:\\\\.*\\\\Policies\\\\Scripts\\\\[a-z0-9\\-]+_(?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})\\.ps1 is generated",
"pattern": "^Script file C:\\\\.*\\\\Policies\\\\Scripts\\\\[a-z0-9\\-]+_(?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})\\.ps1 is generated",
"action": "scriptStarted",
"parameters": {
"scriptType": "platform"
@@ -807,7 +719,7 @@
{
"patternId": "PS-SCRIPT-RESULT",
"category": "always",
"pattern": "\\[PowerShell\\] User Id = [a-z0-9\\-]+, Policy id = (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}), policy result = (?<result>\\w+)",
"pattern": "^\\[PowerShell\\] User Id = [a-z0-9\\-]+, Policy id = (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}), policy result = (?<result>\\w+)",
"action": "scriptCompleted",
"parameters": {
"scriptType": "platform"
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-COMPLIANCE-POST",
"category": "always",
"pattern": "\\[HS\\] the (?<part>post) detection script result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"pattern": "^\\[HS\\] the (?<part>post) detection script result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"action": "healthScriptDetectionResult",
"parameters": { "scriptType": "remediation" },
"description": "Post-remediation detection result, current wording. IME 1.97 (Dec 2025) replaced the old '[HS] the post-remdiation detection script compliance result for ...' line (still covered by HS-COMPLIANCE for older logs) with '[HS] the post detection script result for <guid> is <True|False>' (ScriptProcessorV2.cs:721). The literal word 'post' in the line is captured as the part group so the handler classifies it as the post-detection phase.",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-COMPLIANCE",
"category": "always",
"pattern": "\\[HS\\] the (?<part>pre|post)-rem(?:e)?diation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"pattern": "^\\[HS\\] the (?<part>pre|post)-rem(?:e)?diation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"action": "healthScriptDetectionResult",
"parameters": { "scriptType": "remediation" },
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring). Tolerates both Microsoft's historical typo 'remdiation' and a future corrected 'remediation'. NOTE: IME 1.97 (Dec 2025) no longer emits the post- variant of this line; the post case is covered by HS-COMPLIANCE-POST.",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-EXITCODE",
"category": "always",
"pattern": "\\[HS\\] exit code of the script is (?<exitCode>-?\\d+)",
"pattern": "^\\[HS\\] exit code of the script is (?<exitCode>-?\\d+)",
"action": "scriptExitCode",
"parameters": { "scriptType": "remediation" },
"description": "Captures the exit code of a health-script execution. Feeds the early-signal slot consumed by HS-COMPLIANCE so the user sees the real exit code (not just the inferred 0/1) before HS-NEW-RESULT arrives.",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-NEW-RESULT",
"category": "always",
"pattern": "\\[HS\\] new result = (?<json>\\{[\\s\\S]*\\})\\s*$",
"pattern": "^\\[HS\\] new result = (?<json>\\{[\\s\\S]*\\})\\s*$",
"action": "healthScriptResult",
"parameters": { "scriptType": "remediation" },
"description": "Atomic final JSON of a health script run, covering all three phases (pre-detection / remediation / post-detection) emitted by IME after the policy completes. Single source of truth for health-script outcomes — replaces the line-by-line HS-COMPLIANCE/HS-EXITCODE/HS-STDOUT/HS-STDERR patterns. The end-anchor and [\\s\\S] character class are defensive against future format changes and multi-line CMTrace buffering.",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-RUN-CONTEXT",
"category": "always",
"pattern": "\\[HS\\] The policy needs be run as (?<context>System|User)",
"pattern": "^\\[HS\\] The policy needs be run as (?<context>System|User)",
"action": "scriptContext",
"parameters": { "scriptType": "remediation" },
"description": "Captures the execution context (System or User) for a health-script. Feeds the early-signal slot consumed by HS-COMPLIANCE so context is visible immediately rather than waiting for HS-NEW-RESULT (which exposes RunAsAccount).",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-SCRIPT-START",
"category": "always",
"pattern": "\\[HS\\] ProcessScript PolicyId: (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) PolicyType: (?<policyType>\\d+)",
"pattern": "^\\[HS\\] ProcessScript PolicyId: (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) PolicyType: (?<policyType>\\d+)",
"action": "scriptStarted",
"parameters": { "scriptType": "remediation" },
"description": "Detects when a Proactive Remediation (Health Script) starts processing. Captures PolicyId and PolicyType (6 = remediation). Drives the live script_started event for UI progress indication; the consolidated final result for the same PolicyId arrives later via HS-NEW-RESULT.",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-STDERR",
"category": "always",
"pattern": "\\[HS\\] err output = (?<output>.*)",
"pattern": "^\\[HS\\] err output = (?<output>.*)",
"action": "scriptOutput",
"parameters": { "scriptType": "remediation", "outputType": "stderr" },
"description": "Captures standard error from a health-script execution. Feeds the early-signal slot consumed by HS-COMPLIANCE so stderr is visible immediately rather than waiting for HS-NEW-RESULT.",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-STDOUT",
"category": "always",
"pattern": "\\[HS\\] std output = (?<output>.*)",
"pattern": "^\\[HS\\] std output = (?<output>.*)",
"action": "scriptOutput",
"parameters": { "scriptType": "remediation", "outputType": "stdout" },
"description": "Captures standard output from a health-script execution. Feeds the early-signal slot consumed by HS-COMPLIANCE so the user sees stdout immediately rather than waiting for HS-NEW-RESULT.",
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-AGENT-VERSION",
"category": "always",
"pattern": "Agent version is: (?<agentVersion>[\\d.]+)",
"pattern": "^Agent version is: (?<agentVersion>[\\d.]+)",
"action": "imeAgentVersion",
"description": "Captures the Intune Management Extension agent version number.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ALREADY-DETECTED-1",
"category": "otherPhases",
"pattern": "\\[Win32App\\] Completed detectionManager SideCar\\w+?DetectionManager, applicationDetectedByCurrentRule: True",
"pattern": "^\\[Win32App\\] Completed detectionManager SideCar\\w+?DetectionManager, applicationDetectedByCurrentRule: True",
"action": "ignoreCompletedApp",
"description": "Detects when an app from a previous ESP phase is detected as already installed, so it should be ignored in the current phase.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ALREADY-DETECTED-2",
"category": "otherPhases",
"pattern": "\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
"pattern": "^\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
"action": "ignoreCompletedApp",
"description": "Detects enforcement success in a non-current phase, indicating an app completed in a previous phase should be ignored.",
"enabled": true
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-APP-TYPE-MSI",
"category": "always",
"pattern": "\\[Win32App\\].*?\\bMSI App (?<id>{GUID})",
"pattern": "^\\[Win32App\\].*?\\bMSI App (?<id>{GUID})",
"action": "captureAppTypeMsi",
"description": "Detects an MSI-based Win32 app via the 'MSI App <guid>' phrase in the ESP retry configuration line. Marks AppType as 'MSI' for that app id.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-APP-TYPE-WINGET",
"category": "always",
"pattern": "\\[Win32App\\]\\[WinGetApp\\].*?app with id: (?<id>{GUID})",
"pattern": "^\\[Win32App\\]\\[WinGetApp\\].*?app with id: (?<id>{GUID})",
"action": "captureAppTypeWinGet",
"description": "Detects a WinGet-backed Store app by any [Win32App][WinGetApp] handler log line (Detection/Applicability/Operation). Marks AppType as 'WinGet' for that app id.",
"enabled": true
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-APP-VERSION",
"category": "always",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: (?<id>{GUID}).*?\"DetectedIdentityVersion\":\\{[^}]*?\"NewValue\":\"(?<appVersion>[^\"]+)\"",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: (?<id>{GUID}).*?\"DetectedIdentityVersion\":\\{(?:\"OldValue\":[^,}]{0,64},)?\"NewValue\":\"(?<appVersion>[^\"]+)\"",
"action": "captureAppVersion",
"description": "Captures the product version IME detected after an app install (e.g. \"11.2.1787.0\"). Source: [ReportingManager] Detection state update with DetectedIdentityVersion.NewValue. Used by App Dashboard to show version-level failure breakdowns.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ATTEMPT-NUMBER",
"category": "currentPhase",
"pattern": "\\[Win32App\\] ===Step=== Execute retry (?<attempt>\\d+)$",
"pattern": "^\\[Win32App\\] ===Step=== Execute retry (?<attempt>\\d+)$",
"action": "captureAttemptNumber",
"parameters": {
"useCurrentApp": "true"
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-CACHE-MISS",
"category": "always",
"pattern": "\\[Win32App\\] Content cache miss for app \\(id = {GUID}, name = (?<name>.*?)\\), start downloading",
"pattern": "^\\[Win32App\\] Content cache miss for app \\(id = {GUID}, name = (?<name>.*?)\\), start downloading",
"action": "updateName",
"description": "Captures 'Content cache miss for app (id = <guid>, name = <name>), start downloading...' (ContentManager.cs) as a secondary app-name source. Fires right before a real download begins, so it resolves names for exactly the apps that will produce download/install activity — a redundancy for IME-POLICIES-based naming after IME retired the old name-bearing processing lines.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-DECRYPT-FAILED",
"category": "currentPhase",
"pattern": "Decryption is failed for appId {GUID}",
"pattern": "^Decryption is failed for appId {GUID}",
"action": "updateStateError",
"description": "Detects when app content decryption fails, indicating a corrupted or tampered download.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-DETECTED-INSTALLED-1",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
"action": "updateStateInstalled",
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-DETECTED-INSTALLED-2",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: {GUID} resulted in action status: Success and detection state: Detected",
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: {GUID} resulted in action status: Success and detection state: Detected",
"action": "updateStateInstalled",
"description": "Detects when an app's detection action handler reports successful detection (app is installed).",
"enabled": true
@@ -1,9 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-DETECTED-OLD",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Detected app {GUID} ",
"action": "updateStateInstalled",
"description": "Detects when an app is detected as installed (old log format). DISABLED 2026-08-17: the standalone 'Detected app <guid> ' line no longer exists in IME 1.97.107; this regex instead matched the 'Detected app ... and intent is to install, skip applicability check' line, colliding with IME-SKIP-INSTALL-DETECTED and firing updateStateInstalled and updateStateSkipped on the same physical line (see tasks/ime-v1-code-audit-2026-08-17.md).",
"enabled": false
}
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-DETECTION-RESULT",
"category": "always",
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
"action": "captureDetectionResult",
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
"enabled": true
+2 -2
View File
@@ -2,8 +2,8 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-DO-TEL",
"category": "currentPhase",
"pattern": "\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
"pattern": "^\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
"action": "updateDoTelemetry",
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration.",
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent in 1.104.102.0 and 1.105.103.0 decompiles, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
"enabled": true
}
+2 -2
View File
@@ -2,8 +2,8 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-DO-TIMEOUT-1",
"category": "currentPhase",
"pattern": "\\[Win32App DO\\] DO download(?=.*?\\bnot finished\\b)(?=.*?\\btimeout\\b).*?intunewin-bin_{GUID}",
"pattern": "^\\[Win32App DO\\] DO downloading is not finished within timeout, jobId = [^,]{0,64}, fileId =.*?intunewin-bin_{GUID}",
"action": "updateStatePostponed",
"description": "Detects when a Delivery Optimization download times out for a specific app (identified by GUID in filename).",
"description": "Detects when a Delivery Optimization download times out for a specific app (identified by GUID in the DO fileId). Source literal (ContentDownloaderDeliveryOptimization, identical IME 1.97-1.105): \"[Win32App DO] DO downloading is not finished within timeout, jobId = {0}, fileId = {1}\".",
"enabled": true
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-DO-TIMEOUT-2",
"category": "currentPhase",
"pattern": "\\[Win32App DO\\] DO download(?=.*?\\bnot finished\\b)(?=.*?\\btimeout\\b)",
"pattern": "^\\[Win32App DO\\] DO downloading is not finished within timeout",
"action": "updateStatePostponed",
"parameters": { "useCurrentApp": "true" },
"description": "Detects when a Delivery Optimization download times out (generic, uses current app context).",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-DOWNLOADING",
"category": "currentPhase",
"pattern": "\\[StatusService\\] Downloading app \\(id = {GUID}.*?\\) via (?<tech>\\w+), bytes (?<bytes>\\w+)/(?<ofbytes>\\w+) for user",
"pattern": "^\\[StatusService\\] Downloading app \\(id = {GUID}.*?\\) via (?<tech>\\w+), bytes (?<bytes>\\w+)/(?<ofbytes>\\w+) for user",
"action": "updateStateDownloading",
"description": "Captures app download progress including technology (DO/CDN), bytes downloaded, and total bytes.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ENFORCEMENT-SUCCESS-OLD",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
"pattern": "^\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
"action": "updateStateInstalled",
"parameters": { "useCurrentApp": "true" },
"description": "Detects when enforcement state is set to Success for the current app (old log format).",
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ENFORCEMENT-SUCCESS",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
"action": "updateStateInstalled",
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ERROR-DOWNLOAD",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
"action": "updateStateError",
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent. Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ERROR-ENFORCEMENT",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Setting enforcementState as\\W+Error\\b",
"pattern": "^\\[Win32App\\] Setting enforcementState as\\W+Error\\b",
"action": "updateStateError",
"parameters": { "useCurrentApp": "true" },
"description": "Detects when enforcement state is set to Error for the current app (old log format).",
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ERROR-NO-CONTENT",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Didn't get content info from service",
"pattern": "^\\[Win32App\\] Didn't get content info from service",
"action": "updateStateError",
"parameters": { "useCurrentApp": "true" },
"description": "Detects when IME cannot retrieve content info from the Intune service for the current app.",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ERROR-REPORT",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
"action": "updateStateError",
"parameters": {
"checkTo": "true"
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ERROR-TIMEOUT",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Installation is timeout",
"pattern": "^\\[Win32App\\] Installation is timeout",
"action": "updateStateError",
"parameters": { "useCurrentApp": "true" },
"description": "Detects when an app installation times out.",
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ERROR-UNMAPPED-EXIT",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Admin did NOT set mapping for lpExitCode\\W+(?<exitCode>\\d+) of app\\W+{GUID}",
"pattern": "^\\[Win32App\\] Admin did NOT set mapping for lpExitCode\\W+(?<exitCode>\\d+) of app\\W+{GUID}",
"action": "updateStateError",
"description": "Detects when an app installer returned an exit code that has no mapping configured in Intune.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ESP-APP-REGISTERED",
"category": "always",
"pattern": "\\[Win32App\\]\\[EspManager\\] In EspPhase: \\w+\\. App {GUID} has been registered.*?App name: (?<name>.*?)$",
"pattern": "^\\[Win32App\\]\\[EspManager\\] In EspPhase: \\w+\\. App {GUID} has been registered.*?App name: (?<name>.*?)$",
"action": "updateName",
"description": "Captures the ESP tracked-app registration line ('App <guid> has been registered[ for user <id>]. App name: <name>', EspManager.cs) as an app-name source. Primary replacement for the retired IME-UPDATE-NAME-1/2/3 whose emitters were removed with IME's old V1/V2 processing pipeline; also documents exactly which apps IME registered for ESP tracking. Category 'always' so names resolve even before the first ApplicationPoller EspPhase line is seen.",
"enabled": true
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ESP-PHASE",
"category": "always",
"pattern": "\\[Win32App\\] (?:In|The) EspPhase: (?<espPhase>\\w+)",
"pattern": "^\\[Win32App\\] (?:In|The) EspPhase: (?<espPhase>\\w+)",
"action": "espPhaseDetected",
"description": "Detects ESP phase transitions reported by IME Win32App processing (e.g., DeviceSetup, AccountSetup).",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ESP-TRACK-STATUS",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}",
"pattern": "^\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}",
"action": "espTrackStatus",
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app. No trailing anchor: IME 1.97 (Dec 2025) appends ' with name: <appName>.' after the GUID (EspManager.cs), which broke the previous '\\.' suffix. 'from' can be the literal string 'null' on first tracking.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-EXITCODE-MAPPED-FAILED",
"category": "currentPhase",
"pattern": "\\[Win32App\\] lpExitCode is defined as Failed$",
"pattern": "^\\[Win32App\\] lpExitCode is defined as Failed$",
"action": "updateStateError",
"parameters": { "useCurrentApp": "true" },
"description": "Marks the current app as failed when its exit code maps to Failed via the admin-defined return-code table ('[Win32App] lpExitCode is defined as <Failed|Success|SoftReboot|HardReboot|Retry>', Win32AppInstaller.cs). This is the NORMAL mapped-exit-code outcome; the previously matched 'Setting enforcementState as ... Error' line only fires on the rare no-mapping fallback path. The numeric exit code is captured separately by IME-EXITCODE, which precedes this line.",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-EXITCODE",
"category": "currentPhase",
"pattern": "\\[Win32App\\] lpExitCode (?<exitCode>-?\\d+)$",
"pattern": "^\\[Win32App\\] lpExitCode (?<exitCode>-?\\d+)$",
"action": "captureExitCode",
"parameters": {
"useCurrentApp": "true"
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-GRS-CLEARED",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ExecutionActionHandler\\] Encountered early subgraph termination\\. Clearing GRS for app with id: {GUID} so it can be retried during the next check-in\\.",
"pattern": "^\\[Win32App\\]\\[ExecutionActionHandler\\] Encountered early subgraph termination\\. Clearing GRS for app with id: {GUID} so it can be retried during the next check-in\\.",
"action": "updateStatePostponed",
"description": "IME aborted the app's subgraph before enforcement finished and cleared its GRS entry so the app retries on the next check-in (ExecutionActionHandler, AppWorkload.log). Maps to Postponed: the install did not complete this pass but is scheduled to run again - previously this looked like an unexplained gap. Wording verified in IME 1.97 and 1.104.",
"enabled": true
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-GRS-SKIP",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID} to (?:install|uninstall) is in GRS\\. The app will not be enforced\\.",
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID} to (?:install|uninstall) is in GRS\\. The app will not be enforced\\.",
"action": "updateStatePostponed",
"description": "GRS (Global Retry Schedule) skip: the app failed recently and IME will not re-enforce it this check-in ('App with id: <guid> to install is in GRS. The app will not be enforced.', EnforcementActionProcessor / MixedEnforcementActionProcessor). THE canonical reason an app silently does nothing for up to 24h - previously invisible. Lives in AppActionProcessor.log, which the agent tails since the 2026-08-18 release; wording verified in IME 1.97 and 1.104.",
"enabled": true
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-HRESULT",
"category": "currentPhase",
"pattern": "\\[Win32App\\] hResultFromWin32 (?<hresult>-?\\d+)$",
"pattern": "^\\[Win32App\\] hResultFromWin32 (?<hresult>-?\\d+)$",
"action": "captureHResult",
"parameters": {
"useCurrentApp": "true"
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-IMPERSONATION",
"category": "always",
"pattern": "After impersonation: (?<user>.*?)$",
"pattern": "^After impersonation: (?<user>.*?)$",
"action": "imeImpersonation",
"description": "Detects when IME impersonates a user context for app installation.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-INSTALL-HANDLER",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ExecutionActionHandler\\] Handler invoked with execution type: Install for policy with id: {GUID}",
"pattern": "^\\[Win32App\\]\\[ExecutionActionHandler\\] Handler invoked with execution type: Install for policy with id: {GUID}",
"action": "updateStateInstalling",
"description": "Detects when the IME execution handler starts installing an app.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-LAUNCH-INSTALLER",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Launch Win32AppInstaller in \\w+? session",
"pattern": "^\\[Win32App\\] Launch Win32AppInstaller in \\w+? session",
"action": "updateStateInstalling",
"parameters": { "useCurrentApp": "true" },
"description": "Detects when the Win32 app installer process is launched in a user or system session.",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-NO-ACTION",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] No action required for app with id: {GUID}",
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] No action required for app with id: {GUID}",
"action": "updateStateSkipped",
"description": "Detects when IME determines no action is required for an app (already in desired state).",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-NOT-APPLICABLE-1",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ApplicabilityActionHandler\\] Applicability check for policy with id: {GUID} resulted in action status: Success and applicability state: NotApplicable",
"pattern": "^\\[Win32App\\]\\[ApplicabilityActionHandler\\] Applicability check for policy with id: {GUID} resulted in action status: Success and applicability state: NotApplicable",
"action": "updateStateSkipped",
"description": "Detects when an app's applicability check returns NotApplicable (requirements not met).",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-NOT-APPLICABLE-2",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
"action": "updateStateSkipped",
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-NOT-APPLICABLE-3",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
"action": "updateStateSkipped",
"description": "Detects when an app's desired state is set to None (not targeted or not applicable). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-NOT-APPLICABLE-4",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
"action": "updateStateSkipped",
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-NOT-TARGETED-1",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, targeted intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, targeted intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
"action": "updateStateSkipped",
"description": "Detects when an app with RequiredInstall intent is classified as NotApplicableOrNotTargeted (new log format).",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-NOT-TARGETED-2",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, effective intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, effective intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
"action": "updateStateSkipped",
"description": "Detects when an app with effective RequiredInstall intent is classified as NotApplicableOrNotTargeted.",
"enabled": true
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-POLICIES",
"category": "currentPhase",
"pattern": "Get policies =\\s*(?<policies>.*)",
"pattern": "^Get policies =\\s*(?<policies>.*)",
"action": "policiesDiscovered",
"description": "Captures the JSON policies list discovered by IME, used to build the app inventory.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-RESTART-STOP-PROCESSING",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[V3Processor\\] A restart is required to continue processing apps\\. Stop processing\\.",
"pattern": "^\\[Win32App\\]\\[V3Processor\\] A restart is required to continue processing apps\\. Stop processing\\.",
"action": "updateStatePostponed",
"parameters": { "useCurrentApp": "true" },
"description": "Detects IME aborting app processing because an app requires a restart ('A restart is required to continue processing apps. Stop processing.', SideCarV3AppProcessor.cs). Postpones the current app: remaining apps do nothing until after the reboot, which previously looked like an unexplained stall mid-ESP.",
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-SESSION-CHANGE",
"category": "always",
"pattern": "OnSessionChange:? (?<change>.*?)$",
"pattern": "^OnSessionChange:? (?<change>.*?)$",
"action": "imeSessionChange",
"description": "Detects Windows session change events (logon, logoff, lock, unlock) reported by IME.",
"enabled": true
@@ -1,9 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-SET-CURRENT-1",
"category": "always",
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name",
"action": "setCurrentApp",
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -1,9 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-SET-CURRENT-2",
"category": "always",
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?\\bid\\W+{GUID}",
"action": "setCurrentApp",
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -1,9 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-SET-CURRENT-3",
"category": "always",
"pattern": "\\[Win32App\\] ===Step=== Start to Present app {GUID}",
"action": "setCurrentApp",
"description": "Detects the start of app presentation step in IME, setting the app as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-SET-CURRENT-4",
"category": "always",
"pattern": "\\[Win32App\\] SetCurrentDirectory:.*?\\\\{GUID}_\\d+",
"pattern": "^\\[Win32App\\] SetCurrentDirectory:.*?\\\\{GUID}_\\d+",
"action": "setCurrentApp",
"description": "Detects when IME sets the working directory for an app's staging folder, setting it as current.",
"enabled": true
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-SHUTDOWN",
"category": "currentPhase",
"pattern": "EMS Agent received shutdown signal",
"pattern": "^EMS Agent received shutdown signal",
"action": "updateStatePostponed",
"parameters": { "useCurrentApp": "true" },
"description": "Detects when the IME agent receives a shutdown signal, postponing the current app installation.",
@@ -1,9 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-SKIP-APPLICABILITY-NOTMET",
"category": "currentPhase",
"pattern": "\\[Win32App\\] (?=.*?\\bapplicability\\b)(?=.*?NotMet\\b).*?\\bapp\\W+{GUID}\\b",
"action": "updateStateSkipped",
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -1,9 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-SKIP-DEPENDENCY-DETECT",
"category": "currentPhase",
"pattern": "\\[Win32App\\] app\\W+{GUID}\\b.*?\\bis a dependency\\b.*?\\bDetect Only mode\\b.*?\\bfinished processing",
"action": "updateStateSkipped",
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -1,9 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-SKIP-DETECTED",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Detected app {GUID}.*?skip next step",
"action": "updateStateSkipped",
"description": "Detects when an already-detected app is skipped for further processing steps (old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-SKIP-FILTERS",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[V3Processor\\] All apps in the subgraph are not applicable due to assignment filters\\. Skipping processing\\.",
"pattern": "^\\[Win32App\\]\\[V3Processor\\] All apps in the subgraph are not applicable due to assignment filters\\. Skipping processing\\.",
"action": "updateStateSkipped",
"parameters": { "useCurrentApp": "true" },
"description": "Detects when all apps in a subgraph are skipped due to assignment filter exclusions.",
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-SKIP-INSTALL-DETECTED",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Detected app {GUID} and intent is to install, skip applicability check",
"pattern": "^\\[Win32App\\] Detected app {GUID} and intent is to install, skip applicability check",
"action": "updateStateSkipped",
"description": "Detects when an app targeted for install is already detected/installed, so the install is skipped (old log format).",
"enabled": true
@@ -1,9 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-SKIP-UNINSTALL-NOTDETECTED-2",
"category": "currentPhase",
"pattern": "\\[Win32App\\] NOT detected app {GUID}.*?skip un-installation",
"action": "updateStateSkipped",
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-SKIP-UNINSTALL-NOTDETECTED",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Not detected app {GUID} and intent is to un-install, skip applicability check",
"pattern": "^\\[Win32App\\] Not detected app {GUID} and intent is to un-install, skip applicability check",
"action": "updateStateSkipped",
"description": "Detects when an app targeted for uninstall is not detected, so the uninstall is skipped (old log format).",
"enabled": true
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-STARTED",
"category": "always",
"pattern": "EMS Agent Started",
"pattern": "^EMS Agent Started",
"action": "imeStarted",
"description": "Detects when the Intune Management Extension (IME) agent starts up.",
"enabled": true
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-STOP-SIGNAL",
"category": "currentPhase",
"pattern": "EMS Agent received stop signal",
"pattern": "^EMS Agent received stop signal",
"action": "updateStatePostponed",
"parameters": { "useCurrentApp": "true" },
"description": "Detects an IME service stop ('EMS Agent received stop signal', EmsAgentService.OnStop) — fired on manual restarts and agent-updater restarts, unlike the OS-shutdown-only 'shutdown signal' line matched by IME-SHUTDOWN. Postpones the current app installation, mirroring IME-SHUTDOWN semantics.",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-SUBGRAPH",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[V3Processor\\] Processing subgraph with app ids: {GUID}",
"pattern": "^\\[Win32App\\]\\[V3Processor\\] Processing subgraph with app ids: {GUID}",
"action": "cancelStuckAndSetCurrent",
"description": "Detects when V3Processor starts a new subgraph, cancelling any stuck app and setting the new app as current.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-TOKEN-FAILURE",
"category": "always",
"pattern": "Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
"pattern": "^Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
"action": "imeTokenFailure",
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
"enabled": true
@@ -2,7 +2,7 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-TOKEN-SUCCESS",
"category": "always",
"pattern": "Successfully get the token",
"pattern": "^Successfully get the token",
"action": "espPhaseDetected",
"parameters": { "phase": "AccountSetup" },
"description": "Detects successful token acquisition, indicating the AccountSetup ESP phase has started.",
@@ -1,9 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-UPDATE-NAME-1",
"category": "currentPhase",
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?name\\W+(?<name>.*?)\\W+id\\W+{GUID}",
"action": "updateName",
"description": "Captures the app name from ExecManager processing log (old format: name before id). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -1,9 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-UPDATE-NAME-2",
"category": "currentPhase",
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name (?<name>.*?)$",
"action": "updateName",
"description": "Captures the app name from ProcessAppWithDependencies log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -1,9 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-UPDATE-NAME-3",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Processing app \\(id={GUID}, name = (?<name>.*?)\\)",
"action": "updateName",
"description": "Captures the app name from the Processing app log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}

Some files were not shown because too many files have changed in this diff Show More