Commit Graph

2516 Commits

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:45:34 +02:00
Oliver Kieselbach c41c2327ab fix(security): validate enrollment summary branding image URL (https-only, no quotes); launcher drops unsafe URLs instead of interpolating them into the dialog command line
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2.0.1434
2026-08-29 23:15:07 +02:00
Oliver Kieselbach 83effea592 fix(security): hosted diagnostics upload SAS is create-only and bound to the requesting session; existing blob => 409
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:13:00 +02:00
Oliver Kieselbach 1a4a37b77a fix(web): hardware whitelist editor splits typed comma lists into entries ("Dell*, *" stays allow-all); only insights buttons neutralize ','
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:56:37 +02:00
Oliver Kieselbach f56851fc9f fix(security): hardware whitelist — one added entry is always one pattern; server rejects malformed CSV
The tenant hardware whitelist is stored as a CSV and split on ',' at
enforcement time. Manufacturer/model strings in the Rejected-Hardware
insights come from unauthenticated distress signals, so a value like
"Dell Inc.,*" added via "+ Mfr" appended a standalone "*" and silently
turned the manufacturer gate into allow-all.

- Web: shared hardwareWhitelist helper; "+ Mfr"/"+ Model" and the manual
  editor neutralize ',' to the single-char wildcard '?' so a value can
  never split the list.
- Backend: TenantConfigValidation.ValidateHardwareWhitelist in ValidateModel
  (PUT + patch) rejects blank items, control chars, over-long/too many entries.
- Shared: ParseWhitelist trims entries so enforcement matches the editor view.
- Tests: 8 xUnit + 5 vitest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:51:55 +02:00
Oliver Kieselbach cbe410cbbc fix(security): reject path-shaped sessionId/tenantId in session-report blob names; BlobNameGuard at every GetBlobClient sink
SubmitSessionReport built the report blob name from the raw route sessionId. Route values are
percent-decoded, so an encoded '/'+'..' reached Azure.Storage.Blobs, which keeps separators, and
System.Uri collapsed the dot segments into a write outside the session-reports container.

- SubmitSessionReport / SubmitDiagFilesReport: 400 unless sessionId/tenantId are GUIDs
- SessionReportService: EnsureValidGuid at entry of both submit paths
- New Security/BlobNameGuard (flat-name invariant) applied in SessionReportService,
  SessionReportDiagnosticsArchiveCopier (pre-check + upload) and the GA download function
- Tests: BlobNameGuardTests, copier rejects non-flat destination without storage calls

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:51:13 +02:00
Oliver Kieselbach 43ddc60111 fix(security): enforce tenant suspension in PolicyEnforcementMiddleware, not only auth/me
TenantConfiguration.Disabled/DisabledUntil was checked solely in AuthFunction.BuildAuthResult
(GET /api/auth/me), so a direct REST caller with a valid token kept full read/write access for
the whole suspension window and during an offboarding wipe. DecideAsync now denies 403
TenantSuspended when the caller's home tenant or the request's target tenant is currently
disabled (cached, side-effect-free config read). GA/GlobalReader bypass to administer the
tenant; device/anonymous routes keep their SecurityValidator gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:34:29 +02:00
Oliver Kieselbach 82b26b4ab9 fix(security): HTML-encode domain in transactional mails; freeze DomainName on config PUT, validate first-login seed
Welcome/farewell mails rendered DomainName unencoded while the full-model
config PUT persisted it verbatim, letting a tenant admin ship attacker HTML
in a signed product mail to an arbitrary recipient. Encode at Render() and
the built-in builders, preserve DomainName from the stored row on PUT for
every caller, and only seed a strict DNS host name at first login.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:33:52 +02:00
Oliver Kieselbach 1733f4fd48 fix(agent): SignalIngress worker re-entrancy — never block the single consumer on its own post
EffectRunner posts ClassifierVerdictIssued from inside DecisionStepProcessor.ApplyStep,
i.e. on the SignalIngress worker thread. With the bounded channel (256) full, Post's
back-pressure slow path blocked the channel's only consumer as a producer: nothing could
ever drain, every collector Post blocked behind it, and the whole decision/monitoring
pipeline froze silently until the max-lifetime watchdog.

