Compare commits

...

94 Commits

Author SHA1 Message Date
Oliver Kieselbach 2109133aee bump pointer: internal, .claude/commands (TPM-PSS TLS probe, D-372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:23:07 +02:00
Oliver Kieselbach 7ac7a5e8ba TPM-PSS distress carries a TLS probe: protocol, whether the certificate went out, who answered (D-372)
tls= was empty on every report: .NET Framework's SecureChannelFailure
carries no inner Win32 code. After a terminal SecureChannelFailure the
agent now opens one direct SslStream connection to the backend host with
the client certificate (system-default protocols, normal server
validation, never a proxy) and sends one GET of the agent config with the
agent's headers - the platform asks for the certificate only once it has
seen the path. The message carries tlsp=<protocol>,<m|->,<answer>:
b<status> only when the probe's own correlation id comes back (shared
ResponseOriginClassifier), cc<status> for the platform's "Client
Certificate Required" page, st/x/io otherwise; or unreachable,
hs(<SSPI code>), hs(srvcert), timeout, error. A server certificate not
issued by Microsoft appends ,i=<issuer CN> (TLS inspection).

The probe runs beside the TPM and Schannel reads, all three within 10 s
(4 s per network step), on the failure path only. The Schannel window now
ends when the failure is detected (ReadBetween), so the probe's own
handshake never shows up in sch=. A connect that fails after its timeout
is observed (an unobserved task exception would write crash artefacts);
an answer that began is kept when the server does not close.

