mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b6565b3cb | |||
| 5f3ae2d06b | |||
| 83e15e49de | |||
| 3c8ef02191 | |||
| 251e82a45a | |||
| 01311898a8 | |||
| a5bca8a730 | |||
| 87cb1f1cea | |||
| 44dd8161df | |||
| c84a1c23eb | |||
| 4a7eb2749d | |||
| a536553ee3 | |||
| ce202c6d47 | |||
| 74902bf185 | |||
| 4f021852ee | |||
| 7a3516e570 | |||
| 6907a41a84 | |||
| f4a5eac539 | |||
| 09492e908c | |||
| f432353efa | |||
| 1daf631a02 | |||
| d0190f443d | |||
| e96f49799f | |||
| 4864b2d21d | |||
| 56c37866d1 | |||
| 2852418413 | |||
| 268d8652f1 | |||
| 66f5543fb7 | |||
| 21d200a1e4 | |||
| 8c0cb2c423 | |||
| 84d06e1cf6 | |||
| 88dfd331dd | |||
| d294415d29 | |||
| 76af1234ed | |||
| 0515523e49 | |||
| a47e94f9a5 | |||
| ecfa59d049 | |||
| 6c50a54249 | |||
| 6e3487756a | |||
| dbb154bae9 | |||
| c41c2327ab | |||
| 83effea592 | |||
| 1a4a37b77a | |||
| f56851fc9f | |||
| cbe410cbbc | |||
| 43ddc60111 | |||
| 82b26b4ab9 | |||
| 1733f4fd48 | |||
| fc46b4c78e | |||
| c18de5d76c | |||
| 250cc667dd | |||
| d3261fb890 | |||
| b3bbcf1300 | |||
| a306cd9e40 | |||
| 8ec8524252 | |||
| c4be6d4169 | |||
| d558b94cf6 | |||
| 9b4222e729 | |||
| cf76c4338f | |||
| a6184f165b | |||
| d3918cb740 | |||
| 15722a3885 | |||
| 1a4e13f0e9 | |||
| c39092bf72 | |||
| 494744ad3f | |||
| a68c096a26 | |||
| bf156cf862 | |||
| 0eaa3527f9 | |||
| 5055db2de1 | |||
| 040e411958 | |||
| 42c45cf567 | |||
| b31256c748 | |||
| 51f2dabc8c | |||
| 0f9a77c848 | |||
| d89e3f0b48 | |||
| 058248d95c | |||
| aab0a0c52c | |||
| f22feb71ff | |||
| c92fe7c2a0 | |||
| b7cb75e442 | |||
| d6895f2c7e | |||
| beac51720f | |||
| 32268ba245 | |||
| c91d8d4501 | |||
| bf6850dedc | |||
| 35a3ed0cfb | |||
| ea6a375c4b |
+1
-1
Submodule .claude/commands updated: ba49ac60ca...3e4d919c98
@@ -76,12 +76,23 @@ jobs:
|
||||
- name: Reserve build number (counter blob CAS)
|
||||
id: version
|
||||
shell: pwsh
|
||||
# Input and secret reach the script only as environment variables, never as script
|
||||
# text: ${{ }} is substituted before pwsh parses the block, so a crafted
|
||||
# build_number could otherwise break out of the quotes and run arbitrary code.
|
||||
env:
|
||||
BUILD_NUMBER_OVERRIDE: ${{ inputs.build_number }}
|
||||
AGENT_SAS_TOKEN: ${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}
|
||||
run: |
|
||||
$override = 0
|
||||
if ('${{ inputs.build_number }}' -ne '') { $override = [int]'${{ inputs.build_number }}' }
|
||||
if ($env:BUILD_NUMBER_OVERRIDE -ne '') {
|
||||
if ($env:BUILD_NUMBER_OVERRIDE -notmatch '^\d+$') {
|
||||
throw "build_number must be a positive integer, got '$($env:BUILD_NUMBER_OVERRIDE)'"
|
||||
}
|
||||
$override = [int]$env:BUILD_NUMBER_OVERRIDE
|
||||
}
|
||||
|
||||
$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
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -SasToken $env:AGENT_SAS_TOKEN -Override $override
|
||||
|
||||
$csproj = Get-Content $env:CSPROJ_PATH -Raw
|
||||
if ($csproj -match '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
|
||||
@@ -313,13 +324,19 @@ jobs:
|
||||
name: version-metadata
|
||||
path: ${{ env.MANIFEST_NAME }}
|
||||
|
||||
# First-party `gh` instead of a third-party action: this job holds contents:write,
|
||||
# id-token:write and attestations:write, so no code we do not control runs here.
|
||||
# --generate-notes appends the auto-generated notes below the body.
|
||||
- name: Create GitHub Release
|
||||
if: inputs.create_release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
name: Autopilot Monitor Agent ${{ steps.version.outputs.version }}
|
||||
body: |
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ steps.version.outputs.tag }}
|
||||
RELEASE_NAME: Autopilot Monitor Agent ${{ steps.version.outputs.version }}
|
||||
RELEASE_PRERELEASE: ${{ inputs.prerelease }}
|
||||
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
RELEASE_BODY: |
|
||||
## Autopilot Monitor Agent ${{ steps.version.outputs.version }}
|
||||
|
||||
### Installation
|
||||
@@ -344,9 +361,22 @@ jobs:
|
||||
```
|
||||
gh attestation verify AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
|
||||
```
|
||||
draft: false
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
files: |
|
||||
${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
${{ env.MANIFEST_NAME }}
|
||||
generate_release_notes: true
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$notesFile = Join-Path $env:RUNNER_TEMP 'release-notes.md'
|
||||
Set-Content -Path $notesFile -Value $env:RELEASE_BODY -Encoding utf8 -NoNewline
|
||||
|
||||
$ghArgs = @(
|
||||
'release', 'create', $env:RELEASE_TAG,
|
||||
$env:RELEASE_ZIP, $env:MANIFEST_NAME,
|
||||
'--repo', $env:GITHUB_REPOSITORY,
|
||||
'--target', $env:GITHUB_SHA,
|
||||
'--title', $env:RELEASE_NAME,
|
||||
'--notes-file', $notesFile,
|
||||
'--generate-notes'
|
||||
)
|
||||
if ($env:RELEASE_PRERELEASE -eq 'true') { $ghArgs += '--prerelease' }
|
||||
|
||||
& gh @ghArgs
|
||||
if ($LASTEXITCODE -ne 0) { throw "gh release create failed with exit code $LASTEXITCODE" }
|
||||
Write-Host "Release $env:RELEASE_TAG created"
|
||||
|
||||
@@ -54,14 +54,32 @@ jobs:
|
||||
ajv validate -s rules/schema/ime-log-pattern.schema.json -d "$f" --spec=draft2020 -c ajv-formats
|
||||
done
|
||||
|
||||
# guardrails.json values are baked verbatim into generated TypeScript that is
|
||||
# auto-committed to main and shipped to the portal - the schema forbids control
|
||||
# and line-terminator characters so a "data" PR can never become code.
|
||||
- name: Validate Guardrails
|
||||
run: ajv validate -s rules/schema/guardrails.schema.json -d rules/guardrails.json --spec=draft2020 -c ajv-formats
|
||||
|
||||
- name: Combine rules into dist/
|
||||
run: node rules/scripts/combine.js
|
||||
|
||||
# Plain git instead of a third-party action: this step runs with contents:write on
|
||||
# main, so no code we do not control gets to push. Checkout leaves the job's
|
||||
# GITHUB_TOKEN in the remote credential helper, which is all `git push` needs.
|
||||
# NOTE: combine.js writes THREE outputs; committing only dist/ would silently discard
|
||||
# the two generated TS modules and leave the guardrails-in-sync CI check red.
|
||||
- name: Commit generated outputs
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
commit_message: "chore: regenerate rules/dist + generated TS from individual files"
|
||||
# combine.js writes THREE outputs; committing only dist/ would silently discard
|
||||
# the two generated TS modules and leave the guardrails-in-sync CI check red.
|
||||
file_pattern: "rules/dist/* src/Web/autopilot-monitor-web/utils/guardrails.generated.ts src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add -- rules/dist/ \
|
||||
src/Web/autopilot-monitor-web/utils/guardrails.generated.ts \
|
||||
src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts
|
||||
if git diff --cached --quiet; then
|
||||
echo "Generated outputs unchanged - nothing to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: regenerate rules/dist + generated TS from individual files"
|
||||
git push origin HEAD:main
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@v3
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
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
|
||||
uses: Azure/functions-action@c5060b3b8bb1ebbcb531abd00c822ecbaa8ea656 # v1
|
||||
with:
|
||||
app-name: ${{ env.FUNCTION_APP }}
|
||||
package: ./publish
|
||||
|
||||
@@ -11,7 +11,11 @@ env:
|
||||
ACR_NAME: autopilotmonitoreuacr
|
||||
CONTAINER_APP_NAME: autopilotmonitor-mcp
|
||||
RESOURCE_GROUP: ${{ secrets.AZURE_MCP_RESOURCE_GROUP }}
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
# Unique per RUN, not per commit: the docs corpus is baked into the image, so a
|
||||
# docs-only redeploy at an unchanged code sha must still produce a new image
|
||||
# reference — with a sha-only tag `az containerapp update` saw the same image
|
||||
# and silently created no revision (2026-08-28).
|
||||
IMAGE_TAG: ${{ github.sha }}-${{ github.run_number }}
|
||||
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
|
||||
@@ -28,8 +32,9 @@ jobs:
|
||||
# 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.
|
||||
# RUNNING SERVER; this workflow must be re-run (IMAGE_TAG is per run so the
|
||||
# re-run actually rolls a new revision). /health reports the SHA captured
|
||||
# below so that staleness is checkable.
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -41,7 +46,7 @@ jobs:
|
||||
run: echo "sha=$(git -C mcp-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@v3
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
name: Deploy Web (Static Web App)
|
||||
|
||||
# Build here, upload with the pinned SWA CLI. No third-party action sees the deploy token
|
||||
# (the CLI is Microsoft-published npm code at an exact version), the build runs on the same
|
||||
# path ci.yml tests (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the
|
||||
# action's container, and the exported site is attested like the agent ZIP and the MSI.
|
||||
#
|
||||
# PR preview environments were dropped with the action: their per-PR URLs are never
|
||||
# registered as redirect URIs, so nobody could log in on them anyway.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -8,39 +16,44 @@ on:
|
||||
- 'src/Web/autopilot-monitor-web/**'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'src/Web/autopilot-monitor-web/**'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # keyless Sigstore signing (artifact attestation)
|
||||
attestations: write # store the provenance attestation
|
||||
|
||||
env:
|
||||
PORTAL_VERSION_URL: https://portal.autopilotmonitor.com/version.json
|
||||
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
|
||||
WEB_DIR: src/Web/autopilot-monitor-web
|
||||
# Exact version: `npx --package` resolves from the registry at run time, so the pin is the
|
||||
# only thing that keeps the deploy step's code fixed. Bump deliberately.
|
||||
SWA_CLI_VERSION: 2.0.10
|
||||
|
||||
jobs:
|
||||
build_and_deploy_job:
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.action != 'closed')
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Job
|
||||
# Dependabot PRs run without repo secrets, so the deploy token is absent.
|
||||
# Surface it as an env var so the deploy step can gate on it — secrets
|
||||
# cannot be referenced directly in an `if:` expression.
|
||||
env:
|
||||
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
lfs: false
|
||||
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
|
||||
- name: Build And Deploy
|
||||
id: builddeploy
|
||||
# Skip (green) instead of failing when the token is missing (Dependabot
|
||||
# PRs); real deploys on main and internal PRs still have it.
|
||||
if: env.DEPLOY_TOKEN != ''
|
||||
uses: Azure/static-web-apps-deploy@v1
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: ${{ env.WEB_DIR }}/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ${{ env.WEB_DIR }}
|
||||
run: npm ci
|
||||
|
||||
# next.config output:'export' -- `next build` writes the static site to out/
|
||||
# (prebuild: sync-error-codes, generate-lastmod, generate-version).
|
||||
- name: Build
|
||||
working-directory: ${{ env.WEB_DIR }}
|
||||
env:
|
||||
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
|
||||
# Dual app-reg window: previous app registration (unset = single-app behaviour)
|
||||
@@ -55,28 +68,50 @@ jobs:
|
||||
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
|
||||
# the verify step below polls the deployed file for exactly this commit.
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
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)
|
||||
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
|
||||
app_location: "src/Web/autopilot-monitor-web" # App source code path
|
||||
api_location: "" # Api source code path - optional
|
||||
# Static export (next.config output:'export'): Oryx runs `npm run build`
|
||||
# and uploads the exported out/ directory as plain static files — no
|
||||
# managed SSR runtime is provisioned anymore. (No is_static_export
|
||||
# input on this action version; Oryx detects the export from
|
||||
# next.config + output_location.)
|
||||
output_location: "out" # Built app content directory - optional
|
||||
###### End of Repository/Build Configurations ######
|
||||
run: |
|
||||
npm run build
|
||||
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
|
||||
|
||||
# The SWA action reporting success proves the upload, not that the production host
|
||||
# serves the new build — verify against the live /version.json (Cache-Control:
|
||||
# no-store via staticwebapp.config.json) before publishing the version manifest.
|
||||
# PR builds deploy to per-PR preview environments, so only main deploys are verified.
|
||||
# One archive of the exact bytes that get uploaded, so the attestation has a single subject.
|
||||
- name: Package export
|
||||
id: package
|
||||
run: |
|
||||
archive="portal-${GITHUB_SHA::7}.tar.gz"
|
||||
tar -C "$WEB_DIR/out" -czf "$archive" .
|
||||
echo "archive=$archive" >> "$GITHUB_OUTPUT"
|
||||
sha256sum "$archive"
|
||||
|
||||
# Sigstore keyless provenance: binds the export digest to this workflow run (repo, ref,
|
||||
# commit, workflow file) in a public transparency log.
|
||||
# Verify: gh attestation verify portal-<sha>.tar.gz --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (portal export)
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
subject-path: ${{ steps.package.outputs.archive }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ steps.package.outputs.archive }}
|
||||
|
||||
# Upload only -- out/ is already built. --swa-config-location points at the app root,
|
||||
# where staticwebapp.config.json lives (next export does not copy it into out/).
|
||||
- name: Deploy to Static Web App
|
||||
env:
|
||||
SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
npx --yes --package "@azure/static-web-apps-cli@${SWA_CLI_VERSION}" swa deploy \
|
||||
--app-location "$WEB_DIR" \
|
||||
--output-location out \
|
||||
--swa-config-location "$WEB_DIR" \
|
||||
--env production \
|
||||
--no-use-keychain
|
||||
|
||||
# The CLI reporting success proves the upload, not that the production host serves the
|
||||
# new build -- verify against the live /version.json (Cache-Control: no-store via
|
||||
# staticwebapp.config.json) before publishing the version manifest.
|
||||
- name: Verify deployed portal build is live
|
||||
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$expectedCommit = '${{ github.sha }}'.Substring(0, 7)
|
||||
@@ -100,10 +135,9 @@ jobs:
|
||||
|
||||
throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
|
||||
|
||||
# Cross-component deploy record, written last and only on a verified deploy —
|
||||
# Cross-component deploy record, written last and only on a verified deploy --
|
||||
# same contract as versions/backend.json and versions/mcp.json.
|
||||
- name: Publish version manifest
|
||||
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$manifest = [ordered]@{
|
||||
@@ -119,22 +153,3 @@ jobs:
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/web.json"
|
||||
Write-Host $manifest
|
||||
|
||||
close_pull_request_job:
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
name: Close Pull Request Job
|
||||
env:
|
||||
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
steps:
|
||||
- name: Close Pull Request
|
||||
id: closepullrequest
|
||||
if: env.DEPLOY_TOKEN != ''
|
||||
uses: Azure/static-web-apps-deploy@v1
|
||||
with:
|
||||
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
action: "close"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
name: Federated login + read check
|
||||
steps:
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@v3
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -4,7 +4,7 @@ title: CMTrace Time Resolution — Per-Line Self-Anchoring
|
||||
description: How the agent converts bias-less CMTrace local times to UTC without trusting any process's timezone belief — each provably fresh line anchors its own offset against the agent clock on the 15-minute grid; everything not provably fresh falls back uniformly and says so.
|
||||
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime
|
||||
tags: [agent, ime, cmtrace, timestamps, timezone, calibration, provenance]
|
||||
timestamp: 2026-08-20
|
||||
timestamp: 2026-08-30
|
||||
---
|
||||
|
||||
# Problem
|
||||
@@ -86,6 +86,40 @@ with the process zone).
|
||||
* Mixed pairs remain possible across a restart (start resolved from backlog fallback,
|
||||
completion line-anchored) and are recognizable via provenance.
|
||||
|
||||
# Line parsing (bounded cost)
|
||||
|
||||
`CmTraceLogParser.TryParseLine` (Shared) is the single CMTrace line parser for the agent
|
||||
(`ImeLogTracker`, `LogParserCollector`, `StallProbeCollector`) and the backend
|
||||
(`TestLogPatternFunction`). Both feed it input an outsider influences: tenant-supplied
|
||||
sample lines (200 x 8 KB per request) on the backend, and on the agent every tailed log
|
||||
file, including entries that IME assembles from installer or script output. It is
|
||||
therefore not one regex. The former shape — a greedy `(?<message>.*)` in front of the
|
||||
`]LOG]!><time="` literal, unanchored, no match timeout — re-tried every `<![LOG[`
|
||||
occurrence as a fresh start and backtracked the message group across every later character
|
||||
for each: quadratic in line length (an 8 KB line of repeated prefixes and `]` filler cost
|
||||
~7 million engine steps; a 1 MB assembled entry would have spun the SYSTEM agent for
|
||||
hours and blacked out IME tracking for the rest of the enrollment).
|
||||
|
||||
The parser reproduces exactly what that regex returned, in linear time: the message begins
|
||||
after the first `<![LOG[` (a later start could only match when the first one does, since
|
||||
`.*` spans the difference) and ends at the last `]LOG]!><time="` occurrence whose trailer
|
||||
parses — earlier occurrences are tried in turn, which is the order greedy backtracking
|
||||
visited them. The trailer is checked with a `\G`-anchored regex that has no ambiguous
|
||||
quantifier and a 1-second match timeout as a backstop; a timeout means "not a CMTrace
|
||||
line" (callers already have that path: raw-text matching in the tracker, `parse_failed`
|
||||
on the backend), never an exception. `CmTraceLogParserTests` pins the equivalence against
|
||||
the legacy regex over the shapes that matter (multiline messages, bias suffix, trailer
|
||||
literal inside the message, a last trailer that fails to parse, BOM prefix) and the
|
||||
reported worst case at 200 lines.
|
||||
|
||||
On the agent, `ImeLogTracker` additionally caps an assembled multiline entry at 100 raw
|
||||
lines **and** 1 MB of characters (`MaxMultiLineBufferChars`). A capped entry is dropped
|
||||
and logged at **Warning** — visible at the default `Info` level, so a real IME entry that
|
||||
large shows up in the client log and can be acted on. The remaining physical lines of a
|
||||
dropped entry are skipped until it closes or a new `<.
|
||||
|
||||
## Completion attempt sites and sequence
|
||||
|
||||
Completion can be attempted from whichever fact arrives last: `HelloResolved`,
|
||||
@@ -166,6 +175,25 @@ advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`:
|
||||
scheduler contract) dead-ends as `realmjoin_timeout_stale_superseded_by_rearm`.
|
||||
The idle case (detected, no deployment activity ever) times out at 60 min
|
||||
unchanged, and phase 200/210 activity never extends.
|
||||
**Self-update observation** (gktatooine session 946ccbd6): the version on
|
||||
`RealmJoinDetected` is read once from `RealmJoin.exe`, but the tray's `AppUpdater`
|
||||
often replaces the MSI-shipped binary on its first start — minutes after detection,
|
||||
before the deployment begins. `RealmJoinWatcher` therefore arms a
|
||||
`RegNotifyChangeKeyValue` watcher on `HKLM\...\Uninstall\RealmJoin` (the only
|
||||
registry write of the updater; appearance-watch on the `Uninstall` parent when the
|
||||
entry does not exist yet) once Detected fired, re-reads the binary version after a
|
||||
2 s debounce (registry `DisplayVersion` as fallback while the file is locked) and
|
||||
posts the typed `RealmJoinAutoUpdateDetected` signal plus the
|
||||
`realmjoin_autoupdate_detected` timeline event when the version differs from the
|
||||
baseline. The reducer overrides `RealmJoinFacts.ProductVersion` / `ReleaseChannel`
|
||||
(`WithVersionOverride` — the one legitimate later write past the set-once helpers);
|
||||
it never touches the gate, the deadline, or `LastActivityUtc`. An unreadable
|
||||
baseline at detection is seeded silently by the first successful re-read.
|
||||
A closed gate also outranks the 30-minute `AdvisoryCompletion` backstop: that
|
||||
handler re-arms (trigger suffix `:CompletionGateHolding`) instead of failing while
|
||||
the RealmJoin gate is closed *and* its own deadline is still armed, because the gate
|
||||
carries a bounded resolution of its own. Before this, the shorter timer always won —
|
||||
session 75d6ae8e was failed 2 min after its 25th successful RealmJoin package.
|
||||
2. `Finalizing` stage + ~5 s `FinalizingGrace` deadline.
|
||||
3. Grace fires → `Completed`, `EnrollmentComplete` outcome, `enrollment_complete`
|
||||
timeline event with full audit payload.
|
||||
@@ -194,6 +222,15 @@ IME false-positive AccountSetup entries.
|
||||
- **No silent parking**: a non-terminal post-AccountSetup session must always hold a
|
||||
resolution-capable deadline; a 10-min dwell tripwire emits
|
||||
`session_parked_without_deadline`.
|
||||
- **Every payload-derived string stored into state is bounded** (`FactStringBounds`,
|
||||
256 chars): RealmJoin package id/version/scope (scope normalised to `machine`/`user`),
|
||||
failed app ids, `HelloOutcome`, `ImeMatchedPatternId`, `EspAdvisoryFailureCategory`,
|
||||
RealmJoin product version/channel. The bound is applied *before* any dedupe comparison
|
||||
so a forged multi-hundred-KB key neither costs a full-length compare per stored row nor
|
||||
lands in snapshots/audit trails. It never truncates real telemetry — the sources are
|
||||
registry key names (Windows caps them at 255), GUIDs and enum-like strings. The same
|
||||
engine replays device-uploaded signals server-side (`ReducerVerifier`), which is why
|
||||
the bound lives in the reducer, not in the agent adapter alone.
|
||||
|
||||
# Hosting & threading (inside the agent)
|
||||
|
||||
@@ -202,7 +239,12 @@ Wired by `EnrollmentOrchestrator.Start` (`src/Agent/AutopilotMonitor.Agent.V2.Co
|
||||
- `SignalIngress` — bounded queue (256, back-pressure) + **one** worker thread. Per
|
||||
signal, in order: assign ordinals → `SignalLog.Append` (durable, *before* reducing;
|
||||
append failure ⇒ reducer does not run, no ordinal gap) → `engine.Reduce` →
|
||||
`DecisionStepProcessor.ApplyStep`.
|
||||
`DecisionStepProcessor.ApplyStep`. A `Post` issued *by the worker thread itself*
|
||||
(the `EffectRunner` posting `ClassifierVerdictIssued` from inside `ApplyStep`) never
|
||||
enters the bounded queue: it goes to a worker-local follow-up queue that is drained,
|
||||
ahead of the channel, right after the current item — the worker is the queue's only
|
||||
consumer, so blocking on a full channel there would freeze the pipeline for the rest
|
||||
of the run.
|
||||
- `DecisionStepProcessor.ApplyStep` (same thread, no locks): journal append (the only
|
||||
hard-throwing path; 3 consecutive failures escalate to quarantine) → effects run
|
||||
synchronously via `EffectRunner` → best-effort snapshot → advance `CurrentState` →
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
type: concept
|
||||
title: Diagnostics Package
|
||||
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, caps, and the package manifest.
|
||||
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, the handle-validated reads that close the enumerate-then-open junction race, caps, and the package manifest.
|
||||
resource: agent
|
||||
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal]
|
||||
timestamp: 2026-08-23
|
||||
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal, security]
|
||||
timestamp: 2026-08-30
|
||||
---
|
||||
|
||||
# Schema
|
||||
@@ -90,10 +90,50 @@ validates **admin-typed** paths: allowlisted prefixes, wildcard only in the last
|
||||
`C:\Users` blocked except the token's `AppData\Local|Roaming`. The catalog is reviewed code,
|
||||
exactly like the five sections that never went through the guard before — and
|
||||
`%ProgramData%\RealmJoin` is not on the allowlist at all. What does apply to every section:
|
||||
the reparse-point skip (files and directories), the `.evtx` channel export, and the caps
|
||||
the handle-validated read (next section), the `.evtx` channel export, and the caps
|
||||
(100 MB per file, 500 MB total, 5000 files) tracked globally across sections by
|
||||
`BudgetTracker`, with every skip recorded in the manifest and `_TRUNCATED.txt`.
|
||||
|
||||
## Reads are validated on the handle, not the path
|
||||
|
||||
Every guard above judges a path string. Reading the bytes from that string later —
|
||||
`File.GetAttributes` on the candidate, then a `FileStream` open after earlier files have been
|
||||
compressed — leaves a window: under any folder a local user can write to (the user-profile
|
||||
token paths, `ProgramData` subtrees, `C:\Install\Log`) that user can turn a subdirectory into
|
||||
a junction in between. An attribute check on the file resolves through a junction in a
|
||||
*parent* component, and SYSTEM would copy the junction target into the package.
|
||||
`PinnedSourceFolder` (`Monitoring/Runtime/PinnedSourceFolder.cs`, Win32 declarations in
|
||||
`Monitoring/Interop/FileNativeMethods.cs`) closes the window by deciding on the handle that
|
||||
is read:
|
||||
|
||||
* `AddLogFiles` pins the section folder once. It is opened with
|
||||
`FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS`, must not be a reparse point
|
||||
itself, and its `GetFinalPathNameByHandle` result — every reparse point resolved — must
|
||||
equal the validated lexical path (8.3 names expanded through `GetLongPathName`, so a
|
||||
`%TEMP%`-style short name is not mistaken for a junction). Anything else skips the section
|
||||
with `FOLDER REJECTED (<reason>)`; a missing folder stays `FOLDER MISSING`. The handle is
|
||||
held, with `FILE_LIST_DIRECTORY` access and no delete share, until the section is done:
|
||||
NTFS refuses to rename or delete a directory while it or anything beneath it is open, so
|
||||
the validated chain cannot be swapped underneath the build either.
|
||||
* Every candidate file is opened by `PinnedSourceFolder.TryOpenFile` with
|
||||
`FILE_FLAG_OPEN_REPARSE_POINT` — a symlink at the final component is opened as itself and
|
||||
refused (`SKIPPED (reparse point)`) — and the handle's final path must be exactly
|
||||
`<canonical folder>\<relative path>`. A subdirectory that became a junction after
|
||||
enumeration resolves elsewhere: `SKIPPED (resolved outside validated folder)`, naming only
|
||||
the requested path in the manifest, never the resolved one. The length charged against the
|
||||
caps and the stream copied into the archive come from that same handle; any other open
|
||||
failure is `FAILED (open): <path>: <reason>`.
|
||||
* `CollectFilesNoReparseDirs` still skips reparse-point directories while enumerating — as a
|
||||
candidate filter and to keep the walk out of junction loops — but nothing it decides is
|
||||
trusted at copy time.
|
||||
* The `.evtx` channel export is the agent's own file under an unguessable name in SYSTEM's
|
||||
temp folder and is streamed as-is. Hard links are out of scope: Windows refuses to create
|
||||
one to a file the caller cannot write.
|
||||
|
||||
The race itself is pinned by `DiagnosticsPackageServiceTests`: the `BeforeSourceFileOpen`
|
||||
seam swaps a subdirectory for a junction after enumeration and before the open, and the
|
||||
archive must not carry the file.
|
||||
|
||||
## Configured paths: global ∪ tenant
|
||||
|
||||
`GetAgentConfigFunction.MergeDiagnosticsLogPaths` sends global paths (Global Admin,
|
||||
@@ -120,6 +160,8 @@ same-named folder share one ZIP folder.
|
||||
catalog ids; `sectionFolderOverrides` covers any other section; `devicePreparationProbe`
|
||||
replaces the registry read (seam callers default to "not WDP", so existing rigs never touch
|
||||
the registry). `UserProfileResolver.SetForTesting(path|null)` / `Reset()` drive the token.
|
||||
`BeforeSourceFileOpen` fires with each candidate path after enumeration and before its open
|
||||
— the point at which the junction-race test swaps the directory.
|
||||
* Catalog invariants live in the backend test project (`DiagnosticsBuiltInSectionsTests`),
|
||||
because Shared has no test project.
|
||||
|
||||
@@ -139,9 +181,11 @@ entry is removed.
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsBuiltInSections.cs` — the catalog
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, caps, evtx export
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, `AddLogFiles` / `AddLogFile` / `OpenSource`, caps, evtx export
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/PinnedSourceFolder.cs` — pinned folder handle, `TryOpenFile`, `SourceFile`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/FileNativeMethods.cs` — `CreateFileW`, `GetFileInformationByHandle`, `GetFinalPathNameByHandleW`, `GetLongPathNameW`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs` — `EnableRealmJoinWatcher` mirror
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs` — `GET diagnostics/paths`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs` — `MergeDiagnosticsLogPaths`
|
||||
* `src/Web/autopilot-monitor-web/components/diagnostics/` — `DiagnosticsPathRow`, `BuiltInSectionsList`, `builtInSectionDisplay`
|
||||
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
|
||||
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure, junction swapped in after enumeration, junction as section folder), `PinnedSourceFolderTests` (final-path equality, short names, junction in the chain, symlink at the final component, rename refused while pinned), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
type: concept
|
||||
title: IME Log Tracker — Anchored Patterns and the Bounded Read Loop
|
||||
description: Why every shipped IME log pattern starts with '^', why the on-device matcher reads lines through a byte-bounded reader with a per-line matching budget, and what an operator sees when a hostile log file is tailed — the IME Logs folder is writable by standard users and the SYSTEM tracker must stay linear on whatever lands there.
|
||||
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime
|
||||
tags: [agent, ime, regex, redos, log-tracker, reliability, security]
|
||||
timestamp: 2026-08-30
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
`ImeLogTracker` tails every file in `%ProgramData%\Microsoft\IntuneManagementExtension\Logs`
|
||||
whose name matches the IME log families and matches each entry against every active pattern of
|
||||
the shipped pack (`rules/ime-log-patterns/*.json` → `rules/dist/ime-log-patterns.json`, embedded
|
||||
in agent and backend, delivered live through the config channel). That folder is writable by a
|
||||
standard user, so the input is untrusted: anyone signed in during enrollment can append lines.
|
||||
|
||||
Before 2026-08-30 three things multiplied:
|
||||
|
||||
* **Unanchored patterns with unbounded lazy gaps.** `\[Win32App\]\[EspManager\] … registered.*?App name: (?<name>.*?)$`
|
||||
re-scans its `.*?` tail at every offset where the literal prefix occurs; repeating the prefix
|
||||
*m* times in one line of *n* chars costs O(m·n). `IME-APP-VERSION` chained two lazy scans
|
||||
(`.*?"DetectedIdentityVersion":\{[^}]*?"NewValue"`) and was quadratic even when anchored.
|
||||
* **Per-pattern timeout, no per-line budget.** Each regex had a 1 s timeout, the timeout was
|
||||
swallowed at Debug, and the loop tried all ~80 active patterns for every line — one crafted
|
||||
line concatenating several trigger prefixes cost several seconds of SYSTEM CPU.
|
||||
* **Unbounded line length.** `StreamReader.ReadLineAsync` materializes the whole line before
|
||||
anyone can look at its size; a multi-hundred-MB line is an OutOfMemory in the poll loop, which
|
||||
retries from the same bookmark every second.
|
||||
|
||||
Genuine IME decision signals (app failures, enforcement states, ESP phases, user-session
|
||||
completion) queue behind that work — a monitoring blackout during the enrollment window.
|
||||
|
||||
# Design
|
||||
|
||||
## Anchored, linear pattern pack
|
||||
|
||||
* **Every shipped pattern starts with `^`.** The tracker compiles with
|
||||
`IgnoreCase | Compiled | Singleline` and no `Multiline`, so `^` is the start of the matched
|
||||
string — `CmTraceLogEntry.Message`, i.e. the text after `<![LOG[` (for multiline entries the
|
||||
assembled entry, still offset 0). .NET treats a leading `^` as a beginning anchor: a
|
||||
non-matching pattern costs O(prefix), never a scan. Evidence for the invariant: across eight
|
||||
real diagnostics sets (~9,000 entries, all six log families) every match of the old pack began
|
||||
at offset 0 — except one class of mid-line hits that were false positives (below).
|
||||
* **No chained unbounded scans.** `IME-APP-VERSION` now spells the report-delta JSON out
|
||||
(`\{(?:"OldValue":[^,}]{0,64},)?"NewValue":"`) instead of scanning `[^}]*?` at every
|
||||
occurrence. The test `ImeLogPatternPackLinearityTests` rejects `[^x]*` shapes outright.
|
||||
* **Raw lines.** A line that fails CMTrace parsing (orphaned continuation lines, lines after a
|
||||
dropped entry) is still matched raw; with `^` it matches only if it *begins* with the pattern.
|
||||
Field data: the unanchored `PS-SCRIPT-CONTEXT` fired 122 times on
|
||||
`[Win32App] SideCarScriptDetectionManager Launch powershell executor in machine session`
|
||||
(Win32 detection scripts in AppWorkload.log), each time overwriting the pending *platform*
|
||||
script's `RunContext`. Anchoring removed that contamination; it is asserted by test.
|
||||
* **Custom patterns are not rewritten.** Tenant-authored patterns (and cached pre-anchor
|
||||
configs) keep their semantics; `CompilePatterns` names the enabled patterns without `^` once
|
||||
at Info so a pass summary's budget breaks can be attributed.
|
||||
|
||||
## Bounded read loop (`CheckLogFilesAsync`)
|
||||
|
||||
* **`BoundedLineReader`** replaces `StreamReader`: byte-oriented, splits on `\n`, strips one
|
||||
`\r`, decodes UTF-8 per line, honours a BOM only at absolute file offset 0. A line beyond
|
||||
`MaxEntryBytes` (32 MB) is never materialized — the reader keeps the capped prefix, discards
|
||||
the rest while scanning for the terminator and flags `LastLineTruncated`. Its `Position` is
|
||||
the exact offset of the next unread byte, so bookmarks are exact (the StreamReader's decoder
|
||||
read-ahead used to lose up to a buffer of lines on a cancelled pass).
|
||||
* **Why 32 MB.** Real logs carry single lines of ~110 KB routinely
|
||||
(`IntuneManagementExtension.log` policy dumps) and one large tenant's `AppWorkload.log`
|
||||
writes a 5.6 MB `Get policies = […]` line that `IME-POLICIES` legitimately captures (app
|
||||
names for the dashboard). A "few KB" cap would drop genuine entries; with anchored patterns
|
||||
the CPU cost of a large line is O(n) for the handful of patterns whose prefix matches, so the
|
||||
cap only has to bound memory. The same constant caps an assembled multiline entry.
|
||||
* **Oversized lines are dropped, not matched.** Captures on a cut line would be wrong. If the
|
||||
capped prefix opens a CMTrace entry without closing it, the existing `skippingDroppedEntry`
|
||||
mechanism skips the entry's continuation lines instead of raw-matching them; a line that
|
||||
opens a *new* entry always ends the skip and is processed itself.
|
||||
* **Per-line matching budget.** `MatchLine` runs all active patterns under a 2 s budget that
|
||||
sums only the time spent inside `Regex.Match` on that line; a pattern that times out is
|
||||
skipped and counted, and once the budget is spent the remaining patterns are skipped for that
|
||||
line (matches already handled stand). No genuine line comes near it — the budget exists for
|
||||
unanchored custom patterns and hostile input. It is deliberately not the wall-clock across
|
||||
the pattern loop: that window also holds the match handlers (signal ingress, match-log
|
||||
append) and scheduler stalls, and session 946ccbd6 (2026-08-30, Hyper-V guest with 5–10 s
|
||||
process-wide stalls, 1 MB log, no line over 5 KB) broke the wall-clock budget twice on
|
||||
genuine lines and skipped their remaining patterns.
|
||||
* **Held-back tails.** An unterminated tail at EOF (a physical line without `\n`, or a
|
||||
multiline entry without `]LOG]!>`) is normally the writer mid-write. Instead of dropping it
|
||||
(and raw-matching its remainder next pass) the bookmark stays at the entry start. The hold
|
||||
lasts until the entry completes or the file has stood still for `HeldTailSettle` (1 s), and
|
||||
only for tails up to 1 MB — a never-closing hostile tail costs a handful of re-reads, then is
|
||||
processed as-is and the bookmark advances.
|
||||
* **One Warning per pass and file.** `oversizedLines`, `regexTimeouts`, `lineBudgetBreaks` and
|
||||
the first skipped pattern ID are logged in a single line after the pass when any is non-zero
|
||||
(fail-soft with a trace); the first timeout per pattern per process additionally names the
|
||||
pattern at Debug. No per-line logging, so a hostile file cannot flood the agent log.
|
||||
* **Deliberately not done: per-file preemption.** The first pass over a legitimate
|
||||
`AppWorkload.log` can be hundreds of MB and runs in one pass by design; after the changes
|
||||
above the work per byte is linear and small, so a writer can only delay processing in
|
||||
proportion to the bytes it writes.
|
||||
|
||||
## Pack hygiene against the decompiles (2026-08-30)
|
||||
|
||||
Every shipped pattern's distinctive phrase was checked in the IME decompiles 1.97.107.0,
|
||||
1.104.102.0 and 1.105.103.0 (`ime-decompiles` repo): no wording changed in any of them. The 11
|
||||
disabled legacy patterns whose literals last existed in IME 1.50 (fleet minimum 1.97) were
|
||||
deleted rather than left as re-enable traps; `IME-DO-TIMEOUT-1/2` now match the exact source
|
||||
literal (`[Win32App DO] DO downloading is not finished within timeout, jobId = …, fileId = …`)
|
||||
instead of two lookaheads; `IME-DO-TEL` has no emitter from 1.104 on and stays only while
|
||||
1.101/1.103 devices are in the fleet (`get_ime_version_history`).
|
||||
|
||||
## Observability (2026-08-30)
|
||||
|
||||
Everything the loop skips is counted (`ImeTrackerHealth`: lines read, entries matched, oversized
|
||||
lines, regex timeouts, budget breaks, held tails, unanchored patterns, files tailed, backlog
|
||||
bytes) and, together with a per-pattern match histogram, persisted in `ImeTrackerState`. The
|
||||
periodic `agent_metrics_snapshot` carries the counters as `ime_*` keys, the first pass that
|
||||
skipped work raises the one-shot `ime_tracker_degraded` Warning (immediate upload), and the
|
||||
termination handler emits `ime_pattern_hits` — the input of the backend's pattern-drift loop
|
||||
([IME Pattern Health](../backend/ime-pattern-health.md)).
|
||||
|
||||
# Verification
|
||||
|
||||
* `ImeLogPatternPackLinearityTests` loads the shipped pack, asserts the `^` invariant, runs four
|
||||
hostile constructions (repeated prefix, the reported `"DetectedIdentityVersion":{x`
|
||||
repetition, long word runs, prefix+keywords with newlines; 512 KB each) against every pattern
|
||||
with the agent's exact regex options and fails on a timeout or > 150 ms (best of three),
|
||||
and re-matches representative real lines with their captures.
|
||||
* `BoundedLineReaderTests`, `ImeLogTrackerReadLoopHardeningTests` (oversized single line,
|
||||
oversized multiline opener, held-back tail, settle, cancelled pass bookmark, single Warning).
|
||||
* Parity: the old and new packs were run over eight real diagnostics sets with the tracker's
|
||||
assembly semantics; the match sets are identical apart from the removed `PS-SCRIPT-CONTEXT`
|
||||
false positives. `--run-ime-matching` (the agent's offline matcher) now assembles multiline
|
||||
entries like the tracker so it reproduces the same result.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs` — `CheckLogFilesAsync`, `MatchLine`, `HoldBackTail`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/BoundedLineReader.cs`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs` — `MaxEntryBytes`, `HeldTailSettle`, `CompilePatterns`
|
||||
* `rules/ime-log-patterns/*.json`, `rules/scripts/combine.js`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeLogPatternPackLinearityTests.cs`
|
||||
* [CMTrace Time Resolution](cmtrace-time-resolution.md) — the linear CMTrace line parser this loop feeds
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
type: concept
|
||||
title: IME Process Watcher — Identity Before Attach, Re-Arm After Exit
|
||||
description: Why ImeProcessWatcher attaches only to a process that runs in session 0 with its image under the IME install root — a process name is not an identity — and why a reported exit re-arms discovery instead of ending it.
|
||||
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs
|
||||
tags: [agent, ime, process, security, cwe-807, reliability]
|
||||
timestamp: 2026-08-30
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
`ImeProcessWatcher` reports `ime_process_exited` (Warning — read as an IME crash indicator in the
|
||||
timeline) by subscribing to `Process.Exited` of `IntuneManagementExtension.exe`. Until 2026-08-30 it
|
||||
attached to `Process.GetProcessesByName(...)[0]` and stopped discovery for good once attached.
|
||||
|
||||
A process name is not a protected namespace. During AccountSetup (and on Device Preparation flows)
|
||||
a standard-user session is live and can start any binary renamed to `IntuneManagementExtension.exe`.
|
||||
Two consequences (CWE-807 — the same class as the agent instance guard and the local-admin dynamic
|
||||
allowance):
|
||||
|
||||
* **Forged signal** — the fake exits, the SYSTEM agent emits a Warning with the fake's PID/exit
|
||||
code, and failure diagnosis is misdirected.
|
||||
* **Muted signal** — attached to the fake, discovery is stopped; the real service is never watched,
|
||||
and a genuine IME crash later in the enrollment is never reported. Even without an attacker the
|
||||
one-shot design missed every IME exit after the first (an IME service restart is not rare).
|
||||
|
||||
# Schema
|
||||
|
||||
`ImeProcessIdentity` establishes identity by facts a standard user cannot forge:
|
||||
|
||||
| Check | Source | Why it holds |
|
||||
|---|---|---|
|
||||
| `SessionId == 0` | `Process.SessionId` | Only services and their children run in session 0; a user session cannot start a process there. |
|
||||
| image under `%ProgramFiles(x86)%\Microsoft Intune Management Extension\` (also `%ProgramW6432%`/`%ProgramFiles%` variants) | `QueryFullProcessImageName` (cross-bitness — IME is 32-bit, the agent may be 64-bit; `MainModule` throws there), `MainModule.FileName` fallback | Program Files is admin-writable only. Segment-aware prefix (`...Extension\`), `GetFullPath`-normalised, so a sibling folder or `..` does not pass. |
|
||||
| file name exactly `IntuneManagementExtension.exe` | image path | `GetProcessesByName` matches the name without extension. |
|
||||
|
||||
Unknown facts (session `-1`, unresolved path) are **untrusted** — a false negative only delays
|
||||
attach by one 5 s discovery tick, a false positive mutes the signal for the session.
|
||||
|
||||
Among trusted candidates the **oldest** by `StartTime` wins (the SCM-started service predates
|
||||
anything transient). Untrusted name matches are logged once per PID as a Warning in the agent log
|
||||
(`ignoring untrusted IntuneManagementExtension.exe candidate (PID, session, image)`) and never
|
||||
attached; no timeline event is raised for them.
|
||||
|
||||
**Re-arm:** `OnImeExited` detaches, emits the event, and restarts the discovery timer. Only the
|
||||
currently attached `Process` object is honoured (`ReferenceEquals`), so a late `Exited` from a
|
||||
replaced or disposed handle cannot produce a duplicate event. A process that exits between probe
|
||||
and subscription is handled by an explicit `HasExited` check after subscribing.
|
||||
|
||||
The watcher owns the `Process` handles its source returns (like `GetProcessesByName`) and disposes
|
||||
them — tests must hand it fresh handles (`Process.GetProcessById`), not the ones they hold.
|
||||
|
||||
# Examples
|
||||
|
||||
* Fake started in session 1 from `Downloads\IntuneManagementExtension.exe` before the service is up →
|
||||
never attached; its exit emits nothing; the service arriving later is attached normally.
|
||||
* Service exits (crash or restart) → `ime_process_exited` (PID, exit code, uptime), discovery
|
||||
re-armed, the restarted service is attached on the next tick and its later exit is reported too.
|
||||
* Fake and service both running, fake listed first → the service is attached.
|
||||
|
||||
Covered by `ImeProcessWatcherIdentityTests` (pure identity table + stand-in `cmd.exe` processes
|
||||
with injected identity facts).
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessIdentity.cs`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeProcessWatcherIdentityTests.cs`
|
||||
* Related: [Local Admin Analyzer](local-admin-analyzer.md) (CWE-807 dynamic allowance).
|
||||
@@ -7,6 +7,7 @@ How the on-device agent works:
|
||||
* [Logs, Persistence & Crash Recovery](logs-and-persistence.md) - every persisted file and why it exists; signal log vs spool; snapshot/journal; the replay-based recovery flow and quarantine.
|
||||
* [Diagnostics Package](diagnostics-package.md) - what the diagnostics ZIP contains: the built-in section catalog shared with the portal (Shared `DiagnosticsBuiltInSections`, served by GET diagnostics/paths), the RealmJoin Watcher and Device Preparation collection gates, why built-ins bypass the configured-path guards, global ∪ tenant path dedup, caps and the package manifest.
|
||||
* [Hello Wizard Un-Skip](hello-wizard-unskip.md) - why a single "WHfB disabled" policy read never decides completion: the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read.
|
||||
* [Zero User-Targeted Apps](zero-user-apps-completion-gate.md) - why a user with no targeted Intune apps starved every user-phase completion path (IME never writes its "Completed user session" line in the zero-app branch), and why a completion gate that is still holding the session must outrank the 30-minute backstop.
|
||||
* [Autopilot ZTD Diagnostics](autopilot-ztd-diagnostics.md) - Windows' own diagnostic surfaces for the profile-download flow: ModernDeployment event IDs (807/815/164/...), the Diagnostics\Autopilot registry key, deployment-service endpoints, and the known-issue error-code map.
|
||||
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated 2800 observations (neutral, flush-time watermark) + ANALYZE-ESP-005 gated on an actually-observed reboot.
|
||||
* [Power-State Watcher](power-state-watcher.md) - live AC↔battery transitions and 50/30/15% downward threshold crossings via a WMI push subscription (no polling): power_state_change semantics (latch/debounce/cap, never armed without a battery) and the single-condition analyze rules ANALYZE-DEV-009/-010 on top.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Local Admin Analyzer — account inventory, dormant accounts and Administrators membership
|
||||
description: What the local_admin_analysis event asserts about a device at enrollment start and completion, why disabled accounts are inventoried (a /active:no backdoor re-enabled after enrollment is dormant at both scans), how Administrators-group membership is read from the SAM by well-known SID (NetLocalGroupGetMembers, independent of WMI and account state), why built-ins are matched by well-known RID (localized Guest/Administrator), why the shutdown-time dynamic allowance never covers a local SAM account (CWE-807 — the login state is attacker-controlled), which folders are not profiles, which members are scored versus only listed, and why the built-in Administrator's enabled state is reported but not scored.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs
|
||||
tags:
|
||||
- agent
|
||||
- security
|
||||
- local-admin
|
||||
- analyze-rules
|
||||
- bypass-detection
|
||||
timestamp: 2026-08-30T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Local Admin Analyzer
|
||||
|
||||
`LocalAdminAnalyzer` emits one `local_admin_analysis` event at agent startup and one at
|
||||
shutdown. The pair is a before/after inventory of local accounts on the device; the backend
|
||||
rule `ANALYZE-ID-002` alerts when the finding is `unexpected_local_admins_detected`.
|
||||
The threat model is the classic Autopilot bypass: a person with a Shift+F10 SYSTEM console
|
||||
during OOBE/ESP who creates a persistent local administrator.
|
||||
|
||||
# Schema
|
||||
|
||||
## Checks
|
||||
|
||||
| Check | Source | What it yields |
|
||||
|---|---|---|
|
||||
| `bypass_nro` | `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE\BypassNRO` | value + `flagged` |
|
||||
| Local accounts | WMI `Win32_UserAccount WHERE LocalAccount = True` (Name, Disabled, SID) | `accounts_checked`, `account_details` (name / disabled / administrators_member), `unexpected_accounts`, `builtin_administrator_enabled` |
|
||||
| Administrators membership | `NetLocalGroupGetMembers` level 2 on the group resolved from `S-1-5-32-544` (`LocalGroupNativeMethods`) | `administrators_group.{enumerated,error_code,members}`, `unexpected_admin_members` |
|
||||
| Profile folders | `C:\Users` top-level directories, junctions and `$<32 hex>` folders skipped | `profiles_found`, `unexpected_profiles` |
|
||||
|
||||
## Allowed list
|
||||
|
||||
Built-in names (`Administrator`, `Guest`, `DefaultAccount`, `WDAGUtilityAccount`,
|
||||
`defaultuser0/1/2`, `kioskUser0` — the Assigned Access autologon account of kiosk profiles —
|
||||
and the non-account folders `Public`, `Default`, `Default User`, `All Users`) plus the
|
||||
tenant's *Allowed Local Accounts* (glob `*`/`?`, case-insensitive).
|
||||
`defaultuser1/2` stay allowlisted deliberately: they do appear during OOBE enrollment.
|
||||
|
||||
**Well-known RIDs.** Built-in accounts are additionally recognised by RID on the machine SID
|
||||
(`S-1-5-21-…-500` Administrator, `-501` Guest, `-503` DefaultAccount, `-504`
|
||||
WDAGUtilityAccount), for the WMI inventory and for the SAM-member fallback alike. Localized
|
||||
installs name them `Gast`, `Invité`, `Гость`, `Administrateur`, … — disabled by default and
|
||||
therefore invisible before disabled accounts were inventoried; without the RID match every
|
||||
non-English device would flag its own Guest. Name matching is unchanged (an ordinary-RID
|
||||
account named `Gast` is still unexpected).
|
||||
|
||||
## Dynamic allowance at shutdown
|
||||
|
||||
The shutdown run adds the logged-in users (explorer.exe owners outside session 0) to the
|
||||
allowed list — but **only names that are not a local SAM account**. The WMI inventory is read
|
||||
first; a logged-in name found in it is rejected for both the account and the profile check
|
||||
and reported as `logged_in_local_accounts` (Warning in the agent log).
|
||||
|
||||
Why this is safe for false positives: in real sessions the logged-in identity is an Entra or
|
||||
domain user (user-driven, hybrid, or an Entra user signing in to a self-deploying device
|
||||
after ESP). Those never appear in `Win32_UserAccount`; the allowance only ever mattered for
|
||||
their `C:\Users` folder — and `unexpected_profiles` alone scores 0. A logged-in *local*
|
||||
account is the one case the allowance changed the score, and that is the attack: an account
|
||||
created after the startup scan that signs itself in before the shutdown scan (CWE-807 —
|
||||
exempting on a condition the adversary controls). Kiosk autologon (`kioskUser0`) is covered
|
||||
by the built-in list instead.
|
||||
|
||||
## Profile folders that are not profiles
|
||||
|
||||
Junctions (`All Users`, `Default User` and localized variants such as `Все пользователи`,
|
||||
all pointing at ProgramData / Default) and `$<32 hex>` folders are skipped entirely — neither
|
||||
can be the profile of a local account (a SAM account name is at most 20 characters), so they
|
||||
can never contribute to the profile-overlap score; listing them was payload noise.
|
||||
|
||||
## Evaluation (`EvaluateAccounts`, pure)
|
||||
|
||||
* Every local account is evaluated, **disabled ones included**; the disabled state is reported
|
||||
per account instead of excluding the account.
|
||||
* An account is an Administrators member when its SID matches a group member, or — SID
|
||||
unavailable — its name matches a member of the local machine domain.
|
||||
* `unexpected_accounts` = accounts not matching the allowed list (any state).
|
||||
* `unexpected_admin_members` = unexpected accounts holding Administrators membership, plus
|
||||
local-domain members not on the allowed list that WMI did not return at all (WMI failure
|
||||
or provider gap — the SAM is authoritative for membership).
|
||||
* Members outside the machine domain (Entra role SIDs such as `S-1-12-1-…` on Entra-joined
|
||||
devices, domain groups on hybrid devices, unresolved/deleted SIDs) are **listed for delta
|
||||
comparison but never scored** — they are expected on joined devices.
|
||||
* `builtin_administrator_enabled` = `!Disabled` of the RID-500 account (null when absent).
|
||||
Reported, not scored: whether OOBE enables it transiently is not established; the field
|
||||
collects evidence first.
|
||||
|
||||
## Scoring
|
||||
|
||||
| Signal | Weight |
|
||||
|---|---|
|
||||
| `BypassNRO = 1` | +20 |
|
||||
| any `unexpected_accounts` | +40 |
|
||||
| any `unexpected_admin_members` | +40 |
|
||||
| unexpected account with a matching `C:\Users` folder | +40 |
|
||||
|
||||
Capped at 100. `0` → Info `no_unexpected_admins_detected`; `<40` → Info
|
||||
`bypass_nro_flag_only`; `40–79` → Warning, `≥80` → Error, both
|
||||
`unexpected_local_admins_detected`. A dormant backdoor with admin membership therefore lands
|
||||
at 80 (Error) without ever having a profile folder.
|
||||
|
||||
## Failure behaviour
|
||||
|
||||
WMI and the SAM read are independent; either failing is logged at Warning with the native
|
||||
status (`NET_API_STATUS=…`) and the other source still evaluates. The event carries
|
||||
`administrators_group.enumerated=false` + `error_code` so a "no members" result is never
|
||||
mistaken for an empty group.
|
||||
|
||||
# Examples
|
||||
|
||||
`net user backdoor P@ss /add /active:no` + `net localgroup Administrators backdoor /add`
|
||||
during OOBE, re-enabled by a scheduled task after enrollment: at both scans the account is
|
||||
disabled; before this change it was skipped entirely. Now `unexpected_accounts=[backdoor]`,
|
||||
`unexpected_admin_members=[backdoor]`, `account_details[backdoor].disabled=true` →
|
||||
confidence 80, Error.
|
||||
|
||||
`net user backdoor P@ss /add` + `net localgroup Administrators backdoor /add` during ESP,
|
||||
then an interactive sign-in as `backdoor` (fast user switching) before enrollment completes:
|
||||
the shutdown scan finds `backdoor` in the SAM inventory → not allowed dynamically →
|
||||
`unexpected_accounts=[backdoor]`, `unexpected_admin_members=[backdoor]`, profile overlap →
|
||||
confidence 100, Error, `logged_in_local_accounts=[backdoor]`. Before this change the login
|
||||
alone silenced the finding to 0.
|
||||
|
||||
German device, tenant allowed list empty: `Gast` (RID 501, disabled) and `Administrator`
|
||||
are inventoried and matched by RID → `unexpected_accounts=[]`, confidence 0.
|
||||
|
||||
`net localgroup Administrators adm-helpdesk /add` where `adm-*` is allowlisted: not flagged
|
||||
(the allowed list is the tenant's statement of intent), but `administrators_group.members`
|
||||
shows the new membership in the shutdown event for delta review.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/LocalGroupNativeMethods.cs`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerEvaluateAccountsTests.cs`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerDynamicAllowanceTests.cs`
|
||||
* `rules/analyze/ANALYZE-ID-002.json`
|
||||
@@ -109,7 +109,9 @@ telemetry directly:
|
||||
```
|
||||
Collector callback
|
||||
→ SignalAdapter (dual emission: decision signal + InformationalEvent)
|
||||
→ SignalIngress.Post() (bounded queue 256, back-pressure blocks producer)
|
||||
→ SignalIngress.Post() (bounded queue 256, back-pressure blocks producer;
|
||||
posts from the worker thread itself bypass the
|
||||
queue via a worker-local follow-up queue)
|
||||
→ single worker thread:
|
||||
assign ordinals → SignalLog.Append (durable) ← source of truth
|
||||
→ DecisionEngine.Reduce → apply step/effects
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
type: Concept
|
||||
title: WiFi SSID and the Windows 24H2 Location Gate
|
||||
description: Why wifi_signal_info silently disappeared on Windows 11 24H2+ devices, why the netsh fallback could never fix it, and the tiered reader that restores the SSID from the SYSTEM context.
|
||||
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs
|
||||
tags:
|
||||
- agent
|
||||
- interop
|
||||
- wifi
|
||||
- telemetry
|
||||
timestamp: 2026-08-26T14:05:00+02:00
|
||||
---
|
||||
|
||||
# Summary
|
||||
|
||||
The agent reports the enrollment WiFi as a `wifi_signal_info` event (SSID, signal, PHY, channel).
|
||||
On Windows 11 24H2 and later the event went missing on a large share of devices — no error, no
|
||||
partial payload, simply no event. The cause is a Windows privacy change, not a bug in the reader:
|
||||
**`WlanQueryInterface` with `wlan_intf_opcode_current_connection` is gated behind precise-location
|
||||
consent**, and the agent runs as SYSTEM in session 0 where that consent usually does not exist.
|
||||
|
||||
The fix is a tiered reader ([WifiInfoProvider](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs))
|
||||
whose middle tier uses the one SSID API Microsoft left ungated.
|
||||
|
||||
# The Gate
|
||||
|
||||
Microsoft restricted the Win32 WLAN APIs that expose BSSIDs, because nearby-BSSID lists are a
|
||||
precise-location oracle. Without consent these return `ERROR_ACCESS_DENIED` (5):
|
||||
|
||||
* `WlanGetAvailableNetworkList`
|
||||
* `WlanGetNetworkBssList`
|
||||
* `WlanScan`
|
||||
* **`WlanQueryInterface` when `OpCode` is `wlan_intf_opcode_current_connection`** — the only opcode
|
||||
the agent needs, and the one that carries SSID, signal quality, PHY type.
|
||||
|
||||
Two properties of the gate decide the agent's fate:
|
||||
|
||||
1. **The consent prompt only appears for a process running in the user's context and outside
|
||||
`C:\Windows\System32`.** The agent is neither during ESP: it is SYSTEM, in session 0, before any
|
||||
user has seen the OOBE privacy page. It is therefore denied *silently* — no prompt, no event
|
||||
log, nothing that surfaces without asking the API for its return code.
|
||||
2. **Consent resolves per user hive, with the device-level setting as the floor.** An interactive
|
||||
user typically has
|
||||
`HKCU\...\CapabilityAccessManager\ConsentStore\location\NonPackaged\Value = Allow`
|
||||
("Let desktop apps access your location"). The SYSTEM account (`HKU\S-1-5-18`) normally has
|
||||
**nothing** set there and falls back to
|
||||
`HKLM\...\ConsentStore\location\Value`. Where that device-level value is `Deny`, SYSTEM is
|
||||
denied even though the logged-on user on the very same machine reads the SSID fine.
|
||||
|
||||
That asymmetry is why the failure looks random from the outside: it tracks the **image's
|
||||
device-level location setting**, which varies per fleet, not per hardware, driver, or language.
|
||||
|
||||
# Why netsh Was Never the Answer
|
||||
|
||||
`NetshWifiFallback` was added in 2026-07 on the theory that inbox `netsh` still reported the
|
||||
connection where the WLAN API did not, and it was later extended with localized label variants for
|
||||
every enrollment-geography language. Field data says otherwise: **`netsh wlan` hangs off the same
|
||||
location gate** ("Network shell commands need location permission to access WLAN information"), so
|
||||
it fails in lockstep with tier 1 rather than covering for it. In the reported session the agent
|
||||
build already contained the netsh fallback and still produced no event.
|
||||
|
||||
Consequence for future work: **adding more netsh language labels cannot fix a missing SSID.** Every
|
||||
`wifi_signal_info` event that does exist carries a fully populated SSID — the failure is binary at
|
||||
the reader level, never a half-parsed or mis-decoded payload. A missing SSID is an access problem,
|
||||
not a parsing problem.
|
||||
|
||||
# The Tiered Reader
|
||||
|
||||
`WifiInfoProvider.TryRead` owns the order so the two collectors (`DeviceInfoCollector`,
|
||||
`NetworkChangeDetector`) cannot drift apart. First non-null wins:
|
||||
|
||||
| # | Reader | Yields | Location-gated |
|
||||
|---|---|---|---|
|
||||
| 1 | `WifiInfoReader` (native `wlanapi.dll`) | SSID, signal %, PHY, channel | yes |
|
||||
| 2 | `WinRtWifiSsidReader` (WinRT `GetConnectedSsid`) | SSID only | **no** |
|
||||
| 3 | `NetshWifiFallback` (process spawn, localized parse) | SSID, signal %, radio type, channel | yes |
|
||||
|
||||
Tier 2 is Microsoft's own documented replacement for this exact scenario:
|
||||
`Windows.Networking.Connectivity.WlanConnectionProfileDetails.GetConnectedSsid()`. It is not part of
|
||||
the BSSID-bearing surface, so it needs no consent, and it has existed since Windows 10 1507.
|
||||
|
||||
It is bound through the CLR's built-in WinRT projection with
|
||||
`Type.GetType("…, ContentType=WindowsRuntime")` — no NuGet package, no winmd reference, no csproj
|
||||
change — the same mechanism [OobeStateReader](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/OobeStateReader.cs)
|
||||
already uses from the SYSTEM service context. Every step degrades to null rather than throwing,
|
||||
because the caller runs fire-and-forget inside `Task.Run`.
|
||||
|
||||
# Telling the Operator
|
||||
|
||||
A missing signal reading is indistinguishable from a bug unless the payload says why, and the
|
||||
device-level location setting is not something an admin would think to check. When tier 2 wins
|
||||
*and* the native tier reported `rc=5`, the `wifi_signal_info` payload therefore carries
|
||||
|
||||
```
|
||||
wifiDataLimitedReason = "location_services_off"
|
||||
```
|
||||
|
||||
and the event message gains `- signal unavailable, Location services off`, so the reason is visible
|
||||
in the timeline itself. The portal's WiFi card (`DeviceDetailsCard`) matches that literal and
|
||||
renders the explanation plus the Intune setting that fixes it. The value is a published contract —
|
||||
agent, portal and the MCP device-property catalog all match the same string.
|
||||
|
||||
The claim is only made on observed evidence: any other native failure (no WLAN service, no
|
||||
connected interface) leaves the field null rather than blaming a privacy setting we did not
|
||||
measure. `WifiInfoProvider.IsLocationGateDenied` is the single predicate, pinned by tests at its
|
||||
boundaries (`rc=5` yes; `rc=50`, `rc=5023`, `rc=1062` no).
|
||||
|
||||
The network timeline reads the same field. It needs no payload of its own: `buildSegments`
|
||||
already merges every `wifi_signal_info` into the WiFi segment covering its timestamp, so the
|
||||
reason rides in with the sample. A segment that never got a sample (a mid-session reconnect emits
|
||||
no `wifi_signal_info`) inherits it — the gate is a device-level setting and cannot flip during an
|
||||
enrollment — while a segment that *did* get a real reading is never overwritten. The segment
|
||||
tooltip then reads `Signal: unavailable - Location services off on this device` where the
|
||||
percentage would be, and the "Avg WiFi signal" stat says `n/a (Location services off)` instead of
|
||||
disappearing.
|
||||
|
||||
`network_state_change` deliberately does NOT carry the reason: that event has never carried
|
||||
`signalPercent` or `radioType` at all (`NetworkChangeDetector` collects them into its snapshot for
|
||||
change detection but only `before_/after_wifiSsid` reaches the payload), so there is no absent
|
||||
field there for a reason to explain. Adding it would ship a field nothing reads. The SSID it does
|
||||
carry is unaffected by the gate.
|
||||
|
||||
Fleet-wide the same field is queryable through
|
||||
`search_sessions(deviceProperties: { "wifi_signal_info.wifiDataLimitedReason": "location_services_off" })`,
|
||||
which sizes how much of a fleet is affected before anyone changes a policy.
|
||||
|
||||
Tier 2 returns the SSID alone. Signal quality, PHY type and channel have no ungated source; they
|
||||
stay null when the native tier is denied. That is deliberate — deriving a percentage from
|
||||
`ConnectionProfile.GetSignalBars()` (0–5) would fabricate precision the API does not have.
|
||||
|
||||
# Diagnostics
|
||||
|
||||
The step-level native return codes were already collected, but they were logged with
|
||||
`_logger.Debug` — and the agent's default log level is `Info`, so the diagnostic built to explain
|
||||
this failure never reached a single field log. Both collectors now log at **Warning** when all tiers
|
||||
fail, which they only reach when the active NIC really is WiFi. A native `rc=5` is spelled out as
|
||||
`ERROR_ACCESS_DENIED` with the location-gate explanation, so the log line diagnoses itself:
|
||||
|
||||
```
|
||||
EnrollmentTracker: no wifi_signal_info — no WiFi info from any reader — native: [<guid>]
|
||||
query(current_connection) rc=5; ; WinRT: no SSID; netsh: no parsable output. rc=5 is
|
||||
ERROR_ACCESS_DENIED — Windows 11 24H2+ gates the current-connection opcode (and netsh wlan)
|
||||
behind precise-location consent, which is off in this image; enable Location services to
|
||||
restore signal/PHY/channel.
|
||||
```
|
||||
|
||||
# Evidence
|
||||
|
||||
Session `7305e0ad` (HP EliteBook X Flip G1i, build 26200.9168, agent 2.0.1407, reported as
|
||||
`WIFI SSID fehlt`): `network_interface_info` shows the active NIC as `Intel(R) Wi-Fi 7 BE201`,
|
||||
`Wireless80211`, `connectionType=WiFi`, so the `isWifi` gate passed and collection ran — yet zero
|
||||
`wifi_signal_info` events across 214 events and 22 minutes, with the netsh fallback already shipped
|
||||
in that build.
|
||||
|
||||
Fleet correlation over WiFi sessions from 2026-08-01 (n=276), share with a `wifi_signal_info` event:
|
||||
|
||||
| OS build | WiFi sessions | with SSID |
|
||||
|---|---|---|
|
||||
| 22631 (23H2, pre-gate) | 3 | 100% |
|
||||
| 26100 (24H2) | 178 | 21% |
|
||||
| 26200 (25H2) | 95 | 45% |
|
||||
|
||||
Within a single model the outcome splits by fleet, not by hardware — ThinkPad T14 Gen 6: 7/91;
|
||||
ThinkPad E14 Gen 7: 9/9 — which is what a per-image privacy setting looks like.
|
||||
|
||||
Local verification (build 26220.9022): `HKLM\…\ConsentStore\location = Deny` while
|
||||
`HKCU\…\ConsentStore\location\NonPackaged = Allow`; the interactive user reads the SSID through
|
||||
both the native API and netsh, `HKU\S-1-5-18` has no consent value at all, and the WinRT chain
|
||||
resolves and returns the SSID under net48.
|
||||
|
||||
# Operator Guidance
|
||||
|
||||
A device that reports no SSID is not misconfigured for enrollment — only the telemetry field is
|
||||
missing. To get the full payload (signal, PHY, channel) back, the **device-level** location setting
|
||||
must be on: *Settings → Privacy & security → Location → Location services*, or via Intune
|
||||
(`Privacy/LetAppsAccessLocation`). Without it the SSID still arrives via tier 2.
|
||||
|
||||
# Citations
|
||||
|
||||
* [Changes to API behavior for Wi-Fi access and location](https://learn.microsoft.com/en-us/windows/win32/nativewifi/wi-fi-access-location-changes)
|
||||
* [WlanConnectionProfileDetails.GetConnectedSsid](https://learn.microsoft.com/en-us/uwp/api/windows.networking.connectivity.wlanconnectionprofiledetails.getconnectedssid)
|
||||
* [WifiInfoProvider.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs)
|
||||
* [WinRtWifiSsidReader.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WinRtWifiSsidReader.cs)
|
||||
* [WifiInfoReader.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoReader.cs)
|
||||
* [NetshWifiFallback.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/NetshWifiFallback.cs)
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Zero User-Targeted Apps — the starved user-phase completion gate
|
||||
description: Why a device with no user-targeted Intune apps could never satisfy the IME user-session gate, how that turned into a 30-minute esp_exit_without_completion_evidence false positive on a third of one tenant's enrollments, and the two fixes — reading the line IME actually writes in its zero-app branch, and refusing to fail a session while a completion gate is deliberately holding it.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime
|
||||
tags:
|
||||
- agent
|
||||
- ime
|
||||
- esp
|
||||
- completion
|
||||
- realmjoin
|
||||
- false-positive
|
||||
timestamp: 2026-08-25
|
||||
---
|
||||
|
||||
# Summary
|
||||
|
||||
A Classic user-driven enrollment whose user has **zero targeted Intune Win32 apps** could not
|
||||
produce the evidence every user-phase completion path required. The session parked, and the
|
||||
30-minute `advisory_completion` backstop resolved it as
|
||||
`enrollment_failed: esp_exit_without_completion_evidence` — while the user was at a working
|
||||
desktop, Windows Hello was provisioned, and RealmJoin was still installing packages.
|
||||
|
||||
One tenant hit this on **10 of 30 enrollments in a week**. Every one of them was a false
|
||||
positive; the devices were healthy in Intune and RealmJoin.
|
||||
|
||||
# The starvation chain
|
||||
|
||||
The IME writes `Completed user session N, userId: …` at the end of a user-session processing
|
||||
pass. `IME-USER-SESSION-COMPLETED` matches it and the adapter posts
|
||||
`ImeUserSessionCompleted`, which sets `DecisionState.ImeUserSessionCompletedUtc`.
|
||||
|
||||
But the IME never reaches that statement when the user has no assigned apps. Decompiled IME
|
||||
`1.104.102.0`, `Win32AppPlugIn/ApplicationPoller.cs`:
|
||||
|
||||
```csharp
|
||||
if (!appPolicies.Any())
|
||||
{
|
||||
AppWorkloadLog.TraceInformation($"[Win32App] Get 0 apps for user session {user.RDSSessionId}, user id = {user.IntuneId}");
|
||||
RegisterApplicationsInIfInESP(sender, user, delegate {
|
||||
espLockInProcessor.AttemptToLockInApplications(user, new List<ProcessorSubgraph>());
|
||||
});
|
||||
continue; // skips the "Completed user session" statement below
|
||||
}
|
||||
```
|
||||
|
||||
Identical shape in `1.97.107.0`, `1.83.107.0`, `1.80.132.0`. With that fact missing, every door
|
||||
out of the user phase is shut at once:
|
||||
|
||||
| Path | Requirement | Why it is dead |
|
||||
|---|---|---|
|
||||
| `ShouldTransitionToAwaitingHello` arm A | `AccountSetupProvisioningSucceededUtc` | ESP registry froze at `AccountSetup: 1 of 5`; neither `categorySucceeded` nor the all-subcategories fallback can ever hold |
|
||||
| arm B | `SkipUserEsp` | Full ESP, not a skip-user flow |
|
||||
| arm C | `IsImeUserSessionGenuine` | needs the missing IME fact |
|
||||
| arm D | `EnrollmentMode.DevicePreparation` | Autopilot v1, not WDP |
|
||||
| `MaybeSynthesizeAccountSetupCompleteFromSettledUserApps` | `AreUserEspAppsSettled` | returned `false` on an empty app list |
|
||||
| `HandleAdvisoryCompletionDeadlineFired` conjunction | `imeUserSessionGenuine \|\| isObservationAdvisory` | both false |
|
||||
|
||||
The last row is the one that produced the verdict: the backstop that exists to resolve a
|
||||
dead-end resolved it as a failure.
|
||||
|
||||
`AreUserEspAppsSettled` deserves its own note. Its "at least one required app" rule is
|
||||
deliberately conservative — an empty list normally means *the phase just cleared* or *apps have
|
||||
not surfaced yet*. But it made **zero apps**, the most settled state possible, indistinguishable
|
||||
from *unknown*, and there was no evidence available to tell them apart.
|
||||
|
||||
# Fix 1 — read the line IME does write
|
||||
|
||||
`IME-USER-SESSION-ZERO-APPS` matches the zero-app line and routes it through the **same**
|
||||
`OnUserSessionCompleted` callback. Downstream this genuinely *is* a completed user session, so
|
||||
the adapter's pending-apps deferral and its fire-once flag apply unchanged.
|
||||
|
||||
This is not a weakened gate — it is IME's own verdict. The zero-app branch locks the ESP in with
|
||||
an *empty* application list, and the sibling emit site in `AppWorkloadAbstraction.ExecuteUserCheckIn`
|
||||
returns `ProviderResult.ProvisioningComplete` straight after logging it. All three emit sites are
|
||||
user-session-scoped (`user.RDSSessionId`, `user.IntuneId`); none is device-scoped.
|
||||
|
||||
Guards that stay in force:
|
||||
|
||||
* **Phase guard** — the observation only fires while the tracker is in `AccountSetup`. Firing
|
||||
earlier would burn the adapter's fire-once flag on a pre-sign-in (defaultuser0) timestamp that
|
||||
`IsImeUserSessionGenuine` (`>= AccountSetupEnteredUtc`) can never accept. An observation made
|
||||
before the phase was known is remembered and replayed on the transition into it, because IME
|
||||
writes its check-in lines independently of when the agent first parses the phase marker.
|
||||
* **Engine guards unchanged** — arm C still requires a post-AccountSetup final ESP exit *and* a
|
||||
DAD-validated real-user desktop.
|
||||
* `AreUserEspAppsSettled` returns `true` for an empty list **only** with this explicit
|
||||
observation — never for a merely empty list.
|
||||
|
||||
# Fix 2 — do not fail while a completion gate is holding
|
||||
|
||||
An independent defect, and the decisive one for the affected tenant. `advisory_completion` runs
|
||||
for 30 minutes. The RealmJoin completion gate blocks completion until phase 110 and carries its
|
||||
*own* bounded resolution: 60 minutes from detection, re-armed on deployment activity, hard-capped
|
||||
at 4 hours. The shorter timer always won.
|
||||
|
||||
Session `75d6ae8e` installed **25 RealmJoin packages successfully inside the window**, the last
|
||||
one 2 minutes before the failure, while the engine's own `completion_waiting` still read
|
||||
`waiting on: realmjoin_resolution`.
|
||||
|
||||
`HandleAdvisoryCompletionDeadlineFired` now re-arms instead of failing when the RealmJoin gate is
|
||||
closed *and* its deadline is still armed, emitting `completion_waiting` with trigger
|
||||
`DeadlineFired:advisory_completion:CompletionGateHolding`.
|
||||
|
||||
Convergence comes from the gate itself: `Resolved`, `FirstDeploymentIncomplete` and `Timeout` all
|
||||
set `RealmJoinFacts.Outcome`, which opens `RealmJoinGateOpen` and stops the guard applying.
|
||||
Requiring the gate's deadline to still be armed keeps a gate that can no longer resolve itself
|
||||
from parking the session; `AgentMaxLifetimeMinutes` bounds it regardless.
|
||||
|
||||
# Timeline evidence
|
||||
|
||||
`ime_user_session_completed` carries an `evidence` field so the basis of the verdict is legible
|
||||
without the diagnostics archive:
|
||||
|
||||
* `user_session_completed` — IME wrote its completion line and no required app was pending.
|
||||
* `zero_user_apps_enumerated` — IME reported zero targeted apps; the user phase had nothing to
|
||||
enforce.
|
||||
|
||||
No new event type: the fact is the same, only the observation behind it differs.
|
||||
|
||||
# Deployment
|
||||
|
||||
The pattern ships through the config API and reaches existing agents without a rebuild; an agent
|
||||
that does not know the `userSessionZeroApps` action logs one Debug line and ignores it. The
|
||||
*behaviour* needs an agent release. Sessions already stamped `Failed` are not reclassified — the
|
||||
backend mirrors the agent verdict (`VerdictPaths.AgentFailed`) and has no independent judgement
|
||||
here by design.
|
||||
|
||||
# Citations
|
||||
|
||||
* `rules/ime-log-patterns/IME-USER-SESSION-ZERO-APPS.json`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs` — `HandleUserSessionZeroApps`, the phase replay in `HandleEspPhaseDetected`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs` — `AreUserEspAppsSettled`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs` — `EmitUserSessionCompleted`, evidence labels
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs` — `HandleAdvisoryCompletionDeadlineFired`
|
||||
* [decision-engine.md](decision-engine.md), [post-reboot-completion-recovery.md](post-reboot-completion-recovery.md)
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Admin Identity Binding — tid + oid behind every cross-tenant-role UPN
|
||||
description: Why a GlobalAdmins, DelegatedAdmins or McpUsers row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant, the AdminIdentityBindings table that pins each such UPN to its immutable home tenant id and object id, the fail-closed resolution order in the role services and the MCP whitelist, the first-sign-in object-id pin, and the explicit rebind surface.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs
|
||||
tags:
|
||||
- backend
|
||||
- security
|
||||
- authorization
|
||||
- multi-tenant
|
||||
- global-admin
|
||||
- delegated-admin
|
||||
- mcp
|
||||
timestamp: 2026-08-30T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
The API is a multi-tenant Entra application on purpose: `AuthenticationMiddleware` accepts a token from
|
||||
any tenant (issuer prefix `login.microsoftonline.com/` or `sts.windows.net/`), and signature, audience,
|
||||
lifetime and algorithm are validated strictly. Tenant-member roles (`TenantAdmins`) are keyed on
|
||||
`(tid, upn)` and therefore bound to the verified tenant.
|
||||
|
||||
The two cross-tenant tiers were not. `GlobalAdmins` (GlobalAdmin / GlobalReader) and the delegated MSP
|
||||
tier (`DelegatedAdmins` + `TenantGroupAssignments`) resolved their role from the **UPN string alone**
|
||||
(`upn`, falling back to `preferred_username`). Both claims are mutable and reusable across tenants:
|
||||
|
||||
* **Domain re-registration**: if the domain behind a platform admin's UPN lapses or moves, whoever
|
||||
verifies it in a fresh tenant can mint the identical UPN; the token is genuine, only `tid` differs.
|
||||
* **UPN recycling**: a User Administrator in the home tenant can re-assign the UPN to another account;
|
||||
same `tid`, different `oid`.
|
||||
* **MSP variant**: the delegated tier was gated only on the caller's home tenant being Pro — which any
|
||||
tenant can self-provision via the trial endpoint.
|
||||
|
||||
Any of these yielded full platform scope: raw table dumps including `TenantConfiguration` secrets,
|
||||
tenant offboarding, GA management. Microsoft's own guidance (post-nOAuth) is explicit: authorization
|
||||
data must be keyed on the immutable `tid` + `oid` pair, never on `upn` / `preferred_username`.
|
||||
|
||||
# Mechanism
|
||||
|
||||
## One binding per UPN
|
||||
|
||||
`AdminIdentityBindings` (PK `Bindings`, RK = lowercase UPN) holds, for every UPN that carries a
|
||||
cross-tenant role, the identity that may use it:
|
||||
|
||||
| Column | Meaning |
|
||||
| --- | --- |
|
||||
| `TenantId` | The admin's **home** Entra tenant — must equal the JWT `tid`. Mandatory at grant time. |
|
||||
| `ObjectId` | The admin's Entra object id — must equal the JWT `oid`. Empty until pinned. |
|
||||
| `BoundBy`, `BoundDate` | Operator provenance. |
|
||||
| `ObjectIdPinnedDate` | When the object id was fixed (grant time, or the first matching sign-in). |
|
||||
|
||||
A single table rather than columns on three role tables: a UPN string belongs to exactly one identity
|
||||
at a time, a person may hold a GlobalAdmins row *and* delegated assignments, and the role rows stay
|
||||
what they are — grants. The binding says whose grants they are.
|
||||
|
||||
## Resolution order (both role services)
|
||||
|
||||
`AdminIdentity` is the record `(Upn, TenantId, ObjectId)` built from a validated principal; it is
|
||||
`null` when any of the three claims is missing (app-only tokens, foreign IdP shapes). Both
|
||||
`GlobalAdminService.GetGlobalRoleAsync(AdminIdentity?)` and
|
||||
`DelegatedAdminService.GetScopeAsync(AdminIdentity?)` follow the same order:
|
||||
|
||||
1. `null` identity → no role, no storage read.
|
||||
2. Resolve the **row** by UPN (cached 30 s, unchanged from before).
|
||||
3. No row / empty scope → return; the binding is not even read. Ordinary tenant users cost one cached
|
||||
read and never produce a binding log line.
|
||||
4. `AdminIdentityBindingService.IsBoundAsync(identity)` → false ⇒ no role / empty scope.
|
||||
5. Delegated only: the Pro home-tenant entitlement gate, **after** the binding check — an unbound caller
|
||||
never triggers an edition lookup.
|
||||
|
||||
`IsBoundAsync` is the single choke-point: no binding ⇒ false; `TenantId` mismatch ⇒ false (checked
|
||||
before anything else, so a foreign-tenant token can never claim the pin below); pinned `ObjectId`
|
||||
mismatch ⇒ false; unpinned binding + matching tenant ⇒ **pin** the caller's `oid` and verify against
|
||||
what was actually stored afterwards. Every false outcome is logged at Warning with its reason
|
||||
(`[IdentityBinding] TENANT MISMATCH …` / `OBJECT-ID MISMATCH …` / `holds no identity binding`), the
|
||||
one level the worker forwards to Application Insights; matches are silent. Pins are logged at Warning
|
||||
once — they are one-off, operator-relevant events.
|
||||
|
||||
The pin is a conditional update in the repository (`TryPinIdentityObjectIdAsync`: only onto an unpinned
|
||||
row homed in the caller's tenant, ETag-guarded; a 412 re-reads and the caller compares against the
|
||||
winner). Two accounts racing the first sign-in inside the home tenant therefore leave exactly one bound.
|
||||
|
||||
Row lookups stay cached by UPN; the binding has its own 30 s cache keyed by UPN and is invalidated on
|
||||
pin, rebind and removal. A rebind on one scaled-out instance converges on the others within seconds.
|
||||
|
||||
## The MCP whitelist is a third UPN-keyed grant
|
||||
|
||||
`McpUsers` (PK `McpUsers`, RK = lowercase UPN) is the WhitelistOnly allow-list consulted by
|
||||
`GET auth/mcp`, which runs under the AuthenticatedUser policy — a token from any Entra tenant reaches it.
|
||||
Before 2026-08-30 the row was honoured by UPN alone, so a same-UPN identity from another tenant
|
||||
received `allowed=true` / `accessGrant=McpUser`, inherited the row's `UsagePlan` (a paid quota tier
|
||||
provisioned for another organization's user), and was also hit by the row's Disabled kill-switch. The
|
||||
data reach stayed tenant-bound (the MCP server routes a non-global caller on its own JWT `tid`), but the
|
||||
GA-curated whitelist and the per-user entitlement were bypassable.
|
||||
|
||||
`McpUserService` now resolves the whitelist exactly like the role services: row by UPN (cached 30 s) →
|
||||
no row ⇒ `NotPresent` without touching the binding → `IsBoundAsync(identity)` false ⇒ `NotPresent`. Grant,
|
||||
usage-plan override (`GetBoundMcpUserAsync`, used by `McpQuotaService.ResolvePlanAsync` and the
|
||||
self-service usage view) and the Disabled kill-switch all belong to the bound identity only; an unbound
|
||||
legacy row is inert. `POST global/mcp-users` binds first via the same resolver / 422 / 409 contract as the
|
||||
other grant endpoints, and the MCP users settings section shows the same home-tenant picker on 422. The
|
||||
AllMembers path is unaffected — it is tenant-bound by the auth middleware itself.
|
||||
|
||||
## Grants bind first — and resolve the identity themselves
|
||||
|
||||
Granting is "type the UPN". The four grant endpoints (`POST auth/global-admins`,
|
||||
`POST global/delegated-admins`, `POST global/tenant-groups/{id}/assignees`, `POST global/mcp-users`) accept `homeTenantId` /
|
||||
`objectId` only as optional overrides; normally `AdminIdentityResolver` supplies them:
|
||||
|
||||
1. **Sign-in history** (`UserActivity`, filtered on the UPN): the person has signed in before ⇒ tid + oid
|
||||
are known, both from validated tokens. The most recent sign-in wins for the oid. A UPN seen under
|
||||
**more than one tenant** is never auto-resolved — that is the attack shape the binding exists for.
|
||||
2. **UPN domain → onboarded tenant** (`TenantConfiguration.DomainName`, exactly one match): tid only; the
|
||||
oid is pinned on the first sign-in.
|
||||
3. Neither ⇒ **422 `HomeTenantUnresolved`**; the portal then shows a home-tenant picker (onboarded
|
||||
tenants) and retries with `homeTenantId`. This is the only manual step, and only for a UPN whose
|
||||
domain the platform has never seen.
|
||||
|
||||
`AddGlobalAdminAsync`, `DelegatedAdminService.UpsertAsync`, `AssignGroupAsync` and `AddMcpUserAsync` then take the resolved
|
||||
`homeTenantId` / `objectId` and call `EnsureBoundAsync` **before** writing the role row. `EnsureBoundAsync` creates the binding, keeps a compatible one (same tenant; the
|
||||
supplied object id equals the pin or upgrades an unpinned one), and throws
|
||||
`IdentityBindingConflictException` for a different tenant or a different pinned object id — the
|
||||
functions map that to **409**. A grant can therefore never silently re-home a UPN.
|
||||
|
||||
Why the object id may be unknown: a person who has never signed in has no history to resolve from, and
|
||||
the operator granting an external MSP admin has no directory access to the partner tenant. The first
|
||||
sign-in from the bound tenant pins it; from then on a recycled UPN in the same tenant is refused.
|
||||
|
||||
## Explicit rebind surface
|
||||
|
||||
| Route | Policy | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET global/identity-bindings` | GlobalReadOrAdmin | Audit which tenant / object id each grant is usable from. |
|
||||
| `PUT global/identity-bindings/{upn}` | GlobalAdminOnly | Replace the binding: move a UPN to another tenant, or re-pin after a legitimate account re-creation (omit `objectId` to clear the pin). |
|
||||
| `DELETE global/identity-bindings/{upn}` | GlobalAdminOnly | Make every role row of the UPN inert without touching the rows. Self-removal is refused. |
|
||||
|
||||
Both mutations are audited under the binding's home tenant (`AdminIdentityBinding` entity, previous
|
||||
values in the details) and logged at Warning. The portal surfaces this in **Tenant Management → Edit
|
||||
Tenant Configuration → Identity bindings** (collapsed): the bindings homed in that tenant, with inline
|
||||
edit (re-home / re-pin; empty object id ⇒ re-pinned on the next sign-in) and remove. The grant forms
|
||||
themselves carry no identity fields — the binding is a background property of the grant.
|
||||
|
||||
## Lifecycle and secondary effects
|
||||
|
||||
* `AdminIdentityBindings` is in `TableNames.All`, in `CriticalBackupTables.All` and in
|
||||
`AuthTablesFullRestoreForbidden` (single-row restore only, like the other auth tables).
|
||||
* Tenant offboarding wipes bindings whose `TenantId` is the offboarded tenant (property-only bucket):
|
||||
once a home tenant is gone, its admins' grants become inert.
|
||||
* `RequestContext.ObjectId` carries the JWT `oid`; `AdminIdentity.FromRequestContext` serves the
|
||||
handlers that resolve roles outside the middleware (SignalR group join/leave, health checks).
|
||||
* The throttle identity (`GetCallerId`) now prefers `oid` over the UPN, so two accounts in different
|
||||
tenants that share a UPN string no longer share a rate-limit bucket. Audit and presence rows keep the
|
||||
UPN as their display key; they already carry the tenant id.
|
||||
* Legacy role rows without a binding are **inert, not grandfathered**. Rolling this out therefore means
|
||||
seeding the binding rows for the existing admin UPNs (their `tid` and `oid` are recorded per login in
|
||||
`UserActivity`) *before* the backend deploy — an unseeded platform admin locks themselves out of every
|
||||
GlobalAdminOnly route, including the rebind surface.
|
||||
|
||||
# Examples
|
||||
|
||||
A GlobalAdmin row for `admin@vendor.example` bound to tenant `1111…` with object id `aaaa…`:
|
||||
|
||||
| Token | Result |
|
||||
| --- | --- |
|
||||
| `tid=1111… oid=aaaa… upn=admin@vendor.example` | GlobalAdmin |
|
||||
| `tid=2222… oid=aaaa… upn=admin@vendor.example` | no role — `TENANT MISMATCH` warning |
|
||||
| `tid=1111… oid=bbbb… upn=admin@vendor.example` | no role — `OBJECT-ID MISMATCH` warning |
|
||||
| `tid=1111… upn=admin@vendor.example` (no `oid`) | no role — identity incomplete |
|
||||
|
||||
Same row, binding without an object id: the first `tid=1111…` sign-in pins its `oid` and is admitted; a
|
||||
`tid=2222…` sign-in before that is refused **and does not pin**.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Security/AdminIdentity.cs` — the identity record and its factories.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs` — verdict, pin, grant-time binding, rebind; the table entity.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityResolver.cs` — sign-in-history / UPN-domain resolution at grant time (`AdminIdentityResolverTests`).
|
||||
* `src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx` — the per-tenant inspect/correct panel.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs`, `DelegatedAdminService.cs`, `McpUserService.cs` — resolution order (`McpUserServiceIdentityBindingTests` for the whitelist matrix).
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs` — `TryPinIdentityObjectIdAsync` and the binding CRUD.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/IdentityBindingManagementFunction.cs` — the rebind surface and the shared `IdentityBindingRequest.Validate`.
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/AdminIdentityBindingServiceTests.cs`, `AdminIdentityBindingAuthorizationTests.cs` — verdict matrix, race semantics, and the middleware-level denials for foreign-tenant and recycled-UPN tokens.
|
||||
* [Client-Certificate Tenant Binding](client-cert-tenant-binding.md) — the same "chain-valid is not tenant-bound" argument on the device side.
|
||||
@@ -10,7 +10,7 @@ tags:
|
||||
- certificates
|
||||
- intune
|
||||
- multi-tenant
|
||||
timestamp: 2026-08-24T00:00:00+02:00
|
||||
timestamp: 2026-08-29T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
@@ -77,7 +77,7 @@ Enforcing immediately would be reckless: any device population whose certificate
|
||||
extension, or whose CA behaves differently (sovereign clouds), would be locked out at once. The
|
||||
rollout therefore has staged consequences.
|
||||
|
||||
**Stage 1 — shadow (current).** `SecurityValidator.ObserveCertTenantBinding` evaluates the binding
|
||||
**Stage 1 — shadow (completed, kept as the measurement that justified stage 2).** `SecurityValidator.ObserveCertTenantBinding` evaluates the binding
|
||||
after certificate validation and logs the outcome. It returns `void`, changes no state, and swallows
|
||||
its own exceptions, so it cannot fail a request that would otherwise be accepted. The pure comparison
|
||||
lives in `CertTenantBinding.Evaluate`; the outcome codes (`Match`, `Mismatch`, `ExtensionMissing`,
|
||||
@@ -102,14 +102,117 @@ is disabled), so a `Match` trace is dead code that silently costs the denominato
|
||||
deployment of this feature made exactly that mistake: it emitted zero rows across 32k requests,
|
||||
which is indistinguishable from the code never running.
|
||||
|
||||
**Stage 2 — enforce.** Once telemetry shows the `ExtensionMissing` rate is negligible, `Mismatch`
|
||||
becomes a rejection. The decision that still has to be made is the policy for certificates without
|
||||
the extension: skip with a warning, or reject. Grep marker for every stage-2 site:
|
||||
`CERT-TENANT-BINDING-SHADOW`.
|
||||
**Stage 2 — enforced since 2026-08-25.** `Mismatch` returns 403 and is recorded through the shared
|
||||
`LogRequestRejection` path (`stage=certtenant`) so it lands in the same KQL raster as every other
|
||||
rejection reason. The switch was made against measured data: 35,684 requests across 32 tenants,
|
||||
every one a `Match`, with the Warning channel independently verified to be carrying other traffic so
|
||||
the zero could not be a telemetry artefact.
|
||||
|
||||
The rule lives in one place, `CertTenantBinding.Rejects`, and only `Mismatch` blocks. Everything
|
||||
else means "cannot tell" and is deliberately tolerated:
|
||||
|
||||
* `ExtensionMissing` — the sample only covers devices active in the measurement window, so older
|
||||
certificates or a sovereign-cloud CA could still lack the extension. Rejecting on absence would
|
||||
lock those devices out for something they cannot control. **This is the one to tighten later**,
|
||||
once the `ExtensionMissing` rate is provably zero across a wider window; a test pins the current
|
||||
tolerance so removing it has to be a deliberate decision rather than a drive-by edit.
|
||||
* `Unparseable` — a decoder or encoding surprise is our problem, not the device's.
|
||||
* `RequestTenantNotAGuid` — nothing to compare against, and such a request fails the tenant lookup
|
||||
in §0 long before reaching this point.
|
||||
|
||||
The evaluation fails open on its own exception: this gate exists to stop a cross-tenant certificate,
|
||||
and a defect on our side is not evidence of one. The 403 body deliberately does not echo which
|
||||
tenant the certificate belongs to — that pair is in the rejection log, not in the response.
|
||||
|
||||
Grep marker for every site involved: `CERT-TENANT-BINDING`.
|
||||
|
||||
The bootstrap-token path (`X-Bootstrap-Token`) bypasses certificate validation by design and is
|
||||
unaffected by either stage.
|
||||
|
||||
# Certificate transport and proof of possession
|
||||
|
||||
Every stage above validates certificate *content*: chain, validity window, EKU, tenant extension.
|
||||
None of them proves the caller holds the private key — a certificate is public material (sent in
|
||||
clear in a TLS 1.2 client-auth handshake, readable from the machine store by any local user, present
|
||||
on resold hardware). Proof of possession comes from the TLS handshake alone: the platform runs
|
||||
`clientCertMode=Required` on every path not listed in its `clientCertExclusionPaths`, forwards the
|
||||
handshake certificate as `X-ARR-ClientCert`, and **strips any inbound copy of that header — on
|
||||
excluded paths too** (verified 2026-08-29 with a spoofed header against `/api/bootstrap/config`:
|
||||
the validator saw "No certificate provided"). So `X-ARR-ClientCert` is trustworthy exactly because
|
||||
the caller cannot write it.
|
||||
|
||||
Two consequences shape `SecurityValidator`:
|
||||
|
||||
* **No other certificate header is ever read.** A former local-dev fallback (`X-Client-Certificate`)
|
||||
accepted certificate bytes from a client-writable header, which decouples the certificate from
|
||||
key possession: anyone holding a device's public certificate would authenticate as that device.
|
||||
Removed 2026-08-29. Local development sets `X-ARR-ClientCert` directly — Core Tools does not strip
|
||||
it.
|
||||
* **Bootstrap routes are token-only, fail-closed.** `/api/bootstrap/*` is in the platform's
|
||||
exclusion list so cert-less OOBE agents can reach it — which also means nothing on those paths
|
||||
proves a handshake happened. The token is therefore the sole credential there:
|
||||
`SecurityValidator.IsBootstrapRoute` (path prefix, mirroring the exclusion entry so no function
|
||||
can forget to opt in) rejects a missing or **empty** `X-Bootstrap-Token`, and a missing
|
||||
`BootstrapSessionService`, with 401 before the certificate stage can run. The live gap this
|
||||
closes: the function-level `Headers.Contains` gate accepted an empty header while the validator
|
||||
treated it as "no token" and fell through to the certificate stage, where the fallback header
|
||||
above was parsed. Bootstrap tokens are never accepted as a *replacement* for the certificate on
|
||||
platform-enforced routes either — there the cert requirement is already satisfied by the
|
||||
handshake before the request reaches code.
|
||||
|
||||
The exclusion list is portal-only state (no IaC). Adding `/api/agent` to it, or moving a
|
||||
device-authenticated function under an excluded prefix, would silently reopen the header path;
|
||||
`BootstrapRouteFailClosedTests` pins the code half of this contract.
|
||||
|
||||
**Stage 3 — device binding (Global-Admin-only preview).** Tenant binding proves the certificate was
|
||||
issued to this tenant; it does not prove the certificate belongs to a device the tenant still has.
|
||||
`IntuneDeviceBindingValidator` closes that by resolving the Intune managedDevice id from the
|
||||
certificate's Subject CN against the tenant's own `deviceManagement/managedDevices` inventory
|
||||
(point-GET on `managedDevices/{id}` — no OData filter to inject into, and a 404 is unambiguous).
|
||||
|
||||
It is modelled on `CloudPcDeviceValidator` and shares its cache/retry/transient contract, with one
|
||||
important difference in how it is wired: the lookup is fire-and-forget, like the DevPrep
|
||||
association shadow, because a cold Graph round-trip can take seconds and must never sit in the
|
||||
agent's request path. That rules out the request-row dimension stage 1 uses — the row is written
|
||||
the moment the function returns, before the detached task finishes — so the outcome is logged at
|
||||
Warning instead. Volume stays bounded because only Global Admins can see the toggle
|
||||
(`user?.isGlobalAdmin === true`, the same gate as the DevPrep preview). Widening it beyond preview
|
||||
should move to an inline call plus a request dimension.
|
||||
|
||||
Outcomes: `Match`, `NotFound`, `NoDeviceIdInCert`, `PermissionMissing`, `Transient`. Only
|
||||
`Transient` is retried and left uncached — a missing grant or a foreign device is a state, not an
|
||||
outage.
|
||||
|
||||
Graph cost is not a concern, and that is measured rather than assumed: the first live enrollment
|
||||
produced **136 requests and exactly one Graph call**, because the 30-minute positive cache absorbs
|
||||
the rest. For comparison, the per-request Autopilot serial validator made 25 calls in the same
|
||||
window. Lengthening the positive TTL would buy nothing and would delay noticing a device that has
|
||||
been wiped or removed; shortening the 5-minute negative TTL matters more, since that is how quickly
|
||||
a device object appearing late is picked up.
|
||||
|
||||
What the 136 requests *did* cost was log lines saying the same thing 136 times, so the shadow log is
|
||||
gated on `IntuneDeviceBindingResult.ServedFromCache`: one line per real Graph lookup. Cached repeats
|
||||
add nothing — the reported age just counts up mechanically from the same `enrolledDateTime`.
|
||||
Outcomes that are never cached (`Transient`, `NoDeviceIdInCert`) still log every time, which is the
|
||||
right behaviour for an anomaly.
|
||||
|
||||
The open question this preview exists to answer is a race, not an attack: a device object can in
|
||||
principle appear in Intune *after* the agent's first call, and enforcing before that is understood
|
||||
would reject legitimate enrollments. Every result therefore carries `enrolledDateTime`, and the log
|
||||
line records `enrolledAgeSeconds` — a device whose object was created seconds before the request
|
||||
looks completely different from a certificate that never belonged to the tenant. Grep marker:
|
||||
`CERT-DEVICE-BINDING-SHADOW`.
|
||||
|
||||
Enabling it needs the optional Graph permission `DeviceManagementManagedDevices.Read.All`, granted
|
||||
per tenant with the customer script — it is not in the default-consent set:
|
||||
|
||||
```powershell
|
||||
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<client-id>" -Features IntuneDeviceBinding
|
||||
```
|
||||
|
||||
`GraphFeatureCatalog` (feature `IntuneDeviceBinding`) and the script's catalog, `ValidateSet` and
|
||||
help block are pinned to each other by `GraphFeatureCatalogSyncTests`.
|
||||
|
||||
# Examples
|
||||
|
||||
Coverage — the question stage 2 depends on ("would enforcing lock anyone out?"):
|
||||
@@ -138,6 +241,23 @@ that stage-2 enforcement would have to make a policy decision about. Note that t
|
||||
alone can never show `Match`, so it cannot tell "everything matched" apart from "nothing ran" —
|
||||
use the request-row query for that.
|
||||
|
||||
Stage 3, including the enrollment-race measurement:
|
||||
|
||||
```kusto
|
||||
traces
|
||||
| where message has "AgentCertDeviceBinding"
|
||||
| extend Outcome = tostring(customDimensions.Outcome),
|
||||
AgeSecs = toint(customDimensions.EnrolledAgeSeconds)
|
||||
| summarize Requests = count(),
|
||||
MinAgeSecs = min(AgeSecs),
|
||||
P05AgeSecs = percentile(AgeSecs, 5)
|
||||
by Outcome
|
||||
```
|
||||
|
||||
`NotFound` is the row that decides whether this can ever become a gate. A small `MinAgeSecs` on
|
||||
`Match` means device objects are appearing only just in time — the race is real and enforcement
|
||||
would need a grace path.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.Shared/Security/MsDeviceCertificateOids.cs` — OID catalog and the
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
type: Concept
|
||||
title: IME Installer Archive (version-verified, multi-host)
|
||||
description: How every fleet-observed Intune Management Extension build is archived as its MSI - the ime_agent_version sighting that queues the job, the candidate hosts, the ProductVersion check that keeps a wrong ring's build out of the version folder, the re-queue on later sightings, and the manual skill-side backfill.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Services/Ime/ImeMsiArchiver.cs
|
||||
tags:
|
||||
- backend
|
||||
- ime
|
||||
- archive
|
||||
- blob
|
||||
- queue
|
||||
timestamp: 2026-08-30T12:00:00+02:00
|
||||
---
|
||||
|
||||
# IME Installer Archive
|
||||
|
||||
The platform keeps every Intune Management Extension build it has ever seen in the fleet
|
||||
as the original installer, so any IME version can be decompiled and diffed later
|
||||
(`ime-decompiles` repository, `/ime-decompile` skill) even after Microsoft's CDN has moved
|
||||
on. Two stores:
|
||||
|
||||
| Store | Content | Written by |
|
||||
| --- | --- | --- |
|
||||
| Blob container `ime-archive` (private, kept by design, not tenant-scoped) | `{version}/IntuneWindowsAgent.msi` (write-once) + `{version}/provenance.json` (derived, overwritable) | `ImeMsiArchiver` via the `ime-msi-archive` queue; `/ime-decompile` skill as manual backfill |
|
||||
| Table `ImeVersionHistory` (partition `Global`, RowKey = version) | first/last seen, session count, `CorroboratedAt`, `MsiArchiveStatus`, `MsiArchiveUpdatedAt`, `MsiArchiveBlobPath`, `MsiSha256`, `MsiBytes`, `MsiSourceUrl` | `RecordImeVersionAsync` (sighting), `UpdateImeVersionArchiveInfoAsync` (archive outcome / queued stamp) |
|
||||
|
||||
# Schema
|
||||
|
||||
## Sighting → job
|
||||
|
||||
`EventIngestProcessor` looks at the batch's `ime_agent_version` event (`agentVersion`, plus
|
||||
the agent's CSP-registry enrichment `msiDownloadUrl` / `msiMatchedBy` when present).
|
||||
`RecordImeVersionAsync` returns an `ImeVersionSighting`:
|
||||
|
||||
* `IsNew` — the insert succeeded: first fleet-wide sighting → ops event + enqueue.
|
||||
* otherwise the row's `MsiArchiveStatus` / `MsiArchiveUpdatedAt`, read in the same point
|
||||
read that bumps `SessionCount` (no second table read).
|
||||
|
||||
A known version is **re-queued** (`ImeMsiArchiver.ShouldRequeueOnSighting`) only when all
|
||||
of these hold: the event URL passes the allowlist **and** `msiMatchedBy == productVersion`
|
||||
(the CSP row's ProductVersion equals the observed version, so the URL is authoritative),
|
||||
the status is neither `Archived` nor `Failed:BadVersion`, and the last archive activity is
|
||||
older than 24 h (`RequeueBackoff`) or never happened. The ingest path then merges
|
||||
`MsiArchiveStatus = Queued` (stamping `MsiArchiveUpdatedAt`) before enqueueing, so the other
|
||||
sessions of that version see "in flight" and do not pile on. Worst case for a version no
|
||||
host serves any more: one walk over the hosts per day, not one per enrollment.
|
||||
|
||||
## Trust boundary of a sighting
|
||||
|
||||
The version string is **device-supplied** (any device holding a tenant agent credential can
|
||||
send an `ime_agent_version` event) and becomes the RowKey of a caller-independent GLOBAL
|
||||
partition that every tenant reads. Four layers keep one tenant's device from turning a
|
||||
claim into a platform fact:
|
||||
|
||||
| Layer | Where | Effect |
|
||||
| --- | --- | --- |
|
||||
| Plausibility guard | `ImeMsiArchiver.IsPlausibleVersion`, called first in `RecordImeVersionAsync` (and again by the archiver) | plain dotted digits, 2–4 components, inside Windows Installer `ProductVersion` bounds (major/minor ≤ 255, build ≤ 65535). Anything else → `ImeVersionSighting.Rejected`: no row, no ops event, no archive job, no re-queue. |
|
||||
| One sighting per session | `UpdateSessionImeAgentVersionAsync` returns whether the session's stored version changed; only then does the ingest path call `RecordImeVersionAsync` | `SessionCount` moves once per session and version; a re-sent or replayed event, a tombstoned or unregistered session, count nothing. |
|
||||
| Corroboration | merge path of `RecordImeVersionAsync` stamps `CorroboratedAt` on the first sighting from a tenant other than `FirstSeenTenantId` | the first-seen tenant's own devices can never corroborate their own claim. |
|
||||
| Fleet confirmation on read | `GetImeVersionHistoryFunction.IsFleetConfirmed` (MemberRead projection only) | tenant members see a version once it is `Archived` (ProductVersion matched a real package), or corroborated, or first seen before `LegacyTrustCutoffUtc` (2026-08-31 — the 16 rows reviewed on 2026-08-30 are all genuine builds). Global scope sees every row, unconfirmed ones included. |
|
||||
|
||||
What remains possible for a hostile device: a plausible, never-shipped version creates a
|
||||
row visible to Global Admins plus one `NewImeVersionDetected` ops event and one archive walk
|
||||
that ends `Failed:VersionMismatch` (no blob). It is never listed to other tenants and can
|
||||
never occupy the archive folder of a real build.
|
||||
|
||||
## Candidate hosts and the version check
|
||||
|
||||
Microsoft distributes IME from several versionless URLs that carry **different rollout
|
||||
rings at the same time**. Observed 2026-08-29: `imeswdb-afd-secondary` served 1.104.102.0,
|
||||
`imeswda-afd-primary` 1.105.101.0, `imeswda-afd-hotfix` 1.105.103.0. "A new version is by
|
||||
definition what the canonical URL serves right now" is therefore false, and the first
|
||||
version of the archiver filed a 1.104.102.0 package under `1.105.103.0/` (fixed by hand:
|
||||
blob overwritten, row merged).
|
||||
|
||||
The archiver walks `BuildCandidateUrls(eventUrl)`: the allowlisted event URL first (HTTPS,
|
||||
host `*.manage.microsoft.com`, filename exactly `IntuneWindowsAgent.msi`), then
|
||||
`FallbackMsiUrls` = primary, secondary, hotfix — de-duplicated. For each candidate:
|
||||
|
||||
1. Download with the admin size cap (Content-Length preflight + mid-stream cap) into a
|
||||
delete-on-close temp file while SHA-256 hashing.
|
||||
2. Read the package's `ProductVersion` with `MsiProductVersionReader` — a pure managed OLE
|
||||
compound-file reader (v3 512-byte and v4 4096-byte sectors, DIFAT/FAT/mini-FAT, MSI
|
||||
stream-name decoding, `_StringPool`/`_StringData` incl. long strings and holes, the
|
||||
column-major `Property` table with 2- or 3-byte string refs). It never throws; anything
|
||||
unreadable is `null`. Needed because the Function host is Linux — no `msiexec`.
|
||||
3. `VersionsMatch(observed, productVersion)` (component-count tolerant: `1.105.103` ==
|
||||
`1.105.103.0`). Mismatch or unreadable → log, record the attempt, next candidate.
|
||||
4. Match → write-once upload (If-None-Match:*), then `provenance.json` with
|
||||
`productVersion`, the chosen `url`, `urlFromEvent`, hash/size and the full `candidates`
|
||||
walk (`url`, `outcome` ∈ match / version-mismatch / too-large / download-failed /
|
||||
timeout, `productVersion`).
|
||||
|
||||
Outcome precedence when no candidate archived: any transient failure (download/timeout)
|
||||
→ `Failed:Download` / `Failed:Timeout`, retryable via the queue's visibility ladder (the
|
||||
down host might have had it); every candidate over the cap → `Failed:TooLarge`; otherwise
|
||||
`Failed:VersionMismatch` — **not** retryable by the queue (retrying in minutes changes
|
||||
nothing), left to the sighting re-queue above. Storage/unexpected errors stay
|
||||
`Failed:Error`, retryable. A 409 on the write-once upload means an earlier attempt
|
||||
archived the version: the provenance sidecar is healed from the archived bytes (hash,
|
||||
size, ProductVersion) if missing — this is also how a manual skill backfill turns a null
|
||||
row into `Archived` on the next authoritative sighting.
|
||||
|
||||
## Manual path (skill)
|
||||
|
||||
`/ime-decompile` downloads from the blob (`ime-blob.sh download`), and
|
||||
`fetch-ime-decompile.py` names the decompile folder after the **actual** FileVersion of
|
||||
the extracted binaries — so the decompile repo can never be poisoned, only the blob folder.
|
||||
When the blob's version mismatches, look for `msiDownloadUrl` in *other* sessions of the
|
||||
version (the first-seen session often lacks the enrichment) and fetch from that host.
|
||||
`ime-blob.sh upload … --overwrite` replaces a blob known to hold the wrong build; merge the
|
||||
row's `MsiSha256` / `MsiBytes` / `MsiSourceUrl` afterwards.
|
||||
|
||||
# Examples
|
||||
|
||||
* First sighting of 1.105.103.0 with no URL: primary → 1.105.101.0 (mismatch), secondary →
|
||||
1.104.102.0 (mismatch), hotfix → 1.105.103.0 (match) → archived from hotfix, provenance
|
||||
lists all three.
|
||||
* Hotfix host down during the walk, others mismatching → `Failed:Download`, queue retries.
|
||||
* Version 1.101.111.0 (predates the archiver, row status null): a session whose CSP row
|
||||
matches by ProductVersion arrives → status `Queued`, job runs; if no host serves it any
|
||||
more → `Failed:VersionMismatch`, next try no sooner than 24 h later.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Ime/ImeMsiArchiver.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Ime/MsiProductVersionReader.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Ime/ImeMsiArchiveQueueWorker.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs` (ime_agent_version block)
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs` (`RecordImeVersionAsync`, `UpdateImeVersionArchiveInfoAsync`)
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeMsiInstallSourceProbe.cs` (event enrichment)
|
||||
* `.claude/commands/scripts/ime-blob.sh`, `.claude/commands/scripts/fetch-ime-decompile.py`
|
||||
* Tests: `ImeMsiArchiverTests`, `MsiProductVersionReaderTests` (synthetic packages + local sweep over `ime-files/`)
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
type: concept
|
||||
title: IME Pattern Health — the pattern-drift loop
|
||||
description: How a Microsoft change to IME log wording becomes visible before it breaks timelines — the agent's session-end pattern histogram, the global per-IME-version statistics, the data-driven drift alarm, and the operator page/MCP tool that turn the alarm into a repair workflow.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Services/Ime
|
||||
tags: [backend, agent, ime, log-patterns, drift, ops-events, metrics, mcp]
|
||||
timestamp: 2026-08-30
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
The IME log-pattern pack is the product's core signal source, and Microsoft changes IME log
|
||||
wording without notice. Until now a changed line was invisible until sessions looked wrong:
|
||||
the agent kept matching nothing, the client log said nothing, and the only alarm was the
|
||||
registry/log reconciliation for app states. Tracker problems (skipped lines, regex timeouts)
|
||||
were a client-log Warning nobody sees without pulling diagnostics.
|
||||
|
||||
# Design
|
||||
|
||||
Three layers, one loop:
|
||||
|
||||
## 1. Tracker health in `agent_metrics_snapshot`
|
||||
|
||||
`ImeLogTracker` keeps cumulative counters (`ImeTrackerHealth`: lines read, entries matched,
|
||||
oversized lines, regex timeouts, per-line budget breaks, held tails, unanchored patterns,
|
||||
files tailed, backlog bytes = Σ file length − bookmark). `AgentSelfMetricsCollector` reads them
|
||||
through a read-only probe (`ImeLogHost.GetTrackerHealth`) and adds `ime_*` keys to the periodic
|
||||
snapshot next to the spool queue. Fleet expectation for every skip counter: **0**. The session
|
||||
page's "IME Tracker" section shows queue depth and skips.
|
||||
|
||||
## 2. `ime_tracker_degraded` (Warning, one-shot, immediate upload)
|
||||
|
||||
The first pass that skipped work raises `ImeLogTracker.OnTrackerDegraded`; the adapter emits
|
||||
the event with the counters, file and first skipped pattern. Persisted flag (`ImeTrackerState`)
|
||||
so a restart cannot re-emit. Listed in the MCP `health_events` catalog; informational for the
|
||||
idle/stall clocks (`SignalActivityClassifier`).
|
||||
|
||||
## 3. `ime_pattern_hits` → `ImePatternStats` → `ImePatternDriftSuspected`
|
||||
|
||||
* **Agent.** At termination (same slot as `app_tracking_summary`, so WhiteGlove Part 2 reports
|
||||
the whole session via the persisted histogram) the handler emits `ime_pattern_hits`:
|
||||
`hits` = every ENABLED pattern ID → match count, zeros included, plus the health counters and
|
||||
`imeVersion` (IME's own "Agent version is:" line). Only sessions with a terminal run report —
|
||||
crashes/kills are outside the denominator by construction. Severity is Debug: the event is an
|
||||
operator/backend signal, hidden from the default session timeline; the session page's
|
||||
"IME Tracker" card and the ingest fold read it regardless of severity.
|
||||
* **Ingest.** `EventIngestProcessor` hands the event to `ImePatternHealthService` fire-and-forget:
|
||||
IDs are filtered against `BuiltInImeLogPatterns.BuiltInPatternIds` (a device may only claim an
|
||||
ID; tenant custom IDs never reach the global table), the version must pass
|
||||
`ImeMsiArchiver.IsPlausibleVersion` (event value first, session row as fallback), then one
|
||||
partition read + one transactional batch upsert into `ImePatternStats`
|
||||
(PK = version, RK = patternId: `Sessions`, `SessionsWithHit`, `Hits`, `LastHitAt`,
|
||||
`DriftFlaggedAt`). Permanent table (`TableLifecycleBucketTests` KeptByDesign) — the baseline
|
||||
needs the established versions.
|
||||
* **Drift statistic (`ImePatternDriftEvaluator`, pure).** No hard-coded must-hit list.
|
||||
Baseline = the version with the most reporting sessions (≥ 100, never the candidate).
|
||||
A pattern is *expected* when its baseline hit rate (`SessionsWithHit / Sessions`) is ≥ 0.8.
|
||||
Drift is suspected when the candidate has ≥ 25 reporting sessions and an expected pattern
|
||||
matched in none of them. Conditional patterns (platform/remediation scripts, WinGet, errors)
|
||||
sit below 0.8 on the baseline and cannot alarm. One alarm per version × pattern:
|
||||
`TryMarkImePatternDriftFlaggedAsync` (ETag-conditional) then
|
||||
`OpsEventService.RecordImePatternDriftSuspectedAsync` (Warning, Agent, `System.Ingest`;
|
||||
dual-registered in the web `OPS_EVENT_TYPES`). The stats snapshot used for evaluation is
|
||||
cached 10 minutes and patched with each batch, so a version crossing the threshold is judged
|
||||
promptly.
|
||||
* **Read side.** `GET metrics/ime-pattern-health` (`GlobalReadOrAdmin`) returns baseline,
|
||||
thresholds, versions (with `ImeVersionHistory` first/last seen and fleet session count),
|
||||
patterns (catalog + retired IDs still in the statistics), cells and open alerts. Web:
|
||||
Global Admin → Metrics → IME Pattern Health (matrix, legend: drift / silent / low / few).
|
||||
MCP: `get_ime_pattern_health` (GA + Global Reader).
|
||||
|
||||
# Workflow on an alert
|
||||
|
||||
1. `ImePatternDriftSuspected` names version + pattern; the matrix shows the cell.
|
||||
2. `search_sessions imeAgentVersion=<version>` → `get_session_diagnostics` on a session with a
|
||||
package (upload mode must not be Off).
|
||||
3. `/ime-pattern-validate` against the real log; `/ime-decompile` for the version's sources.
|
||||
4. Fix the pattern under `rules/ime-log-patterns/` (anchored, linear —
|
||||
[IME Log Tracker](../agent/ime-log-tracker-matching-budget.md)); Backend-Deploy delivers it
|
||||
through the config channel without an agent release.
|
||||
|
||||
# Limits
|
||||
|
||||
* Denominator = sessions that reached a terminal run; a version that only crashes never gets
|
||||
a histogram and therefore never alarms here (it alarms elsewhere).
|
||||
* The baseline is fleet-wide: a pattern that only some tenants can produce stays "conditional"
|
||||
even if it is 100 % within those tenants.
|
||||
* Thresholds are constants (`ImePatternDriftEvaluator`), tuned for the current fleet size
|
||||
(~25k sessions/month); revisit when versions roll out faster than 25 sessions per day.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerHealth.cs`, `ImeLogTracker.cs` (`GetHealthSnapshot`, `OnTrackerDegraded`), `ImeTrackerStatePersistence.cs`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs` (`EmitImePatternHits`)
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Ime/{ImePatternHealthService,ImePatternDriftEvaluator}.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.ImePatternStats.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetImePatternHealthFunction.cs`
|
||||
* `src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionImePatternHealth.tsx`, `imePatternHealthLogic.ts`
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts` (`get_ime_pattern_health`)
|
||||
@@ -39,7 +39,9 @@ Every table in `Constants.TableNames` must belong to at least one deletion lifec
|
||||
- Unknown session → `TargetTenantId`, leaving each endpoint's own not-found semantics (404 / empty 200 / 400) in charge.
|
||||
- Delegated (MSP) callers deliberately get NO fallback — extending it to `AllowedTenantIds` is a pending user decision.
|
||||
|
||||
The lookup row is written at session registration (fail-soft), deleted by the cascade one step before the tombstone, and rebuildable from Sessions (not in the critical-backup set). The former lazy triggers (`events.Count == 0` → scan) are gone: empty-but-owned results no longer pay a table scan, and endpoints resolve upfront.
|
||||
The lookup row is **claimed at registration, first-writer-wins** (`ClaimSessionTenantLookupAsync`, create-only `AddEntity` BEFORE any Sessions/SessionsIndex write): on 409 the existing owner is read back — same tenant ⇒ normal re-registration, different tenant ⇒ `SessionTenantConflictException` ⇒ `409 Conflict` with `ErrorCode=session_owner_mismatch` (owning tenant not disclosed) plus a `SessionTenantConflict` ops event, and nothing is written. The claim is deliberately NOT fail-soft. Before this, the row was an unconditional last-writer-wins upsert after the session write, so any tenant's device (self-service onboarding) that knew a victim session GUID (portal deep links, Teams/Slack notifications, progress-portal URLs) could re-point the mapping at its own tenant and have every no-`tenantId` Global Admin/Reader read — and GA writes such as the `globaladmin` annotation lane — silently served from a forged session. Session ids are 122-bit random, so a genuine cross-tenant collision does not occur; a conflict is an attack or an agent bug and fails loudly. The legacy self-heal write is Add-only too (an existing claim is the authority), and `scripts/Migration/Backfill-SessionTenantLookup.ps1` closes the pre-table window once (idempotent, Add-only) so no legacy session id is claimable. The row is deleted by the cascade one step before the tombstone (no tombstone is kept — a freed id gives a foreign tenant nothing a fresh GUID would not) and is rebuildable from Sessions (not in the critical-backup set). The former lazy triggers (`events.Count == 0` → scan) are gone: empty-but-owned results no longer pay a table scan, and endpoints resolve upfront.
|
||||
|
||||
`requireGlobalAdmin: true` additionally corroborates the resolved tenant against its Sessions row (one point-read): a mapping whose session does not exist in the resolved tenant keeps `TargetTenantId`, so a stale or poisoned row can never steer a GA write — silently, without any operator confirmation step (a "confirm tenant GUID" dialog was rejected as unusable). Read paths keep the single point-read; their own session read is the corroboration (404).
|
||||
|
||||
Related enforcement added with the migration: `RecomputeTriggerGate` — `?reanalyze=true` and `?rescan=true` on the MemberRead session routes are actions (delete + rewrite stored results) and now 403 for Viewer / Global Reader / cross-tenant tenant roles (Global Admin or own-tenant Admin/Operator only).
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Session Owner Binding — Session-Level Authorization for Agent Writes
|
||||
description: Why tenant-level agent authentication (any device certificate, or a bootstrap token) was enough to rewrite every session in the tenant, how each Sessions row is now bound to the device identity that created it, the rule matrix that keeps every legitimate lifecycle working, the shadow-then-enforce rollout, and why enforcement needs the agent to rotate its session id rather than count an auth failure.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Security/SessionOwnershipPolicy.cs
|
||||
tags:
|
||||
- backend
|
||||
- security
|
||||
- authorization
|
||||
- sessions
|
||||
- agent
|
||||
- multi-tenant
|
||||
timestamp: 2026-08-29T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
Agent endpoints authenticate at **tenant** granularity: a chain-valid Intune MDM certificate whose
|
||||
tenant stamp matches the request (see [client-cert-tenant-binding.md](client-cert-tenant-binding.md)),
|
||||
or a bootstrap token minted for the tenant. They authorize writes at **session** granularity using a
|
||||
session id the caller supplies — `IngestTelemetryFunction` takes it from the `PartitionKey` of the
|
||||
first item, `RegisterSessionFunction` from the body, `ReportAgentErrorFunction` from the body. Until
|
||||
2026-08-29 nothing on the Sessions row recorded which device created it, so nothing could be compared.
|
||||
|
||||
The consequence: one enrolled device — or anyone who saw a 6-character bootstrap short code — could,
|
||||
for every other session of the tenant, forge `enrollment_complete` / failure events (flipping the live
|
||||
status, firing webhooks and SLA logic), pollute the timeline, re-register the session with different
|
||||
device metadata (which also redirects the Progress-Portal serial-knowledge proof), and — worst — send a
|
||||
Signal-only batch that **fetches and clears the victim's pending ServerActions**, silently stealing
|
||||
admin commands such as diagnostics collection. Session ids are GUIDs, but they travel in portal URLs
|
||||
and tenant SignalR messages; they were never meant to be a capability.
|
||||
|
||||
# Mechanism
|
||||
|
||||
## Owner on the row
|
||||
|
||||
`SessionOwner` (Shared) is stamped by the backend from the validated caller identity — never from
|
||||
agent input — and lives in six primary-only Sessions columns (`OwnerKind`, `OwnerThumbprint`,
|
||||
`OwnerDeviceId`, `OwnerBootstrapCode`, `OwnerSerial`, `OwnerBoundAt`). They are listed in
|
||||
`SessionIndexFieldManifest.PrimaryOnly`: never mirrored to `SessionsIndex`, never on `SessionSummary`,
|
||||
so no certificate identity travels to clients.
|
||||
|
||||
| Auth path | Identity that binds | Proven by |
|
||||
|---|---|---|
|
||||
| Certificate | `OwnerThumbprint` + `OwnerDeviceId` (Intune device id from the certificate CN, lower-case GUID) | TLS handshake; the CN survives a certificate re-issue |
|
||||
| Bootstrap token | `OwnerBootstrapCode` + `OwnerSerial` | Token possession + the serial the caller announced (header, unverified) |
|
||||
|
||||
`SecurityValidationResult.IntuneDeviceId` is new: the CN was already extracted for the Cloud PC
|
||||
validator, now it is surfaced on the result for every certificate-authenticated request.
|
||||
|
||||
## Rule matrix
|
||||
|
||||
`SessionOwnershipPolicy.Evaluate(existingRow, validation, now)` is pure. It returns an outcome, the
|
||||
owner to stamp (if any) and whether the caller's serial equals the row's serial.
|
||||
|
||||
| Caller | Row owner | Outcome | Stamps | Would reject |
|
||||
|---|---|---|---|---|
|
||||
| any | no row | `Fresh` | caller | no |
|
||||
| any | no owner columns (legacy), serial equal | `ClaimLegacy` | caller | no |
|
||||
| any | no owner columns, serial differs | `LegacySerialMismatch` | — | **yes** |
|
||||
| any | — (validation carried neither thumbprint nor code) | `CallerUnidentified` | — | no |
|
||||
| Cert | Cert, same thumbprint | `Match` | — | no |
|
||||
| Cert | Cert, other thumbprint, same device id | `RebindCertRotation` | caller | no |
|
||||
| Cert | Cert, other thumbprint and device id | `MismatchCert` (+ `serialMatch`) | — | **yes** |
|
||||
| Cert | Bootstrap, same serial | `RebindBootstrapHandoff` | caller | no |
|
||||
| Cert | Bootstrap, other serial | `MismatchBootstrapOwned` | — | **yes** |
|
||||
| Bootstrap | Bootstrap, same code + serial | `Match` | — | no |
|
||||
| Bootstrap | Bootstrap, otherwise | `MismatchBootstrap` | — | **yes** |
|
||||
| Bootstrap | Cert | `DowngradeToBootstrap` | — | **yes** |
|
||||
|
||||
The tolerated rows are the legitimate lifecycle: agent restart and reboot (same certificate),
|
||||
WhiteGlove Part 2 (same certificate), the install→runtime handoff where a bootstrap-registered session
|
||||
continues under the MDM certificate once it exists, a re-issued certificate of the same Intune device,
|
||||
and rows that predate the binding.
|
||||
|
||||
`MismatchCert` with `serialMatch=true` is the one shape that is *not* an attack: an Intune
|
||||
re-enrollment **without a wipe**. `session.id` survives in `%ProgramData%\AutopilotMonitor`, but the
|
||||
device gets a new Intune device id and therefore a new certificate identity. Under enforcement this
|
||||
would be refused — which is why enforcement needs the agent-side rotation below.
|
||||
|
||||
## Where it runs
|
||||
|
||||
`SessionOwnerBindingObserver` is the single side-effect carrier; the three call sites hand it the
|
||||
row they already hold:
|
||||
|
||||
* `RegisterSessionFunction.ProcessRegisterAsync` — the cascade-delete guard now returns the row
|
||||
(`EnsureWritableAndGetRowAsync`); the decision's owner goes into `StoreSessionAsync(registration,
|
||||
ownerToStamp)`, whose Replace preserves an existing owner when nothing new is stamped (including a
|
||||
Merge-stamped owner that landed after the initial read — the CAS re-read selects the owner columns).
|
||||
The bootstrap wrapper inherits this.
|
||||
* `IngestTelemetryFunction.Run` — evaluated against `guardSessionRow` (zero extra reads) before
|
||||
`PersistItemsAsync`, so the Signal-only path that serves pending ServerActions is covered. Legacy
|
||||
claims and rebinds are written with `UpdateSessionOwnerAsync` (Merge, fail-soft).
|
||||
* `ReportAgentErrorFunction` and its bootstrap wrapper — one point-read, observe only.
|
||||
|
||||
## Shadow telemetry (stage 1)
|
||||
|
||||
Same three carriers as the cert-tenant binding:
|
||||
|
||||
* **Denominator** — `requests | extend o = tostring(customDimensions.SessionOwnerBinding) | where
|
||||
isnotempty(o) | summarize count() by o` (request-row dimension via `RequestTelemetryMiddleware`;
|
||||
worker `LogInformation` never reaches App Insights).
|
||||
* **Numerator** — `traces | where message startswith "AgentSessionOwnerBinding"` — one Warning per
|
||||
non-Match/non-Fresh request with `outcome`, `wouldReject`, `callerKind`, `ownerKind`, `serialMatch`,
|
||||
`endpoint`, `ver`.
|
||||
* **Operator signal** — ops event `SessionOwnerMismatch` (Security / Warning, dual-registered in the
|
||||
admin alert-rule catalog) for would-reject outcomes, throttled to one per session+outcome per hour.
|
||||
`serialMatch=true` counts re-enrollments without wipe; `serialMatch=false` is a foreign device.
|
||||
|
||||
Nothing is refused in stage 1. `SessionOwnershipPolicy` deliberately has no `Rejects` member
|
||||
(pinned by `SessionOwnershipPolicyTests.Stage1_has_no_Rejects_rule`).
|
||||
|
||||
## Enforcement contract (stage 2, not yet built)
|
||||
|
||||
* Backend: `Rejects(outcome)` = the would-reject set; register/ingest/error answer 403 with
|
||||
`RegisterSessionResponse.ErrorCode = Constants.AgentErrorCodes.SessionOwnerMismatch`
|
||||
(`"session_owner_mismatch"`). The code and field already exist so the agent could ship first.
|
||||
* Agent (shipped dormant with this change): `BackendApiClient` reads `errorCode` off a JSON 401/403
|
||||
body into `BackendAuthException.ErrorCode`; `SessionRegistrationHelper` reacts to exactly that code
|
||||
by calling `rotateSession` once and re-registering immediately — **without** feeding
|
||||
`AuthFailureTracker` (five consecutive 403s would otherwise soft-shutdown a perfectly authorized
|
||||
agent). `BackendSessionRegistration.RotateSession` rotates `session.id` (keeping
|
||||
`whiteglove.complete`), re-targets `agentConfig.SessionId` and the `EmergencyReporter`, and drops
|
||||
`spool.jsonl` + `upload-cursor.json` because their lines carry the refused partition key. The
|
||||
`agent_started` event then reports `sessionRotated=true` + `previousSessionId`.
|
||||
* Registration is the first session-scoped call of every agent start and everything after Phase 6
|
||||
reads `agentConfig.SessionId` live, so a mismatch on ingest can only mean an agent older than the
|
||||
rotation logic or a genuinely foreign caller.
|
||||
|
||||
Switch on only after: the shadow distribution has run for weeks, every `wouldReject=true` is
|
||||
explained, and the fleet's `X-Agent-Version` is at or above the rotation-capable release. Then add
|
||||
`Rejects`, delete the stage-1 pin test, update the trust pages (isolation model) and bump their
|
||||
"Last reviewed" date.
|
||||
|
||||
# Residual risk (accepted)
|
||||
|
||||
* The serial is a caller-supplied header on both paths. A certificate holder who also knows a victim's
|
||||
serial can still claim a **legacy** row, or a **bootstrap-owned** row during its handoff window.
|
||||
* One bootstrap token serves many devices; two devices on the same short code can still write into
|
||||
each other's session until the certificate handoff closes it.
|
||||
* `SupersedeOrphanedPredecessorsAsync` is keyed on the announced serial and can still resolve foreign
|
||||
open sessions to `Incomplete`. Follow-up: restrict to rows without owner or with the same
|
||||
`OwnerSerial`, and require a minimum age.
|
||||
|
||||
None of these are closable without a signature scheme over the request body, which is deliberately
|
||||
not built (it would duplicate the TLS proof).
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Security/SessionOwnershipPolicy.cs` — matrix, `WouldRejectUnderEnforcement`, row (de)serialization
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/SessionOwnerBindingObserver.cs` — carriers, throttle, stamping
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/Enrollment/SessionOwner.cs` — column names and kinds
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs` — `StoreSessionAsync(…, ownerToStamp)`, `UpdateSessionOwnerAsync`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs` — rotation branch
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendSessionRegistration.cs` — `RotateSession`
|
||||
* Tests: `SessionOwnershipPolicyTests`, `SessionOwnerBindingObserverTests`, `StoreSessionReregistrationPreserveTests`, agent `SessionRegistrationHelperTests`, `BackendSessionRegistrationTests`, `SessionIdPersistenceTests`, `BackendApiClientAuthClassificationTests`
|
||||
* Grep marker: `SESSION-OWNER-BINDING`
|
||||
@@ -10,7 +10,7 @@ tags:
|
||||
- classification
|
||||
- whiteglove
|
||||
- self-deploying
|
||||
timestamp: 2026-08-23T18:00:00+02:00
|
||||
timestamp: 2026-08-27T20:00:00+02:00
|
||||
---
|
||||
|
||||
# Silent-Session Classification
|
||||
@@ -30,7 +30,10 @@ so a session gets the same verdict regardless of which path noticed the silence
|
||||
subcategory counts (incl. the 30s fallback-confirmed messages), `HasExplicitFailure`,
|
||||
`HasEnrollmentComplete` vs. `HasTerminalComplete` (the Part-1 `whiteglove_complete`),
|
||||
`HasAgentEmergencyBreak`, `DesktopArrived`, `HelloResolved`, `HelloPolicyDisabled`,
|
||||
`SkipUserEsp`, RealmJoin detection/resolution. Session-row flags are passed alongside:
|
||||
`SkipUserEsp`, RealmJoin detection/resolution, `HasAppInstallFailure` (any
|
||||
`app_install_failed`), and `HasAgentMaxLifetimeTimeout` (either max-lifetime shape:
|
||||
`enrollment_failed`/`agent_timeout` or `agent_shutting_down`/`max_lifetime` — the agent is
|
||||
provably gone). Session-row flags are passed alongside:
|
||||
`IsPreProvisioned` + `ResumedAt` (WhiteGlove Part 2) and `IsSelfDeployingProfile`
|
||||
(registry-confirmed `CloudAssignedOobeConfig 0x20|0x40`, set at registration).
|
||||
|
||||
@@ -44,7 +47,9 @@ subcategory counts (incl. the 30s fallback-confirmed messages), `HasExplicitFail
|
||||
| 2 | Account Setup all-succeeded, or `enrollment_complete`, or `whiteglove_complete` on a **non-resumed** session | Succeeded |
|
||||
| 3 | `HasAgentEmergencyBreak` | Incomplete immediately (agent self-destructed, nothing can arrive) |
|
||||
| 4 | `DesktopArrived` + (Hello terminal, or Hello disabled + SkipUserEsp) | Succeeded (user provably finished) |
|
||||
| 5 | Device Setup done | AwaitingUser within grace, Incomplete past grace |
|
||||
| 5 | Device Setup done + within grace + **no** max-lifetime watchdog observed | AwaitingUser |
|
||||
| 5a | Device Setup done + past grace (or watchdog observed) + `DesktopArrived` + no `app_install_failed` | **Succeeded** — completed (assumed), `r5_assumed` |
|
||||
| 5b | Device Setup done, otherwise past grace (or watchdog observed) | Incomplete |
|
||||
| 6 | otherwise | Incomplete |
|
||||
|
||||
Grace = `ResolveGraceHours(SessionGraceHours, AbsoluteMaxSessionHours)` (~51h default),
|
||||
@@ -65,7 +70,12 @@ budget on a Wi-Fi link still associating), and rule 5 labeled finished kiosks
|
||||
Incomplete turned a working rollout into a red wall. Other self-deploying tenants completed
|
||||
normally in the same window, so this is a classification gap, not a profile-wide agent bug.
|
||||
Agent-side companion fix: `SessionRegistrationHelper` now waits up to 15s for a network link
|
||||
and retries 6× (~62s backoff) instead of 5× (~30s).
|
||||
and retries 6× (~62s backoff) instead of 5× (~30s). Measured 2026-08-27 (builds
|
||||
2.0.1428/1429, both carrying the fix): the reconcile share did not move, and the backend saw
|
||||
no registration attempt from any relaunch — no success, no 4xx, zero `register:superseded`
|
||||
in the tenant — so the relaunch never reaches the network at all (device powered off after
|
||||
ESP, or the relaunch never runs). The sweep reconcile is the permanent completion path for
|
||||
this profile, not a stopgap.
|
||||
|
||||
## Callers (all pass both gate inputs)
|
||||
|
||||
@@ -78,13 +88,33 @@ and retries 6× (~62s backoff) instead of 5× (~30s).
|
||||
| Agent stall probe (`session_stalled`) | `EventIngestProcessor.TryMapStallToWhiteGloveAwaitingUserAsync` | Only the WG gate (1b); a stall probe on a self-deploying device with Device Setup done means the agent is alive and something else blocks — stays Stalled. |
|
||||
| Admin retro-reconcile | `LegacyReclassificationService` | Modes `legacy_timeouts`, `pending_orphans`, `self_deploying_silent` (Incomplete/AwaitingUser/Stalled rows with the profile flag, heal-only to Succeeded, admin-marked rows skipped). `POST /api/maintenance/reclassify-legacy?mode=…&dryRun=false&tenantId=…&maxSessions=…`, dry-run by default. |
|
||||
|
||||
## Known gap (decided, not built)
|
||||
## Why rule 5a exists (calibration read 2026-08-27)
|
||||
|
||||
The verdict-calibration matrix showed r5-Incomplete devices re-enrolling at 2.6 % within
|
||||
7 days — the succeeded background (`agent:complete_soft` sits at 2.8 %), not the ~29 %
|
||||
failure level — consistently across tenants, and half the sampled sessions were healthy
|
||||
desktops with a clean app record whose agent died before the Hello terminal (some with the
|
||||
agent's own "AccountSetup treated as complete" synthesis as the literally last event). Past
|
||||
the full grace, "the user phase is still running" is no longer a possible explanation, so
|
||||
desktop + zero `app_install_failed` + no explicit failure now reconciles to Succeeded
|
||||
("completed (assumed)") instead of a red Incomplete. Sessions without a desktop (user never
|
||||
began sign-in: battery-drained, powered off, max-lifetime) and sessions with any failed app
|
||||
stay Incomplete — "completed" must not overclaim. Rule 4 remains the IMMEDIATE reconcile
|
||||
(desktop + Hello proof); 5a only fires where waiting is over. Desktop arrival alone is still
|
||||
rejected as a live completion signal.
|
||||
|
||||
## Closed gap: max-lifetime AwaitingUser (2026-08-27)
|
||||
|
||||
After max-lifetime the agent writes `enrollment-complete.marker` and self-destructs, so an
|
||||
AwaitingUser verdict from that path can never heal — it is a 51h countdown to Incomplete.
|
||||
The emergency-break path (rule 3) already terminalizes immediately; max-lifetime still goes
|
||||
through the grace window by design ("user simply absent"). Revisit if customers read
|
||||
AwaitingUser as "still possible".
|
||||
AwaitingUser verdict from that path could never heal — the calibration correction stream
|
||||
confirmed it: all five observed `maxlife:r5_awaiting` episodes expired to Incomplete
|
||||
unhealed, while live-agent `sweep:r5_awaiting` parks upgraded 7 of 26 to Succeeded. Rule 5
|
||||
therefore skips the grace once `HasAgentMaxLifetimeTimeout` is in the stream and decides the
|
||||
terminal fork (5a/5b) immediately; the max-lifetime ingest appends its trigger event to the
|
||||
read stream so the fact is never missed, and late straggler telemetry still heals a terminal
|
||||
verdict via `TryLateTelemetryReconcileAsync`. WhiteGlove rule 1b deliberately keeps its
|
||||
park — sealed/boxed devices do heal (the observed `maxlife:r1b_awaiting` episode resolved to
|
||||
Succeeded).
|
||||
|
||||
# Examples
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ same table) — the only reliable address: it is typed by the user on the activa
|
||||
page (`PUT /api/preview/notification-email`). The signup admin UPN may have no mailbox and
|
||||
`ContactEmail` is merely seeded from the notification email.
|
||||
|
||||
The PUT is `AuthenticatedUserWithRole`, not a member tier, because the signup user is still
|
||||
roleless until approval auto-promotes them to Admin. The function re-gates on the resolved
|
||||
role (`PreviewWhitelistFunction.MayWriteNotificationEmailAsync`): a member role or platform
|
||||
scope always may write; a roleless caller may write only while the tenant has **no enabled
|
||||
member** at all. Once any member exists the window is closed for good — the address feeds the
|
||||
welcome and farewell mails and is shown to operators as the tenant contact, so an ordinary
|
||||
employee with a JWT but no product role must not be able to redirect or clear it (403).
|
||||
|
||||
That makes activation and address entry concurrent writers. With auto-approve, activation
|
||||
usually wins (observed in production: approve at T+64 s, address saved at T+69 s → the
|
||||
approval path found no address and the mail was silently skipped).
|
||||
@@ -58,6 +66,24 @@ The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantI
|
||||
always sends (explicit intent) and consumes the marker best-effort so the automatic
|
||||
paths never duplicate afterwards.
|
||||
|
||||
## Finding the tenant behind an address
|
||||
|
||||
The provider's delivery log names only the recipient — never the tenant it belonged to. The
|
||||
reverse lookup is therefore part of the tenant console: its search matches `tenantId`,
|
||||
`domainName`, `ContactEmail` **and** the notification address. No other address is searchable
|
||||
(an admin UPN never receives platform mail and would only widen the PII surface of the list).
|
||||
|
||||
The addresses live in a different table than the tenant configs the list is built from, so the
|
||||
console loads them as a map from `GET /api/preview/notification-emails` (GA + read-only Global
|
||||
Reader, `excludeDelegated`) — one cross-partition scan of `RowKey eq 'notification-email'`
|
||||
instead of a point read per tenant. Keys are lowercased on both sides of the join: the address
|
||||
rows are written from the JWT `tid` (lowercase) while a config PartitionKey's casing is not
|
||||
guaranteed. Stored addresses keep the casing the user typed, so matching is case-insensitive.
|
||||
|
||||
The map is **best-effort** in the portal: it powers only the search-by-address, so a failed read
|
||||
degrades that one capability instead of taking the tenant list down. The addresses are not
|
||||
rendered in the list — they are a search key, and stay visible in the tenant editor.
|
||||
|
||||
## Mail transport
|
||||
|
||||
Both transactional mails (welcome, post-offboarding farewell) go through the
|
||||
@@ -89,6 +115,13 @@ properties `Html`, `UpdatedBy`, `UpdatedUtc`), cached 5 minutes, invalidated on
|
||||
|
||||
* The only placeholder is `{{domainName}}`; an empty domain renders as "your organization",
|
||||
matching the built-ins. Subjects are not editable (pinned constants).
|
||||
* The domain is HTML-encoded at the point of substitution (`EmailTemplateService.Render` and the
|
||||
built-in template builders) — the mail goes out under the product's signed sender to a
|
||||
tenant-chosen recipient, so no writer of `DomainName` may ever inject markup into it.
|
||||
`DomainName` itself is tenant identity: seeded once at first login from the UPN domain
|
||||
(`AuthFunction`, only when it passes `TenantConfigValidation.IsValidDomainName` — a strict
|
||||
dotted host name), preserved from the stored row on the full-model config PUT for every
|
||||
caller, and "never writable" in the field-patch flow.
|
||||
* Limit 30 000 characters (Table string property cap is 32K UTF-16 chars).
|
||||
* The send path is fail-soft: a storage error falls back to the built-in body. The admin
|
||||
read path is not — a broken store surfaces as an error instead of silently showing the
|
||||
@@ -118,3 +151,4 @@ properties `Html`, `UpdatedBy`, `UpdatedUtc`), cached 5 minutes, invalidated on
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EmailTemplateService.cs` — override resolution, placeholder rendering, cache
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/EmailTemplatesFunction.cs` — GA endpoints (read / override / reset / test send)
|
||||
* `src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionEmailTemplates.tsx` — portal editor, preview frame, test send
|
||||
* `src/Web/autopilot-monitor-web/app/admin/components/tenantSearch.ts` — the console's match rules (searchable fields, quoted exact mode, lowercased join)
|
||||
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
- classifier
|
||||
- calibration
|
||||
- regression-radar
|
||||
timestamp: 2026-08-23T18:00:00+02:00
|
||||
timestamp: 2026-08-27T20:00:00+02:00
|
||||
---
|
||||
|
||||
# Verdict Calibration (classifier thermometer)
|
||||
@@ -114,15 +114,18 @@ and is wiped with the tenant on offboarding.
|
||||
## Drift radar — `VerdictCalibrationRadar`
|
||||
|
||||
Same statistics as the [rule regression radar](rule-regression-radar.md) (7-day window vs the
|
||||
prior 28 days, ≥ 5 window hits, ≥ 20 window sessions, lift ≥ 2, `MetricsMath.
|
||||
RateIncreaseSeparated`), anchored on yesterday, over every partition with rows (each tenant
|
||||
and `global`):
|
||||
prior 28 days, ≥ 20 window sessions, lift ≥ 2, `MetricsMath.RateIncreaseSeparated`), with
|
||||
stricter floors since the first-month tuning (2026-08-27): **≥ 10 window hits** (verdict
|
||||
shares on single-digit counts are noise — a 3-hit episode survived as such), and a per-path
|
||||
share regression additionally needs **≥ 5 baseline hits** — a path with no established
|
||||
baseline is new (or renamed) vocabulary, not a regression. Anchored on yesterday, over every
|
||||
partition with rows (each tenant and `global`):
|
||||
|
||||
| Kind | Signal | Re-arm |
|
||||
| --- | --- | --- |
|
||||
| `share_regression` | one **backend-decided** path's share of all sessions doubled (`sweep`/`maxlife`/`late`/`retro`/`rule`/`manual`/`ingest`; `agent:*` and `register:*` mirror customer workflow mix — first prod pass fired on `agent:whiteglove_pending` rollout weeks — and `legacy:*` is derived, all excluded) | share < 1.5× baseline, the path stops occurring, or the path is no longer eligible |
|
||||
| `silence_share_regression` | the `sweep:*` + `maxlife:*` share doubled — the backend had to decide more often because the agent went silent (a liveness signal, not a classifier one) | same |
|
||||
| `evidence_gap` | absolute: `r6` (pure fallthrough) decides ≥ 20 % of the window's classifier verdicts, ≥ 20 verdicts | < 15 % or < 20 verdicts |
|
||||
| `share_regression` | one **backend-decided** path's share of all sessions doubled (`sweep`/`maxlife`/`late`/`retro`/`rule`/`manual`/`ingest`; `agent:*` and `register:*` mirror customer workflow mix — first prod pass fired on `agent:whiteglove_pending` rollout weeks — and `legacy:*` is derived, all excluded per-path) | share < 1.5× baseline, the path stops occurring, the path is no longer eligible, or the current numbers fall below the fire floors |
|
||||
| `silence_share_regression` | the `sweep:*` + `maxlife:*` share doubled — the backend had to decide more often because the agent went silent (a liveness signal, not a classifier one). Rule-shaped `legacy:*` paths count into BOTH sums (window and baseline): they were the same backend decisions before the 2026-08-23 instrumentation, and without that continuity the group had a near-zero baseline by construction and fired a lift-124 rollout artifact | share < 1.5× baseline or hits stop |
|
||||
| `evidence_gap` | absolute: `r6` (pure fallthrough) decides ≥ 20 % of the window's classifier verdicts (stamped rule paths plus rule-shaped `legacy:*`, same continuity), ≥ 20 verdicts | < 15 % or < 20 verdicts |
|
||||
|
||||
Episodes are tracker rows `verdictcalibration|{kind}|{path}|{status}` (register = `AddEntity`,
|
||||
409 = already burning, fail-closed; refresh carries `FirstNotifiedAt`; 30-day tracker
|
||||
@@ -140,6 +143,15 @@ Kill switch `VerdictCalibrationRadarDisabled`.
|
||||
re-enrollment within 7 days after Succeeded 8.9 % (lab background), after Failed 24.3 %,
|
||||
after Incomplete 10.6 % — Incomplete behaves like Succeeded, so rules 5/6 are probably too
|
||||
cautious; the matrix now shows this per rule instead of per status.
|
||||
* **First real calibration read (2026-08-27, four days instrumented):** the per-rule and
|
||||
per-tenant split resolved that hand analysis — `legacy:r5_incomplete` re-enrolled at 2.6 %
|
||||
(the `agent:complete_soft` background), spread across tenants, while `legacy:r6`'s 29 %
|
||||
was concentrated in one tenant that disciplinedly redoes failed enrollments (48.5 % there,
|
||||
6.7 % elsewhere — so r6 stays untouched, it correctly flags that tenant). Consequences
|
||||
shipped the same day: rule 5a "completed (assumed)" (see
|
||||
[Silent-Session Classification](silent-session-classification.md)), the max-lifetime grace
|
||||
skip, and the radar floors + legacy group continuity above — the first production month
|
||||
had produced nine standing alerts, every one an instrumentation-rollout artifact.
|
||||
* **Correction stream:** a session swept to `sweep:r5_incomplete`, later completed by the
|
||||
agent → row `agent:complete` with `PriorVerdictPath = sweep:r5_incomplete`; the aggregate
|
||||
increments `OverriddenByLateCompletion` on the `sweep:r5_incomplete / Incomplete` bucket.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Vulnerability Correlation Sources (NVD, KEV, MSRC, EPSS)
|
||||
description: How the software inventory is turned into CVE findings - CPE mapping, the per-CPE NVD cache with its budget and TTLs, KEV/MSRC/EPSS enrichment, the throttle contract that keeps a refused NVD answer out of the cache, and the act/attend/track priority.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs
|
||||
tags:
|
||||
- backend
|
||||
- vulnerability
|
||||
- nvd
|
||||
- epss
|
||||
- kev
|
||||
- msrc
|
||||
- cache
|
||||
timestamp: 2026-08-29T18:00:00+02:00
|
||||
---
|
||||
|
||||
# Vulnerability Correlation Sources
|
||||
|
||||
The Software Inventory & Vulnerability Analyzer (tenant opt-in) ships the installed-software
|
||||
list with the session; the backend turns it into CVE findings entirely from cached public
|
||||
reference data. Four sources, all trustworthy primaries, no aggregators:
|
||||
|
||||
| Source | What it contributes | Fetch shape | Cache |
|
||||
| --- | --- | --- | --- |
|
||||
| NVD 2.0 (NIST) | CVE records per product: CVSS v3.1 (v3.0 fallback) score, severity, **vector**, description, affected version ranges; CPE dictionary for auto-mapping | `cves/2.0?virtualMatchString={cpe}` per product, `cpes/2.0?keywordSearch=` for unmapped titles; optional API key (`AdminConfiguration.NvdApiKey`) | Azure Table `VulnerabilityCache`, one row per CPE (`cve_{cpe}` / `cache_result`), `CveDataJson` chunked; soft TTL 7 d (serve + background refresh), hard 14 d |
|
||||
| CISA KEV | "Exploited in the wild" flag, due date, required action, ransomware use | Whole catalog JSON | In-memory dictionary, 24 h, blob mirror `kev-catalog.json` |
|
||||
| MSRC | Microsoft-side confirmation: document id/title, release date, Microsoft severity / score / vector | CVRF v3.0 index of the last six Patch Tuesdays | In-memory index, blob mirror `msrc-index.json` |
|
||||
| FIRST EPSS | Probability (0..1) that the CVE is exploited within 30 days + percentile + scoring date | `api.first.org/data/v1/epss?cve=a,b,c` - 100 ids per request, unauthenticated | Stored **on the cached CVE** (no separate table): filled when a CPE row is (re)fetched, re-scored for every row by the nightly sync |
|
||||
|
||||
Nothing about a tenant leaves the backend. The one outbound query that carries content is the
|
||||
EPSS lookup, and its payload is the list of public CVE ids held in the shared, tenant-agnostic
|
||||
CPE cache. Customer-facing wording: `autopilotmonitor-docs/trust/data-flows.md` and the
|
||||
terms page attribution block.
|
||||
|
||||
# Schema
|
||||
|
||||
## Cached CVE (`CachedCve`, serialised into `CveDataJson`)
|
||||
|
||||
```
|
||||
CveId, CvssScore, CvssSeverity, CvssVector?, Description (<=500), PublishedDate,
|
||||
EpssScore?, EpssPercentile?, EpssDate?, AffectedVersions[] {Criteria, VersionStart/End Incl/Excl}
|
||||
```
|
||||
|
||||
`JsonOptions` is `WhenWritingNull` + case-insensitive, so rows written before the vector / EPSS
|
||||
properties existed deserialise with those as `null`, and a row is never rewritten just to add
|
||||
nulls. `CveCacheBudget` bounds the row (24 months, or CVSS >= 7 within 60 months; max 100 CVEs
|
||||
per CPE, 50 ranges per CVE) - which is also why one CPE fill is exactly one EPSS request.
|
||||
|
||||
`ProjectNvdResponse` is the single NVD -> `CachedCve` projection; the live fill and the nightly
|
||||
refresh both call it, so both write the same shape. `BuildCveCacheRow(cpe, cves, cachedAt)` is the
|
||||
single row builder; `cachedAt` is the **NVD** freshness stamp, an EPSS-only rewrite passes the
|
||||
existing value through so it does not look like a fresh NVD fetch to the 7-day TTL.
|
||||
|
||||
## Per-finding output (report `findings[].vulnerabilities[]`)
|
||||
|
||||
```
|
||||
cveId, cvssScore, cvssSeverity, cvssVector?, description, publishedDate, source ("NVD" | "NVD+MSRC"),
|
||||
isKev, kevDueDate, kevRequiredAction, knownRansomware,
|
||||
epssScore?, epssPercentile?, epssDate?,
|
||||
priority ("act" | "attend" | "track"),
|
||||
msrc* (when MSRC confirmed), versionMatchConfidence ("low" on wildcard CPE matches)
|
||||
```
|
||||
|
||||
Finding level: `riskLevel` (unchanged CVSS+KEV bands), `priority` (highest band), `maxEpssScore?`.
|
||||
`scan_summary` additionally carries `act_cves` / `attend_cves`. `MergeReports` (Part 1 + Part 2)
|
||||
recomputes all of these. The `CveIndex` row (one per tenant/CVE/session) carries `EpssScore`
|
||||
(max over occurrences, absent when unscored) and `Priority` (highest), which the fleet summary
|
||||
(`TopCve.epssScore` / `priority`) reads.
|
||||
|
||||
## Priority (`CvePriority.Compute`)
|
||||
|
||||
* `act` - in CISA KEV.
|
||||
* `attend` - EPSS >= 0.1 **or** CVSS >= 9.0.
|
||||
* `track` - everything else.
|
||||
|
||||
Three visible rules instead of a weighted composite: severity (`riskLevel`) keeps saying how bad,
|
||||
priority says how soon. A CVE without an EPSS score is "unknown likelihood" - it can still reach
|
||||
`attend` via CVSS, never via a defaulted 0. The thresholds are constants; changing them is a
|
||||
product decision, not a tuning knob.
|
||||
|
||||
# Throttle contract (NVD)
|
||||
|
||||
The typed `HttpClient` runs under the `ExternalDataApi` Polly policy: 429/5xx/timeouts are retried
|
||||
three times (1/2/4 s + jitter) with a circuit breaker. A 429 that reaches `NvdApiClient` therefore
|
||||
means NVD is refusing us for real. Two things then happen, and one deliberately does not:
|
||||
|
||||
1. `NvdFetchResult<T>` reports `Succeeded = false`, `Throttled = true`. `Data` is an empty object
|
||||
so enumerating callers keep working, but **nothing that persists may treat it as an answer**:
|
||||
the CPE cache fill returns null and writes no row; the negative auto-resolve cache is not
|
||||
touched; the admin auto-resolve reports `nvd_throttled`. Before this contract an empty response
|
||||
was cached as "0 CVEs" for seven days - a product NVD happened to throttle disappeared from
|
||||
every report for a week.
|
||||
2. A process-wide cooldown opens: `Retry-After` when present (delta or date, capped at 120 s),
|
||||
otherwise 30 s. During the cooldown every NVD call fails fast with `Throttled` - no wait, no
|
||||
retry chain. Worst case added latency per cooldown window is one Polly chain (~7.5 s), never
|
||||
a per-item stall; the nightly refresh breaks out of its loop on the first throttled answer and
|
||||
reports the remainder as `cveSkippedThrottled`.
|
||||
3. Not done on purpose: no in-process retry beyond Polly, no VulnCheck-style mirror, no separate
|
||||
"throttled" cache state. Hard-expired cache rows keep serving while NVD is unavailable
|
||||
(better than nothing, logged); a product with no row at all is reported without CVE data for
|
||||
that run and gets a real attempt on the next correlation.
|
||||
|
||||
# Nightly sync (`VulnerabilityDataSync`, 03:00 UTC) and the manual buttons
|
||||
|
||||
1. KEV catalog refresh (blob mirror updated).
|
||||
2. MSRC index refresh (last six Patch Tuesdays).
|
||||
3. NVD refresh of cache rows older than 7 days (sequential, breaks on throttle).
|
||||
4. **EPSS re-score of every `cve_*` row** - all ids batched through FIRST, only rows whose score /
|
||||
percentile / date changed are rewritten (with their original `CachedAt`). A missing id in the
|
||||
response keeps yesterday's score: a failed batch never erases data.
|
||||
|
||||
Steps 3 and 4 live in `VulnerabilityCacheRefreshService` (singleton, one in-flight slot per walk),
|
||||
which the timer and the Global-Admin buttons share, so a manual run writes exactly what the nightly
|
||||
run writes. Four endpoints, one per source, each with its own "Last … Sync" line on the admin page
|
||||
(`GET vulnerability/sync-status`):
|
||||
|
||||
| Button | Endpoint | Shape | Timestamp |
|
||||
| --- | --- | --- | --- |
|
||||
| Sync KEV | `POST vulnerability/sync` | synchronous | `VulnerabilityDataLastSyncUtc` |
|
||||
| Sync MSRC | `POST vulnerability/sync-msrc` | synchronous | `MsrcLastSyncUtc` |
|
||||
| Refresh NVD Cache | `POST vulnerability/sync-nvd` | **202 + background** (minutes, NVD-rate-limited); 409 while running; the page polls `nvdRefreshRunning` / `nvdLastRun` every 10 s | `NvdCacheLastRefreshUtc` - stamped only for a walk that reached every stale row (no throttle cut-off) |
|
||||
| Sync EPSS | `POST vulnerability/sync-epss` | synchronous (seconds); 409 while running | `EpssLastSyncUtc` |
|
||||
|
||||
The timer skips a walk whose slot a manual run currently holds (`cveSkippedBusy` in telemetry)
|
||||
rather than queueing a duplicate. `nvdLastRun` / `epssLastRun` are in-memory on the answering
|
||||
instance (null after a cold start); the persisted timestamps are the durable signal.
|
||||
|
||||
Telemetry event `VulnerabilityDataSyncCompleted` carries `epssRows / epssCves / epssScored /
|
||||
epssRowsRewritten / epssError` next to the KEV / MSRC / CVE counters; the manual EPSS run emits
|
||||
`ManualEpssSyncCompleted`.
|
||||
|
||||
# Examples
|
||||
|
||||
* Chrome 126 finding: `CVE-2024-4947` CVSS 8.8 HIGH, `AV:N/AC:L/PR:N/UI:R`, KEV yes -> `priority =
|
||||
act`, riskLevel `critical` (KEV) - unchanged severity semantics, priority now explicit.
|
||||
* A 5.5 MEDIUM local-privilege CVE with EPSS 0.31 -> `attend`; the same CVE with EPSS 0.004 ->
|
||||
`track`. Same severity band, different order of work.
|
||||
* Nightly sync while NVD throttles: `[VulnSync] NVD throttled - leaving 37 stale entries for the
|
||||
next run`, then `[VulnSync] EPSS refresh: 1412/1450 CVEs scored across 212 rows, 180 rows
|
||||
rewritten` - EPSS is independent of NVD's mood.
|
||||
|
||||
# Not done (recorded)
|
||||
|
||||
* **ExploitDB CSV** as a "public exploit exists" flag (static file, daily, same shape as the KEV
|
||||
mirror) - useful, cheap, deliberately not added: four primary sources are enough for now and
|
||||
each additional outbound dependency is a trust-page line. Revisit if customers ask "is there a
|
||||
public exploit?".
|
||||
* GitHub / Nuclei PoC search, composite 0-100 risk scores, CWE -> ATT&CK mapping - noise for an
|
||||
Intune admin, not adopted.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/NvdApiClient.cs` - `NvdFetchResult`, cooldown, `ComputeCooldown`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/EpssApiClient.cs` - batching, `ParseResponse`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CvePriority.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs` - `ProjectNvdResponse`, `ApplyEpssScores`, `BuildCveCacheRow`, `StampFindingPriority`, `FetchAndCacheCvesFromNvdAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs` - `RefreshStaleCveCacheAsync`, `RefreshEpssScoresAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs` - `BuildCveIndexEntities`, `ScanCveIndexAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/EpssAndNvdThrottleTests.cs`
|
||||
* FIRST EPSS API: https://api.first.org/epss/ - CISA KEV: https://www.cisa.gov/known-exploited-vulnerabilities-catalog
|
||||
+14
-3
@@ -19,21 +19,27 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
|
||||
# Agent
|
||||
|
||||
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [diagnostics package](agent/diagnostics-package.md) (built-in section catalog shared with the portal, RealmJoin/Device Preparation collection gates, caps, manifest), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
|
||||
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [diagnostics package](agent/diagnostics-package.md) (built-in section catalog shared with the portal, RealmJoin/Device Preparation collection gates, handle-validated reads against the junction race, caps, manifest), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
|
||||
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
|
||||
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
|
||||
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
|
||||
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
|
||||
* [ESP Completion Starvation — the sits-d Cloud-PC complex](agent/post-reboot-completion-recovery.md) - Why a SkipUser=true flow (no user ESP page → no Shell-Core 62407, no AccountSetup registry) left the completion gate satisfied but never evaluated — five Cloud PCs idled six hours at "waiting on: hello_resolution" — and the fix: the observed skip stands in for the final-exit evidence in the IME user-session completion attempt. Plus the two adjacent defects fixed alongside: the edge-only user-apps-settled synthesis (now level-triggered) and the never-wired Shell-Core restart replay (now Hello-wizard-only, with the reasoning why a replayed 62407 must never reach the reducer).
|
||||
* [Zero User-Targeted Apps — the starved user-phase completion gate](agent/zero-user-apps-completion-gate.md) - Why a device with no user-targeted Intune apps could never satisfy the IME user-session gate — IME skips its "Completed user session" line entirely in the zero-app branch — turning the 30-minute completion backstop into `esp_exit_without_completion_evidence` on a third of one tenant's enrollments, with the user at the desktop and RealmJoin still installing. The two fixes: read the line IME does write (IME-USER-SESSION-ZERO-APPS), and never fail a session while a completion gate with its own armed deadline is deliberately holding it.
|
||||
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
|
||||
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
|
||||
* [CMTrace Time Resolution](agent/cmtrace-time-resolution.md) - Converting bias-less CMTrace local times to UTC without trusting any process's timezone belief: per-line self-anchoring on the 15-minute offset grid for provably fresh lines, uniform marked reader-zone fallback for backlog, and the provenance vocabulary (bias / line-anchored / reader-zone-fallback) that keeps every event re-derivable.
|
||||
* [CMTrace Time Resolution](agent/cmtrace-time-resolution.md) - Converting bias-less CMTrace local times to UTC without trusting any process's timezone belief: per-line self-anchoring on the 15-minute offset grid for provably fresh lines, uniform marked reader-zone fallback for backlog, and the provenance vocabulary (bias / line-anchored / reader-zone-fallback) that keeps every event re-derivable; plus the linear-cost CMTrace line parser (first open / last parseable trailer, timeout-backed) and the agent multiline caps.
|
||||
* [IME Pattern Health — the pattern-drift loop](backend/ime-pattern-health.md) - How a Microsoft change to IME log wording becomes visible before it breaks timelines: tracker health counters in `agent_metrics_snapshot`, the one-shot `ime_tracker_degraded` Warning, the session-end `ime_pattern_hits` histogram folded into the global per-IME-version `ImePatternStats` table, the data-driven drift alarm (`ImePatternDriftSuspected`: an expected pattern — ≥80 % on the fleet baseline — matched in none of ≥25 sessions on a newer version), the Global Admin → Metrics → IME Pattern Health matrix and the `get_ime_pattern_health` MCP tool.
|
||||
* [IME Log Tracker — Anchored Patterns and the Bounded Read Loop](agent/ime-log-tracker-matching-budget.md) - The IME Logs folder is user-writable, so the SYSTEM tracker must stay linear on hostile input: every shipped pattern starts with `^` (a non-match is O(prefix)), no chained unbounded scans, a byte-bounded line reader (32 MB — real `Get policies` lines reach 5.6 MB) with exact bookmarks, a 2 s per-line matching budget, held-back unterminated tails, and one Warning per pass instead of silent Debug timeouts.
|
||||
* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
|
||||
* [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop.
|
||||
* [IME Process Watcher — Identity Before Attach, Re-Arm After Exit](agent/ime-process-watcher.md) - `ime_process_exited` comes only from a process that runs in session 0 with its image under the IME install root (QueryFullProcessImageName, cross-bitness) — a process name is not an identity (CWE-807: a standard user can start a renamed binary during AccountSetup); the oldest trusted candidate wins, untrusted matches are logged once per PID and never attached, and a reported exit re-arms discovery so an IME restart and a later crash are both seen.
|
||||
* [Local Admin Analyzer](agent/local-admin-analyzer.md) - What local_admin_analysis asserts at enrollment start/end: every local account inventoried, disabled ones included (a /active:no backdoor is dormant at both scans), Administrators membership read from the SAM by well-known SID (NetLocalGroupGetMembers, WMI-independent), unexpected admin members scored +40, built-ins matched by well-known RID (localized Guest/Administrator never flagged), shutdown-time dynamic allowance never covers a local SAM account (CWE-807), junctions and $GUID folders are not profiles, non-local members (Entra role SIDs) listed but never flagged, built-in Administrator enabled-state reported but not scored.
|
||||
* [Power-State Watcher](agent/power-state-watcher.md) - Live AC↔battery transitions and 50/30/15% downward battery-threshold crossings during enrollment via a WMI Win32_PowerManagementEvent push subscription (zero polling, never armed on battery-less devices): the power_state_change event (transition field, per-level lifetime latch, 5s debounce, emission cap) and the analyze rules ANALYZE-DEV-009 (battery <15%, high) / ANALYZE-DEV-010 (AC→battery, warning) with on_event interim triggers.
|
||||
* [System Timeline Watcher](agent/system-timeline-watcher.md) - Ground truth for two timeline blind spots from one System-channel EventLogWatcher with pre-agent backfill: system_clock_changed (Kernel-General 1 — oldTime/newTime/signed timeDeltaMs/reason/setting process; 2 s micro-slew floor, ≥5 min Warning+immediate) and system_sleep_episode (Power-Troubleshooter 1 sleep/hibernate + Kernel-Power 507 Modern Standby; authoritative enteredAt/exitedAt payload instants, 60 s Modern-Standby floor). Consumed by the TimeJumpBadge clock-set cause, the SessionInfoCard standby note, time-attribution SleepSpans (AttributionVersion 3) and ANALYZE-DEV-012.
|
||||
* [DO GroupId Auto-Set](agent/do-group-id-auto-set.md) - EnableDoGroupIdAutoSet (ConfigVersion 40): the agent seeds the Delivery Optimization DOGroupId policy with a deterministic network-fingerprint GUID (default gateway IPv4 + SendARP MAC, RealmJoin-compatible byte layout) so same-network devices form one DO peering group during ESP; ownership-tracked via the StartupEventGate fingerprint, foreign DOGroupId/DOGroupIdSource policies always win, no random-GUID fallback.
|
||||
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
|
||||
* [WiFi SSID and the Windows 24H2 Location Gate](agent/wifi-ssid-location-gate.md) - Why wifi_signal_info vanished on Windows 11 24H2+: WlanQueryInterface(wlan_intf_opcode_current_connection) is gated behind precise-location consent, the prompt only fires in a user context outside System32, and SYSTEM in session 0 falls back to the device-level ConsentStore value - so netsh (same gate) could never cover for it. The tiered WifiInfoProvider (native, then ungated WinRT GetConnectedSsid, then netsh), SSID-only semantics on tier 2, and the Warning-level rc=5 diagnostic that names the gate.
|
||||
|
||||
# Rules
|
||||
|
||||
@@ -57,6 +63,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
|
||||
* [Verdict Calibration](backend/verdict-calibration.md) - How every session status write declares its VerdictPath (origin:detail vocabulary incl. classifier rule ids), how overriding a verdict preserves PriorStatus/PriorVerdictPath as the correction stream, the daily per-path calibration aggregate with the 7-day re-enrollment proxy and read-side legacy derivation, the GA endpoint / MCP tool / admin matrix, and the drift radar (share, silence-share, r6 evidence gap) with its operator-only ops events.
|
||||
* [App Version Duration Regression Radar](backend/app-version-regression-radar.md) - How app versions whose median install duration regressed vs. the previous version are detected (first-seen-ordered versions, median lift ≥2× plus a 5-minute absolute floor over measured installs only), how alert episodes live in the appversionregression tracker keyspace with drain/median re-arm semantics, and the bell/ops-event/analytics surfaces.
|
||||
* [Session Owner Binding](backend/session-owner-binding.md) - Agent endpoints authenticate per tenant but write per caller-supplied session id; each Sessions row is now bound to the device identity that created it (cert thumbprint + Intune device id from the CN, or bootstrap code + serial), the rule matrix that keeps restart / WhiteGlove Part 2 / bootstrap→cert handoff / cert re-issue working, the shadow telemetry (`SessionOwnerBinding` request dimension, `AgentSessionOwnerBinding` warning, `SessionOwnerMismatch` ops event), and the enforcement contract where a 403 `session_owner_mismatch` makes the agent rotate its session id instead of counting an auth failure.
|
||||
* [Plan Downgrade Policy & the Retention Grace Period](backend/plan-downgrade-retention-grace.md) - What happens when a tenant loses Pro: every entitlement gates read-time and reversibly ("gate, don't destroy"), except retention — a 30-day grace keeps the Pro cap before the Community cap starts hard-deleting; the ProDowngradedUtc/trial-expiry anchor model, the TenantPlanDowngraded/RetentionGrace* ops events, and the deliberate non-grace of everything else.
|
||||
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
|
||||
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
|
||||
@@ -66,6 +73,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
|
||||
# MCP Server
|
||||
|
||||
* [MCP Protocol Eras & Client ID Metadata Documents](mcp/protocol-eras-and-cimd.md) - One per-request factory serving the 2026-07-28 stateless revision (server/discover, ttlMs/cacheScope, Mcp-Method routing) and 2025-era initialize clients side by side, why the legacy leg stays a hand-wired JSON+gzip transport, the never-public cache-hint contract, and the CIMD client-registration path (SSRF-gated document fetch, same redirect allowlist) next to the deprecated dynamic registration.
|
||||
* [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.
|
||||
* [MCP Tool Telemetry](mcp/tool-telemetry.md) - The three telemetry layers for judging tool quality: backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
|
||||
|
||||
@@ -76,7 +84,10 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
# 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.
|
||||
* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, and the shadow-then-enforce rollout with its volume-controlled telemetry.
|
||||
* [Vulnerability Correlation Sources](backend/vulnerability-correlation-sources.md) - How the software inventory becomes CVE findings from four cached primaries (NVD per-CPE cache with budget and TTLs, CISA KEV, MSRC, FIRST EPSS stored on the cached CVE and re-scored nightly), the NVD throttle contract (a refused answer is never cached as "0 CVEs"; bounded process-wide cooldown honouring Retry-After), the persisted CVSS vector, and the act/attend/track priority that keeps severity and likelihood as two visible signals instead of one composite score.
|
||||
* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, the shadow-then-enforce rollout with its volume-controlled telemetry, and why `X-ARR-ClientCert` is the only certificate transport (TLS handshake = proof of possession; bootstrap routes token-only, fail-closed).
|
||||
* [Admin Identity Binding](backend/admin-identity-binding.md) - Why a GlobalAdmins / DelegatedAdmins row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant (domain re-registration, UPN recycling), the AdminIdentityBindings table pinning each cross-tenant-role UPN to its immutable home tid + oid, the fail-closed resolution order in both role services (row → binding → entitlement), the first-sign-in object-id pin with its race semantics, the 409 on grant-time conflicts, and the explicit GET/PUT/DELETE rebind surface.
|
||||
* [IME Installer Archive](backend/ime-msi-archive.md) - How every fleet-observed IME build is archived as its MSI: the ime_agent_version sighting that queues the job (first sighting, or a later sighting with a version-authoritative CSP URL after a 24 h backoff), the primary/secondary/hotfix candidate walk, the managed MSI ProductVersion check that keeps another ring's build out of the version folder (Failed:VersionMismatch), provenance with the full candidate walk, the skill-side overwrite path for corrections, and the trust boundary of a device-reported version (plausibility guard, one sighting per session, second-tenant corroboration, fleet-confirmed tenant view).
|
||||
|
||||
# Conventions for this bundle
|
||||
|
||||
|
||||
+154
@@ -1,7 +1,161 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-30 (15)
|
||||
|
||||
* **Update**: `backend/lifecycle-manifests-and-session-scope.md` — SessionTenantLookup is now claimed first-writer-wins at registration (create-only Add BEFORE any session write; foreign owner ⇒ 409 `session_owner_mismatch` + `SessionTenantConflict` ops event, nothing written; not fail-soft). Closes the poisoning path where any tenant's device could re-point a victim sessionId's mapping at its own tenant and hijack every no-tenantId Global Admin/Reader read and GA write (annotation lane). `ResolveSessionScopeAsync(requireGlobalAdmin: true)` corroborates the resolved tenant against its Sessions row (silent — no operator confirmation). Self-heal write is Add-only; `scripts/Migration/Backfill-SessionTenantLookup.ps1` closes the legacy window once. No tombstone on deletion (deliberate: unbounded growth for no gain).
|
||||
|
||||
## 2026-08-30 (14)
|
||||
|
||||
* **Update**: `agent/decision-engine.md` — new invariant: every payload-derived string stored into `DecisionState` is bounded to 256 chars by `FactStringBounds` before any dedupe comparison (RealmJoin package id/version/scope, failed app ids, `HelloOutcome`, `ImeMatchedPatternId`, `EspAdvisoryFailureCategory`, RJ product version/channel). Backend `ReducerVerifier` now replays decode-fold-discard instead of materialising all decoded signals, and `ISignalRepository.QueryBySessionAsync` carries a cumulative `PayloadJson` budget (`SignalQueryLimits`, 32 M chars) reflected in the `truncated` flag of `sessions/{id}/signals` and `sessions/{id}/reducer-verification`.
|
||||
|
||||
## 2026-08-30 (13)
|
||||
|
||||
* **Update**: `agent/decision-engine.md` — RealmJoin self-update observation. `realmjoin_detected` reported the MSI-shipped build (4.21.4) although the tray auto-updated to 4.21.18 nine minutes later and ran the whole deployment on it (gktatooine session 946ccbd6). `RealmJoinWatcher` now watches `HKLM\...\Uninstall\RealmJoin` (the updater's only registry write) after detection, re-reads the binary version on change and emits `realmjoin_autoupdate_detected` (`previousVersion`, `newVersion`, `releaseChannel`); the typed `RealmJoinAutoUpdateDetected` signal overrides `RealmJoinFacts.ProductVersion`/`ReleaseChannel` and nothing else. Portal device card shows the updated version with an "auto-updated from" note.
|
||||
|
||||
## 2026-08-30 (12)
|
||||
|
||||
* **Update**: `web/portal-navigation-prefetch.md` — hosting citation now points at `deploy-web.yml`: the portal is built in CI (setup-node 22, `npm ci`, `next build`) and uploaded with the pinned SWA CLI; the Azure/static-web-apps-deploy action, its Oryx in-container build and the per-PR preview environments are gone. All non-GitHub-owned actions are SHA-pinned; the release and rules auto-commit steps use first-party `gh`/`git`.
|
||||
|
||||
## 2026-08-30 (11)
|
||||
|
||||
* **New**: `agent/ime-process-watcher.md` — `ImeProcessWatcher` no longer attaches to the first `IntuneManagementExtension` name match: `ImeProcessIdentity` requires session 0 + image under the IME install root (`QueryFullProcessImageName`, cross-bitness), prefers the oldest trusted candidate, logs untrusted matches once per PID, and re-arms discovery after every reported exit (previously one-shot — a fake in a user session could forge `ime_process_exited` and mute the real signal for the session; even an IME service restart was never watched again). Registered in `index.md`.
|
||||
|
||||
## 2026-08-30 (10)
|
||||
|
||||
* **Update**: `agent/ime-log-tracker-matching-budget.md` — the per-line budget sums only `Regex.Match` time; the previous wall-clock window also counted match handlers and scheduler stalls and broke twice on genuine lines of a stalled Hyper-V guest (session 946ccbd6). `backend/ime-pattern-health.md` — `ime_pattern_hits` is Debug severity (operator signal, hidden from the default timeline; the IME Tracker card and the ingest fold read it regardless). The session page's "IME Tracker" card moved to the right column.
|
||||
|
||||
## 2026-08-30 (9)
|
||||
|
||||
* **New**: `backend/ime-pattern-health.md` — the pattern-drift loop: `ImeTrackerHealth` counters + per-pattern histogram in the agent (restart-safe via `ImeTrackerState`), `ime_*` keys in `agent_metrics_snapshot`, one-shot `ime_tracker_degraded` Warning, session-end `ime_pattern_hits` (every enabled pattern, zeros included, `imeVersion`), backend `ImePatternStats` (PK = IME version, batch upsert, permanent), `ImePatternDriftEvaluator` (baseline = biggest version ≥100 sessions, expected = ≥0.8 hit rate, drift = 0 hits in ≥25 sessions, once per cell) → `ImePatternDriftSuspected` ops event, `GET metrics/ime-pattern-health` (GlobalReadOrAdmin), web section `admin/metrics/ime-pattern-health` + session-page "IME Tracker" rows, MCP `get_ime_pattern_health`. Registered in `index.md`.
|
||||
|
||||
## 2026-08-30 (8)
|
||||
|
||||
* **Update**: `agent/ime-log-tracker-matching-budget.md` — pattern-pack hygiene against the IME decompiles (1.97.107.0 / 1.104.102.0 / 1.105.103.0, spot checks 1.50 / 1.83): no wording drift in any shipped pattern; the 11 disabled legacy patterns (IME-DETECTED-OLD, IME-SET-CURRENT-1/2/3, IME-SKIP-APPLICABILITY-NOTMET, IME-SKIP-DEPENDENCY-DETECT, IME-SKIP-DETECTED, IME-SKIP-UNINSTALL-NOTDETECTED-2, IME-UPDATE-NAME-1/2/3) are deleted — their literals last existed in IME 1.50, the fleet starts at 1.97; IME-DO-TIMEOUT-1/2 match the exact source literal (`DO downloading is not finished within timeout, jobId = …, fileId = …`) instead of two lookaheads; IME-DO-TEL is documented as dead from 1.104 on and kept only for the 1.101/1.103 fleet share.
|
||||
|
||||
## 2026-08-30 (7)
|
||||
|
||||
* **New**: `agent/ime-log-tracker-matching-budget.md` — the shipped IME pattern pack is anchored (`^` on all 90 patterns, `IME-APP-VERSION` without the chained `[^}]*?` scan) and the tracker's read loop is bounded: `BoundedLineReader` (32 MB per line, exact byte bookmarks), 2 s per-line matching budget, held-back unterminated EOF tails with a 1 s settle, one Warning per pass (`oversizedLines` / `regexTimeouts` / `lineBudgetBreaks`); the unanchored `PS-SCRIPT-CONTEXT` false positives on `[Win32App] SideCarScript…` lines are gone. `--run-ime-matching` assembles multiline entries like the tracker. Registered in `index.md`.
|
||||
|
||||
## 2026-08-30 (6)
|
||||
|
||||
* **Update**: `rules/rule-id-namespace.md` — guard 1 rewritten: gather RuleStats (tenant + `global_*` rows) are now resolved against the tenant's active gather catalog (`EventIngestProcessor.ResolveFiredGatherRules`), metadata catalog-sourced, unknown IDs dropped — same design as the analyze path; the `IsReservedBuiltInId` pattern gate on device-supplied IDs is gone.
|
||||
|
||||
## 2026-08-30 (5)
|
||||
|
||||
* **Update**: `backend/admin-identity-binding.md` — new "The MCP whitelist is a third UPN-keyed grant" section: `McpUsers` rows (grant, `UsagePlan` override and Disabled kill-switch) are honoured only for the identity the UPN is bound to (`McpUserService` row → `IsBoundAsync`, `GetBoundMcpUserAsync` for the quota plan); `POST global/mcp-users` binds first with the shared resolver / 422 / 409 contract; unbound legacy rows are inert.
|
||||
|
||||
## 2026-08-30 (4)
|
||||
|
||||
* **Update**: `backend/ime-msi-archive.md` — new "Trust boundary of a sighting" section: the device-supplied `ime_agent_version` string is gated by `ImeMsiArchiver.IsPlausibleVersion` (dotted digits within MSI ProductVersion bounds) before it can become a GLOBAL `ImeVersionHistory` RowKey (`ImeVersionSighting.Rejected` → no row/ops event/archive job); a session counts once (`UpdateSessionImeAgentVersionAsync` returns whether the version changed); a second tenant's sighting stamps `CorroboratedAt`; the MemberRead projection lists only fleet-confirmed versions (Archived, corroborated, or first seen before 2026-08-31), Global scope sees every row.
|
||||
|
||||
## 2026-08-30 (3)
|
||||
|
||||
* **Update**: `agent/local-admin-analyzer.md` — built-ins matched by well-known RID (500/501/503/504) so localized Guest/Administrator are not flagged now that disabled accounts are inventoried; `kioskUser0` built-in; shutdown-time dynamic allowance restricted to non-SAM identities (a logged-in local account is never exempted — CWE-807) with new `logged_in_local_accounts` field; junctions and `$<32 hex>` folders skipped by the profile check.
|
||||
|
||||
* **New**: `agent/local-admin-analyzer.md` — `LocalAdminAnalyzer` no longer skips disabled accounts (a `/active:no` backdoor re-enabled after enrollment was invisible at both scans) and reads Administrators-group membership from the SAM via `NetLocalGroupGetMembers` on `S-1-5-32-544` (`LocalGroupNativeMethods`, WMI-independent). New event fields `unexpected_admin_members` (+40), `account_details`, `administrators_group.{enumerated,error_code,members}`, `builtin_administrator_enabled` (reported, not scored). Non-local members (Entra role SIDs, domain groups) are listed, never flagged. `ANALYZE-ID-002` 1.2.0 explanation names the new fields. Registered in `index.md`.
|
||||
|
||||
## 2026-08-30 (2)
|
||||
|
||||
* **Update**: `agent/cmtrace-time-resolution.md` — new "Line parsing (bounded cost)" section: `CmTraceLogParser.TryParseLine` is no longer one greedy-`.*` regex (quadratic on hostile lines, no timeout) but a linear first-open / last-parseable-trailer search with a `\G`-anchored, timeout-backed trailer regex that returns exactly what the old regex did; the agent's multiline buffer gains a 1 MB char cap next to the 100-line cap, both logged at Warning, and the tail of a dropped entry is skipped instead of raw-matched.
|
||||
|
||||
## 2026-08-30 (1)
|
||||
|
||||
* **Update**: `agent/diagnostics-package.md` — reads are validated on the handle, not the path: `PinnedSourceFolder` pins each section folder (its final path must equal the validated path; held open so the chain cannot be renamed) and opens every file with `FILE_FLAG_OPEN_REPARSE_POINT`, requiring the handle's final path to be `<canonical folder>\<relative path>`. A subdirectory swapped for a junction between enumeration and open is skipped (`SKIPPED (resolved outside validated folder)`) instead of copied into the package.
|
||||
|
||||
## 2026-08-29 (12)
|
||||
|
||||
* **Update**: `backend/tenant-activation-welcome-mail.md` — `PUT preview/notification-email` moved to `AuthenticatedUserWithRole`; roleless callers may write the address only while the tenant has no enabled member (signup window), afterwards a member role is required.
|
||||
|
||||
## 2026-08-29 (11)
|
||||
|
||||
* **Update**: `rules/gather-rule-phase-scoping.md` — `on_event` feedback-loop guard: `TimelineEventStream` publishes the event source and `GatherRuleExecutorHost` never dispatches `on_event` for gather-emitted events (a rule triggering on its own output type looped for the whole session); per-rule on_event execution cap (100/session); backend rejects `on_event` rules whose trigger equals their output type or `gather_result`.
|
||||
|
||||
## 2026-08-29 (10)
|
||||
|
||||
* **Update**: `backend/tenant-activation-welcome-mail.md` — welcome/farewell mails HTML-encode the domain at substitution; `DomainName` is frozen on the full-model config PUT (was persisted verbatim) and the first-login seed only accepts a strict DNS host name.
|
||||
|
||||
## 2026-08-29 (9)
|
||||
|
||||
* **Update**: `agent/decision-engine.md`, `agent/overview.md` — `SignalIngress.Post` is worker-thread re-entrancy-safe: signals the worker posts to itself from inside `ApplyStep` (EffectRunner `ClassifierVerdictIssued`) go to a worker-local follow-up queue drained ahead of the bounded channel instead of blocking on it, so a full channel can no longer deadlock the single consumer.
|
||||
|
||||
## 2026-08-29 (8)
|
||||
|
||||
* **Creation**: Added `backend/session-owner-binding.md` — SESSION-OWNER-BINDING stage 1 (shadow): Sessions rows carry the creating device identity (`OwnerKind/OwnerThumbprint/OwnerDeviceId/OwnerBootstrapCode/OwnerSerial/OwnerBoundAt`, primary-only), `SessionOwnershipPolicy` evaluates every register / telemetry / error-report request against it, outcomes ride on the request row (`SessionOwnerBinding`), warnings and a throttled `SessionOwnerMismatch` ops event; nothing is refused yet. Agent ships the dormant counterpart: `errorCode` parsing on 401/403, one-shot session rotation on `session_owner_mismatch` without feeding the auth-failure tracker, spool drop, `agent_started.sessionRotated`.
|
||||
|
||||
## 2026-08-29 (7)
|
||||
|
||||
* **Update**: `backend/client-cert-tenant-binding.md` — new "Certificate transport and proof of possession" section: `X-ARR-ClientCert` (platform-set after the mTLS handshake, inbound copies stripped even on excluded paths) is the only certificate source; the client-writable `X-Client-Certificate` fallback is gone; `/api/bootstrap/*` routes are token-only and fail closed on a missing/empty token or unwired token service instead of falling through to the certificate stage.
|
||||
|
||||
## 2026-08-29 (6)
|
||||
|
||||
* **Update**: `backend/admin-identity-binding.md` — grants are self-maintaining: `AdminIdentityResolver` derives the home tenant + object id from sign-in history (`UserActivity`), falls back to the UPN domain of an onboarded tenant, refuses to guess when a UPN was seen under two tenants; 422 `HomeTenantUnresolved` + portal tenant picker only for a never-seen domain. Identity fields and pills removed from the delegated / tenant-group forms; inspect/correct lives in Tenant Management → Edit Tenant Configuration → Identity bindings (collapsed).
|
||||
|
||||
## 2026-08-29 (5)
|
||||
|
||||
* **Creation**: Added `backend/admin-identity-binding.md` — cross-tenant roles (GlobalAdmin / GlobalReader, delegated MSP) were resolved from the UPN string alone while the API accepts tokens from every Entra tenant. New `AdminIdentityBindings` table (RK = UPN → home `TenantId` + `ObjectId`), `AdminIdentity` (upn + tid + oid) as the key both role services resolve on, binding checked after the row and before the Pro gate, object id pinned on the first sign-in from the bound tenant (ETag-guarded), grants require `homeTenantId` and refuse to re-home (409), `global/identity-bindings` GET/PUT/DELETE as the audited rebind path, `RequestContext.ObjectId`, throttle bucket keyed on oid. Legacy rows are inert until bound — seed bindings before the backend deploy.
|
||||
|
||||
## 2026-08-29 (4)
|
||||
|
||||
* **Creation**: Added `backend/ime-msi-archive.md` — the version-verified, multi-host IME installer archive: MsiProductVersionReader (managed CFB/MSI Property reader), candidate host walk, Failed:VersionMismatch, sighting re-queue with 24 h backoff, and the 2026-08-29 1.105.103.0 poisoning that motivated it.
|
||||
|
||||
## 2026-08-29 (3)
|
||||
|
||||
* **Update**: `backend/vulnerability-correlation-sources.md` - the admin page's Data Sync Status is complete: next to Sync KEV / Sync MSRC there are now Refresh NVD Cache and Sync EPSS, each with its own last-sync line (`NvdCacheLastRefreshUtc`, `EpssLastSyncUtc` on AdminConfiguration; shared manifest regenerated). The NVD and EPSS walks moved out of the timer into `VulnerabilityCacheRefreshService` (singleton, one in-flight slot per walk) so the button and the nightly run are literally the same code; the NVD walk is minutes long and rate-limited, so `POST vulnerability/sync-nvd` answers 202 and the page polls `sync-status` (`nvdRefreshRunning`, `nvdLastRun`) while it runs, `sync-epss` runs synchronously. 409 while a walk is in flight; the timer skips a slot a manual run holds. Operator-only surface - no customer docs.
|
||||
|
||||
## 2026-08-29 (2)
|
||||
|
||||
* **New**: `backend/vulnerability-correlation-sources.md` - FIRST EPSS joins NVD / CISA KEV / MSRC as the fourth vulnerability source: the probability + percentile + date live on the cached CVE (one batch of <= 100 ids per CPE fill, every `cve_*` row re-scored by the nightly sync, only changed rows rewritten and with their original `CachedAt`), the CVSS vector is persisted instead of dropped, and each CVE carries an act / attend / track priority (KEV; EPSS >= 0.1 or CVSS >= 9.0; rest) next to the unchanged severity bands - two visible signals, no composite score. The comparison with an open-source CVE MCP server surfaced the real 429 defect: Polly already retried, but a refused NVD answer came back as an empty response and was cached as "0 CVEs" for seven days. `NvdFetchResult` now separates "answered" from "refused" (nothing persists a refusal - CPE cache, negative auto-resolve cache, admin auto-resolve), and a 429 opens a process-wide cooldown (`Retry-After` capped at 120 s, else 30 s) so the run fails fast instead of paying a retry chain per software item; hard-expired rows keep serving meanwhile. NVD -> cache projection and the row builder are now single functions shared by the live fill and the sync. CveIndex / fleet summary carry `epssScore` + `priority`; portal shows EPSS pill, priority pill, vector; MCP tool descriptions and the CVE-audit prompt name the fields. ExploitDB recorded as a deliberate not-now. Backend before web; trust pages (terms attribution, privacy, data-flows), settings and portal-guide docs updated in the same change.
|
||||
|
||||
## 2026-08-29
|
||||
|
||||
* **New**: `mcp/protocol-eras-and-cimd.md` - the MCP server moved to the 2026-07-28 protocol revision (TypeScript SDK v2: `@modelcontextprotocol/server` + `/node`, zod 4; the monolithic `@modelcontextprotocol/sdk` is gone). One per-request factory (`mcp-server-factory.ts`) feeds both eras through `mcp-http.ts`: 2026 clients are served by the SDK entry (`createMcpHandler`, `legacy: 'reject'`, JSON response mode), 2025 clients keep the existing stateless transport with `enableJsonResponse` - routed on the SDK's own `isLegacyRequest` predicate, because the built-in stateless fallback would answer today's Claude/VS Code clients with SSE and lose the gzip win. Capabilities now declare `listChanged: false` (the SDK default advertised a notification a per-request server cannot send); `cacheHints` stamp `ttlMs`/`cacheScope: 'private'` on discover/list results (5 min) and resource reads (60 min) - never public, the catalog is role-dependent. Client registration: `client_id_metadata_document_supported: true`; an https `client_id` is a Client ID Metadata Document resolved by `cimd.ts` (SSRF gate on the resolved addresses before any request, no redirects, 5 s / 16 KB, exact `client_id` match, bounded `redirect_uris`, 10-60 min cache), the requested redirect_uri still has to pass the dynamic-registration allowlist; HMAC-signed DCR client_ids stay as the fallback. `mcp-oauth-flow.md` updated. Package version 1.5 -> 1.6. Pinned by `mcp-http.test.ts` (real v2 client over loopback HTTP, both eras, role parity), `cimd.test.ts`, `oauth-cimd.test.ts`.
|
||||
|
||||
## 2026-08-28
|
||||
|
||||
* **Update**: `CmTraceSkewTripwire` - the backend regression tripwire additionally requires >= 80 % of the individual IME deltas (relative to the other side's median) to sit within 2 min of some 15-minute grid multiple before `CmTraceTimeSkewRegression` fires; the ops event and its details now carry that fraction. Motivation: the first soak week produced 7 fires, all backlog replays - a relaunched agent re-tailing the IME log uploads one burst holding most of the session's IME samples with a continuum of ages whose median hit the grid by chance (session d832560a: 508 of 627 samples, 46...733 min, median 91). Era-mixing regressions (every line on the grid) still fire. Doc: `docs/agent/cmtrace-time-resolution.md`.
|
||||
|
||||
## 2026-08-27 (2)
|
||||
|
||||
* **Update**: `SecurityValidator` / `DeviceAssociationValidator` - Microsoft released Windows Autopilot device association on 2026-08-25 (learn.microsoft.com/autopilot/device-preparation/device-association/overview: TPM-backed UEFI marker, device-targeted device preparation policies, associated devices are marked corporate-owned by Intune with no corporate identifier upload). The DevPrep association lookup therefore leaves shadow mode and becomes the third accepting method of the device-validation gate, evaluated inline after the Autopilot and Corporate Identifier lookups and before the Cloud PC fallback; a match sets `ValidatedBy = DeviceAssociation`, a miss joins the combined 403 detail, a transient failure the 503 Retry-After - the same contract as its siblings, so enabling it can only widen acceptance, never reject a device the other methods would have taken. `ValidateDeviceAssociation` counts toward "at least one validation enabled" and is no longer GA-only anywhere (`UpdateTenantConfigurationFunction` revert, `TenantConfigPatchService.GaOnlyFields`, the Global-Admin gate on the settings toggle); the toggle sits under Device Preparation next to Corporate Identifier and follows the shared consent rule (first serial-based validation carries the consent, any further one is a plain persisted toggle). Known gap carried over from the shadow phase: the `/beta/deviceManagement/tenantAssociatedDevices` endpoint is still undocumented in Graph reference and every production lookup so far answered `matched=False` for Autopilot v1 devices, i.e. the positive match path is unproven in the field - first associated enrollment must be checked for `ValidatedBy = DeviceAssociation` on the session row. Landing hero carries a release pill linking to the customer docs; dashboard tip added. Backend before web; customer docs updated in `autopilotmonitor-docs`.
|
||||
|
||||
## 2026-08-27
|
||||
|
||||
* **Update**: `backend/silent-session-classification.md`, `backend/verdict-calibration.md` - the first real calibration read tuned what it measures. Rule 5 grew a past-grace fork 5a: Device Setup done + real-user desktop + zero `app_install_failed` reconciles to Succeeded "completed (assumed)" (`r5_assumed`) - r5-Incomplete devices re-enrolled at 2.6 %, the succeeded background, and the sampled desktop half had clean app records; no desktop or any failed app stays Incomplete. The max-lifetime watchdog (either event shape) now skips the AwaitingUser grace entirely - all five observed maxlife parks expired unhealed, and late telemetry heals a terminal verdict anyway - while WhiteGlove rule 1b keeps its park (its one episode healed). The drift radar got floors (≥ 10 window hits; per-path share regression needs ≥ 5 baseline hits, so new/renamed vocabulary cannot "regress") and legacy continuity: rule-shaped `legacy:*` paths count into the silence and classifier-verdict group sums on both sides of the 2026-08-23 cutover, which dissolves the lift-124 silence artifact and lets sub-floor episodes clear themselves. Backend only; the new path shows up in the matrix on its own.
|
||||
|
||||
* **Update**: `backend/tenant-activation-welcome-mail.md` - the reverse lookup. A delivery log names only the recipient, so an address had to lead back to its tenant: the tenant console's search now matches the notification and contact address as well as domain and tenant id, with the quoted exact mode unchanged. The addresses sit in `PreviewWhitelist`, not on the tenant config, so a new GA/Reader-only `GET /api/preview/notification-emails` returns them as one map (single cross-partition scan of `RowKey eq 'notification-email'`) instead of a point read per tenant; both sides join on the lowercased tenant id because the address rows carry the JWT `tid` while a config PartitionKey's casing is not guaranteed, and matching is case-insensitive because stored addresses keep the casing the user typed. The map is best-effort in the portal - a failed read costs only the search-by-address, never the tenant list - and the addresses stay out of the list rows (search key, not a column). Match rules moved into `tenantSearch.ts` with unit pins. Backend before web.
|
||||
|
||||
|
||||
## 2026-08-26
|
||||
|
||||
* **Update**: `agent/wifi-ssid-location-gate.md` - the network timeline now explains its blank signal too, with no agent change: `buildSegments` already merges `wifi_signal_info` into the covering WiFi segment, so `wifiDataLimitedReason` rides in with the sample. Segments that never got a sample (a mid-session reconnect emits none) inherit it - the gate is a device-level setting and cannot flip mid-enrollment - while a segment with a real reading is never overwritten. Tooltip shows `Signal: unavailable - Location services off on this device`; the "Avg WiFi signal" stat reads `n/a (Location services off)` instead of vanishing. `network_state_change` is deliberately left alone: it has never carried `signalPercent` or `radioType` (only `before_/after_wifiSsid` reaches its payload), so there is no absent field there for a reason to explain, and the SSID it does carry is unaffected by the gate. Web-only change.
|
||||
|
||||
|
||||
## 2026-08-26
|
||||
|
||||
* **Update**: `agent/wifi-ssid-location-gate.md` - the gate is now surfaced instead of only logged. When the WinRT tier wins and the native tier reported `rc=5`, `wifi_signal_info` carries `wifiDataLimitedReason = "location_services_off"` and the message gains `- signal unavailable, Location services off`, so the reason is readable straight from the timeline; the portal WiFi card (`DeviceDetailsCard`) matches that literal and names the Intune setting that restores the full payload. The claim is evidence-only - any other native failure leaves the field null rather than blaming a privacy setting, with `IsLocationGateDenied` pinned at its boundaries. New `wifi_signal_info` group in the MCP `DEVICE_PROPERTIES_CATALOG` so a fleet can be sized via `search_sessions` before any policy changes. Agent release + web deploy + MCP redeploy; backend untouched (the payload is opaque DataJson).
|
||||
|
||||
|
||||
## 2026-08-26
|
||||
|
||||
* **New**: `agent/wifi-ssid-location-gate.md` - `wifi_signal_info` was missing on most Windows 11 24H2+ WiFi enrollments (build 22631 3/3, 26100 37/178, 26200 43/95 over n=276 sessions from 08-01). Not a parser or language problem: every event that exists carries a full SSID, so the failure is binary at the reader. Cause is the 24H2 location gate on `WlanQueryInterface` with `wlan_intf_opcode_current_connection` - the only opcode the agent uses - which returns `ERROR_ACCESS_DENIED`; the consent prompt only fires for a process in the user context outside `System32`, so the agent (SYSTEM, session 0, pre-privacy-page) is denied silently, and it resolves consent from the HKLM ConsentStore because the `S-1-5-18` hive has none - while the interactive user on the same box reads the SSID fine via the per-user NonPackaged value. `netsh wlan` hangs off the same gate, so the 2026-07 netsh fallback (and its multi-language labels) never covered the case it was built for; more labels cannot fix it. New `WinRtWifiSsidReader` uses the documented replacement `WlanConnectionProfileDetails.GetConnectedSsid` (ungated, SSID only), bound via the CLR WinRT projection with no winmd/NuGet like `OobeStateReader`; new `WifiInfoProvider` owns the tier order (native, WinRT, netsh) that both collectors had duplicated. The step diagnostics added in 2026-07 were logged at `Debug` while the default level is `Info`, so they never reached a field log - the all-tiers-failed case is now a Warning that decodes `rc=5` into the location-gate explanation. Agent-only change; needs an agent release.
|
||||
|
||||
|
||||
## 2026-08-25
|
||||
|
||||
* **Update**: `backend/client-cert-tenant-binding.md` — stage 2 enforced: `Mismatch` now returns 403 via `LogRequestRejection(stage=certtenant)`. Switched on against 35,684 requests / 32 tenants at 100 % `Match`, with the Warning channel verified to be carrying other traffic so the zero was not an artefact. The rule is one function (`CertTenantBinding.Rejects`) and only `Mismatch` blocks; `ExtensionMissing` / `Unparseable` / `RequestTenantNotAGuid` are tolerated on purpose (the first is the candidate for later tightening and has a test pinning the tolerance). Evaluation fails open on its own exception, and the 403 body does not echo the certificate's tenant. Device binding (stage 3) stays shadow + Global-Admin-only and is unaffected.
|
||||
|
||||
## 2026-08-25
|
||||
|
||||
* **New**: `agent/zero-user-apps-completion-gate.md` — a Classic enrollment whose user has zero targeted Intune Win32 apps could not produce the evidence any user-phase completion path required: IME's zero-app branch (`ApplicationPoller`, verified against decompiled 1.104.102.0 and three older builds) `continue`s before its `Completed user session` statement, so `ImeUserSessionCompletedUtc` stayed null, all four arms of `ShouldTransitionToAwaitingHello` were dead, and the 30-min `advisory_completion` backstop resolved the session as `esp_exit_without_completion_evidence` — with a DAD-validated desktop, Hello provisioned and RealmJoin still installing. One tenant: 10 of 30 enrollments in a week, all false positives. Two fixes. (1) New pattern `IME-USER-SESSION-ZERO-APPS` reads the line IME *does* write and routes it through the same `OnUserSessionCompleted` callback, guarded to the AccountSetup phase and replayed if the observation predates the phase marker; `AreUserEspAppsSettled` now accepts an empty app list only with that explicit observation. (2) `HandleAdvisoryCompletionDeadlineFired` re-arms instead of failing while the RealmJoin gate is closed and its own deadline is still armed — session 75d6ae8e installed 25 RealmJoin packages inside the window, the last 2 min before the failure. `ime_user_session_completed` gains an `evidence` field (`zero_user_apps_enumerated` / `user_session_completed`); no new event type. The pattern reaches existing agents via the config API (an unknown action is a Debug line), the behaviour needs an agent release.
|
||||
|
||||
## 2026-08-24
|
||||
|
||||
* **Change**: `DeviceAssociationValidator` / `SecurityValidator` — the DevPrep association shadow now logs one line per real Graph lookup instead of one per request (`DeviceAssociationResult.ServedFromCache`, `AsCacheHit()` copies rather than marking the shared cached instance), the same gate the cert-device-binding shadow uses. Measured: one enrollment produced 263 identical lines off a handful of calls. Status of that preview, for the record: the `/beta/deviceManagement/tenantAssociatedDevices` endpoint answers (6 calls, all HTTP 200, `transient=False`), but every result so far is `matched=False` from a single Autopilot **v1** device — which is the correct answer for a device that was never part of a Device Preparation policy. The matching itself is therefore still unproven, and the endpoint remains undocumented in both v1.0 and beta Graph reference, so it is not a basis for enforcement.
|
||||
|
||||
* **Update**: `backend/client-cert-tenant-binding.md` — stage 3 verified live (`ea6a375`, gktatooine): 136 requests, all `Match`, device object ~72 s older than the session start, so no enrollment race in that run. Measured Graph cost: **1 call for 136 requests** (30 min positive cache), versus 25 for the per-request Autopilot validator — the positive TTL needs no change. The shadow log is now gated on `ServedFromCache` (one line per real lookup instead of per request); never-cached outcomes still log every time.
|
||||
|
||||
* **Update**: `backend/client-cert-tenant-binding.md` — stage 3, the cert-to-device binding: new `IntuneDeviceBindingValidator` resolves the Subject-CN managedDevice id against the tenant's own `deviceManagement/managedDevices` (point-GET, no filter literal), modelled on `CloudPcDeviceValidator` and wired fire-and-forget like the DevPrep shadow so a cold Graph round-trip never enters the agent request path. New GA-only tenant toggle `ValidateIntuneDeviceBinding` (shadow — never touches `deviceValidated`), new optional Graph feature `IntuneDeviceBinding` → `DeviceManagementManagedDevices.Read.All` in `GraphFeatureCatalog` + the customer grant script, and `enrolledAgeSeconds` on every log line so the enrollment race can be measured before this could ever become a gate. Grep marker: `CERT-DEVICE-BINDING-SHADOW`.
|
||||
|
||||
* **Update**: `backend/client-cert-tenant-binding.md` — shadow telemetry moved off the dead `LogInformation` path: the `Match` outcome now rides on the request row as the `CertTenantBinding` dimension (`SecurityValidator` → `FunctionContext.Items` → `RequestTelemetryMiddleware`), which is unsampled and already exists per request, so the denominator costs nothing. Non-Match outcomes keep their Warning trace. Post-deploy verification of 249e39bc found zero rows across 32k requests — worker `LogInformation` never reaches App Insights (provider default rule Warning+), making "everything matched" indistinguishable from "never ran". `CertificateValidationResult.FromCache` removed with the sampling path it existed for.
|
||||
* **New**: `backend/client-cert-tenant-binding.md` — the agent's mTLS certificate chains to Microsoft's shared Intune roots, so certificate validation alone never proved tenant membership; tenant scoping rested entirely on the (non-secret) device serial in the downstream validators. The Intune MDM leaf carries the Entra tenant id in OID `1.2.840.113556.5.14` (nested OCTET STRING), which is NOT `…5.6` (Intune Account id, a different GUID) and NOT `…5.4` (device id, stored as 16 bare bytes) — all decoded by one shared `MsDeviceCertificateOids.TryParseGuid` now used by agent and backend. Stage 1 ships as shadow only: `CertTenantBinding.Evaluate` (pure) plus `SecurityValidator.ObserveCertTenantBinding` (returns void, swallows its own exceptions, cannot change an authorization outcome), with `Match` sampled via `CertificateValidationResult.FromCache` so the denominator survives without per-request volume. Grep marker for the stage-2 enforcement change: `CERT-TENANT-BINDING-SHADOW`.
|
||||
|
||||
|
||||
+11
-7
@@ -8,7 +8,7 @@ tags:
|
||||
- oauth
|
||||
- entra-id
|
||||
- security
|
||||
timestamp: 2026-07-11T10:59:31+02:00
|
||||
timestamp: 2026-08-29T08:45:00+02:00
|
||||
---
|
||||
|
||||
# MCP OAuth Flow — Who Authenticates Where
|
||||
@@ -41,9 +41,12 @@ The two identities never mix:
|
||||
in the vendor's backend, not on the user's machine.
|
||||
2. **MCP server** (Container App) — acts as a stateless **OAuth proxy /
|
||||
authorization-server façade** in front of Entra ID. It is *not* an IdP:
|
||||
it registers clients dynamically (RFC 7591, HMAC-signed stateless
|
||||
`client_id`), enforces the redirect-URI allowlist and PKCE, and exchanges
|
||||
codes at Entra using its confidential client secret.
|
||||
it establishes the client's identity statelessly — a Client ID Metadata
|
||||
Document (MCP spec 2026-07-28: the `client_id` is an HTTPS URL the server
|
||||
fetches and validates) or, for older clients, dynamic registration
|
||||
(RFC 7591, HMAC-signed stateless `client_id`) — enforces the redirect-URI
|
||||
allowlist and PKCE, and exchanges codes at Entra using its confidential
|
||||
client secret. See [Protocol Eras & CIMD](mcp/protocol-eras-and-cimd.md).
|
||||
3. **Entra ID** — the real identity provider. This is where `adm.luke` lives.
|
||||
|
||||
## Full sequence
|
||||
@@ -54,9 +57,10 @@ Claude Desktop Browser (user's MCP server Entra ID
|
||||
│ │ │ │
|
||||
1. │─ POST /mcp ──────────┼───────────────────────▶│ 401 + WWW-Auth │
|
||||
2. │─ Discovery ──────────┼───────────────────────▶│ RFC 9728/8414 docs │
|
||||
3. │─ POST /oauth/register┼───────────────────────▶│ client_id (HMAC- │
|
||||
│ │ │ signed, embeds the │
|
||||
│ │ │ registered callback) │
|
||||
3. │─ client identity ────┼───────────────────────▶│ CIMD: client_id is a │
|
||||
│ (CIMD, or legacy │ │ URL, doc fetched at │
|
||||
│ POST /oauth/ │ │ /authorize; DCR: │
|
||||
│ register) │ │ HMAC-signed client_id│
|
||||
│ │ │ │
|
||||
4. │─ opens browser ─────▶│─ GET /oauth/authorize ▶│─ 302 to Entra ──────▶│
|
||||
│ │ (PKCE challenge) │ app-reg client_id, │
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
type: Concept
|
||||
title: MCP Protocol Eras & Client ID Metadata Documents
|
||||
description: How the MCP server serves the 2026-07-28 stateless revision and 2025-era clients from one per-request factory, why the legacy leg is hand-wired (JSON + gzip), the cache-hint and capability contract, and the CIMD client-registration path next to the deprecated dynamic registration.
|
||||
resource: /src/McpServer/autopilot-monitor-mcp/src/mcp-http.ts
|
||||
tags:
|
||||
- mcp
|
||||
- protocol
|
||||
- oauth
|
||||
- cimd
|
||||
- security
|
||||
timestamp: 2026-08-29T08:40:00+02:00
|
||||
---
|
||||
|
||||
# MCP Protocol Eras & Client ID Metadata Documents
|
||||
|
||||
**Status:** Concept · **Owner:** MCP Server · **Code:** [`mcp-http.ts`](../../src/McpServer/autopilot-monitor-mcp/src/mcp-http.ts), [`mcp-server-factory.ts`](../../src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts), [`cimd.ts`](../../src/McpServer/autopilot-monitor-mcp/src/cimd.ts), [`oauth.ts`](../../src/McpServer/autopilot-monitor-mcp/src/oauth.ts)
|
||||
|
||||
# Schema
|
||||
|
||||
## The two eras
|
||||
|
||||
MCP revision **2026-07-28** turned the protocol into a stateless request/response
|
||||
protocol: no `initialize` handshake, no `Mcp-Session-Id`, every request carries its
|
||||
protocol version, client identity and capabilities in `_meta`, capabilities and
|
||||
instructions are fetched with `server/discover`, list results carry `ttlMs` /
|
||||
`cacheScope`, and `Mcp-Method` / `Mcp-Name` HTTP headers let gateways route without
|
||||
parsing bodies. Roots, Sampling, Logging and the HTTP+SSE transport are deprecated
|
||||
(twelve-month window); Dynamic Client Registration is deprecated in favour of Client
|
||||
ID Metadata Documents.
|
||||
|
||||
The server was already stateless by design (scale-to-zero Container App, no
|
||||
server→client traffic), so the revision cost no architecture — only wiring:
|
||||
|
||||
| Concern | 2026-07-28 ("modern") | 2025-era ("legacy") |
|
||||
| --- | --- | --- |
|
||||
| Routing decision | `isLegacyRequest()` from the SDK — the exact classifier `createMcpHandler` uses internally | same predicate, inverse |
|
||||
| Serving | `createMcpHandler(factory, { legacy: 'reject', responseMode: 'json' })` via `toNodeHandler` | fresh `NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })` per POST |
|
||||
| Response shape | single `application/json` body | single `application/json` body |
|
||||
| Instructions | `server/discover` result | `initialize` result |
|
||||
| Cache fields | `ttlMs` + `cacheScope` stamped by the SDK from `cacheHints` | none (2025 codec has no cache path) |
|
||||
|
||||
Both legs are fed by **one** factory (`createServerForCaller`), so the tool catalog,
|
||||
resource set, prompts and instructions can never drift between eras — the
|
||||
`mcp-http.test.ts` suite pins that a Global Admin and a plain tenant user see the
|
||||
same catalog on either path.
|
||||
|
||||
## Why the legacy leg is hand-wired
|
||||
|
||||
`createMcpHandler`'s built-in `legacy: 'stateless'` fallback constructs its transport
|
||||
with only `sessionIdGenerator: undefined`, i.e. it answers every 2025 client with an
|
||||
SSE frame. Every production client today (Claude Code, Claude.ai, VS Code) is a 2025
|
||||
client, and their large tool results are gzipped by the compression middleware only
|
||||
because the response is a plain JSON body (measured 5–30×). Routing on the SDK's own
|
||||
predicate in user land keeps that behaviour while still running the 2026 path through
|
||||
the SDK entry unchanged — no re-implementation of the classification, no second
|
||||
catalog.
|
||||
|
||||
## Capability and cache contract
|
||||
|
||||
* `capabilities` are declared explicitly with `listChanged: false` for tools,
|
||||
resources and prompts. The SDK's default (`?? true`) would advertise a
|
||||
notification a per-request server can never emit.
|
||||
* `cacheHints`: `server/discover`, `tools/list`, `prompts/list`, `resources/list`,
|
||||
`resources/templates/list` → `{ ttlMs: 300_000, cacheScope: 'private' }`;
|
||||
`resources/read` → `{ ttlMs: 3_600_000, cacheScope: 'private' }`. **Never
|
||||
`public`**: the catalog and the instructions are role-dependent (Global Admin /
|
||||
Global Reader / delegated MSP / tenant user), so a shared cache would leak one
|
||||
caller's surface to another. Five minutes is the bound on how long a role change
|
||||
can go unnoticed by a caching client; resources are static per deployment.
|
||||
* No Roots / Sampling / Logging / elicitation / subscriptions are used anywhere, so
|
||||
nothing on the deprecation list has to be migrated.
|
||||
|
||||
## Client registration: CIMD first, DCR as fallback
|
||||
|
||||
The authorization-server metadata now advertises
|
||||
`client_id_metadata_document_supported: true` next to the (deprecated)
|
||||
`registration_endpoint`. Clients following the spec's priority order use a
|
||||
**Client ID Metadata Document**: their `client_id` *is* an HTTPS URL that serves a
|
||||
JSON document (`client_id`, `client_name`, `redirect_uris`, …). `oauth.ts` resolves
|
||||
every `client_id` through one helper: an HTTPS URL goes to `cimd.ts`, anything else
|
||||
must be one of our HMAC-signed dynamic-registration tokens. Both yield the same shape
|
||||
(`redirectUris`), and the rest of the authorize/callback logic is unchanged.
|
||||
|
||||
What the document decides — and what it does not:
|
||||
|
||||
* It only asserts **which** redirect URIs the client claims. The requested
|
||||
`redirect_uri` must still pass the host/path allowlist that gates dynamic
|
||||
registration (`isAllowedRedirectUri`), checked *before* the document is fetched. A
|
||||
self-hosted document cannot widen the destinations an authorization code may be
|
||||
sent to. Loopback remains allowed for every client (RFC 8252 §7.3);
|
||||
`application_type: "native"` is informational.
|
||||
* Fetching a caller-chosen URL is an SSRF surface, so the fetch is: HTTPS only, path
|
||||
component required, no fragment/userinfo, no loopback or IP-literal host, every
|
||||
resolved address must be public (RFC 1918/4193/3927/6598, CGNAT, multicast,
|
||||
IPv4-mapped all refused — checked before any request leaves), `redirect: 'error'`,
|
||||
5 s budget, 16 KB body cap (declared *and* streamed), JSON media type required.
|
||||
Nothing from the response reaches the caller beyond an OAuth error code.
|
||||
* The document's `client_id` must equal the URL by simple string comparison — no
|
||||
normalization, per the draft. `redirect_uris` is bounded exactly like a dynamic
|
||||
registration (`oauth-limits.ts`). A missing `client_name` falls back to the host:
|
||||
it only ever reaches a log line, and failing a login over a label would be wrong.
|
||||
* Cache: positive entries live for the document's `Cache-Control: max-age`, clamped
|
||||
to 10–60 min so the `/oauth/callback` re-check within the 10-min signed-state
|
||||
window is a cache hit on the same replica; rejections are cached for 60 s; the map
|
||||
is bounded to 256 entries.
|
||||
|
||||
RFC 9207 `iss` (already emitted since 2026-07-29) completes the authorization side
|
||||
of the 2026-07-28 revision.
|
||||
|
||||
# Examples
|
||||
|
||||
* A 2026 client: `POST /mcp` with `_meta["io.modelcontextprotocol/protocolVersion"] = "2026-07-28"`
|
||||
and `Mcp-Method: tools/list` → `{ tools: [...], ttlMs: 300000, cacheScope: "private", _meta: { "io.modelcontextprotocol/serverInfo": { name: "Autopilot-Monitor", version: "1.6.<build>" } } }`.
|
||||
* A 2025 client: `POST /mcp` `initialize` → `application/json` body with
|
||||
`protocolVersion: "2025-11-25"` and the role-tailored `instructions`; no
|
||||
`Mcp-Session-Id` header.
|
||||
* `GET /oauth/authorize?client_id=https://app.example.test/oauth/client.json&redirect_uri=http://127.0.0.1:49152/callback&code_challenge=…` →
|
||||
document fetched (or served from cache) → 302 to Entra. `client_id=https://10.0.0.8/x` → `400 invalid_client`
|
||||
without any outbound request.
|
||||
|
||||
# Citations
|
||||
|
||||
* MCP spec 2026-07-28 — https://blog.modelcontextprotocol.io/posts/2026-07-28/ ; authorization / client registration:
|
||||
https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration
|
||||
* TypeScript SDK v2 (`@modelcontextprotocol/server` 2.0.0) — `createMcpHandler`, `isLegacyRequest`, `cacheHints`.
|
||||
* draft-ietf-oauth-client-id-metadata-document-00 §6 (security considerations, SSRF).
|
||||
* Tests: `src/__tests__/mcp-http.test.ts`, `src/__tests__/cimd.test.ts`, `src/__tests__/oauth-cimd.test.ts`.
|
||||
* Related: [MCP OAuth Flow](../mcp-oauth-flow.md), [MCP Docs Corpus](docs-corpus.md).
|
||||
@@ -8,7 +8,7 @@ tags:
|
||||
- backend
|
||||
- gather-rules
|
||||
- config
|
||||
timestamp: 2026-08-04T00:00:00+02:00
|
||||
timestamp: 2026-08-29T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
@@ -89,6 +89,18 @@ Consequences of the emitted-event feed:
|
||||
the raw signal stream — the documented "On Event with `enrollment_complete`" end-of-enrollment
|
||||
pattern only works because of this. This restores V1 semantics (V1 fired triggers from every
|
||||
emitted `EnrollmentEvent`).
|
||||
* **Feedback-loop guard** (2026-08-29): gather output travels the same post → ingress →
|
||||
reducer → emitter → stream path, so the stream would hand the executor's own events straight
|
||||
back to `OnEvent`. A rule with `triggerEventType == outputEventType` (or two rules chained
|
||||
A→B→A) then executed and emitted without bound for the whole session — `emitMode:
|
||||
on_change` does not help when the collected result varies. `TimelineEventStream` therefore
|
||||
publishes the event **source**, and `GatherRuleExecutorHost` never dispatches `on_event` for
|
||||
`Source == GatherRuleExecutor.SourceName` (phase declarations on such events are still
|
||||
honoured). Second layer: `GatherRuleExecutor.MaxOnEventExecutionsPerRule` (100 per rule and
|
||||
session) bounds any cycle that reaches the executor through a non-gather event; the boundary
|
||||
is logged once (agent log Warning + debug-trace `trigger` line). Backend validation rejects
|
||||
`on_event` rules whose trigger equals their own output type or `gather_result` — such a rule
|
||||
could never fire.
|
||||
* Other hosts (`DeviceInfoHost`, `UserEspKeepAwakeHost`, `ProvisioningPackageHost`) still key
|
||||
off the raw signal stream deliberately — their collections are about OS state at the raw
|
||||
boundary (ESP exit, keep-awake), not about timeline agreement.
|
||||
@@ -103,7 +115,8 @@ Consequences of the emitted-event feed:
|
||||
`_startupRulesExecuted` dedup; it is not part of the `WaitForStartupRules` latch).
|
||||
* `phase_change` — evaluated against the **new** phase; the scope gate runs before the
|
||||
per-(rule, phase) dedup so an out-of-scope pass does not consume the execution slot.
|
||||
* `on_event` — gated by the current phase.
|
||||
* `on_event` — gated by the current phase; never fires on gather output; capped at
|
||||
`MaxOnEventExecutionsPerRule` per rule and session.
|
||||
* **Before the first phase signal** of a session (`_currentPhase == Unknown`) scoped rules are
|
||||
inactive. Sessions that never produce phase signals never run them (documented behavior).
|
||||
* **From-phase latch**: activates when `phase != Unknown && phase != Failed && (int)phase >=
|
||||
@@ -164,4 +177,4 @@ Consequences of the emitted-event feed:
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/GatherRulesFunction.cs` — `ValidateScopeAndEmitMode`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs` — partial-PUT merge, `ContentEquivalent`
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs` — field contracts
|
||||
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests`, `GatherRuleExecutorHostTimelineFeedTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
|
||||
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests`, `GatherRuleExecutorHostTimelineFeedTests`, `GatherRuleExecutorOnEventCapTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
|
||||
|
||||
@@ -31,7 +31,7 @@ Namespace split (`RuleIdPolicy.cs`, shared model):
|
||||
|
||||
# Guards (all enforced, 2026-08-21)
|
||||
|
||||
1. **Global RuleStats aggregate is catalog-only.** The `global_{ruleId}` row would otherwise sum same-ID custom rules of unrelated tenants (title/severity last-writer-wins). Analyze gates on `rule.IsBuiltIn || rule.IsCommunity` (`AnalyzeOnEnrollmentEndHandler`), gather gates on `RuleIdPolicy.IsReservedBuiltInId` (`EventIngestProcessor.RuleStats`) because agent events carry no flag — valid because custom rules can never occupy the reserved pattern.
|
||||
1. **Global RuleStats aggregate is catalog-only.** The `global_{ruleId}` row would otherwise sum same-ID custom rules of unrelated tenants (title/severity last-writer-wins). Both paths gate on the server-side catalog rule object: analyze on `rule.IsBuiltIn || rule.IsCommunity` of the evaluated rule (`AnalyzeOnEnrollmentEndHandler`), gather on the same flags of the rule resolved from the tenant's active gather catalog (`EventIngestProcessor.ResolveFiredGatherRules`). The agent event contributes only the `ruleId`; title/category/severity are taken from the catalog, never from event `Data`, and a claimed ID that is not an active rule for the tenant is dropped — so a device cannot mint `global_*` rows for retired/unshipped IDs, inflate a built-in rule's counters under a foreign ID, or respell its title. (Earlier the gather path selected the global row by `RuleIdPolicy.IsReservedBuiltInId` on the device-supplied ID and wrote the device-supplied title — pattern membership is not catalog membership.)
|
||||
2. **Catalog build fails on namespace violations.** `rules/scripts/combine.js` rejects duplicate IDs (case-insensitive) and any gather/analyze built-in outside the reserved pattern; `BuiltInRuleCatalogPolicyTests` pins the same invariants on the embedded resource actually deployed.
|
||||
3. **GitHub reseed rejects non-reserved IDs** before writing to the `global` partition (`ReseedFromGitHubFunction`) — a seeded `*-CUSTOM-*` rule would silently shadow every tenant's same-ID custom rule at merge time.
|
||||
4. **Cross-tenant RuleState GC is namespace-gated.** `DeleteRuleStatesForRuleIdAcrossTenantsAsync` deletes by bare RowKey across all partitions; it now refuses non-reserved IDs with `failed=-1` (callers then skip the global catalog delete, same contract as enumeration failure).
|
||||
|
||||
@@ -123,4 +123,4 @@ Application Insights resource — the prefetch is cheap only while the runtime i
|
||||
|
||||
* `src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx` — nav link rendering, rationale comment
|
||||
* `src/Web/autopilot-monitor-web/components/Navbar.tsx` — top-bar and notification links
|
||||
* [SWA and Oryx node pinning](../architecture.md) — hosting model for the web app
|
||||
* `.github/workflows/deploy-web.yml` — hosting model: static export built in CI (Node 22 via setup-node, `engines` in package.json), uploaded with the pinned SWA CLI
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-017",
|
||||
"title": "App Install Blocked by Another Installer (0x80070652)",
|
||||
"description": "Detects Win32 app installs that failed because the Windows Installer was busy with another installation (HRESULT 0x80070652 / MSI exit code 1618, ERROR_INSTALL_ALREADY_RUNNING). The IME retries on a fixed budget (~15-17 minutes across several attempts) and then reports the app as failed; when the app is ESP-blocking, the Device Setup Apps subcategory terminates with it.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "mutex_blocked_install",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "hresultFromWin32",
|
||||
"operator": "equals",
|
||||
"value": "-2147023278",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "installer_exit_1618",
|
||||
"source": "event_data",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "exitCode",
|
||||
"operator": "equals",
|
||||
"value": "1618",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "esp_failed_with_mutex_code",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_failure_settle_started",
|
||||
"dataField": "errorCode",
|
||||
"operator": "equals",
|
||||
"value": "0x80070652",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "installer_exit_1618",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "esp_failed_with_mutex_code",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 65,
|
||||
"explanation": "App **{{appName}}** failed to install because the Windows Installer service was already busy with another installation — HRESULT **0x80070652** / MSI exit code **1618** (`ERROR_INSTALL_ALREADY_RUNNING`). Only one Windows Installer transaction can run at a time; the Intune Management Extension retried on its fixed budget (typically ~15-17 minutes across several attempts) and then reported the app as failed. If this app is required in the Enrollment Status Page, the Device Setup Apps subcategory fails with it and the enrollment terminates.\n\nThis failure is **deterministic per attempt**: as long as the competing installer keeps winning the race, a plain re-enrollment fails again at roughly the same minute mark — retrying without fixing the collision only churns devices.\n\nCommon sources of the competing installation:\n\n- The **preceding Win32 app in the install order**: its installer exits 0 (so IME moves on) but leaves a child `msiexec` transaction or post-install MSI work running. Security agents and proxy clients that chain multiple MSIs are frequent offenders.\n- **OEM factory software** installing on first boot (factory-image provisioning packages, vendor updaters) — typical on brand-new hardware batches.\n- **Microsoft 365 Apps (C2R), driver updaters or Windows servicing** running installations in parallel outside IME's control.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Identify the competing installer",
|
||||
"steps": [
|
||||
"In this session's timeline, note which app finished installing immediately before {{appName}} started — its installer is the prime suspect for holding the Windows Installer mutex",
|
||||
"On a reproduction device, when the failure window is active, check for running installers: Get-Process msiexec | Select-Object Id,StartTime and the in-progress marker key HKLM\\SOFTWARE\\Microsoft\\Windows\\Installer\\InProgress",
|
||||
"Check the Application event log for MsiInstaller events around the failure timestamp to see which product held the transaction",
|
||||
"On factory-fresh hardware, also look for OEM first-boot installers (vendor updater services, factory provisioning packages) that run outside Intune"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Serialize the install chain",
|
||||
"steps": [
|
||||
"Add an Intune Win32 dependency so {{appName}} depends on the app whose installer causes the collision — IME then orders them explicitly instead of racing",
|
||||
"Alternatively, wrap the offending predecessor so its install script waits until msiexec has fully finished (poll the InProgress registry key or the _MSIExecute mutex with a bounded timeout) before exiting",
|
||||
"If the app does not have to gate the ESP, remove it from the blocking-app list so a transient 1618 no longer terminates the enrollment; Intune retries it after the desktop"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: MsiExec.exe error messages (1618 = another installation is in progress)",
|
||||
"url": "https://learn.microsoft.com/en-us/windows/win32/msi/error-codes"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Troubleshoot Win32 app installations in Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"apps",
|
||||
"msiexec",
|
||||
"mutex",
|
||||
"0x80070652",
|
||||
"1618",
|
||||
"esp"
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -56,7 +56,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list), unexpected_profiles (C:\\Users profile directories not on the allowed list), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -79,7 +79,7 @@
|
||||
"steps": [
|
||||
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
|
||||
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
|
||||
"Check whether the unexpected account has admin group membership",
|
||||
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-NET-001",
|
||||
"title": "Diagnostics Upload Blocked by Web Security Gateway",
|
||||
"description": "Detects diagnostics package uploads that were answered by a security appliance's HTML block page instead of the Autopilot Monitor backend. A forward proxy / secure web gateway (SWG) with SSL inspection is intercepting the upload endpoint, so exactly the sessions where diagnostics matter most (OnFailure uploads) are the ones losing them.",
|
||||
"severity": "warning",
|
||||
"category": "network",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "foreign_html_response",
|
||||
"source": "event_data",
|
||||
"eventType": "diagnostics_upload_failed",
|
||||
"dataField": "errorCode",
|
||||
"operator": "contains",
|
||||
"value": "HTML platform error page",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "security_gateway_marker",
|
||||
"source": "event_data",
|
||||
"eventType": "diagnostics_upload_failed",
|
||||
"dataField": "errorCode",
|
||||
"operator": "regex",
|
||||
"value": "mcafee|skyhigh|zscaler|netskope|blue ?coat|proxysg|forcepoint|websense|palo ?alto|fortigate|fortiguard|sophos|watchguard|barracuda|iboss|umbrella|lightspeed|smoothwall|contentkeeper|web ?gateway|web ?filter|content ?filter|secure ?gateway",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "http_403_block",
|
||||
"source": "event_data",
|
||||
"eventType": "diagnostics_upload_failed",
|
||||
"dataField": "errorCode",
|
||||
"operator": "contains",
|
||||
"value": "403",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "http_403_block",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "The agent collected a diagnostics package but the upload was answered with an **HTML block page from a web security gateway** instead of a response from the Autopilot Monitor backend — the event message names the product that served the page. A forward proxy / SWG with SSL inspection is intercepting HTTPS traffic from the enrollment network and blocking the upload endpoint.\n\nThe consequence is quietly painful: diagnostics upload is typically configured **OnFailure**, so the sessions that fail — the ones where the IME logs and diagnostics ZIP would explain the root cause — are exactly the sessions whose evidence never arrives. Enrollment telemetry itself may still flow (the agent often starts before the gateway's client enforcement does, or other endpoints are treated differently), which makes the gap easy to miss until a failure needs investigating.\n\nNote this rule only fires when the blocking page carries a recognizable security-product marker. A plain HTML error page without one usually means a stale or stopped endpoint instead — that is a different problem.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Exempt the upload endpoint in the web gateway",
|
||||
"steps": [
|
||||
"Identify the product from the block-page title quoted in this event's message (e.g. a Skyhigh/McAfee Web Gateway notification page)",
|
||||
"In that gateway, add the Autopilot Monitor API host shown in the event message to the SSL-inspection bypass AND the URL allowlist for the enrollment network segment",
|
||||
"Enrollment traffic often originates from a dedicated provisioning VLAN or from devices without the proxy client yet — make sure the exemption applies to that segment, not only to managed clients",
|
||||
"If the gateway enforces per-user policy, remember ESP-phase traffic runs as SYSTEM/device before any user signs in — the exemption must cover unauthenticated device traffic"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Verify the fix",
|
||||
"steps": [
|
||||
"Re-run an enrollment (or wait for the next failed one) and confirm a diagnostics package appears on the session in the portal",
|
||||
"Optionally set diagnostics upload to Always temporarily to validate the path without needing a failure"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Windows Autopilot networking requirements",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/networking-requirements"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"network",
|
||||
"proxy",
|
||||
"ssl-inspection",
|
||||
"swg",
|
||||
"diagnostics"
|
||||
]
|
||||
}
|
||||
+174
-3
@@ -943,6 +943,97 @@
|
||||
"delivery-gap"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-017",
|
||||
"title": "App Install Blocked by Another Installer (0x80070652)",
|
||||
"description": "Detects Win32 app installs that failed because the Windows Installer was busy with another installation (HRESULT 0x80070652 / MSI exit code 1618, ERROR_INSTALL_ALREADY_RUNNING). The IME retries on a fixed budget (~15-17 minutes across several attempts) and then reports the app as failed; when the app is ESP-blocking, the Device Setup Apps subcategory terminates with it.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "mutex_blocked_install",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "hresultFromWin32",
|
||||
"operator": "equals",
|
||||
"value": "-2147023278",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "installer_exit_1618",
|
||||
"source": "event_data",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "exitCode",
|
||||
"operator": "equals",
|
||||
"value": "1618",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "esp_failed_with_mutex_code",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_failure_settle_started",
|
||||
"dataField": "errorCode",
|
||||
"operator": "equals",
|
||||
"value": "0x80070652",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "installer_exit_1618",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "esp_failed_with_mutex_code",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 65,
|
||||
"explanation": "App **{{appName}}** failed to install because the Windows Installer service was already busy with another installation — HRESULT **0x80070652** / MSI exit code **1618** (`ERROR_INSTALL_ALREADY_RUNNING`). Only one Windows Installer transaction can run at a time; the Intune Management Extension retried on its fixed budget (typically ~15-17 minutes across several attempts) and then reported the app as failed. If this app is required in the Enrollment Status Page, the Device Setup Apps subcategory fails with it and the enrollment terminates.\n\nThis failure is **deterministic per attempt**: as long as the competing installer keeps winning the race, a plain re-enrollment fails again at roughly the same minute mark — retrying without fixing the collision only churns devices.\n\nCommon sources of the competing installation:\n\n- The **preceding Win32 app in the install order**: its installer exits 0 (so IME moves on) but leaves a child `msiexec` transaction or post-install MSI work running. Security agents and proxy clients that chain multiple MSIs are frequent offenders.\n- **OEM factory software** installing on first boot (factory-image provisioning packages, vendor updaters) — typical on brand-new hardware batches.\n- **Microsoft 365 Apps (C2R), driver updaters or Windows servicing** running installations in parallel outside IME's control.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Identify the competing installer",
|
||||
"steps": [
|
||||
"In this session's timeline, note which app finished installing immediately before {{appName}} started — its installer is the prime suspect for holding the Windows Installer mutex",
|
||||
"On a reproduction device, when the failure window is active, check for running installers: Get-Process msiexec | Select-Object Id,StartTime and the in-progress marker key HKLM\\SOFTWARE\\Microsoft\\Windows\\Installer\\InProgress",
|
||||
"Check the Application event log for MsiInstaller events around the failure timestamp to see which product held the transaction",
|
||||
"On factory-fresh hardware, also look for OEM first-boot installers (vendor updater services, factory provisioning packages) that run outside Intune"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Serialize the install chain",
|
||||
"steps": [
|
||||
"Add an Intune Win32 dependency so {{appName}} depends on the app whose installer causes the collision — IME then orders them explicitly instead of racing",
|
||||
"Alternatively, wrap the offending predecessor so its install script waits until msiexec has fully finished (poll the InProgress registry key or the _MSIExecute mutex with a bounded timeout) before exiting",
|
||||
"If the app does not have to gate the ESP, remove it from the blocking-app list so a transient 1618 no longer terminates the enrollment; Intune retries it after the desktop"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: MsiExec.exe error messages (1618 = another installation is in progress)",
|
||||
"url": "https://learn.microsoft.com/en-us/windows/win32/msi/error-codes"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Troubleshoot Win32 app installations in Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"apps",
|
||||
"msiexec",
|
||||
"mutex",
|
||||
"0x80070652",
|
||||
"1618",
|
||||
"esp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-CORR-003",
|
||||
"title": "Proxy Configuration Causing Download Failure",
|
||||
@@ -2675,7 +2766,7 @@
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -2726,7 +2817,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list), unexpected_profiles (C:\\Users profile directories not on the allowed list), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -2749,7 +2840,7 @@
|
||||
"steps": [
|
||||
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
|
||||
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
|
||||
"Check whether the unexpected account has admin group membership",
|
||||
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
@@ -2964,6 +3055,86 @@
|
||||
"esp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-NET-001",
|
||||
"title": "Diagnostics Upload Blocked by Web Security Gateway",
|
||||
"description": "Detects diagnostics package uploads that were answered by a security appliance's HTML block page instead of the Autopilot Monitor backend. A forward proxy / secure web gateway (SWG) with SSL inspection is intercepting the upload endpoint, so exactly the sessions where diagnostics matter most (OnFailure uploads) are the ones losing them.",
|
||||
"severity": "warning",
|
||||
"category": "network",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "foreign_html_response",
|
||||
"source": "event_data",
|
||||
"eventType": "diagnostics_upload_failed",
|
||||
"dataField": "errorCode",
|
||||
"operator": "contains",
|
||||
"value": "HTML platform error page",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "security_gateway_marker",
|
||||
"source": "event_data",
|
||||
"eventType": "diagnostics_upload_failed",
|
||||
"dataField": "errorCode",
|
||||
"operator": "regex",
|
||||
"value": "mcafee|skyhigh|zscaler|netskope|blue ?coat|proxysg|forcepoint|websense|palo ?alto|fortigate|fortiguard|sophos|watchguard|barracuda|iboss|umbrella|lightspeed|smoothwall|contentkeeper|web ?gateway|web ?filter|content ?filter|secure ?gateway",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "http_403_block",
|
||||
"source": "event_data",
|
||||
"eventType": "diagnostics_upload_failed",
|
||||
"dataField": "errorCode",
|
||||
"operator": "contains",
|
||||
"value": "403",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "http_403_block",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "The agent collected a diagnostics package but the upload was answered with an **HTML block page from a web security gateway** instead of a response from the Autopilot Monitor backend — the event message names the product that served the page. A forward proxy / SWG with SSL inspection is intercepting HTTPS traffic from the enrollment network and blocking the upload endpoint.\n\nThe consequence is quietly painful: diagnostics upload is typically configured **OnFailure**, so the sessions that fail — the ones where the IME logs and diagnostics ZIP would explain the root cause — are exactly the sessions whose evidence never arrives. Enrollment telemetry itself may still flow (the agent often starts before the gateway's client enforcement does, or other endpoints are treated differently), which makes the gap easy to miss until a failure needs investigating.\n\nNote this rule only fires when the blocking page carries a recognizable security-product marker. A plain HTML error page without one usually means a stale or stopped endpoint instead — that is a different problem.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Exempt the upload endpoint in the web gateway",
|
||||
"steps": [
|
||||
"Identify the product from the block-page title quoted in this event's message (e.g. a Skyhigh/McAfee Web Gateway notification page)",
|
||||
"In that gateway, add the Autopilot Monitor API host shown in the event message to the SSL-inspection bypass AND the URL allowlist for the enrollment network segment",
|
||||
"Enrollment traffic often originates from a dedicated provisioning VLAN or from devices without the proxy client yet — make sure the exemption applies to that segment, not only to managed clients",
|
||||
"If the gateway enforces per-user policy, remember ESP-phase traffic runs as SYSTEM/device before any user signs in — the exemption must cover unauthenticated device traffic"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Verify the fix",
|
||||
"steps": [
|
||||
"Re-run an enrollment (or wait for the next failed one) and confirm a diagnostics package appears on the session in the portal",
|
||||
"Optionally set diagnostics upload to Always temporarily to validate the path without needing a failure"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Windows Autopilot networking requirements",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/networking-requirements"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"network",
|
||||
"proxy",
|
||||
"ssl-inspection",
|
||||
"swg",
|
||||
"diagnostics"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-OFFICE-001",
|
||||
"title": "Microsoft 365 Apps Install Failed",
|
||||
|
||||
+88
-168
@@ -4,7 +4,7 @@
|
||||
{
|
||||
"patternId": "HS-COMPLIANCE",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>pre|post)-rem(?:e)?diation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"pattern": "^\\[HS\\] the (?<part>pre|post)-rem(?:e)?diation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"action": "healthScriptDetectionResult",
|
||||
"parameters": {
|
||||
"scriptType": "remediation"
|
||||
@@ -15,7 +15,7 @@
|
||||
{
|
||||
"patternId": "HS-COMPLIANCE-POST",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>post) detection script result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"pattern": "^\\[HS\\] the (?<part>post) detection script result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"action": "healthScriptDetectionResult",
|
||||
"parameters": {
|
||||
"scriptType": "remediation"
|
||||
@@ -26,7 +26,7 @@
|
||||
{
|
||||
"patternId": "HS-EXITCODE",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] exit code of the script is (?<exitCode>-?\\d+)",
|
||||
"pattern": "^\\[HS\\] exit code of the script is (?<exitCode>-?\\d+)",
|
||||
"action": "scriptExitCode",
|
||||
"parameters": {
|
||||
"scriptType": "remediation"
|
||||
@@ -37,7 +37,7 @@
|
||||
{
|
||||
"patternId": "HS-NEW-RESULT",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] new result = (?<json>\\{[\\s\\S]*\\})\\s*$",
|
||||
"pattern": "^\\[HS\\] new result = (?<json>\\{[\\s\\S]*\\})\\s*$",
|
||||
"action": "healthScriptResult",
|
||||
"parameters": {
|
||||
"scriptType": "remediation"
|
||||
@@ -48,7 +48,7 @@
|
||||
{
|
||||
"patternId": "HS-RUN-CONTEXT",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] The policy needs be run as (?<context>System|User)",
|
||||
"pattern": "^\\[HS\\] The policy needs be run as (?<context>System|User)",
|
||||
"action": "scriptContext",
|
||||
"parameters": {
|
||||
"scriptType": "remediation"
|
||||
@@ -59,7 +59,7 @@
|
||||
{
|
||||
"patternId": "HS-SCRIPT-START",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] ProcessScript PolicyId: (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) PolicyType: (?<policyType>\\d+)",
|
||||
"pattern": "^\\[HS\\] ProcessScript PolicyId: (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) PolicyType: (?<policyType>\\d+)",
|
||||
"action": "scriptStarted",
|
||||
"parameters": {
|
||||
"scriptType": "remediation"
|
||||
@@ -70,7 +70,7 @@
|
||||
{
|
||||
"patternId": "HS-STDERR",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] err output = (?<output>.*)",
|
||||
"pattern": "^\\[HS\\] err output = (?<output>.*)",
|
||||
"action": "scriptOutput",
|
||||
"parameters": {
|
||||
"scriptType": "remediation",
|
||||
@@ -82,7 +82,7 @@
|
||||
{
|
||||
"patternId": "HS-STDOUT",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] std output = (?<output>.*)",
|
||||
"pattern": "^\\[HS\\] std output = (?<output>.*)",
|
||||
"action": "scriptOutput",
|
||||
"parameters": {
|
||||
"scriptType": "remediation",
|
||||
@@ -94,7 +94,7 @@
|
||||
{
|
||||
"patternId": "IME-AGENT-VERSION",
|
||||
"category": "always",
|
||||
"pattern": "Agent version is: (?<agentVersion>[\\d.]+)",
|
||||
"pattern": "^Agent version is: (?<agentVersion>[\\d.]+)",
|
||||
"action": "imeAgentVersion",
|
||||
"description": "Captures the Intune Management Extension agent version number.",
|
||||
"enabled": true
|
||||
@@ -102,7 +102,7 @@
|
||||
{
|
||||
"patternId": "IME-ALREADY-DETECTED-1",
|
||||
"category": "otherPhases",
|
||||
"pattern": "\\[Win32App\\] Completed detectionManager SideCar\\w+?DetectionManager, applicationDetectedByCurrentRule: True",
|
||||
"pattern": "^\\[Win32App\\] Completed detectionManager SideCar\\w+?DetectionManager, applicationDetectedByCurrentRule: True",
|
||||
"action": "ignoreCompletedApp",
|
||||
"description": "Detects when an app from a previous ESP phase is detected as already installed, so it should be ignored in the current phase.",
|
||||
"enabled": true
|
||||
@@ -110,7 +110,7 @@
|
||||
{
|
||||
"patternId": "IME-ALREADY-DETECTED-2",
|
||||
"category": "otherPhases",
|
||||
"pattern": "\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
|
||||
"pattern": "^\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
|
||||
"action": "ignoreCompletedApp",
|
||||
"description": "Detects enforcement success in a non-current phase, indicating an app completed in a previous phase should be ignored.",
|
||||
"enabled": true
|
||||
@@ -118,7 +118,7 @@
|
||||
{
|
||||
"patternId": "IME-APP-TYPE-MSI",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\].*?\\bMSI App (?<id>{GUID})",
|
||||
"pattern": "^\\[Win32App\\].*?\\bMSI App (?<id>{GUID})",
|
||||
"action": "captureAppTypeMsi",
|
||||
"description": "Detects an MSI-based Win32 app via the 'MSI App <guid>' phrase in the ESP retry configuration line. Marks AppType as 'MSI' for that app id.",
|
||||
"enabled": true
|
||||
@@ -126,7 +126,7 @@
|
||||
{
|
||||
"patternId": "IME-APP-TYPE-WINGET",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[WinGetApp\\].*?app with id: (?<id>{GUID})",
|
||||
"pattern": "^\\[Win32App\\]\\[WinGetApp\\].*?app with id: (?<id>{GUID})",
|
||||
"action": "captureAppTypeWinGet",
|
||||
"description": "Detects a WinGet-backed Store app by any [Win32App][WinGetApp] handler log line (Detection/Applicability/Operation). Marks AppType as 'WinGet' for that app id.",
|
||||
"enabled": true
|
||||
@@ -134,7 +134,7 @@
|
||||
{
|
||||
"patternId": "IME-APP-VERSION",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: (?<id>{GUID}).*?\"DetectedIdentityVersion\":\\{[^}]*?\"NewValue\":\"(?<appVersion>[^\"]+)\"",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: (?<id>{GUID}).*?\"DetectedIdentityVersion\":\\{(?:\"OldValue\":[^,}]{0,64},)?\"NewValue\":\"(?<appVersion>[^\"]+)\"",
|
||||
"action": "captureAppVersion",
|
||||
"description": "Captures the product version IME detected after an app install (e.g. \"11.2.1787.0\"). Source: [ReportingManager] Detection state update with DetectedIdentityVersion.NewValue. Used by App Dashboard to show version-level failure breakdowns.",
|
||||
"enabled": true
|
||||
@@ -142,7 +142,7 @@
|
||||
{
|
||||
"patternId": "IME-ATTEMPT-NUMBER",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] ===Step=== Execute retry (?<attempt>\\d+)$",
|
||||
"pattern": "^\\[Win32App\\] ===Step=== Execute retry (?<attempt>\\d+)$",
|
||||
"action": "captureAttemptNumber",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
@@ -153,7 +153,7 @@
|
||||
{
|
||||
"patternId": "IME-CACHE-MISS",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] Content cache miss for app \\(id = {GUID}, name = (?<name>.*?)\\), start downloading",
|
||||
"pattern": "^\\[Win32App\\] Content cache miss for app \\(id = {GUID}, name = (?<name>.*?)\\), start downloading",
|
||||
"action": "updateName",
|
||||
"description": "Captures 'Content cache miss for app (id = <guid>, name = <name>), start downloading...' (ContentManager.cs) as a secondary app-name source. Fires right before a real download begins, so it resolves names for exactly the apps that will produce download/install activity — a redundancy for IME-POLICIES-based naming after IME retired the old name-bearing processing lines.",
|
||||
"enabled": true
|
||||
@@ -161,7 +161,7 @@
|
||||
{
|
||||
"patternId": "IME-DECRYPT-FAILED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "Decryption is failed for appId {GUID}",
|
||||
"pattern": "^Decryption is failed for appId {GUID}",
|
||||
"action": "updateStateError",
|
||||
"description": "Detects when app content decryption fails, indicating a corrupted or tampered download.",
|
||||
"enabled": true
|
||||
@@ -169,7 +169,7 @@
|
||||
{
|
||||
"patternId": "IME-DETECTED-INSTALLED-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
@@ -177,23 +177,15 @@
|
||||
{
|
||||
"patternId": "IME-DETECTED-INSTALLED-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: {GUID} resulted in action status: Success and detection state: Detected",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: {GUID} resulted in action status: Success and detection state: Detected",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's detection action handler reports successful detection (app is installed).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-DETECTED-OLD",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Detected app {GUID} ",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app is detected as installed (old log format). DISABLED 2026-08-17: the standalone 'Detected app <guid> ' line no longer exists in IME 1.97.107; this regex instead matched the 'Detected app ... and intent is to install, skip applicability check' line, colliding with IME-SKIP-INSTALL-DETECTED and firing updateStateInstalled and updateStateSkipped on the same physical line (see tasks/ime-v1-code-audit-2026-08-17.md).",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-DETECTION-RESULT",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
|
||||
"action": "captureDetectionResult",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"enabled": true
|
||||
@@ -201,23 +193,23 @@
|
||||
{
|
||||
"patternId": "IME-DO-TEL",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
|
||||
"pattern": "^\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
|
||||
"action": "updateDoTelemetry",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration.",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent in 1.104.102.0 and 1.105.103.0 decompiles, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-DO-TIMEOUT-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App DO\\] DO download(?=.*?\\bnot finished\\b)(?=.*?\\btimeout\\b).*?intunewin-bin_{GUID}",
|
||||
"pattern": "^\\[Win32App DO\\] DO downloading is not finished within timeout, jobId = [^,]{0,64}, fileId =.*?intunewin-bin_{GUID}",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "Detects when a Delivery Optimization download times out for a specific app (identified by GUID in filename).",
|
||||
"description": "Detects when a Delivery Optimization download times out for a specific app (identified by GUID in the DO fileId). Source literal (ContentDownloaderDeliveryOptimization, identical IME 1.97-1.105): \"[Win32App DO] DO downloading is not finished within timeout, jobId = {0}, fileId = {1}\".",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-DO-TIMEOUT-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App DO\\] DO download(?=.*?\\bnot finished\\b)(?=.*?\\btimeout\\b)",
|
||||
"pattern": "^\\[Win32App DO\\] DO downloading is not finished within timeout",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
@@ -228,7 +220,7 @@
|
||||
{
|
||||
"patternId": "IME-DOWNLOADING",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[StatusService\\] Downloading app \\(id = {GUID}.*?\\) via (?<tech>\\w+), bytes (?<bytes>\\w+)/(?<ofbytes>\\w+) for user",
|
||||
"pattern": "^\\[StatusService\\] Downloading app \\(id = {GUID}.*?\\) via (?<tech>\\w+), bytes (?<bytes>\\w+)/(?<ofbytes>\\w+) for user",
|
||||
"action": "updateStateDownloading",
|
||||
"description": "Captures app download progress including technology (DO/CDN), bytes downloaded, and total bytes.",
|
||||
"enabled": true
|
||||
@@ -236,7 +228,7 @@
|
||||
{
|
||||
"patternId": "IME-ENFORCEMENT-SUCCESS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
@@ -244,7 +236,7 @@
|
||||
{
|
||||
"patternId": "IME-ENFORCEMENT-SUCCESS-OLD",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
|
||||
"pattern": "^\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
|
||||
"action": "updateStateInstalled",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
@@ -255,7 +247,7 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-DOWNLOAD",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent. Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
@@ -263,7 +255,7 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-ENFORCEMENT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Setting enforcementState as\\W+Error\\b",
|
||||
"pattern": "^\\[Win32App\\] Setting enforcementState as\\W+Error\\b",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
@@ -274,7 +266,7 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-NO-CONTENT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Didn't get content info from service",
|
||||
"pattern": "^\\[Win32App\\] Didn't get content info from service",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
@@ -285,7 +277,7 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
@@ -296,7 +288,7 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-TIMEOUT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Installation is timeout",
|
||||
"pattern": "^\\[Win32App\\] Installation is timeout",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
@@ -307,7 +299,7 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-UNMAPPED-EXIT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Admin did NOT set mapping for lpExitCode\\W+(?<exitCode>\\d+) of app\\W+{GUID}",
|
||||
"pattern": "^\\[Win32App\\] Admin did NOT set mapping for lpExitCode\\W+(?<exitCode>\\d+) of app\\W+{GUID}",
|
||||
"action": "updateStateError",
|
||||
"description": "Detects when an app installer returned an exit code that has no mapping configured in Intune.",
|
||||
"enabled": true
|
||||
@@ -315,7 +307,7 @@
|
||||
{
|
||||
"patternId": "IME-ESP-APP-REGISTERED",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] In EspPhase: \\w+\\. App {GUID} has been registered.*?App name: (?<name>.*?)$",
|
||||
"pattern": "^\\[Win32App\\]\\[EspManager\\] In EspPhase: \\w+\\. App {GUID} has been registered.*?App name: (?<name>.*?)$",
|
||||
"action": "updateName",
|
||||
"description": "Captures the ESP tracked-app registration line ('App <guid> has been registered[ for user <id>]. App name: <name>', EspManager.cs) as an app-name source. Primary replacement for the retired IME-UPDATE-NAME-1/2/3 whose emitters were removed with IME's old V1/V2 processing pipeline; also documents exactly which apps IME registered for ESP tracking. Category 'always' so names resolve even before the first ApplicationPoller EspPhase line is seen.",
|
||||
"enabled": true
|
||||
@@ -323,7 +315,7 @@
|
||||
{
|
||||
"patternId": "IME-ESP-PHASE",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] (?:In|The) EspPhase: (?<espPhase>\\w+)",
|
||||
"pattern": "^\\[Win32App\\] (?:In|The) EspPhase: (?<espPhase>\\w+)",
|
||||
"action": "espPhaseDetected",
|
||||
"description": "Detects ESP phase transitions reported by IME Win32App processing (e.g., DeviceSetup, AccountSetup).",
|
||||
"enabled": true
|
||||
@@ -331,7 +323,7 @@
|
||||
{
|
||||
"patternId": "IME-ESP-TRACK-STATUS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}",
|
||||
"pattern": "^\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}",
|
||||
"action": "espTrackStatus",
|
||||
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app. No trailing anchor: IME 1.97 (Dec 2025) appends ' with name: <appName>.' after the GUID (EspManager.cs), which broke the previous '\\.' suffix. 'from' can be the literal string 'null' on first tracking.",
|
||||
"enabled": true
|
||||
@@ -339,7 +331,7 @@
|
||||
{
|
||||
"patternId": "IME-EXITCODE",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] lpExitCode (?<exitCode>-?\\d+)$",
|
||||
"pattern": "^\\[Win32App\\] lpExitCode (?<exitCode>-?\\d+)$",
|
||||
"action": "captureExitCode",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
@@ -350,7 +342,7 @@
|
||||
{
|
||||
"patternId": "IME-EXITCODE-MAPPED-FAILED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] lpExitCode is defined as Failed$",
|
||||
"pattern": "^\\[Win32App\\] lpExitCode is defined as Failed$",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
@@ -361,7 +353,7 @@
|
||||
{
|
||||
"patternId": "IME-GRS-CLEARED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ExecutionActionHandler\\] Encountered early subgraph termination\\. Clearing GRS for app with id: {GUID} so it can be retried during the next check-in\\.",
|
||||
"pattern": "^\\[Win32App\\]\\[ExecutionActionHandler\\] Encountered early subgraph termination\\. Clearing GRS for app with id: {GUID} so it can be retried during the next check-in\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "IME aborted the app's subgraph before enforcement finished and cleared its GRS entry so the app retries on the next check-in (ExecutionActionHandler, AppWorkload.log). Maps to Postponed: the install did not complete this pass but is scheduled to run again - previously this looked like an unexplained gap. Wording verified in IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
@@ -369,7 +361,7 @@
|
||||
{
|
||||
"patternId": "IME-GRS-SKIP",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID} to (?:install|uninstall) is in GRS\\. The app will not be enforced\\.",
|
||||
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID} to (?:install|uninstall) is in GRS\\. The app will not be enforced\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "GRS (Global Retry Schedule) skip: the app failed recently and IME will not re-enforce it this check-in ('App with id: <guid> to install is in GRS. The app will not be enforced.', EnforcementActionProcessor / MixedEnforcementActionProcessor). THE canonical reason an app silently does nothing for up to 24h - previously invisible. Lives in AppActionProcessor.log, which the agent tails since the 2026-08-18 release; wording verified in IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
@@ -377,7 +369,7 @@
|
||||
{
|
||||
"patternId": "IME-HRESULT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] hResultFromWin32 (?<hresult>-?\\d+)$",
|
||||
"pattern": "^\\[Win32App\\] hResultFromWin32 (?<hresult>-?\\d+)$",
|
||||
"action": "captureHResult",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
@@ -388,7 +380,7 @@
|
||||
{
|
||||
"patternId": "IME-IMPERSONATION",
|
||||
"category": "always",
|
||||
"pattern": "After impersonation: (?<user>.*?)$",
|
||||
"pattern": "^After impersonation: (?<user>.*?)$",
|
||||
"action": "imeImpersonation",
|
||||
"description": "Detects when IME impersonates a user context for app installation.",
|
||||
"enabled": true
|
||||
@@ -396,7 +388,7 @@
|
||||
{
|
||||
"patternId": "IME-INSTALL-HANDLER",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ExecutionActionHandler\\] Handler invoked with execution type: Install for policy with id: {GUID}",
|
||||
"pattern": "^\\[Win32App\\]\\[ExecutionActionHandler\\] Handler invoked with execution type: Install for policy with id: {GUID}",
|
||||
"action": "updateStateInstalling",
|
||||
"description": "Detects when the IME execution handler starts installing an app.",
|
||||
"enabled": true
|
||||
@@ -404,7 +396,7 @@
|
||||
{
|
||||
"patternId": "IME-LAUNCH-INSTALLER",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Launch Win32AppInstaller in \\w+? session",
|
||||
"pattern": "^\\[Win32App\\] Launch Win32AppInstaller in \\w+? session",
|
||||
"action": "updateStateInstalling",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
@@ -415,7 +407,7 @@
|
||||
{
|
||||
"patternId": "IME-NO-ACTION",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] No action required for app with id: {GUID}",
|
||||
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] No action required for app with id: {GUID}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when IME determines no action is required for an app (already in desired state).",
|
||||
"enabled": true
|
||||
@@ -423,7 +415,7 @@
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ApplicabilityActionHandler\\] Applicability check for policy with id: {GUID} resulted in action status: Success and applicability state: NotApplicable",
|
||||
"pattern": "^\\[Win32App\\]\\[ApplicabilityActionHandler\\] Applicability check for policy with id: {GUID} resulted in action status: Success and applicability state: NotApplicable",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability check returns NotApplicable (requirements not met).",
|
||||
"enabled": true
|
||||
@@ -431,7 +423,7 @@
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
@@ -439,7 +431,7 @@
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-3",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
@@ -447,7 +439,7 @@
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-4",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
@@ -455,7 +447,7 @@
|
||||
{
|
||||
"patternId": "IME-NOT-TARGETED-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, targeted intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
|
||||
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, targeted intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app with RequiredInstall intent is classified as NotApplicableOrNotTargeted (new log format).",
|
||||
"enabled": true
|
||||
@@ -463,7 +455,7 @@
|
||||
{
|
||||
"patternId": "IME-NOT-TARGETED-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, effective intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
|
||||
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, effective intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app with effective RequiredInstall intent is classified as NotApplicableOrNotTargeted.",
|
||||
"enabled": true
|
||||
@@ -471,7 +463,7 @@
|
||||
{
|
||||
"patternId": "IME-POLICIES",
|
||||
"category": "currentPhase",
|
||||
"pattern": "Get policies =\\s*(?<policies>.*)",
|
||||
"pattern": "^Get policies =\\s*(?<policies>.*)",
|
||||
"action": "policiesDiscovered",
|
||||
"description": "Captures the JSON policies list discovered by IME, used to build the app inventory.",
|
||||
"enabled": true
|
||||
@@ -479,7 +471,7 @@
|
||||
{
|
||||
"patternId": "IME-RESTART-STOP-PROCESSING",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[V3Processor\\] A restart is required to continue processing apps\\. Stop processing\\.",
|
||||
"pattern": "^\\[Win32App\\]\\[V3Processor\\] A restart is required to continue processing apps\\. Stop processing\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
@@ -490,39 +482,15 @@
|
||||
{
|
||||
"patternId": "IME-SESSION-CHANGE",
|
||||
"category": "always",
|
||||
"pattern": "OnSessionChange:? (?<change>.*?)$",
|
||||
"pattern": "^OnSessionChange:? (?<change>.*?)$",
|
||||
"action": "imeSessionChange",
|
||||
"description": "Detects Windows session change events (logon, logoff, lock, unlock) reported by IME.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SET-CURRENT-1",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SET-CURRENT-2",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?\\bid\\W+{GUID}",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SET-CURRENT-3",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ===Step=== Start to Present app {GUID}",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects the start of app presentation step in IME, setting the app as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SET-CURRENT-4",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] SetCurrentDirectory:.*?\\\\{GUID}_\\d+",
|
||||
"pattern": "^\\[Win32App\\] SetCurrentDirectory:.*?\\\\{GUID}_\\d+",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when IME sets the working directory for an app's staging folder, setting it as current.",
|
||||
"enabled": true
|
||||
@@ -530,7 +498,7 @@
|
||||
{
|
||||
"patternId": "IME-SHUTDOWN",
|
||||
"category": "currentPhase",
|
||||
"pattern": "EMS Agent received shutdown signal",
|
||||
"pattern": "^EMS Agent received shutdown signal",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
@@ -538,34 +506,10 @@
|
||||
"description": "Detects when the IME agent receives a shutdown signal, postponing the current app installation.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SKIP-APPLICABILITY-NOTMET",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] (?=.*?\\bapplicability\\b)(?=.*?NotMet\\b).*?\\bapp\\W+{GUID}\\b",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SKIP-DEPENDENCY-DETECT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] app\\W+{GUID}\\b.*?\\bis a dependency\\b.*?\\bDetect Only mode\\b.*?\\bfinished processing",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SKIP-DETECTED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Detected app {GUID}.*?skip next step",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an already-detected app is skipped for further processing steps (old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SKIP-FILTERS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[V3Processor\\] All apps in the subgraph are not applicable due to assignment filters\\. Skipping processing\\.",
|
||||
"pattern": "^\\[Win32App\\]\\[V3Processor\\] All apps in the subgraph are not applicable due to assignment filters\\. Skipping processing\\.",
|
||||
"action": "updateStateSkipped",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
@@ -576,7 +520,7 @@
|
||||
{
|
||||
"patternId": "IME-SKIP-INSTALL-DETECTED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Detected app {GUID} and intent is to install, skip applicability check",
|
||||
"pattern": "^\\[Win32App\\] Detected app {GUID} and intent is to install, skip applicability check",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app targeted for install is already detected/installed, so the install is skipped (old log format).",
|
||||
"enabled": true
|
||||
@@ -584,23 +528,15 @@
|
||||
{
|
||||
"patternId": "IME-SKIP-UNINSTALL-NOTDETECTED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Not detected app {GUID} and intent is to un-install, skip applicability check",
|
||||
"pattern": "^\\[Win32App\\] Not detected app {GUID} and intent is to un-install, skip applicability check",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app targeted for uninstall is not detected, so the uninstall is skipped (old log format).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SKIP-UNINSTALL-NOTDETECTED-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] NOT detected app {GUID}.*?skip un-installation",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-STARTED",
|
||||
"category": "always",
|
||||
"pattern": "EMS Agent Started",
|
||||
"pattern": "^EMS Agent Started",
|
||||
"action": "imeStarted",
|
||||
"description": "Detects when the Intune Management Extension (IME) agent starts up.",
|
||||
"enabled": true
|
||||
@@ -608,7 +544,7 @@
|
||||
{
|
||||
"patternId": "IME-STOP-SIGNAL",
|
||||
"category": "currentPhase",
|
||||
"pattern": "EMS Agent received stop signal",
|
||||
"pattern": "^EMS Agent received stop signal",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
@@ -619,7 +555,7 @@
|
||||
{
|
||||
"patternId": "IME-SUBGRAPH",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[V3Processor\\] Processing subgraph with app ids: {GUID}",
|
||||
"pattern": "^\\[Win32App\\]\\[V3Processor\\] Processing subgraph with app ids: {GUID}",
|
||||
"action": "cancelStuckAndSetCurrent",
|
||||
"description": "Detects when V3Processor starts a new subgraph, cancelling any stuck app and setting the new app as current.",
|
||||
"enabled": true
|
||||
@@ -627,7 +563,7 @@
|
||||
{
|
||||
"patternId": "IME-TOKEN-FAILURE",
|
||||
"category": "always",
|
||||
"pattern": "Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"pattern": "^Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"enabled": true
|
||||
@@ -635,7 +571,7 @@
|
||||
{
|
||||
"patternId": "IME-TOKEN-SUCCESS",
|
||||
"category": "always",
|
||||
"pattern": "Successfully get the token",
|
||||
"pattern": "^Successfully get the token",
|
||||
"action": "espPhaseDetected",
|
||||
"parameters": {
|
||||
"phase": "AccountSetup"
|
||||
@@ -643,42 +579,26 @@
|
||||
"description": "Detects successful token acquisition, indicating the AccountSetup ESP phase has started.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-UPDATE-NAME-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?name\\W+(?<name>.*?)\\W+id\\W+{GUID}",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from ExecManager processing log (old format: name before id). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-UPDATE-NAME-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name (?<name>.*?)$",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from ProcessAppWithDependencies log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-UPDATE-NAME-3",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Processing app \\(id={GUID}, name = (?<name>.*?)\\)",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from the Processing app log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-USER-SESSION-COMPLETED",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] \\.+ Completed user session \\d+, userId: (?<userId>[a-f0-9\\-]+), userSID: (?<userSid>S-[\\d\\-]+) \\.+",
|
||||
"pattern": "^\\[Win32App\\] \\.+ Completed user session \\d+, userId: (?<userId>[a-f0-9\\-]+), userSID: (?<userSid>S-[\\d\\-]+) \\.+",
|
||||
"action": "enrollmentCompleted",
|
||||
"description": "Detects when IME completes a user session, indicating enrollment has finished for that user.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-USER-SESSION-ZERO-APPS",
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\] Get 0 apps for user session (?<sessionId>\\d+), user id = (?<userId>[a-f0-9\\-]+)",
|
||||
"action": "userSessionZeroApps",
|
||||
"description": "Detects when IME enumerated a user session and found zero targeted Win32 apps. IME then skips its 'Completed user session' line entirely (the zero-app branch in ApplicationPoller continues before that log statement), so this line is the only evidence that user-phase enforcement finished on a device with no user-targeted apps. Treated as equivalent to a completed user session.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-WIN32-STATE-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Got InstanceID: Win32App_{GUID}_\\d+, installationStateString: (?<state>\\d)",
|
||||
"pattern": "^\\[Win32App\\] Got InstanceID: Win32App_{GUID}_\\d+, installationStateString: (?<state>\\d)",
|
||||
"action": "updateWin32AppState",
|
||||
"description": "Captures the Win32 app installation state string from instance ID tracking (old log format).",
|
||||
"enabled": true
|
||||
@@ -686,7 +606,7 @@
|
||||
{
|
||||
"patternId": "IME-WIN32-STATE-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] (?:Got|Set) (?:ESP )?win32App state (?<state>\\w+) for {GUID}",
|
||||
"pattern": "^\\[Win32App\\] (?:Got|Set) (?:ESP )?win32App state (?<state>\\w+) for {GUID}",
|
||||
"action": "updateWin32AppState",
|
||||
"description": "Captures the Win32 app ESP state (e.g., Installed, InProgress) for a specific app.",
|
||||
"enabled": true
|
||||
@@ -694,7 +614,7 @@
|
||||
{
|
||||
"patternId": "IME-WINGET-ERROR",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID}.*?- Operation Phase = Error",
|
||||
"pattern": "^\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID}.*?- Operation Phase = Error",
|
||||
"action": "updateStateError",
|
||||
"description": "Marks a WinGet (new Store) app as failed when its operation progress reports phase Error (WinGetOperationInstallPhase: Downloading/Installing/Queued/Finished/Error). Before this pattern only the Installing phase was matched, so WinGet failures never surfaced as app errors (IME 1.97 audit 2026-08-17).",
|
||||
"enabled": true
|
||||
@@ -702,7 +622,7 @@
|
||||
{
|
||||
"patternId": "IME-WINGET-INSTALLING-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID} and user = .*?- Operation Phase = Installing",
|
||||
"pattern": "^\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID} and user = .*?- Operation Phase = Installing",
|
||||
"action": "updateStateInstalling",
|
||||
"description": "Detects WinGet app installation progress with Operation Phase = Installing (format variant 1).",
|
||||
"enabled": true
|
||||
@@ -710,7 +630,7 @@
|
||||
{
|
||||
"patternId": "IME-WINGET-INSTALLING-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID}.*?- Operation Phase = Installing",
|
||||
"pattern": "^\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID}.*?- Operation Phase = Installing",
|
||||
"action": "updateStateInstalling",
|
||||
"description": "Detects WinGet app installation progress with Operation Phase = Installing (format variant 2).",
|
||||
"enabled": true
|
||||
@@ -718,7 +638,7 @@
|
||||
{
|
||||
"patternId": "PS-AGENT-ERROR",
|
||||
"category": "always",
|
||||
"pattern": "error from script =(?<output>[\\s\\S]*)",
|
||||
"pattern": "^error from script =(?<output>[\\s\\S]*)",
|
||||
"action": "scriptOutput",
|
||||
"parameters": {
|
||||
"scriptType": "platform",
|
||||
@@ -730,7 +650,7 @@
|
||||
{
|
||||
"patternId": "PS-AGENT-EXITCODE",
|
||||
"category": "always",
|
||||
"pattern": "Powershell exit code is (?<exitCode>-?\\d+)",
|
||||
"pattern": "^Powershell exit code is (?<exitCode>-?\\d+)",
|
||||
"action": "scriptExitCode",
|
||||
"parameters": {
|
||||
"scriptType": "platform"
|
||||
@@ -741,7 +661,7 @@
|
||||
{
|
||||
"patternId": "PS-AGENT-INVOCATION",
|
||||
"category": "always",
|
||||
"pattern": "ExecutorLog AgentExecutor gets invoked",
|
||||
"pattern": "^ExecutorLog AgentExecutor gets invoked",
|
||||
"action": "resetPlatformScriptContext",
|
||||
"description": "Marks the start of a new AgentExecutor.exe invocation. AgentExecutor hosts BOTH platform scripts (Policies\\Scripts\\<id>.ps1) and proactive-remediation scripts (IMECache\\HealthScripts\\<id>\\detect.ps1) and writes their lines interleaved into the shared AgentExecutor.log. The policyId-less 'Powershell exit code is N' / 'write output done' lines are otherwise routed to the last-started platform script via a sticky pointer, so a remediation invocation (whose start line does not match PS-AGENT-SCRIPT-START) leaks its exit/output into that platform script's result. This pattern resets the platform-script line-capture context at each invocation boundary so only the matching platform invocation captures its own exit/output.",
|
||||
"enabled": true
|
||||
@@ -749,7 +669,7 @@
|
||||
{
|
||||
"patternId": "PS-AGENT-OUTPUT",
|
||||
"category": "always",
|
||||
"pattern": "write output done\\. output = (?<output>[\\s\\S]*?), error = (?<error>[\\s\\S]*)",
|
||||
"pattern": "^write output done\\. output = (?<output>[\\s\\S]*?), error = (?<error>[\\s\\S]*)",
|
||||
"action": "scriptOutput",
|
||||
"parameters": {
|
||||
"scriptType": "platform"
|
||||
@@ -760,7 +680,7 @@
|
||||
{
|
||||
"patternId": "PS-AGENT-SCRIPT-START",
|
||||
"category": "always",
|
||||
"pattern": "Adding argument powershell with value C:\\\\.*\\\\Policies\\\\Scripts\\\\[a-z0-9\\-]+_(?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})\\.ps1",
|
||||
"pattern": "^Adding argument powershell with value C:\\\\.*\\\\Policies\\\\Scripts\\\\[a-z0-9\\-]+_(?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})\\.ps1",
|
||||
"action": "scriptStarted",
|
||||
"parameters": {
|
||||
"scriptType": "platform",
|
||||
@@ -772,7 +692,7 @@
|
||||
{
|
||||
"patternId": "PS-SCRIPT-CONTEXT",
|
||||
"category": "always",
|
||||
"pattern": "Launch powershell executor in (?<context>machine|user) session",
|
||||
"pattern": "^Launch powershell executor in (?<context>machine|user) session",
|
||||
"action": "scriptContext",
|
||||
"description": "Captures whether a PowerShell script runs in machine (System) or user session context.",
|
||||
"enabled": true
|
||||
@@ -780,7 +700,7 @@
|
||||
{
|
||||
"patternId": "PS-SCRIPT-EXITCODE",
|
||||
"category": "always",
|
||||
"pattern": "Powershell execution is done, exitCode = (?<exitCode>-?\\d+)",
|
||||
"pattern": "^Powershell execution is done, exitCode = (?<exitCode>-?\\d+)",
|
||||
"action": "scriptExitCode",
|
||||
"description": "Captures the PowerShell exit code when script execution completes in the IME log.",
|
||||
"enabled": true
|
||||
@@ -788,7 +708,7 @@
|
||||
{
|
||||
"patternId": "PS-SCRIPT-GENERATED",
|
||||
"category": "always",
|
||||
"pattern": "Script file C:\\\\.*\\\\Policies\\\\Scripts\\\\[a-z0-9\\-]+_(?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})\\.ps1 is generated",
|
||||
"pattern": "^Script file C:\\\\.*\\\\Policies\\\\Scripts\\\\[a-z0-9\\-]+_(?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})\\.ps1 is generated",
|
||||
"action": "scriptStarted",
|
||||
"parameters": {
|
||||
"scriptType": "platform"
|
||||
@@ -799,7 +719,7 @@
|
||||
{
|
||||
"patternId": "PS-SCRIPT-RESULT",
|
||||
"category": "always",
|
||||
"pattern": "\\[PowerShell\\] User Id = [a-z0-9\\-]+, Policy id = (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}), policy result = (?<result>\\w+)",
|
||||
"pattern": "^\\[PowerShell\\] User Id = [a-z0-9\\-]+, Policy id = (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}), policy result = (?<result>\\w+)",
|
||||
"action": "scriptCompleted",
|
||||
"parameters": {
|
||||
"scriptType": "platform"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "HS-COMPLIANCE-POST",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>post) detection script result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"pattern": "^\\[HS\\] the (?<part>post) detection script result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"action": "healthScriptDetectionResult",
|
||||
"parameters": { "scriptType": "remediation" },
|
||||
"description": "Post-remediation detection result, current wording. IME 1.97 (Dec 2025) replaced the old '[HS] the post-remdiation detection script compliance result for ...' line (still covered by HS-COMPLIANCE for older logs) with '[HS] the post detection script result for <guid> is <True|False>' (ScriptProcessorV2.cs:721). The literal word 'post' in the line is captured as the part group so the handler classifies it as the post-detection phase.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "HS-COMPLIANCE",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>pre|post)-rem(?:e)?diation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"pattern": "^\\[HS\\] the (?<part>pre|post)-rem(?:e)?diation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"action": "healthScriptDetectionResult",
|
||||
"parameters": { "scriptType": "remediation" },
|
||||
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring). Tolerates both Microsoft's historical typo 'remdiation' and a future corrected 'remediation'. NOTE: IME 1.97 (Dec 2025) no longer emits the post- variant of this line; the post case is covered by HS-COMPLIANCE-POST.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "HS-EXITCODE",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] exit code of the script is (?<exitCode>-?\\d+)",
|
||||
"pattern": "^\\[HS\\] exit code of the script is (?<exitCode>-?\\d+)",
|
||||
"action": "scriptExitCode",
|
||||
"parameters": { "scriptType": "remediation" },
|
||||
"description": "Captures the exit code of a health-script execution. Feeds the early-signal slot consumed by HS-COMPLIANCE so the user sees the real exit code (not just the inferred 0/1) before HS-NEW-RESULT arrives.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "HS-NEW-RESULT",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] new result = (?<json>\\{[\\s\\S]*\\})\\s*$",
|
||||
"pattern": "^\\[HS\\] new result = (?<json>\\{[\\s\\S]*\\})\\s*$",
|
||||
"action": "healthScriptResult",
|
||||
"parameters": { "scriptType": "remediation" },
|
||||
"description": "Atomic final JSON of a health script run, covering all three phases (pre-detection / remediation / post-detection) emitted by IME after the policy completes. Single source of truth for health-script outcomes — replaces the line-by-line HS-COMPLIANCE/HS-EXITCODE/HS-STDOUT/HS-STDERR patterns. The end-anchor and [\\s\\S] character class are defensive against future format changes and multi-line CMTrace buffering.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "HS-RUN-CONTEXT",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] The policy needs be run as (?<context>System|User)",
|
||||
"pattern": "^\\[HS\\] The policy needs be run as (?<context>System|User)",
|
||||
"action": "scriptContext",
|
||||
"parameters": { "scriptType": "remediation" },
|
||||
"description": "Captures the execution context (System or User) for a health-script. Feeds the early-signal slot consumed by HS-COMPLIANCE so context is visible immediately rather than waiting for HS-NEW-RESULT (which exposes RunAsAccount).",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "HS-SCRIPT-START",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] ProcessScript PolicyId: (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) PolicyType: (?<policyType>\\d+)",
|
||||
"pattern": "^\\[HS\\] ProcessScript PolicyId: (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) PolicyType: (?<policyType>\\d+)",
|
||||
"action": "scriptStarted",
|
||||
"parameters": { "scriptType": "remediation" },
|
||||
"description": "Detects when a Proactive Remediation (Health Script) starts processing. Captures PolicyId and PolicyType (6 = remediation). Drives the live script_started event for UI progress indication; the consolidated final result for the same PolicyId arrives later via HS-NEW-RESULT.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "HS-STDERR",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] err output = (?<output>.*)",
|
||||
"pattern": "^\\[HS\\] err output = (?<output>.*)",
|
||||
"action": "scriptOutput",
|
||||
"parameters": { "scriptType": "remediation", "outputType": "stderr" },
|
||||
"description": "Captures standard error from a health-script execution. Feeds the early-signal slot consumed by HS-COMPLIANCE so stderr is visible immediately rather than waiting for HS-NEW-RESULT.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "HS-STDOUT",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] std output = (?<output>.*)",
|
||||
"pattern": "^\\[HS\\] std output = (?<output>.*)",
|
||||
"action": "scriptOutput",
|
||||
"parameters": { "scriptType": "remediation", "outputType": "stdout" },
|
||||
"description": "Captures standard output from a health-script execution. Feeds the early-signal slot consumed by HS-COMPLIANCE so the user sees stdout immediately rather than waiting for HS-NEW-RESULT.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-AGENT-VERSION",
|
||||
"category": "always",
|
||||
"pattern": "Agent version is: (?<agentVersion>[\\d.]+)",
|
||||
"pattern": "^Agent version is: (?<agentVersion>[\\d.]+)",
|
||||
"action": "imeAgentVersion",
|
||||
"description": "Captures the Intune Management Extension agent version number.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ALREADY-DETECTED-1",
|
||||
"category": "otherPhases",
|
||||
"pattern": "\\[Win32App\\] Completed detectionManager SideCar\\w+?DetectionManager, applicationDetectedByCurrentRule: True",
|
||||
"pattern": "^\\[Win32App\\] Completed detectionManager SideCar\\w+?DetectionManager, applicationDetectedByCurrentRule: True",
|
||||
"action": "ignoreCompletedApp",
|
||||
"description": "Detects when an app from a previous ESP phase is detected as already installed, so it should be ignored in the current phase.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ALREADY-DETECTED-2",
|
||||
"category": "otherPhases",
|
||||
"pattern": "\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
|
||||
"pattern": "^\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
|
||||
"action": "ignoreCompletedApp",
|
||||
"description": "Detects enforcement success in a non-current phase, indicating an app completed in a previous phase should be ignored.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-APP-TYPE-MSI",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\].*?\\bMSI App (?<id>{GUID})",
|
||||
"pattern": "^\\[Win32App\\].*?\\bMSI App (?<id>{GUID})",
|
||||
"action": "captureAppTypeMsi",
|
||||
"description": "Detects an MSI-based Win32 app via the 'MSI App <guid>' phrase in the ESP retry configuration line. Marks AppType as 'MSI' for that app id.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-APP-TYPE-WINGET",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[WinGetApp\\].*?app with id: (?<id>{GUID})",
|
||||
"pattern": "^\\[Win32App\\]\\[WinGetApp\\].*?app with id: (?<id>{GUID})",
|
||||
"action": "captureAppTypeWinGet",
|
||||
"description": "Detects a WinGet-backed Store app by any [Win32App][WinGetApp] handler log line (Detection/Applicability/Operation). Marks AppType as 'WinGet' for that app id.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-APP-VERSION",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: (?<id>{GUID}).*?\"DetectedIdentityVersion\":\\{[^}]*?\"NewValue\":\"(?<appVersion>[^\"]+)\"",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: (?<id>{GUID}).*?\"DetectedIdentityVersion\":\\{(?:\"OldValue\":[^,}]{0,64},)?\"NewValue\":\"(?<appVersion>[^\"]+)\"",
|
||||
"action": "captureAppVersion",
|
||||
"description": "Captures the product version IME detected after an app install (e.g. \"11.2.1787.0\"). Source: [ReportingManager] Detection state update with DetectedIdentityVersion.NewValue. Used by App Dashboard to show version-level failure breakdowns.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ATTEMPT-NUMBER",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] ===Step=== Execute retry (?<attempt>\\d+)$",
|
||||
"pattern": "^\\[Win32App\\] ===Step=== Execute retry (?<attempt>\\d+)$",
|
||||
"action": "captureAttemptNumber",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-CACHE-MISS",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] Content cache miss for app \\(id = {GUID}, name = (?<name>.*?)\\), start downloading",
|
||||
"pattern": "^\\[Win32App\\] Content cache miss for app \\(id = {GUID}, name = (?<name>.*?)\\), start downloading",
|
||||
"action": "updateName",
|
||||
"description": "Captures 'Content cache miss for app (id = <guid>, name = <name>), start downloading...' (ContentManager.cs) as a secondary app-name source. Fires right before a real download begins, so it resolves names for exactly the apps that will produce download/install activity — a redundancy for IME-POLICIES-based naming after IME retired the old name-bearing processing lines.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-DECRYPT-FAILED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "Decryption is failed for appId {GUID}",
|
||||
"pattern": "^Decryption is failed for appId {GUID}",
|
||||
"action": "updateStateError",
|
||||
"description": "Detects when app content decryption fails, indicating a corrupted or tampered download.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-DETECTED-INSTALLED-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-DETECTED-INSTALLED-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: {GUID} resulted in action status: Success and detection state: Detected",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: {GUID} resulted in action status: Success and detection state: Detected",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's detection action handler reports successful detection (app is installed).",
|
||||
"enabled": true
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-DETECTED-OLD",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Detected app {GUID} ",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app is detected as installed (old log format). DISABLED 2026-08-17: the standalone 'Detected app <guid> ' line no longer exists in IME 1.97.107; this regex instead matched the 'Detected app ... and intent is to install, skip applicability check' line, colliding with IME-SKIP-INSTALL-DETECTED and firing updateStateInstalled and updateStateSkipped on the same physical line (see tasks/ime-v1-code-audit-2026-08-17.md).",
|
||||
"enabled": false
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-DETECTION-RESULT",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
|
||||
"action": "captureDetectionResult",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-DO-TEL",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
|
||||
"pattern": "^\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
|
||||
"action": "updateDoTelemetry",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration.",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent in 1.104.102.0 and 1.105.103.0 decompiles, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-DO-TIMEOUT-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App DO\\] DO download(?=.*?\\bnot finished\\b)(?=.*?\\btimeout\\b).*?intunewin-bin_{GUID}",
|
||||
"pattern": "^\\[Win32App DO\\] DO downloading is not finished within timeout, jobId = [^,]{0,64}, fileId =.*?intunewin-bin_{GUID}",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "Detects when a Delivery Optimization download times out for a specific app (identified by GUID in filename).",
|
||||
"description": "Detects when a Delivery Optimization download times out for a specific app (identified by GUID in the DO fileId). Source literal (ContentDownloaderDeliveryOptimization, identical IME 1.97-1.105): \"[Win32App DO] DO downloading is not finished within timeout, jobId = {0}, fileId = {1}\".",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-DO-TIMEOUT-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App DO\\] DO download(?=.*?\\bnot finished\\b)(?=.*?\\btimeout\\b)",
|
||||
"pattern": "^\\[Win32App DO\\] DO downloading is not finished within timeout",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Detects when a Delivery Optimization download times out (generic, uses current app context).",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-DOWNLOADING",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[StatusService\\] Downloading app \\(id = {GUID}.*?\\) via (?<tech>\\w+), bytes (?<bytes>\\w+)/(?<ofbytes>\\w+) for user",
|
||||
"pattern": "^\\[StatusService\\] Downloading app \\(id = {GUID}.*?\\) via (?<tech>\\w+), bytes (?<bytes>\\w+)/(?<ofbytes>\\w+) for user",
|
||||
"action": "updateStateDownloading",
|
||||
"description": "Captures app download progress including technology (DO/CDN), bytes downloaded, and total bytes.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ENFORCEMENT-SUCCESS-OLD",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
|
||||
"pattern": "^\\[Win32App\\] Setting enforcementState as\\W+Success\\b",
|
||||
"action": "updateStateInstalled",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Detects when enforcement state is set to Success for the current app (old log format).",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ENFORCEMENT-SUCCESS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-DOWNLOAD",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent. Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-ENFORCEMENT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Setting enforcementState as\\W+Error\\b",
|
||||
"pattern": "^\\[Win32App\\] Setting enforcementState as\\W+Error\\b",
|
||||
"action": "updateStateError",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Detects when enforcement state is set to Error for the current app (old log format).",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-NO-CONTENT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Didn't get content info from service",
|
||||
"pattern": "^\\[Win32App\\] Didn't get content info from service",
|
||||
"action": "updateStateError",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Detects when IME cannot retrieve content info from the Intune service for the current app.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-TIMEOUT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Installation is timeout",
|
||||
"pattern": "^\\[Win32App\\] Installation is timeout",
|
||||
"action": "updateStateError",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Detects when an app installation times out.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-UNMAPPED-EXIT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Admin did NOT set mapping for lpExitCode\\W+(?<exitCode>\\d+) of app\\W+{GUID}",
|
||||
"pattern": "^\\[Win32App\\] Admin did NOT set mapping for lpExitCode\\W+(?<exitCode>\\d+) of app\\W+{GUID}",
|
||||
"action": "updateStateError",
|
||||
"description": "Detects when an app installer returned an exit code that has no mapping configured in Intune.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ESP-APP-REGISTERED",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] In EspPhase: \\w+\\. App {GUID} has been registered.*?App name: (?<name>.*?)$",
|
||||
"pattern": "^\\[Win32App\\]\\[EspManager\\] In EspPhase: \\w+\\. App {GUID} has been registered.*?App name: (?<name>.*?)$",
|
||||
"action": "updateName",
|
||||
"description": "Captures the ESP tracked-app registration line ('App <guid> has been registered[ for user <id>]. App name: <name>', EspManager.cs) as an app-name source. Primary replacement for the retired IME-UPDATE-NAME-1/2/3 whose emitters were removed with IME's old V1/V2 processing pipeline; also documents exactly which apps IME registered for ESP tracking. Category 'always' so names resolve even before the first ApplicationPoller EspPhase line is seen.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ESP-PHASE",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] (?:In|The) EspPhase: (?<espPhase>\\w+)",
|
||||
"pattern": "^\\[Win32App\\] (?:In|The) EspPhase: (?<espPhase>\\w+)",
|
||||
"action": "espPhaseDetected",
|
||||
"description": "Detects ESP phase transitions reported by IME Win32App processing (e.g., DeviceSetup, AccountSetup).",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ESP-TRACK-STATUS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}",
|
||||
"pattern": "^\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}",
|
||||
"action": "espTrackStatus",
|
||||
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app. No trailing anchor: IME 1.97 (Dec 2025) appends ' with name: <appName>.' after the GUID (EspManager.cs), which broke the previous '\\.' suffix. 'from' can be the literal string 'null' on first tracking.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-EXITCODE-MAPPED-FAILED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] lpExitCode is defined as Failed$",
|
||||
"pattern": "^\\[Win32App\\] lpExitCode is defined as Failed$",
|
||||
"action": "updateStateError",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Marks the current app as failed when its exit code maps to Failed via the admin-defined return-code table ('[Win32App] lpExitCode is defined as <Failed|Success|SoftReboot|HardReboot|Retry>', Win32AppInstaller.cs). This is the NORMAL mapped-exit-code outcome; the previously matched 'Setting enforcementState as ... Error' line only fires on the rare no-mapping fallback path. The numeric exit code is captured separately by IME-EXITCODE, which precedes this line.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-EXITCODE",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] lpExitCode (?<exitCode>-?\\d+)$",
|
||||
"pattern": "^\\[Win32App\\] lpExitCode (?<exitCode>-?\\d+)$",
|
||||
"action": "captureExitCode",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-GRS-CLEARED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ExecutionActionHandler\\] Encountered early subgraph termination\\. Clearing GRS for app with id: {GUID} so it can be retried during the next check-in\\.",
|
||||
"pattern": "^\\[Win32App\\]\\[ExecutionActionHandler\\] Encountered early subgraph termination\\. Clearing GRS for app with id: {GUID} so it can be retried during the next check-in\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "IME aborted the app's subgraph before enforcement finished and cleared its GRS entry so the app retries on the next check-in (ExecutionActionHandler, AppWorkload.log). Maps to Postponed: the install did not complete this pass but is scheduled to run again - previously this looked like an unexplained gap. Wording verified in IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-GRS-SKIP",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID} to (?:install|uninstall) is in GRS\\. The app will not be enforced\\.",
|
||||
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID} to (?:install|uninstall) is in GRS\\. The app will not be enforced\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "GRS (Global Retry Schedule) skip: the app failed recently and IME will not re-enforce it this check-in ('App with id: <guid> to install is in GRS. The app will not be enforced.', EnforcementActionProcessor / MixedEnforcementActionProcessor). THE canonical reason an app silently does nothing for up to 24h - previously invisible. Lives in AppActionProcessor.log, which the agent tails since the 2026-08-18 release; wording verified in IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-HRESULT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] hResultFromWin32 (?<hresult>-?\\d+)$",
|
||||
"pattern": "^\\[Win32App\\] hResultFromWin32 (?<hresult>-?\\d+)$",
|
||||
"action": "captureHResult",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-IMPERSONATION",
|
||||
"category": "always",
|
||||
"pattern": "After impersonation: (?<user>.*?)$",
|
||||
"pattern": "^After impersonation: (?<user>.*?)$",
|
||||
"action": "imeImpersonation",
|
||||
"description": "Detects when IME impersonates a user context for app installation.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-INSTALL-HANDLER",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ExecutionActionHandler\\] Handler invoked with execution type: Install for policy with id: {GUID}",
|
||||
"pattern": "^\\[Win32App\\]\\[ExecutionActionHandler\\] Handler invoked with execution type: Install for policy with id: {GUID}",
|
||||
"action": "updateStateInstalling",
|
||||
"description": "Detects when the IME execution handler starts installing an app.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-LAUNCH-INSTALLER",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Launch Win32AppInstaller in \\w+? session",
|
||||
"pattern": "^\\[Win32App\\] Launch Win32AppInstaller in \\w+? session",
|
||||
"action": "updateStateInstalling",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Detects when the Win32 app installer process is launched in a user or system session.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NO-ACTION",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] No action required for app with id: {GUID}",
|
||||
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] No action required for app with id: {GUID}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when IME determines no action is required for an app (already in desired state).",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NOT-APPLICABLE-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ApplicabilityActionHandler\\] Applicability check for policy with id: {GUID} resulted in action status: Success and applicability state: NotApplicable",
|
||||
"pattern": "^\\[Win32App\\]\\[ApplicabilityActionHandler\\] Applicability check for policy with id: {GUID} resulted in action status: Success and applicability state: NotApplicable",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability check returns NotApplicable (requirements not met).",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NOT-APPLICABLE-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NOT-APPLICABLE-3",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NOT-APPLICABLE-4",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NOT-TARGETED-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, targeted intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
|
||||
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, targeted intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app with RequiredInstall intent is classified as NotApplicableOrNotTargeted (new log format).",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NOT-TARGETED-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, effective intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
|
||||
"pattern": "^\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID}, effective intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: NotApplicableOrNotTargeted with desired state: None\\.",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app with effective RequiredInstall intent is classified as NotApplicableOrNotTargeted.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-POLICIES",
|
||||
"category": "currentPhase",
|
||||
"pattern": "Get policies =\\s*(?<policies>.*)",
|
||||
"pattern": "^Get policies =\\s*(?<policies>.*)",
|
||||
"action": "policiesDiscovered",
|
||||
"description": "Captures the JSON policies list discovered by IME, used to build the app inventory.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-RESTART-STOP-PROCESSING",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[V3Processor\\] A restart is required to continue processing apps\\. Stop processing\\.",
|
||||
"pattern": "^\\[Win32App\\]\\[V3Processor\\] A restart is required to continue processing apps\\. Stop processing\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Detects IME aborting app processing because an app requires a restart ('A restart is required to continue processing apps. Stop processing.', SideCarV3AppProcessor.cs). Postpones the current app: remaining apps do nothing until after the reboot, which previously looked like an unexplained stall mid-ESP.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-SESSION-CHANGE",
|
||||
"category": "always",
|
||||
"pattern": "OnSessionChange:? (?<change>.*?)$",
|
||||
"pattern": "^OnSessionChange:? (?<change>.*?)$",
|
||||
"action": "imeSessionChange",
|
||||
"description": "Detects Windows session change events (logon, logoff, lock, unlock) reported by IME.",
|
||||
"enabled": true
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-SET-CURRENT-1",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-SET-CURRENT-2",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?\\bid\\W+{GUID}",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-SET-CURRENT-3",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ===Step=== Start to Present app {GUID}",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects the start of app presentation step in IME, setting the app as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-SET-CURRENT-4",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] SetCurrentDirectory:.*?\\\\{GUID}_\\d+",
|
||||
"pattern": "^\\[Win32App\\] SetCurrentDirectory:.*?\\\\{GUID}_\\d+",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when IME sets the working directory for an app's staging folder, setting it as current.",
|
||||
"enabled": true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-SHUTDOWN",
|
||||
"category": "currentPhase",
|
||||
"pattern": "EMS Agent received shutdown signal",
|
||||
"pattern": "^EMS Agent received shutdown signal",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Detects when the IME agent receives a shutdown signal, postponing the current app installation.",
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-SKIP-APPLICABILITY-NOTMET",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] (?=.*?\\bapplicability\\b)(?=.*?NotMet\\b).*?\\bapp\\W+{GUID}\\b",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-SKIP-DEPENDENCY-DETECT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] app\\W+{GUID}\\b.*?\\bis a dependency\\b.*?\\bDetect Only mode\\b.*?\\bfinished processing",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user