Post now detects a call from the worker thread itself and routes the item to a
worker-local follow-up queue that WorkerLoop drains (iteratively, ahead of the channel)
right after the current item. Same processing path — ordinals, SignalLog, emit,
reduce, ApplyStep — just never blocking. Worker posts are accepted during the Stop
drain too, since the verdict belongs to the step being finished. ReentrantPostCount
exposed for observability/tests.

Tests: full channel + worker post releases a blocked foreign producer and processes the
verdict before the next channel item (fails against the previous code by hanging);
follow-up chain drained in order without recursion; verdict posted during Stop drain is
not lost. Docs: agent/decision-engine.md, agent/overview.md, log.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2.0.1433
2026-08-29 22:11:25 +02:00
Oliver Kieselbach fc46b4c78e feat(security): session-owner binding stage 1 (shadow) + dormant agent session rotation
Agent endpoints authenticated per tenant but authorized session writes on a caller-supplied
session id. Sessions rows now carry the creating device identity (cert thumbprint + Intune
device id from the CN, or bootstrap code + serial) in primary-only Owner* columns.
SessionOwnershipPolicy evaluates register / telemetry / error-report requests against it;
SessionOwnerBindingObserver records the outcome on the request row, as a warning and as a
throttled SessionOwnerMismatch ops event. Nothing is rejected in this stage.

