mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0ad51b965 | |||
| bbb55034e3 | |||
| 58c8cf87a4 | |||
| 71d2c0e47f | |||
| 3b2f5ed46d | |||
| 7329f12217 | |||
| c9a9df99f1 | |||
| 757427ffea | |||
| 71832b9deb | |||
| ce4d884abc | |||
| b56c774103 | |||
| 68767f436f | |||
| c7b06be8b7 | |||
| 479247cdae | |||
| b6ba87f072 | |||
| fa640ebe61 | |||
| dbc20a9f66 | |||
| cbda62d131 | |||
| 3951000b40 | |||
| c59842262e | |||
| c3e9621a78 | |||
| 3ed21c37d1 | |||
| 4a1194569e | |||
| d0794fc7c5 | |||
| f34c9d2322 | |||
| 380a8417c8 | |||
| 63826a4869 | |||
| 7df208fac9 | |||
| 3f9576d4a9 | |||
| ee58242d33 | |||
| eca528b9b3 | |||
| 8305f5c850 | |||
| c2c02b929c | |||
| 1821af6f05 | |||
| 62599d972c | |||
| 21b751be64 | |||
| 63ea2e3cee | |||
| 93dcac44a4 | |||
| 72be8ac89f | |||
| cd570c8b3e | |||
| 6dfc564be6 | |||
| afecd18123 | |||
| 5a294ac4f6 | |||
| dbf6e50e2d | |||
| 062962ae41 | |||
| be08660976 | |||
| b032005f5b | |||
| 0ab568f479 |
+25
-67
@@ -1,79 +1,37 @@
|
||||
## Workflow Orchestration
|
||||
# Agent Directives: Mechanical Overrides
|
||||
# Agent Directives
|
||||
|
||||
You are operating within a constrained context window and strict system prompts. To produce production-grade code, you MUST adhere to these overrides:
|
||||
- **Plan first.** Enter plan mode for any non-trivial task (3+ steps or an architectural decision) and write the spec before building. If something goes sideways, stop and re-plan instead of pushing on.
|
||||
- **Use subagents liberally** for research, exploration, and parallel analysis — one focus each — to keep the main context clean.
|
||||
- **Verify before claiming done.** Run the tests, check the logs, show the evidence. Diff against `main` where behaviour could regress.
|
||||
- **Fix bugs autonomously.** Given a report, a failing test, or an error, resolve it — no hand-holding.
|
||||
- **Capture corrections.** After any correction from the user, record the pattern and a preventing rule in `tasks/lessons.md`; review it at session start.
|
||||
- Read files over 500 LOC in chunks. Never treat one grep as proof of absence.
|
||||
|
||||
Codex reviews your output once you are done.
|
||||
|
||||
## Code Quality
|
||||
|
||||
### 1. Plan Mode Default
|
||||
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
|
||||
- If something goes sideways, STOP and re-plan immediately — don't keep pushing
|
||||
- Use plan mode for specification steps, not just building
|
||||
- Write detailed specs upfront to reduce ambiguity
|
||||
|
||||
### 2. Subagent Strategy
|
||||
- Use subagents liberally to keep main context window clean
|
||||
- Offload research, exploration, and parallel analysis to subagents
|
||||
- For complex problems, throw more compute at it via subagents
|
||||
- One tack per subagent for focused execution
|
||||
|
||||
### 3. Self-Improvement Loop
|
||||
- After ANY correction from the user: update `tasks/lessons.md` with the pattern
|
||||
- Write rules for yourself that prevent the same mistake
|
||||
- Ruthlessly iterate on these lessons until mistake rate drops
|
||||
- Review lessons at session start for relevant project
|
||||
|
||||
### 4. Verification Before Done
|
||||
- Never mark a task complete without proving it works
|
||||
- Diff behavior between main and your changes when relevant
|
||||
- Ask yourself: "Would a staff engineer approve this?"
|
||||
- Run tests, check logs, demonstrate correctness
|
||||
|
||||
### 5. Demand Elegance (Balanced)
|
||||
- For non-trivial changes: pause and ask "is there a more elegant way?"
|
||||
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
|
||||
- Skip this for simple, obvious fixes — don't over-engineer
|
||||
- Challenge your own work before presenting it
|
||||
|
||||
### 6. Autonomous Bug Fixing
|
||||
- When given a bug report: just fix it. Don't ask for hand-holding
|
||||
- Point at logs, errors, failing tests — then resolve them
|
||||
- Zero context switching required from the user
|
||||
- Go fix failing CI tests without being told how
|
||||
|
||||
|
||||
|
||||
### Tools Execution
|
||||
- Read any file over 500 LOC in chunks using offset and limit parameters.
|
||||
- Do not assume a single grep caught everything.
|
||||
|
||||
### Code Quality
|
||||
- If architecture is flawed, state is duplicated, or patterns are inconsistent - propose and implement structural fixes. Ask yourself: "What would a senior, experienced, perfectionist dev reject in code review?" Fix all of it.
|
||||
|
||||
|
||||
Codex will review your output once you are done!
|
||||
|
||||
- Simplicity first: minimal, targeted changes. No temporary fixes — find the root cause.
|
||||
- Where architecture is flawed, state is duplicated, or patterns are inconsistent, propose and implement the structural fix. Ask what a perfectionist reviewer would reject, and fix that too.
|
||||
- On non-trivial changes, ask whether there is a more elegant way before presenting. Skip it for simple fixes — don't over-engineer.
|
||||
|
||||
## Task Management
|
||||
|
||||
1. **Plan First**: Write plan to `tasks/todo.md` with checkable items
|
||||
2. **Verify Plan**: Check in before starting implementation
|
||||
3. **Track Progress**: Mark items complete as you go
|
||||
4. **Explain Changes**: High-level summary at each step
|
||||
5. **Document Results**: Add review section to `tasks/todo.md`
|
||||
6. **Capture Lessons**: Update `tasks/lessons.md` after corrections
|
||||
Keep the plan in `tasks/todo.md` as checkable items, confirm it before implementing, tick items off as you go, and add a review section when done.
|
||||
|
||||
## Core Principles
|
||||
## Customer-Facing Claims
|
||||
|
||||
- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
|
||||
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
|
||||
- **Minimal Impact**: Changes should only touch what's necessary. Avoid introducing bugs.
|
||||
`src/Web/autopilot-monitor-web/app/{about,terms,privacy}/page.tsx` and `autopilotmonitor-docs/trust/*` state facts customers and security reviewers rely on. They drift silently.
|
||||
|
||||
## Technical Docs — OKF Knowledge Bundle (`docs/`)
|
||||
- Verify every claim against the code before writing it — never carry one forward because it was already on the page.
|
||||
- Update them in the same change as: runtime/framework versions, roles, notification providers, agent deployment or lifecycle, diagnostics payload, isolation/delegation model, retention caps, sub-processors, or any default governing what is collected.
|
||||
- Describe only what customers can actually use; operator-only infrastructure is not a feature.
|
||||
- Trust pages: durable phrasing over exact figures, and bump their "Last reviewed" date.
|
||||
|
||||
`docs/` is an [Open Knowledge Format (OKF) v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) knowledge bundle for contributor/AI-facing technical documentation (customer docs live in the separate autopilotmonitor-docs repo).
|
||||
## Technical Docs — OKF Bundle (`docs/`)
|
||||
|
||||
- **Consume first**: start at `docs/index.md` for progressive disclosure before opening individual documents.
|
||||
- **When new durable technical knowledge emerges** (architecture decisions, flows, non-obvious mechanisms), capture it as an OKF concept document in `docs/` — markdown with YAML frontmatter: `type` (mandatory), plus `title`, `description`, `resource`, `tags`, `timestamp` (recommended).
|
||||
- **Maintain the bundle**: add every new document to `docs/index.md`, note changes in `docs/log.md` (grouped by ISO date). Use standard RELATIVE markdown links between docs — never the OKF `/`-prefixed bundle-absolute form (GitHub resolves those from the repo root and navigation breaks). `index.md` and `log.md` are reserved filenames.
|
||||
- Documents are English, structural markdown over prose (`# Schema`, `# Examples`, `# Citations` sections where applicable).
|
||||
Contributor/AI-facing docs (customer docs live in the `autopilotmonitor-docs` repo). Start at `docs/index.md` before opening individual files.
|
||||
|
||||
- New durable knowledge (architecture decisions, flows, non-obvious mechanisms) → an OKF concept doc with YAML frontmatter: `type` (mandatory), plus `title`, `description`, `resource`, `tags`, `timestamp`.
|
||||
- Register it in `docs/index.md`, note the change in `docs/log.md` (ISO date). `index.md` and `log.md` are reserved names.
|
||||
- English, structural markdown (`# Schema`, `# Examples`, `# Citations`). Links between docs are RELATIVE — never `/`-prefixed, that breaks GitHub navigation.
|
||||
|
||||
+1
-1
Submodule .claude/commands updated: 3ed910ab50...af1b8468c7
@@ -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
|
||||
@@ -0,0 +1,47 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: nuget
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
dotnet:
|
||||
patterns: ['*']
|
||||
update-types: [minor, patch]
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/Web/autopilot-monitor-web
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
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
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
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: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
@@ -0,0 +1,102 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reserves a monotone build number from a counter blob (ETag compare-and-swap).
|
||||
|
||||
.DESCRIPTION
|
||||
Single source of build numbers for every component that ships from this repo
|
||||
(agent, backend, MCP). Each component owns one counter blob holding a plain
|
||||
integer; this script reads it, writes value+1 back under If-Match, and returns
|
||||
the reserved number.
|
||||
|
||||
Reserve-BEFORE-build: two concurrent builds can never mint the same number.
|
||||
A failed build burns a number, which is harmless -- uniqueness matters,
|
||||
density does not.
|
||||
|
||||
The blob is public-read; only the write needs the container SAS.
|
||||
|
||||
Why a counter and not the published version manifest: manifests are written
|
||||
on publish, not on build (the agent writes version.json only for stable
|
||||
releases), so two dev builds in a row would derive the same number and
|
||||
overwrite each other's versioned artifact.
|
||||
|
||||
.PARAMETER CounterUrl
|
||||
Full URL of the counter blob, without SAS.
|
||||
|
||||
.PARAMETER SasToken
|
||||
Container SAS with create+write permission. A leading '?' is tolerated.
|
||||
|
||||
.PARAMETER Override
|
||||
Explicit build number instead of counter+1. If it is not greater than the
|
||||
current counter the blob is left untouched (re-build of a number that was
|
||||
already reserved). 0 = no override.
|
||||
|
||||
.PARAMETER MaxAttempts
|
||||
CAS retries before giving up.
|
||||
|
||||
.OUTPUTS
|
||||
[int] the reserved build number. Progress goes to the host, not the pipeline.
|
||||
|
||||
.EXAMPLE
|
||||
$n = ./Request-BuildNumber.ps1 -CounterUrl $url -SasToken $sas
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$CounterUrl,
|
||||
[Parameter(Mandatory = $true)][string]$SasToken,
|
||||
[int]$Override = 0,
|
||||
[int]$MaxAttempts = 5
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Tolerate both secret formats ("sp=cw&..." and "?sp=cw&...").
|
||||
$writeUrl = $CounterUrl + '?' + $SasToken.TrimStart('?')
|
||||
|
||||
function Get-HttpStatus {
|
||||
param($ErrorRecord)
|
||||
if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) {
|
||||
return [int]$ErrorRecord.Exception.Response.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function Read-Counter {
|
||||
try {
|
||||
$resp = Invoke-WebRequest -Uri $CounterUrl -Method Get -UseBasicParsing
|
||||
} catch {
|
||||
if ((Get-HttpStatus $_) -eq 404) {
|
||||
throw "Counter blob not found at $CounterUrl. Seed it once with the last used build number: PUT the plain integer with 'x-ms-blob-type: BlockBlob' and 'If-None-Match: *'. See docs/versioning.md."
|
||||
}
|
||||
throw
|
||||
}
|
||||
$raw = if ($resp.Content -is [byte[]]) { [System.Text.Encoding]::UTF8.GetString($resp.Content) } else { [string]$resp.Content }
|
||||
# If-Match requires the quoted ETag form; PowerShell strips the quotes on read.
|
||||
$etag = '"' + ([string]($resp.Headers['ETag'] | Select-Object -First 1)).Trim('"') + '"'
|
||||
return [pscustomobject]@{ Value = [int]$raw.Trim(); ETag = $etag }
|
||||
}
|
||||
|
||||
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
|
||||
$counter = Read-Counter
|
||||
|
||||
if ($Override -gt 0 -and $Override -le $counter.Value) {
|
||||
Write-Host " Build number $Override <= counter $($counter.Value) -- already reserved, counter blob unchanged"
|
||||
return $Override
|
||||
}
|
||||
$target = if ($Override -gt 0) { $Override } else { $counter.Value + 1 }
|
||||
|
||||
try {
|
||||
$headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'If-Match' = $counter.ETag }
|
||||
Invoke-RestMethod -Uri $writeUrl -Method Put -Headers $headers -Body "$target" -ContentType 'text/plain' | Out-Null
|
||||
Write-Host " Reserved build number $target (CAS write ok, attempt $attempt)"
|
||||
return $target
|
||||
} catch {
|
||||
if ((Get-HttpStatus $_) -eq 412) {
|
||||
Write-Host " CAS conflict on attempt $attempt (another build reserved concurrently) -- retrying"
|
||||
Start-Sleep -Seconds ($attempt * 2)
|
||||
} else {
|
||||
throw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw "Could not reserve a build number after $MaxAttempts CAS attempts"
|
||||
@@ -56,78 +56,27 @@ 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'
|
||||
|
||||
# Build numbers come from the shared counter blob (single source of truth for CI
|
||||
# AND local build.ps1). Reserve-before-build with ETag-CAS: concurrent builds can
|
||||
# never mint the same number; a failed build burns a number, which is harmless.
|
||||
# The blob is public-read (like the rest of the container); writes need the SAS.
|
||||
# Build numbers come from the shared counter blob -- one counter and one
|
||||
# implementation for CI and the local build (.github/scripts/Request-BuildNumber.ps1).
|
||||
# Reserve-before-build with ETag-CAS: concurrent builds can never mint the same
|
||||
# number; a failed build burns a number, which is harmless.
|
||||
- name: Reserve build number (counter blob CAS)
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$counterBlob = "buildcounter-v$env:LINE_MAJOR.txt"
|
||||
$counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/$counterBlob"
|
||||
# Tolerate both secret formats ("sp=cw&..." and "?sp=cw&...") -- the URL below adds the '?'.
|
||||
$sasToken = "${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}".TrimStart('?')
|
||||
$override = "${{ inputs.build_number }}"
|
||||
$override = 0
|
||||
if ('${{ inputs.build_number }}' -ne '') { $override = [int]'${{ inputs.build_number }}' }
|
||||
|
||||
function Get-Counter {
|
||||
try {
|
||||
$resp = Invoke-WebRequest -Uri $counterUrl -Method Get
|
||||
$raw = if ($resp.Content -is [byte[]]) { [System.Text.Encoding]::UTF8.GetString($resp.Content) } else { [string]$resp.Content }
|
||||
# If-Match requires the quoted ETag form; pwsh strips the quotes on read.
|
||||
$etag = '"' + ([string]($resp.Headers['ETag'] | Select-Object -First 1)).Trim('"') + '"'
|
||||
return @{ Value = [int]$raw.Trim(); ETag = $etag }
|
||||
} catch {
|
||||
$status = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 }
|
||||
if ($status -eq 404) {
|
||||
Write-Error "Counter blob '$counterBlob' not found. Seed it once with the last used build number (see docs/agent/build-counter-blob.md) -- do NOT seed from the repo's buildcounter.txt, it is a stale offline fallback."
|
||||
}
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
$buildNumber = $null
|
||||
for ($attempt = 1; $attempt -le 5; $attempt++) {
|
||||
$counter = Get-Counter
|
||||
if ($override -ne '') {
|
||||
$target = [int]$override
|
||||
if ($target -le $counter.Value) {
|
||||
# Re-build of an already-reserved number: counter stays monotonic, no write.
|
||||
Write-Host "Manual build number $target <= counter $($counter.Value) -- counter blob unchanged"
|
||||
$buildNumber = $target
|
||||
break
|
||||
}
|
||||
Write-Host "Manual build number $target > counter $($counter.Value) -- advancing counter"
|
||||
} else {
|
||||
$target = $counter.Value + 1
|
||||
}
|
||||
try {
|
||||
$headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'If-Match' = $counter.ETag }
|
||||
Invoke-RestMethod -Uri "$counterUrl`?$sasToken" -Method Put -Headers $headers -Body "$target" -ContentType 'text/plain' | Out-Null
|
||||
$buildNumber = $target
|
||||
Write-Host "Reserved build number ${target} (CAS write ok, attempt $attempt)"
|
||||
break
|
||||
} catch {
|
||||
$status = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 }
|
||||
if ($status -eq 412) {
|
||||
Write-Host "CAS conflict on attempt $attempt (another build reserved concurrently) -- retrying"
|
||||
Start-Sleep -Seconds ($attempt * 2)
|
||||
} else {
|
||||
throw
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($null -eq $buildNumber) {
|
||||
Write-Error "Could not reserve a build number after 5 CAS attempts"
|
||||
}
|
||||
$counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v$($env:LINE_MAJOR).txt"
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' -Override $override
|
||||
|
||||
$csproj = Get-Content $env:CSPROJ_PATH -Raw
|
||||
if ($csproj -match '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
|
||||
@@ -228,8 +177,13 @@ jobs:
|
||||
Write-Warning "Could not parse bootstrap `$ScriptVersion from $env:BOOTSTRAP_SOURCE"
|
||||
}
|
||||
|
||||
# Enrich the per-line manifest emitted by MSBuild (version-v2.json, read above)
|
||||
# Enrich the per-line manifest emitted by MSBuild (version-v2.json, read above).
|
||||
# commit/buildUtc are the same two fields the backend and MCP manifests carry —
|
||||
# additive, and the bootstrap reads this file by field name, so older installers
|
||||
# ignore them. sha256/bootstrapVersion stay: those are the customer contract.
|
||||
$manifest | Add-Member -NotePropertyName "sha256" -NotePropertyValue $sha256Hash -Force
|
||||
$manifest | Add-Member -NotePropertyName "commit" -NotePropertyValue '${{ github.sha }}'.Substring(0, 7) -Force
|
||||
$manifest | Add-Member -NotePropertyName "buildUtc" -NotePropertyValue ([DateTime]::UtcNow.ToString('o')) -Force
|
||||
if ($bootstrapVersion) {
|
||||
$manifest | Add-Member -NotePropertyName "bootstrapVersion" -NotePropertyValue $bootstrapVersion -Force
|
||||
}
|
||||
@@ -352,13 +306,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 }}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
name: CI
|
||||
|
||||
# Tests, typechecks and drift-checks every push to main and every pull request:
|
||||
# the .NET suites, the web and MCP suites, and a guard that the generated rule
|
||||
# files still match rules/guardrails.json.
|
||||
#
|
||||
# The deploy workflows build but never test, so this is the only place the suites
|
||||
# actually run. It does not gate anything — main is deliberately unprotected, so a
|
||||
# red run reports rather than blocks. Read the result and follow up.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dotnet:
|
||||
name: .NET tests (backend + agent)
|
||||
# windows-latest is required: the agent test projects target net48.
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore AutopilotMonitor.sln
|
||||
|
||||
- name: Build
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release --no-restore
|
||||
|
||||
- name: Backend tests
|
||||
run: dotnet test src/Backend/AutopilotMonitor.Functions.Tests --configuration Release --no-build --verbosity quiet
|
||||
|
||||
- name: Agent V2 Core tests
|
||||
run: dotnet test src/Agent/AutopilotMonitor.Agent.V2.Core.Tests --configuration Release --no-build --verbosity quiet
|
||||
|
||||
- name: Decision Core tests
|
||||
run: dotnet test src/Agent/AutopilotMonitor.DecisionCore.Tests --configuration Release --no-build --verbosity quiet
|
||||
|
||||
- name: Summary Dialog tests
|
||||
run: dotnet test src/Agent/AutopilotMonitor.SummaryDialog.Tests --configuration Release --no-build --verbosity quiet
|
||||
|
||||
web:
|
||||
name: Web (typecheck + tests)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/Web/autopilot-monitor-web
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: src/Web/autopilot-monitor-web/package-lock.json
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
mcp:
|
||||
name: MCP server tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/McpServer/autopilot-monitor-mcp
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: src/McpServer/autopilot-monitor-mcp/package-lock.json
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
guardrails-in-sync:
|
||||
name: Generated guardrails match rules/guardrails.json
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Regenerate
|
||||
run: node rules/scripts/combine.js
|
||||
|
||||
- name: Fail if the generated files drifted
|
||||
run: |
|
||||
if ! git diff --quiet; then
|
||||
echo "::error::Generated files are out of date. Run 'node rules/scripts/combine.js' and commit the result."
|
||||
git diff --stat
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,73 @@
|
||||
name: CodeQL
|
||||
|
||||
# Documentation-only changes cannot affect what CodeQL analyzes (C# and
|
||||
# TypeScript), so they are filtered out rather than spending ~10 minutes to
|
||||
# re-report an unchanged result. A commit that touches docs AND code still runs:
|
||||
# GitHub skips only when EVERY changed path matches the filter.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
schedule:
|
||||
# Weekly, so advisories published after a merge still surface. Deliberately
|
||||
# unfiltered — it re-analyzes the whole tree regardless of what changed, which
|
||||
# is also the backstop if the filters above ever skip too much.
|
||||
- cron: '17 3 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze ${{ matrix.language }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# csharp needs the net48 agent projects to build, so Windows.
|
||||
- language: csharp
|
||||
runner: windows-latest
|
||||
build-mode: manual
|
||||
- language: javascript-typescript
|
||||
runner: ubuntu-latest
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-dotnet@v6
|
||||
if: matrix.language == 'csharp'
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
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@v4
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -7,13 +7,19 @@ permissions:
|
||||
id-token: write # OIDC federated login (secret-less)
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FUNCTION_APP: autopilotmonitor-api-eu
|
||||
HEALTH_URL: https://autopilotmonitor-api-eu.azurewebsites.net/api/health
|
||||
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
|
||||
CSPROJ_PATH: src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@v3
|
||||
@@ -23,21 +29,94 @@ 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'
|
||||
|
||||
# Same counter mechanism as the agent line: reserve-before-build with ETag-CAS
|
||||
# against a per-component counter blob. BuildTimestampUtc is pinned here rather
|
||||
# than left to MSBuild's default so the published manifest and the value baked
|
||||
# into the assembly (reported by /api/health) are the same instant.
|
||||
- name: Reserve build number (counter blob CAS)
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/backend.txt" -SasToken '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'
|
||||
|
||||
$csproj = Get-Content ${{ env.CSPROJ_PATH }} -Raw
|
||||
if ($csproj -notmatch '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
|
||||
throw "VersionPrefix not found in ${{ env.CSPROJ_PATH }}"
|
||||
}
|
||||
$version = "$($Matches[1]).$buildNumber"
|
||||
$buildUtc = [DateTime]::UtcNow.ToString('o')
|
||||
Write-Host "Backend version: $version (built $buildUtc)"
|
||||
|
||||
"build_number=$buildNumber" >> $env:GITHUB_OUTPUT
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
"build_utc=$buildUtc" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
|
||||
run: dotnet restore ${{ env.CSPROJ_PATH }}
|
||||
|
||||
- name: Build
|
||||
run: dotnet build src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj --configuration Release --no-restore -p:SourceRevisionId=${{ github.sha }}
|
||||
run: dotnet build ${{ env.CSPROJ_PATH }} --configuration Release --no-restore -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }}
|
||||
|
||||
- name: Publish
|
||||
run: dotnet publish src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }}
|
||||
run: dotnet publish ${{ env.CSPROJ_PATH }} --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }}
|
||||
|
||||
- name: Deploy to Azure Functions
|
||||
uses: Azure/functions-action@v1
|
||||
with:
|
||||
app-name: autopilotmonitor-api-eu
|
||||
app-name: ${{ env.FUNCTION_APP }}
|
||||
package: ./publish
|
||||
|
||||
# A green deploy step only means the package was accepted. This gate makes the
|
||||
# workflow assert what is actually being served, so the manifest published below
|
||||
# can be trusted as "this is live" rather than "this was uploaded".
|
||||
# /api/health is IP rate-limited at 30/min; one poll per 10s stays well under.
|
||||
- name: Verify deployed build is live
|
||||
shell: pwsh
|
||||
run: |
|
||||
$expectedCommit = '${{ github.sha }}'.Substring(0, 7)
|
||||
$expectedVersion = '${{ steps.version.outputs.version }}'
|
||||
$deadline = [DateTime]::UtcNow.AddMinutes(3)
|
||||
$attempt = 0
|
||||
|
||||
while ([DateTime]::UtcNow -lt $deadline) {
|
||||
$attempt++
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri '${{ env.HEALTH_URL }}' -Method Get -TimeoutSec 30
|
||||
if ($health.commitHash -eq $expectedCommit -and $health.version -eq $expectedVersion) {
|
||||
Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commitHash), built $($health.buildUtc)"
|
||||
exit 0
|
||||
}
|
||||
Write-Host "Attempt ${attempt}: serving $($health.version) @ $($health.commitHash) -- waiting for $expectedVersion @ $expectedCommit"
|
||||
} catch {
|
||||
Write-Host "Attempt ${attempt}: /api/health not answering yet -- $($_.Exception.Message)"
|
||||
}
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
|
||||
throw "Backend did not report $expectedVersion @ $expectedCommit within 3 minutes -- deployment not verified, version manifest NOT published"
|
||||
|
||||
# The manifest is the cross-component record of what is deployed where. It is
|
||||
# written last and only on a verified deploy, so a difference between the
|
||||
# manifest and /api/health means the running app drifted after the fact
|
||||
# (rollback, stale instance) rather than a half-finished pipeline.
|
||||
- name: Publish version manifest
|
||||
shell: pwsh
|
||||
run: |
|
||||
$manifest = [ordered]@{
|
||||
component = 'backend'
|
||||
version = '${{ steps.version.outputs.version }}'
|
||||
commit = '${{ github.sha }}'.Substring(0, 7)
|
||||
buildUtc = '${{ steps.version.outputs.build_utc }}'
|
||||
deployedUtc = [DateTime]::UtcNow.ToString('o')
|
||||
runId = '${{ github.run_id }}'
|
||||
} | ConvertTo-Json
|
||||
|
||||
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/backend.json?$sas"
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/backend.json"
|
||||
Write-Host $manifest
|
||||
|
||||
@@ -12,6 +12,9 @@ env:
|
||||
CONTAINER_APP_NAME: autopilotmonitor-mcp
|
||||
RESOURCE_GROUP: ${{ secrets.AZURE_MCP_RESOURCE_GROUP }}
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
HEALTH_URL: https://mcp.autopilotmonitor.com/health
|
||||
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
|
||||
PACKAGE_JSON: src/McpServer/autopilot-monitor-mcp/package.json
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
@@ -19,7 +22,23 @@ 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
|
||||
# token is needed. Checking it out here rather than vendoring it keeps the
|
||||
# docs single-sourced — but it also means EDITING THE DOCS DOES NOT UPDATE A
|
||||
# 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@v7
|
||||
with:
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
path: mcp-docs
|
||||
|
||||
- name: Resolve docs commit
|
||||
id: docs
|
||||
run: echo "sha=$(git -C mcp-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@v3
|
||||
@@ -28,6 +47,30 @@ jobs:
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# Same counter mechanism as agent and backend: reserve-before-build with
|
||||
# ETag-CAS against the component's own counter blob. major.minor stays curated
|
||||
# in package.json; only the build part is minted here.
|
||||
- name: Reserve build number (counter blob CAS)
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/mcp.txt" -SasToken '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'
|
||||
|
||||
$pkgVersion = (Get-Content ${{ env.PACKAGE_JSON }} -Raw | ConvertFrom-Json).version
|
||||
$prefix = ($pkgVersion -split '\.')[0..1] -join '.'
|
||||
$version = "$prefix.$buildNumber"
|
||||
$buildUtc = [DateTime]::UtcNow.ToString('o')
|
||||
Write-Host "MCP version: $version (built $buildUtc)"
|
||||
|
||||
# 7 chars, matching what the backend's BackendBuildInfo reports — one commit
|
||||
# shape across all components so the values can be compared as-is.
|
||||
$shortSha = '${{ github.sha }}'.Substring(0, 7)
|
||||
|
||||
"build_number=$buildNumber" >> $env:GITHUB_OUTPUT
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
"build_utc=$buildUtc" >> $env:GITHUB_OUTPUT
|
||||
"short_sha=$shortSha" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Login to ACR
|
||||
run: az acr login --name ${{ env.ACR_NAME }}
|
||||
|
||||
@@ -35,6 +78,10 @@ jobs:
|
||||
run: |
|
||||
docker build \
|
||||
-f src/McpServer/autopilot-monitor-mcp/Dockerfile \
|
||||
--build-arg DOCS_COMMIT=${{ steps.docs.outputs.sha }} \
|
||||
--build-arg BUILD_NUMBER=${{ steps.version.outputs.build_number }} \
|
||||
--build-arg BUILD_COMMIT=${{ steps.version.outputs.short_sha }} \
|
||||
--build-arg BUILD_UTC=${{ steps.version.outputs.build_utc }} \
|
||||
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }} \
|
||||
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:latest \
|
||||
.
|
||||
@@ -46,3 +93,54 @@ jobs:
|
||||
--name ${{ env.CONTAINER_APP_NAME }} \
|
||||
--resource-group ${{ env.RESOURCE_GROUP }} \
|
||||
--image ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }}
|
||||
|
||||
# `az containerapp update` returns once the revision is provisioned, not once
|
||||
# it serves traffic — and with minReplicas=0 the first request also pays a cold
|
||||
# start. Polling /health until it reports THIS build is what makes the manifest
|
||||
# below mean "live"; a full 5 minutes because the cold start alone is ~25s.
|
||||
- name: Verify deployed build is live
|
||||
shell: pwsh
|
||||
run: |
|
||||
$expectedCommit = '${{ steps.version.outputs.short_sha }}'
|
||||
$expectedVersion = '${{ steps.version.outputs.version }}'
|
||||
$deadline = [DateTime]::UtcNow.AddMinutes(5)
|
||||
$attempt = 0
|
||||
|
||||
while ([DateTime]::UtcNow -lt $deadline) {
|
||||
$attempt++
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri '${{ env.HEALTH_URL }}' -Method Get -TimeoutSec 60
|
||||
if ($health.commit -eq $expectedCommit -and $health.version -eq $expectedVersion) {
|
||||
Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commit), docs $($health.docs.commit) ($($health.docs.chunks) chunks)"
|
||||
exit 0
|
||||
}
|
||||
Write-Host "Attempt ${attempt}: serving $($health.version) @ $($health.commit) -- waiting for $expectedVersion @ $expectedCommit"
|
||||
} catch {
|
||||
Write-Host "Attempt ${attempt}: /health not answering yet -- $($_.Exception.Message)"
|
||||
}
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
|
||||
throw "MCP did not report $expectedVersion @ $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
|
||||
|
||||
# Cross-component record of what is deployed where; written last and only on a
|
||||
# verified deploy. docsCommit rides along because for this component "which
|
||||
# build" and "which documentation" are two separate questions.
|
||||
- name: Publish version manifest
|
||||
shell: pwsh
|
||||
run: |
|
||||
$manifest = [ordered]@{
|
||||
component = 'mcp'
|
||||
version = '${{ steps.version.outputs.version }}'
|
||||
commit = '${{ steps.version.outputs.short_sha }}'
|
||||
buildUtc = '${{ steps.version.outputs.build_utc }}'
|
||||
deployedUtc = [DateTime]::UtcNow.ToString('o')
|
||||
runId = '${{ github.run_id }}'
|
||||
docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7)
|
||||
} | ConvertTo-Json
|
||||
|
||||
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/mcp.json?$sas"
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/mcp.json"
|
||||
Write-Host $manifest
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-2
@@ -117,8 +117,11 @@ scripts/Deployment/
|
||||
# Migration tooling (local only, vorerst) — cross-tenant storage/cert migration scripts
|
||||
scripts/Migration/
|
||||
|
||||
# Infra (local only)
|
||||
infra/
|
||||
# Infra: tracked. The Bicep templates carry no credentials — every secret is a
|
||||
# @secure() parameter with an empty default, injected at deploy time, and image
|
||||
# pull runs on managed identity. Keep it that way: never commit a resolved secret
|
||||
# value or a *.parameters.json holding one.
|
||||
infra/*.parameters.json
|
||||
|
||||
# M2 — local raw session fixtures before anonymization (plan §4 M2)
|
||||
tests/fixtures/enrollment-sessions-local/
|
||||
|
||||
@@ -22,26 +22,21 @@ attestation on top.
|
||||
|
||||
# Schema
|
||||
|
||||
- **Counter blob**: `https://autopilotmonitor.blob.core.windows.net/agent/buildcounter-v{LINE}.txt`
|
||||
(per release line, analogous to `version-v{LINE}.json`). Content: the **last used**
|
||||
build number as plain text. Public-read like the rest of the container (the number
|
||||
is visible in `version-v{LINE}.json` anyway); writes require the container SAS.
|
||||
- **Protocol — reserve before build, ETag-CAS**:
|
||||
1. Anonymous `GET` the blob, capture value `N` and the `ETag`.
|
||||
2. `PUT N+1` with `If-Match: <etag>` (quoted form — pwsh strips the quotes on read,
|
||||
they must be re-added or the request is rejected client-side).
|
||||
3. `412 Precondition Failed` → another build reserved concurrently → re-read, retry
|
||||
(5 attempts, linear backoff).
|
||||
4. Build with `BUILD_NUMBER=N+1` in the environment. The csproj honors
|
||||
`BUILD_NUMBER` and then skips its `PersistBuildCounter` target, so the repo file
|
||||
is not touched.
|
||||
- **Failed builds burn a number.** That is intentional: uniqueness matters, density
|
||||
does not. Reserve-before-build is what makes concurrent minting impossible.
|
||||
The reserve-before-build CAS protocol itself is shared with the backend and the MCP
|
||||
server and is documented once in [Version Contract](../versioning.md); the single
|
||||
implementation is `.github/scripts/Request-BuildNumber.ps1`. Agent-specific:
|
||||
|
||||
- **Counter blob**: `https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v{LINE}.txt`
|
||||
— one per release line, analogous to `version-v{LINE}.json`, and kept in the `agent`
|
||||
container next to the artifacts it numbers rather than in `versions/`. Content: the
|
||||
**last used** build number as plain text. Public-read like the rest of the container
|
||||
(the number is visible in `version-v{LINE}.json` anyway); writes require the SAS.
|
||||
- The reserved number reaches the build as `BUILD_NUMBER` in the environment. The
|
||||
csproj honors it and then skips its `PersistBuildCounter` target, so the repo file
|
||||
is not touched.
|
||||
- **CI manual override** (`build_number` input): numbers `<=` counter are re-builds
|
||||
and leave the blob unchanged; numbers `>` counter advance it (monotonic guard).
|
||||
- **Seeding** (one-time per line): `PUT <last used number>` with `If-None-Match: *`
|
||||
so an existing counter can never be clobbered. V2 was seeded with `1356` on
|
||||
2026-07-17.
|
||||
- V2 was seeded with `1356` on 2026-07-17.
|
||||
|
||||
# Consequences
|
||||
|
||||
@@ -62,7 +57,9 @@ attestation on top.
|
||||
|
||||
# Citations
|
||||
|
||||
- [Version Contract](../versioning.md) — the shared CAS protocol and manifest format
|
||||
- `.github/workflows/build-agent.yml` — "Reserve build number (counter blob CAS)" step
|
||||
- `scripts/Deployment/build.ps1` (gitignored, contains SAS) — `Request-BuildNumber`
|
||||
- `.github/scripts/Request-BuildNumber.ps1` — the shared implementation
|
||||
- `scripts/Deployment/build.ps1` (gitignored, contains SAS) — local build path
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj` —
|
||||
`BUILD_NUMBER` property + `PersistBuildCounter` target
|
||||
|
||||
+8
-1
@@ -12,6 +12,8 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
# Architecture
|
||||
|
||||
* [Architecture Guide](architecture.md) - High-level architecture overview and solution layout for contributors.
|
||||
* [Version Contract](versioning.md) - One version shape for agent, backend and MCP: the ETag-CAS counter blob that mints build numbers, and the manifests published only after a deploy is verified live.
|
||||
* [URL Registry](url-registry.md) - Every well-known host lives in one registry file per component (Constants.cs / utils/config.ts / src/config.ts), enforced by guard tests; includes the agent-download migration to the download alias and its deploy-order constraint.
|
||||
|
||||
# Agent
|
||||
|
||||
@@ -22,12 +24,17 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
|
||||
# Rules
|
||||
|
||||
* [Gather Rule Phase Scoping & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch) and emitMode "on_change" result-dedup — the anti-spam pair for interval rules.
|
||||
* [Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch), one-shot collection at a phase's start/end (phase_change / phase_exit), and emitMode "on_change" result-dedup.
|
||||
* [Gather Rule Guardrails](rules/gather-rule-guardrails.md) - Why the allowlists in rules/guardrails.json are enforced on the agent and nowhere else, which hard blocks survive unrestricted mode, and the rule that every collector must call a guard and emit security_warning on refusal.
|
||||
|
||||
# Backend
|
||||
|
||||
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
|
||||
|
||||
# MCP Server
|
||||
|
||||
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
|
||||
|
||||
# Security & Identity
|
||||
|
||||
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
|
||||
|
||||
+24
@@ -1,7 +1,31 @@
|
||||
# Log
|
||||
|
||||
## 2026-07-23
|
||||
|
||||
* **Creation**: Added `url-registry.md` — all well-known hosts (own subdomains, both blob accounts, API origin, Graph, Entra login) are now defined once per component (`Constants.cs`, web `utils/config.ts`, MCP `src/config.ts`) and referenced everywhere else; three guard tests fail the build on any literal outside the registries. Written because the EU cutover missed two hardcoded copies of the legacy blob host — `LatestVersionsService` (feeds the portal's version badge) and `ValidateBootstrapCodeFunction` — which kept working only through the fail-soft legacy mirror in `build-agent.yml`; those two plus the agent `SelfUpdater` now use the `download.autopilotmonitor.com` alias (verified serving all four artifacts first). The portal's bootstrap allow-list accepts alias AND legacy host during the transition, which imposes a deploy order: web before (or with) backend. The sweep also surfaced a fourth copy of the www/portal host pair in `middleware.ts` (now derived from the registry via `hostRouting.ts`) and a backend short-link built on the bare apex domain, which only worked through a registrar-level 301.
|
||||
|
||||
## 2026-07-22
|
||||
|
||||
* **Creation**: Added `versioning.md` — agent, backend and MCP now share one version shape (`<major>.<minor>.<build>` + 7-char commit + build timestamp) and one implementation of the build-number reservation (`.github/scripts/Request-BuildNumber.ps1`, previously copy-pasted between `build-agent.yml` and `build.ps1`). The backend had been pinned at a hand-written `1.0.0` since inception and now starts at `1.5.1000`; the MCP server's `package.json` version was bumped by hand and now starts at `1.5.300`, and it reports its own commit on `/health` instead of only the documentation bundle's. Both deploy workflows poll their health endpoint until it reports the build just deployed and publish `versions/<component>.json` only then, so a manifest that disagrees with the live endpoint means the instance drifted rather than the pipeline failing halfway. `agent/build-counter-blob.md` keeps the agent-specific parts and now points at the shared protocol; its counter URL was still the pre-cutover storage account.
|
||||
|
||||
* **Creation**: Added `rules/gather-rule-guardrails.md` — the gather-rule allowlists are enforced only on the agent (the backend validates no `collectorType`, `target`, or `parameters`), so a collector that forgets to call its guard *is* the vulnerability. Written because two had: `LogParserCollector` read any path in `rule.Target`, bypassing even the `C:\Users` hard block that every other file collector honours, and `EventLogCollector` read any channel including `Security` — while the customer-facing documentation already promised both were allowlisted. Both now guard, with `logparser` checking the directory for wildcard targets (`Path.GetFullPath` throws on `*`) and re-checking each resolved path. Added an event-log channel allowlist (`eventLogChannels`) plus hard-blocked channels held in C# rather than JSON, so a parse error cannot lift them. Also fixed the command timeout, which was inert: `ReadToEnd()` blocks until the pipe closes, so `WaitForExit(30000)` was only reached after the process had already exited and a hung command pinned its worker forever — now read asynchronously and killed at the timeout.
|
||||
|
||||
* **Update**: Added `.github/workflows/ci.yml` — the full suite (backend, agent V2 Core, DecisionCore, SummaryDialog, web, MCP) now runs on every pull request and every push to main, plus a job that fails if the generated guardrail files drift from `rules/guardrails.json`. Previously **no** workflow ran tests on a PR: the deploy workflows build without testing, and `build-agent.yml` tested only `DecisionCore.Tests` on manual dispatch — while `trust/security-faq.md` already told customers that automated tests run in CI. Added CodeQL (C# and TypeScript) and Dependabot (nuget, npm ×2, actions). Marked `OfficeBinaryWatcherTests` `[Collection("SerialThreading")]`: its bounded re-probe races the shared ThreadPool and failed once under full-suite load.
|
||||
|
||||
## 2026-07-21
|
||||
|
||||
* **Update**: `search_docs` and `search_knowledge` now state in their tool descriptions that queries must be in English. Both corpora and `all-MiniLM-L6-v2` are English-only, and the failure is total rather than gradual — a German question returns zero results, not weak ones. The caller is a language model, so translating the question before the call and answering in the user's language costs nothing; a multilingual embedding model would be the alternative but is much larger and would have to be measured against the eval harness first, since it changes English retrieval too.
|
||||
|
||||
* **Update**: Added `scripts/eval-docs-search.ts`, an offline retrieval evaluation harness (2465 queries in three classes: chunk headings as a regression detector, rare identifiers, and hand-written questions as the reality anchor), and used it to close a third `search_docs` gap found during the live test. The keyword fallback only fired when semantic search returned *fewer* than `topK`; a pasted identifier like `WOW6432Node` instead returned three chunks at ~0.29 that did not contain the word while the one chunk that did was never considered. It now also fires on a weak semantic head (<0.35), letting exact matches take the front while keeping one semantic result. The threshold is measured, not guessed: 0.35 fixes 335 queries and breaks 0 (identifier recall 66.2% → 81.1%), while 0.40 fixes more but starts demoting hand-written questions. The measurement also resized the problem — identifier queries were only succeeding 66.2% of the time, so the reported case was one of roughly 750.
|
||||
|
||||
* **Update**: Closed the two measured recall gaps in `search_docs` (`mcp/docs-corpus.md`). (1) **Token windows** — 87 of 324 chunks exceeded the model's 256-token context and 13% of all tokens never reached the ranking; a character budget cannot fix this (the corpus ranges 2.43–5.81 chars/token), so long text is now embedded as 250-token windows with 40 overlap and scored by its BEST window (`embeddings: number[][]`, max not mean). Token coverage 87% → 100%; "proxy configuration blocking app downloads" went from absent to 0.559 at rank 1; the ten reference queries kept byte-identical scores. Costs +31% vectors, index 2.63 → 3.12 MB. The rules corpus gained the same treatment (128 rules → 172 vectors). (2) **Keyword fallback** — when the semantic pass returns fewer than `topK`, results are topped up via literal `lexicalMatch` on distinctive query terms, requiring two matching terms for corroboration (one incidental substring match returned a page about LLM providers for a sourdough query). Fuzzy matching was measured and rejected: at a threshold low enough to surface "RealmJoin" it invents 5 hits for "SCEPman", which the corpus never mentions. Also sharpened the tool description with the measured context argument for preferring it over fetching the docs site.
|
||||
|
||||
* **Creation**: Added `mcp/docs-corpus.md` (new `mcp/` section) — the MCP server gains `search_docs`, a third semantic corpus built from the published customer documentation repository (`autopilotmonitor-docs`, checked out into the Docker build context by the deploy workflow). Documents the h2/h3 + `<details>` chunking with breadcrumb prefixes, the two deliberate size exceptions (tables never split, unbroken changelog lists split at line level), the CRLF precondition (`.` does not match `\r` in JS — headings silently stop matching, which produced 286 wrong-but-plausible chunks before it was found), the optional `docs` section in `search-index.json` and its validation asymmetry, embedding rounding to 6 decimals, and the cross-repo coupling: a docs edit reaches clients only after an MCP redeploy, made checkable via `DOCS_COMMIT` on `/health`. Container resources raised to 0.5 vCPU / 1.0 GiB for the cold-start query-embedder load.
|
||||
|
||||
## 2026-07-20
|
||||
|
||||
* **Update**: `rules/gather-rule-phase-scoping.md` — the gather-rule form now shows the phase-scope and emit-mode controls only where they can change behavior (`supportsPhaseScope` / `supportsEmitMode`), nulls hidden-control state out of the payload, and rejects a scope mode selected but left unfilled. Offering all three "when" controls unconditionally allowed combinations that could only suppress a one-shot rule, plus a silent no-op state.
|
||||
* **Update**: Extended `rules/gather-rule-phase-scoping.md` with the `phase_exit` trigger — the closing bookend to `phase_change`, for one-shot collection when a phase is LEFT (e.g. "state at the end of Account Setup"). Documents the evaluation-before-phase-advance rule (triggerPhase match and scope gate see the phase being left), firing into `Failed`, the disjoint `exit:` dedup key space, and why no ConfigVersion bump is needed (no new field — old agents simply never fire the rule).
|
||||
* **Update**: Reworked `agent/mdm-reboot-coalescing.md` after the first real enrollment (session b2e890c1): 2800 records are now **aggregated per burst** into ONE neutral Info event (`rebootUris[]`/`uriCount`/`firstRebootUri`; watermark persisted at flush so a pre-emit reboot kill loses nothing); the raw event no longer claims a reboot — ANALYZE-ESP-005 is gated on an actually-observed `system_reboot_detected`. **Removed** the Shell-Core `esp_reboot_coalescing` event entirely: the "RebootCoalescing" token appears in the routine `SubcategoryProcessing_Started` bootstrap marker on every enrollment (false positive by construction). Verified 2800 description shape recorded.
|
||||
* **Creation**: Added `agent/mdm-reboot-coalescing.md` — the agent now attributes the "unexpected mid-ESP reboot + second sign-in" pattern (PMPC research) to the device-assigned policy URIs that forced it: new `MdmRebootPolicyTracker` (DM-Enterprise EventID 2800, per-URI `mdm_policy_reboot_required` events, cross-restart RecordId watermark), a `RebootCoalescing` branch in `ShellCoreTracker` (`esp_reboot_coalescing`, backfill-emitting by design), and the `ANALYZE-ESP-005` advisory (reassign device → user groups). ConfigVersion 35; event types deliberately distinct from `system_reboot_detected` so RebootCount stays uninflated.
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
---
|
||||
type: Concept
|
||||
title: MCP Docs Corpus — search_docs and the Cross-Repo Build Coupling
|
||||
description: How the published customer documentation becomes a third semantic search corpus in the MCP server — CRLF-safe heading chunking, build-time embedding, the corpus hash guard, and why editing the docs does not update a running server.
|
||||
resource: /src/McpServer/autopilot-monitor-mcp/src/docs-corpus.ts
|
||||
tags:
|
||||
- mcp
|
||||
- search
|
||||
- embeddings
|
||||
- documentation
|
||||
- build
|
||||
timestamp: 2026-07-21T19:30:00+02:00
|
||||
---
|
||||
|
||||
# Purpose
|
||||
|
||||
The MCP server answers two different kinds of question, and they need two
|
||||
different corpora.
|
||||
|
||||
* *"Why did this enrollment fail?"* → analysis rules, gather rules, IME log
|
||||
patterns. That is `search_knowledge`, backed by `knowledge-base.ts`.
|
||||
* *"How do I deploy the agent?" / "Where is my data stored?"* → the published
|
||||
customer documentation. That is `search_docs`, backed by `docs-corpus.ts`.
|
||||
|
||||
They are deliberately **separate indexes**. Merged into one, they would compete
|
||||
for the same `topK` and dilute each other: a rules query would surface prose
|
||||
pages, and a product question would surface rule definitions.
|
||||
|
||||
# Schema
|
||||
|
||||
## Corpus source
|
||||
|
||||
The corpus is the `autopilotmonitor-docs` repository — a public OKF bundle
|
||||
published at `docs.autopilotmonitor.com`. It is **not** vendored into this repo.
|
||||
The deploy workflow checks it out into `mcp-docs/` of the Docker build context;
|
||||
the Dockerfile copies it to `/app/docs` in both the build and the final stage
|
||||
(byte-identical, or the boot-time corpus hash rejects the precomputed index).
|
||||
|
||||
`SUMMARY.md` and `log.md` are skipped as navigation scaffolding. `index.md` is
|
||||
kept — it is the documentation map and answers "what documentation exists" — but
|
||||
gets no `url`, because the bundle deliberately keeps it out of `SUMMARY.md` and
|
||||
GitBook therefore never renders it.
|
||||
|
||||
## Chunking
|
||||
|
||||
A whole page cannot be one document: `trust/security-faq.md` is 36 KB, while
|
||||
MiniLM-L6-v2 truncates at ~256 tokens. An un-chunked page would silently embed
|
||||
only its first paragraph.
|
||||
|
||||
Chunks are cut at **h2/h3 headings**, which in this bundle are also the semantic
|
||||
boundaries — `security-faq.md` carries 37 h3s, one per question.
|
||||
`<details><summary>Q</summary>` blocks (used by `troubleshooting/faq.md`) are
|
||||
treated the same way, with the summary as the section title. Each chunk is
|
||||
prefixed with a `Page Title › Section › Subsection` breadcrumb so a hit lifted
|
||||
out of the middle of a page is self-describing to both the embedder and the
|
||||
reader.
|
||||
|
||||
Two deliberate exceptions to the ~1500-character budget:
|
||||
|
||||
* **Markdown tables are never split.** A table broken across chunks leaves
|
||||
`| --- |` rows with no header — unreadable in a result and meaningless to
|
||||
embed. Tables carry real answers here (the role matrix, the settings
|
||||
reference), so an over-long table is allowed to exceed the budget.
|
||||
* **Unbroken lists split at line level.** The changelog pages are one bullet
|
||||
list per release with no blank lines, so paragraph splitting cannot touch
|
||||
them; left whole, everything past the model's window would be truncated away
|
||||
and become unsearchable.
|
||||
|
||||
Short sections are **kept**, not filtered. A one-line answer ("Is it free?" →
|
||||
"Yes, the Community plan is free") is often the most valuable hit, and the
|
||||
breadcrumb supplies the context the short body lacks. Only structural residue
|
||||
(under 25 alphanumeric characters) is dropped.
|
||||
|
||||
## CRLF is a correctness precondition
|
||||
|
||||
The docs repo is authored on Windows and ships CRLF. In JavaScript `.` does not
|
||||
match `\r`, so a pattern like `/^(#{2,3})\s+(.*)$/` matches **nothing** on a CRLF
|
||||
file: `(.*)` stops before the `\r` and `$` never reaches end-of-string.
|
||||
|
||||
This was observed during development, and the failure mode is what makes it
|
||||
dangerous: no crash, no error — headings and frontmatter simply stop being
|
||||
recognized and every page silently degrades to unlabelled paragraph splits. The
|
||||
first working build produced 286 plausible-looking chunks that were almost all
|
||||
wrong; after normalizing, `security-faq.md` went from 29 blind splits to 46 real
|
||||
question sections.
|
||||
|
||||
`normalizeNewlines()` runs before any parsing. Do not remove it, and do not
|
||||
"fix" a future parsing bug here by adding `\r?` to one pattern.
|
||||
|
||||
## Token windows: why a chunk can hold several vectors
|
||||
|
||||
Chunking at heading boundaries keeps sections coherent, but it does not bound the
|
||||
result by *tokens* — and it cannot. Measured on this corpus the ratio ranges from
|
||||
**2.43 to 5.81 characters per token**, a factor of 2.4, so no character budget maps
|
||||
reliably onto the model's 256-token window. A budget safe for the worst case
|
||||
(~620 characters) would shred prose and tables alike.
|
||||
|
||||
Left alone, 87 of 324 chunks exceeded the window and **13% of all tokens never
|
||||
reached the ranking** — silently, since the tokenizer truncates without complaint.
|
||||
The effect was concrete: in `built-in-rules.md :: Apps` (734 tokens), the strings
|
||||
`ANALYZE-APP-013`, `ANALYZE-OFFICE-001`, `ANALYZE-CORR-003` and `Click-to-Run` all
|
||||
sit past the cutoff, so *"proxy configuration blocking app downloads"* did not
|
||||
retrieve that section at all.
|
||||
|
||||
So the *chunk* stays whole and the *embedding* is split instead:
|
||||
`VectorSearchProvider.index()` cuts long text into 250-token windows with 40 tokens
|
||||
of overlap and embeds each. A document therefore carries `embeddings: number[][]`,
|
||||
and `search()` scores it by its **best** window.
|
||||
|
||||
- **Max, not mean.** Averaging window vectors would blur a long section's several
|
||||
topics into one mediocre direction — the same dilution that makes large context
|
||||
windows worse than chunking. Max keeps a document findable by any one thing it
|
||||
discusses.
|
||||
- **The tokenizer is a build-time dependency only.** `index()` runs during the
|
||||
Docker build; production boots from precomputed vectors and never loads it. It is
|
||||
also only the vocabulary, not the ONNX weights (~93 ms).
|
||||
- Cost: 324 chunks → 424 vectors (+31%), index 2.63 → 3.12 MB, and 424 instead of
|
||||
324 dot products per query — still microseconds.
|
||||
- The rules corpus benefits too, unplanned: 128 rules now carry 172 vectors.
|
||||
|
||||
Measured effect: token coverage 87% → **100%**, *"Office Click-to-Run install never
|
||||
finished"* 0.318 → **0.442**, *"proxy configuration blocking app downloads"* absent
|
||||
→ **0.559 at rank 1**. The ten reference queries kept byte-identical scores — their
|
||||
chunks are short, hence one window, hence the same vector as before.
|
||||
|
||||
## Keyword fallback
|
||||
|
||||
Semantic ranking alone leaves a gap: proper nouns embed poorly. `RealmJoin` occurs
|
||||
in 3 chunks yet scores under the threshold.
|
||||
|
||||
Fuzzy matching is the wrong remedy, and the corpus says so. At a threshold low
|
||||
enough to surface `RealmJoin` (0.28), Fuse also returns 5 hits for `SCEPman` — a
|
||||
word this corpus contains **zero** times. Literal containment has neither problem.
|
||||
|
||||
So when the semantic pass returns fewer than `topK`, `search_docs` tops the list up
|
||||
via `lexicalMatch`, the substring scan every provider already exposes:
|
||||
|
||||
- Query terms are filtered to distinctive ones (≥4 characters, non-stopword, max 6).
|
||||
- A hit needs **corroboration**: at least two matching terms, unless the query has
|
||||
only one. Without this rule, "how do I bake sourdough bread" matched a page about
|
||||
LLM providers, because "bake" is a substring of "baked into the image".
|
||||
- Hits are **appended, never merged**: they carry no cosine similarity, so semantic
|
||||
results keep the head of the list and keyword hits fill the tail, flagged
|
||||
`matchType: "keyword"` with `keywordFallback` in the response.
|
||||
|
||||
### Language
|
||||
|
||||
The corpus and `all-MiniLM-L6-v2` are both English-only, and the failure is total
|
||||
rather than gradual: "wo werden meine Daten gespeichert" returns **zero** results,
|
||||
not weak ones, because the query vector lands nowhere near any English chunk.
|
||||
|
||||
The fix is a sentence in the tool description telling the caller to query in English
|
||||
and answer in the user's language — the caller is a language model, so translating
|
||||
the question before the call costs nothing. A multilingual embedding model would
|
||||
handle it natively but is substantially larger and would need to be measured against
|
||||
`scripts/eval-docs-search.ts` first, since it changes English retrieval too.
|
||||
`search_knowledge` carries the same note for the same reason.
|
||||
|
||||
### When it fires
|
||||
|
||||
Two triggers, and the second one exists because the first was not enough:
|
||||
|
||||
1. **Fewer than `topK` semantic hits** — the obvious case.
|
||||
2. **A weak semantic head** (best score below `WEAK_SEMANTIC_SCORE`, 0.35). Topping
|
||||
up only a short list misses the more common failure: a pasted identifier like
|
||||
`WOW6432Node` returns three chunks at ~0.29 that do not contain the word, while
|
||||
the single chunk that does never gets considered. When nothing semantic is
|
||||
convincing, exact matches take the front — keeping at least one semantic result,
|
||||
so a weak-but-correct hit is never pushed out entirely.
|
||||
|
||||
The threshold was chosen by measurement, not intuition. `scripts/eval-docs-search.ts`
|
||||
scores strategies over 2465 queries in three classes — every chunk heading
|
||||
(known-item, a regression detector), rare identifiers (the class this fallback
|
||||
serves), and hand-written questions (the reality anchor, not derived from the
|
||||
corpus):
|
||||
|
||||
| strategy | known-item | literal | handwritten MRR | fixed | broken |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| append-only (previous) | 71.9% | 66.2% | 1.000 | — | — |
|
||||
| weak < 0.30 | 71.9% | 72.6% | 1.000 | 142 | 0 |
|
||||
| **weak < 0.35** | **73.7%** | **81.1%** | **1.000** | **335** | **0** |
|
||||
| weak < 0.40 | 76.3% | 88.2% | 0.944 | 502 | 3 |
|
||||
| weak < 0.45 | 77.2% | 93.0% | 0.889 | 613 | 5 |
|
||||
|
||||
0.35 is strictly dominant: better in every class, worse in none. Higher thresholds
|
||||
keep repairing more identifier queries but start demoting hand-written questions —
|
||||
the class that most resembles real use — so the gain is not free past that point.
|
||||
|
||||
The measurement also corrected the size of the problem: identifier queries were
|
||||
succeeding only 66.2% of the time, so `WOW6432Node` was not an outlier but one of
|
||||
roughly 750 such failures.
|
||||
|
||||
## Build-time embedding
|
||||
|
||||
Like the rules corpus, docs are embedded once at Docker build time by
|
||||
`precompute-embeddings.js <rulesDir> <outFile> [docsDir]`, into a third
|
||||
`PrecomputedSection` (`docs`) of `search-index.json`. The container never embeds
|
||||
a corpus at boot — only the incoming query.
|
||||
|
||||
The `docs` section is **optional** in the file format, which is what makes a
|
||||
local checkout without the docs repo work: `validatePrecomputedIndex` skips it
|
||||
when the booting server loaded zero doc chunks. The asymmetry is intentional —
|
||||
if the server *has* a docs corpus and the file does *not*, that is staleness and
|
||||
is rejected like any other mismatch.
|
||||
|
||||
Embeddings are rounded to 6 decimals on serialize. A float64 component
|
||||
serializes to ~20 characters of JSON, so 384 dims cost ~7.8 KB per vector;
|
||||
rounding roughly halves the index file, which is `JSON.parse`d on every
|
||||
scale-to-zero cold start. The induced L2 norm error (~1e-5 over 384 dims) sits
|
||||
two orders of magnitude inside `UNIT_NORM_EPSILON`.
|
||||
|
||||
## Registration
|
||||
|
||||
`search_docs` is registered for **every role**, unlike the platform tools: this
|
||||
is public documentation carrying no tenant data, and a tenant user asking "how do
|
||||
I deploy the agent" has as much business here as a Global Admin. It is skipped
|
||||
entirely when the corpus is empty, so a doc-less build advertises no broken tool.
|
||||
|
||||
# Cold start
|
||||
|
||||
`minReplicas: 0` means every idle period ends in a cold start someone waits through.
|
||||
Measured from outside: **24.5 s** to first byte after `ScaledToZero`, against **60 ms**
|
||||
warm.
|
||||
|
||||
Where those seconds go could not be answered from outside the process. Log Analytics
|
||||
stamps every console line of one ingestion batch with the same time, so the boot
|
||||
sequence collapses to a single instant however it is queried. The server therefore
|
||||
times itself — `[boot +Nms]` marks from `process.uptime()`, which includes Node's own
|
||||
startup and module loading.
|
||||
|
||||
Measured in production on a real cold start (31.5 s to first byte that time):
|
||||
|
||||
```
|
||||
+244ms node started (startup + module loading)
|
||||
+431ms corpora read from disk (+187ms)
|
||||
+491ms index read, validated, hydrated (+60ms <- for 3.12 MB)
|
||||
+504ms listening
|
||||
+677ms query embedder warm (background)
|
||||
```
|
||||
|
||||
**The application accounts for half a second of it.** The other ~31 s — 98% — is
|
||||
Container Apps activation: KEDA reacting to the request, scheduling a pod, mounting
|
||||
the 236 MB image, starting the container. The container also boots *faster* than a
|
||||
development laptop (504 ms against 1273 ms), so nothing here is CPU-starved.
|
||||
|
||||
Three conclusions, and two of them overturn assumptions this file previously stated:
|
||||
|
||||
- **Index size is irrelevant to startup.** 3.12 MB hydrates in 60 ms. Growing the
|
||||
corpus is effectively free, and the embedding rounding earns its keep on file size
|
||||
alone, not on boot time.
|
||||
- **Raising the container to 0.5 vCPU was not justified by cold start.** The stated
|
||||
reason was the embedding model load; prebaked, that is 173 ms and runs in the
|
||||
background, where only the first *search* could ever wait on it. The extra vCPU may
|
||||
still help concurrent requests, but the cold-start argument does not survive
|
||||
measurement.
|
||||
- **The only real lever is `minReplicas: 1`**, which removes activation entirely at
|
||||
roughly 7× the monthly free consumption grant. Shrinking the image would shave the
|
||||
mount, but that is a fraction of platform time, not the bulk of it.
|
||||
|
||||
# Cross-repo coupling
|
||||
|
||||
**Editing the documentation does not update a running MCP server.** The corpus is
|
||||
baked into the image, so only re-running the *Deploy MCP Server* workflow picks
|
||||
up doc changes.
|
||||
|
||||
That coupling is made checkable rather than memorable: the build passes the docs
|
||||
checkout SHA as `--build-arg DOCS_COMMIT`, and `/health` reports
|
||||
`docs: { commit, chunks, sections }`. Comparing that commit against
|
||||
`git rev-parse HEAD` in `autopilotmonitor-docs` answers "is the deployed
|
||||
documentation stale?" directly.
|
||||
|
||||
Automatic redeploy on a docs push was considered and rejected: it would need a
|
||||
PAT secret in the docs repo and would turn every typo fix into a container
|
||||
deployment.
|
||||
|
||||
# Examples
|
||||
|
||||
Retrieval measured against the real bundle (323 chunks, all-MiniLM-L6-v2):
|
||||
|
||||
```
|
||||
"where is my data stored" → 0.566 troubleshooting/faq.md :: General › Where is my data stored?
|
||||
"how do I deploy the agent with Intune" → 0.819 troubleshooting/faq.md :: Setup & Agent › How do I deploy the agent?
|
||||
"which roles exist and what can they do" → 0.500 trust/security-faq.md :: Identity and Access › What roles exist?
|
||||
"how do I set up Teams notifications" → 0.592 reference/settings.md :: Tenant › Notifications
|
||||
"what firewall URLs do I need to allow" → 0.374 reference/network-endpoints.md :: (intro)
|
||||
```
|
||||
|
||||
The 0.25 `minScore` default is tuned to that observed band — short keyword
|
||||
queries on all-MiniLM score low, and a relevant-but-marginal hit lands around
|
||||
0.34.
|
||||
|
||||
# Citations
|
||||
|
||||
* `/src/McpServer/autopilot-monitor-mcp/src/docs-corpus.ts` — loader, frontmatter
|
||||
parser, chunker
|
||||
* `/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts` — `search_docs`
|
||||
registration
|
||||
* `/src/McpServer/autopilot-monitor-mcp/src/precomputed-index.ts` — `docs`
|
||||
section and its validation asymmetry
|
||||
* `/src/McpServer/autopilot-monitor-mcp/Dockerfile` — `mcp-docs/` copy,
|
||||
`DOCS_COMMIT`
|
||||
* `/.github/workflows/deploy-mcp.yml` — docs checkout and SHA capture
|
||||
* `/src/McpServer/autopilot-monitor-mcp/scripts/eval-docs-search.ts` — retrieval
|
||||
evaluation harness; run it before changing anything about ranking
|
||||
* [MCP OAuth Flow](../mcp-oauth-flow.md) — the other MCP concept document
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Gather Rule Guardrails
|
||||
description: >-
|
||||
How gather-rule collectors are fenced in — the allowlists in rules/guardrails.json,
|
||||
the hard blocks that survive unrestricted mode, and why every collector must call a
|
||||
guard rather than trusting rule.Target.
|
||||
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather
|
||||
tags: [gather-rules, security, guardrails, agent]
|
||||
timestamp: 2026-07-22
|
||||
---
|
||||
|
||||
# Concept
|
||||
|
||||
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, and that the rule lands in the caller's own tenant partition. Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
|
||||
|
||||
This is the security boundary. The portal's `ValidationIndicator` is a convenience: it does not block submit, and the API is reachable directly.
|
||||
|
||||
# Schema
|
||||
|
||||
Allowlists come from `rules/guardrails.json`, embedded into the agent assembly as
|
||||
`AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json` (see the `EmbeddedResource`
|
||||
item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal's
|
||||
`utils/guardrails.generated.ts` by `node rules/scripts/combine.js`.
|
||||
|
||||
| Guard | Source list | Match rule |
|
||||
| --- | --- | --- |
|
||||
| `IsRegistryPathAllowed` | `registryPrefixes` | prefix, boundary `\` or end |
|
||||
| `IsFilePathAllowed` | `filePrefixes` | `Path.GetFullPath` first, then prefix + boundary |
|
||||
| `IsWmiQueryAllowed` | `wmiQueryPrefixes` | prefix, boundary whitespace or end |
|
||||
| `IsCommandAllowed` | `allowedCommands` | **exact** match, trimmed — never prefix |
|
||||
| `IsEventLogChannelAllowed` | `eventLogChannels` | prefix, boundary `/` or end |
|
||||
|
||||
**Hard blocks live in code, not in the JSON**, so a parse error cannot lift them — on a
|
||||
parse failure the allowlists fall back to empty and everything is blocked (fail-closed),
|
||||
but the hard blocks must hold regardless of configuration:
|
||||
|
||||
* `BlockedUsersPrefix` (`C:\Users`) and `AdditionalHardBlockedPathPrefixes`
|
||||
(`C:\Windows\System32\config`) — exception only for `%LOGGED_ON_USER_PROFILE%`
|
||||
under `AppData\Local` / `AppData\Roaming`.
|
||||
* `HardBlockedCommandPatterns` — download, user/group creation, boot config,
|
||||
persistence, destructive operations; plus `MaxCommandLength`.
|
||||
* `HardBlockedEventLogChannels` — `Security` (audit trail of user behaviour) and the
|
||||
PowerShell channels (script-block logging routinely contains secrets in clear text).
|
||||
|
||||
`rules/guardrails.json` mirrors the blocked lists (`blockedFilePrefixes`,
|
||||
`blockedEventLogChannels`) **for display only**. Enforcement is the C# constant.
|
||||
|
||||
# Examples
|
||||
|
||||
Every collector must call its guard and return `context.EmitSecurityWarning(...)` on
|
||||
refusal — that emits the `security_warning` event that makes a block visible in the
|
||||
timeline instead of failing silently.
|
||||
|
||||
```csharp
|
||||
// FileCollector / JsonCollector / XmlCollector
|
||||
if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode, userProfilePath))
|
||||
return context.EmitSecurityWarning(rule, "file", filePath);
|
||||
```
|
||||
|
||||
Two collectors were added to this contract on 2026-07-22 after shipping without it:
|
||||
|
||||
* **`LogParserCollector`** read any path in `rule.Target`, bypassing even the `C:\Users`
|
||||
hard block. It cannot guard `rule.Target` directly — a wildcard filename makes
|
||||
`Path.GetFullPath` throw — so it guards **the directory** when the filename contains
|
||||
`*`/`?`, and re-guards **each resolved path** after wildcard expansion (a junction can
|
||||
surface a file the directory check did not cover).
|
||||
* **`EventLogCollector`** read any channel, including `Security`.
|
||||
|
||||
`ImeLogPathOverride` comes only from the local `--ime-log-path` CLI flag, never from
|
||||
remote config. The operator using it is already a local admin, so it relaxes the
|
||||
allowlist exactly the way unrestricted mode does — hard blocks still apply.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs` — `EmitSecurityWarning`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleCollectorGuardTests.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/GatherRulesFunction.cs` — what the backend does *not* validate
|
||||
* `rules/guardrails.json`, `rules/scripts/combine.js`
|
||||
* Customer-facing counterpart: `autopilotmonitor-docs/rules/gather-rules.md` (§ Security guardrails)
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Gather Rule Phase Scoping & Emit-on-Change
|
||||
description: How gather rules are restricted to enrollment phases (activePhases / activeFromPhase) and how emitMode "on_change" suppresses repeated identical results — the anti-spam pair for interval rules.
|
||||
title: Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change
|
||||
description: How gather rules are restricted to enrollment phases (activePhases / activeFromPhase), collect once at a phase boundary (phase_change / phase_exit), and how emitMode "on_change" suppresses repeated identical results.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs
|
||||
tags:
|
||||
- agent
|
||||
@@ -35,6 +35,35 @@ Backend validation (`GatherRulesFunction.ValidateScopeAndEmitMode`) rejects `Unk
|
||||
numeric tokens, unknown emit modes, and both scope fields set at once (400). The agent
|
||||
defensively prefers `activePhases` if both arrive anyway.
|
||||
|
||||
# One-shot collection at phase boundaries
|
||||
|
||||
Scoping answers "while *may* this rule run". The trigger answers "when does it fire". For
|
||||
one-shot collection the two phase triggers are the bookends of a phase:
|
||||
|
||||
| Trigger | Fires |
|
||||
|---|---|
|
||||
| `phase_change` | Once when `triggerPhase` is **entered** (dedup key `{ruleId}\|{phase}`). |
|
||||
| `phase_exit` | Once when `triggerPhase` is **left** (dedup key `exit:{ruleId}\|{phase}`). |
|
||||
|
||||
Empty `triggerPhase` means every transition. Both keys live in the same
|
||||
`_phaseRulesExecuted` set; the `exit:` prefix keeps the two key spaces disjoint, so an
|
||||
enter-rule and an exit-rule on the same phase never cannibalise each other's slot.
|
||||
|
||||
`phase_exit` semantics that are easy to get wrong:
|
||||
|
||||
* It is evaluated **before** `_currentPhase` advances, so both the `triggerPhase` match and
|
||||
the phase-scope gate see the phase being **left**. A rule scoped to `DeviceSetup` therefore
|
||||
still fires on its own exit, even though the phase being entered is outside that scope.
|
||||
* A transition into `Failed` **does** fire the exit rules of the failing phase — capturing
|
||||
state at the failure boundary is the point.
|
||||
* Exit of `Unknown` never fires (nothing was entered yet), and a repeated identical phase
|
||||
signal is not a transition.
|
||||
* An exit rule on `Complete` only fires if something transitions *away* from Complete. For
|
||||
"at the end of enrollment", use `phase_change` on `Complete` or `on_event` with
|
||||
`enrollment_complete`.
|
||||
* Old agents do not know the trigger value and simply never fire the rule (silent no-op) —
|
||||
which is why `phase_exit` needs no ConfigVersion bump: no new field is transported.
|
||||
|
||||
# Scope semantics (agent, `GatherRuleExecutor`)
|
||||
|
||||
* Scope applies to **all trigger types**:
|
||||
@@ -78,6 +107,16 @@ defensively prefers `activePhases` if both arrive anyway.
|
||||
`always`. The scope UI is a three-way mode (always / during phases / from phase) backed by
|
||||
a canonical `GATHER_PHASES` list — the same list replaced the free-text `triggerPhase`
|
||||
input, whose placeholder used to suggest invalid tokens.
|
||||
* **Controls are shown only where they change behavior** (`supportsPhaseScope` /
|
||||
`supportsEmitMode` in `types.ts`). Offering all three "when" controls unconditionally
|
||||
produced meaningless combinations — a scope on a `phase_change` rule naming a concrete
|
||||
phase can only ever suppress the single firing, and `on_change` has no second result to
|
||||
compare against. Hidden controls are nulled by `buildScopeFields` so state from an earlier
|
||||
trigger choice cannot leak into the payload, and `validateScopeSelection` rejects a scope
|
||||
mode that was selected but left unfilled (which previously serialised to null and silently
|
||||
ran the rule everywhere while the form still read "From a phase onwards").
|
||||
Startup is the deliberate exception: a scope there defers the one-shot rather than
|
||||
suppressing it, so the scope control stays while emit mode is hidden.
|
||||
* The custom-rule toggle PUT (`{enabled, isBuiltIn, isCommunity}`) previously full-replaced
|
||||
the stored rule and wiped Title/Target/Trigger — `GatherRuleService.UpdateRuleAsync` now
|
||||
merges toggle-style partials (empty Title/CollectorType/Target) into the existing row and
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
type: Concept
|
||||
title: URL Registry — One Place per Component, Guard-Enforced
|
||||
description: Every well-known own or Microsoft host lives in exactly one registry file per component (Constants.cs, utils/config.ts, src/config.ts); guard tests fail the build on any literal outside them. Includes the agent-download host migration to the download alias.
|
||||
resource: /src/Shared/AutopilotMonitor.Shared/Constants.cs
|
||||
tags:
|
||||
- architecture
|
||||
- urls
|
||||
- ci
|
||||
- convention
|
||||
timestamp: 2026-07-23T00:00:00+02:00
|
||||
---
|
||||
|
||||
# URL Registry
|
||||
|
||||
Every well-known base URL — own hosts (portal, www, docs, download alias, MCP,
|
||||
API, both blob accounts) and Microsoft hosts (Graph, Entra login) — is defined
|
||||
in exactly one registry file per component. Code references the constant; the
|
||||
literal appears nowhere else.
|
||||
|
||||
| Component | Registry | Guard test |
|
||||
|---|---|---|
|
||||
| C# (Shared/Backend/Agent) | `AutopilotMonitor.Shared.Constants` (`*BaseUrl`) | `HardcodedUrlGuardTests` |
|
||||
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
|
||||
| MCP | `src/config.ts` (`API_BASE_URL`, `ENTRA_LOGIN_BASE_URL`, `DOCS_BASE_URL`) | `src/__tests__/hardcoded-urls.guard.test.ts` |
|
||||
|
||||
Derived registries build on these instead of repeating hosts:
|
||||
`lib/hostRouting.ts` computes `PUBLIC_HOST`/`PORTAL_HOST`/`APEX_HOST` from the
|
||||
URL constants, and `middleware.ts` imports from it.
|
||||
|
||||
# Schema
|
||||
|
||||
The guards scan non-test source for the enforced host list and fail with
|
||||
file:line on any occurrence outside the registry. Comment lines are ignored
|
||||
(docs may cite URLs). Test files are excluded on purpose: a literal in a test
|
||||
is an independent oracle — `ValidateBootstrapCodeResponseShapeTests` spells the
|
||||
full agent download URL out so a registry change must be acknowledged in two
|
||||
places, backend and portal allow-list, consciously.
|
||||
|
||||
Why enforced rather than agreed: the registry existed before (both
|
||||
`Constants.AgentBlobBaseUrl` and the MCP `config.ts` header document earlier
|
||||
drift), yet the EU cutover still missed two literal copies of the blob host —
|
||||
`LatestVersionsService` and `ValidateBootstrapCodeFunction` kept reading the
|
||||
legacy account, silently kept alive only by the fail-soft legacy mirror in
|
||||
`build-agent.yml`. A convention that is not checked decays; these guards make
|
||||
the next stray literal a red build instead of a production surprise.
|
||||
|
||||
# Agent-download host migration (2026-07-23)
|
||||
|
||||
The two legacy readers above plus the agent's `SelfUpdater` now use
|
||||
`Constants.AgentDownloadBaseUrl` (`download.autopilotmonitor.com/agent`, Front
|
||||
Door in front of the current blob origin — verified serving `version.json`,
|
||||
`version-v2.json` and both ZIPs before the switch). The portal's
|
||||
`bootstrapValidation.ts` allow-lists BOTH the alias and the legacy blob host
|
||||
(`AGENT_DOWNLOAD_HOSTNAMES`) because bootstrap scripts already deployed in
|
||||
customer Intune tenants still carry legacy URLs.
|
||||
|
||||
Deploy-order constraint: the web allow-list must be live BEFORE (or with) the
|
||||
backend that serves alias URLs — an old portal build rejects the alias host and
|
||||
the bootstrap flow dies client-side. Remove the legacy hostname from the
|
||||
allow-list only after the legacy account is torn down.
|
||||
|
||||
Deliberately still on the legacy account: the `HealthCheckService` /
|
||||
`MaintenanceService` legacy-keepalive probes and the fail-soft mirror in
|
||||
`build-agent.yml` — they exist precisely to keep already-deployed customer
|
||||
bootstrap scripts working until the customer migration completes.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — C# registry
|
||||
- `src/Web/autopilot-monitor-web/utils/config.ts` — web registry
|
||||
- `src/McpServer/autopilot-monitor-mcp/src/config.ts` — MCP registry
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/HardcodedUrlGuardTests.cs`
|
||||
- [Version Contract](versioning.md) — the release pipeline this migration protects
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Version Contract — Agent, Backend, MCP
|
||||
description: One version shape for all three shipping components, the ETag-CAS counter blob that mints build numbers, and the published manifests that make "what is deployed" checkable from outside.
|
||||
resource: /.github/scripts/Request-BuildNumber.ps1
|
||||
tags:
|
||||
- build
|
||||
- ci
|
||||
- release
|
||||
- versioning
|
||||
timestamp: 2026-07-22T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Version Contract
|
||||
|
||||
Three components ship from this repository — the agent, the Functions backend and the
|
||||
MCP server. They share one version shape, one way of minting build numbers, and one
|
||||
manifest format, so that a version string means the same thing whoever emits it.
|
||||
|
||||
```
|
||||
version = <major>.<minor>.<build> major.minor curated by hand, build minted per build
|
||||
commit = 7-char short SHA of this repo
|
||||
buildUtc = ISO-8601 build moment
|
||||
```
|
||||
|
||||
| Component | major.minor from | Counter blob | Reported by |
|
||||
|---|---|---|---|
|
||||
| Agent V2 | `VersionPrefix` in `AutopilotMonitor.Agent.V2.csproj` | `agent/buildcounter-v2.txt` | `agent/version.json` |
|
||||
| Backend | `VersionPrefix` in `AutopilotMonitor.Functions.csproj` | `versions/backend.txt` | `GET /api/health` |
|
||||
| MCP | `version` in `package.json` | `versions/mcp.txt` | `GET /health`, MCP handshake |
|
||||
|
||||
# Schema
|
||||
|
||||
## Build numbers — reserve before build, ETag-CAS
|
||||
|
||||
`.github/scripts/Request-BuildNumber.ps1` is the only implementation; the CI
|
||||
workflows and the local `build.ps1` all call it.
|
||||
|
||||
1. Anonymous `GET` on the counter blob → value `N` and `ETag`. The blob holds the
|
||||
**last used** number as plain text and is public-read; only the write needs the
|
||||
container SAS.
|
||||
2. `PUT N+1` with `If-Match: <etag>`. The quoted ETag form is required — PowerShell
|
||||
strips the quotes on read, and an unquoted `If-Match` is rejected client-side.
|
||||
3. `412 Precondition Failed` → a concurrent build reserved first → re-read and retry
|
||||
(5 attempts, linear backoff).
|
||||
4. Build with the reserved number.
|
||||
|
||||
Reserving *before* the build is what makes concurrent minting impossible. **A failed
|
||||
build burns a number** — deliberate: uniqueness matters, density does not.
|
||||
|
||||
Why a counter and not the published manifest: manifests are written on publish, not on
|
||||
build. The agent writes `version.json` only for stable releases, so deriving the next
|
||||
number from it would hand two consecutive dev builds the same number and let the second
|
||||
overwrite the first's artifact. A state-free scheme such as `git rev-list --count HEAD`
|
||||
fails for the same reason — two builds of one commit collide.
|
||||
|
||||
**Seeding** (once per component): `PUT <last used number>` with `If-None-Match: *`, so
|
||||
an existing counter can never be clobbered. Agent V2 was seeded with `1356` on
|
||||
2026-07-17; `versions/backend.txt` with `999` and `versions/mcp.txt` with `299` on
|
||||
2026-07-22.
|
||||
|
||||
## Local builds
|
||||
|
||||
No counter is reached, the build number stays `0`, and the commit is empty or
|
||||
`unknown`. A `1.5.0` with no commit hash on `/api/health` is therefore a developer
|
||||
machine, not a release — the fallback is the signal, not a defect. The agent is the
|
||||
exception: `build.ps1` produces uploadable artifacts and reserves a real number unless
|
||||
`-SkipUpload` is passed.
|
||||
|
||||
## Manifests
|
||||
|
||||
`versions/backend.json` and `versions/mcp.json` (public-read) record what is deployed:
|
||||
|
||||
```json
|
||||
{ "component": "backend", "version": "1.5.1000", "commit": "abc1234",
|
||||
"buildUtc": "…", "deployedUtc": "…", "runId": "…" }
|
||||
```
|
||||
|
||||
The MCP manifest adds `docsCommit`, because for that component "which build" and "which
|
||||
documentation" are separate questions.
|
||||
|
||||
Each is written **last and only after the deploy workflow has polled the component's
|
||||
health endpoint until it reports that exact version and commit**. A green deploy step
|
||||
alone means the package was accepted, not that it is being served. Consequently a
|
||||
manifest that disagrees with the live endpoint means the running instance drifted after
|
||||
the fact (rollback, stale revision) rather than a half-finished pipeline.
|
||||
|
||||
The agent keeps its own manifest at `agent/version.json` — the bootstrap derives that
|
||||
URL from the download URL and verifies the ZIP against its `sha256`, so it cannot move.
|
||||
It carries the same `commit` and `buildUtc` fields; `sha256` and `bootstrapVersion`
|
||||
remain, being the customer-facing integrity contract.
|
||||
|
||||
# Consequences
|
||||
|
||||
- Bumping `major.minor` is a human decision, made in the component's own file
|
||||
(`VersionPrefix`, or `version` in `package.json`). Nothing bumps it automatically.
|
||||
- A new component joins by seeding a counter blob and calling the same script; no new
|
||||
mechanism.
|
||||
- The counter blobs and manifests live in the `versions` container on
|
||||
`autopilotmonitoreu` (writes via `AZURE_BLOB_VERSIONS_SAS_TOKEN`). The agent's
|
||||
counter stays in the `agent` container next to the artifacts it numbers.
|
||||
|
||||
# Citations
|
||||
|
||||
- `.github/scripts/Request-BuildNumber.ps1` — the CAS protocol
|
||||
- `.github/workflows/deploy-backend.yml`, `deploy-mcp.yml` — reserve, verify, publish
|
||||
- `.github/workflows/build-agent.yml`, `scripts/Deployment/build.ps1` — agent line
|
||||
- [Build Counter Blob](agent/build-counter-blob.md) — agent-specific consequences
|
||||
@@ -0,0 +1,268 @@
|
||||
// Autopilot-Monitor MCP Server — Azure Container App Infrastructure
|
||||
//
|
||||
// Reconciled against the live deployment on 2026-07-21 (rg-autopilotmonitor-prd-gwc,
|
||||
// Germany West Central). The template had drifted behind two changes made directly
|
||||
// in Azure: the EU cutover (registry/API renamed) and the ACR hardening (admin user
|
||||
// disabled, image pull moved to the app's system-assigned identity).
|
||||
//
|
||||
// !! BEFORE RE-DEPLOYING THIS TEMPLATE, READ THIS !!
|
||||
// The live app carries an ingress custom-domain binding for mcp.autopilotmonitor.com
|
||||
// (SNI-enabled, with a managed certificate). Custom domains are NOT modelled below,
|
||||
// because the certificate is bound out-of-band. A full template deployment would
|
||||
// therefore REMOVE that binding and take the public MCP endpoint offline.
|
||||
// For routine changes (CPU/memory, env vars, image) prefer the surgical CLI form:
|
||||
// az containerapp update -n autopilotmonitor-mcp -g rg-autopilotmonitor-prd-gwc --cpu 0.5 --memory 1.0Gi
|
||||
// Treat this file as the documented desired state and as a rebuild-from-scratch
|
||||
// recipe — not as a routinely-applied deployment.
|
||||
//
|
||||
// On a FROM-SCRATCH deploy the AcrPull assignment below is created only after the
|
||||
// Container App exists (it needs the app's principalId), so the very first revision
|
||||
// can fail to pull its image. Re-run the deployment, or restart the revision, once
|
||||
// the role assignment has propagated. This does not affect updates to a live app.
|
||||
//
|
||||
// Deploy (two-stage; required to set the OAuth public URL safely):
|
||||
// 1. First deploy without mcpPublicUrl:
|
||||
// az deployment group create \
|
||||
// --resource-group <rg-name> \
|
||||
// --template-file infra/mcp-server.bicep \
|
||||
// --parameters apiUrl=https://autopilotmonitor-api.azurewebsites.net \
|
||||
// entraClientSecret=<secret-value>
|
||||
// This emits `containerAppUrl` as an output (e.g. https://autopilotmonitor-mcp.<env-suffix>.<region>.azurecontainerapps.io).
|
||||
//
|
||||
// 2. Re-deploy with that URL pinned, so the OAuth proxy never trusts caller-
|
||||
// supplied X-Forwarded-Host / Host headers when building issuer / WWW-
|
||||
// Authenticate / redirect_uri metadata:
|
||||
// --parameters mcpPublicUrl=<containerAppUrl from step 1>
|
||||
//
|
||||
// When mcpPublicUrl is set, the MCP server uses it verbatim and ignores any
|
||||
// forwarded-headers from the request. Without it, the server logs a startup
|
||||
// warning and falls back to header-derived URLs (acceptable for local dev,
|
||||
// not for production).
|
||||
|
||||
@description('Azure region for all resources')
|
||||
param location string = resourceGroup().location
|
||||
|
||||
@description('Name of the Container App')
|
||||
param containerAppName string = 'autopilotmonitor-mcp'
|
||||
|
||||
@description('Name of the Container App Environment')
|
||||
param environmentName string = 'autopilotmonitor-env'
|
||||
|
||||
@description('Name of the Log Analytics Workspace (dedicated for Container Apps)')
|
||||
param logAnalyticsName string = 'autopilotmonitor-container-logs'
|
||||
|
||||
@description('Name of the Azure Container Registry')
|
||||
param acrName string = 'autopilotmonitoreuacr'
|
||||
|
||||
@description('Backend API URL')
|
||||
param apiUrl string = 'https://autopilotmonitor-api-eu.azurewebsites.net'
|
||||
|
||||
@description('Container image tag')
|
||||
param imageTag string = 'latest'
|
||||
|
||||
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret.')
|
||||
param entraClientId string = '1a400946-62c1-4ab4-aa37-f730ac89704d'
|
||||
|
||||
@secure()
|
||||
@description('Entra ID client secret for OAuth token exchange')
|
||||
param entraClientSecret string = ''
|
||||
|
||||
@description('Comma-separated allowlist of redirect_uri hosts for the OAuth proxy. Loopback is always allowed. Empty = built-in default (Anthropic, OpenAI, Google).')
|
||||
param mcpAllowedRedirectHosts string = ''
|
||||
|
||||
@description('Public URL of the MCP server, used verbatim for OAuth issuer / WWW-Authenticate / redirect metadata. REQUIRED in production: the container refuses to boot without it (host-spoofing defense — see config.ts). Set on the second deploy with the value of the containerAppUrl output from the first deploy. The stage-1 deploy is safe because minReplicas=0 means no container runs until this is pinned. Empty falls back to X-Forwarded-Host headers only outside production (NODE_ENV != production).')
|
||||
param mcpPublicUrl string = ''
|
||||
|
||||
@secure()
|
||||
// NOTE: avoid characters outside cp1252 here (this one used to say a Unicode
|
||||
// >=). `az bicep build --stdout` writes through the Windows console codec and
|
||||
// dies with a UnicodeEncodeError on anything it cannot map. Em dashes are fine.
|
||||
@description('Base64-encoded HMAC key (>=32 bytes after decode) for signing the OAuth proxy state. Naming and format match the backend\'s PaginationTokenSigningKey for consistency. Empty = generate per-instance random key on startup (acceptable for single-replica deployments since state has 10 min lifetime). Generate with PowerShell: [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))')
|
||||
param oAuthStateSigningKey string = ''
|
||||
|
||||
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs)')
|
||||
param toolLoggingEnabled bool = false
|
||||
|
||||
// --- Azure Container Registry ---
|
||||
|
||||
// Admin user is DISABLED: a registry-wide username/password pair is a static,
|
||||
// non-rotating credential that would also have to live in a Container App secret.
|
||||
// The app pulls with its system-assigned identity + AcrPull instead (see below).
|
||||
resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' = {
|
||||
name: acrName
|
||||
location: location
|
||||
sku: {
|
||||
name: 'Basic'
|
||||
}
|
||||
properties: {
|
||||
adminUserEnabled: false
|
||||
}
|
||||
}
|
||||
|
||||
// AcrPull for the Container App's system-assigned identity — this is what replaces
|
||||
// the admin credentials. Without it the app cannot pull its own image.
|
||||
//
|
||||
// NOTE: this assignment exists on the live registry but could not be enumerated
|
||||
// during reconciliation (the CLI session lacks Microsoft.Authorization read), so it
|
||||
// is reproduced here from the documented pattern rather than read back from Azure.
|
||||
// Verify with:
|
||||
// az role assignment list --scope <acr-id> --assignee <containerApp principalId>
|
||||
var acrPullRoleId = '7f951dda-4ed3-4680-a7ca-43fe172d538d'
|
||||
|
||||
resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
|
||||
scope: acr
|
||||
name: guid(acr.id, containerAppName, acrPullRoleId)
|
||||
properties: {
|
||||
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', acrPullRoleId)
|
||||
principalId: containerApp.identity.principalId
|
||||
principalType: 'ServicePrincipal'
|
||||
}
|
||||
}
|
||||
|
||||
// --- Log Analytics Workspace (required by Container App Environment) ---
|
||||
|
||||
resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
|
||||
name: logAnalyticsName
|
||||
location: location
|
||||
properties: {
|
||||
sku: {
|
||||
name: 'PerGB2018'
|
||||
}
|
||||
retentionInDays: 30
|
||||
}
|
||||
}
|
||||
|
||||
// --- Container App Environment ---
|
||||
|
||||
resource environment 'Microsoft.App/managedEnvironments@2024-03-01' = {
|
||||
name: environmentName
|
||||
location: location
|
||||
properties: {
|
||||
appLogsConfiguration: {
|
||||
destination: 'log-analytics'
|
||||
logAnalyticsConfiguration: {
|
||||
customerId: logAnalytics.properties.customerId
|
||||
sharedKey: logAnalytics.listKeys().primarySharedKey
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Container App ---
|
||||
|
||||
resource containerApp 'Microsoft.App/containerApps@2024-03-01' = {
|
||||
name: containerAppName
|
||||
location: location
|
||||
// System-assigned identity: the credential the app uses to pull from the ACR
|
||||
// (see the AcrPull assignment above). Replaces the former admin-user secret.
|
||||
identity: {
|
||||
type: 'SystemAssigned'
|
||||
}
|
||||
properties: {
|
||||
managedEnvironmentId: environment.id
|
||||
configuration: {
|
||||
ingress: {
|
||||
external: true
|
||||
targetPort: 8080
|
||||
transport: 'auto'
|
||||
allowInsecure: false
|
||||
}
|
||||
// 'system' = pull with the app's system-assigned identity. No username, no
|
||||
// password secret — nothing to rotate and nothing to leak from the app config.
|
||||
registries: [
|
||||
{
|
||||
server: acr.properties.loginServer
|
||||
identity: 'system'
|
||||
}
|
||||
]
|
||||
secrets: [
|
||||
{
|
||||
name: 'entra-client-secret'
|
||||
value: entraClientSecret
|
||||
}
|
||||
{
|
||||
name: 'oauth-state-signing-key'
|
||||
value: oAuthStateSigningKey
|
||||
}
|
||||
]
|
||||
activeRevisionsMode: 'Single'
|
||||
}
|
||||
template: {
|
||||
containers: [
|
||||
{
|
||||
name: 'mcp-server'
|
||||
image: '${acr.properties.loginServer}/${containerAppName}:${imageTag}'
|
||||
// 0.5 vCPU, but NOT for the reason originally given. It was raised from
|
||||
// 0.25 to shorten the scale-to-zero cold start, on the assumption that
|
||||
// loading the ONNX embedding model dominated it. Measurement disproved
|
||||
// that: the app boots in 504 ms in production (see the [boot] marks in
|
||||
// the container log and docs/mcp/docs-corpus.md), of which the prebaked
|
||||
// embedder is 173 ms in the background. Of a 31 s cold start, ~98% is
|
||||
// Container Apps activation — pod scheduling, image mount, container
|
||||
// start — which no CPU setting touches.
|
||||
//
|
||||
// It stays at 0.5 because it plausibly helps CONCURRENT requests
|
||||
// (maxReplicas is 1, so one replica serves up to 10 in-flight calls) and
|
||||
// because at scale-to-zero the container bills only while active, making
|
||||
// the difference negligible. If cold start ever needs to actually go
|
||||
// away, the only lever is minReplicas: 1. Container Apps accepts fixed
|
||||
// CPU/memory pairs, so memory moves with the CPU.
|
||||
resources: {
|
||||
cpu: json('0.5')
|
||||
memory: '1.0Gi'
|
||||
}
|
||||
env: [
|
||||
{ name: 'AUTOPILOT_API_URL', value: apiUrl }
|
||||
{ name: 'PORT', value: '8080' }
|
||||
{ name: 'AUTOPILOT_ENTRA_CLIENT_ID', value: entraClientId }
|
||||
{ name: 'AUTOPILOT_ENTRA_CLIENT_SECRET', secretRef: 'entra-client-secret' }
|
||||
{ name: 'MCP_ALLOWED_REDIRECT_HOSTS', value: mcpAllowedRedirectHosts }
|
||||
{ name: 'MCP_PUBLIC_URL', value: mcpPublicUrl }
|
||||
{ name: 'OAuthStateSigningKey', secretRef: 'oauth-state-signing-key' }
|
||||
{ name: 'MCP_TOOL_LOGGING', value: toolLoggingEnabled ? 'true' : 'false' }
|
||||
]
|
||||
probes: [
|
||||
{
|
||||
type: 'Liveness'
|
||||
httpGet: {
|
||||
path: '/health'
|
||||
port: 8080
|
||||
}
|
||||
periodSeconds: 30
|
||||
initialDelaySeconds: 10
|
||||
}
|
||||
{
|
||||
type: 'Readiness'
|
||||
httpGet: {
|
||||
path: '/health'
|
||||
port: 8080
|
||||
}
|
||||
periodSeconds: 10
|
||||
initialDelaySeconds: 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
scale: {
|
||||
minReplicas: 0
|
||||
maxReplicas: 1
|
||||
rules: [
|
||||
{
|
||||
name: 'http-scaler'
|
||||
http: {
|
||||
metadata: {
|
||||
concurrentRequests: '10'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Outputs ---
|
||||
|
||||
output containerAppUrl string = 'https://${containerApp.properties.configuration.ingress.fqdn}'
|
||||
output mcpEndpoint string = 'https://${containerApp.properties.configuration.ingress.fqdn}/mcp'
|
||||
output acrLoginServer string = acr.properties.loginServer
|
||||
@@ -109,6 +109,79 @@
|
||||
"SELECT * FROM Win32_LogicalDisk",
|
||||
"SELECT * FROM SoftwareLicensingProduct"
|
||||
],
|
||||
"eventLogChannels": [
|
||||
{
|
||||
"category": "Core Windows logs",
|
||||
"channels": [
|
||||
"Application",
|
||||
"System",
|
||||
"Setup",
|
||||
"Microsoft-Windows-Kernel-Boot",
|
||||
"Microsoft-Windows-Diagnostics-Performance"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "MDM / Enrollment",
|
||||
"channels": [
|
||||
"Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider",
|
||||
"Microsoft-Windows-ModernDeployment-Diagnostics-Provider",
|
||||
"Microsoft-Windows-Provisioning-Diagnostics-Provider",
|
||||
"Microsoft-Windows-AAD",
|
||||
"Microsoft-Windows-User Device Registration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "ESP / Shell / Apps",
|
||||
"channels": [
|
||||
"Microsoft-Windows-Shell-Core",
|
||||
"Microsoft-Windows-AppXDeployment",
|
||||
"Microsoft-Windows-AppXDeploymentServer",
|
||||
"Microsoft-Windows-AppReadiness",
|
||||
"Microsoft-Windows-Store"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Security posture / Crypto",
|
||||
"channels": [
|
||||
"Microsoft-Windows-BitLocker-API",
|
||||
"Microsoft-Windows-BitLocker-DrivePreparationTool",
|
||||
"Microsoft-Windows-TPM-WMI",
|
||||
"Microsoft-Windows-CertificateServicesClient-Lifecycle-System"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Update / Servicing",
|
||||
"channels": [
|
||||
"Microsoft-Windows-WindowsUpdateClient"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Logon / Session",
|
||||
"channels": [
|
||||
"Microsoft-Windows-Winlogon",
|
||||
"Microsoft-Windows-User Profile Service",
|
||||
"Microsoft-Windows-GroupPolicy",
|
||||
"Microsoft-Windows-TaskScheduler"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Network",
|
||||
"channels": [
|
||||
"Microsoft-Windows-NetworkProfile",
|
||||
"Microsoft-Windows-Dhcp-Client",
|
||||
"Microsoft-Windows-DNS-Client",
|
||||
"Microsoft-Windows-NCSI",
|
||||
"Microsoft-Windows-WLAN-AutoConfig",
|
||||
"Microsoft-Windows-Time-Service"
|
||||
]
|
||||
}
|
||||
],
|
||||
"blockedEventLogChannels": [
|
||||
"Security",
|
||||
"Microsoft-Windows-PowerShell",
|
||||
"Windows PowerShell",
|
||||
"Microsoft-Windows-Sysmon"
|
||||
],
|
||||
"allowedCommands": [
|
||||
{
|
||||
"category": "TPM and Security",
|
||||
|
||||
@@ -80,8 +80,8 @@
|
||||
},
|
||||
"trigger": {
|
||||
"type": "string",
|
||||
"description": "When to collect data.",
|
||||
"enum": ["startup", "phase_change", "interval", "on_event"]
|
||||
"description": "When to collect data. \"phase_change\" fires once when triggerPhase is entered, \"phase_exit\" once when it is left.",
|
||||
"enum": ["startup", "phase_change", "phase_exit", "interval", "on_event"]
|
||||
},
|
||||
"intervalSeconds": {
|
||||
"type": "integer",
|
||||
@@ -90,7 +90,7 @@
|
||||
},
|
||||
"triggerPhase": {
|
||||
"type": "string",
|
||||
"description": "Phase to trigger on (only used when trigger = \"phase_change\")."
|
||||
"description": "Phase to trigger on (used when trigger = \"phase_change\" or \"phase_exit\"). Empty = every phase transition."
|
||||
},
|
||||
"triggerEventType": {
|
||||
"type": "string",
|
||||
|
||||
@@ -91,6 +91,7 @@ if (fs.existsSync(guardrailsPath)) {
|
||||
// Flatten categorized lists for validation
|
||||
const flatRegistryPrefixes = guardrails.registryPrefixes.flatMap((g) => g.prefixes);
|
||||
const flatCommands = guardrails.allowedCommands.flatMap((g) => g.commands);
|
||||
const flatEventLogChannels = guardrails.eventLogChannels.flatMap((g) => g.channels);
|
||||
|
||||
const ts = `/**
|
||||
* AUTO-GENERATED from rules/guardrails.json — DO NOT EDIT.
|
||||
@@ -114,6 +115,10 @@ export const COMMAND_CATEGORIES: readonly GuardrailCategory[] = [
|
||||
${categorizedArray(guardrails.allowedCommands, 'commands')}
|
||||
];
|
||||
|
||||
export const EVENT_LOG_CHANNEL_CATEGORIES: readonly GuardrailCategory[] = [
|
||||
${categorizedArray(guardrails.eventLogChannels, 'channels')}
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flat arrays (for validation logic)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -141,10 +146,18 @@ ${flatArray(guardrails.diagnosticsPathPrefixes)}
|
||||
export const BLOCKED_FILE_PREFIXES: readonly string[] = [
|
||||
${flatArray(guardrails.blockedFilePrefixes)}
|
||||
];
|
||||
|
||||
export const ALLOWED_EVENT_LOG_CHANNELS: readonly string[] = [
|
||||
${flatArray(flatEventLogChannels)}
|
||||
];
|
||||
|
||||
export const BLOCKED_EVENT_LOG_CHANNELS: readonly string[] = [
|
||||
${flatArray(guardrails.blockedEventLogChannels)}
|
||||
];
|
||||
`;
|
||||
|
||||
fs.writeFileSync(guardrailsOutput, ts, 'utf8');
|
||||
console.log(`guardrails.generated.ts: ${flatRegistryPrefixes.length} registry, ${flatCommands.length} commands, ${guardrails.filePrefixes.length} file prefixes`);
|
||||
console.log(`guardrails.generated.ts: ${flatRegistryPrefixes.length} registry, ${flatCommands.length} commands, ${guardrails.filePrefixes.length} file prefixes, ${flatEventLogChannels.length} event log channels`);
|
||||
} else {
|
||||
console.warn('guardrails.json not found — skipping guardrails generation');
|
||||
}
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
private const string CurrentUrl = "https://autopilotmonitor-api-eu.azurewebsites.net";
|
||||
private const string TargetUrl = "https://autopilotmonitor-api-us.azurewebsites.net";
|
||||
|
||||
private static AgentConfigResponse Config(string migrateUrl) =>
|
||||
private static AgentConfigResponse Config(string? migrateUrl) =>
|
||||
new AgentConfigResponse { MigrateToApiBaseUrl = migrateUrl };
|
||||
|
||||
[Fact]
|
||||
|
||||
+1
@@ -70,6 +70,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
// Read the file the production code actually wrote and deserialize with the same lib.
|
||||
var raw = File.ReadAllText(_cachePath);
|
||||
var persisted = JsonConvert.DeserializeObject<AgentConfigResponse>(raw);
|
||||
Assert.NotNull(persisted);
|
||||
|
||||
Assert.False(persisted.UnrestrictedMode);
|
||||
Assert.False(persisted.AllowAgentDowngrade);
|
||||
|
||||
+3
-3
@@ -193,7 +193,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
case BackendBehaviour.AlwaysAuthFailure401:
|
||||
throw new BackendAuthException("Unauthorized", 401);
|
||||
case BackendBehaviour.AlwaysReturnNull:
|
||||
return Task.FromResult<AgentConfigResponse>(null);
|
||||
return Task.FromResult<AgentConfigResponse>(null!);
|
||||
default:
|
||||
throw new InvalidOperationException("unknown behaviour");
|
||||
}
|
||||
@@ -204,9 +204,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
|
||||
// In-memory + per-instance cache so a prior test's successful fetch can't
|
||||
// bleed into a later test that expects UsedDefaults.
|
||||
private AgentConfigResponse _cached;
|
||||
private AgentConfigResponse? _cached;
|
||||
protected override void CacheConfig(AgentConfigResponse config) { _cached = config; }
|
||||
protected override AgentConfigResponse LoadCachedConfig() => _cached;
|
||||
protected override AgentConfigResponse LoadCachedConfig() => _cached!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
||||
logger: logger,
|
||||
helloTracker: null);
|
||||
|
||||
EspExitedEventArgs captured = null;
|
||||
EspExitedEventArgs? captured = null;
|
||||
tracker.EspExited += (_, args) => captured = args;
|
||||
|
||||
// Source time IS in the past relative to the clock — verify the tracker carries the
|
||||
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Allowlist enforcement for the two collectors that used to bypass it:
|
||||
/// <see cref="LogParserCollector"/> read any path the rule named — including
|
||||
/// C:\Users, whose hard block every other file collector honours — and
|
||||
/// <see cref="EventLogCollector"/> read any channel, including Security.
|
||||
/// A tenant admin can author both through the rules API, so the agent is the
|
||||
/// only place this can be enforced.
|
||||
/// </summary>
|
||||
public sealed class GatherRuleCollectorGuardTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
|
||||
// %TEMP% itself lives under C:\Users, which is hard-blocked — the positive
|
||||
// cases need a directory the guard can actually admit, so they run beside
|
||||
// the test assembly instead.
|
||||
private readonly string _outsideUsers = Path.Combine(
|
||||
AppContext.BaseDirectory, "guard-tests-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public GatherRuleCollectorGuardTests() => Directory.CreateDirectory(_outsideUsers);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tmp.Dispose();
|
||||
try { Directory.Delete(_outsideUsers, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private GatherRuleContext Context(bool unrestricted = false, string? imeLogPathOverride = null)
|
||||
=> new GatherRuleContext(
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
"sess", "tenant",
|
||||
evt => _events.Add(evt),
|
||||
imeLogPathOverride,
|
||||
new LogFilePositionTracker())
|
||||
{
|
||||
UnrestrictedMode = unrestricted
|
||||
};
|
||||
|
||||
private static GatherRule Rule(string collectorType, string target,
|
||||
Dictionary<string, string>? parameters = null) => new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-GUARD-TEST",
|
||||
Title = "guard test",
|
||||
CollectorType = collectorType,
|
||||
Target = target,
|
||||
// Match the model default (empty, never null) so the collectors see the
|
||||
// same shape a deserialized rule without parameters gives them.
|
||||
Parameters = parameters ?? new Dictionary<string, string>(),
|
||||
Trigger = "startup",
|
||||
OutputEventType = "guard_test",
|
||||
};
|
||||
|
||||
private EnrollmentEvent SingleSecurityWarning()
|
||||
=> Assert.Single(_events, e => e.EventType == Constants.EventTypes.SecurityWarning);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Event log channel allowlist
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("System")]
|
||||
[InlineData("Application")]
|
||||
[InlineData("Setup")]
|
||||
[InlineData("Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin")]
|
||||
[InlineData("Microsoft-Windows-AAD/Operational")]
|
||||
[InlineData("Microsoft-Windows-Shell-Core/Operational")]
|
||||
public void EventLogChannel_OnAllowlist_IsAllowed(string channel)
|
||||
=> Assert.True(GatherRuleGuards.IsEventLogChannelAllowed(channel));
|
||||
|
||||
[Theory]
|
||||
[InlineData("Security")]
|
||||
[InlineData("Microsoft-Windows-PowerShell/Operational")]
|
||||
[InlineData("Windows PowerShell")]
|
||||
[InlineData("Microsoft-Windows-Sysmon/Operational")]
|
||||
public void EventLogChannel_HardBlocked_StaysBlockedEvenUnrestricted(string channel)
|
||||
{
|
||||
Assert.False(GatherRuleGuards.IsEventLogChannelAllowed(channel));
|
||||
Assert.False(GatherRuleGuards.IsEventLogChannelAllowed(channel, unrestrictedMode: true));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Microsoft-Windows-AADSomethingElse/Operational")] // prefix spoofing
|
||||
[InlineData("Microsoft-Windows-TerminalServices-LocalSessionManager/Operational")]
|
||||
[InlineData("")]
|
||||
public void EventLogChannel_OffAllowlist_IsBlocked(string channel)
|
||||
=> Assert.False(GatherRuleGuards.IsEventLogChannelAllowed(channel));
|
||||
|
||||
[Fact]
|
||||
public void EventLogChannel_Null_IsBlocked()
|
||||
=> Assert.False(GatherRuleGuards.IsEventLogChannelAllowed(null));
|
||||
|
||||
[Fact]
|
||||
public void EventLogChannel_OffAllowlist_IsAllowedInUnrestrictedMode()
|
||||
=> Assert.True(GatherRuleGuards.IsEventLogChannelAllowed(
|
||||
"Microsoft-Windows-TerminalServices-LocalSessionManager/Operational",
|
||||
unrestrictedMode: true));
|
||||
|
||||
[Fact]
|
||||
public void EventLogCollector_SecurityChannel_EmitsSecurityWarningAndReturnsNoData()
|
||||
{
|
||||
var result = new EventLogCollector().Execute(Rule("eventlog", "Security"), Context());
|
||||
|
||||
Assert.Empty(result);
|
||||
var warning = SingleSecurityWarning();
|
||||
Assert.Equal(true, warning.Data["blocked"]);
|
||||
Assert.Equal("Security", warning.Data["target"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventLogCollector_SecurityChannel_StaysBlockedInUnrestrictedMode()
|
||||
{
|
||||
var result = new EventLogCollector().Execute(
|
||||
Rule("eventlog", "Security"), Context(unrestricted: true));
|
||||
|
||||
Assert.Empty(result);
|
||||
SingleSecurityWarning();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Log parser file allowlist
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
private static Dictionary<string, string> TextPattern() => new Dictionary<string, string>
|
||||
{
|
||||
["pattern"] = "(?<line>.+)",
|
||||
["format"] = "text",
|
||||
["trackPosition"] = "false",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void LogParser_UserProfilePath_IsBlockedAndEmitsSecurityWarning()
|
||||
{
|
||||
const string target = @"C:\Users\Public\Documents\secret.txt";
|
||||
|
||||
var result = new LogParserCollector().Execute(
|
||||
Rule("logparser", target, TextPattern()), Context());
|
||||
|
||||
Assert.Null(result);
|
||||
var warning = SingleSecurityWarning();
|
||||
Assert.Equal(target, warning.Data["target"]);
|
||||
Assert.DoesNotContain(_events, e => e.EventType == "guard_test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogParser_UserProfilePath_StaysBlockedInUnrestrictedMode()
|
||||
{
|
||||
// C:\Users is a hard block — unrestricted mode must not lift it.
|
||||
new LogParserCollector().Execute(
|
||||
Rule("logparser", @"C:\Users\Public\Documents\secret.txt", TextPattern()),
|
||||
Context(unrestricted: true));
|
||||
|
||||
SingleSecurityWarning();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogParser_OffAllowlistPath_IsBlockedEvenWhenFileExists()
|
||||
{
|
||||
var path = _tmp.File("evidence.log");
|
||||
File.WriteAllText(path, "line one\nline two\n");
|
||||
|
||||
var result = new LogParserCollector().Execute(
|
||||
Rule("logparser", path, TextPattern()), Context());
|
||||
|
||||
Assert.Null(result);
|
||||
SingleSecurityWarning();
|
||||
Assert.DoesNotContain(_events, e => e.EventType == "guard_test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogParser_WildcardTarget_IsBlockedByItsDirectory()
|
||||
{
|
||||
// Path.GetFullPath rejects '*', so the guard has to check the directory.
|
||||
File.WriteAllText(_tmp.File("evidence.log"), "line one\n");
|
||||
|
||||
new LogParserCollector().Execute(
|
||||
Rule("logparser", Path.Combine(_tmp.Path, "*.log"), TextPattern()), Context());
|
||||
|
||||
SingleSecurityWarning();
|
||||
Assert.DoesNotContain(_events, e => e.EventType == "guard_test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogParser_UnrestrictedMode_ReadsPathOffTheAllowlist()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "evidence.log");
|
||||
File.WriteAllText(path, "line one\nline two\n");
|
||||
|
||||
new LogParserCollector().Execute(
|
||||
Rule("logparser", path, TextPattern()), Context(unrestricted: true));
|
||||
|
||||
Assert.DoesNotContain(_events, e => e.EventType == Constants.EventTypes.SecurityWarning);
|
||||
Assert.Equal(2, _events.Count(e => e.EventType == "guard_test"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogParser_LocalImeLogPathOverride_RelaxesTheAllowlist()
|
||||
{
|
||||
// --ime-log-path is a local CLI flag, never remote config: the operator
|
||||
// using it is already a local admin, so it relaxes the allowlist the way
|
||||
// unrestricted mode does.
|
||||
File.WriteAllText(Path.Combine(_outsideUsers, "AppWorkload.log"), "line one\n");
|
||||
|
||||
new LogParserCollector().Execute(
|
||||
Rule("logparser", @"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\AppWorkload.log",
|
||||
TextPattern()),
|
||||
Context(imeLogPathOverride: _outsideUsers));
|
||||
|
||||
Assert.DoesNotContain(_events, e => e.EventType == Constants.EventTypes.SecurityWarning);
|
||||
Assert.Single(_events, e => e.EventType == "guard_test");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Command execution
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Command_OffAllowlist_IsBlockedAndNeverRuns()
|
||||
{
|
||||
var result = new CommandCollector().Execute(
|
||||
Rule("command_allowlisted", "Get-Content C:\\Windows\\System32\\config\\SAM"), Context());
|
||||
|
||||
Assert.Equal(true, result["blocked"]);
|
||||
Assert.False(result.ContainsKey("output"));
|
||||
SingleSecurityWarning();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Command_HardBlockedPattern_StaysBlockedInUnrestrictedMode()
|
||||
{
|
||||
var result = new CommandCollector().Execute(
|
||||
Rule("command_allowlisted", "Invoke-WebRequest https://example.invalid/x"),
|
||||
Context(unrestricted: true));
|
||||
|
||||
Assert.Equal(true, result["blocked"]);
|
||||
SingleSecurityWarning();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Command_ThatHangs_IsKilledAtTheTimeoutRatherThanPinningTheWorker()
|
||||
{
|
||||
// Regression: reading stdout synchronously blocked until the pipe closed,
|
||||
// so WaitForExit's timeout was never reached and a hung command pinned the
|
||||
// worker indefinitely. Unrestricted mode is needed to run an ad-hoc command.
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var result = new CommandCollector().Execute(
|
||||
Rule("command_allowlisted", "Start-Sleep -Seconds 120"), Context(unrestricted: true));
|
||||
sw.Stop();
|
||||
|
||||
Assert.Equal(true, result["timed_out"]);
|
||||
Assert.Equal(-1, result["exit_code"]);
|
||||
// The 30s timeout plus drain margin — nowhere near the command's 120s.
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(60),
|
||||
$"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()
|
||||
{
|
||||
new LogParserCollector().Execute(
|
||||
Rule("logparser", @"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\AppWorkload.log",
|
||||
TextPattern()),
|
||||
Context(imeLogPathOverride: @"C:\Users\Public\Documents"));
|
||||
|
||||
SingleSecurityWarning();
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -192,7 +192,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
GatherRuleExecutor.ComputeCanonicalHash(new Dictionary<string, object> { ["v"] = "1" }),
|
||||
GatherRuleExecutor.ComputeCanonicalHash(new Dictionary<string, object> { ["v"] = 1 }));
|
||||
Assert.NotEqual(
|
||||
GatherRuleExecutor.ComputeCanonicalHash(new Dictionary<string, object> { ["v"] = null }),
|
||||
GatherRuleExecutor.ComputeCanonicalHash(new Dictionary<string, object> { ["v"] = null! }),
|
||||
GatherRuleExecutor.ComputeCanonicalHash(new Dictionary<string, object> { ["v"] = "<null>" }));
|
||||
}
|
||||
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// The <c>phase_exit</c> trigger: a one-shot collection when a phase is LEFT — the closing
|
||||
/// bookend to <c>phase_change</c> (which fires on ENTER). The distinguishing invariant is that
|
||||
/// both the TriggerPhase match and the phase-scope gate must evaluate against the phase being
|
||||
/// left, i.e. before the executor's current-phase state moves forward.
|
||||
/// Rules use the absent-registry-path pattern so no real system state is touched.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorPhaseExitTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly object _eventsGate = new object();
|
||||
private readonly GatherRuleExecutor _executor;
|
||||
|
||||
private const string AbsentPath =
|
||||
"HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_9c4d2a7f-0000-0000-0000-000000000000";
|
||||
|
||||
public GatherRuleExecutorPhaseExitTests()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_executor = new GatherRuleExecutor(
|
||||
"sess", "tenant",
|
||||
evt => { lock (_eventsGate) _events.Add(evt); },
|
||||
logger)
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_executor.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private int EventCount { get { lock (_eventsGate) return _events.Count; } }
|
||||
|
||||
private bool WaitForEventCount(int count, int timeoutMs = 10000)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
while (sw.ElapsedMilliseconds < timeoutMs)
|
||||
{
|
||||
if (EventCount >= count) return true;
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
return EventCount >= count;
|
||||
}
|
||||
|
||||
private static GatherRule ExitRule(
|
||||
string id, string triggerPhase, List<string>? activePhases = null, string? fromPhase = null)
|
||||
=> new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
Title = id,
|
||||
CollectorType = "registry",
|
||||
Target = AbsentPath,
|
||||
Trigger = "phase_exit",
|
||||
TriggerPhase = triggerPhase,
|
||||
OutputEventType = "gather_test",
|
||||
Enabled = true,
|
||||
ActivePhases = activePhases,
|
||||
ActiveFromPhase = fromPhase,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void FiresOnceWhenTheNamedPhaseIsLeft_NotWhenItIsEntered()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-001", "AccountSetup") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
|
||||
// Entering AccountSetup must NOT fire an exit rule.
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
|
||||
// Leaving it does.
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
|
||||
Assert.True(WaitForEventCount(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoesNotRefireWhenThePhaseIsLeftTwice()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-002", "AccountSetup") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
|
||||
Assert.True(WaitForEventCount(1));
|
||||
|
||||
// A phase can be re-entered (ESP re-reporting); the one-shot slot stays consumed.
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
|
||||
Thread.Sleep(400);
|
||||
Assert.Equal(1, EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyTriggerPhase_FiresOnEveryExit()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-003", "") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup); // exit of Unknown — must NOT fire
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // exit of DeviceSetup
|
||||
Assert.True(WaitForEventCount(1));
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser); // exit of AccountSetup
|
||||
Assert.True(WaitForEventCount(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeverFiresOnExitOfUnknown()
|
||||
{
|
||||
// Before the first phase signal there is no phase to leave.
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-004", "") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.Start);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedSamePhaseSignal_IsNotAnExit()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-005", "") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // same phase — no transition
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiresOnTransitionIntoFailed_CapturingStateAtTheFailurePoint()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-006", "AccountSetup") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.Failed);
|
||||
|
||||
Assert.True(WaitForEventCount(1), "leaving a phase into Failed must still snapshot it");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiresOnExitOfCompleteOnly_WhenCompleteIsActuallyLeft()
|
||||
{
|
||||
// "End of enrollment" is better served by phase_change on Complete / on_event; an exit
|
||||
// rule on Complete only fires if a later transition happens at all.
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-007", "Complete") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.FinalizingSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.Complete);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopeGate_EvaluatesAgainstThePhaseBeingLeft()
|
||||
{
|
||||
// Scope = only during DeviceSetup. Leaving DeviceSetup must fire (the rule is still in
|
||||
// scope at that instant) even though the NEW phase is outside the scope.
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
ExitRule("GATHER-PX-010", "DeviceSetup", activePhases: new List<string> { "DeviceSetup" })
|
||||
});
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
|
||||
Assert.True(WaitForEventCount(1),
|
||||
"the scope gate must see the phase being left, not the one being entered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutOfScopeExit_DoesNotFire_AndDoesNotConsumeTheOneShotSlot()
|
||||
{
|
||||
// Scope = only during AccountSetup; the rule exits DeviceSetup while out of scope.
|
||||
var rule = ExitRule("GATHER-PX-011", "", activePhases: new List<string> { "AccountSetup" });
|
||||
_executor.UpdateRules(new List<GatherRule> { rule });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // exit of DeviceSetup: out of scope
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser); // exit of AccountSetup: in scope
|
||||
Assert.True(WaitForEventCount(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitAndChangeRulesForTheSamePhase_BothFireIndependently()
|
||||
{
|
||||
// The dedup key spaces must not collide: same ruleId shape, same phase, different trigger.
|
||||
var enter = new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-PX-020",
|
||||
Title = "enter",
|
||||
CollectorType = "registry",
|
||||
Target = AbsentPath,
|
||||
Trigger = "phase_change",
|
||||
TriggerPhase = "AccountSetup",
|
||||
OutputEventType = "gather_test",
|
||||
Enabled = true,
|
||||
};
|
||||
var exit = ExitRule("GATHER-PX-021", "AccountSetup");
|
||||
_executor.UpdateRules(new List<GatherRule> { enter, exit });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
Assert.True(WaitForEventCount(1)); // enter fired
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
|
||||
Assert.True(WaitForEventCount(2)); // exit fired
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitRuleWithEmitOnChange_StillEmitsItsFirstResult()
|
||||
{
|
||||
var rule = ExitRule("GATHER-PX-030", "AccountSetup");
|
||||
rule.EmitMode = "on_change";
|
||||
_executor.UpdateRules(new List<GatherRule> { rule });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
|
||||
|
||||
Assert.True(WaitForEventCount(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -63,7 +63,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
|
||||
/// <summary>Interval rule with a 1h period — the timer never ticks within a test run,
|
||||
/// so these rules exist purely for synchronous scope-matrix assertions.</summary>
|
||||
private static GatherRule ScopeProbeRule(string id, List<string> activePhases = null, string fromPhase = null)
|
||||
private static GatherRule ScopeProbeRule(string id, List<string>? activePhases = null, string? fromPhase = null)
|
||||
=> new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
@@ -78,7 +78,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
ActiveFromPhase = fromPhase,
|
||||
};
|
||||
|
||||
private static GatherRule StartupRule(string id, List<string> activePhases = null, string fromPhase = null)
|
||||
private static GatherRule StartupRule(string id, List<string>? activePhases = null, string? fromPhase = null)
|
||||
=> new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
[InlineData(" ")] // whitespace only
|
||||
[InlineData("<![LOG[truncated line with no closing")] // truncated / missing fields
|
||||
[InlineData("garbage prefix <![LOG[msg]LOG]!><time=\"06:08:04.8834397\" date=\"2-8-2026\" component=\"C\" context=\"\" type=\"1\" thread=\"1\" file=\"\">")] // leading junk (non-ignorable) fails the StartsWith gate
|
||||
public void TryParseLine_returns_false_without_throwing_for_malformed_input(string line)
|
||||
public void TryParseLine_returns_false_without_throwing_for_malformed_input(string? line)
|
||||
{
|
||||
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
|
||||
+3
-2
@@ -24,12 +24,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
/// </summary>
|
||||
public sealed class ImeLogPatternReDoSTests
|
||||
{
|
||||
private static ImeLogPattern Pattern(string id, string regex, string action = null) => new ImeLogPattern
|
||||
private static ImeLogPattern Pattern(string id, string regex, string? action = null) => new ImeLogPattern
|
||||
{
|
||||
PatternId = id,
|
||||
Pattern = regex,
|
||||
Category = "always",
|
||||
Action = action,
|
||||
// Action is optional on the wire (absent = null); the model declares it non-nullable.
|
||||
Action = action!,
|
||||
Enabled = true,
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
};
|
||||
|
||||
+1
@@ -17,6 +17,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
/// cadence and stops at its bound, is idempotent, the initial scan raises when binaries already
|
||||
/// exist, a filesystem create raises, and Dispose cancels the re-probe.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // the bounded re-probe runs on the shared ThreadPool
|
||||
public sealed class OfficeBinaryWatcherTests
|
||||
{
|
||||
private static readonly string[] CoreBinaries = { "WINWORD.EXE", "EXCEL.EXE", "POWERPNT.EXE", "OUTLOOK.EXE" };
|
||||
|
||||
+1
-1
@@ -256,7 +256,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("0x8007", false)]
|
||||
public void IsAppxFamilyHresult_MatchesOnlyAppxDeploymentRange(string hresult, bool expected)
|
||||
public void IsAppxFamilyHresult_MatchesOnlyAppxDeploymentRange(string? hresult, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, AppxFailureAnalyzer.IsAppxFamilyHresult(hresult));
|
||||
}
|
||||
|
||||
+9
-9
@@ -39,7 +39,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
public List<EspFailureDetectedEventArgs> EspFailures { get; } = new List<EspFailureDetectedEventArgs>();
|
||||
public ProvisioningStatusTracker Tracker { get; }
|
||||
|
||||
public Fixture(Func<IReadOnlyList<AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppPackageState>> packageStatesProbe = null)
|
||||
public Fixture(Func<IReadOnlyList<AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppPackageState>>? packageStatesProbe = null)
|
||||
{
|
||||
var clock = new VirtualClock(Fixed);
|
||||
var post = new InformationalEventPost(Sink, clock);
|
||||
@@ -179,7 +179,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
}");
|
||||
|
||||
var captured = f.CapturedEvents();
|
||||
Assert.Single(captured.Where(e => e.EventType == "esp_failure_settle_started"));
|
||||
Assert.Single(captured, e => e.EventType == "esp_failure_settle_started");
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -214,8 +214,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
// No terminal fire — the failure was retracted.
|
||||
Assert.Empty(f.EspFailures);
|
||||
|
||||
var recovered = Assert.Single(f.CapturedEvents()
|
||||
.Where(e => e.EventType == "esp_failure_settle_recovered"));
|
||||
var recovered = Assert.Single(
|
||||
f.CapturedEvents(), e => e.EventType == "esp_failure_settle_recovered");
|
||||
Assert.Equal("AccountSetup", (string)recovered.Data["category"]);
|
||||
Assert.Equal("Apps", (string)recovered.Data["failedSubcategory"]);
|
||||
Assert.Equal("inProgress", (string)recovered.Data["observedState"]);
|
||||
@@ -280,7 +280,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
f.Tracker.TriggerSettleTimerForTest("AccountSetupCategory.Status");
|
||||
|
||||
Assert.Single(f.EspFailures);
|
||||
Assert.Empty(f.CapturedEvents().Where(e => e.EventType == "esp_failure_settle_recovered"));
|
||||
Assert.DoesNotContain(f.CapturedEvents(), e => e.EventType == "esp_failure_settle_recovered");
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -326,8 +326,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
""AppsSubcategory"": {""subcategoryState"":""failed"",""subcategoryStatusText"":""Apps (Error)""}
|
||||
}");
|
||||
|
||||
var settleStarted = Assert.Single(f.CapturedEvents()
|
||||
.Where(e => e.EventType == "esp_failure_settle_started"));
|
||||
var settleStarted = Assert.Single(
|
||||
f.CapturedEvents(), e => e.EventType == "esp_failure_settle_started");
|
||||
Assert.Equal(1, (int)settleStarted.Data["trackedAppsNotCompletedCount"]);
|
||||
var apps = (List<Dictionary<string, object>>)settleStarted.Data["trackedAppsNotCompleted"];
|
||||
var app = Assert.Single(apps);
|
||||
@@ -354,8 +354,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
""SecurityPoliciesSubcategory"": {""subcategoryState"":""failed"",""subcategoryStatusText"":""Failed""}
|
||||
}");
|
||||
|
||||
var settleStarted = Assert.Single(f.CapturedEvents()
|
||||
.Where(e => e.EventType == "esp_failure_settle_started"));
|
||||
var settleStarted = Assert.Single(
|
||||
f.CapturedEvents(), e => e.EventType == "esp_failure_settle_started");
|
||||
Assert.False(settleStarted.Data.ContainsKey("trackedAppsNotCompleted"));
|
||||
Assert.False(settleStarted.Data.ContainsKey("trackedAppsNotCompletedCount"));
|
||||
}
|
||||
|
||||
+15
-6
@@ -44,7 +44,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
private readonly FakeQuarantineSink _quarantine = new FakeQuarantineSink();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private readonly DecisionEngine _engine = new DecisionEngine();
|
||||
private DecisionStepProcessor _sut;
|
||||
private DecisionStepProcessor? _sut;
|
||||
|
||||
public DecisionStepProcessorParkedTripwireTests()
|
||||
{
|
||||
@@ -107,7 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
return (step, signal);
|
||||
}
|
||||
|
||||
private FakeSignalIngressSink.PostedSignal FindTripwirePost()
|
||||
private FakeSignalIngressSink.PostedSignal? FindTripwirePost()
|
||||
{
|
||||
foreach (var posted in _sink.Posted)
|
||||
{
|
||||
@@ -121,6 +121,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>The tripwire post, asserted present — for the assertions that only run
|
||||
/// after <c>WaitForTripwire()</c> has already confirmed one was emitted.</summary>
|
||||
private FakeSignalIngressSink.PostedSignal RequireTripwirePost()
|
||||
{
|
||||
var post = FindTripwirePost();
|
||||
Assert.NotNull(post);
|
||||
return post!;
|
||||
}
|
||||
|
||||
private int CountTripwirePosts()
|
||||
{
|
||||
var count = 0;
|
||||
@@ -165,9 +174,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
Thread.Sleep(SuppressWait);
|
||||
Assert.Equal(1, CountTripwirePosts());
|
||||
|
||||
var post = FindTripwirePost();
|
||||
var post = RequireTripwirePost();
|
||||
Assert.Equal(DecisionSignalKind.InformationalEvent, post.Kind);
|
||||
Assert.Equal(nameof(SessionStage.EspAccountSetup), post.Payload["stage"]);
|
||||
Assert.Equal(nameof(SessionStage.EspAccountSetup), post.Payload!["stage"]);
|
||||
Assert.Equal("Warning", post.Payload[SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", post.Payload[SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Contains("esp_final_exit", post.Payload["signalsSeen"]);
|
||||
@@ -222,7 +231,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
sut.ApplyStep(step, signal);
|
||||
|
||||
Assert.True(WaitForTripwire());
|
||||
Assert.Equal(DeadlineNames.ClassifierTick, FindTripwirePost().Payload["armedDeadlines"]);
|
||||
Assert.Equal(DeadlineNames.ClassifierTick, RequireTripwirePost().Payload!["armedDeadlines"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -237,7 +246,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
sut.ApplyStep(step, signal);
|
||||
|
||||
Assert.True(WaitForTripwire());
|
||||
Assert.Equal(nameof(SessionStage.AwaitingDesktop), FindTripwirePost().Payload["stage"]);
|
||||
Assert.Equal(nameof(SessionStage.AwaitingDesktop), RequireTripwirePost().Payload!["stage"]);
|
||||
}
|
||||
|
||||
// ===================================================================== Dwell semantics
|
||||
|
||||
+2
-2
@@ -256,8 +256,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
RemoteConfigFetchOutcome outcome,
|
||||
int configVersion = 0,
|
||||
int attempts = 1,
|
||||
string failureType = null,
|
||||
string failureMessage = null,
|
||||
string? failureType = null,
|
||||
string? failureMessage = null,
|
||||
int? authStatusCode = null)
|
||||
{
|
||||
// CurrentConfig is set via the protected SetConfig path — easiest:
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
[InlineData("Network connections (1 of 1 added)", null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData(null, null)]
|
||||
public void TryExtractErrorCode_extracts_parenthesised_hex_tail(string statusText, string expected)
|
||||
public void TryExtractErrorCode_extracts_parenthesised_hex_tail(string? statusText, string? expected)
|
||||
{
|
||||
var actual = ProvisioningStatusTracker.TryExtractErrorCode(statusText);
|
||||
Assert.Equal(expected, actual);
|
||||
|
||||
+1
-1
@@ -195,7 +195,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
var all = f.InfoEvents(SharedEventTypes.ScriptCompleted);
|
||||
Assert.Equal(2, all.Count);
|
||||
Assert.Equal("65.00", all[1].Payload!["durationSeconds"]);
|
||||
Assert.Equal("cycle_including_reporting_latency", all[1].Payload["durationBasis"]);
|
||||
Assert.Equal("cycle_including_reporting_latency", all[1].Payload!["durationBasis"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+12
-12
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
|
||||
using Xunit;
|
||||
@@ -19,7 +19,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
private static List<BandwidthJobSample> Jobs(params BandwidthJobSample[] jobs)
|
||||
=> new List<BandwidthJobSample>(jobs);
|
||||
|
||||
private static BandwidthJobSample Job(string fileId, long wan, long lan = 0)
|
||||
private static BandwidthJobSample Job(string? fileId, long wan, long lan = 0)
|
||||
=> new BandwidthJobSample { FileId = fileId, WanBytes = wan, LanBytes = lan };
|
||||
|
||||
[Fact]
|
||||
@@ -43,8 +43,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
var estimate = estimator.TryBuildEstimate();
|
||||
Assert.NotNull(estimate);
|
||||
Assert.Equal(1, estimate.WanSampleCount);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsMax.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90!.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsMax!.Value, 3);
|
||||
Assert.Equal(6_000_000, estimate.WanBytesObserved);
|
||||
Assert.Equal("10-50", estimate.Bucket);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
estimator.AddSnapshot(T0.AddSeconds(3), Jobs(Job("a", 3_000_000), Job("b", 3_000_000)));
|
||||
|
||||
var estimate = estimator.TryBuildEstimate();
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90!.Value, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -74,7 +74,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
|
||||
var estimate = estimator.TryBuildEstimate();
|
||||
Assert.Equal(2, estimate.WanSampleCount);
|
||||
Assert.Equal(16.0, estimate.WanMbpsMax.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsMax!.Value, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -91,7 +91,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
estimator.AddSnapshot(T0.AddSeconds(63), Jobs(Job("a", 106_000_000)));
|
||||
var estimate = estimator.TryBuildEstimate();
|
||||
Assert.Equal(1, estimate.WanSampleCount);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90!.Value, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -130,8 +130,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
estimator.AddSnapshot(T0.AddSeconds(3), Jobs(Job("a", wan: 6_000_000, lan: 375_000_000)));
|
||||
|
||||
var estimate = estimator.TryBuildEstimate();
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3);
|
||||
Assert.Equal(1000.0, estimate.LanMbpsP90.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90!.Value, 3);
|
||||
Assert.Equal(1000.0, estimate.LanMbpsP90!.Value, 3);
|
||||
Assert.Equal("10-50", estimate.Bucket); // bucket keyed to WAN, not LAN
|
||||
}
|
||||
|
||||
@@ -182,8 +182,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
|
||||
var estimate = estimator.TryBuildEstimate();
|
||||
Assert.Equal(20, estimate.WanSampleCount);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3); // p90 stays on the steady plateau
|
||||
Assert.Equal(160.0, estimate.WanMbpsMax.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90!.Value, 3); // p90 stays on the steady plateau
|
||||
Assert.Equal(160.0, estimate.WanMbpsMax!.Value, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -265,7 +265,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
|
||||
var estimate = estimator.TryBuildEstimate();
|
||||
Assert.Equal(1, estimate.WanSampleCount); // only the finite positive sample survived
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90!.Value, 3);
|
||||
Assert.Equal(0, estimate.WanBytesObserved);
|
||||
}
|
||||
|
||||
|
||||
@@ -370,7 +370,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
try
|
||||
{
|
||||
var versionUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentVersionFileNameForLine(2)}";
|
||||
// Download alias, not the legacy blob account: the legacy mirror in the
|
||||
// release pipeline is fail-soft, so it can silently lag behind eu.
|
||||
var versionUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentVersionFileNameForLine(2)}";
|
||||
|
||||
using (var handler = new HttpClientHandler())
|
||||
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(VersionCheckTimeoutMs) })
|
||||
@@ -497,7 +499,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
try
|
||||
{
|
||||
var zipUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentZipFileNameForLine(2)}";
|
||||
var zipUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentZipFileNameForLine(2)}";
|
||||
|
||||
// Clean up any previous download (fragment from aborted run, or symlink/junction).
|
||||
// Pre-delete + FileMode.CreateNew below: CreateNew refuses to open an existing
|
||||
|
||||
+39
-4
@@ -12,6 +12,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
public string CollectorType => "command_allowlisted";
|
||||
|
||||
private const int CommandTimeoutMs = 30000;
|
||||
|
||||
/// <summary>
|
||||
/// Returns what the reader captured, or an empty string if it did not finish
|
||||
/// (a killed process can leave the read faulted or still pending).
|
||||
/// </summary>
|
||||
private static string DrainOrEmpty(System.Threading.Tasks.Task<string> readTask)
|
||||
{
|
||||
try
|
||||
{
|
||||
return readTask.Wait(2000) ? readTask.Result : string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
@@ -81,12 +99,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit(30000); // 30 second timeout
|
||||
// Read asynchronously: a synchronous ReadToEnd() blocks until the pipe
|
||||
// closes, which only happens when the process exits — the WaitForExit
|
||||
// timeout below would never be reached and a hung command would pin
|
||||
// this worker forever.
|
||||
var outputTask = process.StandardOutput.ReadToEndAsync();
|
||||
var errorTask = process.StandardError.ReadToEndAsync();
|
||||
|
||||
var exited = process.WaitForExit(CommandTimeoutMs);
|
||||
if (!exited)
|
||||
{
|
||||
context.Logger.Warning(
|
||||
$"Command timed out after {CommandTimeoutMs / 1000}s, killing: {command} (Rule: {rule.RuleId})");
|
||||
try { process.Kill(); } catch { /* already gone */ }
|
||||
// Give the readers a moment to drain once the pipes close.
|
||||
try { process.WaitForExit(5000); } catch { /* best effort */ }
|
||||
data["timed_out"] = true;
|
||||
}
|
||||
|
||||
data["command"] = command;
|
||||
data["exit_code"] = process.ExitCode;
|
||||
data["exit_code"] = exited ? process.ExitCode : -1;
|
||||
|
||||
var output = DrainOrEmpty(outputTask);
|
||||
var error = DrainOrEmpty(errorTask);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
|
||||
+4
@@ -17,6 +17,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
if (string.IsNullOrEmpty(logName))
|
||||
return data;
|
||||
|
||||
// Guard: only allow enrollment-relevant channels
|
||||
if (!GatherRuleGuards.IsEventLogChannelAllowed(logName, context.UnrestrictedMode))
|
||||
return context.EmitSecurityWarning(rule, "eventlog", logName);
|
||||
|
||||
// Optional: escalate severity when matching entries are found
|
||||
string severityIfFound = null;
|
||||
rule.Parameters?.TryGetValue("severityIfFound", out severityIfFound);
|
||||
|
||||
+33
-2
@@ -34,11 +34,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
if (filePath == null)
|
||||
return null; // Token present but no user logged on — skip silently
|
||||
|
||||
// Apply IME log path override if set
|
||||
// Apply IME log path override if set. The override comes from the local
|
||||
// --ime-log-path flag only (never from remote config), so the operator
|
||||
// running it is already a local admin — it relaxes the allowlist the same
|
||||
// way unrestricted mode does, while the hard blocks below still apply.
|
||||
var localOverride = false;
|
||||
if (!string.IsNullOrEmpty(context.ImeLogPathOverride))
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
filePath = Path.Combine(context.ImeLogPathOverride, fileName);
|
||||
localOverride = true;
|
||||
}
|
||||
|
||||
var relaxAllowlist = context.UnrestrictedMode || localOverride;
|
||||
|
||||
// Guard: only allow enrollment-relevant file paths. Checked before resolution
|
||||
// so a blocked target is reported even when no file matches it. A wildcard
|
||||
// target is checked by its directory — Path.GetFullPath rejects '*' and '?'.
|
||||
var guardPath = HasWildcard(Path.GetFileName(filePath))
|
||||
? Path.GetDirectoryName(filePath)
|
||||
: filePath;
|
||||
if (!GatherRuleGuards.IsFilePathAllowed(guardPath, relaxAllowlist, userProfilePath))
|
||||
{
|
||||
context.EmitSecurityWarning(rule, "logparser", filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get parameters
|
||||
@@ -87,18 +106,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
foreach (var resolvedPath in resolvedPaths)
|
||||
{
|
||||
// Re-check each resolved path: wildcard expansion can surface files
|
||||
// reached through a junction that the directory check did not cover.
|
||||
if (!GatherRuleGuards.IsFilePathAllowed(resolvedPath, relaxAllowlist, userProfilePath))
|
||||
{
|
||||
context.EmitSecurityWarning(rule, "logparser", resolvedPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
ProcessLogFile(resolvedPath, rule, pattern, trackPosition, maxLines, isTextMode, context);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasWildcard(string fileNamePart)
|
||||
=> !string.IsNullOrEmpty(fileNamePart) &&
|
||||
(fileNamePart.Contains("*") || fileNamePart.Contains("?"));
|
||||
|
||||
private static List<string> ResolveLogPaths(string filePath, string ruleId, GatherRuleContext context)
|
||||
{
|
||||
var fileNamePart = Path.GetFileName(filePath);
|
||||
|
||||
// No wildcards — single file
|
||||
if (!fileNamePart.Contains("*") && !fileNamePart.Contains("?"))
|
||||
if (!HasWildcard(fileNamePart))
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
return new List<string> { filePath };
|
||||
|
||||
+48
-1
@@ -176,12 +176,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
/// <summary>
|
||||
/// Called when a phase change event occurs - executes rules triggered by phase changes
|
||||
/// (<c>phase_change</c> on entering the new phase, <c>phase_exit</c> on leaving the old one).
|
||||
/// </summary>
|
||||
public void OnPhaseChanged(EnrollmentPhase newPhase)
|
||||
{
|
||||
var phaseName = newPhase.ToString();
|
||||
|
||||
// Update the phase + from-phase latches FIRST so deferred startup activation and
|
||||
// phase_exit runs FIRST and against the phase being LEFT: both the TriggerPhase match
|
||||
// and the scope gate must see the old phase, so this block has to complete before
|
||||
// _currentPhase moves below. A transition INTO Failed still fires the exit rules of the
|
||||
// phase that failed — that snapshot is the whole point at a failure boundary.
|
||||
List<GatherRule> exitRules = null;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
var previousPhase = _currentPhase;
|
||||
if (previousPhase != EnrollmentPhase.Unknown && previousPhase != newPhase)
|
||||
{
|
||||
var previousPhaseName = previousPhase.ToString();
|
||||
exitRules = new List<GatherRule>();
|
||||
|
||||
foreach (var rule in _activeRules)
|
||||
{
|
||||
if (rule.Trigger != "phase_exit") continue;
|
||||
if (!string.IsNullOrEmpty(rule.TriggerPhase)
|
||||
&& !string.Equals(rule.TriggerPhase, previousPhaseName, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
// Scope gate before the dedup add — an out-of-scope pass must not consume
|
||||
// the once-per-(rule, phase) slot. IsRuleInScopeLocked still sees the old phase.
|
||||
if (!IsRuleInScopeLocked(rule)) continue;
|
||||
|
||||
// "exit:" prefix keeps this key space disjoint from the phase_change keys.
|
||||
if (!_phaseRulesExecuted.Add($"exit:{rule.RuleId}|{previousPhaseName}"))
|
||||
{
|
||||
_logger.Debug($"Phase-exit rule {rule.RuleId} already executed for exit of {previousPhaseName}, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Info($"Phase exit triggered rule {rule.RuleId} (left phase: {previousPhaseName}, now: {phaseName})");
|
||||
exitRules.Add(rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (exitRules != null)
|
||||
{
|
||||
foreach (var rule in exitRules)
|
||||
{
|
||||
var exitRule = rule;
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(exitRule));
|
||||
}
|
||||
}
|
||||
|
||||
// Update the phase + from-phase latches so deferred startup activation and
|
||||
// the phase_change rules below all evaluate against the NEW phase.
|
||||
List<GatherRule> deferredStartup;
|
||||
lock (_scopeLock)
|
||||
|
||||
+55
@@ -33,6 +33,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public static readonly IReadOnlyList<string> AllowedFilePrefixes;
|
||||
public static readonly IReadOnlyList<string> AllowedWmiQueryPrefixes;
|
||||
public static readonly IReadOnlyCollection<string> AllowedCommands;
|
||||
public static readonly IReadOnlyList<string> AllowedEventLogChannels;
|
||||
|
||||
// Hard blocks: never allowed, even in unrestricted mode
|
||||
private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
|
||||
@@ -51,6 +52,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// Hard limit: maximum command length (even in unrestricted mode)
|
||||
private const int MaxCommandLength = 2000;
|
||||
|
||||
// Event log channels that are never readable, even in unrestricted mode.
|
||||
// Security carries the audit trail of user behaviour; the PowerShell channels
|
||||
// carry script-block logging, which routinely contains secrets in clear text.
|
||||
// Mirrored in rules/guardrails.json ("blockedEventLogChannels") for the portal
|
||||
// to display — enforcement lives here so a parse error cannot lift it.
|
||||
private static readonly string[] HardBlockedEventLogChannels = new[]
|
||||
{
|
||||
"Security",
|
||||
"Microsoft-Windows-PowerShell",
|
||||
"Windows PowerShell",
|
||||
"Microsoft-Windows-Sysmon",
|
||||
};
|
||||
|
||||
// Hard-blocked command patterns: never allowed, even in unrestricted mode.
|
||||
// These prevent privilege escalation, persistence, and data exfiltration.
|
||||
private static readonly string[] HardBlockedCommandPatterns = new[]
|
||||
@@ -85,6 +99,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
var commands = FlattenCategorized(obj, "allowedCommands", "commands");
|
||||
AllowedCommands = new HashSet<string>(commands, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
AllowedEventLogChannels = FlattenCategorized(obj, "eventLogChannels", "channels");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -98,6 +114,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
AllowedFilePrefixes = new List<string>();
|
||||
AllowedWmiQueryPrefixes = new List<string>();
|
||||
AllowedCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
AllowedEventLogChannels = new List<string>();
|
||||
}
|
||||
|
||||
private static string LoadEmbeddedGuardrails()
|
||||
@@ -257,6 +274,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
(trimmed.Length == prefix.Length || char.IsWhiteSpace(trimmed[prefix.Length])));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the event log channel matches an allowed entry with
|
||||
/// boundary matching (next char must be '/' or end of string), so that
|
||||
/// "Microsoft-Windows-AAD" admits "Microsoft-Windows-AAD/Operational"
|
||||
/// but not "Microsoft-Windows-AADSomethingElse".
|
||||
/// </summary>
|
||||
public static bool IsEventLogChannelAllowed(string channel)
|
||||
=> IsEventLogChannelAllowed(channel, unrestrictedMode: false);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the event log channel is allowed.
|
||||
/// When unrestrictedMode is true, all channels are allowed except the
|
||||
/// hard-blocked ones (audit trail and script-block logging).
|
||||
/// </summary>
|
||||
public static bool IsEventLogChannelAllowed(string channel, bool unrestrictedMode)
|
||||
{
|
||||
if (string.IsNullOrEmpty(channel))
|
||||
return false;
|
||||
|
||||
var trimmed = channel.Trim();
|
||||
|
||||
// Hard blocks apply even in unrestricted mode
|
||||
foreach (var blocked in HardBlockedEventLogChannels)
|
||||
{
|
||||
if (MatchesChannel(trimmed, blocked))
|
||||
return false;
|
||||
}
|
||||
|
||||
if (unrestrictedMode)
|
||||
return true;
|
||||
|
||||
return AllowedEventLogChannels.Any(allowed => MatchesChannel(trimmed, allowed));
|
||||
}
|
||||
|
||||
private static bool MatchesChannel(string channel, string prefix)
|
||||
=> channel.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
(channel.Length == prefix.Length || channel[prefix.Length] == '/');
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the command (trimmed) exactly matches an entry in the allowlist.
|
||||
/// Exact matching is intentional — no prefix spoofing possible.
|
||||
|
||||
+2
-2
@@ -45,10 +45,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
/// </summary>
|
||||
private static readonly (string Name, string Url)[] MdmEndpoints =
|
||||
{
|
||||
("Entra ID", "https://login.microsoftonline.com"),
|
||||
("Entra ID", Constants.EntraLoginBaseUrl),
|
||||
("Device Registration", "https://enterpriseregistration.windows.net"),
|
||||
("Intune Portal", "https://portal.manage.microsoft.com"),
|
||||
("Microsoft Graph", "https://graph.microsoft.com"),
|
||||
("Microsoft Graph", Constants.GraphBaseUrl),
|
||||
};
|
||||
|
||||
public NetworkChangeDetector(
|
||||
|
||||
@@ -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,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces the URL registry: every well-known own or Microsoft host must be
|
||||
/// referenced through <c>AutopilotMonitor.Shared.Constants</c>, never as a
|
||||
/// repeated string literal. The EU cutover missed two hardcoded copies of the
|
||||
/// blob host (LatestVersionsService, ValidateBootstrapCodeFunction) precisely
|
||||
/// because they did not go through the registry — this test makes that class
|
||||
/// of drift a build failure instead of a production surprise.
|
||||
///
|
||||
/// Scope: non-test C# source under src/. Comment lines are ignored (docs and
|
||||
/// examples may cite URLs); test projects are excluded because tests use
|
||||
/// literals deliberately as independent oracles.
|
||||
/// </summary>
|
||||
public class HardcodedUrlGuardTests
|
||||
{
|
||||
private static readonly string[] EnforcedHosts =
|
||||
{
|
||||
"portal.autopilotmonitor.com",
|
||||
"www.autopilotmonitor.com",
|
||||
"docs.autopilotmonitor.com",
|
||||
"download.autopilotmonitor.com",
|
||||
"mcp.autopilotmonitor.com",
|
||||
"autopilotmonitor-api-eu.azurewebsites.net",
|
||||
"autopilotmonitor.blob.core.windows.net",
|
||||
"autopilotmonitoreu.blob.core.windows.net",
|
||||
"graph.microsoft.com",
|
||||
"login.microsoftonline.com",
|
||||
};
|
||||
|
||||
/// <summary>Files allowed to carry the literals — the registry itself.</summary>
|
||||
private static readonly string[] RegistryFiles =
|
||||
{
|
||||
Path.Combine("src", "Shared", "AutopilotMonitor.Shared", "Constants.cs"),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void WellKnownHosts_OnlyAppearInTheRegistry()
|
||||
{
|
||||
var repoRoot = FindRepoRoot();
|
||||
var srcDir = Path.Combine(repoRoot, "src");
|
||||
var violations = new List<string>();
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(srcDir, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(repoRoot, file);
|
||||
var normalized = relative.Replace('/', Path.DirectorySeparatorChar);
|
||||
|
||||
// bin/obj artifacts and generated files
|
||||
if (normalized.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") ||
|
||||
normalized.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}"))
|
||||
continue;
|
||||
|
||||
// Test projects: literals there are deliberate independent oracles.
|
||||
if (normalized.Contains("Tests"))
|
||||
continue;
|
||||
|
||||
if (RegistryFiles.Any(r => normalized.EndsWith(r, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
|
||||
var lines = File.ReadAllLines(file);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var trimmed = lines[i].TrimStart();
|
||||
// Comment approximation: full-line // and /// comments plus the
|
||||
// body lines of XML docs and block comments. A URL after code on
|
||||
// the same line still counts — that is the case being guarded.
|
||||
if (trimmed.StartsWith("//") || trimmed.StartsWith("*") || trimmed.StartsWith("/*"))
|
||||
continue;
|
||||
|
||||
foreach (var host in EnforcedHosts)
|
||||
{
|
||||
if (trimmed.Contains(host, StringComparison.OrdinalIgnoreCase))
|
||||
violations.Add($"{relative}:{i + 1}: {host}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0,
|
||||
"Hardcoded well-known host(s) found outside the URL registry " +
|
||||
"(use AutopilotMonitor.Shared.Constants.*BaseUrl instead):\n " +
|
||||
string.Join("\n ", violations));
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
+58
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
@@ -87,4 +88,61 @@ public class MaintenanceServiceExcessiveEventAutoActionTests
|
||||
// — better to no-op than block/kill on an unintended config.
|
||||
Assert.Null(MaintenanceService.DecideAutoAction(eventCount: 5000, autoMode: mode, autoThreshold: 2500));
|
||||
}
|
||||
|
||||
// ── Status eligibility ────────────────────────────────────────────────────
|
||||
// A device may only be blocked over a session that can still upload. Without this gate
|
||||
// a session that finished days ago with a high EventCount got its device blocked on the
|
||||
// next sweep, long after it stopped sending.
|
||||
|
||||
[Fact]
|
||||
public void IsAutoActionEligible_InProgress_IsEligible()
|
||||
{
|
||||
Assert.True(MaintenanceService.IsAutoActionEligible(SessionStatus.InProgress));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SessionStatus.Succeeded)]
|
||||
[InlineData(SessionStatus.Failed)]
|
||||
[InlineData(SessionStatus.Incomplete)]
|
||||
[InlineData(SessionStatus.Stalled)]
|
||||
[InlineData(SessionStatus.AwaitingUser)]
|
||||
[InlineData(SessionStatus.Pending)]
|
||||
[InlineData(SessionStatus.Unknown)]
|
||||
public void IsAutoActionEligible_EverythingElse_IsRejected(SessionStatus status)
|
||||
{
|
||||
// Matches the time-window watchdog's `Status eq 'InProgress'` filter, so neither
|
||||
// detector can block a device over a session that no longer sends. Stalled and
|
||||
// AwaitingUser can heal back to InProgress — then the next sweep acts.
|
||||
Assert.False(MaintenanceService.IsAutoActionEligible(status));
|
||||
}
|
||||
|
||||
// ── Status parsing feeding that gate ──────────────────────────────────────
|
||||
// The runaway query projects a subset of columns. SessionStatus.InProgress is ordinal 0,
|
||||
// so an unparsed Status would leave every row looking in-progress and the gate above
|
||||
// silently always-true — these pin the fail-closed mapping.
|
||||
|
||||
[Theory]
|
||||
[InlineData("InProgress", SessionStatus.InProgress)]
|
||||
[InlineData("inprogress", SessionStatus.InProgress)]
|
||||
[InlineData("Succeeded", SessionStatus.Succeeded)]
|
||||
[InlineData("AwaitingUser", SessionStatus.AwaitingUser)]
|
||||
public void ParseStatusForAutoAction_KnownNames_RoundTrip(string stored, SessionStatus expected)
|
||||
{
|
||||
Assert.Equal(expected, TableStorageService.ParseStatusForAutoAction(stored));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)] // column absent from the projection or the row
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("Enrolling")] // status name that never existed
|
||||
[InlineData("0")] // Enum.TryParse would map this onto InProgress and open the gate
|
||||
[InlineData("3")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("InProgress,Failed")] // flags-style combination is not a real status
|
||||
public void ParseStatusForAutoAction_UnusableInput_IsUnknown(string? stored)
|
||||
{
|
||||
Assert.Equal(SessionStatus.Unknown, TableStorageService.ParseStatusForAutoAction(stored));
|
||||
Assert.False(MaintenanceService.IsAutoActionEligible(TableStorageService.ParseStatusForAutoAction(stored)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,6 @@ public class OffboardingSessionEnumeratorTests
|
||||
public Task<List<SessionSummary>> GetLegacyTimeoutFailedSessionsAsync(string tenantId, int maxResults) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetSessionsLeanAsync(string tenantId) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours) => throw new NotImplementedException();
|
||||
public Task<List<string>> GetAllTenantIdsAsync() => throw new NotImplementedException();
|
||||
public Task<int> DeleteSessionEventsAsync(string tenantId, string sessionId) => throw new NotImplementedException();
|
||||
public Task<int> DeleteSessionRuleResultsAsync(string tenantId, string sessionId) => throw new NotImplementedException();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+49
-10
@@ -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)
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// ContactEmail is the address enforcement actions and service notices are sent to.
|
||||
/// The seed must never overwrite a value the tenant owns — that is the whole contract.
|
||||
/// </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()
|
||||
{
|
||||
var config = new TenantConfiguration();
|
||||
Assert.Null(config.ContactEmail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContactEmail_survives_a_round_trip_through_the_model()
|
||||
{
|
||||
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>
|
||||
|
||||
+11
-7
@@ -64,22 +64,26 @@ public class ValidateBootstrapCodeResponseShapeTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The hardcoded agent download URL in <c>ValidateBootstrapCodeFunction</c> must
|
||||
/// The agent download URL served by <c>ValidateBootstrapCodeFunction</c> must
|
||||
/// satisfy the frontend's host allow-list and path regex.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AgentDownloadUrl_MatchesFrontendAllowList()
|
||||
{
|
||||
// Keep in sync with ValidateBootstrapCodeFunction.cs — if that literal
|
||||
// changes, both this assertion and bootstrapValidation.ts must be updated.
|
||||
const string HardcodedAgentUrl =
|
||||
"https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip";
|
||||
// Independent oracle, deliberately spelled out: if Constants or the function
|
||||
// change the URL, this literal AND bootstrapValidation.ts (AGENT_DOWNLOAD_HOSTNAMES)
|
||||
// must be updated consciously — that pairing is what keeps portal and backend in sync.
|
||||
const string ExpectedAgentUrl =
|
||||
"https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip";
|
||||
|
||||
Assert.True(Uri.TryCreate(HardcodedAgentUrl, UriKind.Absolute, out var uri),
|
||||
var built = $"{AutopilotMonitor.Shared.Constants.AgentDownloadBaseUrl}/{AutopilotMonitor.Shared.Constants.AgentZipFileName}";
|
||||
Assert.Equal(ExpectedAgentUrl, built);
|
||||
|
||||
Assert.True(Uri.TryCreate(built, UriKind.Absolute, out var uri),
|
||||
"AgentDownloadUrl is not a well-formed absolute URI");
|
||||
|
||||
Assert.Equal(Uri.UriSchemeHttps, uri!.Scheme);
|
||||
Assert.Equal("autopilotmonitor.blob.core.windows.net", uri.Host);
|
||||
Assert.Equal("download.autopilotmonitor.com", uri.Host);
|
||||
Assert.True(AgentDownloadUrlPath.IsMatch(uri.AbsolutePath),
|
||||
$"AgentDownloadUrl path '{uri.AbsolutePath}' does not match the frontend allow-list regex");
|
||||
}
|
||||
|
||||
@@ -12,8 +12,16 @@
|
||||
BuildTimestampUtc is baked into the assembly as AssemblyMetadata so /api/health
|
||||
reports the actual build moment — not the file mtime, which Azure Functions
|
||||
resets to the zip-extract time on every cold start. CI may override via
|
||||
-p:BuildTimestampUtc=<iso8601> to force a specific timestamp. -->
|
||||
<Version>1.0.0</Version>
|
||||
-p:BuildTimestampUtc=<iso8601> to force a specific timestamp.
|
||||
|
||||
VersionPrefix is the curated part (bump by hand for a release); BuildNumber is
|
||||
reserved per build from the counter blob versions/backend.txt and passed in as
|
||||
-p:BuildNumber=<n> by deploy-backend.yml — same mechanism as the agent line.
|
||||
A local build has no counter and stays at x.y.0 with an empty commit hash,
|
||||
which is how /api/health tells a developer machine from a CI artifact. -->
|
||||
<VersionPrefix>1.5</VersionPrefix>
|
||||
<BuildNumber Condition="'$(BuildNumber)' == ''">0</BuildNumber>
|
||||
<Version>$(VersionPrefix).$(BuildNumber)</Version>
|
||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||
<IncludeSourceRevisionInInformationalVersion>true</IncludeSourceRevisionInInformationalVersion>
|
||||
<BuildTimestampUtc Condition="'$(BuildTimestampUtc)' == ''">$([System.DateTime]::UtcNow.ToString('o'))</BuildTimestampUtc>
|
||||
@@ -47,6 +55,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>
|
||||
|
||||
+50
-4
@@ -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
|
||||
@@ -336,6 +384,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "LastUpdated", config.LastUpdated },
|
||||
{ "UpdatedBy", config.UpdatedBy },
|
||||
{ "OnboardedBy", config.OnboardedBy },
|
||||
{ "ContactEmail", config.ContactEmail },
|
||||
{ "Disabled", config.Disabled },
|
||||
{ "DisabledReason", config.DisabledReason },
|
||||
{ "DisabledUntil", config.DisabledUntil },
|
||||
@@ -432,6 +481,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow,
|
||||
UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown",
|
||||
OnboardedBy = entity.GetString("OnboardedBy"),
|
||||
ContactEmail = entity.GetString("ContactEmail"),
|
||||
Disabled = entity.GetBoolean("Disabled") ?? false,
|
||||
DisabledReason = entity.GetString("DisabledReason"),
|
||||
DisabledUntil = entity.GetDateTime("DisabledUntil"),
|
||||
@@ -540,8 +590,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "ExcessiveEventAutoActionMode", config.ExcessiveEventAutoActionMode ?? "Off" },
|
||||
{ "ExcessiveEventAutoActionThreshold", config.ExcessiveEventAutoActionThreshold },
|
||||
{ "ExcessiveEventAutoActionDurationHours", config.ExcessiveEventAutoActionDurationHours },
|
||||
{ "MaxSessionWindowHours", config.MaxSessionWindowHours },
|
||||
{ "MaintenanceBlockDurationHours", config.MaintenanceBlockDurationHours },
|
||||
{ "DiagnosticsGlobalLogPathsJson", config.DiagnosticsGlobalLogPathsJson },
|
||||
{ "ModernDeploymentHarmlessEventIdsJson", config.ModernDeploymentHarmlessEventIdsJson ?? string.Empty },
|
||||
{ "WhiteGloveSealingPatternIdsJson", config.WhiteGloveSealingPatternIdsJson ?? string.Empty },
|
||||
@@ -607,8 +655,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
ExcessiveEventAutoActionMode = entity.GetString("ExcessiveEventAutoActionMode") ?? "Off",
|
||||
ExcessiveEventAutoActionThreshold = entity.GetInt32("ExcessiveEventAutoActionThreshold") ?? 2500,
|
||||
ExcessiveEventAutoActionDurationHours = entity.GetInt32("ExcessiveEventAutoActionDurationHours") ?? 24,
|
||||
MaxSessionWindowHours = entity.GetInt32("MaxSessionWindowHours") ?? 24,
|
||||
MaintenanceBlockDurationHours = entity.GetInt32("MaintenanceBlockDurationHours") ?? 12,
|
||||
DiagnosticsGlobalLogPathsJson = entity.GetString("DiagnosticsGlobalLogPathsJson"),
|
||||
ModernDeploymentHarmlessEventIdsJson = entity.GetString("ModernDeploymentHarmlessEventIdsJson"),
|
||||
WhiteGloveSealingPatternIdsJson = entity.GetString("WhiteGloveSealingPatternIdsJson"),
|
||||
|
||||
-3
@@ -69,9 +69,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff)
|
||||
=> _storage.GetAgentSilentSessionsAsync(tenantId, silenceCutoff, hardCutoff);
|
||||
|
||||
public Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours)
|
||||
=> _storage.GetExcessiveDataSendersAsync(tenantId, windowCutoff, maxSessionWindowHours);
|
||||
|
||||
public Task<List<SessionSummary>> GetLegacyTimeoutFailedSessionsAsync(string tenantId, int maxResults)
|
||||
=> _storage.GetLegacyTimeoutFailedSessionsAsync(tenantId, maxResults);
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
@@ -107,7 +108,7 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
}
|
||||
|
||||
var consentUrl =
|
||||
$"https://login.microsoftonline.com/{Uri.EscapeDataString(requestCtx.TargetTenantId)}/adminconsent" +
|
||||
$"{Constants.EntraLoginBaseUrl}/{Uri.EscapeDataString(requestCtx.TargetTenantId)}/adminconsent" +
|
||||
$"?client_id={Uri.EscapeDataString(validatorClientId)}" +
|
||||
$"&redirect_uri={Uri.EscapeDataString(redirectUri)}" +
|
||||
$"&state={Uri.EscapeDataString("autopilot-device-validation-enable")}";
|
||||
|
||||
+4
-1
@@ -4,6 +4,7 @@ using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -92,7 +93,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
{
|
||||
Success = true,
|
||||
ShortCode = session.ShortCode,
|
||||
BootstrapUrl = $"https://autopilotmonitor.com/go/{session.ShortCode}",
|
||||
// www directly — the bare apex is only a registrar-level 301 to www,
|
||||
// so pointing there just adds a redirect hop for every bootstrap.
|
||||
BootstrapUrl = $"{Constants.WebsiteBaseUrl}/go/{session.ShortCode}",
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
Message = $"Bootstrap session created. Valid for {validityHours} hours."
|
||||
};
|
||||
|
||||
+3
-1
@@ -92,7 +92,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
Success = true,
|
||||
TenantId = session.TenantId,
|
||||
Token = session.Token,
|
||||
AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip",
|
||||
// Download alias — the portal's bootstrapValidation.ts allowlists this
|
||||
// host (plus the legacy blob host for transition); keep the two in sync.
|
||||
AgentDownloadUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentZipFileName}",
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
Message = "Bootstrap code validated"
|
||||
};
|
||||
|
||||
+59
@@ -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>:
|
||||
|
||||
+30
-3
@@ -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))
|
||||
{
|
||||
|
||||
+32
-1
@@ -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);
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -351,7 +352,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
if (startChannels.Count == 0)
|
||||
return;
|
||||
|
||||
var sessionUrl = $"https://portal.autopilotmonitor.com/sessions/{sessionId}";
|
||||
var sessionUrl = $"{Constants.PortalBaseUrl}/sessions/{sessionId}";
|
||||
var alert = NotificationAlertBuilder.BuildEnrollmentStartedAlert(
|
||||
session.DeviceName,
|
||||
session.SerialNumber,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Azure.Functions.Worker.Middleware;
|
||||
@@ -6,11 +7,13 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
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 +60,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 +74,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 +102,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 +213,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
|
||||
@@ -244,7 +250,7 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
/// missing/garbage tid here prevents an outbound OIDC-metadata fetch to a bogus authority on
|
||||
/// malformed-token floods. Loss-free — every legitimate Entra token carries a GUID tid.
|
||||
/// </summary>
|
||||
internal static bool IsValidTenantId(string? tid) => Guid.TryParse(tid, out _);
|
||||
internal static bool IsValidTenantId([NotNullWhen(true)] string? tid) => Guid.TryParse(tid, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Maps a token issuer + tenant id to the OIDC authority to fetch signing keys from. Entra v1.0
|
||||
@@ -255,8 +261,8 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
var isV1Token = issuer != null && issuer.Contains("sts.windows.net");
|
||||
return isV1Token
|
||||
? $"https://login.microsoftonline.com/{tenantId}" // v1.0
|
||||
: $"https://login.microsoftonline.com/{tenantId}/v2.0"; // v2.0
|
||||
? $"{Constants.EntraLoginBaseUrl}/{tenantId}" // v1.0
|
||||
: $"{Constants.EntraLoginBaseUrl}/{tenantId}/v2.0"; // v2.0
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -279,7 +285,7 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
ValidateIssuer = true,
|
||||
IssuerValidator = (iss, token, parameters) =>
|
||||
{
|
||||
if (iss.StartsWith("https://login.microsoftonline.com/") ||
|
||||
if (iss.StartsWith(Constants.EntraLoginBaseUrl + "/") ||
|
||||
iss.StartsWith("https://sts.windows.net/"))
|
||||
{
|
||||
return iss;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -104,7 +104,8 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
options =>
|
||||
{
|
||||
// Multi-Tenant Configuration
|
||||
options.Instance = "https://login.microsoftonline.com/";
|
||||
// Fully qualified: Microsoft.Identity.Web also exports a 'Constants' type.
|
||||
options.Instance = AutopilotMonitor.Shared.Constants.EntraLoginBaseUrl + "/";
|
||||
options.TenantId = "organizations"; // Accept tokens from any Azure AD tenant
|
||||
options.ClientId = builder.Configuration["EntraId:ClientId"];
|
||||
|
||||
@@ -117,7 +118,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuers = new[]
|
||||
{
|
||||
"https://login.microsoftonline.com/organizations/v2.0",
|
||||
AutopilotMonitor.Shared.Constants.EntraLoginBaseUrl + "/organizations/v2.0",
|
||||
"https://sts.windows.net/{tenantid}/"
|
||||
},
|
||||
// Temporarily accept Microsoft Graph tokens (used by frontend with User.Read scope)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Headers;
|
||||
@@ -119,7 +120,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
// Use contains for server-side narrowing, then perform exact match client-side.
|
||||
var escapedSerial = normalizedSerial.Replace("'", "''");
|
||||
var filter = Uri.EscapeDataString($"contains(serialNumber,'{escapedSerial}')");
|
||||
var graphUrl = $"https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities?$top=100&$filter={filter}";
|
||||
var graphUrl = $"{Constants.GraphBaseUrl}/v1.0/deviceManagement/windowsAutopilotDeviceIdentities?$top=100&$filter={filter}";
|
||||
|
||||
var response = await graphClient.GetAsync(graphUrl);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
@@ -127,7 +127,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
|
||||
// Parse certificate from Base64
|
||||
var certBytes = Convert.FromBase64String(certificateBase64);
|
||||
var certificate = new X509Certificate2(certBytes);
|
||||
var certificate = X509CertificateLoader.LoadCertificate(certBytes);
|
||||
var thumbprint = certificate.Thumbprint;
|
||||
|
||||
logger?.LogDebug($"Validating certificate: Subject={certificate.Subject}, Issuer={certificate.Issuer}, Thumbprint={thumbprint}");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
@@ -132,7 +133,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
var graphUrl = "https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities/searchExistingIdentities";
|
||||
var graphUrl = Constants.GraphBaseUrl + "/beta/deviceManagement/importedDeviceIdentities/searchExistingIdentities";
|
||||
var response = await graphClient.PostAsync(graphUrl, content);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
@@ -118,7 +119,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
var escapedSerial = normalizedSerial.Replace("'", "''");
|
||||
var filter = Uri.EscapeDataString($"contains(serialNumber,'{escapedSerial}')");
|
||||
var orderby = Uri.EscapeDataString("preAssociationDateTime desc");
|
||||
var graphUrl = "https://graph.microsoft.com/beta/deviceManagement/tenantAssociatedDevices"
|
||||
var graphUrl = Constants.GraphBaseUrl + "/beta/deviceManagement/tenantAssociatedDevices"
|
||||
+ $"?$top=25&$filter={filter}&$orderby={orderby}";
|
||||
|
||||
var response = await graphClient.GetAsync(graphUrl);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
@@ -169,7 +170,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
// while this POST is in flight, the generation changes and the token we cache below is
|
||||
// tagged with the now-stale value — so readers reject it and re-mint fresh.
|
||||
var genAtStart = CurrentGen(tenantId);
|
||||
var tokenUrl = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token";
|
||||
var tokenUrl = $"{Constants.EntraLoginBaseUrl}/{tenantId}/oauth2/v2.0/token";
|
||||
|
||||
// Retry with backoff to handle Azure AD consent propagation delays.
|
||||
// After admin consent is granted, the service principal may not be immediately
|
||||
@@ -196,7 +197,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
["client_id"] = clientId,
|
||||
["client_secret"] = clientSecret,
|
||||
["scope"] = "https://graph.microsoft.com/.default",
|
||||
["scope"] = Constants.GraphBaseUrl + "/.default",
|
||||
["grant_type"] = "client_credentials"
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -176,7 +177,7 @@ namespace AutopilotMonitor.Functions.Services.Analyze
|
||||
return;
|
||||
|
||||
var session = await _sessionRepo.GetSessionAsync(envelope.TenantId, envelope.SessionId).ConfigureAwait(false);
|
||||
var sessionUrl = $"https://portal.autopilotmonitor.com/sessions/{envelope.SessionId}";
|
||||
var sessionUrl = $"{Constants.PortalBaseUrl}/sessions/{envelope.SessionId}";
|
||||
|
||||
foreach (var (result, rule) in candidates)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
@@ -97,7 +99,7 @@ public static class EmailTemplates
|
||||
<table cellpadding=""0"" cellspacing=""0"" style=""margin:0 auto 32px;"">
|
||||
<tr>
|
||||
<td style=""background-color:#2563eb; border-radius:8px;"">
|
||||
<a href=""https://www.autopilotmonitor.com/docs"" target=""_blank""
|
||||
<a href=""{Constants.DocsBaseUrl}"" target=""_blank""
|
||||
style=""display:inline-block; padding:14px 32px; color:#ffffff; font-size:15px; font-weight:600; text-decoration:none;"">
|
||||
View Documentation
|
||||
</a>
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -35,7 +36,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return;
|
||||
|
||||
var sessionUrl = updatedSession != null
|
||||
? $"https://portal.autopilotmonitor.com/sessions/{request.SessionId}"
|
||||
? $"{Constants.PortalBaseUrl}/sessions/{request.SessionId}"
|
||||
: null;
|
||||
|
||||
// A failure alert requires an actual failure-ish verdict: an agent_timeout
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
@@ -37,7 +38,7 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan TokenAcquireBudget = TimeSpan.FromMilliseconds(1500);
|
||||
|
||||
private const string GraphBetaBase = "https://graph.microsoft.com/beta";
|
||||
private const string GraphBetaBase = Constants.GraphBaseUrl + "/beta";
|
||||
private const int PageSize = 100;
|
||||
|
||||
private readonly IGraphFeatureDetector _detector;
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -27,7 +28,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
public sealed class LatestVersionsService : ILatestVersionsService
|
||||
{
|
||||
public const string VersionJsonUrl = "https://autopilotmonitor.blob.core.windows.net/agent/version.json";
|
||||
// Download alias (Front Door → current blob origin) — NOT the legacy blob
|
||||
// account. The legacy mirror in build-agent.yml is fail-soft, so reading from
|
||||
// it could silently serve a stale version while the release pipeline is green.
|
||||
public const string VersionJsonUrl = Constants.AgentDownloadBaseUrl + "/" + Constants.AgentVersionFileName;
|
||||
private const string CacheKey = "latest-versions";
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(12);
|
||||
private static readonly TimeSpan FailureCacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
@@ -517,6 +517,58 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// Removes distress reports older than 14 days. Distress data is unverified
|
||||
/// and low-volume; short retention keeps Table Storage lean.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// One-off reconciliation for tenants onboarded before <c>ContactEmail</c> existed:
|
||||
/// copies the preview notification address they already supplied into their tenant
|
||||
/// configuration, but only where no contact address is set yet.
|
||||
/// <para>
|
||||
/// Deliberately never overwrites an existing value — a tenant that has edited its
|
||||
/// contact address owns it, and a maintenance run must not undo that. Going forward
|
||||
/// 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()
|
||||
{
|
||||
var filled = 0;
|
||||
try
|
||||
{
|
||||
var configs = await _tenantConfigService.GetAllConfigurationsAsync();
|
||||
foreach (var config in configs)
|
||||
{
|
||||
if (config == null || string.IsNullOrWhiteSpace(config.TenantId))
|
||||
continue;
|
||||
if (!string.IsNullOrWhiteSpace(config.ContactEmail))
|
||||
continue;
|
||||
|
||||
var email = await _previewWhitelistService.GetNotificationEmailAsync(config.TenantId);
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
continue;
|
||||
|
||||
if (await _tenantConfigService.TrySeedContactEmailAsync(config.TenantId, email))
|
||||
filled++;
|
||||
}
|
||||
|
||||
if (filled > 0)
|
||||
_logger.LogInformation("Backfilled contact address for {Count} tenant(s)", filled);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Housekeeping, not load-bearing: a failure here must not fail the whole run.
|
||||
_logger.LogWarning(ex, "Tenant contact address backfill failed");
|
||||
}
|
||||
|
||||
return filled;
|
||||
}
|
||||
|
||||
private async Task CleanupOldDistressReportsAsync()
|
||||
{
|
||||
const int retentionDays = 14;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
@@ -32,6 +32,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
public bool DataCleanupExecuted { get; set; }
|
||||
public bool PlatformStatsRecomputed { get; set; }
|
||||
public int DevicesBlockedForExcessiveData { get; set; }
|
||||
public int ContactEmailsBackfilled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -64,6 +65,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private readonly IPoisonQueueProbe _poisonQueueProbe;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly PreviewWhitelistService _previewWhitelistService;
|
||||
private readonly ILogger<MaintenanceService> _logger;
|
||||
|
||||
private const string PlatformStatsAliasFileName = "platform-stats.json";
|
||||
@@ -92,6 +94,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
IPoisonQueueProbe poisonQueueProbe,
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
PreviewWhitelistService previewWhitelistService,
|
||||
ILogger<MaintenanceService> logger)
|
||||
{
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
@@ -115,6 +118,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_poisonQueueProbe = poisonQueueProbe;
|
||||
_configuration = configuration;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_previewWhitelistService = previewWhitelistService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -130,7 +134,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
await MarkStalledSessionsAsTimedOutAsync();
|
||||
await DetectExcessiveEventSessionsAsync();
|
||||
await BlockExcessiveDataSendersAsync();
|
||||
await AggregateMetricsWithCatchUpAsync();
|
||||
// Plan §5 PR6 / §16 R14: session retention fanout extracted out of the 2h timer
|
||||
// into the dedicated 12h SessionDeletionMaintenanceFunction so cascade-lifecycle
|
||||
@@ -214,6 +217,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// StoreSessionAsync Replace-mode IndexRowKey bug (now fixed).
|
||||
await _maintenanceRepo.CleanupGhostSessionIndexEntriesAsync();
|
||||
|
||||
// One-off: give tenants onboarded before ContactEmail existed the contact
|
||||
// address they already supplied as a preview notification email. New tenants
|
||||
// are seeded at the point that address is saved, so this converges to a no-op
|
||||
// and can be dropped once every existing tenant has been covered.
|
||||
result.ContactEmailsBackfilled = await BackfillTenantContactEmailsAsync();
|
||||
|
||||
// Mirror the timer path: check embedded Intune cert bundle for
|
||||
// expiring members so manual triggers also exercise the watcher.
|
||||
await CheckEmbeddedCertExpiryAsync();
|
||||
@@ -553,6 +562,16 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure eligibility gate for the runaway-session auto-action path: only a session that
|
||||
/// can still upload may cost its device a block. Mirrors the time-window watchdog's
|
||||
/// <c>Status eq 'InProgress'</c> filter, so neither detector ever blocks a device over a
|
||||
/// session that stopped sending — without it, a session that finished days ago with a
|
||||
/// high EventCount still gets its device blocked on the next sweep.
|
||||
/// The warn tier deliberately stays status-agnostic: it is forensic and blocks nothing.
|
||||
/// </summary>
|
||||
internal static bool IsAutoActionEligible(SessionStatus status) => status == SessionStatus.InProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Scans every tenant for sessions whose EventCount exceeds the configured warn
|
||||
/// threshold (<see cref="AdminConfiguration.ExcessiveEventCountThreshold"/>) or the
|
||||
@@ -570,6 +589,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// mode mid-run never re-fires the warn.
|
||||
/// </para>
|
||||
/// Both paths skip when their threshold is 0; the whole sweep no-ops when both are off.
|
||||
/// <para>
|
||||
/// This is the ONLY automatic device block. A second, time-window detector used to run
|
||||
/// beside it (AdminConfiguration.MaxSessionWindowHours) and blocked any session whose
|
||||
/// first and last event lay more than N hours apart. It measured span, never volume, so
|
||||
/// it flagged every enrollment that spanned a night — 23-event sessions included — and
|
||||
/// was removed 2026-07-22. Event count is the quantity that actually costs anything;
|
||||
/// if a span-based guard is ever reintroduced it has to gate on volume too.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task DetectExcessiveEventSessionsAsync()
|
||||
{
|
||||
@@ -627,6 +654,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var action = DecideAutoAction(session.EventCount, autoMode, autoThreshold);
|
||||
if (action == null) continue;
|
||||
|
||||
if (!IsAutoActionEligible(session.Status))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Skipping auto-action for runaway session {SessionId} (tenant {TenantId}): status {Status} can no longer upload",
|
||||
session.SessionId, tenantId, session.Status);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(session.SerialNumber))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
@@ -635,6 +670,19 @@ namespace AutopilotMonitor.Functions.Services
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't re-block an already-blocked device: BlockDeviceAsync would
|
||||
// overwrite BlockedAt/UnblockAt/Reason of the existing block — most
|
||||
// often one the time-window watchdog placed on the same session,
|
||||
// whose reason then silently disappears from the Active Blocks list.
|
||||
var (alreadyBlocked, _, _, _) = await _blockedDeviceService.IsBlockedAsync(tenantId, session.SerialNumber);
|
||||
if (alreadyBlocked)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Skipping auto-action for runaway session {SessionId} (tenant {TenantId}): device {SerialNumber} is already blocked",
|
||||
session.SessionId, tenantId, session.SerialNumber);
|
||||
continue;
|
||||
}
|
||||
|
||||
var reason = $"Auto-action: excessive session events ({session.EventCount} events ≥ threshold {autoThreshold})";
|
||||
await _blockedDeviceService.BlockDeviceAsync(
|
||||
tenantId,
|
||||
@@ -670,108 +718,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects devices that are still actively sending data beyond the configured maximum session
|
||||
/// window and blocks them automatically. Status-independent: uses LastEventAt (written on every
|
||||
/// event batch) combined with StartedAt to identify sessions spanning too long a data window.
|
||||
/// Controlled by AdminConfiguration.MaxSessionWindowHours (0 = disabled).
|
||||
/// </summary>
|
||||
private async Task<int> BlockExcessiveDataSendersAsync()
|
||||
{
|
||||
_logger.LogInformation("Checking for excessive data senders...");
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var adminConfig = await _adminConfigurationService.GetConfigurationAsync();
|
||||
if (adminConfig.MaxSessionWindowHours == 0)
|
||||
{
|
||||
_logger.LogInformation("Excessive data sender detection disabled (MaxSessionWindowHours = 0)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var windowCutoff = DateTime.UtcNow.AddHours(-adminConfig.MaxSessionWindowHours);
|
||||
var blockDurationHours = adminConfig.MaintenanceBlockDurationHours;
|
||||
var tenantIds = await _maintenanceRepo.GetAllTenantIdsAsync();
|
||||
int totalBlocked = 0;
|
||||
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sessions = await _maintenanceRepo.GetExcessiveDataSendersAsync(tenantId, windowCutoff, adminConfig.MaxSessionWindowHours);
|
||||
|
||||
if (sessions.Count == 0)
|
||||
{
|
||||
_logger.LogInformation($"Tenant {tenantId}: No excessive data sender sessions found (window: {adminConfig.MaxSessionWindowHours}h)");
|
||||
continue;
|
||||
}
|
||||
|
||||
int blockedCount = 0;
|
||||
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
if (string.IsNullOrEmpty(session.SerialNumber))
|
||||
continue;
|
||||
|
||||
var (isBlocked, _, _, _) = await _blockedDeviceService.IsBlockedAsync(tenantId, session.SerialNumber);
|
||||
if (isBlocked)
|
||||
continue;
|
||||
|
||||
var reason = $"Excessive data window: session active for >{adminConfig.MaxSessionWindowHours}h " +
|
||||
$"(started {session.StartedAt:yyyy-MM-dd HH:mm:ss} UTC, " +
|
||||
$"last event {session.LastEventAt:yyyy-MM-dd HH:mm:ss} UTC)";
|
||||
|
||||
await _blockedDeviceService.BlockDeviceAsync(
|
||||
tenantId,
|
||||
session.SerialNumber,
|
||||
durationHours: blockDurationHours,
|
||||
blockedByEmail: "System.Maintenance",
|
||||
reason: reason,
|
||||
blockedSessionId: session.SessionId);
|
||||
|
||||
blockedCount++;
|
||||
}
|
||||
|
||||
if (blockedCount > 0)
|
||||
{
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
tenantId,
|
||||
"ExcessiveDataBlock",
|
||||
"Device",
|
||||
$"{blockedCount} devices",
|
||||
"System.Maintenance",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "DevicesBlocked", blockedCount.ToString() },
|
||||
{ "WindowHours", adminConfig.MaxSessionWindowHours.ToString() },
|
||||
{ "BlockDurationHours", blockDurationHours.ToString() }
|
||||
});
|
||||
|
||||
_logger.LogInformation($"Tenant {tenantId}: Blocked {blockedCount} excessive data sender device(s) (window: {adminConfig.MaxSessionWindowHours}h, block: {blockDurationHours}h)");
|
||||
await _opsEventService.RecordExcessiveDataBlockedAsync(tenantId, blockedCount, adminConfig.MaxSessionWindowHours);
|
||||
}
|
||||
|
||||
totalBlocked += blockedCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Failed to check excessive data senders for tenant {tenantId}");
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogInformation($"Excessive data sender check completed: {totalBlocked} devices blocked in {sw.ElapsedMilliseconds}ms");
|
||||
return totalBlocked;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to check for excessive data senders");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates metrics for any missed days in the last 7 days, plus yesterday.
|
||||
/// Checks the UsageMetrics table for existing snapshots to avoid re-aggregation.
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -167,7 +168,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
},
|
||||
Actions = new List<NotificationAction>
|
||||
{
|
||||
new() { Type = "openUrl", Title = "Open Autopilot Monitor", Url = "https://portal.autopilotmonitor.com" },
|
||||
new() { Type = "openUrl", Title = "Open Autopilot Monitor", Url = Constants.PortalBaseUrl },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -255,10 +255,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
$"Device {serialNumber} blocked: {reason}",
|
||||
tenantId, blockedBy, new { serialNumber, reason });
|
||||
|
||||
public Task RecordExcessiveDataBlockedAsync(string tenantId, int devicesBlocked, int windowHours)
|
||||
=> WriteAsync(OpsEventCategory.Security, "ExcessiveDataBlocked", OpsEventSeverity.Warning,
|
||||
$"{devicesBlocked} device(s) auto-blocked for excessive data (>{windowHours}h window)",
|
||||
tenantId, "System.Maintenance", new { devicesBlocked, windowHours });
|
||||
// ExcessiveDataBlocked was removed 2026-07-22 together with the time-window detector that
|
||||
// raised it — it blocked on session span alone and fired on ordinary overnight enrollments.
|
||||
// Automatic blocks now come only from the event-count path below.
|
||||
|
||||
public Task RecordVersionBlockedAsync(string pattern, string blockedBy)
|
||||
=> WriteAsync(OpsEventCategory.Security, "VersionBlocked", OpsEventSeverity.Warning,
|
||||
|
||||
@@ -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>
|
||||
@@ -102,10 +105,17 @@ public class PreviewWhitelistService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves (or clears) the notification email for a tenant.
|
||||
/// 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 _tenantConfigService.TrySeedContactEmailAsync(tenantId, email);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
@@ -29,7 +30,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// </summary>
|
||||
public class SlaBreachEvaluationService
|
||||
{
|
||||
private const string DashboardUrl = "https://www.autopilotmonitor.com/sla";
|
||||
private const string DashboardUrl = Constants.WebsiteBaseUrl + "/sla";
|
||||
private const int MaxConflictRetries = 4;
|
||||
private const int MinAppInstallSampleSize = 5;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Azure;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
@@ -345,8 +345,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress automated maintenance entries (SessionTimeout, ExcessiveDataBlock,
|
||||
// DataRetentionCleanup, …) from the human-facing audit list. They are written
|
||||
// Suppress automated maintenance entries (SessionTimeout, DataRetentionCleanup, …)
|
||||
// from the human-facing audit list. They are written
|
||||
// by MaintenanceService for traceability but duplicate the OpsEvents stream
|
||||
// and are not actionable as user-attributable audits. Rows stay in storage
|
||||
// and remain reachable via raw table queries.
|
||||
@@ -792,73 +792,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets sessions where the device has been actively sending data for longer than
|
||||
/// <paramref name="maxSessionWindowHours"/>.
|
||||
/// Status-independent: detects excessive data senders regardless of session status.
|
||||
/// Uses LastEventAt (written on every event batch) for the "still active" check.
|
||||
/// Sessions without LastEventAt (predating this field) are not returned.
|
||||
///
|
||||
/// The OData pre-filter narrows candidates to sessions that straddle the cutoff boundary,
|
||||
/// then a post-filter verifies the actual session duration (LastEventAt − StartedAt)
|
||||
/// exceeds the allowed window. This prevents false positives from short sessions that
|
||||
/// merely happen to straddle the cutoff time.
|
||||
/// </summary>
|
||||
public async Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
|
||||
// OData pre-filter: narrow to sessions that straddle the cutoff boundary.
|
||||
// Status eq 'InProgress' → only block devices whose session is still actively
|
||||
// sending data. Completed sessions (Succeeded/Failed) must NOT be re-blocked
|
||||
// even if their LastEventAt is still within the window — they cannot continue
|
||||
// to abuse data transfer. This also defends against ghost-blocks caused by
|
||||
// devices with bad clocks: an agent that submits events with timestamps from
|
||||
// weeks in the past pushes StartedAt back, making the session look long-lived;
|
||||
// but once the session is Succeeded/Failed, no further data flows from it.
|
||||
// IsPreProvisioned ne true → exclude WhiteGlove sessions: a pre-provisioned device
|
||||
// that resumes after weeks in storage looks like an excessive sender (StartedAt old,
|
||||
// LastEventAt recent) but is a legitimate resumption, not abuse.
|
||||
var cutoffStr = windowCutoff.ToString("yyyy-MM-ddTHH:mm:ss");
|
||||
var filter = $"PartitionKey eq '{tenantId}' " +
|
||||
$"and Status eq 'InProgress' " +
|
||||
$"and LastEventAt gt datetime'{cutoffStr}Z' " +
|
||||
$"and StartedAt lt datetime'{cutoffStr}Z' " +
|
||||
$"and IsPreProvisioned ne true";
|
||||
|
||||
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
|
||||
var maxDuration = TimeSpan.FromHours(maxSessionWindowHours);
|
||||
|
||||
var sessions = new List<SessionSummary>();
|
||||
await foreach (var entity in query)
|
||||
{
|
||||
// Post-filter: verify actual session duration exceeds the window.
|
||||
// OData cannot compute date differences, so we check in code.
|
||||
var startedAt = entity.GetDateTimeOffset("StartedAt")?.UtcDateTime;
|
||||
var lastEventAt = entity.GetDateTimeOffset("LastEventAt")?.UtcDateTime;
|
||||
|
||||
if (startedAt.HasValue && lastEventAt.HasValue
|
||||
&& (lastEventAt.Value - startedAt.Value) < maxDuration)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sessions.Add(MapToSessionSummary(entity));
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Failed to get excessive data sender sessions for tenant {tenantId}");
|
||||
return new List<SessionSummary>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all known tenant IDs from the TenantConfiguration table (one "config" row per
|
||||
/// tenant). This replaces an O(corpus) scan of the entire Sessions table — the maintenance
|
||||
|
||||
@@ -3005,6 +3005,26 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
#region Excessive-Event Detection
|
||||
|
||||
/// <summary>
|
||||
/// Status parse for the runaway-session rows. Deliberately stricter than
|
||||
/// <c>MapToSessionSummary</c>, which reads a missing column as InProgress for legacy rows:
|
||||
/// here a missing, empty, numeric or unknown value maps to <see cref="SessionStatus.Unknown"/>
|
||||
/// so the auto-block/kill gate fails closed and no device is blocked over a session that
|
||||
/// cannot be classified. The two explicit rejections exist because <c>Enum.TryParse</c> is
|
||||
/// lenient in ways that would open the gate: it maps the string "0" straight onto
|
||||
/// InProgress, and it ORs comma-separated names into a single unrelated member.
|
||||
/// </summary>
|
||||
internal static SessionStatus ParseStatusForAutoAction(string? statusString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(statusString)) return SessionStatus.Unknown;
|
||||
if (!char.IsLetter(statusString[0])) return SessionStatus.Unknown;
|
||||
if (statusString.Contains(',')) return SessionStatus.Unknown;
|
||||
return Enum.TryParse<SessionStatus>(statusString, ignoreCase: true, out var status)
|
||||
&& Enum.IsDefined(status)
|
||||
? status
|
||||
: SessionStatus.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns sessions in the given tenant whose EventCount exceeds the threshold.
|
||||
/// Projects only the fields needed for the idempotency check — avoids full entity read.
|
||||
@@ -3023,7 +3043,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// SerialNumber + ExcessiveEventsAutoActioned added for the auto-block/kill
|
||||
// path in MaintenanceService — lets us decide and execute in one round-trip
|
||||
// per qualifying session, without a follow-up GetSessionAsync.
|
||||
select: new[] { "PartitionKey", "RowKey", "EventCount", "SerialNumber", "ExcessiveEventsAlerted", "ExcessiveEventsAutoActioned" });
|
||||
// Status is mandatory for that path: SessionStatus.InProgress is ordinal 0, so
|
||||
// an unselected column would leave every row looking in-progress and the
|
||||
// auto-action eligibility gate silently always-true.
|
||||
select: new[] { "PartitionKey", "RowKey", "EventCount", "SerialNumber", "Status", "ExcessiveEventsAlerted", "ExcessiveEventsAutoActioned" });
|
||||
|
||||
await foreach (var entity in query)
|
||||
{
|
||||
@@ -3033,6 +3056,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
SessionId = entity.RowKey,
|
||||
EventCount = SafeGetInt32(entity, "EventCount") ?? 0,
|
||||
SerialNumber = entity.GetString("SerialNumber") ?? string.Empty,
|
||||
Status = ParseStatusForAutoAction(entity.GetString("Status")),
|
||||
ExcessiveEventsAlerted = entity.GetBoolean("ExcessiveEventsAlerted") ?? false,
|
||||
ExcessiveEventsAutoActioned = entity.GetBoolean("ExcessiveEventsAutoActioned") ?? false
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,6 +18,22 @@ COPY rules/analyze/ ./rules/analyze/
|
||||
COPY rules/gather/ ./rules/gather/
|
||||
COPY rules/ime-log-patterns/ ./rules/ime-log-patterns/
|
||||
|
||||
# Published customer documentation (autopilotmonitor-docs) — a SEPARATE public
|
||||
# repo that the deploy workflow checks out into ./mcp-docs of the build context.
|
||||
# Same byte-identical requirement as the rules above.
|
||||
#
|
||||
# This COPY is deliberately strict: if the workflow's docs checkout is ever
|
||||
# removed, the build must fail loudly rather than quietly ship an image whose
|
||||
# search_docs tool simply does not exist. For a local `docker build`, clone the
|
||||
# bundle first:
|
||||
# git clone --depth 1 https://github.com/autopilotmonitor/autopilotmonitor-docs mcp-docs
|
||||
COPY mcp-docs/ ./docs/
|
||||
|
||||
# Commit SHA of that docs checkout. Surfaced on /health so a running server can be
|
||||
# diffed against the docs repo HEAD — the bundle changes independently of this
|
||||
# repo, so a stale deploy is otherwise invisible.
|
||||
ARG DOCS_COMMIT=unknown
|
||||
|
||||
# Pre-bake the ~23 MB embedding model AND precompute the search index at image
|
||||
# build time. Embedding the static corpus at boot cost 35-55s of ONNX inference
|
||||
# on the 0.25 vCPU container (measured); hydrating precomputed vectors is
|
||||
@@ -35,7 +51,7 @@ RUN mkdir -p "$HF_CACHE_DIR" /app/search-index && \
|
||||
if [ "$PREBAKE_MODEL" = "1" ]; then \
|
||||
ok=0; \
|
||||
for i in 1 2 3 4 5; do \
|
||||
node dist/precompute-embeddings.js ./rules /app/search-index/search-index.json && { ok=1; break; }; \
|
||||
node dist/precompute-embeddings.js ./rules /app/search-index/search-index.json ./docs && { ok=1; break; }; \
|
||||
echo "embedding precompute attempt $i/5 failed; retrying in 15s"; \
|
||||
sleep 15; \
|
||||
done && \
|
||||
@@ -66,8 +82,26 @@ COPY rules/analyze/ ./rules/analyze/
|
||||
COPY rules/gather/ ./rules/gather/
|
||||
COPY rules/ime-log-patterns/ ./rules/ime-log-patterns/
|
||||
|
||||
# Customer documentation corpus — copied from the build stage rather than the
|
||||
# context so it is provably the same bytes the embeddings were computed over.
|
||||
COPY --from=build /app/docs/ ./docs/
|
||||
|
||||
ARG DOCS_COMMIT=unknown
|
||||
ENV DOCS_COMMIT=${DOCS_COMMIT}
|
||||
|
||||
# Build identity of the server itself (deploy-mcp.yml supplies all three).
|
||||
# BUILD_NUMBER completes the version as <major>.<minor>.<build>; left unset in a
|
||||
# local `docker build`, which keeps package.json's version and marks it as such.
|
||||
ARG BUILD_NUMBER=
|
||||
ARG BUILD_COMMIT=unknown
|
||||
ARG BUILD_UTC=unknown
|
||||
ENV BUILD_NUMBER=${BUILD_NUMBER}
|
||||
ENV BUILD_COMMIT=${BUILD_COMMIT}
|
||||
ENV BUILD_UTC=${BUILD_UTC}
|
||||
|
||||
ENV PORT=8080
|
||||
ENV RULES_DIR=/app/rules
|
||||
ENV DOCS_DIR=/app/docs
|
||||
ENV SEARCH_INDEX_PATH=/app/search-index/search-index.json
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
+502
-379
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user