Resolves GHSA-q3j6-qgpj-74h6 (path traversal via percent-encoded dot
segments) and GHSA-v39h-62p7-jpjc (host confusion via percent-encoded
authority delimiters). Transitive via @modelcontextprotocol/sdk -> ajv.
Lockfile-only change, no API surface impact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HealthCheckService now runs a fourth check (SignalR Quota) alongside Storage,
Processing, and Agent Binaries. Live read of ConnectionCount (max/1h) and
MessageCount (total since 00:00 UTC) via the same IAzureMonitorMetricsReader
that backs the maintenance-time Ops watcher - no cache; the data is already
3-5min stale from Azure Monitor's side, and a /health/detailed re-check is
expected to give fresh values during incident response.
Reuses MaintenanceService's internal classifier (CalculatePercent,
ClassifySignalRQuotaTier) so the watcher and the dashboard agree on the
80%/95% boundaries down to the floor-vs-ceil rounding.
Tier mapping: None -> healthy, Warning -> warning, Critical -> unhealthy.
Worst-of-both-metrics wins. PerformAllChecksAsync now uses a tri-state
combiner (any unhealthy / any warning / else healthy) so a Warning tier
correctly tints the OverallStatus bar yellow instead of red.
Fail-soft:
- SignalRResourceId unset -> Status="unknown" with "watcher not configured"
(excluded from OverallStatus combiner so dev/local stays healthy)
- Both metric reads null -> Status="unhealthy" pointing at the most likely
cause (Function App MI missing 'Monitoring Reader' on the SignalR resource)
- One read null -> "no data" for that row, normal classification for the other
Frontend unchanged: the existing /health-check Backend Services card loop
renders the new check with Connections, Messages, and Resource as Details.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces V1/V2-hardcoded build + bootstrap with a single parametric
pipeline that scales to V3+ without rewrites. Full architecture in
.claude/plans/v2-cutover.md.
Stable namespace (Bootstrap-side, never renamed): generic Bootstrap
script, agent owns its --install defaults so it works for any line.
Versioned namespace (per-line, SelfUpdater-side): isolated *-v{N}.*
blobs; SelfUpdater never crosses lines or touches stable.
Backend GetAgentConfig dispatches per X-Agent-Version-Major to
AdminConfiguration.GetAgentLine(int) -> LatestAgentV{N}* fields.
Future V3 = add field set + switch arm. TableConfigRepository.Map
falls back to legacy unsuffixed columns for transparent migration.
V1 SelfUpdater now reads version-v1.json (would have been clobbered
by the V2 cutover otherwise). V2 agent owns its own 600s TenantId-wait
default so the Bootstrap calls --install plain.
Tests: 3289 across 6 suites, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
session_searched fired on every dashboard mount with empty query, inflating
event volume ~3x and making unique-user counts unusable. Skip empty queries
and emit queryLength as a measurement instead of the now-redundant hasQuery.
signalr_reconnected lacked any latency signal. Capture performance.now() in
onreconnecting and emit downtimeMs in onreconnected so recovery quality
(not just existence) shows up in App Insights.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two notification-bell React contexts polled GET /api/notifications and
GET /api/global/notifications every 60s regardless of activity, generating
3017+618 = 3635 calls/24h with near-zero hit rate (notifications are emitted
sparsely). SignalR was already used app-wide for live session updates; the
bell was the lone hold-out on a pull model.
Switch to the same push model. Steady-state traffic drops ~86% and the bell
updates sub-second instead of within 60s.
Architecture (Option C+):
- Mount -> 1x HTTP fetch (cold-start hydration via existing GET endpoint)
- SignalR onreconnected -> re-fetch (covers the disconnect window)
- SignalR push -> live append/dismiss
- No periodic polling, no setInterval
Audience-tier security: TenantNotificationAudienceCatalog still drives
visibility (Member vs Admin), but enforcement moves into SignalR group
membership: tenant-{id}-notify-member is joined by every tenant member,
tenant-{id}-notify-admin only by Tenant Admins / Global Admins. The
AddToGroup function fail-closes with 403 on Member-tier joins to the
admin group, so Admin-tier notifications cannot leak via push.
Backend
- ISignalRNotificationService extracted; SignalRNotificationService
implements it. Resolves the long-standing test-mocking blocker
(the real service's constructor validates AzureSignalRConnectionString
and BuildServiceManager(); previously you could not unit-test
consumers). Tenant + Global NotificationService consume the
interface; the three legacy callers (Analyze, Ingest, VulnCorrelate)
still bind to the concrete class -- migrate per test need.
- Six new push methods: SendTenantNotification{,Dismissed,DismissedAll}
and SendGlobalNotification{,Dismissed,DismissedAll}. Tenant variant
routes to the audience-correct group.
- Tenant + Global service push after a successful repo write
(fire-and-forget; never throws into the caller).
- SignalRGroupHelper: parsing handles the two new -notify-{member,admin}
suffixes; new IsTenantNotifyAdminGroup predicate; group-name builders.
- SignalRAddToGroupFunction: explicit 403 when a non-admin attempts
to join the admin-tier group.
Frontend
- TenantNotificationContext: setInterval gone. useSignalR() to
join member-group always, admin-group iff isTenantAdmin || isGlobalAdmin.
Listens for tenantNotification (append + dedupe), tenantNotificationDismissed
(filter), tenantNotificationsDismissedAll (clear). onreconnected
triggers re-fetch.
- GlobalNotificationContext: same pattern against global-admins group.
- app/layout.tsx: SignalRProvider hoisted above the notification
providers so useSignalR() resolves inside them.
Tests
- SignalRGroupHelperTests (8): parsing for all four group formats
incl. new -notify suffixes, IsTenantNotifyAdminGroup predicate.
- TenantNotificationServiceSignalRPushTests (8): audience-correct push
on Create (Admin / Member / Unknown -> Admin fail-closed), no-push
on repo failure, no-push on DismissAll(count=0), no-push on empty
tenantId.
- FakeSignalRNotificationService: in-memory spy implementing the
new interface; replaces the unmockable real service in tests.
- AuthFunctionSideEffectTests + TenantNotificationServiceTests:
constructor signatures updated for the new SignalR dep.
Suites
Backend xUnit: 1161/1161 (was 1146, +15 net).
Web vitest: 259/259 (unchanged; existing notification-API +
gate tests still cover).
Solution build: 0 warnings, 0 errors.
Expected post-deploy reduction (24h):
GET GetTenantNotifications: 3017 -> ~50
GET GetGlobalNotifications: 618 -> ~10
POST AddToGroup: ~246 -> ~350-450 (two joins per tab)
Net bell load: -86%, sub-second live UX.
Disconnect tradeoff (accepted): if SignalR drops without reconnect
(rare -- withAutomaticReconnect retries 1/2/4/8/16/30s ... forever),
the bell shows the snapshot from the last fetch but no live updates
until next reload. The initial-3-retry give-up path of the SignalR
client still gets the user a hydrated bell because the HTTP fetch
runs independently on mount.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds toggles for SignalRConnectionsHigh, SignalRConnectionsCritical,
SignalRMessagesHigh, SignalRMessagesCritical to the Security category of
the OpsAlertRules section, so operators can route them to Telegram /
Teams / Slack via the existing alert-dispatch pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hourly TimerFunction queries Azure Monitor for SignalR ConnectionCount (max
over last hour) and MessageCount (sum since UTC midnight) and emits Warning
(>=80%) / Error (>=95%) OpsEvents when the free-tier limits (20 connections,
20k messages/day) are approached. Mirror of the cert-expiry pattern - global
TenantId=null events, dedup'd to one emission per EventType per UTC day.
Fail-soft: skips silently when SignalRResourceId app setting is unset, logs
warning on Azure Monitor auth/query errors. Function App's managed identity
needs 'Monitoring Reader' on the SignalR resource.
Also called from the manual maintenance trigger so 'Run Maintenance Now'
exercises every health check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
77fde56f hooked CheckEmbeddedCertExpiryAsync only into RunAllAsync (the 2h
timer). Manual triggers via POST /api/maintenance/trigger call RunManualAsync,
which is a different code path - so manual runs were silently skipping the
watcher and gave a false sense of "no alert means bucket is healthy". Mirror
the timer-path call inside the !aggregateOnly block so manual ops verification
exercises the watcher just like the scheduled run does.
Aggregate-only manual runs (date catch-up) still skip it, since they're for
backfilling historical metrics and don't need cert checks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Alert Rules section in the admin UI builds its rule grid from a
hardcoded OPS_EVENT_TYPES catalog. Without this update, the four
EventTypes added by f2257a4b would not appear as toggle rows, so the
operator could not subscribe Telegram/Teams/Slack to them despite the
backend already emitting the events.
Added under the existing Security category:
- EmbeddedCertExpiringSoon (set MinSeverity Warning)
- EmbeddedCertExpiringUrgent (set MinSeverity Error)
- EmbeddedCertExpired (set MinSeverity Critical)
- EmbeddedCertBundleEmpty (set MinSeverity Critical)
After deploy: admin opens Settings -> Alerts, toggles the 4 rows on,
sets MinSeverity per row, enables Telegram + Chat ID, saves. From the
next maintenance run any cert-expiry event matching the threshold pings
the configured providers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
77fde56f's per-cert alarm fires for every expiring cert independently. That
is wrong: when a Root or Intermediate has a fresh successor in the bundle
(e.g. intune-intermediate-mdm-2026.pem alongside intune-intermediate-mdm-2028
.pem), the older cert is just a chain-continuity helper for in-flight device
certs. Alarming on it would page the operator to "rotate" something that is
already rotated.
Refactor to bucket-level: per role (Root, Intermediate), pick the cert with
the MAXIMUM NotAfter and classify only that. Older expiring/expired
siblings are silent. Operator only gets paged when the freshest itself
nears expiry, i.e. when there is genuinely no successor yet.
Concrete behaviour change for current bundle (today 2026-05-08):
Roots: freshest is intune-root-2030.pem (1591d) -> silent
Intermediates: freshest is intune-intermediate-mdm-2028.pem (859d) -> silent
The expired intune-intermediate-mdm-2026.pem (-3d) no longer triggers a
spurious Critical because mdm-2028 has it covered.
Tests: 7 new EvaluateBucket cases (empty, single fresh, single soon, old+
fresh successor stays silent, old+soon successor fires on successor, all
expired falls back to freshest-expired, two future successors). Total 38
cert-related tests, full suite 1105/1105.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds proactive monitoring of the embedded Intune CA bundle so the operator
gets a Telegram ping months before agent mTLS would break. Bundle rotation
(re-exporting fresh PEMs from a corporate-enrolled device) is the only fix
and needs lead time - today's outage from 9af3de15 was rooted in exactly
this blind spot.
Hooks into the existing 2h MaintenanceService.RunAllAsync timer, iterates
the embedded roots + intermediates, and emits tiered OpsEvents on the
Security category:
daysUntilExpiry | Severity | EventType
-----------------+----------+----------------------------
<= 7 (or past) | Critical | EmbeddedCertExpired
<= 30 | Error | EmbeddedCertExpiringUrgent
<= 90 | Warning | EmbeddedCertExpiringSoon
else | - | (silent)
Plus a Critical EmbeddedCertBundleEmpty event if the loader returns no
roots at all (server misconfiguration / failed deploy).
Dedup is one event per cert per tier per UTC day, by querying today's
Security OpsEvents and matching on EventType + thumbprint extracted from
the Details JSON. Cold starts (Flex Consumption scale-to-zero) rebuild
the dedup index from the table, so repeated process restarts do not
multiply Telegram pings.
Operator action required to receive Telegram alerts: configure four
OpsAlertRules in admin UI - one per new EventType - with appropriate
MinSeverity. The OpsEvents themselves land in the table regardless,
visible in the Ops dashboard.
Tests: 23 new (tier-classifier boundary + dedup-key extraction + JSON
edge cases). Full suite 1098/1098 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intune-root-ca.pem and intune-intermediate-ca.pem are bit-for-bit duplicates
of intune-root-2030.pem and intune-intermediate-mdm-2026.pem (matching SHA1
fingerprints A1:97:D6:... and B7:E4:96:...). The post-9af3de15 loader globs
intune-root-*.pem and intune-intermediate-*.pem and dedupes by thumbprint,
so the duplicates were silently de-duped at load. Remove them now to make
the bundle self-explanatory: one PEM per real CA, name encodes its NotAfter.
Verified by running CertificateValidatorTests (8/8 incl. the real-device-cert
happy-path) which loads the bundle via the same glob path the runtime uses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Post-deploy of 9af3de15 surfaced 6 chain-validation failures from a single
agent stuck in a 10s retry loop. The current LogWarning only includes the
chain-status string ("unable to get local issuer certificate"), which is
not enough to triage whether the rejected cert is a forge attempt the new
pinning is correctly blocking, or a legit fleet device whose chain we
need to add to the embedded bundle.
Add LeafSubject (GUID-shaped DN for Intune device certs), LeafIssuer (CA
name), and existing Thumbprint to the warning. No PII is leaked - device
certs use opaque GUIDs and the issuer is a public CA DN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-applies 7e21591b after the chain-validation outage and adds the missing
trust anchors. The first attempt embedded only the 2030 root and the 2026
intermediate; current production device certs chain via the 2026 root +
the 2028 intermediate, so chain.Build() returned "unable to get local
issuer certificate" for every legitimate agent and broke enrolment.
What is different in v2:
- Bundle expanded with both Intune roots (exp 2026-08-12 and 2030-09-15)
and both currently-issuing MDM Device CA intermediates (exp 2026-05-05
and 2028-09-15). Real device certs chain via 2028-intermediate -> 2030-
root today; the 2026 generation is kept for any older device certs
still in flight.
- Loader now globs intune-root-*.pem and intune-intermediate-*.pem from
embedded resources and dedupes by thumbprint, so a future Intune root
rotation is a drop-in PEM with no code change. Legacy generic-named
files (intune-root-ca.pem, intune-intermediate-ca.pem) are duplicates
of the new ones and harmlessly de-dup at load.
- Adds a happy-path integration test against a real Intune-issued device
cert (public-key only, copied from a test VM). This is the regression
test that would have caught the v1 outage and gates any future bundle
drift in CI.
- Fixes a pre-existing TZ bug in the expiry check: X509Certificate2's
NotBefore/NotAfter return Local time, were being compared raw against
DateTime.UtcNow. Surfaced by the integration test on a CEST dev box;
unobservable in prod because Linux Flex Consumption runs in UTC.
Trust model is unchanged from v1: X509ChainTrustMode.CustomRootTrust
pinned to the embedded Intune roots, OS trust store ignored, fail-closed
when no roots are loaded. Self-signed-with-Intune-DN bypass remains shut.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with ConsentFlowStarted/Failed in the OpsEvents table so admins can
see whether repeated consent failures (AADSTS, redirect-mismatch, denied)
eventually resolved into a successful grant. New POST endpoint
config/{tenantId}/autopilot-device-validation/consent-success is called
from the post-redirect callback only after consent-status confirms
isConsented=true, so generic status polls do not fire false positives.
Trigger whitelist accepts autopilot, corporate, and device-preparation;
the third value is wired through the type system end-to-end so a future
DevPrep-specific consent flow can use it without a schema bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CertificateValidator previously combined AllowUnknownCertificateAuthority
with a DN-substring scan ("Microsoft Intune"/"MDM Device CA" in
Subject/Issuer of any chain element). A self-signed leaf with that DN
satisfied the substring gate while the unknown-root flag masked the
untrusted-root status -> the X.509 trust gate was bypassable.
Switch to X509ChainTrustMode.CustomRootTrust with the embedded Intune
root in CustomTrustStore and the intermediate in ExtraStore. chain.Build()
now requires a signature link to a pinned root regardless of the OS
trust store, which keeps Linux/Flex Consumption working without the
self-signed bypass. Empty roots -> fail-closed.
Adds 7 unit tests including the self-signed-impersonating-Intune
regression test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the "View at lifecycle anchors" link (and its sessionId prop) from
FailureSnapshotBlock; SessionInfoCard updated to match the new prop shape.
Bumps page-lastmod.generated.ts and the V2 agent buildcounter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes a tenant-boundary write-side hole and removes two manual cross-tenant
checks in favor of catalog-driven middleware enforcement.
SubmitSessionReport + SubmitDiagFilesReport (the violation):
The functions previously fell back to the JWT tenantId only when the body's
TenantId was empty, otherwise trusting the body verbatim. A Tenant Admin of
Tenant-X could POST a body with tenantId=Y and have the report blob, the
SessionReports row, and the audit-log entry all attributed to Tenant-Y
(integrity / non-repudiation issue, no read-side leak).
New behavior: GA-aware. Non-GAs who send a body tenantId that disagrees with
their JWT get a 403 + warning log. GAs may legitimately submit cross-tenant
reports (the UI sends the session's tenantId), but MUST set the body field
explicitly (no silent fallback that would mis-file under the GA's home tenant).
Diagnostics + Progress middleware elevation (defense-in-depth):
diagnostics/download-url and progress/sessions/{sessionId}/events lifted from
TenantScoping.None (in-function guard) to TenantScoping.QueryParam (catalog +
middleware). Same GA bypass semantics — the in-function check was correct but
copy-paste fragile. Functions now read requestCtx.TargetTenantId, which the
middleware has already validated against the JWT.
Test coverage:
CrossTenantAccessTests gains 10 new InlineData rows across the 5 QueryParam
test methods (no-query / same-tenant / cross-non-GA blocked / cross-GA allowed
/ scoping correctness) for both lifted routes. diagnostics/download-url moved
out of JwtImplicitRoutes_NoCrossTenantCheck since it's no longer JWT-implicit.
Verified: dotnet build clean, 1067/1067 backend tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The tenantId useState initializer used to seed itself from localStorage,
opening a small mount-window where a child component could fetch with a
tampered tenantId before the auth effect overrode it from user.tenantId.
No remaining feature reads from this localStorage key (the only
setTenantId call sites are inside TenantContext itself), so the read +
the corresponding write are dead. Initialize empty, clear any stale LS
value once on mount, and rely solely on the JWT-derived user.tenantId.
Backend MUST still derive tenantId from the JWT claim, not from
query/body params - this change is defense-in-depth, not the primary
guarantee.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop 'unsafe-eval' from script-src CSP — Next.js prod build doesn't
need it; closes an XSS escalation vector for any future inline payload.
- Cap error messages embedded in the generated OOBE PowerShell script at
200 chars to prevent a multi-MB upstream message from being inlined
verbatim into a script that runs as SYSTEM during Windows OOBE.
- Add X-Content-Type-Options: nosniff to the /go error response for
consistency with the success response.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Customer reported "From Peers 100% / Peer breakdown LAN 0 | Group 0 | Internet 0"
mid-download. Root cause: OS collector emitted LAN/Group/Internet/LinkLocal
breakdown only on the final do_telemetry event, not on download_progress events
during the download — so UI rendered 0/0/0 until completion. Empirical check of
917 do_telemetry events / 146 sessions / 13 tenants over 30 days confirmed IME
[DO TEL] log path is dead code (current IME version no longer logs it); OS
cmdlet is the sole live source.
Changes:
- AppPackageState (V2): new fields DoBytesFromLinkLocalPeers,
DoBytesFromCacheServer, DoCacheHost; UpdateDoTelemetry signature extended
with optional defaults so callers without the data pass 0/null.
- DeliveryOptimizationCollector: read BytesFromLinkLocalPeers + CacheHost
from cmdlet output; centralised event payload via BuildDoEventData() so
download_progress and do_telemetry are field-symmetric.
- ImeLogTracker.HandleDoTelemetry: parses new fields null-safe (older IME
JSON without them defaults to 0/null, no breakage).
- Persistence: PackageStateData DTO + ToPackageStateData / FromPackageStateData
mappers carry the new fields across agent restart.
- ImeLogTrackerAdapter.EmitDoTelemetry: emits new fields on do_telemetry,
including doCacheHost when MCC was used.
- Backend ingest (V1 + V2 paths) + AppInstallSummary model +
TableStorageService.Metrics Store/Map/preserve-from-existing extended for
the new columns.
- DoAggregator + GeographicApiModels + GetGeographicMetricsFunction surface
MCC + LinkLocal at location and per-session level.
- UI DownloadProgress.tsx: new "From Connected Cache" line with cache% and
hover host; LAN merges LinkLocal in breakdown ("local network"); 3-color
progress bar (Peers green / MCC purple / HTTP blue) computed by subtracting
CacheServer from HTTP to avoid double-counting (MCC serves over HTTP).
- UI sessions page: hover title now includes MCC bytes and LAN+LinkLocal sum.
Robustness: every new field defaults to 0 / null / "" on machines or paths
that don't supply them; no existing session display regresses.
Tests: backend 1058/1058, agent V2 1007/1007, web vitest 259/259, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rename env var STATE_HMAC_KEY → OAuthStateSigningKey and switch decode
from utf-8 to base64, matching the backend's PaginationTokenSigningKey.
Same naming pattern (PascalCase, "...SigningKey" suffix), same encoding
(base64), same fail-loud validation (≥32 bytes after decode).
Bicep param + secret name renamed accordingly (lives on disk only —
infra/ is gitignored). Operator: rotate the secret value during the next
deploy and update the env var name in the Container App.
The fallback-to-random-bytes branch is preserved so single-replica
deployments without explicit key configuration still get a working,
short-lived signature; that is unchanged from the previous commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
External security review (2026-05-08) flagged 5 issues against the MCP
server's auth surface. All five are addressed in code; one (Bicep param
additions) lives on disk only because infra/ is gitignored — the operator
needs to redeploy with the new mcpPublicUrl + stateHmacKey parameters
documented in the bicep header to put F3 fully into effect.
#1 access-guard cache-key collision (Medium)
Cache was keyed on UPN only. A forged JWT with a victim's UPN would
hit a legitimate user's cached `allowed: true` and bypass the backend
signature check for any MCP-local operation (tools/list, resources/
list etc.) plus burn the user's rate-limit budget.
Fix: cache key is now `${upn}:${sha256(token)}`, so distinct tokens
always require a fresh backend roundtrip. accessCache joins the 5-min
cleanup tick to evict expired entries (cardinality went up).
#2 /oauth/register DoS surface (Medium)
Endpoint is unauthenticated per RFC 7591 and the registry was an
unbounded in-memory Map at 0.5 GiB container budget. Per-IP rate-
limit was rejected (cloud-proxy egress collisions — Zscaler etc.).
Fix: route-level express.json({ limit: '8kb' }) registered before
the global parser; map-size cap at 10_000 entries (~3 MB worst-case)
→ 503 when full; field-level caps on redirect_uris.length and
client_name. TTL/LRU eviction is intentionally NOT enabled —
documented as TODO(oauth-map-ttl) because it interacts with the
manual /mcp reconnect flow.
#3 OAuth base URL trusted X-Forwarded-* headers (Medium)
Discovery, issuer, WWW-Authenticate, and `${baseUrl}/oauth/callback`
were all derived from caller-supplied headers. Container Apps Ingress
appends but does not strip X-Forwarded-Host, so a hostile client could
spoof the issuer in /.well-known/oauth-authorization-server.
Fix: startup warning when MCP_PUBLIC_URL is unset. The bicep file
(gitignored) was also extended with a `mcpPublicUrl` param — operator
must redeploy with that pinned to the Container App FQDN after the
first deploy emits it as an output.
#4 PKCE/state hardening (Medium)
code_challenge was optional and only forwarded conditionally;
code_verifier was never required at /oauth/token; state was bare
base64url-JSON without integrity protection.
Fix: /oauth/authorize now rejects requests without code_challenge
or with code_challenge_method != S256 (the discovery doc already
advertised S256-only). /oauth/token requires code_verifier for
grant_type=authorization_code. state is HMAC-SHA256 signed with
STATE_HMAC_KEY (env, fallback per-instance random), payload includes
iat, /oauth/callback rejects tampered or >10-min-old state in
constant-time compare. Bicep adds a secure `stateHmacKey` param;
unset is acceptable for single-replica deployments.
#5 Structured 5xx leak in error-handler (Low)
ApiError.parsed branch flowed all 5xx detail (error / hint /
exceptionType) through to the MCP client. exceptionType in
particular leaks CLR class names that have no business reaching a
downstream caller.
Fix: status>=500 branch moved before the parsed-detail branch and
applies generic-message sanitization to structured 5xx too. Only
correlationId and errorCode survive — those are operational
handles for an operator to pivot on, not internals.
Tests: src/__tests__/security-guards.test.ts. The pre-existing
"preserves structured 5xx" case is inverted to assert sanitization.
16 new tests cover the cache-key fix, the registry caps, and the
state HMAC roundtrip / tamper / expiry / replay paths. 47/47 green
under `vitest run src/__tests__/security-guards.test.ts`. The
tools-*.test.ts integration suites still require AUTOPILOT_API_TOKEN
to run (pre-existing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
V1 carried the same two bugs as V2: the per-user profile enumeration filter
only matched S-1-5-21-* SIDs, silently skipping the AAD/MSA cloud SIDs
(S-1-12-1-*) that Win10 1903+ uses for AAD-joined users — so on Win11 AAD
boxes the per-user Uninstall hive of the actually-logged-in user was never
read. Additionally, CollectFromKey logged "{source}\{subKeyPath}" which
mashed the telemetry tag (HKU_<RID>) with the relative path, producing a
non-path string like "HKU_1000\S-1-5-21-...\SOFTWARE\..." in debug logs.
Same patch as V2 commit 6c4f7250: extend SID filter to include S-1-12-1-*,
log rootKey.Name (HKEY_USERS / HKEY_LOCAL_MACHINE / ...) as the path prefix
and report the telemetry tag separately as "(source=...)".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Filter in CollectFromAllUserProfiles now also accepts S-1-12-1-* SIDs (Microsoft Account / Azure AD cloud SIDs on Win10 1903+ AAD-joined devices); previously only S-1-5-21-* matched, so the per-user Uninstall hive of the actually-logged-in AAD user was silently skipped on Win11 AAD-joined machines.
- CollectFromKey log line now uses rootKey.Name (HKEY_LOCAL_MACHINE / HKEY_USERS / ...) as the path prefix and reports the telemetry tag separately as "(source=...)". Removes the misleading "HKU_1000\S-1-5-21-...\SOFTWARE\..." Frankenstein-path that mashed the label with the subkey path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The violator table on /sla linked to /session/${tenantId}/${sessionId},
which 404s — the canonical route is /sessions/[sessionId] (single
segment, plural). Same convention as /diagnosis, /admin and the
inspector. Restores navigation and makes sla_violator_opened actually
fire.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User typed a tenant filter without clicking Submit, then clicked Next →
fetchSessionsBatch read the live tenantIdFilterRef and built a URL with the
typed filter, but the continuation token in state was issued before the typing
under a different filter scope. Backend recomputed fingerprint, mismatched →
400 filter_mismatch.
Track the submitted filter in a separate ref, updated only via
refetch/refetchWith/initial-fetch/global-mode-toggle. The live ref keeps
driving client-side display via effectiveSessions; backend pagination always
queries under the scope its continuation was issued for.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
H1 OAuth redirect_uri host allowlist (loopback always; hosted clients
via MCP_ALLOWED_REDIRECT_HOSTS, default Anthropic/OpenAI/Google).
Closes auth-code-leak through unvalidated /oauth/callback.
M1 followNextLink rejects continuation paths that don't equal the
tool's own basePath - closes cross-tool path substitution.
M2 strict UUID schema on every sessionId input - blocks WHATWG-URL
path-normalization attacks via /api/sessions/{id}.
M3 rate-limit moved after access-check (Step 1) - forged JWTs cannot
drain a real user's per-minute budget.
L1 get_rule_stats uses pickGlobalOrTenantPath like every other tool.
L3 5xx response bodies no longer relayed to the caller; structured
4xx errors keep their detail.
L4 hardcoded CLIENT_ID fallback removed - module throws on boot if
AUTOPILOT_ENTRA_CLIENT_ID is unset.
Adds security-guards.test.ts (32 unit tests) covering all guards.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-test post-deploy of #60 surfaced two minor follow-ups:
1. get_ops_events MCP tool: backend already accepts tenantId filter
(since C2), but the MCP tool schema didn't expose tenantId so Zod
silently dropped any caller-passed value. Adds tenantId to the input
schema and forwards it in the query. 2 LOC.
2. /api/global/metrics/summary: the global handler was hardcoded to
pass null (all tenants) and ignored ?tenantId=X. Adds the same
optional filter pattern used elsewhere — null when absent, scoped
when present. The tenant-scoped /api/metrics/summary route is
unchanged (JWT-tid still authoritative).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Submit Logs (tenant settings) and Report Session modal both relabel the
email field so users understand it's the channel we may reply on; the
Submit Logs card also gains mx-auto so it sits centered in the wide
tenant-settings layout instead of left-aligned.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The summary used to embed every key event's full data/dataJson payload —
one busy session could push the response well past 80 KB and trip the
response cap. The point of the summary is triage, not raw payload
delivery; users wanting full event data already have get_session_events.
Now:
* key events sorted by relevance (Errors > phase transitions > Warnings
> others), capped at 50 entries; full timeline still drives stats
* the displayed slice is re-sorted chronologically for readability
* heavy `details` field dropped — was the main bloat source
* stats.keyEventsTotal + keyEventsTruncated exposed so callers know if
more events were filtered out
Description updated to set expectations + point at get_session_events for
full payloads.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Workaround for clients (e.g. Claude Code's HTTP-MCP bridge in stateless
mode) that don't expose `resources/list` reliably. Verified with a paired
in-memory client that the server itself advertises `capabilities.resources`
and answers `resources/list` correctly — the breakage is on the client
side. Tools work everywhere, so a tool-call gateway is the pragmatic fix.
Resources now share a single source-of-truth (`resource-catalog.ts`) used
by both the MCP-protocol resource handlers and the new `get_resource(name)`
tool. Tool descriptions previously said "see the device_properties resource"
— updated to "call get_resource(name=...)" so the hint actually leads
somewhere usable.
Also tightens get_rule_stats description: the default window × number of
rules × per-day trend can easily exceed the response cap, so the doc now
recommends passing a tighter date range or ruleType filter for routine
queries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the silent confusion where a Global Admin who passed `tenantId=X`
got routed to the tenant-scoped endpoint, which used the GA's own JWT
tenant (home), not the requested one. The user saw their home tenant's
data instead of X — no error, just wrong data.
Caller role flows from /api/auth/mcp (now returns isGlobalAdmin) into
an AsyncLocalStorage caller context (token + isGlobalAdmin). Tools pick
the route via a small helper:
pickGlobalOrTenantPath('/api/global/X', '/api/X')
GA → /api/global/X (cross-tenant; tenantId is a filter param)
Tenant-Admin → /api/X (tenant-scoped; JWT-tid authoritative, isolation strict)
Refactors 13 routing sites across tools/{sessions,admin,search}.ts. Each
backend pair was verified to exist (both `/api/X` and `/api/global/X`
have a Function defined).
Also drops the MCP-side filter pass in get_session_events — server-side
filtering was added in the previous commit so count and nextLink are
now coherent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The full LocationSessionRow shape is ~40 fields per session — for
locations with dozens of sessions the response easily blew past 100KB
and tripped MCP token caps. Most fields aren't needed for the typical
"which sessions enrolled from here" triage flow.
Default is now a 17-field lean projection (sessionId, device basics,
status/duration, geo, app/DO summary). UI keeps the full shape via
?full=1 — single-line addition in lib/api.ts.
Also adds an optional tenantId filter on /api/global/metrics/geographic/
sessions so a GA can scope a single tenant's sessions for a location
without leaving the global endpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Push eventType/severity/source filters from MCP-tool layer down into the
Function so count and nextLink stay coherent. Previously filters lived
client-side: backend returned the full page, MCP filtered → could
produce count: 0 with nextLink set, which read as a bug ("nextLink is
emitted even though there's nothing left").
With server-side filters:
- count reflects post-filter items in this page
- nextLink is still gated on NextRawToken (more raw rows ahead in this
session — filter may or may not match further down)
- filter values echo back onto nextLink so callers can follow pagination
without having to re-supply them
SessionEventsPagination.BuildNextLink learns an optional `extras` arg so
arbitrary query params survive the round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both endpoints already supported optional date-window + pagination but
ignored a tenantId query param. With the upcoming MCP role-aware routing
GA needs /api/global/* endpoints to filter to a single tenant while
keeping cross-tenant capability when the param is absent.
- audit/logs: routes to existing tenant-scoped IMaintenanceRepository
methods when tenantId is set (cheap PK lookup); fingerprint scope
widened to "audit:global:tenant:<id>" so cross-token reuse fails.
- ops-events: OpsEvents are partitioned by category, not tenant — the
filter is applied post-fetch. Pages may report fewer items than
pageSize when the filter is narrow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The MCP server needs to know the caller's GA status to make routing
decisions (GA → /api/global/* with tenantId filter; non-GA → /api/*
JWT-bound). Previously the access-check response only carried
allowed/reason/accessGrant — the MCP layer had no way to detect role.
McpUserService now resolves IsGlobalAdmin unconditionally (it's needed
for both whitelist and AllMembers paths), and the result rides through
on McpAccessCheckResult.IsGlobalAdmin. Existing factory signature stays
backward-compatible via default-param.
Prep for the upcoming MCP role-aware routing refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tenant Admins can now ship log files, JSON state snapshots (.log/.txt/.zip/
.json/.jsonl/.ndjson), and screenshots to the Autopilot Monitor team from
/settings/tenant/support without needing a session context — no more
OneDrive round-trips for ad-hoc client-log requests.
Backend
- New POST /api/diag-files-reports (TenantAdminOrGA) -> SessionReports table
+ session-reports container, distinguished by ReportType="diagFiles".
- ReportType discriminator added to SessionReportMetadata + Store/Map
(legacy rows default to "session").
- Telegram + Global-Admin bell notifications mirror the session-report flow.
Frontend
- New "Submit Logs" tenant settings tab with comment / email / log+state
files (5 MB cap) / screenshot pickers; multi-file auto-zip via fflate.
- Admin SessionReportsSection now shows a Type column (Session / Diag Files)
and hides Session ID in the detail modal for diag-files rows.
Verification: dotnet build clean, 1058 backend xUnit tests pass, tsc
--noEmit clean, 259 vitest tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a drop-in wrapper around authenticatedFetch that collapses concurrent
GET/HEAD requests to the same URL into a single underlying network round-
trip. Each caller receives an independently readable Response clone.
This is a request-collapser, NOT a cache. Entries live only as long as
the underlying Promise is unresolved; once it settles, the entry is
removed on the next microtask. No TTL, no eviction, no staleness — by
design, since SignalR push-truth would race against any pull-cache. Calls
that merely happen to hit the same URL after a response has arrived go
fresh.
Mutations (POST/PUT/PATCH/DELETE) bypass the collapser unconditionally
so write semantics are unchanged.
Migrated two known-duplicating callsites:
- useTenantSecurityConfig (dashboard) — feature-flags GET that re-fired
on the user=null → UserInfo transition.
- useSessionTenantConfig (session detail) — feature-flags GET that
could overlap with other consumers on the same render burst.
Belt-and-suspenders: the prior dep-stabilization + !user guard on
useTenantSecurityConfig stays in place; the collapser is defense-in-
depth so future regressions of the same shape are caught generically.
8 new vitest cases cover: concurrent collapse, independent body reads,
no caching across microtask boundaries, different URLs not collapsed,
mutations bypass, rejection propagation + slot release, HEAD collapsing,
and GET vs HEAD keyed separately. 257/257 web tests, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Post-deploy re-validation in the browser surfaced two issues that the
previous commit did not actually solve:
- Timing-Allow-Origin was still missing on every API response (curl
confirmed) and X-Correlation-ID was missing too — a preexisting bug.
Both middlewares used httpContext.Response.OnStarting() to attach the
header, but that callback is not reliably triggered in the .NET 8
isolated worker: the host bridges the worker's response to the wire,
so OnStarting fires on a shadow object that never reaches the client.
UserRateLimitMiddleware already writes X-RateLimit-* directly on
Response.Headers before calling next(), and those headers do come
through in production. Switching both header-emitting middlewares
to that pattern.
- /dashboard kept firing /api/config/<t>/feature-flags twice. The
earlier dep-stabilization on useTenantSecurityConfig was incomplete:
the effect fires once on initial render with user=null (deps
[tenantId, undefined, undefined, undefined]) and once after AuthContext
resolves user (deps [tenantId, bool, bool, role]) — two distinct dep
tuples, two fetches. The user=null path also bypassed the role gate
(the guard was `if (user && ...) return`). Adding `!user` to the
early-return at the top of the effect collapses to one fetch and
also drops the would-be 401 we were producing during the loading
window.
Backend tests 1058/1058, web vitest 249/249, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browser RUM on portal/dashboard surfaced ~1.7s time-to-data driven by
sequential backend reads, two stray duplicate fetches, and a 2s SignalR
defer. Five focused changes:
- AuthContext: store the auth/me prefetch as a Promise instead of its
resolved value so fetchUserInfo awaits the in-flight request rather
than racing a second fetch when the prefetch hasn't settled yet.
- useTenantSecurityConfig: depend on user.isTenantAdmin/isGlobalAdmin/
role primitives instead of the whole user object so the effect stops
refiring when AuthContext swaps prefetched user for fresh user (the
duplicate /config/<t>/feature-flags call observed on /dashboard).
- SignalRContext: drop the hardcoded 2000ms setTimeout before
startConnection. The original "avoid competing with critical API
calls" rationale doesn't apply across origins (web is HTTP/2, API is
a different host); the delay only pushed live updates ~2s into the
load for no benefit.
- FeedbackFunction: keep the kill-switch check sequential, then run the
remaining four Table reads (role, tenant config, sessions existence,
feedback cooldown) in parallel via Task.WhenAll. Endpoint dropped
from ~1285ms (6 sequential reads on Flex Consumption) toward the
slowest single read.
- TimingAllowOriginMiddleware: emit Timing-Allow-Origin: * on every
function response so the portal's Resource Timing API can read API
payload sizes (transferSize / nextHopProtocol) for our own RUM —
cross-origin entries currently report 0/unknown.
Backend tests 1058/1058, Web tsc clean, vitest 249/249.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The paged scan path (`SearchSessionsByScanPageAsync`) routed AgentVersion +
AgentVersionPrefix through `MatchesScanClientFilters` — applied AFTER the
SessionsIndex page was already fetched. For sparse subsets like "all V2
enrollments" (~1% of sessions) this meant the LLM had to follow 10-30 nextLink
roundtrips of mostly count:0 pages while the index walked past the dense
non-V2 majority.
Move both filters into `BuildSearchScanFilter` as native OData clauses, same
trick `DeviceName` and `OsBuild` already use:
AgentVersion eq 'X' (exact)
AgentVersion ge 'X' and AgentVersion lt 'X~' (prefix)
Drop the now-redundant client-side check in `MatchesScanClientFilters`. The
device-snapshot path (`SearchSessionsByDeviceSnapshotPageAsync`) keeps the
client-side filter in `ApplyBasicFilters` because DeviceSnapshot doesn't carry
AgentVersion as a column.
Result: search_sessions(agentVersionPrefix="2.0.") becomes a single-call
operation for typical tenants instead of a roundtrip marathon.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>