Agent: errorCode parsing on 401/403, one-shot session rotation on session_owner_mismatch
without feeding the auth-failure tracker, spool drop, agent_started.sessionRotated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2.0.1432
2026-08-29 21:32:24 +02:00
Oliver Kieselbach c18de5d76c fix(security): drop client-writable certificate header; bootstrap routes token-only, fail-closed
SecurityValidator accepted certificate bytes from X-Client-Certificate when X-ARR-ClientCert was
absent. That header is client-writable, and certificate content alone carries no proof of private-key
possession — the TLS handshake does. On platform-cert-enforced routes the fallback was unreachable,
but /api/bootstrap/* is excluded from the platform cert requirement and an EMPTY X-Bootstrap-Token
passed the function gate (Headers.Contains) while the validator treated it as no token and fell
through to the certificate stage, where the fallback header was parsed (live-verified 2026-08-29).

- Only X-ARR-ClientCert is read; the platform strips inbound copies even on excluded paths.
- IsBootstrapRoute (path prefix mirroring the exclusion entry): missing/empty token or unwired
  BootstrapSessionService => 401 before the certificate stage.
- GetBootstrapToken treats an empty header as absent; the three bootstrap functions use it.
- Tests: BootstrapRouteFailClosedTests. Docs: client-cert-tenant-binding.md transport section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 20:04:04 +02:00
Oliver Kieselbach 250cc667dd fix(web): show full UPN in admin feedback list instead of a 200px truncation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 19:58:08 +02:00
Oliver Kieselbach d3261fb890 feat(ops): ops events for Collect Logs usage and diagnostics-upload enablement
CollectLogsQuickConfigEnabled fires when a tenant admin switches on Hosted
diagnostics upload through the session Collect Logs quick-config dialog
(PUT ?intent=collect-logs -> write source portal-collect-logs); every other
enable/disable path emits DiagnosticsUploadEnabled/Disabled (PUT, PATCH,
revert, MCP). SessionActionQueued records portal-queued server actions.
DiagnosticsUploadConfigChange is the single definition behind the
diagnosticsUploadConfigured feature flag and the flip detection. Event types
dual-registered in the OpsAlertRules catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2.0.1431
2026-08-29 19:30:20 +02:00
Oliver Kieselbach b3bbcf1300 fix(agent): rotate_config applies the fetched config; diagnostics skip gates report their reason
rotate_config only stored the fetched config in RemoteConfigService and never
merged it into the running AgentConfiguration, so the portal quick-config flow
(enable diagnostics upload, then collect) ran the diagnostics gate against the
stale mode=Off and failed with a generic diagnostics_upload_failed 4 ms later.
The action now runs the same ApplyFetchedConfig path as the startup fetch and
reports config_fetch_failed when the live fetch fell back to cache/defaults.
Skipped uploads return a DiagnosticsUploadResult.Skipped with the gate name
(diagnostics_skipped_mode_off / _not_configured / _onfailure_success) instead
of null, so server_action_failed names the actual reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 19:30:00 +02:00
Oliver Kieselbach a306cd9e40 feat(security): resolve admin identity bindings automatically at grant time; per-tenant bindings panel
Granting a cross-tenant role is "type the UPN" again. AdminIdentityResolver derives the home
tenant + object id from sign-in history (UserActivity: both values came from validated tokens,
most recent sign-in wins), falls back to the UPN domain of an onboarded tenant (tid only, oid
pinned on first sign-in), and never guesses when a UPN was seen under two tenants. Only a
never-seen domain yields 422 HomeTenantUnresolved, on which the portal shows a home-tenant picker.

- IMetricsRepository.GetSignInIdentitiesByUpnAsync (filtered UserActivity scan, grant-time only)
- Grant endpoints: homeTenantId/objectId optional overrides; bindings dropped from the list bodies
- Web: identity fields and binding pills removed from the delegated / tenant-group forms;
  new collapsed "Identity bindings" panel in Tenant Management -> Edit Tenant Configuration
  (bindings homed in that tenant; inline edit / remove via global/identity-bindings)
- Tests: AdminIdentityResolverTests; grant validation now accepts an absent home tenant

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 19:21:47 +02:00
Oliver Kieselbach 8ec8524252 fix(security): bind platform and delegated roles to the immutable Entra tid+oid, not the UPN string
GlobalAdmins, DelegatedAdmins and TenantGroupAssignments resolved their role from the
upn/preferred_username claim alone while the API accepts tokens from every Entra tenant, so a
genuine token minted in a foreign tenant (re-registered domain) or by a recycled UPN in the home
tenant inherited platform admin.

- New AdminIdentityBindings table: RK = UPN -> home TenantId + ObjectId; in TableNames.All,
  CriticalBackupTables and the full-restore-forbidden auth set; offboarding wipes bindings homed
  in the offboarded tenant
- AdminIdentity (upn + tid + oid) is what GlobalAdminService.GetGlobalRoleAsync and
  DelegatedAdminService.GetScopeAsync resolve on; order row -> binding -> Pro gate, fail-closed
  (no oid / no binding / tid or oid mismatch => no role), mismatches logged at Warning
- Object id pinned on the first sign-in from the bound tenant (ETag-conditional, verified against
  the stored winner); a foreign-tenant token can never claim the pin
- Grants (auth/global-admins, global/delegated-admins, tenant-group assignees) require
  homeTenantId (+ optional objectId) and answer 409 instead of re-homing a bound UPN;
  GET/PUT/DELETE global/identity-bindings is the audited rebind surface
- RequestContext.ObjectId; throttle identity prefers oid so shared UPN strings across tenants no
  longer collide in one rate-limit bucket
- Web: home tenant / object id fields on the grant and assign forms, per-UPN binding pill
- Tests: binding verdict matrix + race semantics, middleware-level denials for foreign-tenant and
  recycled-UPN tokens; OKF doc docs/backend/admin-identity-binding.md

Legacy role rows are inert until their binding is seeded - seed before deploying the backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2.0.1430
2026-08-29 18:35:47 +02:00
Oliver Kieselbach c4be6d4169 fix(security): block javascript:/data: URLs in analyze-rule relatedDocs (stored XSS)
Author-controlled relatedDocs[].url was rendered verbatim as <a href> on the session
detail, diagnosis and analyze-rules pages and never validated server-side, so a
Tenant Admin could store a javascript: URL that executes in the portal origin for
any viewer — including cross-tenant Global/MSP admins.

- Web: safeHttpUrl() guard (absolute http/https only) at all four render sites;
  anything else renders as plain text.
- Backend: ValidateRelatedDocs on Create/Update — blank URLs dropped, non-http(s)
  URLs rejected with 400.
- Tests on both sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 17:55:50 +02:00
Oliver Kieselbach d558b94cf6 updated commands 2026-08-29 17:24:45 +02:00
Oliver Kieselbach 9b4222e729 fix(ime-archive): verify MSI ProductVersion and walk primary/secondary/hotfix hosts before archiving
Microsoft serves IME from several versionless hosts carrying different rings at once
(secondary = 1.104.102.0 while hotfix = 1.105.103.0), so "new version == what the
canonical URL serves" filed a 1.104 package under 1.105.103.0. The archiver now spools
each candidate, reads ProductVersion with a managed CFB/MSI reader (Linux host, no
msiexec) and uploads only a match; no match ends as Failed:VersionMismatch. A later
sighting with a version-authoritative CSP URL re-queues an unarchived version at most
once per 24 h (MsiArchiveUpdatedAt + Queued stamp).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 17:23:05 +02:00
Oliver Kieselbach cf76c4338f feat(vuln): manual "Refresh NVD Cache" and "Sync EPSS" with their own last-sync lines
The admin Data Sync Status only offered KEV and MSRC; the NVD stale-row refresh and the EPSS
re-score ran on the nightly timer alone. Both walks move out of the timer into
VulnerabilityCacheRefreshService (singleton, one in-flight slot per walk) so the buttons and the
nightly run share one code path and stamp the same timestamps (NvdCacheLastRefreshUtc — only for
a walk that reached every stale row; EpssLastSyncUtc). POST vulnerability/sync-nvd starts the
minutes-long, rate-limited NVD walk in the background and answers 202 (409 while running); the
page polls sync-status (nvdRefreshRunning, nvdLastRun) every 10 s meanwhile. POST
vulnerability/sync-epss runs synchronously and returns the counts. The timer skips a slot a
manual run holds instead of queueing a duplicate. Shared manifest regenerated for the two new
AdminConfiguration fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 11:57:55 +02:00
Oliver Kieselbach a6184f165b feat(vuln): FIRST EPSS as fourth source, act/attend/track priority, CVSS vector; never cache a refused NVD answer
EPSS: EpssApiClient (api.first.org, 100 ids per batch, unauthenticated, fail-soft). Score, percentile
and date live on the cached CVE — one batch per CPE fill, every cve_* row re-scored by the nightly
sync, only changed rows rewritten and with their original CachedAt. CveIndex carries EpssScore (max,
absent when unscored) and Priority; TopCve exposes both. CvePriority: act = CISA KEV, attend =
EPSS >= 0.1 or CVSS >= 9.0, track = rest — riskLevel/overall_risk semantics unchanged.

NVD throttle: Polly already retried 429s; the defect was that a refused answer came back as an
empty response and was cached as "0 CVEs" for seven days (also the negative auto-resolve cache).
NvdFetchResult separates answered from refused; nothing persists a refusal; a 429 opens a
process-wide cooldown (Retry-After capped at 120 s, else 30 s) so a run fails fast instead of
paying a retry chain per software item; hard-expired rows keep serving meanwhile. The NVD->cache
projection and the row builder are now single functions shared by the live fill and the sync.

CVSS v3 vector string persisted and shown. Portal: EPSS column + pill, priority pill, vector line;
exposure panel pills. MCP tool descriptions and the CVE-audit prompt name the fields. Terms
attribution + privacy page updated in the same change. OKF doc backend/vulnerability-correlation-sources.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 11:07:31 +02:00
Oliver Kieselbach d3918cb740 feat(mcp): log the protocol era per request (mcp_request line, MCP_TOOL_LOGGING-gated)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 09:13:00 +02:00
Oliver Kieselbach 15722a3885 fix(mcp): log the CIMD success path so metadata-document logins are positively visible
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 09:06:09 +02:00
Oliver Kieselbach 1a4e13f0e9 feat(mcp): implement MCP spec 2026-07-28 (SDK v2 dual-era handler) and Client ID Metadata Documents
Wire protocol: replace @modelcontextprotocol/sdk 1.30 with @modelcontextprotocol/server + /node 2.0.0
(zod 4). One per-request factory (mcp-server-factory.ts, extracted from index.ts) serves both eras
through mcp-http.ts: 2026-07-28 clients via createMcpHandler (legacy:'reject', JSON response mode,
server/discover, Mcp-Method routing), 2025 clients through the existing stateless transport with
enableJsonResponse — routed on the SDK's own isLegacyRequest predicate, because the built-in stateless
fallback would answer every current client with SSE and lose the gzip win. Capabilities now declare
listChanged:false explicitly; cacheHints stamp ttlMs/cacheScope:'private' on discover/list results
(5 min) and resource reads (60 min) — the catalog is role-dependent, so never public.

Authorization: advertise client_id_metadata_document_supported; an https client_id is a Client ID
Metadata Document resolved by cimd.ts (URL-shape checks, SSRF gate on every resolved address before any
request, no redirects, 5 s / 16 KB caps, exact client_id match, bounded redirect_uris, 10-60 min cache).
The requested redirect_uri still has to pass the dynamic-registration allowlist; HMAC-signed DCR
client_ids remain the fallback. Field limits moved to oauth-limits.ts (no import cycle).

Tests: mcp-http.test.ts drives the real v2 client over loopback HTTP in both eras and pins role parity;
cimd.test.ts / oauth-cimd.test.ts cover recognition, SSRF, validation, cache and the router paths.
Package 1.5 -> 1.6. OKF doc docs/mcp/protocol-eras-and-cimd.md; mcp-oauth-flow.md updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 08:43:58 +02:00
Oliver Kieselbach c39092bf72 ci(mcp): image tag unique per run so docs-only redeploys roll a new revision
A redeploy at an unchanged code sha (the documented way to refresh the
baked-in search_docs corpus) built an image with the identical tag;
az containerapp update saw the same image reference and created no
revision, so the verify step timed out against the old build (run
33215583943). Tag = sha-run_number; build commit/version stay separate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 00:17:04 +02:00
Oliver Kieselbach 494744ad3f feat(web): progress portal follows Windows 365 Cloud PC first-connect enrollments
Cloud PC users know the device name the Windows App shows, and the lookup
already accepts device names, so there is no reason to exclude them. The
monitored enrollment starts at first connect in Account Setup (v1 rail,
phases 4 -> 5 -> 7; device phases 1-3 run headless and are never declared),
so the Cloud PC layout lists only the steps the user can actually watch:
First sign-in / Account setup / Installing apps / Finalizing setup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 00:05:59 +02:00
Oliver Kieselbach a68c096a26 feat(web): progress portal follows every enrollment scenario (Device Preparation, self-deploying, pre-provisioning)
The end-user page hardcoded the seven Autopilot v1 ESP phases and gated its live
app panel on currentPhase 3/5 — phases a Device Preparation session never
declares — so DevPrep users saw 0 % and no app progress until Finalizing.
Steps now derive from the shared resolvePhaseLayout (v2 layout, skip-user
layout), the v2 app step is promoted from app-event evidence, and the status
vocabulary maps AwaitingUser / parked pre-provisioned devices to a waiting
state and Incomplete to neutral — only Failed stays red. Cloud PCs are named
as unsupported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 21:30:04 +02:00
Oliver Kieselbach bf156cf862 fix(backend): CMTrace skew tripwire ignores backlog-replay bursts via per-sample grid conformity
CmTraceTimeSkewRegression fired 7x in its first soak week; every checked case
was a relaunched agent re-tailing the IME log and uploading one burst holding
most of the session's IME samples with a continuum of ages whose median hit the
15-minute grid by chance. The tripwire now also requires >= 80 % of the IME
deltas (relative to the other side's median) to individually sit within 2 min of
some grid multiple; era-mixing regressions still fire. Fraction is carried in the
ops event message and details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:42:51 +02:00
Oliver Kieselbach 0eaa3527f9 feat(validation,web): device association validator generally available
Microsoft released Windows Autopilot device association on 2026-08-25. The
DevPrep association lookup leaves shadow mode and becomes an accepting method
of the device-validation gate (after Autopilot and Corporate Identifier, before
the Cloud PC fallback; ValidatedBy = DeviceAssociation, transient -> 503). The
toggle is no longer Global-Admin-only: backend PUT revert and GaOnlyFields
dropped, settings toggle shown to every tenant admin under Device Preparation
with the shared consent rule. Landing hero carries a release pill linking to
the docs; dashboard tip added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 21:20:57 +02:00
Oliver Kieselbach 5055db2de1 feat(rules): detect installer-mutex app failures (0x80070652) and SWG-blocked diagnostics uploads
Two analyze rules born from the pilatus-aircraft diagnosis (2026-08-27):

- ANALYZE-APP-017 "App Install Blocked by Another Installer (0x80070652)":
  fires on app_install_failed with hresultFromWin32 -2147023278
  (ERROR_INSTALL_ALREADY_RUNNING); confidence boosts for MSI exit code
  1618 and for an ESP failure settling with the same code. The 08-26
  burst failed 16 sessions deterministically at ~21 min (IME's fixed
  msiexec-busy retry budget) right after a preceding installer left the
  Windows Installer mutex held - previously only generic
  "Enrollment Failed" fired, naming no cause.
- ANALYZE-NET-001 "Diagnostics Upload Blocked by Web Security Gateway":
  fires on diagnostics_upload_failed answered by an HTML block page
  carrying a recognizable security-product marker (McAfee/Skyhigh,
  Zscaler, Netskope, ...). OnFailure diagnostics are lost exactly on the
  sessions that need them; plain HTML errors without a vendor marker
  (stale/stopped endpoint) stay excluded on purpose.

Both validated via MCP validate_rule (0 errors) and dry-run with
test_analyze_rule: fire on the burst session (confidence 100 / 90),
silent on a timeout-shaped failure and on a succeeded session that
carried unrelated app_install_failed events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 19:40:17 +02:00
Oliver Kieselbach 040e411958 feat(classification,calibration): rule 5a completed-assumed, max-lifetime grace skip, drift-radar floors
First real calibration read (2026-08-27, four days instrumented) drove three
data-backed changes:

- Rule 5a: past grace (or with the agent provably gone), Device Setup done +
  real-user desktop + zero app_install_failed reconciles to Succeeded
  "completed (assumed)" (r5_assumed). r5-Incomplete devices re-enrolled at
  2.6% - the succeeded background, not the ~29% failure level - and the
  sampled desktop half had clean app records. No desktop or any failed app
  stays Incomplete; rule 4 remains the immediate desktop+Hello reconcile.
- Rule 5 skips the AwaitingUser grace once either max-lifetime watchdog shape
  (enrollment_failed/agent_timeout, agent_shutting_down/max_lifetime) is in
  the stream: all five observed maxlife:r5_awaiting parks expired unhealed
  while live-agent parks upgraded 7/26. The maxlife ingest appends its
  trigger event to the read stream so the fact is never missed; late
  telemetry still heals a terminal verdict. WhiteGlove rule 1b keeps its
  park (its one episode healed).
- Drift radar: MinWindowHits raised to 10 (a 3-hit episode survived as
  noise), per-path share regression now needs >=5 baseline hits (new or
  renamed vocabulary cannot "regress"), and rule-shaped legacy:* paths count
  into the silence/classifier-verdict group sums on both sides of the
  2026-08-23 instrumentation cutover - dissolving the lift-124 silence
  artifact. Episodes below the fire floors clear themselves.

Backend only; the new path appears in the calibration matrix dynamically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 18:10:19 +02:00
Oliver Kieselbach 42c45cf567 feat(admin,backend): find a tenant by its notification or contact email
A delivery log names only the recipient, never the tenant the welcome mail was
for. The tenant console's search now matches the notification and contact
address as well as domain and tenant id; the quoted exact mode is unchanged, and
no other address is searchable (an admin UPN never receives platform mail and
would only widen the PII surface of the list).

The addresses live in PreviewWhitelist, not on the tenant config, so a new
GA/Reader-only GET /api/preview/notification-emails returns them as one map --
a single cross-partition scan instead of a point read per tenant. Both sides
join on the lowercased tenant id (address rows carry the JWT tid, a config
PartitionKey's casing is not guaranteed) and matching is case-insensitive,
because stored addresses keep the casing the user typed.

The map is best-effort in the portal: a failed read costs only the
search-by-address, never the tenant list. Addresses stay out of the list rows --
they are a search key, and remain visible in the tenant editor.

Match rules moved out of the component into tenantSearch.ts with unit pins, and
a catalog test pins that the plural route does not resolve to the per-tenant
entry one character away (which would demand a tenantId the route does not
carry, and hand a delegated caller a rescue path into the whole map).

Deploy: backend before web.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 15:41:05 +02:00
Oliver Kieselbach b31256c748 fix(activation): capture the welcome-mail address instead of redirecting past it
The activation page held the only address field in the product, and its redirect
effect fired on the first poll after auto-approve — roughly 60 seconds after
signup. Anyone who had not typed by then lost the field, no notification-email row
was ever written, and TrySendWelcomeEmailAsync deferred a mail that nothing would
send later. Nine of twenty-one August signups got a welcome mail; the last one
went out on the 21st.

It stayed hidden because every "not sent" branch logged at Information, and worker
application logs never reach Application Insights below Warning.

- Activation page holds the redirect until the address is saved or deliberately
  skipped, and switches to an "Activated" state while it waits. Visitors who
  arrive at an already-activated tenant are passed straight through as before.
- Recipient resolution falls back from the activation-page address to the tenant
  contact address. The signup admin UPN is deliberately not a fallback: those
  accounts often have no mailbox and the bounces cost sender reputation.
- WelcomeEmailSent / WelcomeEmailSkipped / WelcomeEmailFailed ops events make
  every outcome visible, dual-registered in OPS_EVENT_TYPES.

Three defects in the same path, found while fixing it:

- The sent-marker was consumed before the send and never released on refusal, so
  one provider hiccup suppressed a tenant's welcome mail permanently. It now
  records delivery, not an attempt.
- A missing provider key logged at Debug, hiding the one condition that stops
  every mail at once. Now Warning.
- The Global Admin resend endpoint reported success regardless of what the
  provider answered. It is the manual fallback for exactly this failure, so it now
  returns 502 and leaves the marker free when the message is refused.

The welcome mail is also no longer fire-and-forget: a discarded Task can be torn
down when the invocation ends, and its result is what the ops event reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:32:52 +02:00
Oliver Kieselbach 51f2dabc8c feat(web): explain the blank WiFi signal in the network timeline
The timeline showed WiFi segments with an SSID and no signal, and an "Avg WiFi
signal" stat that simply vanished — on a location-gated device that reads as a
product defect rather than the Windows 24H2 gate that caused it.

No agent change was needed: buildSegments already merges every wifi_signal_info
into the WiFi segment covering its timestamp, so wifiDataLimitedReason rides in
with the sample that was already being read.

- NetSegment.dataLimitedReason, filled from the sample.
- Segments that never got a sample of their own (a mid-session reconnect emits
  no wifi_signal_info) inherit it: the gate is a device-level setting and cannot
  flip during an enrollment. A segment WITH a real reading is never overwritten
  — pinned by a test.
- Tooltip: "Signal: unavailable — Location services off on this device" where
  the percentage would be.
- Stats row: "Avg WiFi signal: n/a (Location services off)" instead of dropping
  the row.

network_state_change is deliberately left alone. That event has never carried
signalPercent or radioType — NetworkChangeDetector collects them into its
snapshot for change detection, but only before_/after_wifiSsid reaches the
payload — so there is no absent field there for a reason to explain, and the
SSID it does carry is unaffected by the gate. Adding it would ship a field
nothing reads.

Verified: web tsc + eslint (0 warnings) + vitest 893/893.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v2.0.1429
2026-08-26 14:06:49 +02:00
Oliver Kieselbach 0f9a77c848 feat(agent,web,mcp): surface the 24H2 location gate instead of only logging it
A missing WiFi signal reading is indistinguishable from a bug unless the payload
says why, and the device-level location setting is not something an admin would
think to check. Since 058248d9 the SSID survives the gate via the WinRT tier —
but signal, radio and channel silently vanish with no explanation anywhere.

When the WinRT tier wins AND the native tier reported rc=5, wifi_signal_info now
carries wifiDataLimitedReason = "location_services_off" and the event message
gains "— signal unavailable, Location services off", so the reason reads straight
off the timeline. The portal's WiFi card matches that literal and names the
Intune setting that restores the full payload.

The claim is evidence-only: any other native failure (no WLAN service, no
connected interface) leaves the field null rather than blaming a privacy setting
we did not observe. IsLocationGateDenied is the single predicate, pinned at its
boundaries (rc=5 yes; rc=50, rc=5023, rc=1062 no), and the literal is pinned as
the published agent/portal/MCP contract.

New wifi_signal_info group in the MCP DEVICE_PROPERTIES_CATALOG, so a fleet can
be sized before anyone changes a policy:
search_sessions(deviceProperties: {
  "wifi_signal_info.wifiDataLimitedReason": "location_services_off" })

Backend is untouched — the event payload is opaque DataJson with no projection.
network_state_change and the network timeline deliberately do not carry the
reason yet; that is a second payload plus web change for no extra insight.

Verified: build 0 warnings, agent suite 2553/2553, web tsc + eslint (0 warnings)
+ vitest 889/889, MCP tsc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:58:31 +02:00
Oliver Kieselbach d89e3f0b48 fix(backend): cap vulnerability_report severity at Warning
A completed scan is a finding about the software inventory, not an
enrollment failure. Mapping the CVE risk onto Error/Critical made the
report read as a failed enrollment: the diagnosis page collected it as
failure evidence, MCP search lifted it on failure-intent queries, and the
timeline's Error filter filled up with clean sessions. critical/high now
map to Warning, everything else to Info; the CVE severity itself stays in
scan_summary and on the report panel's own scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:54:58 +02:00
Oliver Kieselbach 058248d95c fix(agent): read WiFi SSID via ungated WinRT API — 24H2 location gate
wifi_signal_info was missing on most Windows 11 24H2+ WiFi enrollments
(build 22631 3/3, 26100 37/178, 26200 43/95 over n=276 sessions since 08-01).
Not a parser or language problem: every event that exists carries a full SSID,
so the failure is binary at the reader level.

Since 24H2 Windows gates WlanQueryInterface with
wlan_intf_opcode_current_connection — the only opcode we use — behind
precise-location consent and returns ERROR_ACCESS_DENIED. The consent prompt
only fires for a process in the user's context outside System32, so the agent
(SYSTEM, session 0, before the OOBE privacy page) is denied silently, and it
resolves consent from the HKLM ConsentStore because the S-1-5-18 hive has none
— while the interactive user on the same machine reads the SSID fine via the
per-user NonPackaged value. That is why it tracks the image's location setting,
not hardware, driver or language.

netsh wlan hangs off the same gate, so the 2026-07 netsh fallback and its
multi-language labels never covered the case they were built for; more labels
cannot fix it.

- New WinRtWifiSsidReader: Microsoft's documented replacement
  WlanConnectionProfileDetails.GetConnectedSsid (ungated, SSID only), bound via
  the CLR WinRT projection — no winmd, no NuGet, no csproj change — the same
  mechanism OobeStateReader already uses from the SYSTEM context.
- New WifiInfoProvider owns the tier order (native -> WinRT -> netsh) that both
  collectors had duplicated.
- The step diagnostics added in 2026-07 were logged at Debug while the default
  level is Info, so they never reached a field log. The all-tiers-failed case is
  now a Warning that decodes rc=5 into the location-gate explanation.

Signal, PHY and channel have no ungated source and stay null on tier 2;
deriving a percentage from GetSignalBars would fabricate precision.

Verified: build 0 warnings, agent suite 2542/2542, interop struct sizes measured
live (532/604), and the full WinRT chain resolves and returns the SSID under
net48.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:27:36 +02:00
Oliver Kieselbach aab0a0c52c feat(admin): make report ID one-click copyable in report details
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:45:41 +02:00
Oliver Kieselbach f22feb71ff feat(web): exclude events from the session timeline search
The timeline search box now tokenizes the query instead of treating it as a
single substring: terms are AND-ed and a leading minus hides its matches
(e.g. -app_install_progress), following the convention every other search box
uses. Quoting keeps a term literal, so an exit code like "-1" stays searchable.

The negation is decided at a token's first character, before a quote is
consumed, and terms match the same fields as the positive search (event type,
message, source) joined by a newline so a term can never match across a field
boundary. The event counter follows the search and an active exclusion is
named in a pill next to the severity filters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 14:33:05 +02:00
Oliver Kieselbach c92fe7c2a0 commands: bump to 3061dca (cert tenant binding queries in backend-logs + session-debug) 2026-08-25 12:56:29 +02:00
Oliver Kieselbach b7cb75e442 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-25 12:50:12 +02:00
Oliver Kieselbach d6895f2c7e feat(security): enforce cert tenant binding — reject cross-tenant certificates
The Intune roots the chain is pinned to are shared by every Intune tenant, so
a valid certificate only ever proved "issued by Intune to someone". Tenant
scoping rested on the device serial, which is printed on the chassis. A
Mismatch now returns 403.

Switched on against measured data, not a hunch: 35,684 requests across 32
tenants since the shadow telemetry landed, every one a Match, with the
Warning channel independently verified to be carrying other traffic so the
zero could not be a telemetry artefact.

Only Mismatch blocks, and the rule lives in one function so "what rejects a
request" has a single answer. ExtensionMissing, Unparseable and
RequestTenantNotAGuid mean "cannot tell" and are tolerated: the sample only
covers devices active in the window, so an older certificate or a
sovereign-cloud CA could still lack the extension, and rejecting on absence
would lock those devices out for something outside their control.
ExtensionMissing is the candidate for later tightening and has its own test
pinning the tolerance, so removing it must be a decision.

Evaluation fails open on its own exception — a defect on our side is not
evidence of a cross-tenant certificate. The 403 body does not echo which
tenant the certificate belongs to; that pair goes to the rejection log.

Device binding (stage 3) is untouched: still shadow, still Global-Admin-only.

Backend 4638/4638, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 12:45:21 +02:00