Compare commits

...

20 Commits

Author SHA1 Message Date
Oliver Kieselbach 71832b9deb docs(about): list hardware rejection among the webhook alert types
The integrations list named start, success, failure and SLA alerts but omitted
hardware rejection, understating what tenants can actually subscribe to.

Verified before claiming it: WebhookNotifyOnHardwareRejection is wired
end-to-end - model, portal toggle in SectionHardwareWhitelist, and dispatch in
ReportDistressFunction. This list occurs nowhere else in the repo or in
autopilotmonitor-docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:49 +02:00
Oliver Kieselbach ce4d884abc test(gather-rules): drive the collectors through their guards
The guard functions had unit tests, but nothing proved that Registry, Wmi,
Json, Xml and File actually consult them before touching the registry, WMI or
disk. These drive the collectors themselves: each blocked target must come
back empty, emit exactly one security_warning naming the target, and produce
no rule output. Prefix-spoofing included, plus a positive case through
JsonCollector so it is pinned that the guard is a gate and not a wall.

Path traversal had no explicit coverage either. The sharpest case is
...\Logs\..\..\..\..\Secret.txt - the RAW string starts with an allowed
prefix, so a naive StartsWith would admit it; the test pins that
Path.GetFullPath canonicalizes first and the allowlist sees C:\Secret.txt.
Also traversal into the C:\Users privacy block and into System32\config, both
asserted under unrestrictedMode, which lifts the allowlist but never those.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:49 +02:00
Oliver Kieselbach b56c774103 fix(logging): strip Unicode line separators in LogSanitizer
The sanitizer matched \p{Cc}, which covers the C0/C1 control ranges but not
U+2028 LINE SEPARATOR or U+2029 PARAGRAPH SEPARATOR - those are categories
Zl/Zp. A log viewer, a JSON-lines consumer or any JavaScript-side reader still
breaks a line on them, so they were a residual log-forging vector.

Matching \p{Zl} and \p{Zp} rather than listing escapes: the two categories
contain exactly those two characters, and it keeps the source free of
invisible literals.

Verified the test is not vacuous - the old pattern left "a<U+2028>b" as "a b",
the new one yields "ab".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:48 +02:00
Oliver Kieselbach 68767f436f fix(config): validate ContactEmail and seed it conditionally
The tenant contact address had no server-side validation, so a direct API
caller could store any string in the field service notices are sent to.
UpdateTenantConfiguration now rejects non-addresses, recipient lists (a comma
would silently widen who receives those notices), display-name forms and
control characters (mail-header injection), and normalizes what it stores.
Empty stays valid - having no contact address is a legitimate state.

The seed path had a worse problem than missing validation. It was a
read-modify-write of the whole model through SaveTenantConfigurationAsync,
which upserts with TableUpdateMode.Replace and no ETag: a concurrent portal
save was clobbered by the seeder's stale snapshot of EVERY field, not just
ContactEmail. It also wrote straight through IConfigRepository, bypassing the
TenantConfigurationService cache, so a seeded address stayed invisible for the
full 5-minute TTL.

TrySeedTenantContactEmailAsync now writes ETag-conditionally in Merge mode -
one property, and a 412 means someone else wrote the row so the seed abandons
rather than overwriting the winner. A lost seed is recoverable on the next
maintenance run; a lost admin edit is not.

The same seed logic existed twice (preview notification-email save and the
maintenance backfill). Both now call a single owner on
TenantConfigurationService, which holds the never-overwrite invariant, the
cache eviction and the fail-soft guarantee. Fail-soft lives in the service
rather than the repository so it holds for any implementation.