Live, .NET Framework 4.8 against production without a certificate:
tls12,-,cc403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:22:56 +02:00
Oliver Kieselbach 014b3e1e83 bump pointer: .claude/commands (platform-health catalog notes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:18:26 +02:00
Oliver Kieselbach b948d32c9a bump pointer: internal, .claude/commands (TPM-PSS precision, D-372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:26:43 +02:00
Oliver Kieselbach 40d3922fe0 TPM-PSS distress says where the device stood when the failure began: ESP phase, OOBE flag, boot; a later success compares reboot and certificate (D-372)
The first reports with context all carried OobeState=completed: Windows'
OOBE flag flips at the desktop, was read at send time after minutes of
retries, and marks the later successful starts too, so it separated
nothing.

Agent: EspPhaseSnapshot reads the ESP registry once with the tracker's
own parser (device_preparation / device_setup / account_setup while
running, <category>_failed, *_done, esp_done, none, unavailable).
DistressDeviceContext (OOBE flag, ESP phase, boot from the uptime
counter) is captured before a registration's first attempt and travels
with the TPM-PSS report; other reports sample it at send time. The
reporter takes a DistressDetail (TPM version, context). sch= is always
set: the events, none, unreadable or timeout. The failed-run record keeps
the ESP phase and OOBE flag, and beside it - never on the wire - the boot
time and the client-certificate thumbprint; the next successful start's
agent_started reports priorRegistrationLastEspPhase, LastOobeState,
RebootedSince (boots more than 2 min apart) and CertChanged, and every
start reports espPhaseAtAgentStart.

Shared/backend: DistressReport.EspPhase (kept as [a-z_] up to 32) and
DeviceBootUtc (kept within 30 days before and 5 minutes after the report),
stored, logged and on the custom event; the TPM panel item carries the ESP
phase. The GA distress detail names the OOBE flag as such and shows ESP
phase and device boot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:26:41 +02:00
Oliver Kieselbach 37bd4c73f5 chore(deps-dev): bump vite to 8.3.4 in web and MCP
Closes Dependabot alerts #123 and #124 (GHSA-698v-4r3x-3vcv, launch-editor
escapeCmdArgs() omits % escaping on Windows). Lockfile-only: vite is a
transitive dev dependency of vitest, whose range already allows 8.3.x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:21:07 +02:00
dependabot[bot] 9f4c1efe26 chore(deps): bump hono (#190)
Bumps the mcp-security group with 1 update in the /src/McpServer/autopilot-monitor-mcp directory: [hono](https://github.com/honojs/hono).


Updates `hono` from 4.13.7 to 4.13.13
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.13.7...v4.13.13)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.13.13
  dependency-type: indirect
  dependency-group: mcp-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 21:11:26 +02:00
Oliver Kieselbach 13a34fb7f1 bump pointer: internal (CI credential isolation released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:44:57 +02:00
Oliver Kieselbach 60a41e9778 CI: signing jobs run in the release environment
The code-signing identity trusts only environment:release now, so build-agent
release, build-msi sign-loader and release, and publish-scripts publish run in it.
Every job that federates to Azure carries an environment; the ref is no longer a
trusted subject. Help texts of the storage and endpoint helpers follow: the local
build publishes nothing since D-354.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:30:02 +02:00
Oliver Kieselbach 9aa5e46e22 bump pointer: internal (TPM-PSS distress released, D-370)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:36:32 +02:00
Oliver Kieselbach 3ba4a10031 bump pointer: internal, .claude/commands (TPM-PSS distress, D-370)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:56:56 +02:00
Oliver Kieselbach fd0ae98868 TPM-PSS distress says when and where, the tenant panel shows devices that connected later (D-370)
A TpmPssUnsupported report classifies the registration start that failed,
not the device: devices with older TPM firmware fail during setup and
connect after it, on 24H2, 25H2 and 26H2 alike.

Agent: every distress report carries the OS build and the Windows setup
state at send time; the TPM-PSS report adds the TPM manufacturer and
firmware and a language-neutral message with the NCrypt codes of the PSS
signs, the native code of the failed handshake and up to two Schannel
System-log events since the registration started (numeric properties
only). The TPM and System-log reads run in parallel for at most 5 s. The
send is awaited, since the run exits right after it. An attempt that got
an answer resets the last exception, so the probe never classifies an
earlier attempt. The run's last error carries the native code. Win32_Tpm
is read by one shared TpmInfoReader (tpm_status unchanged).

Shared/backend: one body cap for both sides (DistressReportMaxBytes 2048);
the agent shortens its message to stay within it. The endpoint keeps OS
build and setup state only in their format, stores the three fields and
adds them to the log and the custom event. GET audit/tpm-pss-unsupported
returns TPM version, OS build, setup state and connectedAt: the last
upload of a certificate-authenticated session of the serial after the
device's last report (bootstrap sessions excluded, six serials per
SessionsIndex query). The bell says the device could not connect and may
connect after setup and a restart.

Web: the Incompatible TPM panel shows TPM and Status columns, uses the
generated types and no longer says "never" or names a Windows version; the
GA distress detail shows the new fields, Cloud PC and the certificate.

Tests: probe message, Schannel tokens, native codes, payload cap, awaited
send, context bound, last-exception reset, connectedAt rule, filter
comparison limit, repository round trip, panel render. A device serial
from production data in a test fixture is replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:56:43 +02:00
Oliver Kieselbach 8101aa1e50 bump pointer: internal (push encryption claim in the docs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:02:55 +02:00
Oliver Kieselbach cf3647da49 bump pointer: internal (docs descriptions within GitBook's 200-character limit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:34:40 +02:00
Oliver Kieselbach cafb1e9a33 bump pointer: internal (push customer release live, D-368, D-369)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:28:33 +02:00
Oliver Kieselbach d38265071d bump pointer: internal, .claude/commands (push customer release built, D-368, D-369)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:14:12 +02:00
Oliver Kieselbach 40d88ef1e0 Push notifications for every tenant: one Push channel per scope that gates every send, app-role members equal, alerts name the tenant (D-368, D-369)
- Tenant Admins add the Push channel; Telegram and e-mail stay Global-Admin-only
- One Push channel per scope on the tenant and ops paths; the dispatcher fans Push out once per send
- The channel also gates session-watch completions and the device test; paired devices stay listed while it is off
- Members whose role comes from an Entra app role pair and receive like table members; a paused device resumes at the owner's next sign-in
- SLA and failure-streak alerts name the tenant; the id moves to a Tenant ID fact
- The legacy single webhook accepts URL webhook providers only
- Portal: Conditional Access hint in the channel editor, neutral role texts
- Landing: push section with the Notify me when done animation and the hero announcement; SignalR is "live updates"
- Privacy, about and FAQ describe push notifications

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:13:58 +02:00
Oliver Kieselbach 14d1266b89 bump pointer: internal (target prefetch removal verified in production, D-366)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:48:43 +02:00
Oliver Kieselbach 0730373e9b bump pointer: internal (target prefetch removed, D-366)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:31:17 +02:00
Oliver Kieselbach 03f4470db6 The sign-in return no longer prefetches the target route (D-366)
Measured in production with 300 ms latency, router.prefetch loaded only the route tree while auth/me was in flight, and the replace still fetched the route payload after auth/me: no gain on the critical path, one to three extra requests per sign-in return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:31:16 +02:00
Oliver Kieselbach 697077c7e5 bump pointer: internal (sign-in return and preflight cache released, D-366, D-367)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:18:35 +02:00
dependabot[bot] befc101d8f chore(deps-dev): bump knip (#187)
Bumps the web group in /src/Web/autopilot-monitor-web with 1 update: [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip).


Updates `knip` from 6.40.0 to 6.41.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.41.0/packages/knip)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.41.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 10:03:08 +02:00
dependabot[bot] 7250baca02 chore(deps-dev): bump jscpd (#189)
Bumps the quality-tooling group in /tools/quality with 1 update: [jscpd](https://github.com/kucherenko/jscpd/tree/HEAD/rust/jscpd).


Updates `jscpd` from 5.4.0 to 5.4.1
- [Release notes](https://github.com/kucherenko/jscpd/releases)
- [Commits](https://github.com/kucherenko/jscpd/commits/v5.4.1/rust/jscpd)

---
updated-dependencies:
- dependency-name: jscpd
  dependency-version: 5.4.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: quality-tooling
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 10:02:53 +02:00
dependabot[bot] fa1809d910 chore(deps): bump the mcp group (#188)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 4 updates: [express](https://github.com/expressjs/express), [zod](https://github.com/colinhacks/zod), [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `express` from 5.2.1 to 5.3.0
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/v5.2.1...v5.3.0)

Updates `zod` from 4.6.5 to 4.6.7
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.6.5...v4.6.7)

Updates `knip` from 6.40.0 to 6.41.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.41.0/packages/knip)

Updates `typescript-eslint` from 8.71.1 to 8.71.2
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.2/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: express
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: zod
  dependency-version: 4.6.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: knip
  dependency-version: 6.41.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: typescript-eslint
  dependency-version: 8.71.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 10:02:41 +02:00
Oliver Kieselbach 9a718539fd bump pointer: internal, .claude/commands (sign-in return and preflight cache, D-366, D-367)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:46:15 +02:00
Oliver Kieselbach a1ba1fd1ab Sign-in returns no longer show the public landing, and API preflights are cached for two hours (D-366, D-367)
- MSAL's in-flight redirect state stays in its tab. storeAuthStateInCookie mirrored it into origin-wide cookies that MSAL reads first, so two tabs signing in at once overwrote each other and the first one failed with state_mismatch, stuck on the landing. A guard test pins the cache config.
- AuthGate keeps the auth-pending overlay for as long as the browser is signed in, so it lifts only once the target route has committed; it used to lift when loading ended and exposed the landing for the whole client navigation. The target's route payload is prefetched while auth/me is in flight.
- A sign-in return that carried a code but ends without an account starts one fresh sign-in from "/", keeping the saved deep link; any further failure in the tab hands over to ProtectedRoute's sign-in card on the deep link, which waits for a click. The auth response is read at module load (the router writes its hydration-time URL back), the tab's temporary MSAL keys are purged before a retry and before the card's sign-in, and loading holds while a redirect leaves the page. New events: auth_redirect_recovered, auth_redirect_recovery_exhausted, auth_redirect_failed (error codes only).
- host.json adds Access-Control-Max-Age: 7200 through the http customHeaders. The Functions host answers every preflight itself and its CORS policy carries no max-age; its custom-headers middleware runs outside the CORS middleware, so the header lands on the preflight too. A guard test pins the entry to exactly that header. The CORS startup log goes: it read settings this host does not use, at a level that never reached the telemetry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:46:03 +02:00
Oliver Kieselbach 94399170d8 bump pointer: internal (GA Customers dashboard released, D-365)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:36:02 +02:00
Oliver Kieselbach 824bc64f96 bump pointer: internal (GA Customers dashboard, D-365; IME diff note)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:17:21 +02:00
Oliver Kieselbach a7c3c16054 Global Admin sees new customers per day and the growth over time under Metrics > Customers (D-365)
The section reads the tenant configurations from config/all and derives everything in the browser: no endpoint, no counter, no backfill.

- Customers: new tenants per UTC day (per Monday-anchored week beyond 90 days), the window's best day, weekly average, the last 7 days, the total and the cumulative gain; 7/30/90/365 days in ?days=; clicking a column lists that bucket's tenants with their edition. A signup is a configuration row with a domain. The section is the first Metrics entry and the target of /admin/metrics.
- Charts: AppBarChart takes a category formatter, a custom tooltip, a column click and whole-number ticks; AppLineChart can hide its legend and use whole-number ticks. Existing callers get the same props as before.
- The Monday-of-week helper moves from the Fleet Health timeline into lib/timeWindow; the Stat tile duplicated in two metrics sections becomes one component, and the duplicate baseline loses that entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:15:29 +02:00
Oliver Kieselbach 525be338b8 bump pointer: internal (self-service assignee membership gate released, D-364)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:52:44 +02:00
Oliver Kieselbach 734ca2b8fc bump pointer: internal (D-364 dead code removed, texts approved, lesson)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:33:23 +02:00
Oliver Kieselbach 2a74395874 Dead delegated-admin and repository members go: status transition, single-row reads, MCP-user flag
None of them had a production caller. DelegatedAdminService.GetAssignmentsForUpnAsync and GetAssigneesForTenantAsync, DelegatedAdminService.SetStatusAsync with IAdminRepository.SetDelegatedAdminStatusAsync (only a test called it; the operator revoke is RemoveAsync), and IAdminRepository.GetDelegatedAdminAsync and IsMcpUserAsync, which only had their own implementation.

Revoke_InvalidatesCache now pins the revoke the operator route runs (RemoveAsync); Disable_InvalidatesCache covers SetEnabledAsync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:32:55 +02:00
Oliver Kieselbach 012eb8c057 bump pointer: internal (self-service assignee membership gate, D-364)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:52:56 +02:00
Oliver Kieselbach 0c1b83268c A self-service assignee reads the managed tenants only while an enabled member of the managing tenant (D-364)
Removing or disabling a member under Access Management left their assignment to the tenant's self-service group in place, and the delegated scope never re-checked the membership, so the person kept reading every managed customer.

- Scope: an assignment to msp-{owner} confers the group's tenants only while the assignee holds an enabled TenantAdmins row in the owner (inside the cached row resolution, fail-closed, Warning when ignored). Operator groups and direct grants are unchanged.
- Access Management: remove and disable end the self-service assignment (DelegatedGroupAccess DELETE under every managed tenant, reason member-removed / member-disabled) before the existing stream cut. Re-enabling restores nothing.
- Assigning requires an enabled member and re-reads the membership after the write; the Global Admin group route follows the same rule for a self-service group (enabled member of the owner, bound to it, read-only).
- The SignalR group join resolves the delegated scope uncached, so the re-join right after a revoke cannot be admitted by another instance's warm cache.
- Web: Delegated Access offers only enabled members and says that removing or disabling a member ends their access; the Global Admin page hides the role select for self-service groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:52:39 +02:00
Oliver Kieselbach 1088b36b8b A malformed device error code no longer fails the session events read
ErrorCodeCatalog.TryLookupDetailed normalised any 0x-prefixed value without
checking its characters and then ran int.Parse on the low word, so a value
such as 0x8007zzzz threw FormatException. The backend enricher runs that
lookup over device-authored event data on every GET /api/sessions/{id}/events,
so one such event answered HTTP 500 for every reader of the session timeline.

- The catalog parses the hex body once with uint.TryParse (hex digits only)
  and derives the HRESULT_FROM_WIN32 low word from the number; it now returns
  null for any input instead of throwing.
- ErrorCodeEnricher.EnrichEvents isolates each event: a failed lookup leaves
  that event unenriched, logs a warning and the page still returns 200.
- Web errorCodeMap.ts gets the same hex-only check: parseInt read
  0x8007643z as 1603 and labelled the garbage ERROR_INSTALL_FAILURE.
  MCP already validated; a test pins it.

Tests: the reported shapes return null, an exhaustive sweep of short tails
after every prefix never throws, and the enricher keeps the rest of the page
enriched when one event fails. Against the previous catalog 10 of the new
tests fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:02:53 +02:00
Oliver Kieselbach dfd1b86e01 bump pointer: internal (delegated reach on operator routes live, D-363)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:21:56 +02:00
Oliver Kieselbach b8a663e11d bump pointer: internal (delegated reach on operator routes, D-363)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:03:58 +02:00
Oliver Kieselbach 38b499b80b Operator reads on global routes stay with platform roles; the middleware decides on the tenantId the handler reads (D-363)
Five platform reads take ?tenantId= only as a filter, but their QueryParam
scoping also admitted a delegated (MSP) reader of the named tenant:
global/session-reports, global/rule-submissions, global/metrics/mcp-usage,
global/metrics/mcp-usage/daily and global/metrics/verdict-calibration. They
carry operator data (admin notes, contact data and reviews, an organization's
accounts, classifier diagnostics), so they are catalogued None now; the
handlers still read ?tenantId= themselves, and Global Admins and Global
Readers keep the filter.

- A catalog ratchet in PolicyEnforcementMiddlewareTests lists every platform
  route a delegated caller reaches; a new one fails the build until it is
  added on purpose. The TenantScoping.QueryParam doc states the rule.
- The middleware took the cross-tenant decision on the first ?tenantId=
  value while the handlers read all values comma-joined. It now decides on
  the joined string, with ASP.NET Core's parser (the handlers parse Url.Query,
  where System.Uri has already escaped a stray '%').
- The rule pages show a delegated reader the admin-only hint instead of the
  submission list, as they already did for an Operator or Viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:03:47 +02:00
Oliver Kieselbach 9252b8081f bump pointer: internal (rating feedback ops event live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:44:17 +02:00
Oliver Kieselbach ba92811f45 bump pointer: internal, .claude/commands (rating feedback ops event, D-362)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:24:38 +02:00
Oliver Kieselbach 0b813891e4 Rating feedback raises its own ops event; Alert Rules in the Ops menu with a search (D-362)
A rating from the feedback bubble reached only the direct Telegram ping: it was the one customer
submission without an ops event and a bell. It now raises FeedbackRatingReceived (Tenant, Info) and a
Global Admin bell, so an alert rule can route it to the Push channel. Message and details carry the
stars and the comment length, never the comment or the sender; the Telegram ping stays until it is
retired. The answer route admits members only (MemberRead, like feedback/general): it now feeds an
operator alert, and the bubble only ever asks members.

The alert rules page was linked from no menu. Ops > Alert Rules points at it, and a search in its
header narrows the rules by event type and category; it filters the display only, so a save keeps
every rule. navSectionReachability.test.ts holds the sidebar and the section registries together in
both directions. The ops events page and the rules search share one SearchInput.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:24:18 +02:00
Oliver Kieselbach dc4f6ed28b bump pointer: internal (tenant onboarding checklist)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:56:06 +02:00
Oliver Kieselbach 3564e5ba88 bump pointer: internal (app failure-rate base live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:43:08 +02:00
Oliver Kieselbach de00471da9 bump pointer: internal (app failure-rate base released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:07:31 +02:00
Oliver Kieselbach 9b3e9e3900 fix: app failure rates show the finished installs they are computed from
The device model table on the app detail page put the terminal-only failure
rate (failed / (failed + succeeded)) next to an Installs count that also held
in-progress, incomplete and skipped rows, so 11 failed of 28 read as 50%
without a way to check it.

- Version and device-model rows carry Succeeded on the wire.
- The device model table shows Finished (the rate's base) with a muted
  "+N not counted"; the Total card names the same rows.
- The PDF report gets Finished and Not counted columns in both tables and
  the hint on Total installs.
- The device-model sample floor counts exactly the rate's base; skipped rows
  no longer count toward it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:06:37 +02:00
Oliver Kieselbach 58e9f19f82 fix(web): a What's new link opens the panel after the sign-in round trip too
A portal link with ?whats-new= opened without a session flashed the panel,
went through the sign-in, and came back without it: MSAL lands on the
origin root and AuthGate reaches the saved link by a client-side
navigation, while WhatsNewPanelHost (root layout, stays mounted) read the
parameter only once on mount. It now follows the search parameter and
opens once per arrival; a protected page waits for the session, so the
panel no longer flashes before the redirect or counts the open twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:01:10 +02:00
Oliver Kieselbach 8e000f1849 bump pointer: internal (usage metrics breakdown released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:40:09 +02:00
Oliver Kieselbach 77de8c92c6 bump pointer: internal (usage metrics breakdown, D-361)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:20:56 +02:00
Oliver Kieselbach 17fc23f8cb feat: usage metrics split enrollments by type, join and deployment mode (D-361)
Usage Metrics (tenant page and the platform section) shows the window's
sessions in three groups that each sum to the session count:
Autopilot v1 or Device Preparation, Entra or hybrid joined, and the
Autopilot deployment mode (user-driven, self-deploying, pre-provisioned).
Type and mode follow the fleet enrollment class; user-driven no longer
counts the IsUserDriven flag, which every registration sets. The counts
come from the session rows on each request, so every retained session is
covered without a counter.

- DeploymentTypeMetrics gains the new counts and shares; the usage
  projection reads EnrollmentType, IsSelfDeployingProfile, IsHybridJoin
- platform and tenant compute share one builder per block
- Platform Usage takes ?days= like the other analytics pages
- the usage pages share their blocks (components/UsageMetricsBlocks.tsx);
  enrollment class labels and the time-attribution segments each live in
  one place, the duplication baseline shrinks by seven clones

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:19:48 +02:00
Oliver Kieselbach e046571350 bump pointer: internal (push deep links confirmed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:45:47 +02:00
Oliver Kieselbach 6b5c55998f bump pointer: internal (push deep links live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:38:49 +02:00
Oliver Kieselbach 6df57459c2 bump pointer: internal (push deep links, D-360)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:19:34 +02:00
Oliver Kieselbach caeb7a3e58 feat: every alert links to its subject; ops alerts open their own event (D-360)
A push carries only category, event and severity for an ops alert, so the
portal link is the way to the details, and ops alerts had none: the event
id was minted inside the save and never reached the dispatch.

Backend: SaveOpsEventAsync sets OpsEventEntry.Id (OpsEventId,
{Category}_{RowKey}); WriteAsync hands it to the dispatch, and BuildAlert
adds "Open in the portal" -> /admin/ops/?eventId=<id> on the plain and the
payload path, so every ops channel (push, Telegram, Teams, Slack, Discord,
e-mail, JSON) gets it. GET global/ops-events?id= is a point read (400 for a
malformed id, an empty list for a deleted one). hardware_rejected links to
the hardware whitelist (one path constant, also for the bell); the session
watch link names the session's tenant; TenantSignup's portalPath keeps its
tenant query. Only a session link makes a push session-bound: tag and Topic
no longer key on any "id=" in the link.

Web: /admin/ops?eventId= opens that event's detail from the point read
(matched by id), and the detail links to the tenant and to the event's
related page (same-origin paths only) besides the session, which now
carries the tenant. CopyButton and the cursor pager footer are shared
components instead of four and three copies; the duplicate baseline shrinks
by five entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:19:22 +02:00
Oliver Kieselbach bf37fe46c1 bump pointer: internal (session block visibility RELEASED)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:43:24 +02:00
Oliver Kieselbach 3678995a9b bump pointer: internal (session block visibility, D-359)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:27:56 +02:00
Oliver Kieselbach 7a917eeaee feat: a session the platform blocked says so (D-359)
When the event limit or the platform operator blocks a session's device, the
session now carries a block record: Block* fields on the session and its index
row, a server-authored device_blocked timeline event, an interim analyze run for
that event and, on the auto path, an audit entry. The block list itself stays
platform-only.

- Portal: a card above the failure snapshot says the monitoring was paused
  (orange) or ended (red), when, until when and, for the event limit, the
  session's event count against the limit. The Blocked pill reads the record for
  every role; Global Admin mode still adds the live block list.
- A Kill is device-wide until it expires, so its record carries the end and the
  card says new enrollments on the device are not monitored until then.
- Status: the stalled and timeout reasons name the block instead of a silent
  agent; the classification is unchanged.
- Rules: ANALYZE-ENRL-006 explains the pause right after it (on_event
  device_blocked); ANALYZE-ENRL-002 no longer fires for a blocked session.
- MCP: the coverage block of get_session_summary names the block as a gap.
- The agent-facing block message no longer says "by an administrator".
- BlockCause == event_limit replaces ExcessiveEventsAutoActioned as the
  auto-action's idempotency marker.
- Privacy page: the event limit next to rate limiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:27:55 +02:00
dependabot[bot] d5a83374d0 chore(deps): bump the web group (#186)
Bumps the web group in /src/Web/autopilot-monitor-web with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@microsoft/applicationinsights-web](https://github.com/microsoft/ApplicationInsights-JS) | `3.4.4` | `3.4.5` |
| [next](https://github.com/vercel/next.js) | `16.3.8` | `16.4.0` |
| [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) | `16.3.8` | `16.4.0` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.3.8` | `16.4.0` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `6.39.0` | `6.40.0` |


Updates `@microsoft/applicationinsights-web` from 3.4.4 to 3.4.5
- [Release notes](https://github.com/microsoft/ApplicationInsights-JS/releases)
- [Changelog](https://github.com/microsoft/ApplicationInsights-JS/blob/main/RELEASES.md)
- [Commits](https://github.com/microsoft/ApplicationInsights-JS/compare/v3.4.4...v3.4.5)

Updates `next` from 16.3.8 to 16.4.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.8...v16.4.0)

Updates `@next/bundle-analyzer` from 16.3.8 to 16.4.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.4.0/packages/next-bundle-analyzer)

Updates `eslint-config-next` from 16.3.8 to 16.4.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.4.0/packages/eslint-config-next)

Updates `knip` from 6.39.0 to 6.40.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.40.0/packages/knip)

---
updated-dependencies:
- dependency-name: "@microsoft/applicationinsights-web"
  dependency-version: 3.4.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: next
  dependency-version: 16.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: knip
  dependency-version: 6.40.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 15:12:49 +02:00
dependabot[bot] 596a9ffc2b chore(deps): bump the mcp group (#185)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 4 updates: [@modelcontextprotocol/server](https://github.com/modelcontextprotocol/typescript-sdk), [@modelcontextprotocol/client](https://github.com/modelcontextprotocol/typescript-sdk), [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `@modelcontextprotocol/server` from 2.3.0 to 2.3.1
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/@modelcontextprotocol/server@2.3.0...@modelcontextprotocol/server@2.3.1)

Updates `@modelcontextprotocol/client` from 2.3.0 to 2.3.1
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/@modelcontextprotocol/client@2.3.0...@modelcontextprotocol/client@2.3.1)

Updates `knip` from 6.39.0 to 6.40.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.40.0/packages/knip)

Updates `typescript-eslint` from 8.71.0 to 8.71.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@modelcontextprotocol/server"
  dependency-version: 2.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: "@modelcontextprotocol/client"
  dependency-version: 2.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: knip
  dependency-version: 6.40.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: typescript-eslint
  dependency-version: 8.71.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 15:12:31 +02:00
Oliver Kieselbach 0e1428922f bump pointer: internal (diagnosis plan RELEASED)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:36:43 +02:00
Oliver Kieselbach 10314cd8f2 bump pointer: internal (diagnosis plan: live, visual check open)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:35:28 +02:00
Oliver Kieselbach 9ee882ab68 bump pointer: internal (diagnosis plan: customer docs approved and committed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:55:13 +02:00
Oliver Kieselbach eefa365b6c bump pointer: internal (diagnosis plan: docs drafted)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 03:30:35 +02:00
Oliver Kieselbach 21ca9eb068 bump pointer: internal (only an InProgress session stalls)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 03:28:53 +02:00
Oliver Kieselbach af86e8f7cc Sessions: only an In Progress session stalls
A stall report never overwrote a guard: the ingest branch for session_stalled
runs after the completion and failure chain, and the transition gate let
Stalled through over every status but Pending. An offline batch carrying
session_stalled next to enrollment_complete left a finished enrollment
Stalled until the timeout sweep reclassified it, and a sweep that lost the
race against a completion could do the same.

IsTerminalTransitionAllowed now allows Stalled only over InProgress, on the
normal and the ETag-retry path alike; the separate Pending guard is folded
into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 03:28:52 +02:00
Oliver Kieselbach 8c5fbd5739 bump pointer: internal (diagnosis headline concept doc, backlog side findings, plan phase 6 internal), .claude/commands (rule-builder presentation)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 03:19:30 +02:00
Oliver Kieselbach 55b667ad2c bump pointer: internal (D-358, diagnosis plan phase 5), .claude/commands (GetAppFleetContext marker, open_fleet_sessions)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 03:14:56 +02:00
Oliver Kieselbach 325632e780 Diagnosis: the culprit app in the tenant's other enrollments, and the rule line beside each finding (D-358)
GET apps/fleet-context (MemberRead, QueryParam) counts one app over the
tenant's last 7 days: installed, skipped, failed, incomplete, in progress,
collision rows left out, the three most frequent failure codes. It reads
the apps dashboard's partition query, leaves out the asking session and,
with the culprit's app id, another app of the same name; no session point
reads. AppFleetContextService caches the app's rows per tenant, channel,
name and minute for 60 s (single flight).

The classification is the analytics view's: MetricsMath.IsInstalled
replaces six inline copies, AppsAnalyticsHelper.IsSameApp and
TopFailureCodes are shared by analytics, sessions and the fleet context.

The diagnosis page shows the result as a tile in "Around the failure"
only when other enrollments exist (the read fails soft), with "Show
sessions" on the app page where that page shows the viewed tenant. An
expanded finding shows "Fired in N enrollments in the last 14 days"
through useRuleFireStats and RecentFiresLine, both extracted from the
session page, whose cross-tenant check now ignores case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 03:14:55 +02:00
Oliver Kieselbach 9ad3cb2df1 bump pointer: internal (diagnosis plan phase 4, D-356/D-357 amended, drift guards), .claude/commands (diagnosis telemetry rows)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:30:53 +02:00
Oliver Kieselbach 127d5c1173 Diagnosis: the page answers what happened, for every status with a verdict (D-356, D-357)
The diagnosis page is rebuilt on the server headline instead of rule
confidence: a verdict card per status and cause (culprit tiles, ESP
phases, what an unfinished enrollment still waits for), the
circumstances around it, the steps of the finding that explains it,
where the time went, all open findings grouped by role, team notes and
the deduplicated errors and warnings. It reuses the session page's hook
set, refetches the headline on live activity and opens for Failed,
Stalled, AwaitingUser, Incomplete and Succeeded with open findings, also
from read-only and cross-tenant views.

Rule text: lib/ruleText.ts prunes a finding's explanation and steps like
the notifications do (shared cases in tests/fixtures/rule-text-display,
also run by RuleTemplateInterpolator.InterpolateForNotification), then
parses the markdown and fills values into text nodes only. The session
page's analysis section, the rule card and the dry run render through it;
components/findings/EvidenceChips replaces the two evidence-link copies
and covers correlation pairs.

Vocabularies: DiagnosisPrerequisites names the engine's completion
prerequisites (manifest diagnosis.prerequisites, parity fact against
DecisionEngine.CompletionPrerequisites); the page's texts for every
diagnosis vocabulary are exhaustive over the manifest types.

Fixed on the way: useSessionSignalR held the first render's callbacks,
so ruleResultsReady was lost on an already connected hub; a deep link to
an event dropped its hash before the event loaded; the global analyze
rule update answered 500 for an invalid presentation (now 400); the
rule editor kept the presentation block on create and in JSON mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:30:29 +02:00
Oliver Kieselbach 6df88abdb3 bump pointer: internal (D-357, diagnosis plan phase 3), .claude/commands (GetSessionDiagnosis marker)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:48:07 +02:00
Oliver Kieselbach 5c3d158d85 Sessions: GET sessions/{id}/diagnosis returns the session's verdict (D-357)
A typed headline built on demand from the session row and every event:
outcome = status; cause from the verdict path (derived for older rows,
flagged as a caveat), and for an explicit failure from the ESP evidence
(failed step, error code, culprit apps, AppX candidate, stuck policy
providers). Plus the ESP phase rollup, the ESP profile, what an unfinished
session still waited for, and observation caveats. Never from rule
confidence: Stalled, AwaitingUser and reconciled sessions get no terminal
rule run.

- App facts come from the session's own app events (the app table has no
  intent or targeting); only required apps count, a later install clears
  an app, a zero error code is none, and a 0x87d1041c ESP failure ranks
  the NotDetected app first (real IME payload shape).
- The event read is strict: a storage error is a 500, never a verdict
  built from no evidence. 400 for a non-GUID id, 404 for an unknown session.
- Helpers/EventDataReader replaces three private Data-string copies
  (classifier, failure snapshot, time attribution) and the list parsing in
  EspBlockingSets; EspBlockingSets.FromLatest replaces two backward walks.
- Six vocabularies in the shared manifest section `diagnosis`; parity facts
  pin the full and the minimal shape; route in the policy catalog.
- MCP get_session_summary carries it as `headline` right after coverage,
  null when the call fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:48:05 +02:00
Oliver Kieselbach 3bedc65ed5 bump pointer: internal (D-356 amended, diagnosis plan phase 2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:36:33 +02:00
Oliver Kieselbach e5cb3c4f5e Rules: every analyze rule carries a presentation block (D-356)
All 54 shipped analyze rules get role, summary, subject and key facts as
{{signal.key}} templates, each with a minor version bump so the self-seed
picks them up. Values were checked against stored findings, the rows that
receive the block at read time.

- ENRL-001 repeats the outcome only: the 0x87D1041C paragraph and the
  placeholder note are gone, labels read "ESP step that failed" / "ESP
  error code", remediation is "Find the cause". This changes its alert text.
- ENRL-002 says the sweep "closed" the session (it can end Incomplete).
- ID-002 and ID-004 gain evidence-only conditions (unexpected account
  names; IME token failure count and codes); firing and confidence are
  unchanged.
- Resolution: facts sharing a label are alternatives, an exact repeat of
  label and value is left out (new shared fixture case, web module and
  its generated MCP copy).
- Ratchet: every built-in presentation passes the save validator, uses
  qualified tokens only, and each token names a condition of the rule and
  a key its source records (RuleTemplateInterpolator.TokenRegex internal).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:36:21 +02:00
Oliver Kieselbach d7b094852d bump pointer: internal (D-356, diagnosis plan phase 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:49:00 +02:00
Oliver Kieselbach 516cdc71a7 Rules: optional presentation block, {{signal.key}} tokens, one resolution for web and MCP (D-356)
Analyze rules can describe how a finding is shown: a role (outcome, cause, context,
advisory), a one-line summary, the subject it is about and up to six key facts. Like the
explanation, the block is stored raw on the result and resolved where it is rendered.

- Templates: a qualified token {{signal.key}} reads one key of one condition's evidence,
  so counts, durations and correlation fields are reachable. RuleEvidenceKeys (Shared,
  exported to the manifest) lists the keys each condition source records; tests pin it
  against the evaluators.
- Backend: PresentationJson on AnalyzeRules and RuleResults, RuleVersion on results,
  ValidatePresentation on create/update, and results stored without a presentation get
  the current rule's at read time.
- Web + MCP: lib/rulePresentation.ts resolves the block (unrecorded facts left out, summary
  falls back to the explanation). The MCP now runs generated copies of the web modules
  instead of a hand-kept mirror (npm run generate:mcp-logic, freshness tests in both
  suites); the duplicate baseline shrinks by one.
- MCP output: get_session and get_session_summary return the resolved block (issues gain
  ruleId, role, summary, subject, facts); test_analyze_rule previews it; validate_rule
  checks qualified tokens against the key catalog and lints the subject.
- Shared case files: rule-template-interpolation gains the qualified-token cases,
  rule-presentation is new.

No rule carries a presentation yet; renderers behave as before until rules gain one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:48:46 +02:00
Oliver Kieselbach 4a822767d4 bump pointer: internal (catalog check opens files exclusively, B-ute)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:33:02 +02:00
Oliver Kieselbach f82eabd6eb bump pointer: internal (payload catalog released, B-5q2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:21 +02:00
Oliver Kieselbach 1892da30e4 Agent: the bootstrap provenance also reads the log names from before 2026-08-18
The first device on 2.0.1486 reported no bootstrap log at all. Installers up to v2.3 write
bootstrap_agent.log, and the MSI's own downloader (before the loader of 2026-09-08)
msi-bootstrap.log; the reader knew only the names from the rename of 2026-08-18 on, so the
old directly assigned installers that agent_started is meant to count read as "no bootstrap".

- The installer log is the more recently written of bootstrap-script.log and
  bootstrap_agent.log; the MSI channel also covers msi-bootstrap.log, which has no version.
- Tests: the old installer name, both installer logs (the newer one counts), the old MSI
  downloader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:04:06 +02:00
Oliver Kieselbach fd44518e14 Bootstrap: installer v2.6 and the OOBE go-script verify the payload catalog (D-354, phase B)
- Install-AutopilotMonitor.ps1 v2.6: Get-AgentPayloadRejections (Test-FileCatalog
  -Detailed; signature Valid, signer -like the pin, files exactly as catalogued) and
  Install-VerifiedAgentPayload (extract into Agent-Install, verify there, then move into
  the agent folder; a rejection deletes the staging folder, so the next run starts clean).
  Replaces the name-filtered Get-UnverifiedAgentBinaries; the dead "already installed"
  branch behind guard 5 is gone.
- OOBE go-script: embeds the installer's marked block verbatim (EmbeddedResource) and
  verifies before --install; pin and catalog name come from the Shared constants; the
  golden is updated, new tests pin the embedding and the order check-before-install.
- build-agent.yml: the release verify step dot-sources the installer and runs its own
  function on the final ZIP.
- Pester: every decision branch, a real unsigned catalog, the staging flow (64/64).
- Privacy page: the catalog claim; LAST_UPDATED.

Merge only after a catalog ZIP is live on stable (plan step V0): pushing this to main
publishes the installer to every loader on its next run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:43:25 +02:00
Oliver Kieselbach d95640c597 Agent: protected DACL on the agent data directory (D-355)
%ProgramData%\AutopilotMonitor inherited ProgramData's "Users may create files and
folders" entry, so a standard user could add files to the folder the SYSTEM agent and its
boot task start from. The agent outlives the device phase of OOBE (user phase, desktop,
reboots until the enrollment ends), and the payload catalog is checked at install time
only.

- AgentDirectoryAcl.EnsureProtected: protected DACL, SYSTEM and Administrators full
  control, Users read and execute, inherited by every subfolder; idempotent, never blocks
  the agent.
- Install mode applies it after the agent directories exist; every runtime start
  re-checks it, which also reaches agents that arrived by self-update.
- Tests: protected shape, apply once then leave alone, existing subfolders take it over,
  a widened or unprotected DACL is not taken as protected, a missing folder is not created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:26:44 +02:00
Oliver Kieselbach b5e9f45a46 bump pointer: internal (payload catalog lab VM result)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:57:58 +02:00
Oliver Kieselbach b4dabcce94 bump pointer: internal (payload catalog dev rehearsal)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:32:25 +02:00
Oliver Kieselbach 6bbdcc0313 bump pointer: internal (agent payload catalog plan status)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:09:51 +02:00
Oliver Kieselbach 283c094986 Agent payload: signed catalog, vendor check, dev channel in CI (D-354, phase A)
- build-agent.yml: input channel (release | dev; dev writes the lab blobs only and
  refuses stable/release). Every third-party PE needs its vendor's embedded Authenticode
  signature (signtool verify /pa, read-only). AutopilotMonitor.Agent.cat, a signed
  SHA-256 catalog over every payload file, is created after Authenticode and before the
  ZIP; the device check runs under Windows PowerShell 5.1 on the final ZIP.
- Agent: SelfUpdater step 5b verifies the staged payload against the catalog before any
  file is swapped (PayloadCatalogVerifier, Test-FileCatalog in-process; a rejection means
  no update, skip reason catalog_rejected). agent_started reports which bootstrap
  installed the agent, read from the bootstrap logs (BootstrapProvenanceReader).
- Payload: System.Management.Automation.dll (never loaded, the GAC copy binds first) and
  AutopilotMonitor.Shared.xml no longer ship; PDBs stay.
- Shared: CodeSigningPublisherPattern, AgentPayloadCatalogFileName;
  CodeSigningParityTests pins them against installer, loader and workflow.
- Submodules: infra (build.ps1 local only), internal (D-354, plan, docs), commands.

Installer v2.6 and the OOBE script follow once a catalog ZIP is on stable: a merged
installer change is live on the next loader run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:48:29 +02:00
Oliver Kieselbach 47728fbcd4 bump pointer: internal (external identities released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:44:18 +02:00
Oliver Kieselbach d5f885c4b4 bump pointer: internal (external identities never claim a tenant, D-353)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:21:38 +02:00
Oliver Kieselbach dd4975b73e Auth: an external identity never claims a tenant (D-353)
A caller whose token shows it is not a member of the token's tenant (acct = 1, a #EXT# user name,
or an idp naming another tenant, a personal Microsoft account or an external sign-in provider) no
longer seeds DomainName/OnboardedBy or queues the activation, is never the first-user Tenant Admin,
and cannot set the activation-page address in the roleless signup window. Positive evidence only:
a token without these signals stays a member's, and tid/idp are read under their inbound-mapped
names as well. Activation promotes OnboardedBy only; the UpdatedBy fallback is gone and a stored
#EXT# UPN is refused. Explicit grants (TenantAdmins row, app role) still apply.

The once-per-interval log gate of the client-app binding becomes Helpers/LogThrottle and also
throttles the new warning, which the polling activation page would otherwise repeat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:19:56 +02:00
Oliver Kieselbach 18310b05aa bump pointer: internal (MCP input bounds released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:29:24 +02:00
Oliver Kieselbach 40894ed5e7 bump pointer: internal (MCP input bounds, D-352)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:10:02 +02:00
Oliver Kieselbach 6894e71668 MCP: bound caller-sized search input; scoring cost no longer grows with the keyword count
search_events, search_knowledge and search_docs take a query of at most 1,000
characters; search_events takes at most 20 keywords of 100 characters and
scores at most 32 distinct keywords, caller keywords first, reporting the rest
as keywordsDropped with a note. The error-code fallback takes at most 10 codes,
search_docs.section at most 100 characters.

The event scorer compiles the query once (compileConcepts): each event field is
split into words once and its words are looked up by the key of their first
three characters, so scoring an event costs the same whatever the number of
keywords. The matching rule is unchanged; a seeded differential test pins it
against the per-keyword rule. scoreEvents yields to the event loop every 256
events and stops once the caller has gone.

Every paginated tool's continuation goes through one ContinuationSchema
(at most 4,096 characters), and buildQuery appends instead of set: both
URLSearchParams.set paths were quadratic in caller-sized input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:07:28 +02:00
Oliver Kieselbach a28fdc874f bump pointer: internal (watch at the seal + app badge released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:45:24 +02:00
Oliver Kieselbach 7480a2e218 Push receiver: an app badge counts unread alerts; an open history reads them
Every stored history entry starts unread. After a new entry the worker sets the
app badge to the unread count through applyBadge, fire and forget: inside
Safari's declarative push event the setAppBadge promise never settles, so it is
never awaited and never handed to waitUntil (pushSw.test.ts pins it). The
history page marks everything read on every visible load, clears the badge,
closes the notifications whose entries the history holds and shows "New" on
what arrived since the last visit. Unpair clears the badge; the wipe push
leaves it to the next open. The payload repeats mutable inside notification,
where Safari 18.4-26.1 reads it, and carries no app_badge.

Bumps internal (plan, D-350, D-351, concept doc).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:22:56 +02:00
Oliver Kieselbach 96fc88462d Push: the session watch fires at the WhiteGlove seal
"Notify me when done" now also fires on the first genuine pre-provisioning
seal ("Pre-provisioning finished") on the gate of the Pre-Provisioning
Completed alert, and the watch is consumed there; watching the user phase takes
a new click. A watch placed on a sealed session lives 30 days instead of 48
hours. EventIngestProcessor.WatchOutcomeFor is the one gate, the watch text
comes from PushSessionWatchService.WatchMessage. The portal button re-reads the
watch on every status change and names what it waits for. The Success hint
says it covers a finished pre-provisioning; the receiver labels the
preprovisioning_* alerts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:22:44 +02:00
Oliver Kieselbach 6b86549fd0 bump pointers: internal, .claude/commands (client-cert interception released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:30:15 +02:00
485 changed files with 28652 additions and 4734 deletions
+6 -5
View File
@@ -4,15 +4,16 @@
.DESCRIPTION
CI's member of the URL registry (Shared Constants.cs, web lib/config.ts, MCP src/config.ts):
every own host the workflows, the .github/scripts helpers and the local release build talk to,
plus the Authenticode timestamp server, is defined here once. Assert-HostsFromRegistry.ps1
(Workflow gates) fails on any of these hosts spelled out elsewhere under .github/.
every own host the workflows and the .github/scripts helpers talk to, plus the Authenticode
timestamp server, is defined here once. Assert-HostsFromRegistry.ps1 (Workflow gates) fails
on any of these hosts spelled out elsewhere under .github/.
The storage URLs derive from the account name, so blob and table can never point at two
different accounts.
A file in the repository rather than a GitHub variable: the local release build reads it too,
and bearer tokens are sent to these URLs -- where a token goes stays a reviewed line in git.
A file in the repository rather than a GitHub variable: the helpers read it themselves, so
they behave the same in every job and in a local run, and bearer tokens are sent to these
URLs -- where a token goes stays a reviewed line in git.
.PARAMETER ExportToGitHubEnv
Write every entry to $GITHUB_ENV instead of returning it, so the following steps of the job
+2 -2
View File
@@ -9,8 +9,8 @@
live here so they cannot drift between call sites.
Identity comes from whatever `az login` established: the federated GitHub OIDC principal
in CI (azure/login, subject repo:okieselbach/AutopilotMonitor:ref:refs/heads/main), the
operator's own account for the local build. No secret is read, stored or passed around.
in CI (azure/login in a job of the `production` or `release` environment), the operator's
own account when a helper runs locally. No secret is read, stored or passed around.
Under GitHub Actions the token is masked in the run log before it is returned.
x-ms-version is MANDATORY here, not decoration: Entra-authorized requests are rejected
+191 -6
View File
@@ -7,6 +7,14 @@ on:
description: 'Build number override (leave empty to auto-increment from buildcounter.txt)'
required: false
type: string
channel:
description: 'release = versioned line (+ stable on cutover). dev = lab channel only (Agent-v2-dev.zip, version-v2-dev.json, Agent-dev.zip, version-dev.json); needs publish_as_stable=false and create_release=false'
required: true
type: choice
options:
- release
- dev
default: release
upload_blob:
description: 'Upload to versioned namespace (Agent-v2.zip + version-v2.json)'
required: true
@@ -60,6 +68,15 @@ env:
VERSIONED_ZIP: AutopilotMonitor-Agent-v2.zip
STABLE_ZIP: AutopilotMonitor-Agent.zip
STABLE_MANIFEST: version.json
# Lab channel (channel=dev): the names the dev renders of the bootstrap scripts read.
CHANNEL: ${{ inputs.channel }}
DEV_VERSIONED_ZIP: AutopilotMonitor-Agent-v2-dev.zip
DEV_MANIFEST: version-v2-dev.json
DEV_STABLE_ZIP: AutopilotMonitor-Agent-dev.zip
DEV_STABLE_MANIFEST: version-dev.json
# Signed catalog over every other file of the payload. Must equal
# Constants.AgentPayloadCatalogFileName; CodeSigningParityTests keeps them in step.
PAYLOAD_CATALOG: AutopilotMonitor.Agent.cat
# Authenticode via Azure Key Vault; identity and vault come from repository variables, the
# timestamp server (CODE_SIGN_TIMESTAMP_URL) from the endpoint registry like every host here.
# CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify step.
@@ -106,6 +123,22 @@ jobs:
}
Write-Host "Code-signing configuration present (certificate '$env:CODE_SIGN_CERTIFICATE_NAME')"
# The dev channel is a lab channel: it never touches stable, AdminConfiguration, the
# bootstrap scripts or a GitHub Release. Refused here, before a build number is burnt.
- name: Check channel inputs
shell: pwsh
env:
PUBLISH_AS_STABLE: ${{ inputs.publish_as_stable }}
CREATE_RELEASE: ${{ inputs.create_release }}
run: |
if ($env:CHANNEL -notin @('release', 'dev')) {
throw "Unknown channel '$($env:CHANNEL)'."
}
if ($env:CHANNEL -eq 'dev' -and ($env:PUBLISH_AS_STABLE -eq 'true' -or $env:CREATE_RELEASE -eq 'true')) {
throw "channel=dev publishes the lab channel only: run it with publish_as_stable=false and create_release=false."
}
Write-Host "Channel: $env:CHANNEL"
- name: Login to Azure (OIDC)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
@@ -222,14 +255,17 @@ jobs:
retention-days: 1
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool
# run here -- never a restore or build. The build output arrives as data.
# run here -- never a restore or build. The build output arrives as data. The runner
# image's Windows SDK signtool is used read-only, to verify third-party signatures.
#
# No `environment:` yet: the code-signing credential is still bound to the ref, and a job
# carries exactly one OIDC subject, so an environment here would break the signing login.
# Runs in the `release` environment: both identities trust its subject, and it admits main
# only. No federated credential trusts the ref, so a job without an environment can neither
# sign nor write storage, whatever its permissions say.
release:
name: Sign and publish
needs: [reserve, build]
runs-on: windows-latest
environment: release
permissions:
contents: write # tag + GitHub Release
id-token: write # code-signing and storage logins, keyless Sigstore signing
@@ -353,8 +389,7 @@ jobs:
throw "azuresigntool failed with exit code $LASTEXITCODE -- aborting before anything is published."
}
- name: Create release artifact
id: artifact
- name: Stage release payload
shell: pwsh
run: |
$version = $env:AGENT_VERSION
@@ -399,6 +434,98 @@ jobs:
}
Write-Host "Version check passed: EXE = manifest = reserved = $version"
# Third-party code enters the payload only with its vendor's own signature: every PE that
# is not ours needs a valid EMBEDDED Authenticode signature from an allowed vendor. Embedded
# only (signtool verify /pa, no /a): Get-AuthenticodeSignature prefers the runner's Windows
# catalogs and reports "Microsoft Windows" for files Windows happens to ship as well. An
# unsigned file or a new vendor stops the release until a person decides. This guards the
# bytes we did not write; a build that alters our own code is D-346's remaining risk.
- name: Check third-party signatures
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$allowedVendors = @('Microsoft Corporation', 'Json.NET (.NET Foundation)')
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" -ErrorAction SilentlyContinue |
Where-Object { $_.Directory.Parent.Name -match '^\d+(\.\d+){1,3}$' } |
Sort-Object { [version]$_.Directory.Parent.Name } -Descending |
Select-Object -First 1
if (-not $signtool) { throw "signtool.exe not found in the runner image's Windows SDK." }
Write-Host "Using $($signtool.FullName)"
$failed = $false
$checked = 0
foreach ($file in Get-ChildItem -Path 'staging/agent' -Recurse -File) {
# A PE by content (MZ header), not by extension.
$head = [byte[]]::new(2)
$stream = [System.IO.File]::OpenRead($file.FullName)
try { $read = $stream.Read($head, 0, 2) } finally { $stream.Dispose() }
if ($read -lt 2 -or $head[0] -ne 0x4D -or $head[1] -ne 0x5A) { continue }
if ($file.Name -like 'AutopilotMonitor.*') { continue } # ours: the verify step pins them
$checked++
$output = & $signtool.FullName verify /pa $file.FullName 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "::error file=$($file.Name)::No valid embedded vendor signature. $(($output | Out-String).Trim())"
$failed = $true
continue
}
$subject = [System.Security.Cryptography.X509Certificates.X509Certificate]::CreateFromSignedFile($file.FullName).Subject
$vendor = if ($subject -match '(?:^|,\s*)O=("[^"]*"|[^,]*)') { $Matches[1].Trim('"') } else { '' }
if ($vendor -notin $allowedVendors) {
Write-Host "::error file=$($file.Name)::Signed by '$subject' -- vendor '$vendor' is not on the allowlist."
$failed = $true
continue
}
Write-Host "$($file.Name): embedded signature valid, vendor $vendor"
}
if ($failed) { throw "Third-party signature check failed. Nothing was published." }
Write-Host "All $checked third-party PE files carry a valid signature from an allowed vendor."
# The payload contract: one catalog, signed like our binaries, holding the SHA-256 of every
# other file in the ZIP. Installer, OOBE script and self-updater accept a payload only when
# this catalog is ours and matches the extracted files exactly. Created AFTER signing and
# BEFORE the ZIP -- the same load-bearing position as the signing step above.
- name: Create and sign payload catalog
shell: pwsh
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: |
$ErrorActionPreference = 'Stop'
$stagingDir = 'staging/agent'
$catalog = Join-Path $env:RUNNER_TEMP $env:PAYLOAD_CATALOG
if (Test-Path (Join-Path $stagingDir $env:PAYLOAD_CATALOG)) {
throw "$stagingDir already contains $env:PAYLOAD_CATALOG."
}
New-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -CatalogVersion 2.0 | Out-Null
& azuresigntool.exe sign --verbose `
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
--file-digest sha256 `
--description "Autopilot Monitor Agent" `
--description-url "$env:WWW_BASE_URL" `
$catalog
if ($LASTEXITCODE -ne 0) {
throw "azuresigntool failed on the payload catalog with exit code $LASTEXITCODE -- aborting before anything is published."
}
Copy-Item -Path $catalog -Destination $stagingDir
$count = (Test-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -FilesToSkip $env:PAYLOAD_CATALOG -Detailed).CatalogItems.Count
Write-Host "Payload catalog signed: $count files"
- name: Create release artifact
id: artifact
shell: pwsh
run: |
$version = $env:AGENT_VERSION
$stagingDir = "staging/agent"
$manifest = Get-Content "$env:AGENT_OUTPUT/$env:MANIFEST_NAME" -Raw | ConvertFrom-Json
$versionedZip = "AutopilotMonitor-Agent-v$version.zip"
Compress-Archive -Path "$stagingDir/*" -DestinationPath $versionedZip
Write-Host "Created GitHub-Release artifact: $versionedZip"
@@ -488,6 +615,52 @@ jobs:
}
Write-Host "All $($files.Count) binaries signed with certificate $($thumbprints.Keys -join ', ')"
# The devices' check, run on the FINAL ZIP rather than the staging folder: whatever the
# archiver did (Compress-Archive skips hidden files) fails here instead of on a device.
# Windows PowerShell 5.1 and the installer's own functions (dot-sourced; its entry guard
# keeps that side-effect free) -- the very code the installer and the OOBE go-script run.
- name: Verify payload catalog (device check)
shell: powershell
env:
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
run: |
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.IO.Compression.FileSystem
$payload = Join-Path $env:RUNNER_TEMP 'verify-payload'
if (Test-Path $payload) { throw "$payload already exists." }
[System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $env:RELEASE_ZIP).Path, $payload)
. (Resolve-Path $env:BOOTSTRAP_SOURCE).Path
if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and $ExpectedPublisher -ne $env:CODE_SIGN_EXPECTED_SUBJECT) {
throw "Installer pin '$ExpectedPublisher' differs from CODE_SIGN_EXPECTED_SUBJECT '$env:CODE_SIGN_EXPECTED_SUBJECT'."
}
if ($AgentCatalogName -ne $env:PAYLOAD_CATALOG) {
throw "Installer catalog name '$AgentCatalogName' differs from PAYLOAD_CATALOG '$env:PAYLOAD_CATALOG'."
}
# Assign first, then flatten: the function returns its list as ONE object (comma
# operator), so @(Get-AgentPayloadRejections ...) would nest it inside another array.
$problems = Get-AgentPayloadRejections -Path $payload -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
$problems = @($problems)
# Release-only checks a device need not repeat: a timestamp (without it the signature
# dies with the certificate) and the same certificate as the binaries.
$signature = Get-AuthenticodeSignature (Join-Path $payload $AgentCatalogName)
if (-not $signature.TimeStamperCertificate) {
$problems += 'catalog signature carries no RFC-3161 timestamp -- it would expire with the certificate'
}
$exeSigner = (Get-AuthenticodeSignature (Join-Path $payload 'AutopilotMonitor.Agent.exe')).SignerCertificate
if ($signature.SignerCertificate -and $exeSigner -and $signature.SignerCertificate.Thumbprint -ne $exeSigner.Thumbprint) {
$problems += 'catalog and agent exe carry different signing certificates'
}
if ($problems.Count -gt 0) {
$problems | ForEach-Object { Write-Host "::error::$_" }
throw "Payload catalog verification failed. Nothing was published."
}
Write-Host "Payload catalog verified on the final ZIP with the installer's own check, signer $($signature.SignerCertificate.Subject)"
- name: Upload to Azure Blob Storage
if: inputs.upload_blob
shell: pwsh
@@ -531,6 +704,18 @@ jobs:
}
}
# Dev channel: lab blobs only, under the names the dev renders of the bootstrap scripts
# read. Nothing else is touched -- no versioned or stable line, no AdminConfiguration, no
# scripts; the channel check in `reserve` already refused publish_as_stable/create_release.
if ($env:CHANNEL -eq 'dev') {
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_VERSIONED_ZIP -ContentType "application/zip"
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_MANIFEST -ContentType "application/json"
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_STABLE_ZIP -ContentType "application/zip"
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_STABLE_MANIFEST -ContentType "application/json"
Write-Host "DEV CHANNEL: lab blobs written, nothing else touched."
exit 0
}
# Versioned namespace (always)
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:VERSIONED_ZIP -ContentType "application/zip"
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:MANIFEST_NAME -ContentType "application/json"
@@ -636,7 +821,7 @@ jobs:
- **Published as stable:** ${{ inputs.publish_as_stable }}
### Provenance
The executables are Authenticode-signed and timestamped. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
The executables are Authenticode-signed and timestamped. Every file in the ZIP is listed in `AutopilotMonitor.Agent.cat`, a catalog signed with the same certificate, and third-party libraries carry their vendor's signature. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
```
gh attestation verify AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
```
+5 -4
View File
@@ -39,11 +39,11 @@ jobs:
# need the embedded loader to carry a signature of its own.
#
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool run
# here. No `environment:` yet: the code-signing credential is still bound to the ref, and a
# job carries exactly one OIDC subject.
# here. The `release` environment is the only subject the code-signing identity trusts.
sign-loader:
name: Sign bootstrap loader
runs-on: windows-latest
environment: release
permissions:
contents: read
id-token: write # code-signing login
@@ -198,12 +198,13 @@ jobs:
if-no-files-found: error
retention-days: 1
# Trust zone: signs, verifies and publishes the MSI as data; never builds. No
# `environment:` yet, for the same reason as sign-loader.
# Trust zone: signs, verifies and publishes the MSI as data; never builds. Environment
# `release`, like sign-loader.
release:
name: Sign and publish MSI
needs: build
runs-on: windows-latest
environment: release
permissions:
contents: read
id-token: write # code-signing and storage logins, keyless Sigstore signing
+3 -3
View File
@@ -54,6 +54,8 @@ jobs:
name: Publish to the download alias
needs: gates
runs-on: windows-latest
# Both identities trust only the `release` environment, which admits main only.
environment: release
# Job-scoped: only the publish job federates to Azure, the gates job never holds an
# OIDC token it does not need.
permissions:
@@ -74,9 +76,7 @@ jobs:
}
# First, and only to fetch the Key Vault token: azure/login sets the default az
# account and the storage login below has to be the one that stays. See D-223 --
# both federated credentials are bound to the ref, so this job must never carry a
# GitHub `environment:`.
# account and the storage login below has to be the one that stays.
- name: Login to Azure (code-signing identity)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
+1 -1
Submodule infra updated: 0af4689368...0085deab45
+21 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
"severity": "warning",
"category": "apps",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -38,6 +38,26 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "{{detection_failure.appName}} was not detected after its install",
"subject": {
"kind": "app",
"key": "{{detection_failure.appId}}",
"name": "{{detection_failure.appName}}"
},
"facts": [
{
"label": "Detection",
"value": "{{detection_failure.value}}"
},
{
"label": "Error",
"value": "{{detection_failure.errorCode}}",
"format": "errorCode"
}
]
},
"tags": [
"apps",
"detection",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects app installation failures with specific exit codes or HRESULT error codes and suggests fixes.",
"severity": "high",
"category": "apps",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -41,6 +41,27 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "{{install_error.appName}} failed to install",
"subject": {
"kind": "app",
"key": "{{install_error.appId}}",
"name": "{{install_error.appName}}"
},
"facts": [
{
"label": "Exit code",
"value": "{{install_error.exitCode}}",
"format": "code"
},
{
"label": "Error",
"value": "{{install_error.errorCode}}",
"format": "errorCode"
}
]
},
"tags": [
"apps",
"msi",
+17 -1
View File
@@ -33,9 +33,25 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "{{dependency_failure.appName}} failed because a dependency did not install",
"subject": {
"kind": "app",
"key": "{{dependency_failure.appId}}",
"name": "{{dependency_failure.appName}}"
},
"facts": [
{
"label": "Error",
"value": "{{dependency_failure.errorCode}}",
"format": "errorCode"
}
]
},
"tags": [
"apps",
"dependency"
],
"version": "1.1.0"
"version": "1.2.0"
}
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when disk space is critically low during enrollment, which can cause app installation failures. Confidence increases when app installations actually failed while disk space was low.",
"severity": "critical",
"category": "apps",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -71,6 +71,27 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "Free disk space dropped to {{low_disk.value}} GB",
"subject": {
"kind": "device",
"key": "disk",
"name": "Disk space"
},
"facts": [
{
"label": "Free space (GB)",
"value": "{{low_disk.value}}",
"format": "number"
},
{
"label": "Measured",
"value": "{{low_disk.timestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"apps",
"disk-space",
+17 -1
View File
@@ -33,10 +33,26 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "The content download of {{download_timeout.appName}} timed out or failed",
"subject": {
"kind": "app",
"key": "{{download_timeout.appId}}",
"name": "{{download_timeout.appName}}"
},
"facts": [
{
"label": "Error",
"value": "{{download_timeout.errorCode}}",
"format": "errorCode"
}
]
},
"tags": [
"apps",
"download",
"timeout"
],
"version": "1.1.0"
"version": "1.2.0"
}
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the same app installation is attempted 3 or more times during one enrollment — a retry loop caused by unhonored reboot requests, repeated failures, or detection mismatches.",
"severity": "high",
"category": "apps",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -35,6 +35,27 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "{{install_retry_loop.appName}} was started {{install_retry_loop.count}} times",
"subject": {
"kind": "app",
"key": "{{install_retry_loop.groupKey}}",
"name": "{{install_retry_loop.appName}}"
},
"facts": [
{
"label": "Install starts",
"value": "{{install_retry_loop.count}}",
"format": "number"
},
{
"label": "First start",
"value": "{{install_retry_loop.timestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"apps",
"reboot",
+12 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the app tracking summary reports multiple failed app installations.",
"severity": "high",
"category": "apps",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -50,6 +50,17 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "{{summary_errors.value}} app installations failed",
"facts": [
{
"label": "Failed apps",
"value": "{{summary_errors.value}}",
"format": "number"
}
]
},
"tags": [
"apps",
"summary",
+22 -1
View File
@@ -32,11 +32,32 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "{{slow_app_install.appName}} took {{slow_app_install.durationFormatted}} to install",
"subject": {
"kind": "app",
"key": "{{slow_app_install.appId}}",
"name": "{{slow_app_install.appName}}"
},
"facts": [
{
"label": "Install time",
"value": "{{slow_app_install.durationSeconds}}",
"format": "duration"
},
{
"label": "Started",
"value": "{{slow_app_install.startTimestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"apps",
"performance",
"slow-install",
"warning"
],
"version": "1.1.0"
"version": "1.2.0"
}
+27
View File
@@ -1,6 +1,7 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-APP-010",
"version": "1.1.0",
"title": "App Reverted to Error After Successful Install",
"description": "Detects when a specific app (matched by appId) was first reported as successfully installed but subsequently reported an IME enforcement error — while enrollment completed successfully. The app may be broken post-enrollment.",
"severity": "warning",
@@ -56,5 +57,31 @@
{ "title": "Troubleshoot Win32 app installations", "url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install" },
{ "title": "Intune Management Extension logs", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/intune-management-extension" }
],
"presentation": {
"role": "cause",
"summary": "{{app_regression.eventA_appName}} installed, then reported an error",
"subject": {
"kind": "app",
"key": "{{app_regression.joinValue}}",
"name": "{{app_regression.eventA_appName}}"
},
"facts": [
{
"label": "Installed",
"value": "{{app_regression.eventA_timestamp}}",
"format": "timestamp"
},
{
"label": "Error after",
"value": "{{app_regression.timeDeltaSeconds}}",
"format": "duration"
},
{
"label": "Error",
"value": "{{app_regression.eventB_errorCode}}",
"format": "errorCode"
}
]
},
"tags": ["apps", "win32", "enforcement", "post-install", "correlation"]
}
+27 -1
View File
@@ -43,6 +43,32 @@
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-app-management#step-4-return-codes"
}
],
"presentation": {
"role": "advisory",
"summary": "{{enforcement_error_resolved.eventA_appName}} reported an install error but was detected as installed",
"subject": {
"kind": "app",
"key": "{{enforcement_error_resolved.joinValue}}",
"name": "{{enforcement_error_resolved.eventA_appName}}"
},
"facts": [
{
"label": "Exit code",
"value": "{{enforcement_error_resolved.eventA_exitCode}}",
"format": "code"
},
{
"label": "Error",
"value": "{{enforcement_error_resolved.eventA_errorCode}}",
"format": "errorCode"
},
{
"label": "Detected installed after",
"value": "{{enforcement_error_resolved.timeDeltaSeconds}}",
"format": "duration"
}
]
},
"tags": [
"apps",
"enforcement",
@@ -50,5 +76,5 @@
"unmapped",
"resolved"
],
"version": "1.1.0"
"version": "1.2.0"
}
+21 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
"severity": "critical",
"category": "apps",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -82,6 +82,26 @@
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules"
}
],
"presentation": {
"role": "cause",
"summary": "{{app_with_detection_failure.appName}} installed, but its detection rule did not find it",
"subject": {
"kind": "app",
"key": "{{app_with_detection_failure.appId}}",
"name": "{{app_with_detection_failure.appName}}"
},
"facts": [
{
"label": "ESP error",
"value": "{{esp_failure_errorcode.value}}",
"format": "errorCode"
},
{
"label": "Detection",
"value": "{{app_with_detection_failure.value}}"
}
]
},
"tags": [
"apps",
"detection",
+31 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the Enrollment Status Page failed on the Apps subcategory and the agent's AppX deployment log scan identified a suspected MSIX/Store package. Store/MSIX apps install outside the Win32 pipeline, so the regular per-app tracking sees nothing — this correlation names the likely culprit.",
"severity": "warning",
"category": "apps",
"version": "1.2.1",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -123,6 +123,36 @@
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/store-apps-microsoft"
}
],
"presentation": {
"role": "cause",
"summary": "A Store or MSIX app failed during the ESP: {{appx_candidate.value}}",
"subject": {
"kind": "app",
"key": "{{appx_candidate.value}}",
"name": "{{appx_candidate.value}}"
},
"facts": [
{
"label": "ESP error",
"value": "{{appx_esp_errorcode.value}}",
"format": "errorCode"
},
{
"label": "ESP phase",
"value": "{{appx_esp_category.value}}"
},
{
"label": "Package",
"value": "{{appx_top_candidate_package.value}}",
"format": "code"
},
{
"label": "Candidates",
"value": "{{appx_candidate_count.value}}",
"format": "number"
}
]
},
"tags": [
"apps",
"appx",
+16 -1
View File
@@ -5,7 +5,7 @@
"description": "The IME reported its user-session completion line, but at least one required app stayed pending — without ever failing or even starting to install — so the agent kept deferring the enrollment completion signal. Typically an assignment/requirement problem (requirement rules never met, dependency never satisfied, or the app needs a context that is not available during ESP, e.g. a VPN client waiting for user sign-in to a portal).",
"severity": "high",
"category": "apps",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "correlation",
@@ -76,5 +76,20 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "Completion waited on {{apps_pending.value}} pending app(s)",
"facts": [
{
"label": "Pending apps",
"value": "{{apps_pending.value}}",
"format": "number"
},
{
"label": "Apps",
"value": "{{pending_app_names.value}}"
}
]
},
"tags": ["apps", "esp", "completion", "assignment", "pending"]
}
+21 -1
View File
@@ -5,7 +5,7 @@
"description": "One or more required user-ESP apps never started installing while the ESP AccountSetup apps gate waited on them — the agent observed the ESP page exit with those apps still in a non-terminal, never-started state. Unlike ANALYZE-APP-015 (which reports that completion was DEFERRED because of a pending app, and stays silent once the session reaches a terminal verdict) this rule reports the delivery gap itself and fires regardless of the final outcome: an enrollment that Windows called done while required apps never ran is a successful enrollment with missing software, and the operator must see it.",
"severity": "warning",
"category": "apps",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -102,6 +102,26 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "Required apps never started installing; first: {{appName.value}}",
"subject": {
"kind": "app",
"key": "{{appName.appId}}",
"name": "{{appName.value}}"
},
"facts": [
{
"label": "First app",
"value": "{{appName.value}}"
},
{
"label": "State",
"value": "{{state.value}}",
"format": "code"
}
]
},
"tags": [
"apps",
"esp",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects Win32 app installs that failed because the Windows Installer was busy with another installation (HRESULT 0x80070652 / MSI exit code 1618, ERROR_INSTALL_ALREADY_RUNNING). The IME retries on a fixed budget (~15-17 minutes across several attempts) and then reports the app as failed; when the app is ESP-blocking, the Device Setup Apps subcategory terminates with it.",
"severity": "high",
"category": "apps",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"baseConfidence": 70,
@@ -81,6 +81,27 @@
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
}
],
"presentation": {
"role": "cause",
"summary": "{{mutex_blocked_install.appName}} was blocked by another installation",
"subject": {
"kind": "app",
"key": "{{mutex_blocked_install.appId}}",
"name": "{{mutex_blocked_install.appName}}"
},
"facts": [
{
"label": "Exit code",
"value": "{{installer_exit_1618.value}}",
"format": "code"
},
{
"label": "ESP error",
"value": "{{esp_failed_with_mutex_code.value}}",
"format": "errorCode"
}
]
},
"tags": [
"apps",
"msiexec",
+26 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects Win32 apps whose installer exit code maps to Soft reboot or Hard reboot in the app's Intune return-code table (the '[Win32App] lpExitCode is defined as ...' line). Hard reboot stops app processing and restarts the device in Device Setup - the mid-enrollment restart and second sign-in; Soft reboot leaves the app pending until the next restart while the ESP moves on. Confidence rises for Hard reboot and when a reboot was actually observed in the session.",
"severity": "warning",
"category": "apps",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -86,6 +86,31 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "{{reboot_return_code.appName}} asked for a restart",
"subject": {
"kind": "app",
"key": "{{reboot_return_code.appId}}",
"name": "{{reboot_return_code.appName}}"
},
"facts": [
{
"label": "Return code",
"value": "{{reboot_return_code.exitCode}}",
"format": "code"
},
{
"label": "Return code class",
"value": "{{reboot_return_code.value}}"
},
{
"label": "Restarts",
"value": "{{reboot_observed.count}}",
"format": "number"
}
]
},
"tags": [
"apps",
"reboot",
+25 -1
View File
@@ -5,7 +5,7 @@
"description": "Correlates an active proxy configuration with app content download failures (Delivery Optimization timeout or download error), suggesting the proxy is blocking or throttling Intune content delivery.",
"severity": "high",
"category": "apps",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -67,6 +67,30 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "App downloads failed while a {{proxy_active.value}} proxy was configured",
"subject": {
"kind": "network",
"key": "proxy",
"name": "Proxy"
},
"facts": [
{
"label": "Proxy type",
"value": "{{proxy_active.value}}"
},
{
"label": "Failed app",
"value": "{{download_failure.appName}}"
},
{
"label": "Error",
"value": "{{download_failure.errorCode}}",
"format": "errorCode"
}
]
},
"tags": [
"correlation",
"apps",
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when Windows Hello for Business provisioning did not start within the expected timeframe after ESP completion.",
"severity": "warning",
"category": "device",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"baseConfidence": 60,
"conditions": [
@@ -30,5 +30,21 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "Windows Hello provisioning did not start in time",
"subject": {
"kind": "device",
"key": "windows-hello",
"name": "Windows Hello"
},
"facts": [
{
"label": "Timed out",
"value": "{{hello_timeout.timestamp}}",
"format": "timestamp"
}
]
},
"tags": ["device", "hello", "timeout"]
}
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when memory usage stays above 90% across at least 3 performance snapshots during enrollment, indicating sustained memory pressure that can cause app installation failures or system instability.",
"severity": "warning",
"category": "device",
"version": "2.1.0",
"version": "2.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -65,6 +65,22 @@
]
}
],
"presentation": {
"role": "context",
"summary": "Memory use was above 90% in {{sustained_high_memory.count}} snapshots",
"subject": {
"kind": "device",
"key": "memory",
"name": "Memory"
},
"facts": [
{
"label": "Snapshots above 90%",
"value": "{{sustained_high_memory.count}}",
"format": "number"
}
]
},
"tags": [
"device",
"memory",
+21 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects devices enrolling with an OS build that has reached end of support: Windows 11 22H2 (build 22621) or older, which includes every Windows 10 build. Maintenance note: raise the build cutoff to 22631 after Windows 11 23H2 reaches end of support on 2026-11-10.",
"severity": "warning",
"category": "device",
"version": "2.1.0",
"version": "2.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -56,6 +56,26 @@
"url": "https://learn.microsoft.com/en-us/lifecycle/products/windows-11-home-and-pro"
}
],
"presentation": {
"role": "advisory",
"summary": "Windows build {{old_build.value}} is out of support",
"subject": {
"kind": "device",
"key": "os",
"name": "Windows version"
},
"facts": [
{
"label": "OS",
"value": "{{os_version.value}}"
},
{
"label": "Build",
"value": "{{old_build.value}}",
"format": "code"
}
]
},
"tags": [
"device",
"os-version",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects a Windows quality/cumulative update that FAILED to install or download during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20 from the System log, 31 from the Operational channel). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
"severity": "high",
"category": "device",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -113,6 +113,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "A Windows update failed during setup: {{wu_update_title.value}}",
"subject": {
"kind": "device",
"key": "windows-update",
"name": "Windows Update"
},
"facts": [
{
"label": "Error",
"value": "{{wu_hresult.value}}",
"format": "errorCode"
},
{
"label": "Symbol",
"value": "{{wu_hresult_symbol.value}}",
"format": "code"
}
]
},
"tags": [
"device",
"windows-update",
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Informational: a Windows quality/cumulative update installed during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 19). Surfaces updates applied mid-enrollment — useful context when correlating enrollment duration, reboots, or later issues with a specific KB.",
"severity": "info",
"category": "device",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -61,6 +61,22 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "A Windows update installed during setup: {{wu_update_title.value}}",
"subject": {
"kind": "device",
"key": "windows-update",
"name": "Windows Update"
},
"facts": [
{
"label": "Installed",
"value": "{{wu_installed.timestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"device",
"windows-update",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Informational: the OS build (CurrentBuild.UBR) changed across a reboot during enrollment — an update was installed, regardless of which servicing path delivered it. Deterministic corroboration that works even when the Windows Update event channel shows nothing (registry comparison across restart cannot be lost to timing or logging-path differences).",
"severity": "info",
"category": "device",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -69,6 +69,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "Windows was updated from build {{previous_build.value}} to {{current_build.value}}",
"subject": {
"kind": "device",
"key": "os",
"name": "Windows version"
},
"facts": [
{
"label": "Build before",
"value": "{{previous_build.value}}",
"format": "code"
},
{
"label": "Build after",
"value": "{{current_build.value}}",
"format": "code"
}
]
},
"tags": [
"device",
"windows-update",
+21 -1
View File
@@ -5,7 +5,7 @@
"description": "The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts.",
"severity": "warning",
"category": "device",
"version": "1.1.1",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -59,6 +59,26 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"subject": {
"kind": "device",
"key": "clock",
"name": "System clock"
},
"facts": [
{
"label": "Clock offset (s)",
"value": "{{clock_behind.value}}",
"format": "number"
},
{
"label": "Clock offset (s)",
"value": "{{clock_ahead.value}}",
"format": "number"
}
]
},
"tags": [
"device",
"ntp",
+21 -1
View File
@@ -5,7 +5,7 @@
"description": "The device's clock changed by five or more minutes mid-session (clock jump) or ran five or more minutes off server time for the whole session (sustained offset), measured against the server receive frame of every uploaded event. Complements ANALYZE-DEV-007, which is a one-shot NTP check at agent startup and misses devices where NTP was unreachable or the clock changed later. IME-log-derived events are excluded from the measurement.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -57,6 +57,26 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"subject": {
"kind": "device",
"key": "clock",
"name": "System clock"
},
"facts": [
{
"label": "Clock jump (min)",
"value": "{{clock_jump.jumpMinutes}}",
"format": "number"
},
{
"label": "Clock offset (min)",
"value": "{{sustained_offset.offsetMinutes}}",
"format": "number"
}
]
},
"tags": [
"device",
"clock-skew",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects that the device's battery dropped to 15% or less while enrolling on battery power (power_state_change threshold event from the agent's live power watcher). A device dying mid-enrollment leaves a half-provisioned state that typically needs a full reset; this rule surfaces the risk while there is still time to plug the device in. Uses a single required condition on the agent-stamped thresholdPercent field (the data field 'transition' on the event is unrelated to this rule's 'trigger' property).",
"severity": "high",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -39,6 +39,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "The battery fell below {{battery_threshold_15.value}}% during setup",
"subject": {
"kind": "device",
"key": "power",
"name": "Power"
},
"facts": [
{
"label": "Battery below (%)",
"value": "{{battery_threshold_15.value}}",
"format": "number"
},
{
"label": "Reported",
"value": "{{battery_threshold_15.timestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"device",
"power",
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects that the device lost AC power and switched to battery while the enrollment was running (power_state_change event with transition ac_to_battery from the agent's live power watcher). Uses a single required condition so the match is pinned to one event instance; the event payload carries the battery percentage at the moment of the switch. The agent debounces power flapping (5s) so a brief unplug/replug does not produce this event.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -39,6 +39,22 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "The device switched from AC to battery power during setup",
"subject": {
"kind": "device",
"key": "power",
"name": "Power"
},
"facts": [
{
"label": "Switched",
"value": "{{switched_to_battery.timestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"device",
"power",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log). The watcher also backfills pre-agent episodes from the last 24 h for timeline context (backfilled=true); those ended before the enrollment started and are excluded via the same-event filter backfilled equals false — a separate condition could not do that, because each condition scans all events on its own and a pre-session hibernate would still satisfy the duration test. A single required condition keeps the match pinned to one event instance so the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
"severity": "info",
"category": "device",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -42,6 +42,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "The device went to sleep during setup",
"subject": {
"kind": "device",
"key": "power",
"name": "Power"
},
"facts": [
{
"label": "Asleep for",
"value": "{{slept_during_enrollment.value}}",
"format": "duration"
},
{
"label": "Recorded",
"value": "{{slept_during_enrollment.timestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"device",
"power",
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects that the Windows quality update page at the end of OOBE (Enrollment Status Page setting \"Install Windows quality updates\") reported a failed download or install and recorded no successful install afterwards — the page's own failure names on the oobe_update_page events. Setup usually continues, so the failure is otherwise invisible: the device finishes OOBE without the monthly security update. An OS build change across a restart (os_build_changed) vetoes the finding, and so does the agent's names_capped marker — once the page's event names were capped, a missing success name proves nothing. A downloadInstallFailureHelper entry alone is no failure: updates that installed fine log it too.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
@@ -109,6 +109,22 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "The Windows quality update at the end of OOBE failed",
"subject": {
"kind": "device",
"key": "oobe-update",
"name": "OOBE quality update"
},
"facts": [
{
"label": "Failed step",
"value": "{{update_failure.value}}",
"format": "code"
}
]
},
"tags": [
"device",
"windows-update",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects that the Windows quality update page at the end of OOBE found no update to install although the device is out of date, on a device whose update page counts an earlier failed download or install (oobe_update_page: NDUPDownloadInstallPreviousFailureCount above 0, osUpdatesWithNDUPPI false, getDaysOutOfDateSucceeded above 0). No failure and no install success are recorded in this session — a failure in this session is ANALYZE-DEV-013 — and the agent's names_capped marker vetoes the finding, because a missing name proves nothing once the page's names were capped. The device finishes OOBE without the monthly security update.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
@@ -110,6 +110,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "No quality update was offered although the device is {{days_out_of_date.value}} days out of date",
"subject": {
"kind": "device",
"key": "oobe-update",
"name": "OOBE quality update"
},
"facts": [
{
"label": "Days out of date",
"value": "{{days_out_of_date.value}}",
"format": "number"
},
{
"label": "Earlier update failures",
"value": "{{previous_failures.value}}",
"format": "number"
}
]
},
"tags": [
"device",
"windows-update",
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects that the Windows quality update page at the end of OOBE started its Windows Update scan but ended without a scan result (oobe_update_page: startWUScanStartedSuccedded, an OobeNDUP stop and no ExpeditedUpdate_getUpdateResults name), on a device that is out of date and allowed to update. In the observed sessions the page ended two minutes after the scan started, on devices more than a year behind. The device finishes OOBE without the monthly security update. The agent's names_capped marker vetoes the finding: once the page's event names were capped, a missing scan result proves nothing.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
@@ -108,6 +108,22 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "The OOBE update check ended without a result",
"subject": {
"kind": "device",
"key": "oobe-update",
"name": "OOBE quality update"
},
"facts": [
{
"label": "Days out of date",
"value": "{{days_out_of_date.value}}",
"format": "number"
}
]
},
"tags": [
"device",
"windows-update",
+30 -9
View File
@@ -5,7 +5,7 @@
"description": "Detects when the Autopilot enrollment has explicitly failed.",
"severity": "critical",
"category": "enrollment",
"version": "1.1.2",
"version": "1.2.0",
"enabled": true,
"baseConfidence": 95,
"conditions": [
@@ -57,19 +57,40 @@
"confidenceFactors": [
{ "signal": "failed_app", "condition": "exists", "weight": 5 }
],
"explanation": "The Autopilot enrollment has explicitly failed.\n\n**Key facts extracted from this session's events:**\n\n- **Failure reason:** `{{reason}}`\n- **First failed app:** `{{appName}}`\n- **ESP failed subcategory:** `{{failedSubcategory}}`\n- **ESP HRESULT:** `{{errorCode}}`\n\nWhen an ESP Apps-subcategory failure is paired with HRESULT `0x87D1041C`, the device-side install most likely completed but the Intune detection rule did not match afterwards — see the **App Detection Failure During ESP** rule (ANALYZE-APP-013) in this same analysis for the actionable root cause and remediation steps. Any field still shown as a literal `{{…}}` placeholder was simply not recorded for this session — for example, a non-ESP failure path has no ESP failed-subcategory or HRESULT.",
"explanation": "The Autopilot enrollment has explicitly failed.\n\n**Key facts from this session's events:**\n\n- **Failure reason:** `{{reason}}`\n- **First failed app:** `{{appName}}`\n- **ESP step that failed:** `{{failedSubcategory}}`\n- **ESP error code:** `{{errorCode}}`\n\nA more specific finding in this analysis usually names the cause, such as an app, ESP or timeout finding.",
"remediation": [
{
"title": "Investigate enrollment failure",
"title": "Find the cause",
"steps": [
"Start with the more specific ANALYZE-APP-* / ANALYZE-ESP-* rules that fired alongside this one — they correlate specific HRESULTs to actionable causes (e.g. 0x87D1041C → detection rule mismatch in ANALYZE-APP-013)",
"Read the `reason` field on the enrollment_failed event for the immediate trigger (e.g. esp_terminal_failure, hello_timeout, max_lifetime)",
"If a HRESULT is present, look it up against the ESP error catalog (Microsoft Learn: Autopilot ESP error codes)",
"Review other analyze rule results for correlated issues (disk space, network, app failures)",
"Check the device event timeline for the sequence of events leading to failure",
"Verify the Autopilot profile and ESP configuration in Intune"
"Open the finding in this analysis that names the cause (an app, ESP or timeout finding) and follow its steps",
"If no other finding fired, look up the ESP error code and check the failed ESP step in the session timeline",
"Verify the Autopilot profile and the ESP settings in Intune"
]
}
],
"presentation": {
"role": "outcome",
"summary": "The enrollment failed at the ESP step {{esp_failure_subcategory.value}} with {{esp_failure_errorcode.value}}",
"facts": [
{
"label": "Reason",
"value": "{{enrollment_failed_reason.value}}",
"format": "code"
},
{
"label": "ESP step",
"value": "{{esp_failure_subcategory.value}}"
},
{
"label": "ESP error",
"value": "{{esp_failure_errorcode.value}}",
"format": "errorCode"
},
{
"label": "First failed app",
"value": "{{failed_app.appName}}"
}
]
},
"tags": ["enrollment", "failed", "critical"]
}
+25 -3
View File
@@ -2,10 +2,10 @@
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-ENRL-002",
"title": "Session Timed Out",
"description": "Detects when the enrollment session was force-failed by the server-side inactivity timeout (no agent telemetry for the configured SessionTimeoutHours window) without ever completing or explicitly failing.",
"description": "Detects when the server-side inactivity timeout closed the enrollment session (no agent telemetry for the configured SessionTimeoutHours window) before it ever completed or explicitly failed.",
"severity": "high",
"category": "enrollment",
"version": "1.2.0",
"version": "1.4.0",
"enabled": true,
"baseConfidence": 80,
"preconditions": [
@@ -20,6 +20,12 @@
"eventType": "enrollment_failed",
"operator": "not_exists",
"description": "Suppress when an explicit enrollment_failed exists — that path is already covered by ANALYZE-ENRL-001; avoid double-reporting the same session."
},
{
"source": "event_data",
"eventType": "device_blocked",
"operator": "not_exists",
"description": "Suppress when the platform blocked the session's device: the silence is the refused uploads, which ANALYZE-ENRL-006 explains — 'the agent died or the device went offline' would be wrong."
}
],
"conditions": [
@@ -58,7 +64,7 @@
"required": false
}
],
"explanation": "The enrollment session was force-failed by the server-side maintenance sweep after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.\n\n**If the evidence shows the user desktop was reached and all tracked apps completed**, the device most likely got all its content but the ESP never signaled completion — on hybrid-joined devices the usual culprit is the user session never establishing Entra affinity (Account Setup re-running on every logon). Check whether the hybrid user-affinity finding also fired for this session.",
"explanation": "The server-side maintenance sweep closed this session after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.\n\n**If the evidence shows the user desktop was reached and all tracked apps completed**, the device most likely got all its content but the ESP never signaled completion — on hybrid-joined devices the usual culprit is the user session never establishing Entra affinity (Account Setup re-running on every logon). Check whether the hybrid user-affinity finding also fired for this session.",
"remediation": [
{
"title": "Investigate the silent session",
@@ -78,5 +84,21 @@
]
}
],
"presentation": {
"role": "outcome",
"summary": "No completion or failure arrived within {{timeoutHours.value}} hours",
"facts": [
{
"label": "Timeout (hours)",
"value": "{{timeoutHours.value}}",
"format": "number"
},
{
"label": "Desktop reached",
"value": "{{desktop_was_reached.timestamp}}",
"format": "timestamp"
}
]
},
"tags": ["enrollment", "timeout", "stalled"]
}
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "A TPM-attestation-related error code documented in the Microsoft Autopilot known-issues list was observed (0x800705B4, 0x801C03EA, 0x81039001, 0x81039023, 0x81039024, 0x80070490). These block self-deploying and pre-provisioning deployments during 'Securing your hardware'. Observability: attestation precedes MDM enrollment, so in the normal deployment model (agent installed via IME after enrollment) these codes are rarely visible — the rule primarily serves bootstrap-token deployments.",
"severity": "high",
"category": "enrollment",
"version": "1.1.0",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -80,6 +80,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "TPM attestation failed with a known Autopilot error code",
"subject": {
"kind": "device",
"key": "tpm",
"name": "TPM attestation"
},
"facts": [
{
"label": "Error code",
"value": "{{tpm_hresult_esp_failure.value}}",
"format": "errorCode"
},
{
"label": "Error code",
"value": "{{tpm_hresult_esp_advisory.value}}",
"format": "errorCode"
}
]
},
"tags": [
"enrollment",
"tpm",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "An enrollment-blocking error code documented in the Microsoft Autopilot known-issues/troubleshooting pages was observed (0x80180014, 0x80180018, 0xC1036501, 0x801C03F3, 0x80070774). Each has a specific, documented cause and fix. Observability: a blocked MDM enrollment normally means no agent gets installed at all (IME install happens after enrollment) — this rule can realistically only fire for bootstrap-token deployments (pre-MDM reporting) or mixed retry scenarios.",
"severity": "high",
"category": "enrollment",
"version": "1.1.0",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -78,6 +78,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "MDM enrollment was blocked with a known Autopilot error code",
"subject": {
"kind": "device",
"key": "mdm-enrollment",
"name": "MDM enrollment"
},
"facts": [
{
"label": "Error code",
"value": "{{enrl_hresult_esp_failure.value}}",
"format": "errorCode"
},
{
"label": "Error code",
"value": "{{enrl_hresult_esp_advisory.value}}",
"format": "errorCode"
}
]
},
"tags": [
"enrollment",
"mdm",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "A Microsoft Entra hybrid join Autopilot deployment hit error 0x80004005 — a documented build-dependent known issue, resolved by KB5065789 (25H2, build >= 26200.6725), KB5065426 (24H2, build >= 26100.6584), and KB5070312 (23H2, build >= 22631.6276).",
"severity": "high",
"category": "enrollment",
"version": "1.1.0",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -103,6 +103,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "Hybrid join hit 0x80004005, a known timeout fixed by Windows updates",
"subject": {
"kind": "device",
"key": "hybrid-join",
"name": "Hybrid join"
},
"facts": [
{
"label": "Error code",
"value": "{{e_fail_esp_failure.value}}",
"format": "errorCode"
},
{
"label": "Error code",
"value": "{{e_fail_esp_advisory.value}}",
"format": "errorCode"
}
]
},
"tags": [
"enrollment",
"hybrid-join",
+107
View File
@@ -0,0 +1,107 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-ENRL-006",
"title": "Monitoring Paused by a Protective Limit",
"description": "Detects when the platform blocked the session's device — automatically after an unusually high event volume, or by the platform operator — so the session's telemetry stopped reaching Autopilot Monitor.",
"severity": "warning",
"category": "enrollment",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"on_event:device_blocked"
],
"trigger": "single",
"baseConfidence": 90,
"confidenceThreshold": 50,
"conditions": [
{
"signal": "device_blocked",
"source": "event_type",
"eventType": "device_blocked",
"operator": "exists",
"value": "",
"required": true
},
{
"signal": "event_count",
"source": "event_data",
"eventType": "device_blocked",
"dataField": "eventCount",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "event_limit",
"source": "event_data",
"eventType": "device_blocked",
"dataField": "threshold",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "blocked_until",
"source": "event_data",
"eventType": "device_blocked",
"dataField": "blockedUntil",
"operator": "exists",
"value": "",
"required": false
}
],
"explanation": "Autopilot Monitor stopped accepting telemetry from this session. A protective limit keeps a single device that sends far more events than an enrollment normally does from distorting your fleet data or overloading the platform. When a session crosses it, the device's uploads are paused for a limited time; in severe cases the agent ends monitoring and removes itself. The platform operator can apply the same pause by hand.\n\nA pause does not affect the enrollment itself: the agent keeps running and only holds back its uploads, so Windows Autopilot and Intune carry on as usual. While the pause lasts, no events from this session reach Autopilot Monitor, so the timeline and the final status reflect the last state the agent reported before it. A paused session does not affect the device's next enrollment, which starts unblocked. When the agent ended monitoring, the device stays unmonitored until the end time shown on the session page, new enrollments included.",
"remediation": [
{
"title": "Find what made the session so busy",
"steps": [
"Open the event timeline and look at which event type repeats most often before the pause — for example a network connection that drops and reconnects every few minutes, or a download that reports progress continuously",
"Fix the underlying condition (for example a loose dock, cable or Wi-Fi), then re-enroll the device if you need a complete record of the enrollment",
"If no event type stands out, report the session from its page so it can be looked at"
]
},
{
"title": "What happens next",
"steps": [
"A pause ends on its own at the time shown on the session page; an agent that is still running then resumes uploading",
"After a pause, a new enrollment on this device starts unblocked",
"After the agent ended monitoring, wait for the end time shown on the session page before re-enrolling if you want the new enrollment monitored"
]
}
],
"relatedDocs": [
{
"title": "Autopilot Monitor: Protective Limits",
"url": "https://docs.autopilotmonitor.com/concepts/protective-limits"
}
],
"presentation": {
"role": "outcome",
"summary": "Monitoring was paused by a protective limit",
"facts": [
{
"label": "Paused at",
"value": "{{device_blocked.timestamp}}",
"format": "timestamp"
},
{
"label": "Events in this session",
"value": "{{event_count.value}}",
"format": "number"
},
{
"label": "Event limit",
"value": "{{event_limit.value}}",
"format": "number"
},
{
"label": "Paused until",
"value": "{{blocked_until.value}}",
"format": "timestamp"
}
]
},
"tags": ["enrollment", "protective-limit", "blocked"]
}
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the DeviceSetup phase of the ESP took longer than 30 minutes, indicating a stuck or very slow blocking app. Confidence increases when the phase exceeds 60 minutes.",
"severity": "high",
"category": "esp",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -47,6 +47,27 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "Device setup took {{esp_stalled.durationFormatted}}",
"subject": {
"kind": "esp",
"key": "{{esp_stalled.phase}}",
"name": "Device setup"
},
"facts": [
{
"label": "Duration",
"value": "{{esp_stalled.durationSeconds}}",
"format": "duration"
},
{
"label": "Started",
"value": "{{esp_stalled.phaseStartTimestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"esp",
"blocking",
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the Certificates subcategory of the Enrollment Status Page reported a failure, even if the enrollment later appears to have succeeded. Users can click 'Continue anyway' on an ESP error, which masks the underlying certificate provisioning failure — this rule surfaces the transition so it stays visible.",
"severity": "high",
"category": "esp",
"version": "1.1.0",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -58,6 +58,22 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "The ESP certificates step failed",
"subject": {
"kind": "esp",
"key": "Certificates",
"name": "Certificates"
},
"facts": [
{
"label": "Failed at",
"value": "{{certificates_subcategory_failed.timestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"esp",
"certificates",
+26 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects an ESP terminal failure where the ESP gave up while a blocking app was still installing (the agent's own esp_apps_timeout verdict — no per-app HRESULT was reported) on a profile that allows 'Continue anyway'. The enrollment is reported as failed, but the user most likely dismissed the error screen and reached the desktop — a soft failure rather than an unusable device. An ESP failure that carries a specific error code (a failed installer, a detection mismatch) is a different case and stays with the rule that explains that code.",
"severity": "warning",
"category": "esp",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"baseConfidence": 80,
"conditions": [
@@ -81,5 +81,30 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "The ESP gave up while {{appName.value}} was still installing ('Continue anyway' allowed)",
"subject": {
"kind": "app",
"key": "{{appName.appId}}",
"name": "{{appName.value}}"
},
"facts": [
{
"label": "ESP error",
"value": "{{enrollment_failed.errorCode}}",
"format": "errorCode"
},
{
"label": "ESP time limit (min)",
"value": "{{espSyncFailureTimeoutMinutes.value}}",
"format": "number"
},
{
"label": "ESP step",
"value": "{{failedSubcategory.value}}"
}
]
},
"tags": ["esp", "timeout", "continue-anyway", "soft-failure", "enrollment"]
}
+26 -1
View File
@@ -5,7 +5,7 @@
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
"severity": "warning",
"category": "esp",
"version": "1.3.0",
"version": "1.4.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -94,6 +94,31 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "A device policy forced a restart during setup: {{reboot_forcing_policy_uri.value}}",
"subject": {
"kind": "policy",
"key": "{{reboot_forcing_policy_uri.value}}"
},
"facts": [
{
"label": "First policy",
"value": "{{reboot_forcing_policy_uri.value}}",
"format": "code"
},
{
"label": "Policies",
"value": "{{multiple_reboot_uris.value}}",
"format": "number"
},
{
"label": "Restarts",
"value": "{{reboot_observed.count}}",
"format": "number"
}
]
},
"tags": [
"esp",
"reboot",
+29 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects the recovery story: the ESP reported a terminal failure (typically DeviceSetup/Apps), the user most likely pressed 'Try again' on the failure page, the failed step re-ran and completed successfully. The earlier failure did not stick — the enrollment continued past it.",
"severity": "info",
"category": "esp",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -98,6 +98,34 @@
]
}
],
"presentation": {
"role": "context",
"summary": "The ESP failed in the {{category.value}} phase, then recovered",
"subject": {
"kind": "esp",
"key": "{{failedSubcategory.value}}",
"name": "{{failedSubcategory.value}}"
},
"facts": [
{
"label": "ESP phase",
"value": "{{category.value}}"
},
{
"label": "ESP step",
"value": "{{failedSubcategory.value}}"
},
{
"label": "Likely apps",
"value": "{{likelyCulpritApps.value}}"
},
{
"label": "Recovered after (min)",
"value": "{{minutesSinceFailure.value}}",
"format": "number"
}
]
},
"tags": [
"esp",
"recovery",
+10 -1
View File
@@ -12,7 +12,7 @@
"enrollment_end",
"whiteglove_sealed"
],
"version": "1.3.0",
"version": "1.4.0",
"baseConfidence": 75,
"conditions": [
{
@@ -55,6 +55,15 @@
]
}
],
"presentation": {
"role": "advisory",
"summary": "The expected machine certificate was not found",
"subject": {
"kind": "device",
"key": "machine-certificate",
"name": "Machine certificate"
}
},
"tags": [
"identity",
"certificates",
+45 -1
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.3.0",
"version": "1.4.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -41,6 +41,30 @@
"operator": "equals",
"value": "error",
"required": false
},
{
"signal": "unexpected_accounts",
"source": "event_data",
"eventType": "local_admin_analysis",
"dataField": "checks.unexpected_accounts",
"operator": "not_equals",
"value": "",
"filterField": "finding",
"filterOperator": "equals",
"filterValue": "unexpected_local_admins_detected",
"required": false
},
{
"signal": "unexpected_admin_members",
"source": "event_data",
"eventType": "local_admin_analysis",
"dataField": "checks.unexpected_admin_members",
"operator": "not_equals",
"value": "",
"filterField": "finding",
"filterOperator": "equals",
"filterValue": "unexpected_local_admins_detected",
"required": false
}
],
"confidenceFactors": [
@@ -85,6 +109,26 @@
}
],
"relatedDocs": [],
"presentation": {
"role": "advisory",
"summary": "Unexpected local accounts: {{unexpected_accounts.value}}",
"subject": {
"kind": "account",
"key": "{{unexpected_accounts.value}}"
},
"facts": [
{
"label": "Unexpected accounts",
"value": "{{unexpected_accounts.value}}",
"format": "code"
},
{
"label": "In Administrators",
"value": "{{unexpected_admin_members.value}}",
"format": "code"
}
]
},
"tags": [
"identity",
"security",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Fires when the vulnerability correlation finds critical-severity CVEs or CISA KEV entries in installed software. This indicates the device has software with known, actively exploited vulnerabilities.",
"severity": "critical",
"category": "security",
"version": "1.1.0",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -72,6 +72,27 @@
"url": "https://nvd.nist.gov/"
}
],
"presentation": {
"role": "advisory",
"summary": "Installed software has {{critical_vulnerabilities.value}} critical vulnerabilities",
"subject": {
"kind": "device",
"key": "vulnerabilities",
"name": "Installed software"
},
"facts": [
{
"label": "Critical CVEs",
"value": "{{critical_vulnerabilities.value}}",
"format": "number"
},
{
"label": "Known exploited",
"value": "{{kev_matches.value}}",
"format": "number"
}
]
},
"tags": [
"vulnerability",
"cve",
+50 -1
View File
@@ -5,7 +5,7 @@
"description": "On a hybrid-joined device the real user reached the desktop, but the Intune Management Extension never acquired a Microsoft Entra user token for that user within the wait window — repeatedly across reboots when the warning recurs. Without a user token the signed-in user has no Entra user affinity: user-targeted apps and policies cannot apply, Windows Hello for Business cannot provision, and with a user ESP the Account Setup page re-runs on every logon.",
"severity": "high",
"category": "identity",
"version": "2.0.0",
"version": "2.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "correlation",
@@ -44,6 +44,24 @@
"operator": "not_exists",
"value": "",
"required": true
},
{
"signal": "token_failure_count",
"source": "event_data",
"eventType": "entra_user_affinity_pending",
"dataField": "tokenFailureCount",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "token_failure_codes",
"source": "event_data",
"eventType": "entra_user_affinity_pending",
"dataField": "tokenFailureCodes",
"operator": "not_equals",
"value": "",
"required": false
}
],
"confidenceFactors": [
@@ -96,5 +114,36 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "The user reached the desktop, but Entra user affinity never formed",
"subject": {
"kind": "account",
"key": "entra-user-affinity",
"name": "Entra user affinity"
},
"facts": [
{
"label": "Token failures",
"value": "{{token_failure_count.value}}",
"format": "number"
},
{
"label": "Token error codes",
"value": "{{token_failure_codes.value}}",
"format": "code"
},
{
"label": "Reported in agent runs",
"value": "{{user_affinity_pending.count}}",
"format": "number"
},
{
"label": "Desktop reached",
"value": "{{user_desktop_reached.timestamp}}",
"format": "timestamp"
}
]
},
"tags": ["identity", "hybrid-join", "entra-id", "user-affinity", "esp"]
}
+10 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects diagnostics package uploads that were answered by a security appliance's HTML block page instead of the Autopilot Monitor backend. A forward proxy / secure web gateway (SWG) with SSL inspection is intercepting the upload endpoint, so exactly the sessions where diagnostics matter most (OnFailure uploads) are the ones losing them.",
"severity": "warning",
"category": "network",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"baseConfidence": 75,
@@ -71,6 +71,15 @@
"url": "https://learn.microsoft.com/en-us/autopilot/networking-requirements"
}
],
"presentation": {
"role": "advisory",
"summary": "A web security gateway blocked the diagnostics upload",
"subject": {
"kind": "network",
"key": "security-gateway",
"name": "Web security gateway"
}
},
"tags": [
"network",
"proxy",
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the Microsoft 365 Apps (Office Click-to-Run) install failed. The V2 OfficeInstallDetector watches the C2R client directly (registry, Delivery Optimization, process) and emits office_install_failed when the C2R INSTALL scenario records an error code. Completion is proven by the core Office binaries on disk; an install window that closes without that proof ends silently, never as a failure. This catches Office failures the deployment status hides: the Intune built-in app type is marked done once the configuration is delivered, and a Win32 or RealmJoin package once its installer exits, while C2R keeps installing in the background.",
"severity": "warning",
"category": "apps",
"version": "1.1.1",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -52,6 +52,22 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "Microsoft 365 Apps failed to install",
"subject": {
"kind": "app",
"key": "office-c2r",
"name": "Microsoft 365 Apps"
},
"facts": [
{
"label": "Error",
"value": "{{error_code_present.value}}",
"format": "errorCode"
}
]
},
"tags": [
"apps",
"office",
+30 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed. Since v3 the agent reads the firmware db/KEK variables directly: when the firmware itself confirms the 2023 certificate, the rule is suppressed even if the Windows servicing status has not caught up yet.",
"severity": "warning",
"category": "security",
"version": "3.1.0",
"version": "3.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -132,6 +132,35 @@
"url": "https://support.microsoft.com/en-us/topic/kb5036210"
}
],
"presentation": {
"role": "advisory",
"summary": "The Windows UEFI CA 2023 certificate is not deployed",
"subject": {
"kind": "device",
"key": "secure-boot",
"name": "Secure Boot"
},
"facts": [
{
"label": "CA 2023 status",
"value": "{{ca2023_not_updated.value}}",
"format": "code"
},
{
"label": "Error",
"value": "{{ca2023_error.value}}",
"format": "code"
},
{
"label": "CA 2023 in firmware DB",
"value": "{{db_ca2023_firmware_missing.value}}"
},
{
"label": "KEK 2023 in firmware",
"value": "{{kek_2023_missing.value}}"
}
]
},
"tags": [
"security",
"secureboot",
+10 -1
View File
@@ -5,7 +5,7 @@
"description": "Fires when the AutoLogon analyzer reports a DefaultPassword value present in Winlogon. A plaintext AutoLogon password stored in the registry is readable by any local administrator and is a credential exposure — more severe than AutoLogon alone (the Sysinternals Autologon tool deliberately avoids this by storing the secret in the LSA instead).",
"severity": "high",
"category": "security",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -49,6 +49,15 @@
"url": "https://learn.microsoft.com/en-us/sysinternals/downloads/autologon"
}
],
"presentation": {
"role": "advisory",
"summary": "A plaintext AutoLogon password is stored in the registry",
"subject": {
"kind": "device",
"key": "autologon",
"name": "AutoLogon"
}
},
"tags": [
"security",
"autologon",
+16 -1
View File
@@ -5,7 +5,7 @@
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
"severity": "warning",
"category": "security",
"version": "1.1.0",
"version": "1.2.0",
"enabled": false,
"evaluateOn": [
"enrollment_end",
@@ -59,6 +59,21 @@
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
}
],
"presentation": {
"role": "advisory",
"summary": "AutoLogon is enabled for an unexpected user: {{unexpected_autologon_user.value}}",
"subject": {
"kind": "account",
"key": "{{unexpected_autologon_user.value}}"
},
"facts": [
{
"label": "AutoLogon user",
"value": "{{unexpected_autologon_user.value}}",
"format": "code"
}
]
},
"tags": [
"security",
"autologon",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects provisioning packages (PPKG) that are not on the built-in allow-list. The agent emits one or more provisioning_package_scan events whose artifacts[] arrays list every PPKG (registry package, .ppkg file, or Recovery\\Customizations residue; a large set is split across several events). This rule iterates that array across all of them and fires for any artifact whose identity does not match the allow-list regex. The default allow-list covers the Windows OS-inbox packages (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) plus common OEM factory-preload families (Intel PPM power profiles, AMD Power profiles, Lenovo ThinkPad power/battery packages, Microsoft Surface defaults and carrier packages) so clean factory devices stay quiet. OEM imaging leftovers (e.g. USMT.PPKG, InstallOrder.ini) and generically named packages deliberately still alert — on a pure-Autopilot device they are worth a look. To allow your own vendor/recovery packages, enable the template rule ANALYZE-SEC-006, extend its allow-list, then disable this rule.",
"severity": "warning",
"category": "security",
"version": "2.2.0",
"version": "2.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -38,6 +38,27 @@
]
}
],
"presentation": {
"role": "advisory",
"summary": "Provisioning package not on the allow list: {{ppkg_not_allowlisted.value}}",
"subject": {
"kind": "device",
"key": "provisioning-packages",
"name": "Provisioning packages"
},
"facts": [
{
"label": "Package",
"value": "{{ppkg_not_allowlisted.value}}",
"format": "code"
},
{
"label": "Unexpected packages",
"value": "{{ppkg_not_allowlisted.matchCount}}",
"format": "number"
}
]
},
"tags": [
"security",
"provisioning-package",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Template version of ANALYZE-SEC-005 with a customizable allow-list. Iterates the provisioning_package_scan artifacts[] array and fires for any PPKG whose identity is not on YOUR allow-list regex. The placeholder ships with the same OS-inbox defaults as ANALYZE-SEC-005 — extend it with your own vendor/recovery packages. This rule is OFF by default: enable it, set your allow-list, then disable ANALYZE-SEC-005 so only packages outside your list alert.",
"severity": "warning",
"category": "security",
"version": "2.2.0",
"version": "2.3.0",
"enabled": false,
"evaluateOn": [
"enrollment_end",
@@ -45,6 +45,27 @@
]
}
],
"presentation": {
"role": "advisory",
"summary": "Provisioning package not on the allow list: {{ppkg_not_allowlisted.value}}",
"subject": {
"kind": "device",
"key": "provisioning-packages",
"name": "Provisioning packages"
},
"facts": [
{
"label": "Package",
"value": "{{ppkg_not_allowlisted.value}}",
"format": "code"
},
{
"label": "Unexpected packages",
"value": "{{ppkg_not_allowlisted.matchCount}}",
"format": "number"
}
]
},
"tags": [
"security",
"provisioning-package",
+10 -1
View File
@@ -5,7 +5,7 @@
"description": "Fires when the provisioning-package scan hit a per-source collection cap (too many .ppkg files, registry packages, or Recovery\\Customizations residue files to capture them all). When that happens the allow-list rules (ANALYZE-SEC-005/006) could not evaluate every package, so a non-allow-listed package may have gone unseen. A device with that many provisioning packages is itself unusual and worth a manual look.",
"severity": "warning",
"category": "security",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -36,6 +36,15 @@
]
}
],
"presentation": {
"role": "advisory",
"summary": "The provisioning package scan hit its capture limit",
"subject": {
"kind": "device",
"key": "provisioning-packages",
"name": "Provisioning packages"
}
},
"tags": [
"security",
"provisioning-package",
+10 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
"severity": "warning",
"category": "security",
"version": "1.1.0",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -82,6 +82,15 @@
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
}
],
"presentation": {
"role": "advisory",
"summary": "Secure Boot is disabled",
"subject": {
"kind": "device",
"key": "secure-boot",
"name": "Secure Boot"
}
},
"tags": [
"security",
"secureboot",
+1271 -63
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -133,6 +133,7 @@
"items": { "$ref": "#/$defs/relatedDoc" },
"default": []
},
"presentation": { "$ref": "#/$defs/rulePresentation" },
"tags": {
"type": "array",
"description": "Tags for filtering and categorization.",
@@ -352,6 +353,83 @@
"required": ["title", "url"],
"additionalProperties": false
},
"rulePresentation": {
"type": "object",
"description": "How a finding of this rule is shown: its role, a one-line summary, what it is about and its key facts. Every string is a {{token}} template resolved from the finding's evidence when it is rendered; a fact whose value was not recorded is left out.",
"properties": {
"role": {
"type": "string",
"enum": ["outcome", "cause", "context", "advisory"],
"description": "outcome: restates the session outcome (shown in the verdict, not as a finding row). cause: can explain why a session failed or is stuck. context: a circumstance such as standby, a restart or battery power. advisory: hygiene or security without bearing on the outcome."
},
"summary": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"description": "One line for the finding row, for example \"{{install_retry_loop.appName}} was started {{install_retry_loop.count}} times\". When a token was not recorded, the first sentence of the explanation is shown instead."
},
"subject": { "$ref": "#/$defs/ruleSubject" },
"facts": {
"type": "array",
"description": "Key facts shown as label/value pairs, in this order.",
"maxItems": 6,
"items": { "$ref": "#/$defs/ruleFact" }
}
},
"required": ["role"],
"additionalProperties": false
},
"ruleSubject": {
"type": "object",
"description": "What the finding is about. Findings with the same kind and resolved key are grouped, for example every finding about one app. Use qualified tokens of one signal for key and name, such as {{failed_app.appId}} and {{failed_app.appName}}, so both come from the same event.",
"properties": {
"kind": {
"type": "string",
"enum": ["app", "policy", "account", "script", "device", "network", "esp", "session"],
"description": "The kind of thing the key identifies."
},
"key": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Template of the grouping key."
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Template of the display name; the key is shown when it is missing."
}
},
"required": ["kind", "key"],
"additionalProperties": false
},
"ruleFact": {
"type": "object",
"description": "One key fact of a finding.",
"properties": {
"label": {
"type": "string",
"minLength": 1,
"maxLength": 40,
"description": "Short label, for example \"Install starts\"."
},
"value": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Template of the value, for example {{install_retry_loop.count}}."
},
"format": {
"type": "string",
"enum": ["text", "code", "errorCode", "duration", "number", "timestamp"],
"description": "How the value is shown. errorCode adds the error-code catalog meaning, duration reads seconds, timestamp an ISO 8601 time.",
"default": "text"
}
},
"required": ["label", "value"],
"additionalProperties": false
},
"templateVariable": {
"type": "object",
"description": "A variable in a rule condition that must be customized per-tenant before the rule can be used.",
@@ -30,6 +30,17 @@ BeforeAll {
return $dir
}
# What Test-FileCatalog -Detailed returns, reduced to the fields the check reads.
function New-FakeCatalogResult {
param(
[string]$Status = 'Valid',
[object]$Signature,
[hashtable]$CatalogItems = @{ 'AutopilotMonitor.Agent.exe' = 'AA' },
[hashtable]$PathItems = @{ 'AutopilotMonitor.Agent.exe' = 'AA' }
)
return [pscustomobject]@{ Status = $Status; Signature = $Signature; CatalogItems = $CatalogItems; PathItems = $PathItems }
}
function New-FakeProfile {
param([string]$Name, [double]$AgeMinutes)
$p = Join-Path $TestDrive $Name
@@ -322,86 +333,163 @@ Describe 'logging purity' {
}
}
Describe 'Get-UnverifiedAgentBinaries' {
# The publisher gate on the extracted payload. What matters is the DECISION: which
# payload is accepted, which is refused, and which files are looked at at all.
Describe 'Get-AgentPayloadRejections' {
# The payload catalog check, shared verbatim with the OOBE go-script. What matters is the
# DECISION: which payload is accepted and which is refused. Test-FileCatalog is mocked for
# the branches; one test runs the real cmdlet over a real (unsigned) catalog.
BeforeEach {
Mock Write-Log { }
$script:ours = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE'
$script:dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat')
}
It 'accepts a payload where every binary of ours is validly signed by us' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject $script:ours }
Get-UnverifiedAgentBinaries -Path $dir | Should -BeNullOrEmpty
It 'accepts a payload whose catalog is signed by us and matches every file' {
Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours) }
Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher | Should -BeNullOrEmpty
}
It 'checks the binaries we author and leaves third-party assemblies alone' {
$dir = New-FakePayload -Files @(
'AutopilotMonitor.Agent.exe',
'AutopilotMonitor.Agent.exe.config',
'AutopilotMonitor.Shared.dll',
'Newtonsoft.Json.dll',
'System.Management.Automation.dll'
)
$script:checked = @()
Mock Get-AuthenticodeSignature {
$script:checked += (Split-Path $FilePath -Leaf)
New-FakeSignature -Status 'Valid' -Subject $script:ours
It 'asks for the detailed result and skips the catalog itself' {
Mock Test-FileCatalog {
$script:call = @{ Skip = $FilesToSkip; Detailed = [bool]$Detailed; Catalog = $CatalogFilePath; Path = $Path }
New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours)
}
Get-UnverifiedAgentBinaries -Path $dir | Should -BeNullOrEmpty
$script:checked | Should -Be @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher | Out-Null
$script:call.Skip | Should -Be @('AutopilotMonitor.Agent.cat')
$script:call.Detailed | Should -BeTrue
$script:call.Catalog | Should -Be (Join-Path $script:dir 'AutopilotMonitor.Agent.cat')
$script:call.Path | Should -Be $script:dir
}
It 'refuses an unsigned agent executable' {
It 'refuses a payload without catalog and does not run the check at all' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'NotSigned' -Subject $null }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
Mock Test-FileCatalog { throw 'must not run' }
$r = Get-AgentPayloadRejections -Path $dir -ExpectedPublisher $ExpectedPublisher
$r.Count | Should -Be 1
$r[0] | Should -BeLike '*AutopilotMonitor.Agent.cat missing*'
Should -Invoke Test-FileCatalog -Times 0
}
It 'refuses a tampered binary (hash mismatch)' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'HashMismatch' -Subject $script:ours }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
It 'refuses matching files under an unsigned catalog (Status alone is not the verdict)' {
Mock Test-FileCatalog { New-FakeCatalogResult -Status 'Valid' -Signature (New-FakeSignature -Status 'NotSigned' -Subject $null) }
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
$r.Count | Should -Be 1
$r[0] | Should -BeLike '*signature status NotSigned*'
}
It 'refuses a signature whose chain does not validate' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'UnknownError' -Subject $script:ours }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
It 'refuses a catalog whose signature does not validate' {
Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'HashMismatch' -Subject $script:ours) }
(Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher).Count | Should -Be 1
}
It 'refuses a validly signed binary from a different publisher' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
It 'refuses a validly signed catalog from another publisher' {
Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US') }
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
$r.Count | Should -Be 1
$r[0] | Should -BeLike '*does not match*'
}
It 'refuses the whole payload when a single DLL of ours is swapped' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
Mock Get-AuthenticodeSignature {
if ((Split-Path $FilePath -Leaf) -eq 'AutopilotMonitor.Shared.dll') {
New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US'
} else {
New-FakeSignature -Status 'Valid' -Subject $script:ours
}
It 'names the missing, extra and changed files of a payload that differs' {
Mock Test-FileCatalog {
New-FakeCatalogResult -Status 'ValidationFailed' -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours) `
-CatalogItems @{ 'a.dll' = 'AA'; 'b.dll' = 'BB'; 'c.config' = 'CC' } `
-PathItems @{ 'a.dll' = 'AA'; 'b.dll' = 'XX'; 'evil.dll' = 'EE' }
}
$rejected = Get-UnverifiedAgentBinaries -Path $dir
$rejected.Count | Should -Be 1
$rejected[0] | Should -BeLike 'AutopilotMonitor.Shared.dll*'
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
$r.Count | Should -Be 1
$r[0] | Should -BeLike '*missing: c.config;*'
$r[0] | Should -BeLike '*extra: evil.dll;*'
$r[0] | Should -BeLike '*changed: b.dll)*'
}
It 'refuses when the signature check itself throws' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Get-AuthenticodeSignature { throw 'access denied' }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
It 'reports a bad signature and a differing payload together' {
Mock Test-FileCatalog {
New-FakeCatalogResult -Status 'ValidationFailed' -Signature (New-FakeSignature -Status 'NotSigned' -Subject $null) `
-PathItems @{ 'AutopilotMonitor.Agent.exe' = 'AA'; 'extra.dll' = 'EE' }
}
(Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher).Count | Should -Be 2
}
It 'refuses an empty payload instead of passing it' {
$dir = New-FakePayload -Files @('Newtonsoft.Json.dll')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject $script:ours }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
It 'refuses when the catalog check itself throws' {
Mock Test-FileCatalog { throw 'access denied' }
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
$r.Count | Should -Be 1
$r[0] | Should -BeLike '*failed to run*access denied*'
}
It 'refuses when the catalog check returns nothing' {
Mock Test-FileCatalog { }
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
$r[0] | Should -BeLike '*returned nothing*'
}
It 'returns a single rejection as an array' {
Mock Test-FileCatalog { throw 'boom' }
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
($r -is [array]) | Should -BeTrue
}
It 'catches an extra file with the real cmdlet over a real catalog' {
# Real New-FileCatalog/Test-FileCatalog over text files: no PE, no process, no mock.
$dir = New-FakePayload -Files @('a.txt', 'b.txt')
$catalog = Join-Path $TestDrive "real-$([guid]::NewGuid()).cat"
New-FileCatalog -Path $dir -CatalogFilePath $catalog -CatalogVersion 2.0 | Out-Null
Copy-Item $catalog (Join-Path $dir 'AutopilotMonitor.Agent.cat')
Set-Content -Path (Join-Path $dir 'extra.txt') -Value 'planted'
$r = Get-AgentPayloadRejections -Path $dir -ExpectedPublisher $ExpectedPublisher
($r -join ' | ') | Should -BeLike '*extra: extra.txt;*'
($r -join ' | ') | Should -BeLike '*signature status NotSigned*'
}
}
Describe 'Install-VerifiedAgentPayload' {
BeforeEach {
Mock Write-Log { }
$src = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat', 'Newtonsoft.Json.dll')
$script:zip = Join-Path $TestDrive "payload-$([guid]::NewGuid()).zip"
Compress-Archive -Path (Join-Path $src '*') -DestinationPath $script:zip
$script:staging = Join-Path $TestDrive "staging-$([guid]::NewGuid())"
$script:target = Join-Path $TestDrive "agent-$([guid]::NewGuid())"
}
It 'moves an accepted payload into the agent folder and removes the staging folder' {
Mock Get-AgentPayloadRejections { , @() }
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
@(Get-ChildItem $script:target | ForEach-Object Name | Sort-Object) |
Should -Be @('AutopilotMonitor.Agent.cat', 'AutopilotMonitor.Agent.exe', 'Newtonsoft.Json.dll')
Test-Path $script:staging | Should -BeFalse
}
It 'verifies the staging folder, never the agent folder' {
Mock Get-AgentPayloadRejections { $script:checked = $Path; , @() }
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
$script:checked | Should -Be $script:staging
}
It 'leaves the agent folder untouched when the payload is refused, and cleans up' {
Mock Get-AgentPayloadRejections { , @('payload differs from catalog (missing: none; extra: evil.dll; changed: none)') }
{ Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher } |
Should -Throw '*Payload verification FAILED*evil.dll*'
Test-Path $script:target | Should -BeFalse
Test-Path $script:staging | Should -BeFalse
}
It 'removes leftovers from the agent folder before the payload moves in' {
New-Item -ItemType Directory -Path $script:target | Out-Null
Set-Content -Path (Join-Path $script:target 'stale.dll') -Value 'x'
Mock Get-AgentPayloadRejections { , @() }
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
Test-Path (Join-Path $script:target 'stale.dll') | Should -BeFalse
Test-Path (Join-Path $script:target 'AutopilotMonitor.Agent.exe') | Should -BeTrue
}
It 'starts from an empty staging folder even when a previous run left one behind' {
New-Item -ItemType Directory -Path $script:staging | Out-Null
Set-Content -Path (Join-Path $script:staging 'leftover.dll') -Value 'x'
Mock Get-AgentPayloadRejections { $script:seen = @(Get-ChildItem $Path | ForEach-Object Name); , @() }
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
$script:seen | Should -Not -Contain 'leftover.dll'
}
}
@@ -410,4 +498,8 @@ Describe 'publisher pin' {
$ExpectedPublisher | Should -Be '*O=glueckkanja AG*'
$ExpectedPublisher | Should -Not -Match '^[0-9A-Fa-f]{40}$'
}
It 'names the payload catalog the release pipeline signs' {
$AgentCatalogName | Should -Be 'AutopilotMonitor.Agent.cat'
}
}
+200 -146
View File
@@ -11,10 +11,12 @@
freshly provisioned Windows 365 Cloud PC.
2. Downloads the monitoring agent ZIP from Azure Blob Storage
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
5. Verifies the Authenticode signature and publisher of every binary we author
6. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
7. Verifies the runtime process actually launched
4. Extracts the agent into a fresh staging folder
5. Verifies the payload against its signed catalog (AutopilotMonitor.Agent.cat):
signed by the expected publisher, every file present and unchanged, nothing extra
6. Moves the verified payload into %ProgramData%\AutopilotMonitor\Agent
7. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
8. Verifies the runtime process actually launched
Agent self-destructs when enrollment completes.
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
@@ -77,6 +79,14 @@
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
.CHANGELOG
2026-10-13 v2.6 Payload catalog: the agent ZIP carries AutopilotMonitor.Agent.cat,
a catalog of every payload file signed by the expected publisher.
The ZIP is extracted into a staging folder and moved into the
agent folder only when Test-FileCatalog finds the catalog signed
by the expected publisher and every file present, unchanged and
nothing extra. A rejected payload is deleted, so the next run
starts clean. Replaces the v2.5 gate, which covered only the
AutopilotMonitor binaries.
2026-09-08 v2.5 Publisher gate: every AutopilotMonitor binary in the extracted
payload must carry a valid Authenticode signature from the
expected publisher before --install runs. The SHA-256 manifest
@@ -134,16 +144,20 @@ param(
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "2.5"
$ScriptVersion = "2.6"
# The publisher every binary we author must carry. A -like pattern on the subject, NOT a
# The publisher the payload catalog must carry. A -like pattern on the subject, NOT a
# thumbprint: certificates are renewed, deployed copies of this script are not. Same value
# the loader pins for this script.
$ExpectedPublisher = "*O=glueckkanja AG*"
# Signed catalog at the root of every agent ZIP: the SHA-256 of every other payload file.
$AgentCatalogName = "AutopilotMonitor.Agent.cat"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentStagingPath = "$AgentBasePath\Agent-Install"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\bootstrap-script.log"
@@ -340,56 +354,107 @@ function Get-BootstrapDecision {
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
}
# Returns the list of reasons why the extracted payload is not ours; an empty result means
# every binary we author carries a valid Authenticode signature from the expected publisher.
# Third-party assemblies keep their own publisher's signature and are not checked here.
# Any error is a rejection: an unreadable signature is an unverified binary.
function Get-UnverifiedAgentBinaries {
param([string]$Path)
$rejected = @()
$ourBinaries = @(
Get-ChildItem -Path $Path -Filter 'AutopilotMonitor.*' -File -ErrorAction SilentlyContinue |
Where-Object { $_.Extension -in @('.exe', '.dll') }
### BEGIN shared payload verification -- embedded verbatim into the OOBE go-script by
### OobeBootstrapScriptGenerator (D-354). Keep it self-contained: parameters only, no
### script-scope variables, no Write-Log.
# Returns the reasons why the extracted payload in $Path must not run; an empty result means
# it matches its signed catalog exactly. Test-FileCatalog's Status covers the hashes only (an
# unsigned catalog over matching files reports Valid), so the signature and its publisher are
# judged on their own. Any error is a rejection: a payload that cannot be verified is an
# unverified payload. The comma keeps a single rejection an ARRAY: PowerShell unrolls a
# one-element result to a bare string, and the caller reads .Count.
function Get-AgentPayloadRejections {
param(
[string]$Path,
[string]$ExpectedPublisher,
[string]$CatalogName = 'AutopilotMonitor.Agent.cat'
)
# An empty set is a rejection, not a pass: it means the extraction produced nothing we
# recognise, and the alternative would be a gate that silently approves an empty folder.
# The comma keeps a single rejection an ARRAY: PowerShell unrolls a one-element result to
# a bare string, and the caller reads .Count and indexes into it.
if ($ourBinaries.Count -eq 0) {
return , @("no AutopilotMonitor binaries found in $Path")
$catalog = Join-Path $Path $CatalogName
if (-not (Test-Path -LiteralPath $catalog -PathType Leaf)) {
return , @("catalog $CatalogName missing")
}
foreach ($binary in $ourBinaries) {
try {
$sig = Get-AuthenticodeSignature -FilePath $binary.FullName -ErrorAction Stop
}
catch {
$rejected += "$($binary.Name): signature check failed to run ($($_.Exception.Message))"
continue
}
try {
$result = Test-FileCatalog -Path $Path -CatalogFilePath $catalog -FilesToSkip $CatalogName -Detailed -ErrorAction Stop
}
catch {
return , @("catalog check failed to run ($($_.Exception.Message))")
}
if ($null -eq $result) {
return , @('catalog check returned nothing')
}
if ($null -eq $sig) {
$rejected += "$($binary.Name): signature check returned nothing"
continue
}
$rejected = @()
$signature = $result.Signature
if ($null -eq $signature -or "$($signature.Status)" -ne 'Valid') {
$status = if ($null -eq $signature) { 'missing' } else { "$($signature.Status)" }
$rejected += "catalog signature status $status"
}
elseif ($null -eq $signature.SignerCertificate -or $signature.SignerCertificate.Subject -notlike $ExpectedPublisher) {
$subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject }
$rejected += "catalog publisher '$subject' does not match $ExpectedPublisher"
}
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '<unsigned>' }
if ($sig.Status -ne 'Valid') {
$rejected += "$($binary.Name): signature status $($sig.Status)"
continue
}
if ($subject -notlike $ExpectedPublisher) {
$rejected += "$($binary.Name): unexpected publisher $subject"
continue
if ("$($result.Status)" -ne 'Valid') {
$expected = if ($null -eq $result.CatalogItems) { @{} } else { $result.CatalogItems }
$actual = if ($null -eq $result.PathItems) { @{} } else { $result.PathItems }
$names = {
param($list)
if ($list.Count -eq 0) { return 'none' }
$shown = ($list | Select-Object -First 5) -join ', '
if ($list.Count -gt 5) { $shown += " (+$($list.Count - 5) more)" }
return $shown
}
$missing = @($expected.Keys | Where-Object { -not $actual.ContainsKey($_) } | Sort-Object)
$extra = @($actual.Keys | Where-Object { -not $expected.ContainsKey($_) } | Sort-Object)
$changed = @($expected.Keys | Where-Object { $actual.ContainsKey($_) -and $actual[$_] -ne $expected[$_] } | Sort-Object)
$rejected += "payload differs from catalog (missing: $(& $names $missing); extra: $(& $names $extra); changed: $(& $names $changed))"
}
return , $rejected
}
# Extracts the agent ZIP into a fresh staging folder, verifies it there and moves it into the
# agent folder only when it matches its signed catalog. A rejected payload is deleted with the
# staging folder: nothing unverified lands where SYSTEM runs the agent, and the next run starts
# clean instead of finding a half-installed agent. The caller has made sure no agent is
# installed, so anything already in the agent folder is a leftover and goes first.
function Install-VerifiedAgentPayload {
param(
[string]$ZipPath,
[string]$StagingPath,
[string]$TargetPath,
[string]$ExpectedPublisher,
[string]$CatalogName = 'AutopilotMonitor.Agent.cat'
)
if (Test-Path -LiteralPath $StagingPath) {
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction Stop
}
New-Item -Path $StagingPath -ItemType Directory -Force | Out-Null
try {
Expand-Archive -Path $ZipPath -DestinationPath $StagingPath -Force -ErrorAction Stop
$rejections = Get-AgentPayloadRejections -Path $StagingPath -ExpectedPublisher $ExpectedPublisher -CatalogName $CatalogName
if ($rejections.Count -gt 0) {
throw "Payload verification FAILED ($($rejections -join '; ')). Refusing to install an agent that is not the published one."
}
if (Test-Path -LiteralPath $TargetPath) {
Get-ChildItem -LiteralPath $TargetPath -Force | Remove-Item -Recurse -Force -ErrorAction Stop
}
else {
New-Item -Path $TargetPath -ItemType Directory -Force | Out-Null
}
Get-ChildItem -LiteralPath $StagingPath -Force | Move-Item -Destination $TargetPath -Force -ErrorAction Stop
}
finally {
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction SilentlyContinue
}
}
### END shared payload verification
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
# at the bottom); never on dot-source.
function Invoke-Bootstrap {
@@ -408,117 +473,106 @@ function Invoke-Bootstrap {
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (relaxActive=$($decision.RelaxActive))"
# Download and extract agent binaries
# Download, verify and install the agent binaries. Guard 5 has already returned when an
# agent exe exists, so this always starts without an installed agent.
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
Write-Log "Downloading agent from $AgentDownloadUrl..."
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath"
}
else {
Write-Log "Downloading agent from $AgentDownloadUrl..."
try {
# Derive manifest URL from the agent download URL (same blob container)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
$expectedSha256 = $null
$manifestAttempts = 3
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
try {
# Derive manifest URL from the agent download URL (same blob container)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
$expectedSha256 = $null
$manifestAttempts = 3
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
try {
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
}
break # manifest reached (with or without hash); retrying would not help
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
try {
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
}
catch {
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
if ($attempt -lt $manifestAttempts) {
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
}
break # manifest reached (with or without hash); retrying would not help
}
catch {
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
if ($attempt -lt $manifestAttempts) {
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
}
}
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) {
throw
}
$retryDelaysInSeconds = @(2, 4, 8)
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
Start-Sleep -Seconds $retryDelaySeconds
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# SHA-256 from the version manifest is the only accepted integrity proof.
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
if (-not $expectedSha256) {
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
}
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Cleaned up temporary files"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "Agent installation completed successfully"
}
catch {
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
throw
}
}
# Publisher gate. The SHA-256 above proves the ZIP is the one the manifest names, but
# that manifest lives in the same container as the ZIP and cannot outlive a compromise
# of the download host. The expected publisher travels inside THIS script, which the
# loader verified before running it, so the chain from the file an administrator
# assigns to the process that starts here stays publisher-anchored end to end.
# Fail-closed, exactly like the hash check: running unverified code as SYSTEM is the
# risk being removed.
Write-Log "Verifying Authenticode signatures of the agent binaries..."
$unverified = Get-UnverifiedAgentBinaries -Path $AgentBinPath
if ($unverified.Count -gt 0) {
throw "Authenticode verification FAILED ($($unverified -join '; ')). Refusing to run an agent that is not signed by the expected publisher."
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) {
throw
}
$retryDelaysInSeconds = @(2, 4, 8)
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
Start-Sleep -Seconds $retryDelaySeconds
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# SHA-256 from the version manifest is the only accepted integrity proof.
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
if (-not $expectedSha256) {
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
}
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
# Payload catalog. The SHA-256 above proves the ZIP is the one the manifest names, but
# that manifest lives in the same container as the ZIP and cannot outlive a compromise
# of the download host. The catalog is signed by the expected publisher, whose pin
# travels inside THIS script -- verified by the loader before it ran -- so the chain
# from the file an administrator assigns to every file the agent runs from stays
# publisher-anchored end to end. Fail-closed like the hash check: running unverified
# code as SYSTEM is the risk being removed.
Write-Log "Verifying the payload against its signed catalog ($AgentCatalogName, publisher pin: $ExpectedPublisher)..."
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
Write-Log "Payload verified against its signed catalog and installed to $AgentBinPath"
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Cleaned up temporary files"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "Agent installation completed successfully"
}
catch {
Write-Log "ERROR downloading/verifying agent: $($_.Exception.Message)"
throw
}
Write-Log "Authenticode verification passed (publisher pin: $ExpectedPublisher)"
Write-Log "Calling agent install mode (--install)..."
& $agentExePath --install
@@ -17,6 +17,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
public DistressErrorType ErrorType { get; set; }
public string Message { get; set; } = default!;
public int? HttpStatusCode { get; set; }
public string? TpmVersion { get; set; }
public DistressDeviceContext? Context { get; set; }
}
public List<Call> Calls { get; } = new List<Call>();
@@ -32,9 +34,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
{
}
public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null)
public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null, DistressDetail? detail = null)
{
Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode });
Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode, TpmVersion = detail?.TpmVersion, Context = detail?.Context });
return Send?.Invoke() ?? Task.CompletedTask;
}
}
@@ -0,0 +1,82 @@
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
{
/// <summary>
/// The ESP phase a report names, derived from the three ESP registry values. Both value shapes
/// Windows writes are covered: flat status texts (older builds) and nested subcategory objects.
/// </summary>
public sealed class EspPhaseSnapshotTests
{
private const string DoneFlag = "{\"categorySucceeded\":true}";
private const string FailedFlag = "{\"categorySucceeded\":false}";
private const string NestedRunning =
"{\"SecurityPoliciesSubcategory\":{\"subcategoryState\":\"succeeded\",\"subcategoryStatusText\":\"Complete\"}," +
"\"AppsSubcategory\":{\"subcategoryState\":\"inProgress\",\"subcategoryStatusText\":\"Installing (1 of 3)\"}}";
private const string NestedAllDoneNoFlag =
"{\"SecurityPoliciesSubcategory\":{\"subcategoryState\":\"succeeded\"},\"AppsSubcategory\":{\"subcategoryState\":\"notRequired\"}}";
private const string NestedFailedNoFlag =
"{\"SecurityPoliciesSubcategory\":{\"subcategoryState\":\"succeeded\"},\"AppsSubcategory\":{\"subcategoryState\":\"failed\"}}";
private const string FlatRunning = "{\"CertificatesSubcategory\":\"Certificates (0 of 1 applied)\",\"AppsSubcategory\":\"Identifying\"}";
private static string Derive(string? preparation, string? deviceSetup, string? accountSetup)
=> EspPhaseSnapshot.Derive(new List<string> { preparation!, deviceSetup!, accountSetup! });
[Fact]
public void No_value_is_none()
{
Assert.Equal("none", Derive(null, null, null));
}
[Fact]
public void Running_category_names_its_phase()
{
Assert.Equal("device_preparation", Derive(NestedRunning, null, null));
Assert.Equal("device_setup", Derive(DoneFlag, NestedRunning, null));
Assert.Equal("account_setup", Derive(DoneFlag, DoneFlag, NestedRunning));
}
[Fact]
public void Flat_status_texts_are_read_too()
{
Assert.Equal("device_setup", Derive(DoneFlag, FlatRunning, null));
}
[Fact]
public void Done_categories_name_the_last_one_reached()
{
Assert.Equal("device_preparation_done", Derive(DoneFlag, null, null));
Assert.Equal("device_setup_done", Derive(DoneFlag, DoneFlag, null));
Assert.Equal("esp_done", Derive(DoneFlag, DoneFlag, DoneFlag));
}
[Fact]
public void All_subcategories_done_counts_as_done_without_the_flag()
{
// Some builds never set categorySucceeded although every subcategory finished.
Assert.Equal("esp_done", Derive(DoneFlag, NestedAllDoneNoFlag, NestedAllDoneNoFlag));
}
[Fact]
public void A_failure_wins_over_everything_after_it()
{
Assert.Equal("device_setup_failed", Derive(DoneFlag, FailedFlag, NestedRunning));
Assert.Equal("account_setup_failed", Derive(DoneFlag, DoneFlag, NestedFailedNoFlag));
}
[Fact]
public void Unparseable_value_counts_as_running()
{
Assert.Equal("device_setup", Derive(DoneFlag, "{not json", null));
}
[Fact]
public void Read_on_this_machine_returns_a_known_token()
{
var phase = EspPhaseSnapshot.Read();
Assert.Matches("^[a-z_]+$", phase);
}
}
}
@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
{
/// <summary>
/// The bootstrap provenance in <c>agent_started</c>. Fixture lines are the scripts' real
/// formats: installer <c>[ts] Bootstrap script version: vX.Y</c>
/// (Install-AutopilotMonitor.ps1), loader <c>[ts] === Autopilot Monitor bootstrap loader vX.Y
/// started (user=..., pid=...) ===</c> (Start-AutopilotMonitor.ps1), the MSI's downloader before
/// the loader <c>[ts] MSI bootstrap runner started (user=..., pid=...).</c>.
/// </summary>
public sealed class BootstrapProvenanceReaderTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "apm-provenance-" + Guid.NewGuid().ToString("N"));
public BootstrapProvenanceReaderTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ }
}
private void Write(string name, params string[] lines) =>
File.WriteAllText(Path.Combine(_dir, name), string.Join("\r\n", lines) + "\r\n", Encoding.ASCII);
private static string InstallerRun(string version) => $"[2026-10-13 09:00:00] Bootstrap script version: v{version}";
private static string LoaderRun(string version) =>
$"[2026-10-13 08:59:58] === Autopilot Monitor bootstrap loader v{version} started (user=SECRETUSER, pid=4242) ===";
[Fact]
public void Loader_path_reports_installer_and_loader_versions()
{
Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.2"), "[2026-10-13 08:59:59] Installer signature verified");
Write(BootstrapProvenanceReader.InstallerLogName, "[2026-10-13 09:00:00] ===== Autopilot Monitor Bootstrap Started =====", InstallerRun("2.5"));
var p = BootstrapProvenanceReader.Read(_dir);
Assert.True(p.ScriptLogPresent);
Assert.Equal("2.5", p.ScriptVersion);
Assert.Equal("loader", p.LoaderLog);
Assert.Equal("1.2", p.LoaderVersion);
Assert.False(p.OobeLogPresent);
}
[Fact]
public void The_last_run_wins_when_the_log_grew_over_several_runs()
{
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.3"), "x", InstallerRun("2.4"), "y", InstallerRun("2.5"), "z");
Assert.Equal("2.5", BootstrapProvenanceReader.Read(_dir).ScriptVersion);
}
[Fact]
public void A_directly_assigned_installer_has_no_loader_log()
{
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.4"));
var p = BootstrapProvenanceReader.Read(_dir);
Assert.Equal("2.4", p.ScriptVersion);
Assert.Null(p.LoaderLog);
Assert.Null(p.LoaderVersion);
}
[Fact]
public void An_installer_from_before_the_version_line_is_present_but_without_version()
{
Write(BootstrapProvenanceReader.InstallerLogName, "[2026-03-01 09:00:00] ===== Autopilot Monitor Bootstrap Started =====");
var p = BootstrapProvenanceReader.Read(_dir);
Assert.True(p.ScriptLogPresent);
Assert.Null(p.ScriptVersion);
}
[Fact]
public void An_installer_from_before_the_log_rename_is_read_under_its_old_name()
{
Write(BootstrapProvenanceReader.LegacyInstallerLogName, InstallerRun("2.3"));
var p = BootstrapProvenanceReader.Read(_dir);
Assert.True(p.ScriptLogPresent);
Assert.Equal("2.3", p.ScriptVersion);
}
[Fact]
public void With_both_installer_logs_the_most_recently_written_one_counts()
{
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.6"));
Write(BootstrapProvenanceReader.LegacyInstallerLogName, InstallerRun("2.3"));
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.InstallerLogName), DateTime.UtcNow.AddDays(-3));
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.LegacyInstallerLogName), DateTime.UtcNow.AddMinutes(-1));
Assert.Equal("2.3", BootstrapProvenanceReader.Read(_dir).ScriptVersion);
}
[Fact]
public void The_msi_downloader_from_before_the_loader_counts_as_msi_without_version()
{
Write(BootstrapProvenanceReader.LegacyMsiLoaderLogName, "[2026-08-01 09:00:00] MSI bootstrap runner started (user=SECRETUSER, pid=4242).");
var p = BootstrapProvenanceReader.Read(_dir);
Assert.Equal("msi", p.LoaderLog);
Assert.Null(p.LoaderVersion);
}
[Fact]
public void The_msi_loader_log_is_told_apart_from_the_intune_loader()
{
Write(BootstrapProvenanceReader.MsiLoaderLogName, LoaderRun("1.1"));
var p = BootstrapProvenanceReader.Read(_dir);
Assert.Equal("msi", p.LoaderLog);
Assert.Equal("1.1", p.LoaderVersion);
}
[Fact]
public void With_both_loader_logs_the_most_recently_written_one_counts()
{
Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.0"));
Write(BootstrapProvenanceReader.MsiLoaderLogName, LoaderRun("1.2"));
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.LoaderLogName), DateTime.UtcNow.AddDays(-3));
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.MsiLoaderLogName), DateTime.UtcNow.AddMinutes(-1));
var p = BootstrapProvenanceReader.Read(_dir);
Assert.Equal("msi", p.LoaderLog);
Assert.Equal("1.2", p.LoaderVersion);
}
[Fact]
public void The_oobe_go_script_is_recognised_by_its_log()
{
Write(BootstrapProvenanceReader.OobeLogName, "[2026-10-13 09:00:00] ===== Autopilot Monitor OOBE Bootstrap =====");
Assert.True(BootstrapProvenanceReader.Read(_dir).OobeLogPresent);
}
[Fact]
public void Only_the_tail_of_a_large_log_is_read()
{
var filler = Enumerable.Repeat(new string('.', 200), 600).ToArray(); // ~120 KB
var lines = new List<string> { InstallerRun("2.1") };
lines.AddRange(filler);
lines.Add(InstallerRun("2.5"));
Write(BootstrapProvenanceReader.InstallerLogName, lines.ToArray());
Assert.Equal("2.5", BootstrapProvenanceReader.Read(_dir).ScriptVersion);
}
[Fact]
public void A_missing_directory_reads_as_unknown_and_never_throws()
{
var p = BootstrapProvenanceReader.Read(Path.Combine(_dir, "does-not-exist"));
Assert.False(p.ScriptLogPresent);
Assert.Null(p.ScriptVersion);
Assert.Null(p.LoaderLog);
Assert.False(p.OobeLogPresent);
}
[Fact]
public void The_event_keys_carry_versions_only_never_the_account_name()
{
Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.2"));
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.5"));
var data = new Dictionary<string, object>();
BootstrapProvenanceReader.Read(_dir).AddTo(data);
Assert.Equal(true, data["bootstrapScriptLogPresent"]);
Assert.Equal("2.5", data["bootstrapScriptVersion"]);
Assert.Equal("loader", data["bootstrapLoaderLog"]);
Assert.Equal("1.2", data["bootstrapLoaderVersion"]);
Assert.Equal(false, data["oobeBootstrapLogPresent"]);
Assert.DoesNotContain(data.Values, v => v is string s && s.Contains("SECRETUSER"));
}
[Fact]
public void Keys_that_were_not_found_are_left_out()
{
var data = new Dictionary<string, object>();
BootstrapProvenanceReader.Read(_dir).AddTo(data);
Assert.Equal(new[] { "bootstrapScriptLogPresent", "oobeBootstrapLogPresent" }, data.Keys.OrderBy(k => k).ToArray());
}
}
}
@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
{
/// <summary>
/// Step 5b of the self-update: the staged payload must match its signed catalog before a
/// single file is swapped in. The verifier is replaced through the static hook; each test
/// restores it, as in <see cref="SelfUpdaterGracefulShutdownTests"/>.
/// </summary>
public sealed class SelfUpdaterPayloadCatalogTests : IDisposable
{
private const string StagingDir = @"C:\ProgramData\AutopilotMonitor\Agent-Update";
private readonly Func<string, PayloadCatalogVerdict> _savedVerifier;
private readonly List<string> _log = new List<string>();
public SelfUpdaterPayloadCatalogTests()
{
_savedVerifier = SelfUpdater.VerifyPayloadCatalog;
}
public void Dispose() => SelfUpdater.VerifyPayloadCatalog = _savedVerifier;
[Fact]
public void Production_default_is_the_real_verifier()
{
Assert.Equal(typeof(PayloadCatalogVerifier), _savedVerifier.Method.DeclaringType);
Assert.Equal(nameof(PayloadCatalogVerifier.Verify), _savedVerifier.Method.Name);
}
[Fact]
public void Accepted_payload_lets_the_update_continue()
{
string? checkedDir = null;
SelfUpdater.VerifyPayloadCatalog = dir => { checkedDir = dir; return PayloadCatalogVerdict.Accept(); };
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
Assert.True(ok);
Assert.Null(reason);
Assert.Equal(StagingDir, checkedDir);
Assert.Contains(_log, line => line.Contains("payload catalog verified"));
}
[Fact]
public void Rejected_payload_stops_the_update_and_hands_over_the_reason()
{
SelfUpdater.VerifyPayloadCatalog = _ =>
PayloadCatalogVerdict.Reject("payload differs from catalog (missing: none; extra: evil.dll; changed: none)");
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
Assert.False(ok);
Assert.Contains("extra: evil.dll", reason);
Assert.Contains(_log, line => line.Contains("REJECTED"));
}
[Fact]
public void A_throwing_verifier_is_a_rejection_not_a_pass()
{
SelfUpdater.VerifyPayloadCatalog = _ => throw new InvalidOperationException("runspace broke");
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
Assert.False(ok);
Assert.Contains("runspace broke", reason);
}
[Fact]
public void The_catalog_check_runs_before_any_file_is_swapped()
{
// Source-level pin: CheckAndApplyUpdateAsync has no seam for the download, so the
// ordering that makes the check meaningful is pinned on the source itself.
var source = File.ReadAllText(Path.Combine(FindRepoRoot(),
"src", "Agent", "AutopilotMonitor.Agent.V2.Core", "Monitoring", "Runtime", "SelfUpdater.cs"));
var check = source.IndexOf("if (!VerifyStagedPayload(stagingDir, log, out var catalogRejection))", StringComparison.Ordinal);
var swap = source.IndexOf("var swapOk = SwapFiles(agentDir, stagingDir, log);", StringComparison.Ordinal);
Assert.True(check > 0, "step 5b not found in SelfUpdater.cs");
Assert.True(swap > check, "SwapFiles must come after the catalog check");
Assert.Contains("WriteSkipMarker(\"catalog_rejected\"", source);
}
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
{
dir = dir.Parent;
}
Assert.NotNull(dir);
return dir.FullName;
}
}
}
@@ -164,6 +164,70 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Null(persistence.LoadRegistrationFailure());
}
[Fact]
public void Success_after_a_failed_start_tells_reboot_certificate_and_where_the_failure_began()
{
using var tmp = new TempDirectory();
var persistence = new SessionIdPersistence(tmp.Path);
var at = new DateTime(2026, 10, 21, 7, 0, 0, DateTimeKind.Utc);
var failedBoot = at.AddMinutes(-16);
BackendSessionRegistration.RecordFailedRun(
persistence, SessionRegistrationResult.Failed("An error occurred while sending the request."),
at, at.AddSeconds(90), true, true, "Succeeded", "fp-a", NewLogger(tmp.Path),
new AutopilotMonitor.Agent.V2.Core.Monitoring.Transport.DistressDeviceContext("completed", "account_setup", failedBoot),
certThumbprint: "6B98620E8BE952FEA7CD9200B899A9A8CCADD1E5");
// The record carries phase and OOBE flag; boot and thumbprint stay beside it.
var summary = persistence.LoadRegistrationFailure()!;
Assert.Equal("account_setup", summary.EspPhase);
Assert.Equal("completed", summary.OobeState);
var prior = BackendSessionRegistration.ClearAfterSuccess(
persistence, () => "fp-a", NewLogger(tmp.Path),
currentBootUtc: at.AddHours(1), currentCertThumbprint: "6b98620e8be952fea7cd9200b899a9a8ccadd1e5");
Assert.NotNull(prior);
Assert.Equal("account_setup", prior!.LastEspPhase);
Assert.Equal("completed", prior.LastOobeState);
Assert.True(prior.RebootedSince);
Assert.False(prior.CertChanged);
Assert.False(prior.NetworkChanged);
}
[Fact]
public void Same_boot_and_unknown_sides_are_reported_as_such()
{
var boot = new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc);
Assert.False(BackendSessionRegistration.RebootedSince(boot, boot.AddSeconds(30)));
Assert.True(BackendSessionRegistration.RebootedSince(boot, boot.AddMinutes(5)));
Assert.Null(BackendSessionRegistration.RebootedSince(null, boot));
Assert.Null(BackendSessionRegistration.RebootedSince(boot, null));
Assert.True(BackendSessionRegistration.CertificateChanged("AA", "BB"));
Assert.Null(BackendSessionRegistration.CertificateChanged(null, "BB"));
Assert.Null(BackendSessionRegistration.CertificateChanged("AA", ""));
}
[Fact]
public void Record_from_an_older_agent_leaves_the_new_comparisons_unknown()
{
using var tmp = new TempDirectory();
var persistence = new SessionIdPersistence(tmp.Path);
var at = new DateTime(2026, 10, 21, 7, 0, 0, DateTimeKind.Utc);
// Written without context, boot or thumbprint — the shape agents up to 2.0.1487 leave.
BackendSessionRegistration.RecordFailedRun(
persistence, SessionRegistrationResult.Failed("x"), at, at.AddSeconds(5), true, true, null, null, NewLogger(tmp.Path));
var prior = BackendSessionRegistration.ClearAfterSuccess(
persistence, () => null, NewLogger(tmp.Path), at.AddHours(1), "AA");
Assert.NotNull(prior);
Assert.Null(prior!.LastEspPhase);
Assert.Null(prior.RebootedSince);
Assert.Null(prior.CertChanged);
}
[Fact]
public void Recording_without_persistence_is_a_no_op()
{
@@ -44,6 +44,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Equal("Succeeded", data["remoteConfigOutcome"]);
}
[Fact]
public void EmitAgentStarted_CarriesTheBootstrapProvenance()
{
// Values depend on the machine's %ProgramData%\AutopilotMonitor\Logs (see
// BootstrapProvenanceReaderTests); the two presence flags are always on the wire.
using var rig = new EmitterRig();
rig.RemoteConfig.SetFakeOutcome(RemoteConfigFetchOutcome.Succeeded, configVersion: 28);
LifecycleEmitters.EmitAgentStarted(
rig.Post, rig.AgentConfig, rig.PreviousExit, "2.0.806", rig.RemoteConfig, rig.Logger);
var data = AssertSingleEmit(rig.Sink, expectedEventType: "agent_started");
Assert.IsType<bool>(data["bootstrapScriptLogPresent"]);
Assert.IsType<bool>(data["oobeBootstrapLogPresent"]);
}
[Fact]
public void EmitAgentStarted_OnFallbackToDefaults_FlagsRemoteConfigFalse()
{
@@ -0,0 +1,200 @@
using System;
using System.ComponentModel;
using System.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Runtime;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// The TPM-PSS report after a terminal SecureChannelFailure: it leaves before the run exits,
/// carries the handshake code, the Schannel events and the TPM firmware, and a slow or failing
/// context read never holds it back.
/// </summary>
public sealed class TpmPssReportTests
{
private static AgentLogger NewLogger(TempDirectory tmp)
=> new AgentLogger(Path.Combine(tmp.Path, "logs"), AgentLogLevel.Debug);
private static TpmPssProbeResult Probe(bool pssWorks = false, string provider = "Microsoft Platform Crypto Provider") =>
new TpmPssProbeResult(
pkcs1Sha256Works: true, pssSha256Works: pssWorks, pssSha384Works: pssWorks,
providerName: provider, keySizeBits: 2048,
pssSha256Error: pssWorks ? null : "salt", pssSha384Error: pssWorks ? null : "not supported",
pssSha256ErrorCode: pssWorks ? (int?)null : unchecked((int)0x80290423),
pssSha384ErrorCode: pssWorks ? (int?)null : unchecked((int)0x80090029));
private static Exception TlsFailure() =>
new WebException("Could not create SSL/TLS secure channel.",
new Win32Exception(unchecked((int)0x80090326)), WebExceptionStatus.SecureChannelFailure, null);
private static Func<Task<(string TpmVersion, string SchannelEvents, string TlsProbe)>> Context(
string tpm, string schannel, string tlsProbe = null!) =>
() => Task.FromResult((tpm, schannel, tlsProbe));
[Fact]
public async Task Broken_tpm_report_completes_only_after_the_send()
{
using var tmp = new TempDirectory();
var sendGate = new TaskCompletionSource<bool>();
var reporter = new FakeDistressReporter { Send = () => sendGate.Task };
var report = BackendSessionRegistration.ReportProbeResultAsync(
Probe(), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.63.3353.0", "36888:40/1205", "tls12,-,cc403"));
var call = Assert.Single(reporter.Calls);
Assert.False(report.IsCompleted);
Assert.Equal(DistressErrorType.TpmPssUnsupported, call.ErrorType);
Assert.Equal("IFX 7.63.3353.0", call.TpmVersion);
Assert.Equal(
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 tlsp=tls12,-,cc403 sch=36888:40/1205 prov=PCP key=2048",
call.Message);
sendGate.SetResult(true);
await report;
}
[Fact]
public async Task Failing_context_read_still_sends()
{
using var tmp = new TempDirectory();
var reporter = new FakeDistressReporter();
await BackendSessionRegistration.ReportProbeResultAsync(
Probe(), TlsFailure(), reporter, NewLogger(tmp),
() => throw new InvalidOperationException("WMI gone"));
var call = Assert.Single(reporter.Calls);
Assert.Null(call.TpmVersion);
Assert.Contains("sch=unreadable", call.Message);
}
[Fact]
public async Task Working_pss_sends_nothing()
{
using var tmp = new TempDirectory();
var reporter = new FakeDistressReporter();
await BackendSessionRegistration.ReportProbeResultAsync(
Probe(pssWorks: true), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.85", null!));
Assert.Empty(reporter.Calls);
}
[Fact]
public async Task Software_key_with_broken_pss_sends_nothing()
{
using var tmp = new TempDirectory();
var reporter = new FakeDistressReporter();
await BackendSessionRegistration.ReportProbeResultAsync(
Probe(provider: "Microsoft Software Key Storage Provider"), TlsFailure(), reporter, NewLogger(tmp), Context(null!, null!));
Assert.Empty(reporter.Calls);
}
[Fact]
public async Task Context_reads_return_what_finished_within_the_bound()
{
using var tmp = new TempDirectory();
using var slowRelease = new ManualResetEventSlim(false);
using var fastReturned = new ManualResetEventSlim(false);
try
{
// The bound ends only after the fast read returned (plus a grace for its task to
// complete) — independent of how busy the thread pool is.
var bound = Task.Run(async () =>
{
fastReturned.Wait(TimeSpan.FromSeconds(30));
await Task.Delay(500);
});
var (tpm, schannel, tlsProbe) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => { fastReturned.Set(); return "IFX 7.63.3353.0"; },
() => { slowRelease.Wait(TimeSpan.FromSeconds(30)); return "late"; },
() => { slowRelease.Wait(TimeSpan.FromSeconds(30)); return "tls12,-,cc403"; },
bound,
NewLogger(tmp));
Assert.Equal("IFX 7.63.3353.0", tpm);
Assert.Equal(BackendSessionRegistration.SchannelReadTimeout, schannel);
Assert.Equal(BackendSessionRegistration.SchannelReadTimeout, tlsProbe);
}
finally
{
slowRelease.Set();
}
}
[Fact]
public async Task Context_read_that_throws_counts_as_missing()
{
using var tmp = new TempDirectory();
var (tpm, schannel, tlsProbe) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => throw new InvalidOperationException("no WMI"),
() => "36887:116",
() => "tls13,-,cc403",
Task.Delay(TimeSpan.FromSeconds(30)),
NewLogger(tmp));
Assert.Null(tpm);
Assert.Equal("36887:116", schannel);
Assert.Equal("tls13,-,cc403", tlsProbe);
}
[Fact]
public async Task Schannel_read_that_throws_is_reported_as_unreadable_not_as_empty()
{
using var tmp = new TempDirectory();
var (_, schannel, _) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => "IFX 7.63.3353.0",
() => throw new UnauthorizedAccessException("System log"),
() => "tls12,-,cc403",
Task.Delay(TimeSpan.FromSeconds(30)),
NewLogger(tmp));
Assert.Equal(SchannelEventReader.Unreadable, schannel);
}
[Fact]
public async Task Tls_probe_that_throws_is_reported_as_error_not_left_out()
{
using var tmp = new TempDirectory();
var (_, _, tlsProbe) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => "IFX 7.63.3353.0",
() => "none",
() => throw new InvalidOperationException("socket"),
Task.Delay(TimeSpan.FromSeconds(30)),
NewLogger(tmp));
Assert.Equal("error", tlsProbe);
}
[Fact]
public async Task Report_carries_the_context_the_run_started_in()
{
using var tmp = new TempDirectory();
var reporter = new FakeDistressReporter();
var startContext = new DistressDeviceContext("in_progress", "device_setup",
new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc));
await BackendSessionRegistration.ReportProbeResultAsync(
Probe(), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.63.3353.0", "none"), startContext);
var call = Assert.Single(reporter.Calls);
Assert.Same(startContext, call.Context);
Assert.Contains("sch=none", call.Message);
}
}
}
@@ -0,0 +1,113 @@
using System;
using System.IO;
using System.Linq;
using System.Security.AccessControl;
using System.Security.Principal;
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// The agent data directory keeps standard users from adding files where the SYSTEM agent
/// starts from. Works on a temp folder the test owns; as owner it can always hand itself the
/// rights back to clean up.
/// </summary>
public sealed class AgentDirectoryAclTests : IDisposable
{
private const FileSystemRights WriteRights =
FileSystemRights.WriteData | FileSystemRights.AppendData | FileSystemRights.WriteAttributes
| FileSystemRights.WriteExtendedAttributes | FileSystemRights.Delete | FileSystemRights.DeleteSubdirectoriesAndFiles
| FileSystemRights.ChangePermissions | FileSystemRights.TakeOwnership;
private static readonly SecurityIdentifier Users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
private readonly string _root = Path.Combine(Path.GetTempPath(), "apm-acl-" + Guid.NewGuid().ToString("N"));
public AgentDirectoryAclTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
try
{
var me = WindowsIdentity.GetCurrent().User!;
var directory = new DirectoryInfo(_root);
var security = directory.GetAccessControl();
security.AddAccessRule(new FileSystemAccessRule(me, FileSystemRights.FullControl,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
directory.SetAccessControl(security);
Directory.Delete(_root, recursive: true);
}
catch
{
// best effort: a leftover temp folder must not fail the run
}
}
private static FileSystemRights UsersRights(string path) =>
new DirectoryInfo(path).GetAccessControl()
.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier))
.Cast<FileSystemAccessRule>()
.Where(r => r.AccessControlType == AccessControlType.Allow && Users.Equals(r.IdentityReference))
.Aggregate((FileSystemRights)0, (all, r) => all | r.FileSystemRights);
[Fact]
public void The_protected_security_inherits_nothing_and_gives_users_read_only()
{
var security = AgentDirectoryAcl.BuildProtectedSecurity();
Assert.True(security.AreAccessRulesProtected);
var users = security.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast<FileSystemAccessRule>()
.Single(r => Users.Equals(r.IdentityReference));
// An allow rule always carries Synchronize; .NET adds it on construction.
Assert.Equal(FileSystemRights.ReadAndExecute, users.FileSystemRights & ~FileSystemRights.Synchronize);
Assert.Equal((FileSystemRights)0, users.FileSystemRights & WriteRights);
}
[Fact]
public void A_folder_is_protected_once_and_then_left_alone()
{
var first = AgentDirectoryAcl.EnsureProtected(_root);
var second = AgentDirectoryAcl.EnsureProtected(_root);
Assert.Equal(AgentDirectoryAclOutcome.Applied, first.Outcome);
Assert.Equal(AgentDirectoryAclOutcome.AlreadyProtected, second.Outcome);
Assert.True(AgentDirectoryAcl.IsProtectedAsExpected(new DirectoryInfo(_root).GetAccessControl(AccessControlSections.Access)));
Assert.Equal((FileSystemRights)0, UsersRights(_root) & WriteRights);
}
[Fact]
public void Existing_subfolders_take_over_the_protection()
{
// Install mode protects the base after Agent, Spool and Logs already exist.
var agent = Directory.CreateDirectory(Path.Combine(_root, "Agent")).FullName;
AgentDirectoryAcl.EnsureProtected(_root);
Assert.Equal((FileSystemRights)0, UsersRights(agent) & WriteRights);
Assert.NotEqual((FileSystemRights)0, UsersRights(agent) & FileSystemRights.ReadAndExecute);
}
[Fact]
public void An_unprotected_or_widened_dacl_is_not_taken_as_protected()
{
var unprotected = AgentDirectoryAcl.BuildProtectedSecurity();
unprotected.SetAccessRuleProtection(isProtected: false, preserveInheritance: true);
Assert.False(AgentDirectoryAcl.IsProtectedAsExpected(unprotected));
var widened = AgentDirectoryAcl.BuildProtectedSecurity();
widened.AddAccessRule(new FileSystemAccessRule(Users, FileSystemRights.Modify,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
Assert.False(AgentDirectoryAcl.IsProtectedAsExpected(widened));
}
[Fact]
public void A_missing_folder_is_reported_not_created()
{
var missing = Path.Combine(_root, "does-not-exist");
Assert.Equal(AgentDirectoryAclOutcome.Missing, AgentDirectoryAcl.EnsureProtected(missing).Outcome);
Assert.False(Directory.Exists(missing));
}
}
}
@@ -0,0 +1,142 @@
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Shared;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// The decision over what <c>Test-FileCatalog -Detailed</c> reports. The runspace itself is
/// exercised on a lab device (plan agent-payload-catalog, V1/V3), never in the test host.
/// </summary>
public sealed class PayloadCatalogVerifierTests
{
private const string OurSubject = "CN=glueckkanja AG, O=glueckkanja AG, L=Offenbach, S=Hessen, C=DE";
private static Dictionary<string, string> Files(params string[] namesAndHashes)
{
var map = new Dictionary<string, string>();
for (var i = 0; i < namesAndHashes.Length; i += 2) map[namesAndHashes[i]] = namesAndHashes[i + 1];
return map;
}
private static PayloadCatalogFacts Facts(
string status = "Valid", string? signatureStatus = "Valid", string? subject = OurSubject,
Dictionary<string, string>? catalog = null, Dictionary<string, string>? path = null)
=> new PayloadCatalogFacts(status, signatureStatus, subject,
catalog ?? Files("a.dll", "AA"), path ?? Files("a.dll", "AA"));
private static PayloadCatalogVerdict Evaluate(PayloadCatalogFacts facts)
=> PayloadCatalogVerifier.Evaluate(facts, Constants.CodeSigningPublisherPattern);
[Fact]
public void Signed_by_us_and_matching_is_accepted()
{
var verdict = Evaluate(Facts());
Assert.True(verdict.Accepted);
Assert.Null(verdict.Reason);
}
[Fact]
public void Matching_hashes_under_an_unsigned_catalog_are_rejected()
{
// Status covers the hashes only: an unsigned catalog over matching files reports Valid.
var verdict = Evaluate(Facts(signatureStatus: "NotSigned", subject: null));
Assert.False(verdict.Accepted);
Assert.Contains("catalog signature status NotSigned", verdict.Reason);
}
[Fact]
public void A_validly_signed_catalog_from_another_publisher_is_rejected()
{
var verdict = Evaluate(Facts(subject: "CN=Contoso Ltd, O=Contoso Ltd, C=US"));
Assert.False(verdict.Accepted);
Assert.Contains("does not match", verdict.Reason);
}
[Fact]
public void Missing_signature_details_are_a_rejection_not_a_pass()
{
Assert.False(Evaluate(Facts(signatureStatus: null, subject: null)).Accepted);
Assert.False(Evaluate(Facts(subject: "")).Accepted);
}
[Fact]
public void A_differing_payload_names_missing_extra_and_changed_files()
{
var verdict = Evaluate(Facts(
status: "ValidationFailed",
catalog: Files("a.dll", "AA", "b.dll", "BB", "c.config", "CC"),
path: Files("a.dll", "AA", "b.dll", "XX", "evil.dll", "EE")));
Assert.False(verdict.Accepted);
Assert.Contains("missing: c.config", verdict.Reason);
Assert.Contains("extra: evil.dll", verdict.Reason);
Assert.Contains("changed: b.dll", verdict.Reason);
}
[Fact]
public void Long_difference_lists_are_capped()
{
var path = Files("a.dll", "AA");
for (var i = 0; i < 12; i++) path[$"extra{i:00}.txt"] = "00";
var verdict = Evaluate(Facts(status: "ValidationFailed", path: path));
Assert.Contains("(+7 more)", verdict.Reason);
}
[Fact]
public void Signature_and_payload_problems_are_both_reported()
{
var verdict = Evaluate(Facts(status: "ValidationFailed", signatureStatus: "HashMismatch", subject: null,
path: Files("a.dll", "AA", "x.dll", "11")));
Assert.Contains("catalog signature status HashMismatch", verdict.Reason);
Assert.Contains("extra: x.dll", verdict.Reason);
}
[Theory]
[InlineData(OurSubject, true)]
[InlineData("cn=glueckkanja ag, o=glueckkanja ag, c=de", true)] // -like is case-insensitive
[InlineData("CN=x, O=glueckkanja AG Holding, C=DE", true)] // the pattern is open-ended by design
[InlineData("CN=glueckkanja AG, O=Contoso, C=DE", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void Publisher_pattern_behaves_like_powershell_like(string? subject, bool expected)
{
Assert.Equal(expected, PayloadCatalogVerifier.MatchesPublisherPattern(subject, Constants.CodeSigningPublisherPattern));
}
[Fact]
public void Regex_metacharacters_in_the_pattern_are_literal()
{
Assert.True(PayloadCatalogVerifier.MatchesPublisherPattern("O=Json.NET (.NET Foundation)", "*O=Json.NET (.NET Foundation)*"));
Assert.False(PayloadCatalogVerifier.MatchesPublisherPattern("O=JsonXNET (.NET Foundation)", "*O=Json.NET (.NET Foundation)*"));
}
[Fact]
public void A_payload_without_catalog_is_rejected_before_any_runspace_starts()
{
var dir = Path.Combine(Path.GetTempPath(), "apm-catalog-test-" + System.Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "readme.txt"), "x");
var verdict = PayloadCatalogVerifier.Verify(dir);
Assert.False(verdict.Accepted);
Assert.Contains("missing", verdict.Reason);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
}
@@ -0,0 +1,74 @@
using System;
using System.Diagnostics.Eventing.Reader;
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
public sealed class SchannelEventReaderTests
{
[Fact]
public void Token_is_event_id_with_numeric_properties()
{
Assert.Equal("36888:40/1205", SchannelEventReader.FormatToken(36888, new[] { "40", "1205" }));
}
[Fact]
public void Token_drops_text_properties()
{
// Newer builds add target name and certificate subjects; they stay on the device.
Assert.Equal("36887:116", SchannelEventReader.FormatToken(
36887, new[] { "116", "autopilotmonitor-api.example.test", "CN=device", "" }));
}
[Fact]
public void Token_without_numbers_is_the_event_id()
{
Assert.Equal("36871", SchannelEventReader.FormatToken(36871, new[] { "TLS 1.2" }));
Assert.Equal("36871", SchannelEventReader.FormatToken(36871, null!));
}
[Fact]
public void Token_keeps_at_most_three_numbers()
{
Assert.Equal("36888:1/2/3", SchannelEventReader.FormatToken(36888, new[] { "1", "2", "3", "4" }));
}
[Fact]
public void Token_rejects_non_ascii_digits()
{
Assert.Equal("36888", SchannelEventReader.FormatToken(36888, new[] { "٤٠" }));
}
[Fact]
public void Query_is_accepted_by_the_event_log_service()
{
// A malformed XPath would only surface as "unreadable" through ReadBetween (it never
// throws), so the query itself is run here.
using (var reader = new EventLogReader(new EventLogQuery(
"System", PathType.LogName, SchannelEventReader.BuildXPath(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow)) { ReverseDirection = true }))
{
using (reader.ReadEvent()) { }
}
}
[Fact]
public void No_events_after_now()
{
Assert.Equal(SchannelEventReader.NoEvents,
SchannelEventReader.ReadBetween(DateTime.UtcNow.AddHours(1), DateTime.UtcNow.AddHours(2)));
}
[Fact]
public void Window_ends_where_the_diagnostics_begin()
{
var xpath = SchannelEventReader.BuildXPath(
new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc),
new DateTime(2026, 10, 21, 6, 55, 30, 250, DateTimeKind.Utc));
Assert.Contains(
"TimeCreated[@SystemTime >= '2026-10-21T06:40:00.000Z' and @SystemTime <= '2026-10-21T06:55:30.250Z']",
xpath);
}
}
}
@@ -332,6 +332,74 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Contains("transient attempt 6", result.ErrorMessage);
}
[Fact]
public async Task Terminal_callback_gets_the_last_attempts_exception()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
Exception? seen = null;
var apiClient = new FakeApiClient(attempt =>
throw new InvalidOperationException($"transient attempt {attempt}"));
await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
backoffDelay: _ => Task.CompletedTask,
onTerminalTransportFailure: ex => { seen = ex; return Task.CompletedTask; });
Assert.Equal("transient attempt 6", seen?.Message);
}
[Fact]
public async Task Terminal_callback_skipped_when_the_last_attempt_answered()
{
// Five transport failures, then the backend answers without success: the run did not
// end in a transport failure, so nothing may classify the old exception.
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var callbackRan = false;
var apiClient = new FakeApiClient(attempt =>
{
if (attempt < 6) throw new InvalidOperationException($"transient attempt {attempt}");
return new RegisterSessionResponse { Success = false, Message = "backend 500" };
});
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
backoffDelay: _ => Task.CompletedTask,
onTerminalTransportFailure: _ => { callbackRan = true; return Task.CompletedTask; });
Assert.Equal(SessionRegistrationOutcome.Failed, result.Outcome);
Assert.Equal("backend 500", result.ErrorMessage);
Assert.False(callbackRan);
}
[Fact]
public async Task Failure_message_carries_the_native_code_of_the_tls_failure()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(_ =>
throw new System.Net.Http.HttpRequestException(
"An error occurred while sending the request.",
new System.Net.WebException(
"Could not create SSL/TLS secure channel.",
new System.ComponentModel.Win32Exception(unchecked((int)0x80090326)),
System.Net.WebExceptionStatus.SecureChannelFailure,
null)));
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
backoffDelay: _ => Task.CompletedTask);
Assert.Equal("An error occurred while sending the request. (win32 0x80090326)", result.ErrorMessage);
}
[Fact]
public async Task Waits_for_network_link_before_first_attempt()
{
@@ -0,0 +1,151 @@
using System.Security.Authentication;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
using AutopilotMonitor.Agent.V2.Core.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// The TLS probe's request and the reading of its answer: only an echo of the probe's own
/// correlation id counts as the backend, the platform's "Client Certificate Required" page says
/// the certificate did not arrive, and a device value can never split the request.
/// </summary>
public sealed class TlsHandshakeProbeTests
{
private const string SentId = "0123456789abcdef0123456789abcdef";
private static TlsProbeTarget Target(string serial = "SN-1") => new TlsProbeTarget
{
BaseUrl = "https://api.example.test",
TenantId = "00000000-0000-0000-0000-000000000001",
Manufacturer = "Contoso",
Model = "Book 1",
SerialNumber = serial,
AgentVersion = "2.0.1500",
};
private static byte[] Raw(string statusLine, string headers, string body) =>
Encoding.UTF8.GetBytes(statusLine + "\r\n" + headers + "\r\n" + body);
[Fact]
public void Request_asks_for_the_agent_config_with_the_agent_headers()
{
var request = TlsHandshakeProbe.BuildRequest(Target(), "api.example.test", SentId);
Assert.StartsWith(
"GET /api/agent/config?tenantId=00000000-0000-0000-0000-000000000001 HTTP/1.1\r\nHost: api.example.test\r\n",
request);
Assert.Contains("\r\nX-Correlation-ID: " + SentId + "\r\n", request);
Assert.Contains("\r\nX-Device-Manufacturer: Contoso\r\n", request);
Assert.Contains("\r\nX-Device-Model: Book 1\r\n", request);
Assert.Contains("\r\nX-Device-SerialNumber: SN-1\r\n", request);
Assert.Contains("\r\nX-Agent-Version: 2.0.1500\r\n", request);
Assert.EndsWith("\r\nConnection: close\r\n\r\n", request);
}
[Fact]
public void Device_value_with_a_line_break_cannot_add_a_header()
{
var request = TlsHandshakeProbe.BuildRequest(Target("SN\r\nX-Injected: 1"), "api.example.test", SentId);
Assert.DoesNotContain("\r\nX-Injected", request);
Assert.Contains("\r\nX-Device-SerialNumber: SN??X-Injected: 1\r\n", request);
}
[Fact]
public void Echoed_correlation_id_means_the_backend_answered()
{
var raw = Raw("HTTP/1.1 401 Unauthorized", "X-Correlation-ID: " + SentId + "\r\nContent-Type: application/json\r\n", "{}");
Assert.Equal("b401", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Backend_success_is_read_as_backend()
{
var raw = Raw("HTTP/1.1 200 OK", "x-correlation-id: " + SentId + "\r\n", "{\"ok\":true}");
Assert.Equal("b200", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Platform_certificate_required_page_means_the_certificate_did_not_arrive()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "Content-Type: text/html\r\n", InterceptionFixtures.CertificateRequiredPage);
Assert.Equal("cc403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Certificate_required_page_with_an_echo_is_still_the_backend()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "X-Correlation-ID: " + SentId + "\r\n", InterceptionFixtures.CertificateRequiredPage);
Assert.Equal("b403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Echo_of_another_id_is_someone_else()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "X-Correlation-ID: ffffffffffffffffffffffffffffffff\r\n", "<html>Access denied</html>");
Assert.Equal("x403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Stopped_app_page_is_reported_as_stopped()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "Content-Type: text/html\r\n", InterceptionFixtures.StoppedAppPage);
Assert.Equal("st403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Theory]
[InlineData("")]
[InlineData("garbage")]
[InlineData("HTTP/1.1 abc\r\n\r\n")]
public void No_parsable_answer_is_reported_as_no_response(string text)
{
Assert.Equal("io(noresponse)", TlsHandshakeProbe.ClassifyAnswer(Encoding.ASCII.GetBytes(text), SentId));
}
[Fact]
public void Microsoft_issued_server_certificate_adds_nothing()
{
using var cert = ServerCertificateObserverTests.SelfSigned(
"CN=Microsoft Azure RSA TLS Issuing CA 03, O=Microsoft Corporation, C=US");
Assert.Null(TlsHandshakeProbe.ForeignIssuer(cert));
Assert.Null(TlsHandshakeProbe.ForeignIssuer(null!));
}
[Fact]
public void Inspection_issuer_is_named_in_a_compact_token()
{
using var cert = ServerCertificateObserverTests.SelfSigned("CN=Contoso Inspection CA (2026), O=Contoso Ltd");
Assert.Equal("Contoso_Inspection_CA__2026_", TlsHandshakeProbe.ForeignIssuer(cert));
}
[Fact]
public void Long_issuer_is_capped()
{
using var cert = ServerCertificateObserverTests.SelfSigned("CN=" + new string('A', 80) + ", O=Fabrikam");
Assert.Equal(new string('A', TlsHandshakeProbe.MaxIssuerLength), TlsHandshakeProbe.ForeignIssuer(cert));
}
[Theory]
[InlineData(SslProtocols.Tls12, "tls12")]
[InlineData((SslProtocols)12288, "tls13")]
[InlineData(SslProtocols.Tls11, "tls11")]
[InlineData(SslProtocols.Tls, "tls10")]
[InlineData(SslProtocols.None, "p0")]
public void Protocol_token_names_the_negotiated_version(SslProtocols protocol, string expected)
{
Assert.Equal(expected, TlsHandshakeProbe.ProtocolToken(protocol));
}
}
}
@@ -0,0 +1,94 @@
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// The TPM-PSS distress message: codes instead of localized error texts, so reports from
/// German, Czech and English devices read the same and the 256-char budget holds the
/// handshake code and the Schannel events.
/// </summary>
public sealed class TpmPssProbeResultTests
{
private const int SaltSizeMismatch = unchecked((int)0x80290423);
private const int NotSupported = unchecked((int)0x80090029);
private static TpmPssProbeResult BrokenPcp(string provider = "Microsoft Platform Crypto Provider") =>
new TpmPssProbeResult(
pkcs1Sha256Works: true,
pssSha256Works: false,
pssSha384Works: false,
providerName: provider,
keySizeBits: 2048,
pssSha256Error: "Die angeforderte Salt-Größe für die Signierung mit RSAPSS entspricht nicht dem, was TPM verwendet.",
pssSha384Error: "Der angeforderte Vorgang wird nicht unterstützt.",
pssSha256ErrorCode: SaltSizeMismatch,
pssSha384ErrorCode: NotSupported);
[Fact]
public void Message_carries_codes_handshake_and_schannel_events()
{
var message = BrokenPcp().ToDistressMessage("0x80090326", "36888:40/1205,36887:116", "tls12,-,cc403");
Assert.Equal(
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 tlsp=tls12,-,cc403 sch=36888:40/1205,36887:116 prov=PCP key=2048",
message);
}
[Fact]
public void Message_never_carries_the_localized_error_text()
{
var message = BrokenPcp().ToDistressMessage();
Assert.DoesNotContain("Salt", message);
Assert.Equal("pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) prov=PCP key=2048", message);
}
[Fact]
public void Working_signatures_and_failures_without_a_code()
{
var result = new TpmPssProbeResult(
pkcs1Sha256Works: true, pssSha256Works: true, pssSha384Works: false,
providerName: "Microsoft Software Key Storage Provider", keySizeBits: 0,
pssSha256Error: null, pssSha384Error: "empty signature");
Assert.Equal("pkcs1=ok pss256=ok pss384=fail prov=KSP", result.ToDistressMessage());
}
[Fact]
public void Message_stays_within_the_distress_cap()
{
var longEvents = string.Join(",", System.Linq.Enumerable.Repeat("36888:4294967295/4294967295/4294967295", 10));
var message = BrokenPcp(new string('P', 200)).ToDistressMessage("0x80090326", longEvents);
Assert.Equal(TpmPssProbeResult.MaxDistressMessageLength, message.Length);
}
[Theory]
[InlineData("Microsoft Platform Crypto Provider", "PCP")]
[InlineData("microsoft platform crypto provider", "PCP")]
[InlineData("Microsoft Software Key Storage Provider", "KSP")]
[InlineData("Contoso Smart Card Provider", "Contoso Smart Card Provider")]
[InlineData("", "(unknown)")]
public void Provider_names_are_shortened(string provider, string expected)
{
Assert.Equal(expected, TpmPssProbeResult.ShortProviderName(provider));
}
[Fact]
public void Long_provider_name_is_cut_to_32_chars()
{
Assert.Equal(32, TpmPssProbeResult.ShortProviderName(new string('x', 40)).Length);
}
[Fact]
public void Tpm_backed_broken_key_is_the_reportable_footprint()
{
var result = BrokenPcp();
Assert.True(result.IsTpmPssBroken);
Assert.True(result.IsTpmBacked);
}
}
}
@@ -0,0 +1,20 @@
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.DeviceInfo
{
public sealed class TpmInfoLabelTests
{
[Theory]
[InlineData("IFX", "7.63.3353.0", "IFX 7.63.3353.0")]
[InlineData("STM ", "73.4.17568.4452", "STM 73.4.17568.4452")] // ManufacturerIdTxt is space-padded
[InlineData("INTC", null, "INTC")]
[InlineData(null, "403.1.0.0", "403.1.0.0")]
[InlineData(" ", " ", null)]
[InlineData(null, null, null)]
public void Label_is_trimmed_manufacturer_and_firmware(string? name, string? version, string? expected)
{
Assert.Equal(expected, TpmInfo.FormatLabel(name!, version!));
}
}
}
@@ -0,0 +1,124 @@
using System;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
/// <summary>
/// What a distress report carries and that it fits the backend's body cap: the endpoint drops
/// a larger body with 200, so an oversized report would be lost without a trace.
/// </summary>
public sealed class DistressReporterPayloadTests
{
private static DistressReporter NewReporter(string field = "x", string agentVersion = "2.0.1500")
=> new DistressReporter(
baseUrl: "https://example.test",
tenantId: "11111111-2222-3333-4444-555555555555",
manufacturer: field,
model: field,
serialNumber: field,
agentVersion: agentVersion,
logger: null!);
[Fact]
public void Without_a_context_the_report_samples_the_device_when_sent()
{
using (var reporter = NewReporter())
{
var report = reporter.BuildReport(DistressErrorType.AuthCertificateRejected, "m", 401, detail: null);
var osBuild = DeviceInfoProvider.GetOsBuild();
Assert.Equal(string.IsNullOrEmpty(osBuild) ? null : osBuild, report.OsBuild);
Assert.Equal(OobeStateReader.Read(), report.OobeState);
Assert.Equal(EspPhaseSnapshot.Read(), report.EspPhase);
Assert.NotNull(report.DeviceBootUtc);
Assert.True(report.DeviceBootUtc <= DateTime.UtcNow);
Assert.Null(report.TpmVersion);
}
}
[Fact]
public void A_given_context_wins_over_the_send_time_sample()
{
// A failed registration samples the device before its first attempt; the report must
// carry that, not where the device stands minutes later.
var boot = new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc);
using (var reporter = NewReporter())
{
var report = reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new DistressDetail
{
Context = new DistressDeviceContext("in_progress", "device_setup", boot),
});
Assert.Equal("in_progress", report.OobeState);
Assert.Equal("device_setup", report.EspPhase);
Assert.Equal(boot, report.DeviceBootUtc);
}
}
[Fact]
public void Tpm_version_passes_through_capped_at_48()
{
using (var reporter = NewReporter())
{
Assert.Equal("IFX 7.63.3353.0",
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new DistressDetail { TpmVersion = "IFX 7.63.3353.0" }).TpmVersion);
Assert.Equal(48,
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new DistressDetail { TpmVersion = new string('v', 60) }).TpmVersion!.Length);
}
}
[Fact]
public void Largest_ascii_report_fits_the_cap_untouched()
{
// Every field at the agent's own caps; the agent version as built (not cut to 32).
using (var reporter = NewReporter(new string('A', 64), "2.0.1486+d95640c597cb90849803dd65afda19e2d7a39444"))
{
var report = reporter.BuildReport(DistressErrorType.ClientCertificateNotReceived, new string('m', 256), 403,
new DistressDetail { TpmVersion = new string('t', 48) });
report.CertThumbprint = new string('F', 40);
report.CertSubject = new string('S', 96);
report.CertIssuer = new string('I', 96);
report.CertNotBefore = DateTime.UtcNow;
report.CertNotAfter = DateTime.UtcNow;
report.OsBuild = "26300.97111";
report.OobeState = "unknown_2147483647".Substring(0, 16);
report.EspPhase = "device_preparation_failed";
report.DeviceBootUtc = DateTime.UtcNow;
var json = DistressReporter.SerializeWithinCap(report);
Assert.Equal(256, report.Message!.Length);
Assert.True(Encoding.UTF8.GetByteCount(json) <= Constants.DistressReportMaxBytes,
$"{Encoding.UTF8.GetByteCount(json)} bytes");
}
}
[Fact]
public void Oversized_report_gives_up_message_characters_to_fit()
{
// Three-byte characters everywhere — far past the cap before trimming.
using (var reporter = NewReporter(new string('€', 64)))
{
var report = reporter.BuildReport(DistressErrorType.ClientCertificateNotReceived, new string('€', 256), 403, null);
report.CertSubject = new string('€', 96);
report.CertIssuer = new string('€', 96);
Assert.True(Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(report)) > Constants.DistressReportMaxBytes);
var json = DistressReporter.SerializeWithinCap(report);
Assert.True(Encoding.UTF8.GetByteCount(json) <= Constants.DistressReportMaxBytes,
$"{Encoding.UTF8.GetByteCount(json)} bytes");
Assert.True(report.Message!.Length < 256);
Assert.Equal(report.Message, JsonConvert.DeserializeObject<DistressReport>(json)!.Message);
}
}
}
}
@@ -0,0 +1,73 @@
using System;
using System.ComponentModel;
using System.Net;
using System.Net.Http;
using System.Security.Authentication;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
public sealed class TransportErrorCodesTests
{
private const int IllegalMessage = unchecked((int)0x80090326);
// The .NET Framework shape of a failed TLS handshake through HttpClient.
private static Exception TlsFailureChain(params Exception[] innermost) =>
new HttpRequestException(
"An error occurred while sending the request.",
new WebException(
"The request was aborted: Could not create SSL/TLS secure channel.",
new AuthenticationException("A call to SSPI failed, see inner exception.", innermost.Length > 0 ? innermost[0] : null),
WebExceptionStatus.SecureChannelFailure,
null));
[Fact]
public void Hex_is_eight_digits_with_prefix()
{
Assert.Equal("0x80090326", TransportErrorCodes.Hex(IllegalMessage));
Assert.Equal("0x00002746", TransportErrorCodes.Hex(10054));
}
[Fact]
public void Innermost_win32_code_of_a_tls_failure()
{
var chain = TlsFailureChain(new Win32Exception(IllegalMessage));
Assert.Equal("0x80090326", TransportErrorCodes.InnermostNativeCode(chain));
}
[Fact]
public void Innermost_wins_over_an_outer_win32_exception()
{
// The outer code is whatever the last Win32 error was; only the innermost counts.
var chain = new Win32Exception("outer", new Win32Exception(IllegalMessage));
Assert.Equal("0x80090326", TransportErrorCodes.InnermostNativeCode(chain));
}
[Fact]
public void No_win32_exception_no_code()
{
Assert.Null(TransportErrorCodes.InnermostNativeCode(TlsFailureChain()));
Assert.Null(TransportErrorCodes.InnermostNativeCode(null!));
}
[Fact]
public void Message_gets_the_code_appended()
{
var chain = TlsFailureChain(new Win32Exception(IllegalMessage));
Assert.Equal(
"An error occurred while sending the request. (win32 0x80090326)",
TransportErrorCodes.MessageWithNativeCode(chain));
}
[Fact]
public void Message_without_a_code_stays_as_is()
{
Assert.Equal("boom", TransportErrorCodes.MessageWithNativeCode(new InvalidOperationException("boom")));
Assert.Null(TransportErrorCodes.MessageWithNativeCode(null!));
}
}
}
@@ -62,8 +62,15 @@
<ItemGroup>
<Reference Include="System.Net.Http" />
<Reference Include="System.Management" />
<!--
Compile against the GAC copy, never ship it (Private=false). The runtime binds this
strong-named assembly from the device's GAC before it probes the app folder, and every
Windows 10/11 has it (Windows PowerShell 5.1 runs on it), so a shipped copy was never
loaded. It was the build machine's file: unsigned on any other machine, and half the ZIP.
-->
<Reference Include="System.Management.Automation">
<HintPath>$(windir)\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="System.ServiceProcess" />
<Reference Include="System.IO.Compression" />
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// One-shot read of the Autopilot ESP phase from the values <see cref="ProvisioningStatusTracker"/>
/// watches (<c>HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings</c>), for reports taken
/// without the tracker — a failed registration has no session and no tracker yet.
/// <para>
/// <b>Contract:</b> observational only, never decision-engine input. Unlike the tracker it has
/// no settle or fallback timers: a category whose <c>categorySucceeded</c> Windows never sets
/// counts as done once all its subcategories read succeeded or notRequired.
/// </para>
/// </summary>
internal static class EspPhaseSnapshot
{
internal const string None = "none";
internal const string Unavailable = "unavailable";
internal enum CategoryState { Absent, Running, Done, Failed }
// ESP order: device preparation, device setup, account setup.
private static readonly (string ValueName, string Phase)[] Categories =
{
("DevicePreparationCategory.Status", "device_preparation"),
("DeviceSetupCategory.Status", "device_setup"),
("AccountSetupCategory.Status", "account_setup"),
};
/// <summary>
/// <c>device_preparation</c> / <c>device_setup</c> / <c>account_setup</c> while that
/// category runs, <c>&lt;category&gt;_failed</c>, <c>device_preparation_done</c> /
/// <c>device_setup_done</c> when the next category has not appeared (yet, or skipped),
/// <c>esp_done</c>, <c>none</c> without any ESP value, <c>unavailable</c> when the
/// registry cannot be read. Never throws.
/// </summary>
public static string Read()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusTracker.ProvisioningStatusRegistryPath, writable: false))
{
if (key == null) return None;
return Derive(Categories.Select(c => key.GetValue(c.ValueName) as string).ToList());
}
}
catch
{
return Unavailable;
}
}
/// <param name="statusJson">The three category values in ESP order; null where absent.</param>
internal static string Derive(IReadOnlyList<string> statusJson)
{
var states = Categories.Select((_, i) => Classify(i < statusJson.Count ? statusJson[i] : null)).ToList();
for (var i = 0; i < Categories.Length; i++)
if (states[i] == CategoryState.Failed) return Categories[i].Phase + "_failed";
for (var i = 0; i < Categories.Length; i++)
if (states[i] == CategoryState.Running) return Categories[i].Phase;
var lastDone = states.FindLastIndex(s => s == CategoryState.Done);
if (lastDone < 0) return None;
return lastDone == Categories.Length - 1 ? "esp_done" : Categories[lastDone].Phase + "_done";
}
/// <summary>
/// One category value: done (<c>categorySucceeded=true</c>, or every subcategory succeeded
/// or notRequired), failed (<c>false</c>, or a failed subcategory), running (anything
/// else that is present, also unparseable JSON), absent (no value).
/// </summary>
internal static CategoryState Classify(string json)
{
if (string.IsNullOrWhiteSpace(json)) return CategoryState.Absent;
try
{
using (var doc = JsonDocument.Parse(json))
{
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return CategoryState.Running;
var succeeded = ProvisioningStatusTracker.SafeGetBool(root, "categorySucceeded");
if (succeeded == true) return CategoryState.Done;
if (succeeded == false) return CategoryState.Failed;
var subcategories = ProvisioningStatusTracker.ParseSubcategories(root);
if (subcategories.Any(s => string.Equals(s.State, "failed", StringComparison.OrdinalIgnoreCase)))
return CategoryState.Failed;
if (subcategories.Count > 0 && subcategories.All(s =>
string.Equals(s.State, "succeeded", StringComparison.OrdinalIgnoreCase)
|| string.Equals(s.State, "notRequired", StringComparison.OrdinalIgnoreCase)))
return CategoryState.Done;
return CategoryState.Running;
}
}
catch (JsonException)
{
return CategoryState.Running;
}
}
}
}
@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// Which bootstrap installed this agent, read from the bootstrap scripts' own logs next to the
/// agent log. The scripts cannot report it themselves (they run before any agent exists), and
/// passing a version to <c>--install</c> would only ever cover installers released after that
/// change -- the old installers still assigned in Intune are exactly the ones worth counting.
/// <list type="bullet">
/// <item><c>bootstrap-script.log</c> (<c>bootstrap_agent.log</c> up to installer v2.3, before
/// the log rename of 2026-08-18): <c>Bootstrap script version: vX.Y</c>, written by every
/// installer run since 2026-04-09. A log without that line means an older installer.</item>
/// <item><c>bootstrap-loader.log</c> (Intune) / <c>bootstrap-msi.log</c> (MSI,
/// <c>msi-bootstrap.log</c> before 2026-08-18): <c>bootstrap loader vX.Y started</c>, written by
/// every loader since v1.0. The MSI's own downloader, which the loader replaced on 2026-09-08,
/// writes no version.</item>
/// <item><c>oobe_bootstrap.log</c>: the OOBE go-script (generated by the backend, no version).</item>
/// </list>
/// Only the version number is extracted; the loader line also names the account, which is
/// never read out. Observational only, and it never throws: an unreadable log reads as absent.
/// </summary>
internal static class BootstrapProvenanceReader
{
internal const string InstallerLogName = "bootstrap-script.log";
internal const string LoaderLogName = "bootstrap-loader.log";
internal const string MsiLoaderLogName = "bootstrap-msi.log";
internal const string OobeLogName = "oobe_bootstrap.log";
// Names before the log rename of 2026-08-18; copies of those scripts are still assigned.
internal const string LegacyInstallerLogName = "bootstrap_agent.log";
internal const string LegacyMsiLoaderLogName = "msi-bootstrap.log";
// The logs grow across runs; the last run's lines sit at the end. One run writes a few KB.
private const int TailBytes = 64 * 1024;
private static readonly Regex InstallerVersionLine = new Regex(
@"Bootstrap script version: v(?<v>\d+(?:\.\d+){0,3})\b", RegexOptions.CultureInvariant);
private static readonly Regex LoaderStartLine = new Regex(
@"bootstrap loader v(?<v>\d+(?:\.\d+){0,3}) started\b", RegexOptions.CultureInvariant);
public static BootstrapProvenance Read(string logDirectory)
{
try
{
var installerLog = ReadTail(MostRecentlyWritten(logDirectory, InstallerLogName, LegacyInstallerLogName));
// Intune loader and MSI loader write separate files; every name but the Intune
// loader's is the MSI's.
var loaderPath = MostRecentlyWritten(logDirectory, LoaderLogName, MsiLoaderLogName, LegacyMsiLoaderLogName);
var loaderChannel = loaderPath == null ? null
: string.Equals(Path.GetFileName(loaderPath), LoaderLogName, StringComparison.OrdinalIgnoreCase) ? "loader" : "msi";
return new BootstrapProvenance(
scriptLogPresent: installerLog != null,
scriptVersion: LastVersion(installerLog, InstallerVersionLine),
loaderLog: loaderChannel,
loaderVersion: LastVersion(ReadTail(loaderPath), LoaderStartLine),
oobeLogPresent: File.Exists(Path.Combine(logDirectory, OobeLogName)));
}
catch
{
return BootstrapProvenance.Unknown;
}
}
/// <summary>
/// The most recently written of the named logs that exist, or null. On a device with
/// several, it belongs to the bootstrap that ran last.
/// </summary>
private static string MostRecentlyWritten(string directory, params string[] names) =>
names.Select(name => Path.Combine(directory, name))
.Where(path => File.Exists(path))
.OrderByDescending(path => File.GetLastWriteTimeUtc(path))
.FirstOrDefault();
internal static string LastVersion(string text, Regex line)
{
if (string.IsNullOrEmpty(text)) return null;
string version = null;
foreach (Match match in line.Matches(text))
version = match.Groups["v"].Value;
return version;
}
/// <summary>The last <see cref="TailBytes"/> of the file, or null when it is missing or unreadable.</summary>
private static string ReadTail(string path)
{
try
{
if (!File.Exists(path)) return null;
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
{
var start = Math.Max(0, stream.Length - TailBytes);
stream.Seek(start, SeekOrigin.Begin);
var buffer = new byte[stream.Length - start];
var read = 0;
while (read < buffer.Length)
{
var n = stream.Read(buffer, read, buffer.Length - read);
if (n == 0) break;
read += n;
}
// The scripts write ASCII (their own pure-ASCII rule); UTF-8 decodes it either way.
return Encoding.UTF8.GetString(buffer, 0, read);
}
}
catch
{
return null;
}
}
}
/// <summary>What <see cref="BootstrapProvenanceReader"/> found; null fields were not found.</summary>
internal sealed class BootstrapProvenance
{
public static readonly BootstrapProvenance Unknown = new BootstrapProvenance(false, null, null, null, false);
public BootstrapProvenance(bool scriptLogPresent, string scriptVersion, string loaderLog, string loaderVersion, bool oobeLogPresent)
{
ScriptLogPresent = scriptLogPresent;
ScriptVersion = scriptVersion;
LoaderLog = loaderLog;
LoaderVersion = loaderVersion;
OobeLogPresent = oobeLogPresent;
}
public bool ScriptLogPresent { get; }
public string ScriptVersion { get; }
public string LoaderLog { get; }
public string LoaderVersion { get; }
public bool OobeLogPresent { get; }
/// <summary>
/// Adds the <c>agent_started</c> keys. Version and loader keys only when found, so a
/// missing key always means "not found" and never an empty string.
/// </summary>
public void AddTo(IDictionary<string, object> data)
{
data["bootstrapScriptLogPresent"] = ScriptLogPresent;
if (ScriptVersion != null) data["bootstrapScriptVersion"] = ScriptVersion;
if (LoaderLog != null) data["bootstrapLoaderLog"] = LoaderLog;
if (LoaderVersion != null) data["bootstrapLoaderVersion"] = LoaderVersion;
data["oobeBootstrapLogPresent"] = OobeLogPresent;
}
}
}
@@ -139,6 +139,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// </summary>
internal const int GracefulExitFallbackMs = 15000;
/// <summary>
/// The payload catalog check of step 5b. Production keeps the default,
/// <see cref="PayloadCatalogVerifier.Verify"/>; tests swap it so the update path runs
/// without a PowerShell runspace. Same static-hook idiom as <see cref="BackendExpectedSha256"/>.
/// </summary>
internal static Func<string, PayloadCatalogVerdict> VerifyPayloadCatalog { get; set; } = PayloadCatalogVerifier.Verify;
/// <summary>
/// Checks for a newer agent version and applies the update if available.
/// On success, restarts the process and never returns.
@@ -381,6 +388,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
return;
}
// Step 5b: The payload must match its signed catalog -- the check the installer and
// the OOBE script run before --install. Covers what the ZIP hash cannot: a hash
// from the download host vouches for nothing if that host is compromised, and
// without any hash there was no check at all.
if (!VerifyStagedPayload(stagingDir, log, out var catalogRejection))
{
CleanupStaging(stagingDir, zipPath);
if (writeMarkers)
WriteSkipMarker("catalog_rejected", currentVersion, latestVersion, catalogRejection, log);
return;
}
// Step 6: Swap files (rename locked → .old, copy new)
var swapTimerSw = Stopwatch.StartNew();
var swapOk = SwapFiles(agentDir, stagingDir, log);
@@ -597,6 +616,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
}
}
/// <summary>
/// Step 5b. Fail-closed: a rejected payload is never swapped in, and the running agent --
/// verified when it was installed -- stays. A verifier that throws counts as a rejection.
/// </summary>
internal static bool VerifyStagedPayload(string stagingDir, Action<string> log, out string rejectionReason)
{
var sw = Stopwatch.StartNew();
PayloadCatalogVerdict verdict;
try
{
verdict = VerifyPayloadCatalog(stagingDir);
}
catch (Exception ex)
{
verdict = PayloadCatalogVerdict.Reject($"catalog check failed to run ({ex.Message})");
}
sw.Stop();
if (verdict.Accepted)
{
log($"Self-update: payload catalog verified ({sw.ElapsedMilliseconds}ms)");
rejectionReason = null;
return true;
}
log($"Self-update: payload catalog REJECTED ({sw.ElapsedMilliseconds}ms) — {verdict.Reason}. Aborting update.");
rejectionReason = verdict.Reason;
return false;
}
/// <summary>
/// Extracts the ZIP to the staging directory. Returns false on failure.
///
@@ -633,26 +633,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
{
var data = new Dictionary<string, object>();
using (var searcher = new ManagementObjectSearcher(
new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftTpm"),
new ObjectQuery("SELECT IsActivated_InitialValue, IsEnabled_InitialValue, IsOwned_InitialValue, ManufacturerIdTxt, ManufacturerVersion, SpecVersion FROM Win32_Tpm")))
var tpm = TpmInfoReader.Read();
if (tpm != null)
{
foreach (ManagementObject obj in searcher.Get())
{
data["isActivated"] = obj["IsActivated_InitialValue"]?.ToString() == "True";
data["isEnabled"] = obj["IsEnabled_InitialValue"]?.ToString() == "True";
data["isOwned"] = obj["IsOwned_InitialValue"]?.ToString() == "True";
data["manufacturerName"] = obj["ManufacturerIdTxt"]?.ToString();
data["manufacturerVersion"] = obj["ManufacturerVersion"]?.ToString();
// SpecVersion is comma-separated: "2.0, 0, 1.59" → first element is the spec version
var specVersion = obj["SpecVersion"]?.ToString();
if (!string.IsNullOrEmpty(specVersion))
{
var parts = specVersion.Split(',');
data["specVersion"] = parts[0].Trim();
}
}
data["isActivated"] = tpm.IsActivated;
data["isEnabled"] = tpm.IsEnabled;
data["isOwned"] = tpm.IsOwned;
data["manufacturerName"] = tpm.ManufacturerName;
data["manufacturerVersion"] = tpm.ManufacturerVersion;
if (tpm.SpecVersion != null)
data["specVersion"] = tpm.SpecVersion;
}
if (data.Count > 0)
@@ -0,0 +1,62 @@
using System.Management;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
{
/// <summary>The TPM as <c>Win32_Tpm</c> reports it.</summary>
internal sealed class TpmInfo
{
public bool IsActivated { get; set; }
public bool IsEnabled { get; set; }
public bool IsOwned { get; set; }
/// <summary><c>ManufacturerIdTxt</c> as reported — may be space-padded (<c>"STM "</c>).</summary>
public string ManufacturerName { get; set; }
/// <summary><c>ManufacturerVersion</c>: the firmware version.</summary>
public string ManufacturerVersion { get; set; }
/// <summary>First element of <c>SpecVersion</c> (<c>"2.0, 0, 1.59"</c> → <c>"2.0"</c>); null when absent.</summary>
public string SpecVersion { get; set; }
/// <summary>Manufacturer id and firmware version, trimmed: <c>IFX 7.63.3353.0</c>; null when both are empty.</summary>
public string Label => FormatLabel(ManufacturerName, ManufacturerVersion);
internal static string FormatLabel(string manufacturerName, string manufacturerVersion)
{
var label = $"{manufacturerName?.Trim()} {manufacturerVersion?.Trim()}".Trim();
return label.Length == 0 ? null : label;
}
}
/// <summary>
/// Reads <c>Win32_Tpm</c> from <c>root\CIMV2\Security\MicrosoftTpm</c>: one WMI connect plus one
/// query. Shared by the <c>tpm_status</c> device-info event and the TPM-PSS distress report.
/// </summary>
internal static class TpmInfoReader
{
/// <summary>The last listed TPM instance (there is one in practice); null when WMI lists none. Throws on WMI failure.</summary>
public static TpmInfo Read()
{
TpmInfo info = null;
using (var searcher = new ManagementObjectSearcher(
new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftTpm"),
new ObjectQuery("SELECT IsActivated_InitialValue, IsEnabled_InitialValue, IsOwned_InitialValue, ManufacturerIdTxt, ManufacturerVersion, SpecVersion FROM Win32_Tpm")))
{
foreach (ManagementObject obj in searcher.Get())
{
var specVersion = obj["SpecVersion"]?.ToString();
info = new TpmInfo
{
IsActivated = obj["IsActivated_InitialValue"]?.ToString() == "True",
IsEnabled = obj["IsEnabled_InitialValue"]?.ToString() == "True",
IsOwned = obj["IsOwned_InitialValue"]?.ToString() == "True",
ManufacturerName = obj["ManufacturerIdTxt"]?.ToString(),
ManufacturerVersion = obj["ManufacturerVersion"]?.ToString(),
SpecVersion = string.IsNullOrEmpty(specVersion) ? null : specVersion.Split(',')[0].Trim(),
};
}
}
return info;
}
}
}
@@ -291,6 +291,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
sb.AppendLine($" WebException.Status = {we.Status}");
}
if (current is System.ComponentModel.Win32Exception win32)
{
sb.AppendLine($" NativeErrorCode = {TransportErrorCodes.Hex(win32.NativeErrorCode)}");
}
current = current.InnerException;
depth++;
}
@@ -0,0 +1,50 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Termination;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
/// <summary>
/// Where the device stood when a failure was observed: Windows' OOBE flag, the Autopilot ESP
/// phase and the last boot. Observational only. Sample it when the failing work starts — a
/// failed registration retries for minutes, and the device moves on meanwhile.
/// </summary>
public sealed class DistressDeviceContext
{
public DistressDeviceContext(string oobeState, string espPhase, DateTime? deviceBootUtc)
{
OobeState = oobeState;
EspPhase = espPhase;
DeviceBootUtc = deviceBootUtc;
}
/// <summary><see cref="OobeStateReader"/>: flips to <c>completed</c> at the desktop, not ESP progress.</summary>
public string OobeState { get; }
/// <summary><see cref="EspPhaseSnapshot"/> token.</summary>
public string EspPhase { get; }
/// <summary>Last boot (UTC) from the monotonic uptime counter; null when it could not be read.</summary>
public DateTime? DeviceBootUtc { get; }
/// <summary>Samples all three now. Registry, WinRT and one kernel call; never throws.</summary>
public static DistressDeviceContext Capture()
{
DateTime? boot;
try { boot = ObservationCoverage.DeviceBootUtc(); }
catch { boot = null; }
return new DistressDeviceContext(OobeStateReader.Read(), EspPhaseSnapshot.Read(), boot);
}
}
/// <summary>Optional parts of one distress report.</summary>
public sealed class DistressDetail
{
/// <summary>TPM manufacturer id and firmware (TPM-PSS report only).</summary>
public string TpmVersion { get; set; }
/// <summary>The device context of the failure; null samples it when the report is sent.</summary>
public DistressDeviceContext Context { get; set; }
}
}
@@ -6,6 +6,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
@@ -120,16 +121,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
}
/// <summary>
/// Attempts to send a distress report. Returns immediately without awaiting
/// (intended to be called as fire-and-forget via <c>_ = reporter.TrySendAsync(...)</c>).
/// Attempts to send a distress report. Callers usually fire and forget
/// (<c>_ = reporter.TrySendAsync(...)</c>); one that exits right after awaits it — the send
/// is bounded by the 5-second HTTP timeout.
///
/// All anti-flood checks are applied synchronously before the async HTTP call,
/// so repeated calls are cheap when suppressed.
/// so repeated calls are cheap when suppressed. Every report carries the OS build and a
/// <see cref="DistressDeviceContext"/> — <paramref name="detail"/>'s, sampled when the
/// failure was observed, or one sampled at send time; the TPM version comes only with the
/// TPM-PSS report.
/// </summary>
public virtual async Task TrySendAsync(
DistressErrorType errorType,
string message,
int? httpStatusCode = null)
int? httpStatusCode = null,
DistressDetail detail = null)
{
var key = $"{errorType}:{httpStatusCode}";
int currentReportCount;
@@ -164,29 +170,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
var statusText = httpStatusCode.HasValue ? $" HTTP {httpStatusCode}" : string.Empty;
_logger?.Warning($"[DistressChannel] Sending report {currentReportCount}/{MaxReportsPerSession}: {errorType}{statusText}");
var report = new DistressReport
{
TenantId = _tenantId,
ErrorType = errorType,
Manufacturer = _manufacturer,
Model = _model,
SerialNumber = _serialNumber,
AgentVersion = _agentVersion,
HttpStatusCode = httpStatusCode,
Message = Truncate(message, 256),
Timestamp = DateTime.UtcNow,
IsCloudPc = _isCloudPc,
CertSourceState = _capturedCertState,
CertThumbprint = _capturedCertThumbprint,
CertSubject = Truncate(_capturedCertSubject, 96),
CertIssuer = Truncate(_capturedCertIssuer, 96),
CertNotBefore = _capturedCertNotBefore,
CertNotAfter = _capturedCertNotAfter,
};
var report = BuildReport(errorType, message, httpStatusCode, detail);
try
{
var json = JsonConvert.SerializeObject(report);
var json = SerializeWithinCap(report);
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, _distressUrl)
{
@@ -216,6 +204,57 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
return value.Length <= maxLength ? value : value.Substring(0, maxLength);
}
/// <summary>
/// The report as sent: construction-time device and cert context, the send-time OS build,
/// and the device context of <paramref name="detail"/> (sampled now when it has none).
/// </summary>
internal DistressReport BuildReport(DistressErrorType errorType, string message, int? httpStatusCode, DistressDetail detail)
{
var context = detail?.Context ?? DistressDeviceContext.Capture();
return new DistressReport
{
TenantId = _tenantId,
ErrorType = errorType,
Manufacturer = _manufacturer,
Model = _model,
SerialNumber = _serialNumber,
AgentVersion = _agentVersion,
HttpStatusCode = httpStatusCode,
Message = Truncate(message, 256),
Timestamp = DateTime.UtcNow,
IsCloudPc = _isCloudPc,
CertSourceState = _capturedCertState,
CertThumbprint = _capturedCertThumbprint,
CertSubject = Truncate(_capturedCertSubject, 96),
CertIssuer = Truncate(_capturedCertIssuer, 96),
CertNotBefore = _capturedCertNotBefore,
CertNotAfter = _capturedCertNotAfter,
OsBuild = NullIfEmpty(DeviceInfoProvider.GetOsBuild()),
OobeState = context.OobeState,
EspPhase = context.EspPhase,
DeviceBootUtc = context.DeviceBootUtc,
TpmVersion = Truncate(detail?.TpmVersion, 48),
};
}
private static string NullIfEmpty(string value) => string.IsNullOrEmpty(value) ? null : value;
/// <summary>
/// The report as JSON within <see cref="Constants.DistressReportMaxBytes"/>: the backend drops
/// a larger body without a word, so an oversized report gives up message characters
/// (each removed character removes at least one byte).
/// </summary>
internal static string SerializeWithinCap(DistressReport report)
{
var json = JsonConvert.SerializeObject(report);
var overflow = Encoding.UTF8.GetByteCount(json) - Constants.DistressReportMaxBytes;
if (overflow <= 0 || string.IsNullOrEmpty(report.Message))
return json;
report.Message = report.Message.Substring(0, Math.Max(0, report.Message.Length - overflow));
return JsonConvert.SerializeObject(report);
}
public void Dispose()
{
_httpClient?.Dispose();
@@ -0,0 +1,37 @@
using System;
using System.ComponentModel;
using System.Globalization;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
/// <summary>
/// The native status behind a transport failure. Schannel and WinHTTP report it only on the
/// innermost <see cref="Win32Exception"/> of the chain (e.g. <c>0x80090326</c>); the outer
/// <c>HttpRequestException</c> and <c>WebException</c> texts are generic.
/// </summary>
internal static class TransportErrorCodes
{
/// <summary>The innermost <see cref="Win32Exception.NativeErrorCode"/> as <c>0x80090326</c>; null when the chain has none.</summary>
public static string InnermostNativeCode(Exception ex)
{
string code = null;
for (var current = ex; current != null; current = current.InnerException)
{
if (current is Win32Exception win32)
code = Hex(win32.NativeErrorCode);
}
return code;
}
/// <summary><paramref name="ex"/>'s message with the innermost native code appended as <c>(win32 0x80090326)</c>.</summary>
public static string MessageWithNativeCode(Exception ex)
{
if (ex == null) return null;
var code = InnermostNativeCode(ex);
return code == null ? ex.Message : $"{ex.Message} (win32 {code})";
}
/// <summary>Eight-digit hex with <c>0x</c>: <c>-2146893018</c> → <c>0x80090326</c>.</summary>
public static string Hex(int value) => "0x" + value.ToString("X8", CultureInfo.InvariantCulture);
}
}
@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.AccessControl;
using System.Security.Principal;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Keeps <c>%ProgramData%\AutopilotMonitor</c> out of reach of standard users. Left alone the
/// folder inherits ProgramData's "Users may create files and folders" entry, and the SYSTEM
/// agent and its boot task start from the Agent subfolder: a user who can add a file there
/// can plant code the next SYSTEM start loads. During the device phase of OOBE only SYSTEM is
/// around, but the agent keeps running through the user phase and on the desktop until the
/// enrollment ends, restarted by the boot task after every reboot; that is the window this
/// closes. The payload catalog does not cover it: it is checked at install time, a file
/// added later is never looked at.
/// <para>
/// The DACL is protected (nothing inherited from ProgramData): SYSTEM and Administrators full
/// control, Users read and execute, all inherited by the subfolders. Explicit entries on a
/// child stay. The summary dialog's temp folder lives outside this tree anyway.
/// </para>
/// </summary>
public static class AgentDirectoryAcl
{
private const InheritanceFlags Inherit = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
private static readonly SecurityIdentifier LocalSystem = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
private static readonly SecurityIdentifier Administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
private static readonly SecurityIdentifier Users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
/// <summary>
/// Applies the protected DACL to <paramref name="path"/> unless it is already in place.
/// Never throws: a folder the agent cannot protect must not stop the agent.
/// </summary>
public static AgentDirectoryAclResult EnsureProtected(string path)
{
try
{
var directory = new DirectoryInfo(path);
if (!directory.Exists)
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Missing, null);
if (IsProtectedAsExpected(directory.GetAccessControl(AccessControlSections.Access)))
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.AlreadyProtected, null);
directory.SetAccessControl(BuildProtectedSecurity());
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Applied, null);
}
catch (Exception ex)
{
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Failed, ex.Message);
}
}
internal static DirectorySecurity BuildProtectedSecurity()
{
var security = new DirectorySecurity();
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
security.AddAccessRule(new FileSystemAccessRule(LocalSystem, FileSystemRights.FullControl, Inherit, PropagationFlags.None, AccessControlType.Allow));
security.AddAccessRule(new FileSystemAccessRule(Administrators, FileSystemRights.FullControl, Inherit, PropagationFlags.None, AccessControlType.Allow));
security.AddAccessRule(new FileSystemAccessRule(Users, FileSystemRights.ReadAndExecute, Inherit, PropagationFlags.None, AccessControlType.Allow));
return security;
}
/// <summary>
/// True when the DACL is protected and holds exactly the three entries above. Windows adds
/// <see cref="FileSystemRights.Synchronize"/> when it stores an entry, so that bit is ignored.
/// </summary>
internal static bool IsProtectedAsExpected(DirectorySecurity security)
{
if (!security.AreAccessRulesProtected)
return false;
var rules = security.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier))
.Cast<FileSystemAccessRule>()
.ToList();
return rules.Count == 3
&& Has(rules, LocalSystem, FileSystemRights.FullControl)
&& Has(rules, Administrators, FileSystemRights.FullControl)
&& Has(rules, Users, FileSystemRights.ReadAndExecute);
}
private static bool Has(IEnumerable<FileSystemAccessRule> rules, SecurityIdentifier sid, FileSystemRights rights) =>
rules.Any(r => r.AccessControlType == AccessControlType.Allow
&& !r.IsInherited
&& sid.Equals(r.IdentityReference)
&& (r.FileSystemRights & ~FileSystemRights.Synchronize) == (rights & ~FileSystemRights.Synchronize)
&& r.InheritanceFlags == Inherit
&& r.PropagationFlags == PropagationFlags.None);
}
public enum AgentDirectoryAclOutcome
{
Applied,
AlreadyProtected,
Missing,
Failed,
}
/// <summary>Outcome of <see cref="AgentDirectoryAcl.EnsureProtected"/>; <see cref="Error"/> only on failure.</summary>
public sealed class AgentDirectoryAclResult
{
public AgentDirectoryAclResult(AgentDirectoryAclOutcome outcome, string error)
{
Outcome = outcome;
Error = error;
}
public AgentDirectoryAclOutcome Outcome { get; }
public string Error { get; }
}
}
@@ -0,0 +1,203 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Verifies an extracted agent payload against its signed catalog
/// (<see cref="Constants.AgentPayloadCatalogFileName"/>) with Windows PowerShell's own
/// <c>Test-FileCatalog</c>, the cmdlet the bootstrap installer and the OOBE script run as well,
/// so all three verifiers share one semantics: the catalog must carry a valid signature from
/// <see cref="Constants.CodeSigningPublisherPattern"/>, and every file of the payload must match
/// it, with nothing missing, changed or extra.
/// <para>
/// A reimplementation of the catalog format (WinVerifyTrust plus the CryptCAT APIs) was the
/// alternative and was rejected: a second parser for the same contract would drift from the
/// cmdlet the other two verifiers use. The runspace costs about a second, paid only on the
/// rare update path.
/// </para>
/// </summary>
public static class PayloadCatalogVerifier
{
private const int InvokeTimeoutMs = 60000;
private const int MaxNamesPerList = 5;
/// <summary>
/// Checks <paramref name="payloadDir"/>. Never throws: any failure to run the check is a
/// rejection, because an unverifiable payload must not be installed.
/// </summary>
public static PayloadCatalogVerdict Verify(string payloadDir)
{
var catalogPath = Path.Combine(payloadDir, Constants.AgentPayloadCatalogFileName);
if (!File.Exists(catalogPath))
return PayloadCatalogVerdict.Reject($"catalog {Constants.AgentPayloadCatalogFileName} missing");
return RunCatalogCheck(payloadDir, catalogPath);
}
// Kept out of Verify (and never inlined) so the PowerShell engine is loaded only when a
// catalog is actually checked, not when the method that guards it is compiled.
[MethodImpl(MethodImplOptions.NoInlining)]
private static PayloadCatalogVerdict RunCatalogCheck(string payloadDir, string catalogPath)
{
try
{
using (var runspace = RunspaceFactory.CreateRunspace())
{
runspace.Open();
using (var ps = PowerShell.Create())
{
ps.Runspace = runspace;
ps.AddCommand(@"Microsoft.PowerShell.Security\Test-FileCatalog")
.AddParameter("Path", payloadDir)
.AddParameter("CatalogFilePath", catalogPath)
.AddParameter("FilesToSkip", new[] { Constants.AgentPayloadCatalogFileName })
.AddParameter("Detailed");
var asyncResult = ps.BeginInvoke();
if (!asyncResult.AsyncWaitHandle.WaitOne(InvokeTimeoutMs))
{
ps.Stop();
return PayloadCatalogVerdict.Reject($"catalog check timed out after {InvokeTimeoutMs / 1000}s");
}
var results = ps.EndInvoke(asyncResult);
if (ps.HadErrors && ps.Streams.Error.Count > 0)
return PayloadCatalogVerdict.Reject($"catalog check failed to run ({ps.Streams.Error[0]})");
var info = results.FirstOrDefault();
if (info == null)
return PayloadCatalogVerdict.Reject("catalog check returned nothing");
var signature = info.Properties["Signature"]?.Value as Signature;
return Evaluate(new PayloadCatalogFacts(
status: info.Properties["Status"]?.Value?.ToString(),
signatureStatus: signature?.Status.ToString(),
signerSubject: signature?.SignerCertificate?.Subject,
catalogItems: ToStringMap(info.Properties["CatalogItems"]?.Value),
pathItems: ToStringMap(info.Properties["PathItems"]?.Value)),
Constants.CodeSigningPublisherPattern);
}
}
}
catch (Exception ex)
{
return PayloadCatalogVerdict.Reject($"catalog check failed to run ({ex.Message})");
}
}
/// <summary>
/// The decision over the facts <c>Test-FileCatalog -Detailed</c> reports. Separate from the
/// runspace so it is testable without PowerShell. <c>Status</c> covers the hashes only: an
/// unsigned catalog over matching files reports <c>Valid</c>, which is why the signature is
/// judged on its own.
/// </summary>
internal static PayloadCatalogVerdict Evaluate(PayloadCatalogFacts facts, string publisherPattern)
{
var reasons = new List<string>();
if (!string.Equals(facts.SignatureStatus, "Valid", StringComparison.Ordinal))
{
reasons.Add($"catalog signature status {facts.SignatureStatus ?? "unknown"}");
}
else if (!MatchesPublisherPattern(facts.SignerSubject, publisherPattern))
{
reasons.Add($"catalog publisher '{facts.SignerSubject}' does not match {publisherPattern}");
}
if (!string.Equals(facts.Status, "Valid", StringComparison.Ordinal))
reasons.Add(DescribeDifference(facts.CatalogItems, facts.PathItems));
return reasons.Count == 0
? PayloadCatalogVerdict.Accept()
: PayloadCatalogVerdict.Reject(string.Join("; ", reasons));
}
/// <summary>
/// PowerShell's case-insensitive <c>-like</c> for the two wildcards the pin uses (<c>*</c>,
/// <c>?</c>), without loading the PowerShell engine. Bracket classes are not supported;
/// <c>CodeSigningParityTests</c> pins the pattern's shape.
/// </summary>
internal static bool MatchesPublisherPattern(string subject, string pattern)
{
if (string.IsNullOrEmpty(subject) || string.IsNullOrEmpty(pattern)) return false;
var regex = "^" + Regex.Escape(pattern).Replace(@"\*", ".*").Replace(@"\?", ".") + "$";
return Regex.IsMatch(subject, regex, RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant);
}
private static string DescribeDifference(
IReadOnlyDictionary<string, string> expected, IReadOnlyDictionary<string, string> actual)
{
var missing = expected.Keys.Where(k => !actual.ContainsKey(k));
var extra = actual.Keys.Where(k => !expected.ContainsKey(k));
var changed = expected.Keys.Where(k => actual.TryGetValue(k, out var hash)
&& !string.Equals(hash, expected[k], StringComparison.OrdinalIgnoreCase));
return $"payload differs from catalog (missing: {Cap(missing)}; extra: {Cap(extra)}; changed: {Cap(changed)})";
}
private static string Cap(IEnumerable<string> names)
{
var list = names.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
if (list.Count == 0) return "none";
var shown = string.Join(", ", list.Take(MaxNamesPerList));
return list.Count > MaxNamesPerList ? $"{shown} (+{list.Count - MaxNamesPerList} more)" : shown;
}
private static IReadOnlyDictionary<string, string> ToStringMap(object value)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (value is IDictionary dictionary)
{
foreach (DictionaryEntry entry in dictionary)
map[Convert.ToString(entry.Key)] = Convert.ToString(entry.Value);
}
return map;
}
}
/// <summary>What <c>Test-FileCatalog -Detailed</c> reported, reduced to the fields the decision needs.</summary>
internal sealed class PayloadCatalogFacts
{
public PayloadCatalogFacts(
string status, string signatureStatus, string signerSubject,
IReadOnlyDictionary<string, string> catalogItems, IReadOnlyDictionary<string, string> pathItems)
{
Status = status;
SignatureStatus = signatureStatus;
SignerSubject = signerSubject;
CatalogItems = catalogItems ?? new Dictionary<string, string>();
PathItems = pathItems ?? new Dictionary<string, string>();
}
public string Status { get; }
public string SignatureStatus { get; }
public string SignerSubject { get; }
public IReadOnlyDictionary<string, string> CatalogItems { get; }
public IReadOnlyDictionary<string, string> PathItems { get; }
}
/// <summary>Outcome of a payload catalog check; <see cref="Reason"/> is null when accepted.</summary>
public sealed class PayloadCatalogVerdict
{
private PayloadCatalogVerdict(bool accepted, string reason)
{
Accepted = accepted;
Reason = reason;
}
public bool Accepted { get; }
public string Reason { get; }
public static PayloadCatalogVerdict Accept() => new PayloadCatalogVerdict(true, null);
public static PayloadCatalogVerdict Reject(string reason) => new PayloadCatalogVerdict(false, reason);
}
}
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using System.Globalization;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// One-shot read of the Schannel errors the System log recorded in a time window:
/// 36871 (creating the client credential failed), 36887 (fatal TLS alert received) and
/// 36888 (fatal TLS alert sent). Only the terminal registration-failure path calls it.
/// Another process's TLS failure in the same window shows up too, so the result is a hint.
/// </summary>
internal static class SchannelEventReader
{
internal const int MaxEvents = 2;
/// <summary>The log was read and holds no matching event — a finding, unlike <see cref="Unreadable"/>.</summary>
internal const string NoEvents = "none";
/// <summary>The System log could not be queried; nothing is known about Schannel.</summary>
internal const string Unreadable = "unreadable";
/// <summary>
/// Newest first, at most <see cref="MaxEvents"/> events as <see cref="FormatToken"/> tokens
/// joined by commas (<c>36888:40/1205,36887:116</c>); <see cref="NoEvents"/> when there is
/// none, <see cref="Unreadable"/> when the log cannot be read. <paramref name="untilUtc"/>
/// keeps out what the diagnostics after the failure log themselves (the TLS probe's own
/// handshake). Never throws.
/// </summary>
public static string ReadBetween(DateTime sinceUtc, DateTime untilUtc, AgentLogger logger = null)
{
var tokens = new List<string>();
try
{
var query = new EventLogQuery("System", PathType.LogName, BuildXPath(sinceUtc, untilUtc)) { ReverseDirection = true };
using (var reader = new EventLogReader(query))
{
EventRecord record;
while (tokens.Count < MaxEvents && (record = reader.ReadEvent()) != null)
{
using (record)
{
tokens.Add(FormatToken(record.Id, ReadProperties(record)));
}
}
}
}
catch (Exception ex)
{
logger?.Debug($"SchannelEventReader: System log not readable ({ex.Message}).");
return Unreadable;
}
return tokens.Count == 0 ? NoEvents : string.Join(",", tokens);
}
internal static string BuildXPath(DateTime sinceUtc, DateTime untilUtc)
{
return "*[System[Provider[@Name='Schannel'] and (EventID=36871 or EventID=36887 or EventID=36888)" +
$" and TimeCreated[@SystemTime >= '{XPathTime(sinceUtc)}' and @SystemTime <= '{XPathTime(untilUtc)}']]]";
}
private static string XPathTime(DateTime utc) =>
utc.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
/// <summary>
/// <c>36888:40/1205</c>: the event id and its numeric properties (TLS alert code, Schannel
/// error state). Text properties (target name, certificate subjects) are left out.
/// </summary>
internal static string FormatToken(int eventId, IEnumerable<string> properties)
{
var numbers = (properties ?? Enumerable.Empty<string>())
.Where(p => !string.IsNullOrEmpty(p) && p.All(c => c >= '0' && c <= '9'))
.Take(3)
.ToList();
return numbers.Count == 0
? eventId.ToString(CultureInfo.InvariantCulture)
: $"{eventId.ToString(CultureInfo.InvariantCulture)}:{string.Join("/", numbers)}";
}
private static List<string> ReadProperties(EventRecord record)
{
var values = new List<string>();
try
{
foreach (var property in record.Properties)
values.Add(Convert.ToString(property?.Value, CultureInfo.InvariantCulture));
}
catch { /* best-effort — keep what was read */ }
return values;
}
}
}
@@ -29,8 +29,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
private readonly string _registrationFailurePath;
private readonly object _lockObject = new object();
// Agent-local key in registration-failure.json; RegistrationFailureSummary has no such member.
// Agent-local keys in registration-failure.json; RegistrationFailureSummary has no such members.
private const string LocalNetworkFingerprintKey = "NetworkFingerprint";
private const string LocalDeviceBootKey = "DeviceBootUtc";
private const string LocalCertThumbprintKey = "CertThumbprint";
public SessionIdPersistence(string dataDirectory)
{
@@ -180,13 +182,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <see cref="RegistrationFailureSummary.InterceptedRuns"/>), the first failure time is kept,
/// everything else describes the latest run. A run that never registers exits and leaves
/// nothing else behind — the emergency break reads this file so the backend learns why the
/// session never appeared. <paramref name="networkFingerprint"/> (the latest run's network)
/// is stored beside the summary, never in it, so it cannot reach the wire. Written through to
/// disk (L.12): the process exits right after and the next boot decides on the record.
/// session never appeared. <paramref name="networkFingerprint"/>, <paramref name="deviceBootUtc"/>
/// and <paramref name="certThumbprint"/> (the latest run's network, boot and client
/// certificate) are stored beside the summary, never in it, so they cannot reach the wire —
/// the next successful start reports only whether they changed. Written through to disk
/// (L.12): the process exits right after and the next boot decides on the record.
/// Returns the merged summary, null when it could not be written. Never throws.
/// </summary>
public RegistrationFailureSummary RecordRegistrationFailure(
RegistrationFailureSummary latestRun, AgentLogger logger = null, string networkFingerprint = null)
RegistrationFailureSummary latestRun, AgentLogger logger = null, string networkFingerprint = null,
DateTime? deviceBootUtc = null, string certThumbprint = null)
{
if (latestRun == null) return null;
lock (_lockObject)
@@ -206,8 +211,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
NetworkLinkUpAtEnd = latestRun.NetworkLinkUpAtEnd,
AttemptWindowSeconds = latestRun.AttemptWindowSeconds,
ConfigFetchOutcome = latestRun.ConfigFetchOutcome,
EspPhase = latestRun.EspPhase,
OobeState = latestRun.OobeState,
};
WriteRegistrationFailure(merged, networkFingerprint);
WriteRegistrationFailure(merged, networkFingerprint, deviceBootUtc, certThumbprint);
logger?.Info($"SessionIdPersistence: recorded failed registration run #{merged.FailedRuns} ({merged.InterceptedRuns} intercepted).");
return merged;
}
@@ -286,7 +293,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
var json = JObject.Parse(File.ReadAllText(_registrationFailurePath));
var summary = json.ToObject<RegistrationFailureSummary>();
return summary != null && summary.FailedRuns > 0
? new RegistrationFailureRecord(summary, (string)json[LocalNetworkFingerprintKey])
? new RegistrationFailureRecord(
summary,
(string)json[LocalNetworkFingerprintKey],
json[LocalDeviceBootKey]?.Type == JTokenType.Date ? json[LocalDeviceBootKey].ToObject<DateTime>().ToUniversalTime() : (DateTime?)null,
(string)json[LocalCertThumbprintKey])
: null;
}
catch
@@ -297,14 +308,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
}
/// <summary>
/// The summary's own properties plus one agent-local key: the fingerprint sits next to the
/// wire type, never inside it (the summary deserializes without it).
/// The summary's own properties plus the agent-local keys: they sit next to the wire type,
/// never inside it (the summary deserializes without them).
/// </summary>
private void WriteRegistrationFailure(RegistrationFailureSummary summary, string networkFingerprint)
private void WriteRegistrationFailure(
RegistrationFailureSummary summary, string networkFingerprint, DateTime? deviceBootUtc, string certThumbprint)
{
var json = JObject.FromObject(summary);
if (!string.IsNullOrEmpty(networkFingerprint))
json[LocalNetworkFingerprintKey] = networkFingerprint;
if (deviceBootUtc.HasValue)
json[LocalDeviceBootKey] = deviceBootUtc.Value.ToUniversalTime();
if (!string.IsNullOrEmpty(certThumbprint))
json[LocalCertThumbprintKey] = certThumbprint;
var bytes = Encoding.UTF8.GetBytes(json.ToString(Formatting.None));
using (var stream = new FileStream(
@@ -357,20 +373,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <summary>
/// <c>registration-failure.json</c> as the agent keeps it: the <see cref="RegistrationFailureSummary"/>
/// the emergency break may send, plus the network fingerprint of the latest failed run, which
/// stays on the device — only the next successful start's comparison (changed or not) is reported.
/// the emergency break may send, plus the network fingerprint, boot time and client-certificate
/// thumbprint of the latest failed run, which stay on the device — only the next successful
/// start's comparisons (changed or not) are reported.
/// </summary>
public sealed class RegistrationFailureRecord
{
public RegistrationFailureRecord(RegistrationFailureSummary summary, string networkFingerprint)
public RegistrationFailureRecord(
RegistrationFailureSummary summary, string networkFingerprint,
DateTime? deviceBootUtc = null, string certThumbprint = null)
{
Summary = summary ?? throw new ArgumentNullException(nameof(summary));
NetworkFingerprint = networkFingerprint;
DeviceBootUtc = deviceBootUtc;
CertThumbprint = certThumbprint;
}
public RegistrationFailureSummary Summary { get; }
/// <summary>Fingerprint of the latest failed run's network; null when it was unknown.</summary>
public string NetworkFingerprint { get; }
/// <summary>Boot time (UTC) of the latest failed run; null when it was unknown or the record predates it.</summary>
public DateTime? DeviceBootUtc { get; }
/// <summary>Thumbprint of the client certificate the latest failed run used; null when none or unknown.</summary>
public string CertThumbprint { get; }
}
}
@@ -116,6 +116,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
}
lastError = response?.Message ?? "(null response)";
// The newest attempt answered: an earlier attempt's exception no longer
// describes how this run ended (onTerminalTransportFailure).
lastException = null;
logger.Warning($"Session registration failed: {lastError}");
}
catch (Exception ex) when (
@@ -170,7 +173,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
}
catch (Exception ex)
{
lastError = ex.Message;
// The native code (e.g. the Schannel status) is the only specific part of a
// transport failure; it travels on with the run record.
lastError = TransportErrorCodes.MessageWithNativeCode(ex);
lastException = ex;
logger.Error($"Failed to register session (attempt {attempt}/{MaxAttempts}): {ex.Message}", ex);
@@ -181,7 +186,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
if (emergencyReporter != null)
_ = emergencyReporter.TrySendAsync(
AgentErrorType.RegisterSessionFailed,
ex.Message);
lastError);
}
catch { /* emergency channel is best-effort */ }
}
@@ -0,0 +1,306 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Transport;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>Who the TLS probe talks to and with which identity headers.</summary>
internal sealed class TlsProbeTarget
{
public string BaseUrl { get; set; }
public string TenantId { get; set; }
public string Manufacturer { get; set; }
public string Model { get; set; }
public string SerialNumber { get; set; }
public string AgentVersion { get; set; }
}
/// <summary>
/// Diagnostic after a terminal <c>SecureChannelFailure</c>: one direct TLS connection to the
/// backend on a raw <see cref="SslStream"/> with the client certificate (system-default
/// protocols, default certificate selection, normal server validation) and one
/// <c>GET /api/agent/config</c> with the agent's own headers — the platform asks for the
/// certificate only once it has seen a path that needs one. The HTTP stack reports only
/// <c>SecureChannelFailure</c>; this says which protocol was negotiated, whether the certificate
/// went out and who answered, or the SSPI code of the failure. Failure path only, never a proxy.
/// <para>
/// Result token: <c>unreachable</c>, <c>hs(srvcert)</c> / <c>hs(0x…)</c> / <c>hs</c> (handshake
/// failed), or <c>&lt;protocol&gt;,&lt;m|-&gt;,&lt;answer&gt;</c> — protocol <c>tls12</c> /
/// <c>tls13</c> / …, <c>m</c> when the session ended mutually authenticated, answer
/// <c>b&lt;status&gt;</c> (the backend answered: the certificate arrived), <c>cc&lt;status&gt;</c>
/// (the platform's "Client Certificate Required" page: it did not), <c>st&lt;status&gt;</c>
/// (stopped app), <c>x&lt;status&gt;</c> (someone else), <c>io(0x…)</c> / <c>io</c> (the
/// exchange failed, e.g. during renegotiation). A server certificate not issued by Microsoft
/// (TLS inspection on the path) appends <c>,i=&lt;issuer CN&gt;</c>.
/// </para>
/// </summary>
internal static class TlsHandshakeProbe
{
internal const string Unreachable = "unreachable";
internal const int MaxResponseBytes = 16 * 1024;
internal const int MaxIssuerLength = 32;
/// <summary>Runs the probe within <paramref name="timeout"/> per network step. Never throws.</summary>
public static string Run(TlsProbeTarget target, X509Certificate2 clientCertificate, TimeSpan timeout, AgentLogger logger = null)
{
Uri baseUri;
try { baseUri = new Uri(target.BaseUrl); }
catch { return Unreachable; }
var host = baseUri.Host;
var port = baseUri.IsDefaultPort ? 443 : baseUri.Port;
var timeoutMs = (int)timeout.TotalMilliseconds;
try
{
using (var tcp = new TcpClient())
{
var connect = tcp.ConnectAsync(host, port);
// A connect that fails after the timeout must not surface as an unobserved
// task exception: the agent writes crash artefacts for those.
connect.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
try
{
if (!connect.Wait(timeout))
return Unreachable;
}
catch
{
return Unreachable;
}
var serverCertificateRejected = false;
string foreignIssuer = null;
using (var ssl = new SslStream(tcp.GetStream(), leaveInnerStreamOpen: false,
userCertificateValidationCallback: (sender, certificate, chain, errors) =>
{
foreignIssuer = ForeignIssuer(certificate);
serverCertificateRejected = errors != SslPolicyErrors.None;
return !serverCertificateRejected;
}))
{
ssl.ReadTimeout = timeoutMs;
ssl.WriteTimeout = timeoutMs;
var certificates = new X509CertificateCollection();
if (clientCertificate != null) certificates.Add(clientCertificate);
try
{
ssl.AuthenticateAsClient(host, certificates, SslProtocols.None, checkCertificateRevocation: false);
}
catch (Exception ex)
{
logger?.Debug($"TlsHandshakeProbe: handshake failed: {ex.GetBaseException().Message}");
return (serverCertificateRejected ? "hs(srvcert)" : "hs" + CodeSuffix(ex)) + IssuerSuffix(foreignIssuer);
}
var protocol = ProtocolToken(ssl.SslProtocol);
string answer;
try
{
var correlationId = Guid.NewGuid().ToString("N");
var request = Encoding.ASCII.GetBytes(BuildRequest(target, host, correlationId));
ssl.Write(request, 0, request.Length);
ssl.Flush();
answer = ClassifyAnswer(ReadResponse(ssl), correlationId);
}
catch (Exception ex)
{
logger?.Debug($"TlsHandshakeProbe: exchange failed: {ex.GetBaseException().Message}");
answer = "io" + CodeSuffix(ex);
}
bool mutual;
try { mutual = ssl.IsMutuallyAuthenticated; }
catch { mutual = false; }
return $"{protocol},{(mutual ? "m" : "-")},{answer}{IssuerSuffix(foreignIssuer)}";
}
}
}
catch (Exception ex)
{
logger?.Debug($"TlsHandshakeProbe: failed: {ex.Message}");
return Unreachable;
}
}
internal static string BuildRequest(TlsProbeTarget target, string host, string correlationId)
{
var sb = new StringBuilder();
sb.Append("GET ").Append(Constants.ApiEndpoints.GetAgentConfig)
.Append("?tenantId=").Append(Uri.EscapeDataString(target.TenantId ?? string.Empty)).Append(" HTTP/1.1\r\n");
sb.Append("Host: ").Append(host).Append("\r\n");
sb.Append("User-Agent: AutopilotMonitor.Agent/").Append(HeaderValue(target.AgentVersion)).Append(" tls-probe\r\n");
sb.Append(ResponseOriginClassifier.CorrelationIdHeader).Append(": ").Append(correlationId).Append("\r\n");
AppendHeader(sb, "X-Device-Manufacturer", target.Manufacturer);
AppendHeader(sb, "X-Device-Model", target.Model);
AppendHeader(sb, "X-Device-SerialNumber", target.SerialNumber);
AppendHeader(sb, "X-Agent-Version", target.AgentVersion);
sb.Append("Connection: close\r\n\r\n");
return sb.ToString();
}
/// <summary>
/// Reads until the server closes or <see cref="MaxResponseBytes"/> arrived; a read that
/// times out after the answer began keeps what came (a server that ignores
/// <c>Connection: close</c>).
/// </summary>
private static byte[] ReadResponse(Stream stream)
{
var buffer = new byte[4096];
using (var collected = new MemoryStream())
{
while (collected.Length < MaxResponseBytes)
{
int read;
try
{
read = stream.Read(buffer, 0, buffer.Length);
}
catch (IOException) when (collected.Length > 0)
{
break;
}
if (read <= 0) break;
collected.Write(buffer, 0, read);
}
return collected.ToArray();
}
}
/// <summary>
/// The answer token for a raw HTTP response, through the shared <see cref="ResponseOriginClassifier"/>:
/// an echo of <paramref name="correlationId"/> means the backend wrote it.
/// </summary>
internal static string ClassifyAnswer(byte[] raw, string correlationId)
{
if (!TryParse(raw, out var status, out var headers, out var body))
return "io(noresponse)";
using (var response = new HttpResponseMessage((HttpStatusCode)status))
{
foreach (var header in headers)
response.Headers.TryAddWithoutValidation(header.Key, header.Value);
response.Content = new StringContent(body ?? string.Empty);
var classification = ResponseOriginClassifier.Classify(response, correlationId, body);
switch (classification.Origin)
{
case ResponseOrigin.Backend:
return "b" + status;
case ResponseOrigin.PlatformStopped:
return "st" + status;
default:
return classification.ForeignReason == ResponseClassification.ClientCertificateRequired
? "cc" + status
: "x" + status;
}
}
}
internal static bool TryParse(byte[] raw, out int status, out List<KeyValuePair<string, string>> headers, out string body)
{
status = 0;
headers = new List<KeyValuePair<string, string>>();
body = null;
if (raw == null || raw.Length == 0) return false;
var text = Encoding.UTF8.GetString(raw);
var headEnd = text.IndexOf("\r\n\r\n", StringComparison.Ordinal);
var head = headEnd >= 0 ? text.Substring(0, headEnd) : text;
body = headEnd >= 0 ? text.Substring(headEnd + 4) : string.Empty;
var lines = head.Split(new[] { "\r\n" }, StringSplitOptions.None);
var statusParts = lines[0].Split(' ');
if (statusParts.Length < 2 || !statusParts[0].StartsWith("HTTP/", StringComparison.Ordinal)
|| !int.TryParse(statusParts[1], out status))
return false;
for (var i = 1; i < lines.Length; i++)
{
var colon = lines[i].IndexOf(':');
if (colon > 0)
headers.Add(new KeyValuePair<string, string>(lines[i].Substring(0, colon).Trim(), lines[i].Substring(colon + 1).Trim()));
}
return true;
}
internal static string ProtocolToken(SslProtocols protocol)
{
switch ((int)protocol)
{
case 12288: return "tls13"; // SslProtocols.Tls13
case 3072: return "tls12";
case 768: return "tls11";
case 192: return "tls10";
default: return "p" + ((int)protocol).ToString(System.Globalization.CultureInfo.InvariantCulture);
}
}
/// <summary>
/// The issuer CN of a server certificate that Microsoft did not issue (the backend's comes
/// from a Microsoft CA), else null: <c>[A-Za-z0-9.-]</c>, others as <c>_</c>, at most
/// <see cref="MaxIssuerLength"/>.
/// </summary>
internal static string ForeignIssuer(X509Certificate certificate)
{
if (certificate == null) return null;
try
{
if (certificate.Issuer.IndexOf("O=Microsoft Corporation", StringComparison.Ordinal) >= 0)
return null;
string cn;
using (var cert2 = new X509Certificate2(certificate))
cn = cert2.GetNameInfo(X509NameType.SimpleName, forIssuer: true);
if (string.IsNullOrEmpty(cn)) return "unknown";
var sb = new StringBuilder(Math.Min(cn.Length, MaxIssuerLength));
foreach (var c in cn)
{
if (sb.Length == MaxIssuerLength) break;
sb.Append((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '-' ? c : '_');
}
return sb.ToString();
}
catch
{
return "unknown";
}
}
private static string IssuerSuffix(string foreignIssuer) =>
foreignIssuer == null ? string.Empty : ",i=" + foreignIssuer;
private static string CodeSuffix(Exception ex)
{
var code = TransportErrorCodes.InnermostNativeCode(ex);
return code == null ? string.Empty : "(" + code + ")";
}
private static void AppendHeader(StringBuilder sb, string name, string value)
{
if (!string.IsNullOrEmpty(value))
sb.Append(name).Append(": ").Append(HeaderValue(value)).Append("\r\n");
}
/// <summary>ASCII only, no line breaks — a header value must never split the request.</summary>
private static string HeaderValue(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
var sb = new StringBuilder(value.Length);
foreach (var c in value)
sb.Append(c >= 0x20 && c < 0x7F ? c : '?');
return sb.ToString();
}
}
}
@@ -3,24 +3,24 @@ using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Diagnostic probe that asks the local key-storage provider to produce three RSA signatures
/// over a fixed payload — PKCS#1 v1.5 SHA-256 (legacy, what every TPM 2.0 supports),
/// PSS SHA-256, and PSS SHA-384 (what modern Schannel prefers for TLS 1.2/1.3 client-auth
/// on Windows 11 25H2+).
/// PSS SHA-256, and PSS SHA-384 (what Schannel prefers for TLS client authentication, and the
/// only RSA signature TLS 1.3 allows).
/// <para>
/// The probe exists for one reason: to distinguish a generic <c>SecureChannelFailure</c>
/// from the specific case where a TPM-backed cert is silently filtered out of TLS client-auth
/// because its firmware can't do PSS. On 2015-era Infineon TPM 2.0 chips (Surface Book 1
/// SLB 9665 is the canonical example) PKCS#1 succeeds but PSS reports
/// <c>"The requested salt size for signing with RSAPSS does not match what the TPM uses"</c>
/// or <c>"The requested operation is not supported"</c>. Schannel observes this during cert
/// pre-filtering, drops the cert, and the handshake then completes with an empty client
/// Certificate message — which the backend rejects, surfacing in .NET as the generic
/// <c>WebException.Status = SecureChannelFailure</c>.
/// from the case where a TPM-backed cert can be filtered out of TLS client-auth because its
/// firmware can't do PSS. On older TPM 2.0 firmware (Surface Book 1's Infineon SLB 9665,
/// Infineon 7.63 and STMicro 73.4 in 2017/18 business devices) PKCS#1 succeeds but PSS
/// reports <c>"The requested salt size for signing with RSAPSS does not match what the TPM
/// uses"</c> or <c>"The requested operation is not supported"</c>. Such a device can still
/// connect at another time (failed during setup, connected after it), so the result
/// classifies the failed start, not the device.
/// </para>
/// <para>
/// <b>Hot-path discipline:</b> this probe is intentionally NOT called at agent startup.
@@ -71,20 +71,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
providerName: providerName,
keySizeBits: keySizeBits,
pssSha256Error: pssSha256.ErrorMessage,
pssSha384Error: pssSha384.ErrorMessage);
pssSha384Error: pssSha384.ErrorMessage,
pssSha256ErrorCode: pssSha256.ErrorCode,
pssSha384ErrorCode: pssSha384.ErrorCode);
}
private static (bool Success, string ErrorMessage) TrySignData(
private static (bool Success, string ErrorMessage, int? ErrorCode) TrySignData(
RSA rsa, byte[] data, HashAlgorithmName hash, RSASignaturePadding padding)
{
try
{
var sig = rsa.SignData(data, hash, padding);
return (sig != null && sig.Length > 0, null);
return (sig != null && sig.Length > 0, null, null);
}
catch (Exception ex)
{
return (false, ex.Message);
// CryptographicException carries the NCrypt status as HResult
// (0x80290423 = the TPM's PSS salt size does not match, 0x80090029 = not supported).
return (false, ex.Message, ex.HResult);
}
}
@@ -122,6 +126,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// </summary>
public sealed class TpmPssProbeResult
{
/// <summary>Longest <see cref="ToDistressMessage"/> — the distress message cap.</summary>
internal const int MaxDistressMessageLength = 256;
public bool Pkcs1Sha256Works { get; }
public bool PssSha256Works { get; }
public bool PssSha384Works { get; }
@@ -129,12 +136,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
public int KeySizeBits { get; }
public string PssSha256Error { get; }
public string PssSha384Error { get; }
public int? PssSha256ErrorCode { get; }
public int? PssSha384ErrorCode { get; }
public bool IsProbable { get; }
/// <summary>
/// True iff PKCS#1 succeeded but PSS did not — the canonical "TPM firmware is too old
/// for modern Schannel" footprint. Distinguishes the TPM-PSS regression from generic
/// crypto failures (which would also fail PKCS#1).
/// for PSS" footprint. Distinguishes the TPM-PSS case from generic crypto failures
/// (which would also fail PKCS#1).
/// </summary>
public bool IsTpmPssBroken => IsProbable && Pkcs1Sha256Works && !PssSha256Works;
@@ -154,7 +163,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
string providerName,
int keySizeBits,
string pssSha256Error,
string pssSha384Error)
string pssSha384Error,
int? pssSha256ErrorCode = null,
int? pssSha384ErrorCode = null)
{
Pkcs1Sha256Works = pkcs1Sha256Works;
PssSha256Works = pssSha256Works;
@@ -163,6 +174,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
KeySizeBits = keySizeBits;
PssSha256Error = pssSha256Error;
PssSha384Error = pssSha384Error;
PssSha256ErrorCode = pssSha256ErrorCode;
PssSha384ErrorCode = pssSha384ErrorCode;
IsProbable = true;
}
@@ -176,23 +189,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
public static TpmPssProbeResult Unprobable(string reason) => new TpmPssProbeResult(reason);
/// <summary>
/// Compact, distress-message-friendly summary (under 256 chars). Caller passes this to
/// <c>DistressReporter.TrySendAsync</c>.
/// Language-neutral summary for the distress message, codes instead of the localized
/// error texts: <c>pkcs1=ok pss256=fail(0x80070423) pss384=fail(0x80090029)
/// tlsp=tls12,-,cc403 sch=none prov=PCP key=2048</c>. <paramref name="tlsCode"/> is the
/// native code of the failed HTTP-stack handshake (<see cref="TransportErrorCodes"/>),
/// <paramref name="tlsProbe"/> the <see cref="TlsHandshakeProbe"/> token,
/// <paramref name="schannelEvents"/> the System-log finding (<see cref="SchannelEventReader"/>);
/// each is left out when null. At most <see cref="MaxDistressMessageLength"/> chars.
/// </summary>
public string ToDistressMessage()
public string ToDistressMessage(string tlsCode = null, string schannelEvents = null, string tlsProbe = null)
{
var sb = new StringBuilder();
sb.Append("provider=").Append(ProviderName);
if (KeySizeBits > 0) sb.Append(" keySize=").Append(KeySizeBits);
sb.Append(" pkcs1=").Append(Pkcs1Sha256Works ? "ok" : "fail");
sb.Append(" pssSha256=").Append(PssSha256Works ? "ok" : "fail");
sb.Append(" pssSha384=").Append(PssSha384Works ? "ok" : "fail");
if (!PssSha256Works && !string.IsNullOrEmpty(PssSha256Error))
{
sb.Append(" err256=").Append(PssSha256Error);
}
const int maxLen = 240;
return sb.Length <= maxLen ? sb.ToString() : sb.ToString(0, maxLen);
sb.Append("pkcs1=").Append(Pkcs1Sha256Works ? "ok" : "fail");
AppendSignOutcome(sb, " pss256=", PssSha256Works, PssSha256ErrorCode);
AppendSignOutcome(sb, " pss384=", PssSha384Works, PssSha384ErrorCode);
if (!string.IsNullOrEmpty(tlsCode)) sb.Append(" tls=").Append(tlsCode);
if (!string.IsNullOrEmpty(tlsProbe)) sb.Append(" tlsp=").Append(tlsProbe);
if (!string.IsNullOrEmpty(schannelEvents)) sb.Append(" sch=").Append(schannelEvents);
sb.Append(" prov=").Append(ShortProviderName(ProviderName));
if (KeySizeBits > 0) sb.Append(" key=").Append(KeySizeBits);
return sb.Length <= MaxDistressMessageLength ? sb.ToString() : sb.ToString(0, MaxDistressMessageLength);
}
private static void AppendSignOutcome(StringBuilder sb, string key, bool works, int? errorCode)
{
sb.Append(key);
if (works) sb.Append("ok");
else if (errorCode.HasValue) sb.Append("fail(").Append(TransportErrorCodes.Hex(errorCode.Value)).Append(')');
else sb.Append("fail");
}
internal static string ShortProviderName(string providerName)
{
if (string.Equals(providerName, "Microsoft Platform Crypto Provider", StringComparison.OrdinalIgnoreCase)) return "PCP";
if (string.Equals(providerName, "Microsoft Software Key Storage Provider", StringComparison.OrdinalIgnoreCase)) return "KSP";
if (string.IsNullOrEmpty(providerName)) return "(unknown)";
return providerName.Length <= 32 ? providerName : providerName.Substring(0, 32);
}
}
}

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