Tests cover the validator, the service contract, and the repository against a
mocked TableClient: Merge mode, the ETag actually passed, only ContactEmail in
the patch, the 412 race, 404, storage faults. Plus a regression pin that
SaveTenantConfigurationAsync is never called from the seed path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:30 +02:00
Oliver Kieselbach c7b06be8b7 fix(ratelimit): key the user throttle on CallerId, not the UPN
Follow-up to 479247cd. Publishing only a genuine UPN closed the fleet-wide bucket
collapse but opened a narrower hole: AuthenticationMiddleware validates issuer,
audience, lifetime and signature but requires neither upn nor scp, so an app-only
(client-credentials) token authenticates and reaches every AuthenticatedUser route
(feedback, realtime/negotiate, progress/*, health/detailed, auth/mcp, ...). Such a
token carries no upn, so it resolved to an empty identity and UserRateLimitMiddleware
skipped it entirely — previously it at least landed in the shared anonymous bucket.

Rather than reinstating a placeholder as a bucket key, separate the two concepts:

- RequestContext.UserPrincipalName keeps its meaning — a real UPN or nothing. Audit,
  presence and admin flows continue to see only genuine user identities.
- RequestContext.CallerId is the throttle identity: UPN, else the token subject
  (oid -> sub -> appid/azp), else a fail-closed shared bucket. Empty means exactly one
  thing — no JWT at all (device/anonymous routes, which carry their own limits).
- UserRateLimitMiddleware gates and buckets on CallerId.

This matches how McpQuotaEnforcementMiddleware already keys its quota (oid), so the
subject-based identity is the existing house convention for enforcement.

10 new tests: the oid/sub/appid/azp fallback chain and its precedence, the fail-closed
bucket for a token with no identifying claim (asserting it is never "anonymous"), and
that empty CallerId occurs only on unauthenticated routes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:17:34 +02:00
Oliver Kieselbach 479247cdae fix(ratelimit): stop agent fleet sharing one anonymous user bucket
Device and anonymous routes published the "anonymous" placeholder as
RequestContext.UserPrincipalName. UserRateLimitMiddleware skips only on an EMPTY UPN, so
every agent of every tenant was bucketed under user_ratelimit_anonymous and throttled
against the 120/min per-user limit. On 2026-07-21 21:00-21:30 UTC that rejected 1821
agent requests with 429 (/api/agent/telemetry, config, register-session, error); the
7-day trend shows it recurring and growing with fleet size.

- PolicyEnforcementMiddleware: RequestContext.UserPrincipalName carries a genuine JWT
  UPN or nothing. decision.UserIdentifier keeps the placeholder for log lines.
- Device/bootstrap routes already hold their own limit in SecurityValidator (per cert
  thumbprint / per bootstrap token), so they lose nothing.
- stats/platform and diagnostics/download had NO limiter of their own and were riding on
  the accidental shared bucket. Both now limit per trusted client IP (60/min and 30/min)
  via ClientIpExtractor, matching the HealthCheck pattern.
- 13 new tests pin the empty-UPN contract across all 12 device/anonymous routes, plus a
  guard that an authenticated caller still surfaces its real UPN.

Solution suite 5552 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:48:33 +02:00
Oliver Kieselbach b6ba87f072 fix(deps): pin patched DataProtection + Cryptography.Xml to clear NuGet advisories
Microsoft.Identity.Web 4.3.0 pulls Microsoft.AspNetCore.DataProtection and
System.Security.Cryptography.Xml transitively at 10.0.0, which carry known
advisories (GHSA-9mv3-2cwr-p262 critical on DataProtection; GHSA-37gx-xxp4-5rgx
and siblings, high, on Cryptography.Xml). Add explicit references pinning both
to the patched 10.0.10 servicing build (matching the existing JwtBearer pin) —
the NuGet equivalent of the npm overrides used for the web/MCP alerts.

Pinned in the production Functions project so the fix flows to the deployed
artifact and to Functions.Tests via project reference. `dotnet list package
--vulnerable` now reports 0 across every project; build clean; 3110 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:44:44 +02:00
Oliver Kieselbach fa640ebe61 fix(security): resolve CodeQL log-forging findings and suppress by-design queries
Bucket B (real fixes) + Bucket A (config) of the CodeQL alert triage.

Log-forging (cs/log-forging, 8 sinks): add Helpers/LogSanitizer that strips
CR/LF and other control characters, and apply it to the logged copy of every
user-controlled value in AuthenticationMiddleware, PolicyEnforcementMiddleware
(Invoke + DecideAsync), McpQuotaEnforcementMiddleware and TableUserUsageRepository.
Only the logged value is sanitized — routing, authorization and storage keys are
untouched, so tracing fidelity for legitimate values (paths, UPNs, GUIDs) is
preserved. Structured logging already isolated the fields; this closes the CRLF
vector as defense-in-depth and clears the query.

Three genuine JS/TS fixes:
- guardValidation.ts: replace the no-op `.replace(/%/g, "%")` with a real
  escapeRegExp() — the author intended to escape; this also closes the latent
  metacharacter gap (js/identity-replacement).
- access-guard.ts: use a `%s` format argument instead of interpolating the UPN
  into the console.error format string (js/tainted-format-string).
- docs-corpus.ts: strip HTML tags with a loop-until-stable stripHtmlTags() so the
  sanitizer is robust and recognized as complete (js/incomplete-multi-char-sanitization).

CodeQL config: filter the two queries that fire structurally on this codebase,
where the flagged behavior is the feature — cs/exposure-of-sensitive-information
(audit/notification logs legitimately record the actor UPN / recipient email) and
cs/cleartext-storage-of-sensitive-information (rate limiters key on the trusted
client IP). Default suites stay active; genuine one-off false positives from other
queries are dismissed individually so those queries stay sharp.

Tests: Backend xUnit 3110 (+9 LogSanitizer), MCP vitest 19 (+1 tag-strip),
Web vitest 49, all tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:38:47 +02:00
Oliver Kieselbach dbc20a9f66 test(deletion-maintenance): de-flake watchdog tests via event gates
The three watchdog tests raced a fixed fanout-body sleep against sub-second
warn/severe thresholds (e.g. 150ms body vs 500ms severe). On a loaded CI
runner the whole RunCoreAsync body overran the severe threshold before the
finally-block cancelled the watchdog, so the severe OpsEvent fired and
"LongRunning past warning" flaked (3100/3101).

Replace the wall-clock race with event gates: the fanout body now blocks on
WaitForEventAsync(<watchdog OpsEvent>) instead of sleeping, so the target
watchdog is guaranteed to have fired before the body returns. The opposite
threshold is pushed to minutes (warn-only tests) or the body waits on the
severe event (both-fire test) so warn is necessarily first. OpsEvents
recording is now lock-guarded since watchdog tasks record concurrently.

No production change. Watchdog class ran 5x green (16/16); full suite 3110/3110.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:33:26 +02:00
Oliver Kieselbach cbda62d131 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-07-22 01:19:36 +02:00
Oliver Kieselbach 3951000b40 ci(deploy-web): skip SWA deploy when token absent instead of failing
Dependabot PRs run without repository secrets, so AZURE_SWA_DEPLOY_TOKEN is
empty and Azure/static-web-apps-deploy exits with an error, painting a red
"Build and Deploy Job" check on every web-touching Dependabot PR. Setting
skip_deploy_on_missing_secrets makes the action skip the deploy (green) when
the token is missing; real pushes to main still carry the token and deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:19:26 +02:00
dependabot[bot] c59842262e chore(deps): bump the web group across 1 directory with 10 updates (#104)
Bumps the web group with 10 updates in the /src/Web/autopilot-monitor-web directory:

| Package | From | To |
| --- | --- | --- |
| [@microsoft/applicationinsights-web](https://github.com/microsoft/ApplicationInsights-JS) | `3.3.11` | `3.4.3` |
| [@xyflow/react](https://github.com/xyflow/xyflow/tree/HEAD/packages/react) | `12.10.2` | `12.11.2` |
| [fflate](https://github.com/101arrowz/fflate) | `0.8.2` | `0.8.3` |
| [next](https://github.com/vercel/next.js) | `15.5.18` | `15.5.21` |
| [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) | `16.2.7` | `16.2.11` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.11` | `22.20.1` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.4.24` | `10.5.4` |
| [eslint](https://github.com/eslint/eslint) | `9.39.2` | `9.39.5` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `15.5.12` | `15.5.21` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.2` | `4.1.10` |



Updates `@microsoft/applicationinsights-web` from 3.3.11 to 3.4.3
- [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/commits)

Updates `@xyflow/react` from 12.10.2 to 12.11.2
- [Release notes](https://github.com/xyflow/xyflow/releases)
- [Changelog](https://github.com/xyflow/xyflow/blob/main/packages/react/CHANGELOG.md)
- [Commits](https://github.com/xyflow/xyflow/commits/@xyflow/react@12.11.2/packages/react)

Updates `fflate` from 0.8.2 to 0.8.3
- [Release notes](https://github.com/101arrowz/fflate/releases)
- [Changelog](https://github.com/101arrowz/fflate/blob/master/CHANGELOG.md)
- [Commits](https://github.com/101arrowz/fflate/compare/v0.8.2...v0.8.3)

Updates `next` from 15.5.18 to 15.5.21
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.18...v15.5.21)

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

Updates `@types/node` from 22.19.11 to 22.20.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `autoprefixer` from 10.4.24 to 10.5.4
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.4.24...10.5.4)

Updates `eslint` from 9.39.2 to 9.39.5
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.2...v9.39.5)

Updates `eslint-config-next` from 15.5.12 to 15.5.21
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v15.5.21/packages/eslint-config-next)

Updates `vitest` from 4.1.2 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

---
updated-dependencies:
- dependency-name: "@microsoft/applicationinsights-web"
  dependency-version: 3.4.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: "@xyflow/react"
  dependency-version: 12.11.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: fflate
  dependency-version: 0.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: next
  dependency-version: 15.5.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.2.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@types/node"
  dependency-version: 22.20.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: autoprefixer
  dependency-version: 10.5.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: eslint
  dependency-version: 9.39.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 15.5.21
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: vitest
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:15:43 +02:00
dependabot[bot] c3e9621a78 chore(deps): bump the mcp group across 1 directory with 5 updates (#103)
Bumps the mcp group with 5 updates in the /src/McpServer/autopilot-monitor-mcp directory:

| Package | From | To |
| --- | --- | --- |
| [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk) | `1.28.0` | `1.29.0` |
| [fuse.js](https://github.com/krisk/Fuse) | `7.1.0` | `7.5.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.15` | `22.20.1` |
| [tsx](https://github.com/privatenumber/tsx) | `4.22.4` | `4.23.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.2` | `4.1.10` |



Updates `@modelcontextprotocol/sdk` from 1.28.0 to 1.29.0
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/v1.28.0...v1.29.0)

Updates `fuse.js` from 7.1.0 to 7.5.0
- [Release notes](https://github.com/krisk/Fuse/releases)
- [Changelog](https://github.com/krisk/Fuse/blob/main/CHANGELOG.md)
- [Commits](https://github.com/krisk/Fuse/compare/v7.1.0...v7.5.0)

Updates `@types/node` from 22.19.15 to 22.20.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `tsx` from 4.22.4 to 4.23.1
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.4...v4.23.1)

Updates `vitest` from 4.1.2 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

---
updated-dependencies:
- dependency-name: "@modelcontextprotocol/sdk"
  dependency-version: 1.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: fuse.js
  dependency-version: 7.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: "@types/node"
  dependency-version: 22.20.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: tsx
  dependency-version: 4.23.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: vitest
  dependency-version: 4.1.10
  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-07-22 01:15:10 +02:00
dependabot[bot] 3ed21c37d1 chore(deps): bump actions/upload-artifact from 6 to 7 (#102)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:08:23 +02:00
dependabot[bot] 4a1194569e chore(deps): bump actions/checkout from 5 to 7 (#91)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:07:14 +02:00
dependabot[bot] d0794fc7c5 chore(deps): bump actions/setup-node from 5 to 7 (#90)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:06:47 +02:00
dependabot[bot] f34c9d2322 chore(deps): bump github/codeql-action from 3 to 4 (#89)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:06:44 +02:00
dependabot[bot] 380a8417c8 chore(deps): bump actions/setup-dotnet from 5 to 6 (#88)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5 to 6.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:06:40 +02:00
Oliver Kieselbach 63826a4869 chore(dependabot): stop auto-PRing npm major bumps
Group config already batches minor/patch; majors fall outside the group and
each opens its own PR (zod 4, typescript 7, transformers 4, msal-react 5,
react-leaflet 5, react 19, @types/node 26, eslint-config-next 16 — all
breaking). Ignore version-update:semver-major for both npm ecosystems so
majors are reviewed deliberately instead of flooding the PR list. Security
updates are exempt from this filter, so vulnerability fixes still flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:03:22 +02:00
Oliver Kieselbach 7df208fac9 fix(deps): pin patched transitive deps via overrides to clear Dependabot alerts
Force the security-patched versions of transitive dependencies without
downgrading their parents (next 15, @modelcontextprotocol/sdk 1.28), which
is what `npm audit fix` would otherwise do:

- sharp ^0.35.0   (web via next, mcp via @huggingface/transformers) — libvips CVEs
- fast-uri ^3.1.4 (mcp via ajv) — host confusion
- hono ^4.12.27   (mcp via sdk) — jsx context/XSS/header advisories
- @hono/node-server ^2.0.5 (mcp via sdk)

npm audit: 0 vulnerabilities in both projects. Builds clean; MCP 447 tests
and Web 572 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 00:58:37 +02:00
42 changed files with 2345 additions and 988 deletions
+31
View File
@@ -0,0 +1,31 @@
# CodeQL configuration.
#
# The default query suites stay active — this file only filters out two C# queries
# that fire structurally on this codebase, where the flagged behavior is the feature,
# not a defect:
#
# cs/exposure-of-sensitive-information
# Audit and operational logs record WHO performed an action (actor UPN / email)
# and notification services deliver mail to a recipient address. Writing that
# identity to the log / notification IS the purpose of the field. Every such site
# is a legitimate, reviewed use — the query cannot distinguish it from a leak.
#
# cs/cleartext-storage-of-sensitive-information
# The rate limiters and distress guard use the trusted client IP as their bucket
# key. The IP is an identifier for throttling, not a secret at rest; storing it in
# the in-memory / table rate-limit key is by design.
#
# Genuine one-off false positives from OTHER queries (e.g. an open-redirect the taint
# model can't see is allow-listed, a rate-limited route it doesn't recognize) are
# dismissed individually in the Security tab so those queries stay sharp — they are
# NOT suppressed here.
#
# Log-injection (cs/log-forging) is deliberately NOT filtered: user-controlled values
# are sanitized at the sink via Helpers/LogSanitizer, so the query stays useful for
# catching a future unsanitized sink.
query-filters:
- exclude:
id: cs/exposure-of-sensitive-information
- exclude:
id: cs/cleartext-storage-of-sensitive-information
+10
View File
@@ -20,6 +20,11 @@ updates:
web:
patterns: ['*']
update-types: [minor, patch]
# Major bumps are breaking and reviewed deliberately, not auto-PR'd.
# Security updates ignore this filter, so the Security tab stays clean.
ignore:
- dependency-name: '*'
update-types: ['version-update:semver-major']
- package-ecosystem: npm
directory: /src/McpServer/autopilot-monitor-mcp
@@ -30,6 +35,11 @@ updates:
mcp:
patterns: ['*']
update-types: [minor, patch]
# Major bumps are breaking and reviewed deliberately, not auto-PR'd.
# Security updates ignore this filter, so the Security tab stays clean.
ignore:
- dependency-name: '*'
update-types: ['version-update:semver-major']
- package-ecosystem: github-actions
directory: /
+4 -4
View File
@@ -56,11 +56,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v7
# SDK 10 = single build toolchain (parity with local build.ps1 / global.json).
- name: Setup .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'
@@ -352,13 +352,13 @@ jobs:
subject-path: ${{ steps.artifact.outputs.versioned_zip_release }}
- name: Upload build artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}
path: ${{ steps.artifact.outputs.versioned_zip_release }}
- name: Upload version metadata
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: version-metadata
path: ${{ env.MANIFEST_NAME }}
+8 -8
View File
@@ -24,9 +24,9 @@ jobs:
# windows-latest is required: the agent test projects target net48.
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v5
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'
@@ -55,9 +55,9 @@ jobs:
run:
working-directory: src/Web/autopilot-monitor-web
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: actions/setup-node@v5
- uses: actions/setup-node@v7
with:
node-version: '20'
cache: npm
@@ -78,9 +78,9 @@ jobs:
run:
working-directory: src/McpServer/autopilot-monitor-mcp
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: actions/setup-node@v5
- uses: actions/setup-node@v7
with:
node-version: '20'
cache: npm
@@ -98,9 +98,9 @@ jobs:
name: Generated guardrails match rules/guardrails.json
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: actions/setup-node@v5
- uses: actions/setup-node@v7
with:
node-version: '20'
+5 -4
View File
@@ -35,24 +35,25 @@ jobs:
build-mode: none
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v5
- uses: actions/setup-dotnet@v6
if: matrix.language == 'csharp'
with:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: .github/codeql/codeql-config.yml
- name: Build
if: matrix.build-mode == 'manual'
run: dotnet build AutopilotMonitor.sln --configuration Release
- name: Analyze
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
with:
category: /language:${{ matrix.language }}
+2 -2
View File
@@ -22,9 +22,9 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: actions/setup-node@v5
- uses: actions/setup-node@v7
with:
node-version: '20'
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
name: Build and Deploy Backend
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Login to Azure (OIDC)
uses: azure/login@v3
@@ -23,7 +23,7 @@ jobs:
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Setup .NET 10
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
name: Build and Deploy MCP Server
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
# The published customer documentation lives in its own PUBLIC repo and is
# baked into the image as the search_docs corpus (see the Dockerfile). No
@@ -28,7 +28,7 @@ jobs:
# RUNNING SERVER; this workflow must be re-run. /health reports the SHA
# captured below so that staleness is checkable.
- name: Check out documentation bundle
uses: actions/checkout@v5
uses: actions/checkout@v7
with:
repository: autopilotmonitor/autopilotmonitor-docs
path: mcp-docs
+4 -1
View File
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
name: Build and Deploy Job
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
lfs: false
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
@@ -39,6 +39,9 @@ jobs:
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
# Dependabot PRs run without repo secrets, so the deploy token is absent.
# Skip (green) instead of failing the check; real deploys on main still have the token.
skip_deploy_on_missing_secrets: true
action: "upload"
###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
@@ -266,6 +266,130 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
$"expected the hung command to be killed at the timeout; took {sw.Elapsed}");
}
// -------------------------------------------------------------------
// Every remaining collector actually calls its guard
//
// The guard functions have their own unit tests, but those prove nothing about
// whether a collector consults them before touching the registry, WMI or disk.
// These drive the collectors themselves: each blocked target must come back empty,
// emit exactly one security_warning naming the target, and produce no rule output.
// -------------------------------------------------------------------
private void AssertBlocked(Dictionary<string, object> result, string target)
{
Assert.Empty(result);
var warning = SingleSecurityWarning();
Assert.Equal(true, warning.Data["blocked"]);
Assert.Equal(target, warning.Data["target"]);
Assert.DoesNotContain(_events, e => e.EventType == "guard_test");
}
[Theory]
[InlineData(@"SAM\SAM\Domains\Account\Users")]
[InlineData(@"SECURITY\Policy\Secrets")]
[InlineData(@"HKLM\SOFTWARE\Microsoft\EnrollmentsSomethingElse")] // prefix spoofing
public void RegistryCollector_OffAllowlistPath_IsBlockedBeforeTheHiveIsOpened(string target)
=> AssertBlocked(new RegistryCollector().Execute(Rule("registry", target), Context()), target);
[Theory]
[InlineData("SELECT * FROM Win32_UserAccount")]
[InlineData("SELECT * FROM Win32_ShadowCopy")]
[InlineData("SELECT * FROM Win32_OperatingSystemExtra")] // prefix spoofing
public void WmiCollector_OffAllowlistQuery_IsBlockedBeforeTheQueryRuns(string query)
=> AssertBlocked(new WmiCollector().Execute(Rule("wmi", query), Context()), query);
[Fact]
public void JsonCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
{
const string target = @"C:\Users\Public\Documents\secrets.json";
AssertBlocked(
new JsonCollector().Execute(
Rule("json", target, new Dictionary<string, string> { ["jsonpath"] = "$.token" }),
Context()),
target);
}
[Fact]
public void XmlCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
{
const string target = @"C:\Users\Public\Documents\secrets.xml";
AssertBlocked(
new XmlCollector().Execute(
Rule("xml", target, new Dictionary<string, string> { ["xpath"] = "//token" }),
Context()),
target);
}
[Fact]
public void FileCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
{
const string target = @"C:\Users\Public\Documents\secrets.txt";
AssertBlocked(new FileCollector().Execute(Rule("file", target), Context()), target);
}
[Fact]
public void JsonCollector_AllowedPath_ReadsTheFile()
{
// Counterpart to the blocked cases: proves the guard is a gate, not a wall —
// the collector does reach the file once the path is admitted.
var path = Path.Combine(_outsideUsers, "inventory.json");
File.WriteAllText(path, "{ \"agent\": { \"version\": \"2.4.0\" } }");
var result = new JsonCollector().Execute(
Rule("json", path, new Dictionary<string, string> { ["jsonpath"] = "$.agent.version" }),
Context(unrestricted: true));
Assert.DoesNotContain(_events, e => e.EventType == Constants.EventTypes.SecurityWarning);
Assert.Contains("2.4.0", string.Join(",", result.Values));
}
// -------------------------------------------------------------------
// Path traversal
// -------------------------------------------------------------------
[Fact]
public void FilePath_TraversalOutOfAnAllowedPrefix_IsBlocked()
{
// The raw string starts with an allowed prefix, so a naive StartsWith check would
// admit it. Path.GetFullPath runs FIRST, so the allowlist sees C:\Secret.txt.
const string traversal =
@"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\..\..\..\..\Secret.txt";
Assert.Equal(@"C:\Secret.txt", Path.GetFullPath(traversal));
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal));
}
[Fact]
public void FilePath_TraversalIntoTheUsersHardBlock_StaysBlockedEvenUnrestricted()
{
// Unrestricted mode lifts the allowlist but never the C:\Users privacy block —
// and it must not be reachable by spelling the path with '..' either.
const string traversal =
@"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\..\..\..\..\Users\Public\ntuser.dat";
Assert.StartsWith(@"C:\Users\", Path.GetFullPath(traversal), StringComparison.OrdinalIgnoreCase);
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal));
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal, unrestrictedMode: true));
}
[Fact]
public void FilePath_TraversalIntoTheConfigHiveDirectory_StaysBlockedEvenUnrestricted()
{
const string traversal = @"C:\Windows\Logs\..\System32\config\SAM";
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal));
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal, unrestrictedMode: true));
}
[Fact]
public void FileCollector_TraversalTarget_IsBlockedAndReported()
{
const string traversal =
@"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\..\..\..\..\Users\Public\ntuser.dat";
AssertBlocked(new FileCollector().Execute(Rule("file", traversal), Context()), traversal);
}
[Fact]
public void LogParser_ImeLogPathOverride_StillHonoursTheUsersHardBlock()
{
@@ -55,7 +55,7 @@ public class AuthFunctionSideEffectTests
{ CallBase = false };
var previewMock = new Mock<PreviewWhitelistService>(
configRepo, cache, Mock.Of<ILogger<PreviewWhitelistService>>())
configRepo, cache, Mock.Of<ILogger<PreviewWhitelistService>>(), _tenantConfigMock.Object)
{ CallBase = false };
_telegramMock = new Mock<TelegramNotificationService>(
@@ -0,0 +1,72 @@
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Tests;
public class LogSanitizerTests
{
[Fact]
public void Clean_WithCrlf_StripsNewlines()
{
// The core log-forging vector: a value that injects a fake log line.
var forged = "/api/sessions\r\n[Auth Middleware] FAKE: admin granted";
var cleaned = LogSanitizer.Clean(forged);
Assert.Equal("/api/sessions[Auth Middleware] FAKE: admin granted", cleaned);
Assert.DoesNotContain('\r', cleaned!);
Assert.DoesNotContain('\n', cleaned!);
}
[Fact]
public void Clean_WithBareLineFeedOrCarriageReturn_StripsBoth()
{
Assert.Equal("ab", LogSanitizer.Clean("a\nb"));
Assert.Equal("ab", LogSanitizer.Clean("a\rb"));
}
[Fact]
public void Clean_WithOtherControlChars_StripsThem()
{
// Tab (9), NUL (0) and ESC (27, terminal escape) are all removed. Built
// from char codes so the source file carries no literal control bytes.
var input = $"a{(char)9}b{(char)0}c{(char)27}de";
Assert.Equal("abcde", LogSanitizer.Clean(input));
}
[Fact]
public void Clean_WithUnicodeLineSeparators_StripsThem()
{
// U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are Unicode categories
// Zl/Zp, not Cc — so a \p{Cc}-only pass would let them through, and a log viewer
// or JavaScript-side reader would still break the line on them. Built from char
// codes so the source file carries no literal separator bytes.
var forged = $"/api/sessions{(char)0x2028}[Auth Middleware] FAKE: admin granted";
var cleaned = LogSanitizer.Clean(forged);
Assert.Equal("/api/sessions[Auth Middleware] FAKE: admin granted", cleaned);
Assert.DoesNotContain((char)0x2028, cleaned!);
Assert.Equal("ab", LogSanitizer.Clean($"a{(char)0x2029}b"));
}
[Theory]
[InlineData("/api/sessions/abc-123/events")]
[InlineData("user@contoso.com")]
[InlineData("11111111-2222-3333-4444-555555555555")]
[InlineData("GET")]
public void Clean_WithLegitimateValue_ReturnsUnchanged(string value)
{
// Real paths, UPNs, GUIDs and methods never contain control chars, so
// tracing fidelity is fully preserved.
Assert.Equal(value, LogSanitizer.Clean(value));
}
[Fact]
public void Clean_WithNull_ReturnsNull()
{
Assert.Null(LogSanitizer.Clean(null));
}
[Fact]
public void Clean_WithEmpty_ReturnsEmpty()
{
Assert.Equal("", LogSanitizer.Clean(""));
}
}
@@ -868,4 +868,149 @@ public class PolicyEnforcementMiddlewareTests
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
// ── Empty-UPN contract for device / anonymous routes ───────────────────────────
//
// UserRateLimitMiddleware buckets on RequestContext.UserPrincipalName and skips only when it is
// EMPTY. Publishing the "anonymous" placeholder there collapsed every agent of every tenant into a
// single shared user_ratelimit_anonymous bucket, which 429'd agent telemetry fleet-wide once the
// per-user limit was reached. These tests pin the contract: no JWT ⇒ empty UPN.
[Theory]
[InlineData("POST", "/api/agent/telemetry")]
[InlineData("GET", "/api/agent/config")]
[InlineData("POST", "/api/agent/register-session")]
[InlineData("POST", "/api/agent/upload-url")]
[InlineData("POST", "/api/agent/error")]
[InlineData("POST", "/api/bootstrap/register-session")]
[InlineData("GET", "/api/bootstrap/config")]
[InlineData("POST", "/api/bootstrap/error")]
public async Task DeviceRoute_WithoutJwt_PublishesEmptyUpn(string method, string path)
{
var h = BuildHarness();
var result = await h.Middleware.DecideAsync(method, path, null, principal: null);
Assert.True(result.Allowed);
// The rate-limit bucket key: empty means "no user bucket applies to this call".
Assert.Equal(string.Empty, result.Context!.UserPrincipalName);
// The placeholder still renders in log lines — it just never reaches the RequestContext.
Assert.Equal("anonymous", result.UserIdentifier);
}
[Theory]
[InlineData("GET", "/api/health")]
[InlineData("GET", "/api/stats/platform")]
[InlineData("POST", "/api/agent/distress")]
[InlineData("GET", "/api/diagnostics/download")]
public async Task AnonymousRoute_WithoutJwt_PublishesEmptyUpn(string method, string path)
{
var h = BuildHarness();
var result = await h.Middleware.DecideAsync(method, path, null, principal: null);
Assert.True(result.Allowed);
Assert.Equal(string.Empty, result.Context!.UserPrincipalName);
}
[Fact]
public async Task AuthenticatedRoute_StillPublishesTheRealUpn()
{
// Guard the other direction: the fix must not blank the UPN for genuine JWT callers, or
// per-user rate limiting and presence tracking would silently stop working.
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn);
var result = await h.Middleware.DecideAsync("GET", "/api/notifications", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
Assert.Equal(upn, result.Context!.UserPrincipalName);
Assert.Equal(upn, result.Context!.CallerId);
}
// ── CallerId: throttle identity survives tokens that carry no UPN ──────────────
//
// AuthenticationMiddleware validates issuer/audience/lifetime/signature but requires NO upn and no
// scp, so an app-only (client-credentials) token authenticates and reaches every AuthenticatedUser
// route (feedback, realtime/negotiate, progress/*, health/detailed, …). UserRateLimitMiddleware
// skips on an EMPTY throttle identity — so if CallerId fell back to the (absent) UPN, those tokens
// would bypass rate limiting entirely. These tests pin the fallback chain.
/// <summary>Principal with a tid but NO upn/preferred_username — shaped like an app-only token.</summary>
private static ClaimsPrincipal AppOnlyPrincipal(string tenantId, params (string Type, string Value)[] claims)
{
var all = new List<Claim> { new("tid", tenantId) };
all.AddRange(claims.Select(c => new Claim(c.Type, c.Value)));
return new ClaimsPrincipal(new ClaimsIdentity(all, "TestAuth"));
}
[Theory]
[InlineData("oid", "aaaaaaaa-0000-0000-0000-000000000001")]
[InlineData("http://schemas.microsoft.com/identity/claims/objectidentifier", "aaaaaaaa-0000-0000-0000-000000000002")]
[InlineData("sub", "subject-abc")]
[InlineData("appid", "bbbbbbbb-0000-0000-0000-000000000003")]
[InlineData("azp", "cccccccc-0000-0000-0000-000000000004")]
public async Task AuthenticatedUserRoute_TokenWithoutUpn_StillGetsAThrottleIdentity(string claimType, string claimValue)
{
var h = BuildHarness();
// /api/feedback/status is AuthenticatedUser — admits ANY valid principal, role unresolved.
var result = await h.Middleware.DecideAsync(
"GET", "/api/feedback/status", null, AppOnlyPrincipal(TenantA, (claimType, claimValue)));
Assert.True(result.Allowed);
// No UPN to report — audit/presence correctly see nothing...
Assert.Equal(string.Empty, result.Context!.UserPrincipalName);
// ...but the throttle still has something stable to bucket on.
Assert.Equal(claimValue, result.Context!.CallerId);
}
[Fact]
public async Task AuthenticatedUserRoute_TokenWithoutUpn_PrefersOidOverSubAndAppId()
{
var h = BuildHarness();
var principal = AppOnlyPrincipal(TenantA,
("appid", "cccccccc-0000-0000-0000-000000000009"),
("sub", "subject-xyz"),
("oid", "aaaaaaaa-0000-0000-0000-00000000000a"));
var result = await h.Middleware.DecideAsync("GET", "/api/feedback/status", null, principal);
Assert.True(result.Allowed);
Assert.Equal("aaaaaaaa-0000-0000-0000-00000000000a", result.Context!.CallerId);
}
[Fact]
public async Task AuthenticatedUserRoute_TokenWithNoIdentifyingClaim_FailsClosedToASharedBucket()
{
// Pathological (every real Entra token has a sub): must NOT resolve to empty, because empty
// means "skip the limiter". A shared bucket is the safe answer.
var h = BuildHarness();
var result = await h.Middleware.DecideAsync(
"GET", "/api/feedback/status", null, AppOnlyPrincipal(TenantA));
Assert.True(result.Allowed);
Assert.NotEqual(string.Empty, result.Context!.CallerId);
Assert.Equal(PolicyEnforcementMiddleware.UnidentifiedCallerBucket, result.Context!.CallerId);
// Never reuse the log placeholder as a bucket key — that was the original fleet-wide collapse.
Assert.NotEqual("anonymous", result.Context!.CallerId);
}
[Theory]
[InlineData("POST", "/api/agent/telemetry")]
[InlineData("GET", "/api/health")]
[InlineData("GET", "/api/stats/platform")]
public async Task UnauthenticatedRoute_HasEmptyCallerId(string method, string path)
{
// Empty CallerId is the ONLY signal that skips the user limiter — it must mean
// "no JWT at all", never "authenticated but unnamed".
var h = BuildHarness();
var result = await h.Middleware.DecideAsync(method, path, null, principal: null);
Assert.True(result.Allowed);
Assert.Equal(string.Empty, result.Context!.CallerId);
}
}
@@ -78,15 +78,16 @@ public class SessionDeletionMaintenanceFunctionTests
[Fact]
public async Task RunCore_emits_LongRunning_when_body_runs_past_warning_threshold()
{
// Set watchdog thresholds to sub-second; make the fanout sleep long enough to trip warning
// but not severe.
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(50), watchdogSevere: TimeSpan.FromMilliseconds(500));
// Deterministic (no wall-clock race): the fanout body blocks until the WARN watchdog has
// actually recorded its OpsEvent, then returns. Severe sits minutes out, so it can never
// fire in the moment between warn firing and the body completing (which cancels it).
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(20), watchdogSevere: TimeSpan.FromMinutes(10));
harness.SetKillSwitch(false);
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(async (SessionRetentionFanoutService.FanoutResult r, DateTime _, CancellationToken ct) =>
{
r.TenantsProcessed = 1;
await Task.Delay(150, ct);
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunning");
});
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
@@ -102,14 +103,17 @@ public class SessionDeletionMaintenanceFunctionTests
// assigned only AFTER the fanout returned, so every LongRunning warning showed zeros.
// The fanout now mutates the caller-supplied result incrementally; the watchdog snapshot
// must reflect that progress mid-run.
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(50), watchdogSevere: TimeSpan.FromSeconds(5));
// Deterministic: counters are set at fanout entry, then the body blocks until the WARN
// watchdog fires and snapshots them. Severe sits minutes out and never fires — no
// body-sleep-vs-threshold race.
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(50), watchdogSevere: TimeSpan.FromMinutes(10));
harness.SetKillSwitch(false);
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(async (SessionRetentionFanoutService.FanoutResult r, DateTime _, CancellationToken ct) =>
{
r.TenantsProcessed = 3;
r.SessionsEnqueued = 7;
await Task.Delay(200, ct); // long enough for the 50ms watchdog to fire mid-run
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunning");
});
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
@@ -122,17 +126,20 @@ public class SessionDeletionMaintenanceFunctionTests
[Fact]
public async Task RunCore_emits_LongRunningSevere_when_body_runs_past_severe_threshold()
{
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(50), watchdogSevere: TimeSpan.FromMilliseconds(150));
// Deterministic: the body blocks until the SEVERE watchdog records its OpsEvent. Warn has a
// smaller threshold, so it is guaranteed to have fired first — neither depends on a body
// sleep outlasting a fixed wall-clock threshold.
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(20), watchdogSevere: TimeSpan.FromMilliseconds(60));
harness.SetKillSwitch(false);
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(async (SessionRetentionFanoutService.FanoutResult _, DateTime _, CancellationToken ct) =>
{
await Task.Delay(300, ct);
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunningSevere");
});
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
// Both watchdogs fire: warning at 50ms, severe at 150ms.
// Both watchdogs fire: warning first (smaller threshold), then severe (which the body waited for).
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunning");
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunningSevere");
}
@@ -345,6 +352,12 @@ public class SessionDeletionMaintenanceFunctionTests
private readonly Dictionary<string, List<TableEntity>> _sessionsByState = new(StringComparer.Ordinal);
private readonly List<DeletionManifestBlobSummary> _ttlBlobs = new();
// Watchdog OpsEvents are recorded from concurrent watchdog tasks; _opsSync serializes the
// OpsEvents append + waiter signalling, and _eventWaiters lets a test's fanout body block
// until a specific watchdog OpsEvent has actually fired (see WaitForEventAsync).
private readonly object _opsSync = new();
private readonly Dictionary<string, TaskCompletionSource> _eventWaiters = new(StringComparer.Ordinal);
public Harness(TimeSpan? watchdogWarn = null, TimeSpan? watchdogSevere = null)
{
StorageMock = new Mock<TableStorageService>(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance);
@@ -376,7 +389,12 @@ public class SessionDeletionMaintenanceFunctionTests
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Returns<OpsEventEntry>(e =>
{
OpsEvents.Add(new CapturedOpsEvent(e.EventType, e.Severity, e.Message, e.Details ?? string.Empty));
lock (_opsSync)
{
OpsEvents.Add(new CapturedOpsEvent(e.EventType, e.Severity, e.Message, e.Details ?? string.Empty));
if (_eventWaiters.TryGetValue(e.EventType, out var waiter))
waiter.TrySetResult();
}
return Task.CompletedTask;
});
var alertDispatch = new OpsAlertDispatchService(
@@ -426,6 +444,27 @@ public class SessionDeletionMaintenanceFunctionTests
public void SetKillSwitch(bool active) =>
AdminConfig.Setup(a => a.IsSessionDeletionKillSwitchActiveAsync()).ReturnsAsync(active);
/// <summary>
/// Deterministic watchdog gate: returns a task that completes once an OpsEvent whose type is
/// <paramref name="eventName"/> has been recorded. A test's fanout body awaits this instead of
/// sleeping a fixed span, so the watchdog is guaranteed to have fired before the body returns —
/// removing the wall-clock race (body sleep vs sub-second thresholds) that flaked under CI load.
/// </summary>
public Task WaitForEventAsync(string eventName)
{
lock (_opsSync)
{
if (OpsEvents.Any(e => e.EventName == eventName))
return Task.CompletedTask;
if (!_eventWaiters.TryGetValue(eventName, out var waiter))
{
waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_eventWaiters[eventName] = waiter;
}
return waiter.Task;
}
}
public void SeedTtlBlobs(params (string TenantId, string SessionId, string ManifestId, DateTime LastModified)[] entries)
{
foreach (var e in entries)
@@ -1,4 +1,19 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
@@ -9,6 +24,8 @@ namespace AutopilotMonitor.Functions.Tests;
/// </summary>
public class TenantContactEmailTests
{
private const string TenantId = "77777777-7777-7777-7777-777777777777";
[Fact]
public void ContactEmail_defaults_to_null_so_absence_is_distinguishable_from_empty()
{
@@ -22,4 +39,348 @@ public class TenantContactEmailTests
var config = new TenantConfiguration { ContactEmail = "it-operations@contoso.com" };
Assert.Equal("it-operations@contoso.com", config.ContactEmail);
}
// ------------------------------------------------------------------
// Server-side validation (UpdateTenantConfigurationFunction)
// ------------------------------------------------------------------
[Theory]
[InlineData("it-operations@contoso.com")]
[InlineData("first.last+autopilot@sub.fabrikam.co.uk")]
[InlineData("UPPER@CONTOSO.COM")]
public void ValidateContactEmail_accepts_real_addresses(string email)
=> Assert.Null(UpdateTenantConfigurationFunction.ValidateContactEmail(email));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ValidateContactEmail_treats_absence_as_valid(string? email)
{
// No contact address is a legitimate state — it means we cannot reach this tenant.
Assert.Null(UpdateTenantConfigurationFunction.ValidateContactEmail(email));
}
[Theory]
[InlineData("not-an-address", "single")] // no @ at all
[InlineData("@contoso.com", "single")] // empty local part
[InlineData("ops@", "single")] // empty domain
[InlineData("ops@@contoso.com", "single")] // two @
[InlineData("ops@localhost", "dotted")] // unreachable bare host
[InlineData("ops@.contoso.com", "dotted")] // leading dot
[InlineData("ops@contoso.", "dotted")] // trailing dot
[InlineData("ops@contoso.com, attacker@evil.test", "single address")] // recipient list
[InlineData("ops@contoso.com; attacker@evil.test", "single address")]
[InlineData("Ops Team <ops@contoso.com>", "single address")] // display-name form
public void ValidateContactEmail_rejects_values_that_are_not_a_single_address(string email, string expectedHint)
{
var error = UpdateTenantConfigurationFunction.ValidateContactEmail(email);
Assert.NotNull(error);
Assert.Contains(expectedHint, error!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ValidateContactEmail_rejects_header_injection()
{
// Once this address is actually mailed, an embedded CR/LF would forge mail headers.
var error = UpdateTenantConfigurationFunction.ValidateContactEmail(
$"ops@contoso.com{(char)13}{(char)10}Bcc: attacker@evil.test");
Assert.NotNull(error);
}
[Fact]
public void ValidateContactEmail_rejects_an_over_long_value()
{
var local = new string('a', UpdateTenantConfigurationFunction.MaxContactEmailLength);
var error = UpdateTenantConfigurationFunction.ValidateContactEmail($"{local}@contoso.com");
Assert.NotNull(error);
Assert.Contains("at most", error!);
}
// ------------------------------------------------------------------
// Seed contract (TenantConfigurationService — the single owner)
// ------------------------------------------------------------------
private static (TenantConfigurationService service, Mock<IConfigRepository> repo, MemoryCache cache) BuildService()
{
var repo = new Mock<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
var service = new TenantConfigurationService(
repo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
return (service, repo, cache);
}
[Fact]
public async Task TrySeedContactEmail_when_the_write_lands_reports_success_and_drops_the_cached_row()
{
var (service, repo, cache) = BuildService();
repo.Setup(r => r.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com")).ReturnsAsync(true);
cache.Set($"tenant-config:{TenantId}", new TenantConfiguration { TenantId = TenantId });
Assert.True(await service.TrySeedContactEmailAsync(TenantId, "ops@contoso.com"));
// The repository writes behind this cache; without the eviction the freshly seeded
// address would stay invisible for the full 5-minute TTL.
Assert.False(cache.TryGetValue($"tenant-config:{TenantId}", out _));
}
[Fact]
public async Task TrySeedContactEmail_when_the_tenant_already_owns_an_address_leaves_the_cache_alone()
{
var (service, repo, cache) = BuildService();
repo.Setup(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(false);
var cached = new TenantConfiguration { TenantId = TenantId, ContactEmail = "owner@contoso.com" };
cache.Set($"tenant-config:{TenantId}", cached);
Assert.False(await service.TrySeedContactEmailAsync(TenantId, "ops@contoso.com"));
Assert.True(cache.TryGetValue($"tenant-config:{TenantId}", out TenantConfiguration? still));
Assert.Equal("owner@contoso.com", still!.ContactEmail);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task TrySeedContactEmail_with_no_address_never_reaches_storage(string? email)
{
var (service, repo, _) = BuildService();
Assert.False(await service.TrySeedContactEmailAsync(TenantId, email));
repo.Verify(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task TrySeedContactEmail_with_no_tenant_never_reaches_storage()
{
var (service, repo, _) = BuildService();
Assert.False(await service.TrySeedContactEmailAsync(" ", "ops@contoso.com"));
repo.Verify(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task TrySeedContactEmail_trims_before_it_reaches_storage()
{
var (service, repo, _) = BuildService();
repo.Setup(r => r.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com")).ReturnsAsync(true);
Assert.True(await service.TrySeedContactEmailAsync(TenantId, " ops@contoso.com "));
repo.Verify(r => r.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"), Times.Once);
}
[Fact]
public async Task TrySeedContactEmail_never_writes_the_whole_configuration()
{
// Regression guard: the seed used to be a read-modify-write of the full model through
// SaveTenantConfigurationAsync, which replaces the row unconditionally — a concurrent
// portal save was clobbered by the seeder's stale snapshot of EVERY field.
var (service, repo, _) = BuildService();
repo.Setup(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(true);
await service.TrySeedContactEmailAsync(TenantId, "ops@contoso.com");
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
}
// ------------------------------------------------------------------
// Seed trigger (PreviewWhitelistService)
// ------------------------------------------------------------------
private static (PreviewWhitelistService preview, Mock<IConfigRepository> repo) BuildPreview()
{
var repo = new Mock<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
var tenantConfig = new TenantConfigurationService(
repo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
var preview = new PreviewWhitelistService(
repo.Object, cache, NullLogger<PreviewWhitelistService>.Instance, tenantConfig);
return (preview, repo);
}
[Fact]
public async Task SaveNotificationEmail_persists_the_address_and_seeds_the_contact_field()
{
var (preview, repo) = BuildPreview();
repo.Setup(r => r.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com")).ReturnsAsync(true);
await preview.SaveNotificationEmailAsync(TenantId, "ops@contoso.com");
repo.Verify(r => r.SaveNotificationEmailAsync(TenantId, "ops@contoso.com"), Times.Once);
repo.Verify(r => r.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"), Times.Once);
}
[Fact]
public async Task SaveNotificationEmail_clearing_the_address_does_not_seed()
{
var (preview, repo) = BuildPreview();
await preview.SaveNotificationEmailAsync(TenantId, null);
repo.Verify(r => r.SaveNotificationEmailAsync(TenantId, null), Times.Once);
repo.Verify(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task SaveNotificationEmail_still_succeeds_when_the_seed_blows_up()
{
// The notification email has already been persisted at that point; a failing side
// effect must not turn a completed write into a failed request.
var (preview, repo) = BuildPreview();
repo.Setup(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()))
.ThrowsAsync(new RequestFailedException(503, "throttled"));
await preview.SaveNotificationEmailAsync(TenantId, "ops@contoso.com");
repo.Verify(r => r.SaveNotificationEmailAsync(TenantId, "ops@contoso.com"), Times.Once);
}
// ------------------------------------------------------------------
// Conditional write (TableConfigRepository) — the actual invariant
// ------------------------------------------------------------------
private sealed class RepoHarness
{
public TableConfigRepository Sut { get; }
public Mock<TableClient> Table { get; } = new();
public List<(TableEntity Entity, ETag IfMatch, TableUpdateMode Mode)> Updates { get; } = new();
public RepoHarness(TableEntity? stored, RequestFailedException? throwOnUpdate = null)
{
if (stored == null)
{
Table.Setup(c => c.GetEntityAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(404, "Not Found", "ResourceNotFound", null));
}
else
{
Table.Setup(c => c.GetEntityAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(stored, Mock.Of<Response>()));
}
Table.Setup(c => c.UpdateEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<ETag>(),
It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, tag, mode, _) =>
{
if (throwOnUpdate != null)
throw throwOnUpdate;
Updates.Add((e, tag, mode));
return Task.FromResult(Mock.Of<Response>());
});
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
Sut = new TableConfigRepository(
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
NullLogger<TableConfigRepository>.Instance);
}
}
private static TableEntity StoredConfig(string? contactEmail, string etag = "W/\"stored\"")
{
var entity = new TableEntity(TenantId, "config") { ["DomainName"] = "contoso.com" };
if (contactEmail != null)
entity["ContactEmail"] = contactEmail;
entity.ETag = new ETag(etag);
return entity;
}
[Fact]
public async Task Repo_seeds_only_the_contact_field_and_only_conditionally()
{
var harness = new RepoHarness(StoredConfig(contactEmail: null));
Assert.True(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, " ops@contoso.com "));
var (entity, ifMatch, mode) = Assert.Single(harness.Updates);
// Merge, not Replace: every other stored property must survive untouched.
Assert.Equal(TableUpdateMode.Merge, mode);
Assert.Equal(new ETag("W/\"stored\""), ifMatch);
Assert.Equal("ops@contoso.com", entity["ContactEmail"]);
Assert.False(entity.ContainsKey("DomainName"));
}
[Fact]
public async Task Repo_never_overwrites_an_address_the_tenant_already_owns()
{
var harness = new RepoHarness(StoredConfig("owner@contoso.com"));
Assert.False(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"));
Assert.Empty(harness.Updates);
}
[Fact]
public async Task Repo_treats_a_whitespace_only_stored_value_as_absent()
{
var harness = new RepoHarness(StoredConfig(" "));
Assert.True(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"));
Assert.Single(harness.Updates);
}
[Fact]
public async Task Repo_loses_the_race_rather_than_clobbering_a_concurrent_write()
{
// Someone wrote the row between our read and our write. The ETag precondition turns
// that into a 412; the seed abandons rather than overwriting the winner.
var harness = new RepoHarness(
StoredConfig(contactEmail: null),
throwOnUpdate: new RequestFailedException(412, "Precondition Failed", "UpdateConditionNotSatisfied", null));
Assert.False(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"));
}
[Fact]
public async Task Repo_with_no_configuration_row_reports_no_seed()
{
var harness = new RepoHarness(stored: null);
Assert.False(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"));
Assert.Empty(harness.Updates);
}
[Fact]
public async Task Repo_is_fail_soft_on_a_storage_fault()
{
// Callers are side effects of an already-committed write — a lost seed is recoverable
// on the next maintenance run, a thrown exception is not.
var harness = new RepoHarness(
StoredConfig(contactEmail: null),
throwOnUpdate: new RequestFailedException(503, "Server Busy"));
Assert.False(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"));
}
[Theory]
[InlineData(null, "ops@contoso.com")]
[InlineData("", "ops@contoso.com")]
[InlineData(TenantId, null)]
[InlineData(TenantId, " ")]
public async Task Repo_rejects_incomplete_input_without_touching_storage(string? tenantId, string? email)
{
var harness = new RepoHarness(StoredConfig(contactEmail: null));
Assert.False(await harness.Sut.TrySeedTenantContactEmailAsync(tenantId!, email!));
Assert.Empty(harness.Updates);
harness.Table.Verify(c => c.GetEntityAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()), Times.Never);
}
}
@@ -87,7 +87,11 @@ public sealed class TenantOffboardFunctionTests
return new PreviewWhitelistService(
configRepoMock.Object,
cache,
NullLogger<PreviewWhitelistService>.Instance);
NullLogger<PreviewWhitelistService>.Instance,
new TenantConfigurationService(
configRepoMock.Object,
NullLogger<TenantConfigurationService>.Instance,
cache));
}
/// <summary>
@@ -47,6 +47,12 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<!-- Security pins: Microsoft.Identity.Web 4.3.0 pulls these transitively at 10.0.0, which carries
known advisories (GHSA-9mv3-2cwr-p262 DataProtection; GHSA-37gx-xxp4-5rgx and siblings on
Cryptography.Xml). Force the patched 10.0.10 servicing build. Remove once Identity.Web ships
a dependency floor past the fix. -->
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.10" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
<PackageReference Include="Resend" Version="0.2.1" />
</ItemGroup>
@@ -71,6 +71,54 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
/// <summary>
/// Conditional, single-property seed of ContactEmail (see <see cref="IConfigRepository"/>).
/// Merge mode leaves every other stored property untouched, and the ETag precondition turns
/// a concurrent write into a 412 rather than a silent overwrite. Both matter: the caller is
/// a background/side-effect path that must never beat a tenant admin's own edit.
/// </summary>
public async Task<bool> TrySeedTenantContactEmailAsync(string tenantId, string email)
{
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(email))
return false;
try
{
var existing = await _tenantConfigTableClient.GetEntityAsync<TableEntity>(tenantId, "config");
// The tenant already owns an address — the seed is one-way and never re-syncs.
if (!string.IsNullOrWhiteSpace(existing.Value.GetString("ContactEmail")))
return false;
var patch = new TableEntity(tenantId, "config")
{
["ContactEmail"] = email.Trim()
};
await _tenantConfigTableClient.UpdateEntityAsync(
patch, existing.Value.ETag, TableUpdateMode.Merge);
return true;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// No configuration row yet — nothing to seed onto.
return false;
}
catch (RequestFailedException ex) when (ex.Status == 412)
{
// Someone wrote the row between the read and the write. They win by construction:
// a lost seed is recoverable (the backfill retries), a lost admin edit is not.
_logger.LogInformation(
"Contact address seed for {TenantId} skipped — the configuration changed concurrently", tenantId);
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error seeding contact address for tenant {TenantId}", tenantId);
return false;
}
}
public async Task<List<TenantConfiguration>> GetAllTenantConfigurationsAsync()
{
try
@@ -1,3 +1,4 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
@@ -72,7 +73,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
_logger.LogWarning("Failed to increment user usage after {MaxRetries} retries: user={UserId}, endpoint={Endpoint}",
maxRetries, userId, endpoint);
maxRetries, LogSanitizer.Clean(userId), LogSanitizer.Clean(endpoint));
}
public async Task<List<UserUsageRecord>> GetUsageByUserAsync(string userId, string? dateFrom = null, string? dateTo = null)
@@ -52,6 +52,34 @@ public static class ClaimsPrincipalExtensions
?? principal.FindFirst("preferred_username")?.Value;
}
/// <summary>
/// Stable per-caller identity for THROTTLING keys — never for authorization or audit.
/// <para>
/// Prefers the UPN so a human caller keeps the same bucket as before. Falls back to the token
/// subject (<c>oid</c> → <c>sub</c> → <c>appid</c>/<c>azp</c>) because a valid token need not carry
/// a user identity at all: AuthenticationMiddleware validates issuer/audience/lifetime/signature
/// but requires no <c>upn</c>, so an app-only (client-credentials) token authenticates fine and
/// reaches every AuthenticatedUser route. Without this fallback such a caller would present an
/// empty identity and skip per-user rate limiting entirely.
/// </para>
/// <para>
/// Both the raw and the WS-Fed-mapped claim names are checked: JwtSecurityTokenHandler runs with
/// inbound claim mapping ON, so <c>oid</c>/<c>sub</c> may arrive under their schema URIs.
/// </para>
/// Returns null only for a principal carrying no identifying claim whatsoever; callers must
/// decide fail-closed what that means (see <c>RequestContext.CallerId</c>).
/// </summary>
public static string? GetCallerId(this ClaimsPrincipal principal)
{
return principal.GetUserPrincipalName()
?? principal.FindFirst("oid")?.Value
?? principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value
?? principal.FindFirst("sub")?.Value
?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? principal.FindFirst("appid")?.Value
?? principal.FindFirst("azp")?.Value;
}
/// <summary>
/// Gets the user's display name from token claims
/// Supports both v1.0 and v2.0 token formats
@@ -76,6 +76,19 @@ namespace AutopilotMonitor.Functions.Functions.Config
return badRequest;
}
// The contact address is where enforcement actions and service notices are sent,
// so it must be an address rather than whatever string a direct API caller posts.
var contactEmailError = ValidateContactEmail(config.ContactEmail);
if (contactEmailError != null)
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid contact email: {contactEmailError}" });
return badRequest;
}
// Normalize so the stored value never carries surrounding whitespace, and an
// all-whitespace submission clears the field instead of masquerading as a value.
config.ContactEmail = string.IsNullOrWhiteSpace(config.ContactEmail) ? null : config.ContactEmail.Trim();
// Load the stored config up-front so we can (1) restore any redacted secret placeholders
// before validation/save and (2) protect GA-only fields below.
var existingConfig = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
@@ -251,6 +264,52 @@ namespace AutopilotMonitor.Functions.Functions.Config
private const int MaxCustomHeaderCount = 25;
private const int MaxNotificationChannelsJsonLength = 65536;
// RFC 5321 caps a forward path at 254 characters.
internal const int MaxContactEmailLength = 254;
/// <summary>
/// Validates the tenant contact address. Returns an error message, or null when valid/empty.
/// Empty is legitimate — it means we have no way to reach the tenant.
/// <para>
/// Deliberately not an RFC 5322 parser: the job is to reject values that are not addresses
/// at all. Specifically it rejects recipient lists (a comma would silently widen who receives
/// service notices), display-name forms, and control characters (which would let a caller
/// forge mail headers once this address is actually mailed).
/// </para>
/// </summary>
internal static string? ValidateContactEmail(string? email)
{
if (string.IsNullOrWhiteSpace(email))
return null;
var trimmed = email.Trim();
if (trimmed.Length > MaxContactEmailLength)
return $"must be at most {MaxContactEmailLength} characters.";
foreach (var ch in trimmed)
{
if (char.IsControl(ch))
return "must not contain control characters.";
if (char.IsWhiteSpace(ch) || ch == ',' || ch == ';' || ch == '<' || ch == '>')
return "must be a single address, without spaces, separators or angle brackets.";
}
var at = trimmed.IndexOf('@');
if (at <= 0 || at != trimmed.LastIndexOf('@') || at == trimmed.Length - 1)
return "must contain a single \"@\" with text on both sides.";
// A bare host with no dot is unreachable from our sender, so it is a typo, not an address.
var domain = trimmed.Substring(at + 1);
if (!domain.Contains('.') || domain.StartsWith(".", StringComparison.Ordinal)
|| domain.EndsWith(".", StringComparison.Ordinal))
{
return "the domain part must be a dotted host name.";
}
return null;
}
/// <summary>
/// Validates the notification-channel list JSON. Returns an error message, or null when
/// valid/empty. Strict counterpart of the fail-soft <c>NotificationChannel.ParseList</c>:
@@ -1,5 +1,7 @@
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Diagnostics;
using AutopilotMonitor.Shared.Diagnostics;
using Microsoft.Azure.Functions.Worker;
@@ -17,21 +19,29 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
/// from the query string, so a ticket cannot be retargeted.
/// <para>
/// Replay is bounded by the 10-min ticket TTL, the admin size cap, and the fact that the ticket
/// only ever points at the minting caller's own authorized blob; HMAC forgery is infeasible, so
/// no additional rate limiting is layered here.
/// only ever points at the minting caller's own authorized blob; HMAC forgery is infeasible. On top
/// of that the route carries a per-client-IP limit as defense in depth — it is unauthenticated and
/// streams whole blobs, so a valid ticket replayed in a loop would otherwise be unbounded egress.
/// </para>
/// </summary>
public class DiagnosticsTicketDownloadFunction
{
// Bounds replay of a still-valid ticket. A real client downloads a package once; the ceiling
// only has to stay clear of a legitimate retry-after-timeout.
private const int MaxRequestsPerMinutePerIp = 30;
private readonly ILogger<DiagnosticsTicketDownloadFunction> _logger;
private readonly DiagnosticsBlobStreamer _streamer;
private readonly RateLimitService _rateLimitService;
public DiagnosticsTicketDownloadFunction(
ILogger<DiagnosticsTicketDownloadFunction> logger,
DiagnosticsBlobStreamer streamer)
DiagnosticsBlobStreamer streamer,
RateLimitService rateLimitService)
{
_logger = logger;
_streamer = streamer;
_rateLimitService = rateLimitService;
}
[Function("DiagnosticsTicketDownload")]
@@ -40,6 +50,23 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
{
try
{
// Rightmost X-Forwarded-For hop only — leftmost entries are caller-controlled.
var clientIp = ClientIpExtractor.GetTrustedClientIp(req);
var rateLimitResult = _rateLimitService.CheckRateLimit(
$"diag_ticket_download_{clientIp}", MaxRequestsPerMinutePerIp);
if (!rateLimitResult.IsAllowed)
{
_logger.LogWarning("DiagnosticsTicketDownload rate limit exceeded for IP {ClientIp} ({Count} requests)",
clientIp, rateLimitResult.RequestsInWindow);
var tooMany = req.CreateResponse(HttpStatusCode.TooManyRequests);
if (rateLimitResult.RetryAfter.HasValue)
tooMany.Headers.Add("Retry-After", ((int)rateLimitResult.RetryAfter.Value.TotalSeconds).ToString());
await tooMany.WriteAsJsonAsync(new { success = false, message = "Rate limit exceeded." });
return tooMany;
}
var ticket = HttpUtility.ParseQueryString(req.Url.Query)["t"];
if (string.IsNullOrEmpty(ticket))
{
@@ -1,4 +1,6 @@
using System.Net;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -9,16 +11,27 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
/// <summary>
/// Public endpoint - no authentication required.
/// Returns pre-computed platform stats for the landing page.
/// Rate-limited per client IP: the route is unauthenticated and hits Table Storage on every call,
/// so it needs a limit of its own — no user bucket applies to an anonymous caller.
/// </summary>
public class GetPlatformStatsFunction
{
// Landing-page widget: one call per page load. Generous enough that a shared corporate NAT
// never trips it, tight enough to bound an unauthenticated storage read.
private const int MaxRequestsPerMinutePerIp = 60;
private readonly ILogger<GetPlatformStatsFunction> _logger;
private readonly IMetricsRepository _metricsRepo;
private readonly RateLimitService _rateLimitService;
public GetPlatformStatsFunction(ILogger<GetPlatformStatsFunction> logger, IMetricsRepository metricsRepo)
public GetPlatformStatsFunction(
ILogger<GetPlatformStatsFunction> logger,
IMetricsRepository metricsRepo,
RateLimitService rateLimitService)
{
_logger = logger;
_metricsRepo = metricsRepo;
_rateLimitService = rateLimitService;
}
[Function("GetPlatformStats")]
@@ -27,6 +40,24 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
{
try
{
// Rightmost X-Forwarded-For hop only — leftmost entries are caller-controlled and would
// let one client rotate the rate-limit key on every request.
var clientIp = ClientIpExtractor.GetTrustedClientIp(req);
var rateLimitResult = _rateLimitService.CheckRateLimit(
$"platform_stats_{clientIp}", MaxRequestsPerMinutePerIp);
if (!rateLimitResult.IsAllowed)
{
_logger.LogWarning("Platform stats rate limit exceeded for IP {ClientIp} ({Count} requests)",
clientIp, rateLimitResult.RequestsInWindow);
var tooMany = req.CreateResponse(HttpStatusCode.TooManyRequests);
if (rateLimitResult.RetryAfter.HasValue)
tooMany.Headers.Add("Retry-After", ((int)rateLimitResult.RetryAfter.Value.TotalSeconds).ToString());
await tooMany.WriteAsJsonAsync(new { error = "Rate limit exceeded" });
return tooMany;
}
var stats = await _metricsRepo.GetPlatformStatsAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -0,0 +1,36 @@
using System.Text.RegularExpressions;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// Strips control characters from user-controlled values before they reach a log
/// sink, so a caller cannot forge additional log lines (CRLF injection) or smuggle
/// terminal escape sequences through the message. Structured logging already stores
/// each value as a discrete property, so this is defense-in-depth — it also clears
/// the CodeQL cs/log-forging alerts by removing the newline characters they key on.
/// Only ever apply to the LOGGED copy of a value; never to a value used for routing,
/// authorization, or storage keys.
/// </summary>
public static class LogSanitizer
{
// \p{Cc} = Unicode "Other, control" (C0 + C1 ranges), which includes CR and LF.
// \p{Zl} and \p{Zp} are the line and paragraph separators (U+2028 / U+2029). They
// are separators, not control characters, so \p{Cc} misses them — yet a log viewer,
// a JSON-lines consumer, or any JavaScript-side reader still breaks a line on them.
private static readonly Regex ControlChars = new(@"[\p{Cc}\p{Zl}\p{Zp}]", RegexOptions.Compiled);
/// <summary>
/// Removes CR, LF, the Unicode line/paragraph separators, and every other control
/// character from <paramref name="value"/>. Returns null/empty inputs unchanged.
/// </summary>
public static string? Clean(string? value)
{
if (string.IsNullOrEmpty(value))
return value;
// Explicit CR/LF removal first — this is the log-injection barrier the CodeQL
// taint model recognizes; the regex pass then drops any remaining separators.
var sanitized = value.Replace("\r", string.Empty).Replace("\n", string.Empty);
return ControlChars.Replace(sanitized, string.Empty);
}
}
@@ -19,6 +19,23 @@ public sealed record RequestContext
/// <summary>The user's UPN (from JWT upn/preferred_username claim).</summary>
public string UserPrincipalName { get; init; } = string.Empty;
/// <summary>
/// Stable identity of the authenticated caller, for THROTTLING keys only — never authorization,
/// never audit (use <see cref="UserPrincipalName"/> there; this may be a bare object id).
/// <para>
/// Empty ⇔ the request carried no JWT at all (device/bootstrap or anonymous route). Those routes
/// bring their own limits — per cert thumbprint / per bootstrap token in SecurityValidator, per
/// client IP on the public endpoints — so an empty value means "no user bucket applies here".
/// </para>
/// <para>
/// It is deliberately NOT the same field as <see cref="UserPrincipalName"/>: a valid token need
/// not carry a UPN (app-only/client-credentials tokens do not), and such a caller still reaches
/// every AuthenticatedUser route. Keying the throttle off the UPN alone let those tokens through
/// unlimited; keying audit off this would write object ids where a UPN is expected.
/// </para>
/// </summary>
public string CallerId { get; init; } = string.Empty;
/// <summary>True if the user is a Global Admin of the platform (full read + write).</summary>
public bool IsGlobalAdmin { get; init; }
@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Http;
using System.Net;
using System.Security.Claims;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Middleware;
@@ -57,9 +58,12 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
// JWT validation. The exempt set is DERIVED from EndpointAccessPolicyCatalog — never a
// hand-kept parallel list — so a new anonymous/device route can't drift out of sync here.
var requestPath = httpContext.Request.Path.Value ?? string.Empty;
// Sanitized copies for logging only — never used for routing/authorization decisions.
var logPath = LogSanitizer.Clean(requestPath);
var logMethod = LogSanitizer.Clean(httpContext.Request.Method);
if (SkipsJwtValidation(httpContext.Request.Method, requestPath))
{
_logger.LogDebug("[Auth Middleware] JWT-exempt route: {Method} {Path}", httpContext.Request.Method, requestPath);
_logger.LogDebug("[Auth Middleware] JWT-exempt route: {Method} {Path}", logMethod, logPath);
await next(context);
return;
}
@@ -68,7 +72,7 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
var authHeader = httpContext.Request.Headers.Authorization.ToString();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("[Auth Middleware] Blocked unauthenticated request to {Path}", requestPath);
_logger.LogWarning("[Auth Middleware] Blocked unauthenticated request to {Path}", logPath);
httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsJsonAsync(new
@@ -96,7 +100,7 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
// MSA, tid 9188040d-…) carries a GUID tid, so this reject is loss-free.
if (!IsValidTenantId(tenantId))
{
_logger.LogWarning("[Auth Middleware] Rejected token with missing/non-GUID tid for {Path}", requestPath);
_logger.LogWarning("[Auth Middleware] Rejected token with missing/non-GUID tid for {Path}", logPath);
throw new SecurityTokenValidationException("Token tid claim is missing or not a valid GUID");
}
@@ -207,7 +211,7 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
if (!authenticated)
{
_logger.LogWarning("[Auth Middleware] Blocked request with invalid token to {Path}", requestPath);
_logger.LogWarning("[Auth Middleware] Blocked request with invalid token to {Path}", logPath);
httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsJsonAsync(new
@@ -159,7 +159,7 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
}
catch (Exception ex)
{
logger.LogWarning(ex, "[McpQuota] Failed to record usage: user={UserId}, endpoint={Endpoint}", oid, normalizedEndpoint);
logger.LogWarning(ex, "[McpQuota] Failed to record usage: user={UserId}, endpoint={Endpoint}", LogSanitizer.Clean(oid), LogSanitizer.Clean(normalizedEndpoint));
}
});
}
@@ -23,6 +23,15 @@ namespace AutopilotMonitor.Functions.Middleware;
/// </summary>
public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
{
/// <summary>
/// Throttle bucket for an authenticated token that carries no identifying claim at all (no upn,
/// oid, sub, appid or azp). Every real Entra token has at least a <c>sub</c>, so this is a
/// fail-closed backstop rather than a live code path: such callers share one bucket instead of
/// escaping the limiter. Deliberately NOT "anonymous" — that value is reserved for the log
/// rendering of unauthenticated calls and must never become a rate-limit key again.
/// </summary>
internal const string UnidentifiedCallerBucket = "unidentified-token";
private readonly ILogger<PolicyEnforcementMiddleware> _logger;
private readonly GlobalAdminService _globalAdminService;
private readonly DelegatedAdminService _delegatedAdminService;
@@ -57,6 +66,10 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
var httpMethod = httpContext.Request.Method;
var queryTenantId = httpContext.Request.Query["tenantId"].FirstOrDefault();
// Sanitized copies for logging only — never used for routing/authorization decisions.
var logPath = LogSanitizer.Clean(requestPath);
var logMethod = LogSanitizer.Clean(httpMethod);
// Authenticated principal set by AuthenticationMiddleware (null on anonymous/device routes).
// Read from FunctionContext.Items (reliable in the isolated worker) rather than httpContext.User.
ClaimsPrincipal? principal = null;
@@ -76,7 +89,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
}
catch (Exception ex)
{
_logger.LogError(ex, "[PolicyEnforcement] Service error evaluating policy for {Method} {Path}", httpMethod, requestPath);
_logger.LogError(ex, "[PolicyEnforcement] Service error evaluating policy for {Method} {Path}", logMethod, logPath);
httpContext.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
httpContext.Response.ContentType = "application/json";
httpContext.Response.Headers["Retry-After"] = "5";
@@ -92,13 +105,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
{
context.Items[RequestContext.ItemsKey] = result.Context!;
_logger.LogDebug("[PolicyEnforcement] ALLOW {Method} {Path} user={User} role={Role}",
httpMethod, requestPath, result.UserIdentifier, result.UserRole);
logMethod, logPath, LogSanitizer.Clean(result.UserIdentifier), result.UserRole);
await next(context);
return;
}
_logger.LogWarning("[PolicyEnforcement] DENIED {Method} {Path} status={Status} user={User} role={Role} reason={Reason}",
httpMethod, requestPath, result.StatusCode, result.UserIdentifier, result.UserRole, result.LogReason);
logMethod, logPath, result.StatusCode, LogSanitizer.Clean(result.UserIdentifier), result.UserRole, result.LogReason);
httpContext.Response.StatusCode = result.StatusCode;
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsJsonAsync(new { error = result.ErrorCode, message = result.ErrorMessage });
@@ -115,12 +128,16 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
internal async Task<PolicyResult> DecideAsync(
string httpMethod, string requestPath, string? queryTenantId, ClaimsPrincipal? principal)
{
// Sanitized copies for logging only — never used for routing/authorization decisions.
var logPath = LogSanitizer.Clean(requestPath);
var logMethod = LogSanitizer.Clean(httpMethod);
var catalogEntry = EndpointAccessPolicyCatalog.FindPolicy(httpMethod, requestPath);
if (catalogEntry == null)
{
// Fail-closed: unregistered route → 403
_logger.LogError("[PolicyEnforcement] BLOCKED unregistered route: {Method} {Path} — not in catalog (fail-closed)",
httpMethod, requestPath);
logMethod, logPath);
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "Forbidden", "Access denied.", "anonymous", "N/A", "Unregistered");
}
@@ -188,7 +205,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
if (crossTenant && !hasGlobalScope && !delegatedGrantsRead)
{
_logger.LogWarning("[PolicyEnforcement] BLOCKED cross-tenant access: user={User} jwtTenant={JwtTenant} target={Target} scoping={Scoping} path={Path}",
decision.UserIdentifier, jwtTenantId, namedTarget, catalogEntry.TenantScoping, requestPath);
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(jwtTenantId), LogSanitizer.Clean(namedTarget), catalogEntry.TenantScoping, logPath);
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "CrossTenantAccessDenied",
"Access denied. You can only access your own tenant's resources.",
decision.UserIdentifier, decision.UserRole, "CrossTenant");
@@ -206,7 +223,21 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
{
TenantId = jwtTenantId,
TargetTenantId = targetTenantId,
UserPrincipalName = decision.UserIdentifier,
// ONLY a genuine JWT UPN lands here — never the "anonymous" placeholder that
// decision.UserIdentifier carries on device/anonymous routes. The placeholder used to be
// published here, which collapsed the entire agent fleet (every tenant) into one shared
// user_ratelimit_anonymous bucket and 429'd agent telemetry fleet-wide. It stays on
// decision.UserIdentifier for log lines, where "anonymous" is the useful rendering.
UserPrincipalName = upn ?? string.Empty,
// Throttle identity — NOT the same thing as the UPN. Empty only when the request carried
// no JWT at all (device/anonymous routes, which bring their own limits). An authenticated
// caller ALWAYS gets a non-empty value, including app-only tokens that carry no upn:
// those authenticate fine and reach every AuthenticatedUser route, so keying the throttle
// off the UPN alone would let them through unlimited. Fail closed if a token somehow
// carries no identifying claim at all — a shared bucket is the safe answer there.
CallerId = principal is null
? string.Empty
: (principal.GetCallerId() ?? UnidentifiedCallerBucket),
IsGlobalAdmin = isGlobalAdmin,
IsGlobalReader = isGlobalReader,
// Delegated flags reflect the caller's role for THIS request (null unless a delegated grant
@@ -9,9 +9,14 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Middleware;
/// <summary>
/// Per-user rate limiting for authenticated (JWT) requests.
/// Runs after PolicyEnforcementMiddleware so RequestContext (UPN, IsGlobalAdmin) is already resolved.
/// Agent/device routes have no RequestContext and are automatically skipped.
/// Per-caller rate limiting for authenticated (JWT) requests.
/// Runs after PolicyEnforcementMiddleware so RequestContext (CallerId, IsGlobalAdmin) is already resolved.
/// Buckets on RequestContext.CallerId — the throttle identity, which is the UPN for a human caller and
/// the token subject (oid/sub/appid) for a token that carries none. Device/bootstrap and anonymous
/// routes resolve to an EMPTY CallerId and are skipped here; they carry their own limits (per cert
/// thumbprint / per bootstrap token in SecurityValidator, per client IP on the public endpoints).
/// Keying on CallerId rather than UserPrincipalName is what keeps BOTH holes shut: the agent fleet out
/// of one shared bucket, and app-only tokens inside a bucket at all.
/// </summary>
public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
{
@@ -45,9 +50,12 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
}
var requestContext = context.GetRequestContext();
if (string.IsNullOrEmpty(requestContext.UserPrincipalName))
if (string.IsNullOrEmpty(requestContext.CallerId))
{
// Anonymous or device-auth route — no user to rate limit
// No JWT on this request — device-auth or anonymous route. Those limit themselves
// (per cert thumbprint / bootstrap token in SecurityValidator, per client IP on the
// public endpoints). Gate on CallerId, NOT UserPrincipalName: a valid app-only token
// carries no upn, and skipping on that would exempt it from throttling entirely.
await next(context);
return;
}
@@ -96,13 +104,13 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
config.GlobalAdminRateLimitRequestsPerMinute,
entitlementFloor);
var key = $"user_ratelimit_{requestContext.UserPrincipalName.ToLowerInvariant()}";
var key = $"user_ratelimit_{requestContext.CallerId.ToLowerInvariant()}";
result = _rateLimitService.CheckRateLimit(key, limit);
}
catch (Exception ex)
{
_logger.LogError(ex, "[UserRateLimit] Rate limit check failed for user={User}, allowing request (fail-open)",
requestContext.UserPrincipalName);
_logger.LogError(ex, "[UserRateLimit] Rate limit check failed for caller={Caller}, allowing request (fail-open)",
requestContext.CallerId);
await next(context);
return;
}
@@ -127,8 +135,8 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
: 60;
_logger.LogWarning(
"[UserRateLimit] THROTTLED user={User} requests={Count}/{Max} retryAfter={RetryAfter}s",
requestContext.UserPrincipalName, result.RequestsInWindow, result.MaxRequests, retryAfterSeconds);
"[UserRateLimit] THROTTLED caller={Caller} requests={Count}/{Max} retryAfter={RetryAfter}s",
requestContext.CallerId, result.RequestsInWindow, result.MaxRequests, retryAfterSeconds);
httpContext.Response.StatusCode = (int)HttpStatusCode.TooManyRequests;
httpContext.Response.Headers["Retry-After"] = retryAfterSeconds.ToString();
@@ -527,6 +527,13 @@ namespace AutopilotMonitor.Functions.Services
/// the seed happens when the notification address is saved, so this converges to a
/// no-op and can be removed once every existing tenant has been covered.
/// </para>
/// <para>
/// The enumerated snapshot is only a cheap pre-filter; the authoritative "is it still
/// empty" check happens under an ETag inside
/// <see cref="TenantConfigurationService.TrySeedContactEmailAsync"/>. A full-model save
/// here would write a snapshot that is minutes old by the time the loop reaches this
/// tenant, silently reverting whatever an admin changed in between.
/// </para>
/// </summary>
/// <returns>Number of tenants that received a contact address.</returns>
private async Task<int> BackfillTenantContactEmailsAsync()
@@ -546,9 +553,8 @@ namespace AutopilotMonitor.Functions.Services
if (string.IsNullOrWhiteSpace(email))
continue;
config.ContactEmail = email.Trim();
await _tenantConfigService.SaveConfigurationAsync(config);
filled++;
if (await _tenantConfigService.TrySeedContactEmailAsync(config.TenantId, email))
filled++;
}
if (filled > 0)
@@ -16,16 +16,19 @@ public class PreviewWhitelistService
private readonly IConfigRepository _configRepo;
private readonly IMemoryCache _cache;
private readonly ILogger<PreviewWhitelistService> _logger;
private readonly TenantConfigurationService _tenantConfigService;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public PreviewWhitelistService(
IConfigRepository configRepo,
IMemoryCache cache,
ILogger<PreviewWhitelistService> logger)
ILogger<PreviewWhitelistService> logger,
TenantConfigurationService tenantConfigService)
{
_configRepo = configRepo;
_cache = cache;
_logger = logger;
_tenantConfigService = tenantConfigService;
}
/// <summary>
@@ -104,42 +107,15 @@ public class PreviewWhitelistService
/// <summary>
/// Saves (or clears) the notification email for a tenant, and seeds the tenant's
/// contact address from it the first time one is given.
/// <para>
/// The seed is a side effect of a write that has already been persisted, so it must never
/// fail this call — <see cref="TenantConfigurationService.TrySeedContactEmailAsync"/> is
/// fail-soft and owns the "never overwrite what the tenant owns" invariant.
/// </para>
/// </summary>
public async Task SaveNotificationEmailAsync(string tenantId, string? email)
{
await _configRepo.SaveNotificationEmailAsync(tenantId, email);
await SeedContactEmailAsync(tenantId, email);
}
/// <summary>
/// One-way seed: copies the preview notification address into
/// <see cref="TenantConfiguration.ContactEmail"/> only while that field is still empty.
/// Once the tenant owns a contact address — seeded or edited in the portal — this never
/// touches it again, so a later change here cannot silently overwrite the tenant's choice.
/// Best-effort: the notification email has already been persisted by the caller, and a
/// failure to seed must not fail that write.
/// </summary>
private async Task SeedContactEmailAsync(string tenantId, string? email)
{
if (string.IsNullOrWhiteSpace(email))
return;
try
{
var config = await _configRepo.GetTenantConfigurationAsync(tenantId);
if (config == null || !string.IsNullOrWhiteSpace(config.ContactEmail))
return;
config.ContactEmail = email.Trim();
await _configRepo.SaveTenantConfigurationAsync(config);
_logger.LogInformation(
"Seeded tenant contact address for {TenantId} from the preview notification email", tenantId);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Could not seed tenant contact address for {TenantId} — the notification email was still saved", tenantId);
}
await _tenantConfigService.TrySeedContactEmailAsync(tenantId, email);
}
}
@@ -125,6 +125,48 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// One-way seed of the tenant contact address: writes <paramref name="email"/> only while
/// the tenant has none, and only if nothing else wrote the configuration in the meantime.
/// Returns true when the seed landed.
/// <para>
/// Single owner for both seed paths (preview notification-email save and the maintenance
/// backfill), because both need the same three things: the conditional single-property
/// write, the "never overwrite what the tenant owns" invariant, and the cache invalidation
/// below — the repository writes behind this cache, so without it a freshly seeded address
/// stays invisible for the 5-minute TTL.
/// </para>
/// <para>
/// Fail-soft by design, unlike <see cref="SaveConfigurationAsync"/>: every caller is a side
/// effect of an operation that has already succeeded, and a lost seed is recoverable on the
/// next maintenance run. Enforced here rather than left to the repository, so the guarantee
/// holds for any implementation.
/// </para>
/// </summary>
public virtual async Task<bool> TrySeedContactEmailAsync(string tenantId, string? email)
{
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(email))
return false;
try
{
var seeded = await _configRepo.TrySeedTenantContactEmailAsync(tenantId, email!.Trim());
if (seeded)
{
_cache.Remove($"tenant-config:{tenantId}");
_logger.LogInformation("Seeded contact address for tenant {TenantId}", tenantId);
}
return seeded;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Could not seed contact address for tenant {TenantId} — the triggering write still stands", tenantId);
return false;
}
}
/// <summary>
/// Invalidates cache for a tenant (forces reload on next request)
/// </summary>
File diff suppressed because it is too large Load Diff
@@ -17,18 +17,24 @@
},
"dependencies": {
"@huggingface/transformers": "^3.8.1",
"@modelcontextprotocol/sdk": "^1.28.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"compression": "^1.8.1",
"express": "^5.2.1",
"fuse.js": "^7.1.0",
"fuse.js": "^7.5.0",
"zod": "^3.22.0"
},
"devDependencies": {
"@types/compression": "^1.8.1",
"@types/express": "^5.0.6",
"@types/node": "^22.0.0",
"tsx": "^4.0.0",
"@types/node": "^22.20.1",
"tsx": "^4.23.1",
"typescript": "^5.0.0",
"vitest": "^4.1.2"
"vitest": "^4.1.10"
},
"overrides": {
"sharp": "^0.35.0",
"fast-uri": "^3.1.4",
"hono": "^4.12.27",
"@hono/node-server": "^2.0.5"
}
}
@@ -129,6 +129,21 @@ describe('loadDocsCorpus', () => {
expect(chunks.map((c) => c.metadata.heading)).toContain('General › Is Autopilot Monitor free?');
});
it('strips inline HTML tags from a <summary> title, leaving no angle brackets', async () => {
const chunks = await withBundle(
{
'troubleshooting/faq.md': page(
'type: FAQ\ntags: [faq]',
['# FAQ', '', '## General', '', '<details>', '', '<summary>Does <b>keep-awake</b> work during <i>ESP</i>?</summary>', '', 'Keep-awake is an opt-in behavior during the user ESP phase for supported devices.', '', '</details>'].join('\n'),
),
},
(root) => loadDocsCorpus(root),
);
const headings = chunks.map((c) => c.metadata.heading);
expect(headings).toContain('General › Does keep-awake work during ESP?');
expect(headings.every((h) => h == null || (!h.includes('<') && !h.includes('>')))).toBe(true);
});
it('never emits a heading from inside a fenced code block', async () => {
const chunks = await withBundle(
{
@@ -232,7 +232,7 @@ async function checkAccess(upn: string, token: string, clientIp: string): Promis
}, MAX_ACCESS_CACHE_ENTRIES);
return result;
} catch (err) {
console.error(`[access-guard] Backend check failed for ${upn}:`, err);
console.error('[access-guard] Backend check failed for %s:', upn, err);
// Fail-closed: deny on backend error
return { allowed: false, reason: 'Backend access check unavailable', isGlobalAdmin: false, isGlobalReader: false, infraError: true };
}
@@ -217,6 +217,24 @@ interface Section {
content: string;
}
/**
* Remove HTML tags from a string. The pattern is applied repeatedly until the
* string stops changing, so tag removal can never re-form a tag from the joined
* remainder. A single `.replace(/<[^>]+>/g, '')` happens to be complete for this
* pattern, but the looped form is the shape CodeQL accepts as a robust sanitizer
* (js/incomplete-multi-character-sanitization) and is defensive against any
* future change to the tag regex.
*/
function stripHtmlTags(input: string): string {
let prev: string;
let out = input;
do {
prev = out;
out = out.replace(/<[^>]+>/g, '');
} while (out !== prev);
return out;
}
/**
* Split a page body into heading-delimited sections.
*
@@ -262,7 +280,7 @@ function splitIntoSections(body: string): Section[] {
const summary = /^\s*<summary>(.*?)<\/summary>\s*$/i.exec(line);
if (summary) {
flush(detailsTitle ?? h3 ?? h2);
detailsTitle = summary[1].replace(/<[^>]+>/g, '').trim();
detailsTitle = stripHtmlTags(summary[1]).trim();
continue;
}
if (/^\s*<\/details>\s*$/i.test(line)) {
@@ -24,6 +24,21 @@ namespace AutopilotMonitor.Shared.DataAccess
/// </summary>
Task<RawPage<TenantConfiguration>> GetTenantConfigurationsPageAsync(int pageSize, string? continuation);
/// <summary>
/// Writes <paramref name="email"/> to the tenant's ContactEmail ONLY while that field is
/// still empty, and only if nothing else wrote the row in the meantime. Returns true when
/// the seed landed, false when the tenant already owns an address, has no config row, or
/// lost the race.
/// <para>
/// Exists because the seed cannot be expressed as a read-modify-write of the whole model:
/// <see cref="SaveTenantConfigurationAsync"/> replaces the entire row unconditionally, so a
/// concurrent portal save would be clobbered by the seeder's stale snapshot — of every
/// field, not just this one. The implementation must therefore write conditionally and
/// touch no other property.
/// </para>
/// </summary>
Task<bool> TrySeedTenantContactEmailAsync(string tenantId, string email);
// --- Admin Configuration ---
Task<AdminConfiguration?> GetAdminConfigurationAsync();
Task<bool> SaveAdminConfigurationAsync(AdminConfiguration config);
@@ -218,7 +218,7 @@ export default function AboutPage() {
title: "Integrations",
items: [
"Microsoft Intune — agent deployment target",
"Microsoft Teams, Slack & generic JSON webhooks — start, success, failure and SLA alerts",
"Microsoft Teams, Slack & generic JSON webhooks — start, success, failure, hardware rejection and SLA alerts",
"Intune Management Extension (IME) log — event source for log pattern detection",
"WMI & Registry — extended data gather rules",
],
File diff suppressed because it is too large Load Diff
+12 -11
View File
@@ -14,38 +14,39 @@
"dependencies": {
"@azure/msal-browser": "^3.28.0",
"@azure/msal-react": "^2.1.3",
"@microsoft/applicationinsights-web": "^3.3.11",
"@microsoft/applicationinsights-web": "^3.4.3",
"@microsoft/signalr": "^10.0.0",
"@types/leaflet": "^1.9.21",
"@xyflow/react": "^12.10.2",
"@xyflow/react": "^12.11.2",
"dagre": "^0.8.5",
"fflate": "^0.8.2",
"fflate": "^0.8.3",
"leaflet": "^1.9.4",
"next": "^15.1.6",
"next": "^15.5.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"recharts": "^2.15.4"
},
"devDependencies": {
"@next/bundle-analyzer": "^16.2.7",
"@next/bundle-analyzer": "^16.2.11",
"@types/dagre": "^0.7.54",
"@types/node": "^22.10.5",
"@types/node": "^22.20.1",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.20",
"eslint": "^9.18.0",
"eslint-config-next": "^15.1.6",
"autoprefixer": "^10.5.4",
"eslint": "^9.39.5",
"eslint-config-next": "^15.5.21",
"postcss": "^8.5.10",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vitest": "^4.1.2"
"vitest": "^4.1.10"
},
"optionalDependencies": {
"@next/swc-win32-x64-msvc": "npm:null@*"
},
"overrides": {
"postcss": "^8.5.10",
"@nevware21/ts-utils": "^0.14.0"
"@nevware21/ts-utils": "^0.14.0",
"sharp": "^0.35.0"
}
}
@@ -65,13 +65,19 @@ const ALLOWED_USER_PROFILE_SUBDIRS = ["AppData\\Local", "AppData\\Roaming"];
// Helpers
// ---------------------------------------------------------------------------
/** Escape a literal string so it can be embedded in a RegExp without its
* characters being interpreted as regex metacharacters. */
function escapeRegExp(literal: string): string {
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Expand known Windows environment variables and custom tokens (case-insensitive). */
function expandEnvVars(path: string): string {
let result = path;
// Expand custom agent token first
result = result.replace(new RegExp(USER_PROFILE_TOKEN.replace(/%/g, "%"), "gi"), USER_PROFILE_PLACEHOLDER);
result = result.replace(new RegExp(escapeRegExp(USER_PROFILE_TOKEN), "gi"), USER_PROFILE_PLACEHOLDER);
for (const [envVar, replacement] of Object.entries(COMMON_ENV_VARS)) {
const regex = new RegExp(envVar.replace(/%/g, "%"), "gi");
const regex = new RegExp(escapeRegExp(envVar), "gi");
result = result.replace(regex, replacement);
}
return result;