mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-12 20:51:56 +00:00
Compare commits
190 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6191831bd | |||
| ccea1dacfe | |||
| 33e614da5d | |||
| a1433141ce | |||
| a7e76f0584 | |||
| 3829b6ce92 | |||
| dabd0d5b7e | |||
| 68dab1e154 | |||
| a1590579f9 | |||
| a558ca5510 | |||
| e01e7abc2b | |||
| 512b145484 | |||
| 8ece30631a | |||
| c9078d00b6 | |||
| 1ff68dc0af | |||
| 7c8204357b | |||
| dfcf7ad8ba | |||
| 54d4c0bd4c | |||
| 11bc0ec2ab | |||
| 7e9b0cea54 | |||
| c115cb354e | |||
| 9cfe3fb74d | |||
| 892ff4c1e1 | |||
| 2cb181734a | |||
| 7055a6f0fc | |||
| 587de860dd | |||
| 409794c037 | |||
| 337b3b012a | |||
| c2ae5ddd83 | |||
| 60baf5317c | |||
| a5d5a37dc1 | |||
| d4b56c417d | |||
| a5bbdf9f80 | |||
| 389d4f27dc | |||
| ce5f22bd19 | |||
| 85fadc8ac4 | |||
| 7633c02a1c | |||
| bd66ad7097 | |||
| be13d8f5ef | |||
| 8296fa7291 | |||
| 4faca8692b | |||
| ba715d5aa7 | |||
| ed73dc3a87 | |||
| 1e88d1db4b | |||
| f3778fbad6 | |||
| 1a85a75af1 | |||
| 5f13c71f1d | |||
| 041803e55c | |||
| eff89fd09f | |||
| 0cc35d2bae | |||
| 27cb286118 | |||
| c88e5411a8 | |||
| 5eba39f658 | |||
| 5f95648c73 | |||
| e4d527b6d5 | |||
| 258d7cd5f4 | |||
| 668ae14657 | |||
| 16bd82b148 | |||
| 225d0feca9 | |||
| 6b06edb222 | |||
| 12b29c2e08 | |||
| 5a37721d24 | |||
| 884f8495b2 | |||
| cc674b26ad | |||
| 7139374681 | |||
| e6d92c6005 | |||
| 46880b58e7 | |||
| ee96ff2d23 | |||
| 4275c13dfc | |||
| 1616e33c14 | |||
| 0b39455d4e | |||
| 48baffe753 | |||
| f6f88161f9 | |||
| d0a525ecbd | |||
| 257f403949 | |||
| 7cbcb6bc4c | |||
| f43462567e | |||
| b5ac4651a0 | |||
| 3bc8b87a19 | |||
| 950b3cc679 | |||
| 0559919a9e | |||
| e8f8b876e3 | |||
| 83afd4f1c9 | |||
| d695c9b8c4 | |||
| 1ee7c48c92 | |||
| 98ac414208 | |||
| 5153e90e7c | |||
| 5b139f90e7 | |||
| 05d21cb240 | |||
| 5cc69a696a | |||
| fd3ce44c0c | |||
| 0b48b1ce63 | |||
| 5cb536c46e | |||
| 3f97a418d4 | |||
| cdd44d152a | |||
| d26d877989 | |||
| 59959c663e | |||
| ed2e3fd96c | |||
| d35edeaf9f | |||
| 21b802a028 | |||
| ae258cd9a7 | |||
| 640a4fec04 | |||
| 96d2e6610a | |||
| a510418623 | |||
| 0d43abf249 | |||
| a867fea5bf | |||
| 1a0147c942 | |||
| 4ba5a2af55 | |||
| 327b5b07d0 | |||
| 141b3ca737 | |||
| 9b9ec8586e | |||
| 877c50ce6c | |||
| dcefbda2bf | |||
| e10f1ffda9 | |||
| a4f9d89c02 | |||
| 943087a026 | |||
| 20bf50c935 | |||
| a82a82757a | |||
| 6676565d5f | |||
| b9083c93df | |||
| 2c79d61003 | |||
| bb8ac2181e | |||
| 684f66f560 | |||
| 86e98f39d5 | |||
| 0be30de253 | |||
| 942650890a | |||
| a8f2fb2368 | |||
| 924ca6c916 | |||
| 3f40531b33 | |||
| 7b336c8cf0 | |||
| 74a0bc13a3 | |||
| c0e91ca0f9 | |||
| ccbc754b0e | |||
| 15cd78d81a | |||
| d95da2eeab | |||
| 967d38c7cc | |||
| b47487617a | |||
| f2b86dfc73 | |||
| 0c7b533107 | |||
| 5eec2d7e60 | |||
| e9cb51205e | |||
| cc5dd7d6c2 | |||
| 11731db5e4 | |||
| 6866fe79af | |||
| 6e34fa517c | |||
| dc05428dae | |||
| cd0c750e80 | |||
| cede4be654 | |||
| 0d63057725 | |||
| 7dd151b714 | |||
| 778eb44a34 | |||
| 00f37d869a | |||
| d88a118903 | |||
| 0ed3258d4f | |||
| 3941c3b984 | |||
| 3d831d16d7 | |||
| 46b895a70d | |||
| 77fae059ed | |||
| fd309da51d | |||
| 9c4039b3c3 | |||
| b0fb8eb5fa | |||
| dd2432fa62 | |||
| d9b67e444a | |||
| 47b91f76e7 | |||
| 9c6b55a8bc | |||
| f00b475f59 | |||
| f8743dcae0 | |||
| bd94382edb | |||
| 8abd3a8848 | |||
| 43b3008266 | |||
| 2304436b8c | |||
| ad875d4021 | |||
| fd6b68231e | |||
| 88494a312f | |||
| 6901f6f682 | |||
| 82c0b20be1 | |||
| 4fa439cc3c | |||
| b631c63c53 | |||
| e9aaf3db53 | |||
| a37930e87a | |||
| 6bc5d47afe | |||
| 0ebbf87e5b | |||
| fec4bcc6c5 | |||
| 6ce2176aa0 | |||
| 1fe4e6711e | |||
| 61947c416c | |||
| 25c65f1837 | |||
| eb287248c6 | |||
| 738a349710 | |||
| 73525f1d34 |
+1
-1
@@ -30,7 +30,7 @@
|
||||
- Stage explicit file lists; never `git add -A` or a directory. Pushing is the user's call.
|
||||
- New API responses need a typed DTO plus the parity fact. MCP vocabularies are generated from the wire types, never retyped.
|
||||
- Every route belongs in the route policy catalog; every new event type goes into the MCP resource catalog.
|
||||
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
|
||||
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `.github/zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
|
||||
- The about, terms and privacy pages under `src/Web/autopilot-monitor-web/app/` state facts customers rely on: verify every claim against the code before writing it and update them in the same change as the fact they describe.
|
||||
|
||||
## Code Quality
|
||||
|
||||
+1
-1
Submodule .claude/commands updated: 0efa7c79ca...9fb9fe058b
@@ -24,6 +24,11 @@ updates:
|
||||
dotnet-security:
|
||||
applies-to: security-updates
|
||||
patterns: ['*']
|
||||
# Application Insights 3 is the OpenTelemetry rewrite (no telemetry processors, another sampling
|
||||
# model) and the Functions worker integration still builds on 2.x: a planned migration, not a bump.
|
||||
ignore:
|
||||
- dependency-name: 'Microsoft.ApplicationInsights.WorkerService'
|
||||
update-types: ['version-update:semver-major']
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/Web/autopilot-monitor-web
|
||||
@@ -98,6 +103,24 @@ updates:
|
||||
applies-to: security-updates
|
||||
patterns: ['*']
|
||||
|
||||
# SWA CLI for the deploy job of deploy-web.yml, which installs it from this lockfile next to
|
||||
# the production deploy token: a version reaches that job only through a reviewed bump here.
|
||||
# deploy-web watches this folder, so a merged bump redeploys and proves the new CLI at once.
|
||||
# Not under .github/: there Dependabot's update PR was refused as an unauthorized change.
|
||||
- package-ecosystem: npm
|
||||
directory: /tools/swa-cli
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
swa-cli:
|
||||
patterns: ['*']
|
||||
swa-cli-security:
|
||||
applies-to: security-updates
|
||||
patterns: ['*']
|
||||
|
||||
# Every action is referenced by commit SHA with the version in a trailing comment;
|
||||
# action-pins.yml fails any workflow change that reintroduces a mutable tag. Dependabot
|
||||
# is what keeps those pins current: it rewrites the SHA and the comment together, so the
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
name: Workflow gates
|
||||
|
||||
# Three checks over the workflow files and their helper scripts:
|
||||
# Four checks over the workflow files and their helper scripts:
|
||||
# * verify-pins -- every third-party action is referenced by a full commit SHA plus a
|
||||
# version comment (stricter than zizmor's unpinned-uses audit).
|
||||
# * zizmor -- static analysis of every workflow (permissions, credential
|
||||
# persistence, template injection, ...). Configuration and the
|
||||
# reasoned ignores live in zizmor.yml at the repository root; the
|
||||
# same run is expected locally before a workflow change is committed.
|
||||
# reasoned ignores live in .github/zizmor.yml, which zizmor finds on
|
||||
# its own; the same run is expected locally before a workflow change
|
||||
# is committed.
|
||||
# * hosts -- no workflow or .github/scripts helper spells out a host; every one
|
||||
# comes from .github/scripts/Get-Endpoints.ps1, CI's member of the URL
|
||||
# registry (the C#, web and MCP registries have guard tests of their own).
|
||||
# * packages -- no workflow or helper fetches a package at a version the repository
|
||||
# does not fix, in the forms zizmor's adhoc-packages audit does not see.
|
||||
#
|
||||
# Every third-party action must be referenced by a full commit SHA, never by a tag.
|
||||
# Tags are mutable: whoever controls an action repository can repoint v4 at new code,
|
||||
@@ -37,10 +40,10 @@ name: Workflow gates
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', '.github/zizmor.yml']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', '.github/zizmor.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -105,7 +108,7 @@ jobs:
|
||||
# the local install so the gate and the pre-commit run agree on the finding set.
|
||||
# Informational findings (template expansion of our own step outputs in pwsh) are
|
||||
# visible locally but do not fail the gate.
|
||||
- uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
- uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
|
||||
with:
|
||||
version: 1.30.1
|
||||
min-severity: low
|
||||
@@ -123,3 +126,51 @@ jobs:
|
||||
- name: No host literal outside .github/scripts/Get-Endpoints.ps1
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Assert-HostsFromRegistry.ps1
|
||||
|
||||
packages:
|
||||
name: Packages only at a version the repository fixes
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# npm packages come from a committed lockfile via npm ci; zizmor's adhoc-packages flags
|
||||
# `npm install <pkg>` (also yarn, pnpm, gem), but not the forms below, which also fetch at
|
||||
# run time:
|
||||
# * npx / npm exec without --no: in CI they install a missing package without asking.
|
||||
# The -- is load-bearing: without it npx hands the tool's own flags to npm.
|
||||
# * a .NET tool without an exact --version, a PowerShell module without -RequiredVersion.
|
||||
# Other package managers (pip, cargo, dlx runners) are covered by neither check.
|
||||
- name: No package fetched at a version the repository does not fix
|
||||
run: |
|
||||
set -uo pipefail
|
||||
failed=0
|
||||
fail() { echo "::error file=$1,line=$2::$3"; failed=1; }
|
||||
while IFS=: read -r file line text; do
|
||||
# YAML, shell and PowerShell comments alike start the line with #.
|
||||
[[ "$text" =~ ^[[:space:]]*# ]] && continue
|
||||
|
||||
if [[ "$text" =~ (^|[^[:alnum:]_./-])(npx|npm[[:space:]]+(exec|x))([[:space:]]|$) ]] &&
|
||||
! [[ "$text" =~ (npx|npm[[:space:]]+(exec|x))[[:space:]]+--no[[:space:]]+--([[:space:]]|$) ]]; then
|
||||
fail "$file" "$line" "Write 'npx --no -- <tool>' (or 'npm exec --no -- <tool>'): without --no a missing package is fetched from the registry, without -- the tool's flags go to npm."
|
||||
fi
|
||||
|
||||
if [[ "$text" =~ dotnet[[:space:]]+tool[[:space:]]+(install|update) ]] &&
|
||||
! [[ "$text" =~ --version[[:space:]]+[0-9]+\.[0-9]+\.[0-9]+([^0-9.*]|$) ]]; then
|
||||
fail "$file" "$line" "Give the .NET tool an exact --version X.Y.Z on the same line; a range or a missing version resolves at run time."
|
||||
fi
|
||||
|
||||
if [[ "$text" =~ Install-(Module|PSResource)[[:space:]] ]] &&
|
||||
! [[ "$text" =~ -RequiredVersion[[:space:]]+[^[:space:]] || "$text" =~ -Version[[:space:]]+[0-9]+\.[0-9]+\.[0-9]+([^0-9.*]|$) ]]; then
|
||||
fail "$file" "$line" "Give the PowerShell module an exact version on the same line, -RequiredVersion for Install-Module, -Version X.Y.Z for Install-PSResource; -MinimumVersion resolves at run time."
|
||||
fi
|
||||
# -H is load-bearing, as in verify-pins: with a single matching file grep omits the name.
|
||||
done < <(grep -nHE 'npx|npm[[:space:]]+(exec|x)([[:space:]]|$)|dotnet[[:space:]]+tool[[:space:]]+(install|update)|Install-(Module|PSResource)' \
|
||||
.github/workflows/*.yml .github/scripts/*.ps1)
|
||||
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "::error::Packages fetched at a version the repository does not fix -- fix them before this reaches main."
|
||||
exit 1
|
||||
fi
|
||||
echo "Every package the workflows and their helpers fetch has a version the repository fixes."
|
||||
|
||||
@@ -65,11 +65,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
# For all users: the SYSTEM pass below runs Pester as SYSTEM.
|
||||
- name: Install Pester 5+
|
||||
- name: Install Pester
|
||||
shell: powershell
|
||||
run: |
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers | Out-Null
|
||||
Install-Module Pester -Scope AllUsers -Force -SkipPublisherCheck -MinimumVersion 5.5.0
|
||||
Install-Module Pester -Scope AllUsers -Force -SkipPublisherCheck -RequiredVersion 6.2.0
|
||||
|
||||
# Windows PowerShell 5.1 on purpose: that is the runtime IME uses to execute
|
||||
# platform scripts, so guard/relax logic is verified without resetting a device.
|
||||
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
Get-ChildItem $outDir | ForEach-Object { Write-Host "$($_.Name): $([math]::Round($_.Length / 1MB, 2)) MB" }
|
||||
|
||||
- name: Upload unsigned build output
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: agent-unsigned
|
||||
path: ${{ runner.temp }}/agent-unsigned
|
||||
@@ -293,7 +293,7 @@ jobs:
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Download unsigned build output
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: agent-unsigned
|
||||
path: ${{ runner.temp }}/agent-unsigned
|
||||
@@ -790,13 +790,13 @@ jobs:
|
||||
staging/agent/AutopilotMonitor.SummaryDialog.exe
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }}
|
||||
path: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
|
||||
- name: Upload version metadata
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: version-metadata
|
||||
path: ${{ env.MANIFEST_NAME }}
|
||||
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
Write-Host "$env:RUNNER_SCRIPT : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])"
|
||||
|
||||
- name: Upload signed loader
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: bootstrap-loader-signed
|
||||
path: ${{ env.RUNNER_SCRIPT }}
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download signed loader
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: bootstrap-loader-signed
|
||||
path: ${{ runner.temp }}/loader
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
|
||||
|
||||
- name: Upload unsigned MSI
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download unsigned MSI
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ runner.temp }}/msi
|
||||
@@ -310,7 +310,7 @@ jobs:
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Download unsigned MSI
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ runner.temp }}/msi
|
||||
@@ -442,7 +442,7 @@ jobs:
|
||||
subject-path: ${{ env.STABLE_MSI }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: AutopilotMonitor-Bootstrap-v${{ steps.version.outputs.version }}
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
|
||||
+10
-10
@@ -24,7 +24,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -56,7 +56,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -145,10 +145,10 @@ jobs:
|
||||
# next-env.d.ts + .next type stubs are gitignored (Next-managed since
|
||||
# v16); typegen generates them so tsc sees the same types as a build.
|
||||
- name: Generate Next.js types
|
||||
run: npx next typegen
|
||||
run: npx --no -- next typegen
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
run: npx --no -- tsc --noEmit
|
||||
|
||||
# Errors fail outright, and so does ANY warning: the codebase is
|
||||
# warning-clean and must stay that way — fix new findings, never
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
# tsconfig.json covers sources, tests and scripts; the build emits via
|
||||
# tsconfig.build.json.
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
run: npx --no -- tsc --noEmit
|
||||
|
||||
# Same rule as the web job: any warning fails — fix new findings, never
|
||||
# raise the cap.
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -245,7 +245,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -276,7 +276,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -48,7 +48,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -123,6 +123,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
# `git push` needs.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
run: tar -czf "$RUNNER_TEMP/backend-publish.tgz" -C publish .
|
||||
|
||||
- name: Upload publish output
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: backend-publish
|
||||
path: ${{ runner.temp }}/backend-publish.tgz
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Download publish output
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: backend-publish
|
||||
path: ${{ runner.temp }}/backend-artifact
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
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.
|
||||
# Build here, upload with the SWA CLI from its own lockfile (tools/swa-cli). No
|
||||
# third-party action sees the deploy token, 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.
|
||||
#
|
||||
# Three jobs, each holding only what it needs: build runs all third-party code without any
|
||||
# credential, deploy holds the SWA token alone, record attests, verifies and writes the
|
||||
# manifest. A failed record job is re-run on its own; it writes only once the live site
|
||||
# serves this commit.
|
||||
# Three jobs, each holding only what it needs: build runs the third-party build code without
|
||||
# any credential, deploy holds the SWA token alone and runs nothing but the locked CLI, record
|
||||
# attests, verifies and writes the manifest. A failed record job is re-run on its own; it
|
||||
# writes only once the live site serves this commit.
|
||||
#
|
||||
# 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.
|
||||
@@ -19,6 +19,7 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'src/Web/autopilot-monitor-web/**'
|
||||
- 'tools/swa-cli/**'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -32,9 +33,6 @@ permissions:
|
||||
# repository variable.
|
||||
env:
|
||||
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 zone: no login, no id-token. The one secret here is the Application Insights
|
||||
@@ -74,7 +72,7 @@ jobs:
|
||||
id: docs
|
||||
run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -149,14 +147,15 @@ jobs:
|
||||
sha256sum "$archive"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ steps.package.outputs.archive }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Holds the SWA deployment token and nothing else -- deliberately no id-token: the SWA CLI
|
||||
# is third-party npm code whose dependencies resolve at run time.
|
||||
# Holds the SWA deployment token and nothing else -- deliberately no id-token. The token alone
|
||||
# can replace the portal, so the SWA CLI, the one third-party code here, comes from a committed
|
||||
# lockfile: a version published after the last reviewed bump never runs next to it.
|
||||
deploy:
|
||||
name: Deploy to Static Web App
|
||||
needs: build
|
||||
@@ -164,13 +163,21 @@ jobs:
|
||||
environment: production
|
||||
|
||||
steps:
|
||||
# For staticwebapp.config.json only.
|
||||
# For staticwebapp.config.json and the CLI's lockfile.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# npm ci, never npx or npm install: the lockfile pins every transitive package by hash.
|
||||
# No lifecycle scripts: keytar, the only package with one, serves the keychain, which
|
||||
# --no-use-keychain keeps unloaded. Before the artifact arrives, so nothing from the
|
||||
# build zone can touch the install.
|
||||
- name: Install the SWA CLI from its lockfile
|
||||
working-directory: tools/swa-cli
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/portal
|
||||
@@ -186,7 +193,7 @@ jobs:
|
||||
env:
|
||||
SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
npx --yes --package "@azure/static-web-apps-cli@${SWA_CLI_VERSION}" swa deploy \
|
||||
tools/swa-cli/node_modules/.bin/swa deploy \
|
||||
--app-location "$WEB_DIR" \
|
||||
--output-location out \
|
||||
--swa-config-location "$WEB_DIR" \
|
||||
@@ -215,7 +222,7 @@ jobs:
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/portal
|
||||
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
# when someone wants to look at the file.
|
||||
- name: Upload signed scripts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: signed-scripts
|
||||
path: ${{ runner.temp }}/signed-scripts
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
# The three submodules are private. "update = none" makes recursive clones skip them (the
|
||||
# public, Dependabot's NuGet updater); a clone with access sets
|
||||
# submodule.<name>.update = checkout once, or runs git submodule update --init --checkout.
|
||||
[submodule ".claude/commands"]
|
||||
path = .claude/commands
|
||||
url = https://github.com/okieselbach/Autopilot-Monitor-dotclaude.git
|
||||
update = none
|
||||
[submodule "docs"]
|
||||
path = internal
|
||||
url = https://github.com/okieselbach/Autopilot-Monitor-internal.git
|
||||
update = none
|
||||
[submodule "infra"]
|
||||
path = infra
|
||||
url = https://github.com/okieselbach/Autopilot-Monitor-infra.git
|
||||
update = none
|
||||
|
||||
+1
-1
Submodule internal updated: f7d94b395a...fea540d819
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ID-002",
|
||||
"title": "Unexpected Local Admin Accounts Detected",
|
||||
"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.",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts on the device, including accounts allowed only as standard users that hold local admin rights. 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.4.0",
|
||||
"version": "1.5.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -65,6 +65,18 @@
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "role_violations",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.role_violations",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -80,7 +92,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 — 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.",
|
||||
"explanation": "The agent detected unexpected local user accounts on this device: accounts the built-in or tenant-configured allowed list does not allow, or accounts it allows only as standard users that are members of the local Administrators group.\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 allowed by the 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), role_violations (accounts allowed only as standard users that are members of Administrators — Windows never puts Guest, kioskUser0 or WsiAccount there), account_details (per account: disabled state, Administrators membership and RID — 500 is the built-in Administrator), 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 under Settings → Agent Analyzers. Choose Standard user only for an account that must never be a local administrator.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -94,7 +106,8 @@
|
||||
"title": "Add expected accounts to the allowed list (false positive)",
|
||||
"steps": [
|
||||
"Go to Settings → Agent Analyzers section",
|
||||
"Under 'Allowed Admin Accounts', add the account names that are expected in your environment",
|
||||
"Under 'Allowed Local Accounts', add the account names that are expected in your environment",
|
||||
"Choose 'Standard user only' for an account that must not be a local administrator — it is still reported if it ever joins the Administrators group",
|
||||
"Save the settings — future enrollments will no longer flag these accounts"
|
||||
]
|
||||
},
|
||||
@@ -104,6 +117,7 @@
|
||||
"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 '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)",
|
||||
"Check 'role_violations' — a built-in Windows account or an account you allowed as standard user that sits in Administrators was put there on purpose",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
@@ -126,6 +140,11 @@
|
||||
"label": "In Administrators",
|
||||
"value": "{{unexpected_admin_members.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Allowed only as standard user",
|
||||
"value": "{{role_violations.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-SEC-004",
|
||||
"title": "AutoLogon enabled for an unexpected user",
|
||||
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.2.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "autologon_enabled",
|
||||
"source": "event_type",
|
||||
"eventType": "autologon_analysis",
|
||||
"dataField": "checks.autologon_enabled",
|
||||
"operator": "equals",
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "unexpected_autologon_user",
|
||||
"source": "event_data",
|
||||
"eventType": "autologon_analysis",
|
||||
"dataField": "checks.default_user_name",
|
||||
"operator": "not_in",
|
||||
"value": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 40,
|
||||
"explanation": "AutoLogon is enabled on this device for a user that is **not on your approved kiosk allow-list**. AutoLogon is legitimate for kiosk and shared-device scenarios, so this rule only warns when the configured default user differs from the account(s) you expect.\n\nThis rule is **off by default**. A normal user-driven Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (`AutoAdminLogon=1`, a `DefaultUserName`, an `AutoLogonSID`, and no plaintext password) — registry-indistinguishable from a kiosk AutoLogon — so AutoLogon-enabled alone is not graded. The only AutoLogon state graded by default is a plaintext `DefaultPassword` on disk (**ANALYZE-SEC-003**).\n\nTo use this rule: configure the allow-list with your approved kiosk account name(s) (comma-separated, case-insensitive exact match against the Winlogon `DefaultUserName`) and enable it. AutoLogon for any other user will then warn.\n\nNote: an AutoLogon with no explicit default user (e.g. ForceAutoLogon only) is treated as unexpected and will warn.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Confirm the AutoLogon user",
|
||||
"steps": [
|
||||
"Open the autologon_analysis event in the session timeline and check the default user/domain",
|
||||
"If the user is an approved kiosk account, add it to this rule's allow-list",
|
||||
"If the user is unexpected, treat it as a possible enrollment/OOBE manipulation and investigate what wrote the Winlogon keys"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Disable AutoLogon if not required",
|
||||
"steps": [
|
||||
"Set HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon\\AutoAdminLogon to 0",
|
||||
"Remove DefaultPassword (and any Sysinternals LSA secret) if present",
|
||||
"For kiosk scenarios, prefer Assigned Access / kiosk profiles over raw AutoLogon where possible"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft: Configure Windows to automatically sign in (AutoLogon)",
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "AutoLogon is enabled for an unexpected user: {{unexpected_autologon_user.value}}",
|
||||
"subject": {
|
||||
"kind": "account",
|
||||
"key": "{{unexpected_autologon_user.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "AutoLogon user",
|
||||
"value": "{{unexpected_autologon_user.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"autologon",
|
||||
"winlogon",
|
||||
"kiosk",
|
||||
"allow-list"
|
||||
],
|
||||
"templateVariables": [
|
||||
{
|
||||
"name": "allowed_autologon_users",
|
||||
"label": "Allowed AutoLogon users (kiosk accounts)",
|
||||
"description": "Comma-separated list of approved kiosk / shared-device account names that are expected to use AutoLogon (case-insensitive exact match against the Winlogon DefaultUserName, e.g. 'KioskUser' or 'Reception,LobbyPC'). AutoLogon for any other user raises a warning.",
|
||||
"conditionIndex": 1,
|
||||
"field": "value",
|
||||
"placeholder": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2"
|
||||
}
|
||||
]
|
||||
}
|
||||
+23
-97
@@ -4103,10 +4103,10 @@
|
||||
{
|
||||
"ruleId": "ANALYZE-ID-002",
|
||||
"title": "Unexpected Local Admin Accounts Detected",
|
||||
"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.",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts on the device, including accounts allowed only as standard users that hold local admin rights. 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.4.0",
|
||||
"version": "1.5.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -4166,6 +4166,18 @@
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "role_violations",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.role_violations",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -4181,7 +4193,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 — 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.",
|
||||
"explanation": "The agent detected unexpected local user accounts on this device: accounts the built-in or tenant-configured allowed list does not allow, or accounts it allows only as standard users that are members of the local Administrators group.\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 allowed by the 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), role_violations (accounts allowed only as standard users that are members of Administrators — Windows never puts Guest, kioskUser0 or WsiAccount there), account_details (per account: disabled state, Administrators membership and RID — 500 is the built-in Administrator), 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 under Settings → Agent Analyzers. Choose Standard user only for an account that must never be a local administrator.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -4195,7 +4207,8 @@
|
||||
"title": "Add expected accounts to the allowed list (false positive)",
|
||||
"steps": [
|
||||
"Go to Settings → Agent Analyzers section",
|
||||
"Under 'Allowed Admin Accounts', add the account names that are expected in your environment",
|
||||
"Under 'Allowed Local Accounts', add the account names that are expected in your environment",
|
||||
"Choose 'Standard user only' for an account that must not be a local administrator — it is still reported if it ever joins the Administrators group",
|
||||
"Save the settings — future enrollments will no longer flag these accounts"
|
||||
]
|
||||
},
|
||||
@@ -4205,6 +4218,7 @@
|
||||
"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 '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)",
|
||||
"Check 'role_violations' — a built-in Windows account or an account you allowed as standard user that sits in Administrators was put there on purpose",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
@@ -4227,6 +4241,11 @@
|
||||
"label": "In Administrators",
|
||||
"value": "{{unexpected_admin_members.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Allowed only as standard user",
|
||||
"value": "{{role_violations.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -4907,99 +4926,6 @@
|
||||
"plaintext-password"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-SEC-004",
|
||||
"title": "AutoLogon enabled for an unexpected user",
|
||||
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.2.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "autologon_enabled",
|
||||
"source": "event_type",
|
||||
"eventType": "autologon_analysis",
|
||||
"dataField": "checks.autologon_enabled",
|
||||
"operator": "equals",
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "unexpected_autologon_user",
|
||||
"source": "event_data",
|
||||
"eventType": "autologon_analysis",
|
||||
"dataField": "checks.default_user_name",
|
||||
"operator": "not_in",
|
||||
"value": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 40,
|
||||
"explanation": "AutoLogon is enabled on this device for a user that is **not on your approved kiosk allow-list**. AutoLogon is legitimate for kiosk and shared-device scenarios, so this rule only warns when the configured default user differs from the account(s) you expect.\n\nThis rule is **off by default**. A normal user-driven Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (`AutoAdminLogon=1`, a `DefaultUserName`, an `AutoLogonSID`, and no plaintext password) — registry-indistinguishable from a kiosk AutoLogon — so AutoLogon-enabled alone is not graded. The only AutoLogon state graded by default is a plaintext `DefaultPassword` on disk (**ANALYZE-SEC-003**).\n\nTo use this rule: configure the allow-list with your approved kiosk account name(s) (comma-separated, case-insensitive exact match against the Winlogon `DefaultUserName`) and enable it. AutoLogon for any other user will then warn.\n\nNote: an AutoLogon with no explicit default user (e.g. ForceAutoLogon only) is treated as unexpected and will warn.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Confirm the AutoLogon user",
|
||||
"steps": [
|
||||
"Open the autologon_analysis event in the session timeline and check the default user/domain",
|
||||
"If the user is an approved kiosk account, add it to this rule's allow-list",
|
||||
"If the user is unexpected, treat it as a possible enrollment/OOBE manipulation and investigate what wrote the Winlogon keys"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Disable AutoLogon if not required",
|
||||
"steps": [
|
||||
"Set HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon\\AutoAdminLogon to 0",
|
||||
"Remove DefaultPassword (and any Sysinternals LSA secret) if present",
|
||||
"For kiosk scenarios, prefer Assigned Access / kiosk profiles over raw AutoLogon where possible"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft: Configure Windows to automatically sign in (AutoLogon)",
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "AutoLogon is enabled for an unexpected user: {{unexpected_autologon_user.value}}",
|
||||
"subject": {
|
||||
"kind": "account",
|
||||
"key": "{{unexpected_autologon_user.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "AutoLogon user",
|
||||
"value": "{{unexpected_autologon_user.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"autologon",
|
||||
"winlogon",
|
||||
"kiosk",
|
||||
"allow-list"
|
||||
],
|
||||
"templateVariables": [
|
||||
{
|
||||
"name": "allowed_autologon_users",
|
||||
"label": "Allowed AutoLogon users (kiosk accounts)",
|
||||
"description": "Comma-separated list of approved kiosk / shared-device account names that are expected to use AutoLogon (case-insensitive exact match against the Winlogon DefaultUserName, e.g. 'KioskUser' or 'Reception,LobbyPC'). AutoLogon for any other user raises a warning.",
|
||||
"conditionIndex": 1,
|
||||
"field": "value",
|
||||
"placeholder": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-SEC-005",
|
||||
"title": "Unexpected Provisioning Package",
|
||||
|
||||
@@ -89,12 +89,14 @@
|
||||
"type": "array",
|
||||
"description": "Conditions evaluated against the event stream. All required conditions must match for the rule to fire.",
|
||||
"items": { "$ref": "#/$defs/ruleCondition" },
|
||||
"minItems": 1
|
||||
"minItems": 1,
|
||||
"maxItems": 20
|
||||
},
|
||||
"preconditions": {
|
||||
"type": "array",
|
||||
"description": "Optional device-fact gates evaluated BEFORE conditions. ALL preconditions must pass; if any fails the rule is silently skipped — no result, no UI card. Useful for filtering out hardware/OS profiles where the rule does not apply (e.g. virtual machines).",
|
||||
"items": { "$ref": "#/$defs/rulePrecondition" },
|
||||
"maxItems": 10,
|
||||
"default": []
|
||||
},
|
||||
"baseConfidence": {
|
||||
|
||||
+3
-3
@@ -18,9 +18,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -44,7 +44,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
IReadOnlyCollection<string>? whiteGloveSealingPatternIds = null,
|
||||
TimeSpan? drainInterval = null,
|
||||
TimeSpan? terminalDrainTimeout = null,
|
||||
TimeSpan? agentMaxLifetime = null) =>
|
||||
TimeSpan? agentMaxLifetime = null,
|
||||
TimeSpan? collectorHostStopTimeout = null) =>
|
||||
new EnrollmentOrchestrator(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
@@ -58,7 +59,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds,
|
||||
drainInterval: drainInterval ?? TimeSpan.FromDays(1),
|
||||
terminalDrainTimeout: terminalDrainTimeout ?? TimeSpan.FromSeconds(2),
|
||||
agentMaxLifetime: agentMaxLifetime);
|
||||
agentMaxLifetime: agentMaxLifetime,
|
||||
collectorHostStopTimeout: collectorHostStopTimeout);
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
}
|
||||
|
||||
@@ -125,5 +125,74 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
// No assertion needed beyond "did not throw"; the regression we guard against is
|
||||
// the original swallow-all behaviour, which the rotation refactor must preserve.
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AgentLogLevel.Info, "INFO")]
|
||||
[InlineData(AgentLogLevel.Debug, "INFO,DEBUG")]
|
||||
[InlineData(AgentLogLevel.Verbose, "INFO,DEBUG,VERBOSE")]
|
||||
[InlineData(AgentLogLevel.Trace, "INFO,DEBUG,VERBOSE,TRACE")]
|
||||
public void Each_level_adds_to_the_one_before_it(AgentLogLevel level, string expectedTags)
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, level);
|
||||
|
||||
logger.Info("line");
|
||||
logger.Debug("line");
|
||||
logger.Verbose("line");
|
||||
logger.Trace("line");
|
||||
|
||||
var tags = ReadLines(tmp.Path).Select(Tag);
|
||||
Assert.Equal(expectedTags, string.Join(",", tags));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatKey_sends_an_identical_repeat_to_trace_and_a_change_back_to_its_level()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Trace);
|
||||
|
||||
logger.Info("rule A ran", repeatKey: "rule:A");
|
||||
logger.Info("rule A ran", repeatKey: "rule:A");
|
||||
logger.Info("rule A ran again with new data", repeatKey: "rule:A");
|
||||
logger.Info("rule A ran", repeatKey: "rule:A");
|
||||
|
||||
Assert.Equal(new[] { "INFO", "TRACE", "INFO", "INFO" }, ReadLines(tmp.Path).Select(Tag).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatKey_keeps_subjects_apart()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Trace);
|
||||
|
||||
logger.Debug("scan failed: access denied", repeatKey: "scan:System");
|
||||
logger.Debug("scan failed: access denied", repeatKey: "scan:Application");
|
||||
logger.Debug("scan failed: access denied", repeatKey: "scan:System");
|
||||
|
||||
Assert.Equal(new[] { "DEBUG", "DEBUG", "TRACE" }, ReadLines(tmp.Path).Select(Tag).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatKey_does_not_remember_a_line_its_level_did_not_write()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
logger.Debug("waiting for the policy", repeatKey: "hello.poll");
|
||||
logger.SetLogLevel(AgentLogLevel.Debug);
|
||||
logger.Debug("waiting for the policy", repeatKey: "hello.poll");
|
||||
|
||||
Assert.Equal(new[] { "DEBUG" }, ReadLines(tmp.Path).Select(Tag).ToArray());
|
||||
}
|
||||
|
||||
private static string[] ReadLines(string dir) =>
|
||||
File.ReadAllLines(Path.Combine(dir, BaseName + ".log"));
|
||||
|
||||
// "[2026-10-30 12:00:00.000] [INFO] message" -> "INFO"
|
||||
private static string Tag(string line)
|
||||
{
|
||||
var open = line.IndexOf("] [", StringComparison.Ordinal) + 3;
|
||||
return line.Substring(open, line.IndexOf(']', open) - open);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -135,6 +135,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
Assert.Equal("CONTOSO", checks["default_domain_name"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPayload_reports_a_sign_in_address_as_its_domain_only()
|
||||
{
|
||||
var snap = new AutoLogonSnapshot
|
||||
{
|
||||
WinlogonKeyPresent = true,
|
||||
AutoAdminLogon = "1",
|
||||
DefaultUserName = "luke.skywalker@example.net",
|
||||
AltDefaultUserName = @"AzureAD\luke.skywalker@example.net",
|
||||
};
|
||||
var data = AutoLogonAnalyzer.BuildPayload(snap, "shutdown");
|
||||
|
||||
var checks = Checks(data);
|
||||
Assert.Equal("[redacted]@example.net", checks["default_user_name"]);
|
||||
Assert.Equal("[redacted]@example.net", checks["alt_default_user_name"]);
|
||||
Assert.DoesNotContain(checks.Values.OfType<string>(), v => v.Contains("skywalker"));
|
||||
// The reduction is presentation only — the identity is still reported as present.
|
||||
Assert.True((bool)checks["default_identity_present"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("kioskuser", "kioskuser")]
|
||||
[InlineData("defaultuser0", "defaultuser0")]
|
||||
[InlineData("foouser@example.net", "foouser@example.net")]
|
||||
[InlineData("autopilot@example.net", "autopilot@example.net")]
|
||||
[InlineData("luke.skywalker@example.net", "[redacted]@example.net")]
|
||||
[InlineData("luke@skywalker@example.net", "[redacted]@example.net")]
|
||||
[InlineData("luke.skywalker@", "[redacted]@unknown")]
|
||||
[InlineData("", "")]
|
||||
[InlineData(null, "")]
|
||||
public void ReduceUserName_keeps_local_accounts_and_placeholders_readable(string? raw, string expected)
|
||||
{
|
||||
Assert.Equal(expected, AutoLogonAnalyzer.ReduceUserName(raw));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1", true)]
|
||||
[InlineData("true", true)]
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>tests/fixtures/local-admin-allow-list/cases.json</c> run against the netstandard2.0 build of
|
||||
/// <see cref="LocalAdminAllowList"/> — the one the agent ships. The backend runs the same file against
|
||||
/// its net10.0 build, the portal against <c>allowedAccounts.ts</c>.
|
||||
/// </summary>
|
||||
public sealed class LocalAdminAllowListFixtureTests
|
||||
{
|
||||
private static IEnumerable<JToken> ParseCases()
|
||||
{
|
||||
var path = Path.Combine(RepoPaths.Fixtures("local-admin-allow-list"), "cases.json");
|
||||
return JObject.Parse(File.ReadAllText(path))["parse"]!;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> ParseEntries() => ParseCases().Select(c => new object[] { c.Value<string>("entry")! });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParseEntries))]
|
||||
public void Agent_parses_the_entry_as_the_shared_case_says(string entry)
|
||||
{
|
||||
var c = ParseCases().First(x => x.Value<string>("entry") == entry);
|
||||
|
||||
var parsed = LocalAdminAllowList.TryParseEntry(entry, out var pattern, out var role);
|
||||
|
||||
if (c.Value<bool?>("ignored") == true)
|
||||
{
|
||||
Assert.False(parsed);
|
||||
return;
|
||||
}
|
||||
Assert.True(parsed);
|
||||
Assert.Equal(c.Value<string>("pattern"), pattern);
|
||||
Assert.Equal(c.Value<string>("role") == "user" ? LocalAccountRole.StandardUser : LocalAccountRole.Any, role);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-21
@@ -18,10 +18,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
private const string Machine = "DESKTOP-TEST01";
|
||||
private const string MachineSidPrefix = "S-1-5-21-111-222-333-";
|
||||
|
||||
private static readonly List<string> Allowed = new List<string>
|
||||
{
|
||||
"Administrator", "Guest", "DefaultAccount", "WDAGUtilityAccount", "defaultuser0", "defaultuser1", "adm-*"
|
||||
};
|
||||
// The real built-in catalog plus one tenant glob.
|
||||
private static readonly List<LocalAdminAnalyzer.AllowedEntry> Allowed =
|
||||
LocalAdminAnalyzer.BuildAllowedEntries(new[] { "adm-*" }, new List<string>());
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, bool disabled, int rid) =>
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid };
|
||||
@@ -126,7 +125,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
[Fact]
|
||||
public void Local_admin_member_missing_from_the_wmi_inventory_is_still_flagged()
|
||||
{
|
||||
// WMI failed or did not list the account — the SAM group membership alone is enough.
|
||||
// WMI failed or did not list the account — the SAM group membership alone is enough,
|
||||
// and the account counts as unexpected too, like any unknown administrator (score 80).
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
LocalMember("Administrator", 500),
|
||||
@@ -136,8 +136,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
var result = LocalAdminAnalyzer.EvaluateAccounts(
|
||||
new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, true, Machine, Allowed);
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -191,26 +192,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Well_known_rid_does_not_cover_ordinary_accounts_or_foreign_sids()
|
||||
public void Machine_account_rid_is_read_only_from_local_machine_sids()
|
||||
{
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "500"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "501"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "503"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "504"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "502"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1500"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1001"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-12-1-1-2-3-500"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-5-32-544"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(null));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(""));
|
||||
Assert.Equal(500, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "500"));
|
||||
Assert.Equal(501, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "501"));
|
||||
Assert.Equal(1001, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "1001"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid("S-1-12-1-1-2-3-500"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid("S-1-5-32-544"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "x1"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(null!));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_named_like_a_builtin_but_with_an_ordinary_rid_is_still_matched_by_name_only()
|
||||
public void Localized_builtin_name_on_an_ordinary_rid_is_not_allowed()
|
||||
{
|
||||
// Name-based allowance is unchanged: the RID match only ADDS coverage.
|
||||
// Only the RID makes a built-in: an ordinary account merely named like a localized
|
||||
// built-in is no entry at all.
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
Account("Gast", disabled: false, rid: 1001)
|
||||
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// Roles on the allowed list: <c>name|user</c> and the standard-user built-ins (Guest,
|
||||
/// DefaultAccount, WDAGUtilityAccount by RID; kioskUser0, WsiAccount and other accounts carrying a
|
||||
/// built-in name by name) allow an account only while it is not an Administrators member. A
|
||||
/// restriction always wins; the four built-in accounts are decided by RID alone; profile folders
|
||||
/// have no role; a login allows a profile folder, never an account. All names are synthetic.
|
||||
/// </summary>
|
||||
public sealed class LocalAdminAnalyzerRoleTests
|
||||
{
|
||||
private const string Machine = "DESKTOP-TEST01";
|
||||
private const string MachineSidPrefix = "S-1-5-21-111-222-333-";
|
||||
|
||||
private static List<LocalAdminAnalyzer.AllowedEntry> Entries(params string[] tenantEntries)
|
||||
=> LocalAdminAnalyzer.BuildAllowedEntries(tenantEntries, new List<string>());
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, int rid, bool disabled = false) =>
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid };
|
||||
|
||||
private static LocalGroupMember Member(string name, int rid) =>
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\" + name, Sid = MachineSidPrefix + rid, SidUsage = 1 };
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountCheckResult Evaluate(
|
||||
IList<LocalAdminAnalyzer.LocalAccountInfo> accounts, IList<LocalGroupMember> admins, List<LocalAdminAnalyzer.AllowedEntry> entries,
|
||||
bool enumerated = true)
|
||||
=> LocalAdminAnalyzer.EvaluateAccounts(accounts, admins, enumerated, Machine, entries);
|
||||
|
||||
// -------------------------------------------------------------- WsiAccount (the field case)
|
||||
|
||||
[Fact]
|
||||
public void WsiAccount_as_standard_user_with_its_profile_folder_scores_zero()
|
||||
{
|
||||
// The field case: web sign-in enabled the account for the sign-in, it holds no admin
|
||||
// membership and has C:\Users\WsiAccount — before roles existed this scored 80 (Error).
|
||||
var accounts = new[]
|
||||
{
|
||||
Account("Administrator", 500, disabled: true),
|
||||
Account("DefaultAccount", 503, disabled: true),
|
||||
Account("defaultuser0", 1000, disabled: true),
|
||||
Account("Gast", 501, disabled: true),
|
||||
Account("WDAGUtilityAccount", 504, disabled: true),
|
||||
Account("WsiAccount", 1001, disabled: true),
|
||||
Account("ADM-0A1B2C3D", 1002),
|
||||
};
|
||||
var admins = new List<LocalGroupMember>
|
||||
{
|
||||
Member("Administrator", 500),
|
||||
new LocalGroupMember { DomainAndName = "S-1-12-1-11-22-33-44", Sid = "S-1-12-1-11-22-33-44", SidUsage = 8 },
|
||||
Member("ADM-0A1B2C3D", 1002),
|
||||
};
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries("ADM-*"));
|
||||
var score = LocalAdminAnalyzer.ComputeConfidenceScore(
|
||||
bypassNro: false, result.Unexpected, result.UnexpectedAdminMembers, new List<string> { "jane.doe" });
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Empty(result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.Equal(0, score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WsiAccount_in_administrators_is_a_role_violation_even_while_disabled()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("WsiAccount", 1001, disabled: true) },
|
||||
new List<LocalGroupMember> { Member("WsiAccount", 1001) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.RoleViolations);
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(
|
||||
false, result.Unexpected, result.UnexpectedAdminMembers, new List<string>()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("WsiAccount")]
|
||||
[InlineData("wsiaccount")]
|
||||
[InlineData("Wsi*")]
|
||||
[InlineData("*")]
|
||||
public void Tenant_entries_cannot_lift_a_builtin_restriction(string tenantEntry)
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("WsiAccount", 1001) },
|
||||
new List<LocalGroupMember> { Member("WsiAccount", 1001) },
|
||||
Entries(tenantEntry));
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KioskUser0_in_administrators_is_a_role_violation()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("kioskUser0", 1003) },
|
||||
new List<LocalGroupMember> { Member("kioskUser0", 1003) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "kioskUser0" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- built-ins by RID
|
||||
|
||||
[Theory]
|
||||
[InlineData("Guest", 501)]
|
||||
[InlineData("Gast", 501)]
|
||||
[InlineData("Invité", 501)]
|
||||
[InlineData("DefaultAccount", 503)]
|
||||
[InlineData("WDAGUtilityAccount", 504)]
|
||||
public void Builtin_accounts_that_are_never_administrators_are_flagged_as_one(string name, int rid)
|
||||
{
|
||||
// A tenant entry naming the localized account (or even "*") cannot lift it either.
|
||||
var result = Evaluate(
|
||||
new[] { Account(name, rid, disabled: true) },
|
||||
new List<LocalGroupMember> { Member(name, rid) },
|
||||
Entries(name, "*"));
|
||||
|
||||
Assert.Equal(new[] { name }, result.Unexpected);
|
||||
Assert.Equal(new[] { name }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { name }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Administrator")]
|
||||
[InlineData("Administrateur")]
|
||||
[InlineData("Renamed_Admin")]
|
||||
public void The_builtin_administrator_is_allowed_by_rid_under_any_name(string name)
|
||||
{
|
||||
// "Admin*|user" must not turn the real Administrator into a finding: name entries never
|
||||
// decide the RID accounts — and the built-in name entry "Administrator|user" neither.
|
||||
var result = Evaluate(
|
||||
new[] { Account(name, 500) },
|
||||
new List<LocalGroupMember> { Member(name, 500) },
|
||||
Entries("Admin*|user", "Renamed*|user"));
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.True(result.BuiltInAdministratorEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ordinary_account_named_administrator_is_allowed_only_as_a_decoy()
|
||||
{
|
||||
// French device: the RID-500 account is "Administrateur", so "Administrator" is free.
|
||||
var accounts = new[]
|
||||
{
|
||||
Account("Administrateur", 500, disabled: true),
|
||||
Account("Administrator", 1004),
|
||||
};
|
||||
|
||||
var decoy = Evaluate(accounts, new List<LocalGroupMember> { Member("Administrateur", 500) }, Entries());
|
||||
Assert.Empty(decoy.Unexpected);
|
||||
|
||||
var impostor = Evaluate(accounts,
|
||||
new List<LocalGroupMember> { Member("Administrateur", 500), Member("Administrator", 1004) }, Entries());
|
||||
Assert.Equal(new[] { "Administrator" }, impostor.RoleViolations);
|
||||
Assert.Equal(new[] { "Administrator" }, impostor.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_oobe_account_may_be_an_administrator()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("defaultuser0", 1000) },
|
||||
new List<LocalGroupMember> { Member("defaultuser0", 1000) },
|
||||
Entries());
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_sid_lets_the_english_builtin_name_stand_in_for_the_rid()
|
||||
{
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = "Administrator", Disabled = false, Sid = null! },
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = "Guest", Disabled = false, Sid = null! },
|
||||
};
|
||||
var admins = new List<LocalGroupMember>
|
||||
{
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\Administrator", Sid = null!, SidUsage = 1 },
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\Guest", Sid = null!, SidUsage = 1 },
|
||||
};
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries());
|
||||
|
||||
Assert.Equal(new[] { "Guest" }, result.RoleViolations);
|
||||
Assert.DoesNotContain("Administrator", result.Unexpected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- tenant roles
|
||||
|
||||
[Fact]
|
||||
public void A_standard_user_entry_allows_the_account_until_it_is_an_administrator()
|
||||
{
|
||||
var accounts = new[] { Account("frontdesk", 1005) };
|
||||
|
||||
Assert.Empty(Evaluate(accounts, new List<LocalGroupMember>(), Entries("frontdesk|user")).Unexpected);
|
||||
|
||||
var admin = Evaluate(accounts, new List<LocalGroupMember> { Member("frontdesk", 1005) }, Entries("frontdesk|user"));
|
||||
Assert.Equal(new[] { "frontdesk" }, admin.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_standard_user_glob_restricts_each_matching_account()
|
||||
{
|
||||
var accounts = new[] { Account("svc-print", 1006), Account("svc-scan", 1007) };
|
||||
|
||||
var result = Evaluate(accounts, new List<LocalGroupMember> { Member("svc-scan", 1007) }, Entries("svc-*|user"));
|
||||
|
||||
Assert.Equal(new[] { "svc-scan" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "svc-scan" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_restriction_wins_over_a_broader_entry_that_allows_more()
|
||||
{
|
||||
var accounts = new[] { Account("adm-kiosk", 1008), Account("adm-4711", 1009) };
|
||||
var admins = new List<LocalGroupMember> { Member("adm-kiosk", 1008), Member("adm-4711", 1009) };
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries("adm-*", "adm-kiosk|user"));
|
||||
|
||||
Assert.Equal(new[] { "adm-kiosk" }, result.RoleViolations);
|
||||
Assert.DoesNotContain("adm-4711", result.Unexpected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Membership_unknown_lets_a_standard_user_entry_act_like_a_plain_one()
|
||||
{
|
||||
// Enumeration failed: nobody is known to be an administrator, so nothing is a violation.
|
||||
var result = Evaluate(new[] { Account("WsiAccount", 1001) }, new List<LocalGroupMember>(), Entries(), enumerated: false);
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.False(result.AdministratorsGroupEnumerated);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- folders are no accounts
|
||||
|
||||
[Fact]
|
||||
public void An_account_named_like_a_profile_folder_is_unexpected_while_the_folder_is_allowed()
|
||||
{
|
||||
var entries = Entries();
|
||||
|
||||
var result = Evaluate(new[] { Account("Public", 1010) }, new List<LocalGroupMember> { Member("Public", 1010) }, entries);
|
||||
|
||||
Assert.Equal(new[] { "Public" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "Public" }, result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.Equal(LocalAdminAnalyzer.AccountDecision.Unlisted,
|
||||
LocalAdminAnalyzer.Decide("Public", MachineSidPrefix + "1010", isAdmin: false, entries));
|
||||
Assert.Contains(entries, e => e.Pattern == "Public" && e.ProfileFolderOnly);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- accounts WMI did not return
|
||||
|
||||
[Fact]
|
||||
public void Administrators_members_missing_from_wmi_are_judged_as_administrators()
|
||||
{
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
Member("Administrator", 500),
|
||||
Member("WsiAccount", 1001),
|
||||
Member("Gast", 501),
|
||||
Member("adm-4711", 1009),
|
||||
};
|
||||
|
||||
var result = Evaluate(new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, Entries("adm-*"));
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.RoleViolations);
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_member_wmi_already_returned_is_not_judged_twice()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("backdoor", 1011) },
|
||||
new List<LocalGroupMember> { Member("backdoor", 1011) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_login_never_allows_an_account_not_even_when_wmi_failed()
|
||||
{
|
||||
// WMI failed: the split cannot recognise "backdoor" as local and allows it dynamically.
|
||||
// The dynamic entry is folder-only, so the signed-in backdoor administrator is still found.
|
||||
var split = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "backdoor" }, null!);
|
||||
var entries = Entries();
|
||||
entries.AddRange(split.Allowed.Select(u => new LocalAdminAnalyzer.AllowedEntry(u, LocalAccountRole.Any, profileFolderOnly: true)));
|
||||
|
||||
var result = Evaluate(new List<LocalAdminAnalyzer.LocalAccountInfo>(), new List<LocalGroupMember> { Member("backdoor", 1011) }, entries);
|
||||
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- the entry list
|
||||
|
||||
[Fact]
|
||||
public void Entries_carry_the_builtins_and_skip_invalid_tenant_entries()
|
||||
{
|
||||
var ignored = new List<string>();
|
||||
var entries = LocalAdminAnalyzer.BuildAllowedEntries(
|
||||
new[] { "kiosk|user", "x|admin", "|user", "legacy*", "CONTOSO\\admin", " ", "Guest|user" }, ignored);
|
||||
|
||||
Assert.Equal(new[] { "x|admin", "|user" }, ignored);
|
||||
var accounts = entries.Where(e => !e.ProfileFolderOnly).Select(e => e.Display).ToList();
|
||||
Assert.Contains("WsiAccount|user", accounts);
|
||||
Assert.Contains("kioskUser0|user", accounts);
|
||||
Assert.Contains("defaultuser0", accounts);
|
||||
Assert.Contains("kiosk|user", accounts);
|
||||
Assert.Contains("legacy*", accounts);
|
||||
Assert.Contains("CONTOSO\\admin", accounts);
|
||||
Assert.Single(accounts, a => a == "Guest|user");
|
||||
Assert.DoesNotContain("Public", accounts);
|
||||
Assert.Contains(entries, e => e.Pattern == "All Users" && e.ProfileFolderOnly);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- scoring
|
||||
|
||||
[Fact]
|
||||
public void Score_follows_the_signals()
|
||||
{
|
||||
var none = new List<string>();
|
||||
var backdoor = new List<string> { "backdoor" };
|
||||
|
||||
Assert.Equal(0, LocalAdminAnalyzer.ComputeConfidenceScore(false, none, none, backdoor));
|
||||
Assert.Equal(20, LocalAdminAnalyzer.ComputeConfidenceScore(true, none, none, none));
|
||||
Assert.Equal(40, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, none, none));
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, backdoor, none));
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, none, new List<string> { "BACKDOOR" }));
|
||||
Assert.Equal(100, LocalAdminAnalyzer.ComputeConfidenceScore(true, backdoor, backdoor, backdoor));
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-1
@@ -45,7 +45,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.DeviceInfo
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var notJoined = new Dictionary<string, object> { { "joinType", "Not Joined" } };
|
||||
var joined = new Dictionary<string, object> { { "joinType", "Azure AD Joined" }, { "userEmail", "alice@contoso.com" } };
|
||||
var joined = DeviceInfoCollector.BuildAadJoinedData("T1", "alice@contoso.com", "AABB", isPlaceholderUser: false);
|
||||
|
||||
Assert.True(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, notJoined));
|
||||
Assert.False(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, notJoined));
|
||||
@@ -56,6 +56,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.DeviceInfo
|
||||
Assert.False(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, joined));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Placeholder_replaced_by_a_user_of_the_same_domain_reemits()
|
||||
{
|
||||
// Pre-provisioning hands the device from the placeholder account to the real user
|
||||
// in the same domain — the two payloads must still differ for the gate.
|
||||
using var rig = new Rig();
|
||||
var placeholder = DeviceInfoCollector.BuildAadJoinedData("T1", "foouser@contoso.com", "AABB", isPlaceholderUser: true);
|
||||
var realUser = DeviceInfoCollector.BuildAadJoinedData("T1", "alice@contoso.com", "AABB", isPlaceholderUser: false);
|
||||
|
||||
Assert.True(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, placeholder));
|
||||
Assert.True(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, realUser));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Boot_time_and_wifi_signal_are_exempt_from_the_gate()
|
||||
{
|
||||
@@ -114,6 +127,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.DeviceInfo
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Payload of <c>aad_join_status</c> for a joined device.</summary>
|
||||
public sealed class AadJoinStatusPayloadTests
|
||||
{
|
||||
[Fact]
|
||||
public void Joined_payload_carries_the_domain_of_the_signed_in_address_only()
|
||||
{
|
||||
var data = DeviceInfoCollector.BuildAadJoinedData("T1", "alice@contoso.com", "AABB", isPlaceholderUser: false);
|
||||
|
||||
Assert.Equal("Azure AD Joined", data["joinType"]);
|
||||
Assert.Equal("T1", data["tenantId"]);
|
||||
Assert.Equal("AABB", data["thumbprint"]);
|
||||
Assert.Equal("contoso.com", data["userDomain"]);
|
||||
Assert.False(data.ContainsKey("isFooUser"));
|
||||
Assert.DoesNotContain(data.Values, v => v is string s && s.Contains("alice"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Joined_payload_flags_the_placeholder_and_omits_the_domain_without_an_address()
|
||||
{
|
||||
var placeholder = DeviceInfoCollector.BuildAadJoinedData("T1", "foouser@contoso.com", "AABB", isPlaceholderUser: true);
|
||||
Assert.Equal(true, placeholder["isFooUser"]);
|
||||
Assert.Equal("contoso.com", placeholder["userDomain"]);
|
||||
|
||||
var deviceOnly = DeviceInfoCollector.BuildAadJoinedData("T1", null!, "AABB", isPlaceholderUser: false);
|
||||
Assert.False(deviceOnly.ContainsKey("userDomain"));
|
||||
Assert.False(deviceOnly.ContainsKey("isFooUser"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Inventory fingerprint used by the SoftwareInventoryAnalyzer's restart dedup.</summary>
|
||||
public sealed class SoftwareInventoryFingerprintTests
|
||||
{
|
||||
|
||||
+31
@@ -164,5 +164,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
var info = f.InfoEvent("hello_pin_status");
|
||||
Assert.Equal("false", info.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_throwing_HelloCompleted_handler_is_logged_and_the_completion_stands()
|
||||
{
|
||||
// The handler carries the Hello resolution to the decision engine; a fault there used
|
||||
// to vanish without a trace.
|
||||
using var f = new Fixture();
|
||||
f.Tracker.HelloCompleted += (_, _) => throw new InvalidOperationException("handler bug");
|
||||
|
||||
f.Tracker.ProcessHelloEvent(300, Fixed, "Microsoft-Windows-User Device Registration", isBackfill: false);
|
||||
|
||||
Assert.True(f.Tracker.IsHelloCompleted);
|
||||
f.InfoEvent("hello_provisioning_completed");
|
||||
Assert.Contains("[WARN] HelloTracker: HelloCompleted handler threw: handler bug", ReadLog(f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_repeated_terminal_event_logs_COMPLETED_once()
|
||||
{
|
||||
// Live plus backfill deliver event 300 twice; only the first one completes Hello.
|
||||
using var f = new Fixture();
|
||||
|
||||
f.Tracker.ProcessHelloEvent(300, Fixed, "Microsoft-Windows-User Device Registration", isBackfill: false);
|
||||
f.Tracker.ProcessHelloEvent(300, Fixed, "Microsoft-Windows-User Device Registration", isBackfill: true);
|
||||
|
||||
var log = ReadLog(f);
|
||||
Assert.Single(log.Split('\n'), l => l.Contains("Windows Hello provisioning COMPLETED successfully"));
|
||||
}
|
||||
|
||||
private static string ReadLog(Fixture f) =>
|
||||
System.IO.File.ReadAllText(System.IO.Path.Combine(f.Tmp.Path, "agent.log"));
|
||||
}
|
||||
}
|
||||
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// IME rolls a log with File.Move at 3 MB. The tracker used to take the archive for a new file and
|
||||
/// read it from 0: every app line played again, finished apps fell back to Installing and the
|
||||
/// subgraph line of an older app marked the running app as skipped (session 190fed00).
|
||||
/// </summary>
|
||||
public sealed partial class ImeLogTrackerOverwriteRewindTests
|
||||
{
|
||||
private const string FirstStamp = "20260910-193000";
|
||||
private const string SecondStamp = "20260910-193500";
|
||||
|
||||
private static string Folder(Harness h) => Path.GetDirectoryName(h.LogPath)!;
|
||||
|
||||
private static string ArchivePath(Harness h, string stamp = FirstStamp)
|
||||
=> Path.Combine(Folder(h), $"{Path.GetFileNameWithoutExtension(h.LogName)}-{stamp}.log");
|
||||
|
||||
private static string Roll(Harness h, string stamp = FirstStamp)
|
||||
{
|
||||
var archive = ArchivePath(h, stamp);
|
||||
File.Move(h.LogPath, archive);
|
||||
return archive;
|
||||
}
|
||||
|
||||
private static List<ImeLogPattern> AppPatterns() => new List<ImeLogPattern>
|
||||
{
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-POLICIES", Category = "always", Enabled = true,
|
||||
Pattern = @"^policies discovered = (?<policies>\[.*\])", Action = "policiesDiscovered",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-SUBGRAPH", Category = "always", Enabled = true,
|
||||
Pattern = @"^subgraph (?<id>[\w-]+)", Action = "cancelStuckAndSetCurrent",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-INSTALLING", Category = "always", Enabled = true,
|
||||
Pattern = @"^installing (?<id>[\w-]+)", Action = "updateStateInstalling",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-INSTALLED", Category = "always", Enabled = true,
|
||||
Pattern = @"^installed (?<id>[\w-]+)", Action = "updateStateInstalled",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
};
|
||||
|
||||
private const string AppPoliciesLine = @"policies discovered = [{""Id"":""app-1"",""Name"":""App One""},{""Id"":""app-2"",""Name"":""App Two""}]";
|
||||
|
||||
private static string OneFinishedOneRunning() =>
|
||||
Entry(AppPoliciesLine) + Entry("subgraph app-1") + Entry("installing app-1") + Entry("installed app-1")
|
||||
+ Entry("subgraph app-2") + Entry("installing app-2");
|
||||
|
||||
[Theory]
|
||||
[InlineData("AppWorkload.log")]
|
||||
[InlineData("AppActionProcessor.log")]
|
||||
[InlineData("HealthScripts.log")]
|
||||
[InlineData("AgentExecutor.log")]
|
||||
[InlineData("IntuneManagementExtension.log")]
|
||||
public async Task Rolled_over_log_is_not_read_again(string logName)
|
||||
{
|
||||
using var h = new Harness(logName, MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
|
||||
Roll(h);
|
||||
h.Append(Entry("marker 4"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
|
||||
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddSeconds(2);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
|
||||
Assert.Equal(0, h.Health.OverwriteRewinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bytes_written_after_the_last_read_come_from_the_archive_once_and_first()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2"));
|
||||
await h.Pass();
|
||||
|
||||
h.Append(Entry("marker 3"));
|
||||
Roll(h);
|
||||
h.Append(Entry("marker 4"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Line_held_back_at_the_end_of_the_file_is_finished_from_the_archive()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", MarkerPatterns());
|
||||
var second = Entry("marker 2");
|
||||
h.Append(Entry("marker 1") + second.Substring(0, 10));
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1 }, h.Matched());
|
||||
|
||||
h.Append(second.Substring(10));
|
||||
Roll(h);
|
||||
h.Append(Entry("marker 3"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3 }, h.Matched());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rollover_leaves_finished_and_running_apps_alone()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", AppPatterns());
|
||||
var transitions = new List<string>();
|
||||
h.Tracker.OnAppStateChanged = (pkg, from, to) => transitions.Add($"{pkg.Id}:{from}->{to}");
|
||||
h.Append(OneFinishedOneRunning());
|
||||
await h.Pass();
|
||||
var before = transitions.ToList();
|
||||
|
||||
Roll(h);
|
||||
h.Append(Entry("marker unrelated"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(before, transitions);
|
||||
var states = h.Tracker.GetAllKnownPackageStates().ToDictionary(p => p.Id, p => p.InstallationState);
|
||||
Assert.Equal(AppInstallationState.Installed, states["app-1"]);
|
||||
Assert.Equal(AppInstallationState.Installing, states["app-2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_readable_identity_the_archive_is_read_again_as_before()
|
||||
{
|
||||
// The fallback, and the proof that the tests above catch the replay: this is what they saw before the fix.
|
||||
using var h = new Harness("AppWorkload.log", AppPatterns());
|
||||
h.Tracker.FileIdentityProvider = _ => null!;
|
||||
var transitions = new List<string>();
|
||||
h.Tracker.OnAppStateChanged = (pkg, from, to) => transitions.Add($"{pkg.Id}:{from}->{to}");
|
||||
h.Append(OneFinishedOneRunning());
|
||||
await h.Pass();
|
||||
|
||||
Roll(h);
|
||||
h.Append(Entry("marker unrelated"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Contains("app-2:Installing->Skipped", transitions);
|
||||
Assert.Contains("app-1:Installed->Installing", transitions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Live_file_that_sorts_before_its_archive_hands_over_its_bookmark()
|
||||
{
|
||||
// LogMaxHistory <= 1: the archive is _X.log, which sorts behind X.log. The pass reaches the
|
||||
// shrunken live file first, and the next rollover replaces the archive under the same name.
|
||||
using var h = new Harness("IntuneManagementExtension.log", MarkerPatterns());
|
||||
var archive = Path.Combine(Folder(h), "_IntuneManagementExtension.log");
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3") + Entry("marker 4"));
|
||||
await h.Pass();
|
||||
|
||||
File.Move(h.LogPath, archive);
|
||||
h.Append(Entry("marker 5"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 4, 5 }, h.Matched());
|
||||
|
||||
h.Append(Entry("marker 6"));
|
||||
File.Delete(archive);
|
||||
File.Move(h.LogPath, archive);
|
||||
h.Append(Entry("marker 8"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6, 8 }, h.Matched());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rollover_during_a_restart_with_a_longer_new_file_loses_and_repeats_nothing()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2"));
|
||||
await h.Pass();
|
||||
|
||||
h.Restart();
|
||||
h.Append(Entry("marker 3"));
|
||||
Roll(h);
|
||||
h.Append(Entry("marker 4") + Entry("marker 5") + Entry("marker 6") + Entry("marker 7"));
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6, 7 }, h.Matched());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_rollovers_during_a_restart_with_the_bookmarked_file_deleted_lose_nothing()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2"));
|
||||
await h.Pass();
|
||||
|
||||
h.Restart();
|
||||
var first = Roll(h, FirstStamp);
|
||||
h.Append(Entry("marker 3"));
|
||||
Roll(h, SecondStamp);
|
||||
h.Append(Entry("marker 4") + Entry("marker 5") + Entry("marker 6"));
|
||||
File.Delete(first);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6 }, h.Matched());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Archives_found_at_the_first_start_are_read_from_the_beginning_in_order()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", MarkerPatterns());
|
||||
File.WriteAllText(ArchivePath(h, FirstStamp), Entry("marker 1") + Entry("marker 2"));
|
||||
File.WriteAllText(ArchivePath(h, SecondStamp), Entry("marker 3"));
|
||||
h.Append(Entry("marker 4"));
|
||||
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_script_in_flight_keeps_its_end_block_across_a_rollover()
|
||||
{
|
||||
// Reading the archive again used to restore the marker of the run in flight by accident.
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(Entry("ExecutorLog AgentExecutor gets invoked") + Entry(PlatformStartLine) + Entry("[Executor] created powershell with process id 12708"));
|
||||
await h.Pass();
|
||||
|
||||
Roll(h);
|
||||
h.Append(Entry("Powershell exit code is 0") + Entry("write output done. output = Hello from E, error = ") + Entry("Agent executor completed."));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
h.Tracker.ProcessLogMessageForTest(ResultLine);
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(PlatformId, script.PolicyId);
|
||||
Assert.Equal(0, script.ExitCode);
|
||||
Assert.Equal("Hello from E", script.Stdout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Identity_of_a_real_file_survives_a_rename_and_differs_from_the_next_file()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1"));
|
||||
var before = ImeLogFileIdentity.TryRead(h.LogPath);
|
||||
Assert.NotNull(before);
|
||||
|
||||
var archive = Roll(h);
|
||||
h.Append(Entry("marker 2"));
|
||||
|
||||
Assert.Equal(before, ImeLogFileIdentity.TryRead(archive));
|
||||
Assert.NotEqual(before, ImeLogFileIdentity.TryRead(h.LogPath));
|
||||
Assert.Null(ImeLogFileIdentity.TryRead(archive + ".missing"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
@@ -13,6 +14,8 @@ using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
|
||||
#pragma warning disable xUnit1031 // one test waits on a task on purpose: a blocked call is the failure it looks for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
@@ -137,6 +140,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
public void Dispose()
|
||||
{
|
||||
Watcher.Dispose();
|
||||
ReleaseProcessesAndFiles();
|
||||
}
|
||||
|
||||
/// <summary>Teardown that leaves the watcher alone — for a test that found its Dispose blocked.</summary>
|
||||
public void ReleaseProcessesAndFiles()
|
||||
{
|
||||
foreach (var p in Running)
|
||||
{
|
||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
||||
@@ -210,6 +219,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.Equal(new object[] { first.Id, second.Id }, pids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_returns_while_the_exit_of_the_attached_process_is_being_delivered()
|
||||
{
|
||||
var f = new Fixture();
|
||||
var child = f.Spawn(trusted: true);
|
||||
|
||||
// The watcher gets a Process object that already carries a handler. .NET runs that one
|
||||
// first and holds its own lock on the Process object for as long as it delivers Exited.
|
||||
var delivering = new ManualResetEventSlim(false);
|
||||
var release = new ManualResetEventSlim(false);
|
||||
f.Watcher.ProcessSource = () =>
|
||||
{
|
||||
var handle = Process.GetProcessById(child.Id);
|
||||
handle.EnableRaisingEvents = true;
|
||||
handle.Exited += (_, __) => { delivering.Set(); release.Wait(TimeSpan.FromSeconds(10)); };
|
||||
return new[] { handle };
|
||||
};
|
||||
f.Watcher.TryAttachNow();
|
||||
Assert.Equal(child.Id, f.Watcher.AttachedProcessId);
|
||||
|
||||
Task dispose;
|
||||
try
|
||||
{
|
||||
child.Kill();
|
||||
Assert.True(delivering.Wait(TimeSpan.FromSeconds(10)), "the exit was not delivered");
|
||||
|
||||
dispose = Task.Run(() => f.Watcher.Dispose());
|
||||
Thread.Sleep(300); // let Dispose reach the Process object
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
var returned = dispose.Wait(TimeSpan.FromSeconds(10));
|
||||
// A blocked Dispose holds the watcher's lock for good; disposing the watcher again would
|
||||
// hang the test run instead of failing this test.
|
||||
f.ReleaseProcessesAndFiles();
|
||||
Assert.True(returned,
|
||||
"Dispose and the exit delivery blocked each other: the Process is disposed under the watcher's lock");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_spoof_and_service_both_running_the_service_wins_regardless_of_order()
|
||||
{
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// A bookmark belongs to a file, not to a name: IME rolls a log by renaming it, so the identity
|
||||
/// (volume plus file index) tells what happened to the bytes behind the bookmark.
|
||||
/// </summary>
|
||||
public sealed class LogFilePositionTrackerTests
|
||||
{
|
||||
private const string Live = @"C:\Logs\AppWorkload.log";
|
||||
private const string Archive = @"C:\Logs\AppWorkload-20260910-193000.log";
|
||||
private const string Underscore = @"C:\Logs\_AppWorkload.log";
|
||||
|
||||
private static readonly DateTime Checked = new DateTime(2026, 9, 10, 19, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
private static Dictionary<string, string> Ids(params (string Path, string Id)[] entries)
|
||||
{
|
||||
var ids = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in entries) ids[entry.Path] = entry.Id;
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static LogFilePositionTracker TrackerWithBookmark(string path, string id, long position)
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.Reconcile(Ids((path, id)));
|
||||
tracker.MarkChecked(path, Checked);
|
||||
tracker.SetPosition(path, position);
|
||||
return tracker;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renamed_file_keeps_its_bookmark_and_the_old_path_starts_over()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Archive, "A"), (Live, "B")));
|
||||
|
||||
var move = Assert.Single(moves);
|
||||
Assert.Equal(Live, move.From);
|
||||
Assert.Equal(Archive, move.To);
|
||||
Assert.Equal(3000, move.Position);
|
||||
Assert.Equal(3000, tracker.GetPosition(Archive));
|
||||
Assert.Equal("A", tracker.GetFileId(Archive));
|
||||
Assert.Equal(0, tracker.GetPosition(Live));
|
||||
Assert.Equal("B", tracker.GetFileId(Live));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Both_paths_keep_the_freshness_reference()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
tracker.Reconcile(Ids((Archive, "A"), (Live, "B")));
|
||||
|
||||
Assert.True(tracker.HasSeen(Archive));
|
||||
Assert.Equal(Checked, tracker.GetLastCheckedUtc(Archive));
|
||||
Assert.True(tracker.HasSeen(Live));
|
||||
Assert.Equal(Checked, tracker.GetLastCheckedUtc(Live));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renamed_file_whose_old_path_is_not_recreated_yet_keeps_its_bookmark()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Archive, "A")));
|
||||
|
||||
Assert.Single(moves);
|
||||
Assert.Equal(3000, tracker.GetPosition(Archive));
|
||||
Assert.Equal(0, tracker.GetPosition(Live));
|
||||
Assert.False(tracker.HasIdentity(Live));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Second_name_for_the_same_file_moves_nothing()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Live, "A"), (Archive, "A")));
|
||||
|
||||
Assert.Empty(moves);
|
||||
Assert.Equal(3000, tracker.GetPosition(Live));
|
||||
Assert.False(tracker.HasSeen(Archive));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_file_stays_unseen_so_its_first_pass_is_not_taken_for_fresh_lines()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Live, "A"), (Archive, "X")));
|
||||
|
||||
Assert.Empty(moves);
|
||||
Assert.True(tracker.HasIdentity(Archive));
|
||||
Assert.False(tracker.HasSeen(Archive));
|
||||
Assert.Equal(0, tracker.GetPosition(Archive));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Path_holding_another_file_with_no_one_to_take_over_starts_at_zero()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Live, "B")));
|
||||
|
||||
Assert.Empty(moves);
|
||||
// GetSafePosition alone would keep 3000 here when the new file is longer: lost lines.
|
||||
Assert.Equal(0, tracker.GetSafePosition(Live, 5000));
|
||||
Assert.Equal("B", tracker.GetFileId(Live));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unreadable_identity_changes_nothing()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Live, null!), (Archive, null!)));
|
||||
|
||||
Assert.Empty(moves);
|
||||
Assert.Equal(3000, tracker.GetPosition(Live));
|
||||
Assert.Equal("A", tracker.GetFileId(Live));
|
||||
Assert.True(tracker.HasIdentity(Archive));
|
||||
Assert.Null(tracker.GetFileId(Archive));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_slot_archive_is_replaced_by_the_next_rollover()
|
||||
{
|
||||
// LogMaxHistory <= 1: IME deletes _X.log and renames X.log to it.
|
||||
var tracker = TrackerWithBookmark(Underscore, "A", 9000);
|
||||
tracker.Reconcile(Ids((Underscore, "A"), (Live, "B")));
|
||||
tracker.SetPosition(Live, 3000);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Underscore, "B"), (Live, "C")));
|
||||
|
||||
var move = Assert.Single(moves);
|
||||
Assert.Equal(Live, move.From);
|
||||
Assert.Equal(Underscore, move.To);
|
||||
Assert.Equal(3000, tracker.GetPosition(Underscore));
|
||||
Assert.Equal(0, tracker.GetPosition(Live));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_renamed_file_can_leave_the_path_it_moved_into()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
tracker.Reconcile(Ids((Live, "A"), (Underscore, "B")));
|
||||
tracker.SetPosition(Underscore, 700);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Underscore, "A"), (Archive, "B"), (Live, "C")));
|
||||
|
||||
Assert.Equal(2, moves.Count);
|
||||
Assert.Equal(3000, tracker.GetPosition(Underscore));
|
||||
Assert.Equal(700, tracker.GetPosition(Archive));
|
||||
Assert.Equal(0, tracker.GetPosition(Live));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Behind_bookmark_only_when_smaller_than_the_position()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
Assert.True(tracker.IsBehindBookmark(Live, 2999));
|
||||
Assert.False(tracker.IsBehindBookmark(Live, 3000));
|
||||
Assert.False(tracker.IsBehindBookmark(Archive, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Restored_identity_is_used_to_find_a_rollover_during_the_downtime()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.RestorePosition(Live, 3000, 3000);
|
||||
tracker.RestoreFileId(Live, "A");
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Archive, "A"), (Live, "B")));
|
||||
|
||||
Assert.Single(moves);
|
||||
Assert.Equal(3000, tracker.GetPosition(Archive));
|
||||
Assert.Equal(0, tracker.GetPosition(Live));
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -2,11 +2,14 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // one test waits on tasks on purpose: a blocked call is the failure it looks for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
@@ -148,6 +151,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
Assert.Equal(afterDispose, Volatile.Read(ref raises)); // no raises after dispose
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_from_another_thread_returns_while_the_initial_scan_handler_is_running()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
LayDownCoreBinary(tmp.Path); // the initial scan in Start() finds it and raises
|
||||
var watcher = new OfficeBinaryWatcher(tmp.Path, CoreBinaries, NewLogger(tmp.Path));
|
||||
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
watcher.BinaryAppeared += (_, __) => { entered.Set(); release.Wait(TimeSpan.FromSeconds(10)); };
|
||||
|
||||
var start = Task.Run(() => watcher.Start());
|
||||
try
|
||||
{
|
||||
Assert.True(entered.Wait(TimeSpan.FromSeconds(5)), "initial scan did not raise");
|
||||
|
||||
var dispose = Task.Run(() => watcher.Dispose());
|
||||
|
||||
Assert.True(dispose.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Dispose waited for the running handler: the watcher's lock is held while BinaryAppeared is raised");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
start.Wait(TimeSpan.FromSeconds(10));
|
||||
watcher.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SpinUntil(Func<bool> condition, int timeoutMs)
|
||||
{
|
||||
var deadline = Environment.TickCount + timeoutMs;
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="OfficeProcessWatcher"/> with stand-in worker processes handed in through the
|
||||
/// startup probe (the WMI start trace is never armed here, as in the sibling watchers' tests).
|
||||
/// Covers the worker window (started / stopped) and the two ways a worker's exit used to go
|
||||
/// wrong: disposing the watcher while the exit is being delivered, and a worker that was
|
||||
/// already gone when the watcher subscribed.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // Process.Exited is delivered on the shared ThreadPool
|
||||
public sealed class OfficeProcessWatcherTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly List<Process> _spawned = new List<Process>();
|
||||
|
||||
public OfficeProcessWatcherTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var p in _spawned)
|
||||
{
|
||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
||||
try { p.Dispose(); } catch { }
|
||||
}
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>A stand-in worker that blocks on stdin until killed.</summary>
|
||||
private Process Spawn()
|
||||
{
|
||||
var p = Process.Start(new ProcessStartInfo("cmd.exe", "/c pause")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
Assert.NotNull(p);
|
||||
_spawned.Add(p!);
|
||||
return p!;
|
||||
}
|
||||
|
||||
/// <summary>A second handle on the same process — what the watcher would have opened itself.</summary>
|
||||
private static Process HandleOn(Process child)
|
||||
{
|
||||
var handle = Process.GetProcessById(child.Id);
|
||||
handle.EnableRaisingEvents = true;
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>Starts the watcher with <paramref name="handle"/> as the one running worker.</summary>
|
||||
private static void StartWith(OfficeProcessWatcher watcher, Process handle)
|
||||
{
|
||||
watcher.WorkerSource = () => new[] { handle };
|
||||
watcher.Start(armWmi: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_running_worker_is_reported_before_Start_returns_and_its_exit_closes_the_window()
|
||||
{
|
||||
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
using var started = new ManualResetEventSlim(false);
|
||||
using var stopped = new ManualResetEventSlim(false);
|
||||
watcher.Started += (_, __) => started.Set();
|
||||
watcher.Stopped += (_, __) => stopped.Set();
|
||||
var child = Spawn();
|
||||
|
||||
StartWith(watcher, HandleOn(child));
|
||||
|
||||
Assert.True(started.IsSet, "Started was not raised by the startup probe");
|
||||
Assert.True(watcher.IsActive);
|
||||
|
||||
child.Kill();
|
||||
|
||||
Assert.True(stopped.Wait(Timeout), "Stopped was not raised after the last worker exited");
|
||||
Assert.False(watcher.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_returns_while_the_exit_of_a_tracked_worker_is_being_delivered()
|
||||
{
|
||||
var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
var child = Spawn();
|
||||
var handle = HandleOn(child);
|
||||
|
||||
// Subscribed before the watcher, so .NET runs it first — and .NET holds its own lock on
|
||||
// the Process object for as long as it delivers Exited.
|
||||
using var delivering = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
handle.Exited += (_, __) => { delivering.Set(); release.Wait(Timeout); };
|
||||
StartWith(watcher, handle);
|
||||
|
||||
Task dispose;
|
||||
try
|
||||
{
|
||||
child.Kill();
|
||||
Assert.True(delivering.Wait(Timeout), "the worker's exit was not delivered");
|
||||
|
||||
dispose = Task.Run(() => watcher.Dispose());
|
||||
Thread.Sleep(300); // let Dispose reach the Process object
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
Assert.True(dispose.Wait(Timeout),
|
||||
"Dispose and the exit delivery blocked each other: the Process is disposed under the watcher's lock");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_worker_that_was_already_gone_when_the_watcher_subscribed_still_closes_the_window()
|
||||
{
|
||||
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
using var stopped = new ManualResetEventSlim(false);
|
||||
watcher.Stopped += (_, __) => stopped.Set();
|
||||
|
||||
var child = Spawn();
|
||||
var handle = HandleOn(child);
|
||||
using var exitDelivered = new ManualResetEventSlim(false);
|
||||
handle.Exited += (_, __) => exitDelivered.Set();
|
||||
child.Kill();
|
||||
// .NET raises Exited once per Process object. After this, a later subscriber never hears it.
|
||||
Assert.True(exitDelivered.Wait(Timeout), "the stand-in's exit was not delivered");
|
||||
|
||||
StartWith(watcher, handle);
|
||||
|
||||
Assert.True(stopped.Wait(Timeout),
|
||||
"the window never closed: the worker exited before the watcher subscribed to Exited");
|
||||
Assert.False(watcher.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_traced_id_that_belongs_to_another_program_is_not_tracked()
|
||||
{
|
||||
// The start trace arrives late: by then the id can belong to an unrelated process.
|
||||
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
watcher.WorkerSource = () => Array.Empty<Process>();
|
||||
watcher.Start(armWmi: false);
|
||||
var child = Spawn();
|
||||
|
||||
watcher.TrackTracedProcess(child.Id, "OfficeC2RClient");
|
||||
|
||||
Assert.False(watcher.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_traced_id_that_still_carries_the_expected_name_is_tracked()
|
||||
{
|
||||
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
watcher.WorkerSource = () => Array.Empty<Process>();
|
||||
watcher.Start(armWmi: false);
|
||||
var child = Spawn();
|
||||
|
||||
watcher.TrackTracedProcess(child.Id, "cmd");
|
||||
|
||||
Assert.True(watcher.IsActive);
|
||||
}
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="RegistryChangeWatcher"/> against a real key under HKCU (no elevation needed).
|
||||
/// The watcher's lock is a leaf lock: <c>Changed</c> is raised with the lock free, so a
|
||||
/// handler can take other locks or dispose the watcher, and <c>Dispose</c> never waits for a
|
||||
/// handler. One handler runs at a time, and a change that arrives while it runs is reported
|
||||
/// after it returns.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // callbacks arrive on the shared ThreadPool
|
||||
public sealed class RegistryChangeWatcherTests : IDisposable
|
||||
{
|
||||
private const string RootSubKey = @"SOFTWARE\AutopilotMonitor.Tests\OfficeRegistryChangeWatcher";
|
||||
private const string TargetSubKey = RootSubKey + @"\Target";
|
||||
private const uint RegNotifyThreadAgnostic = 0x10000000;
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public RegistryChangeWatcherTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
DeleteTestKeys();
|
||||
using (var root = Registry.CurrentUser.CreateSubKey(RootSubKey)) Assert.NotNull(root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DeleteTestKeys();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private static void DeleteTestKeys()
|
||||
{
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(RootSubKey, throwOnMissingSubKey: false); }
|
||||
catch { /* teardown noise must not fail a test */ }
|
||||
}
|
||||
|
||||
private static void CreateTarget()
|
||||
{
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(TargetSubKey)) Assert.NotNull(key);
|
||||
}
|
||||
|
||||
private static void WriteValue(string name)
|
||||
{
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(TargetSubKey, writable: true))
|
||||
{
|
||||
Assert.NotNull(key);
|
||||
key!.SetValue(name, Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
}
|
||||
|
||||
private RegistryChangeWatcher NewWatcher() =>
|
||||
new RegistryChangeWatcher(RegistryHive.CurrentUser, TargetSubKey, _logger);
|
||||
|
||||
[Fact]
|
||||
public void Changed_fires_when_a_value_is_written_under_the_target()
|
||||
{
|
||||
CreateTarget();
|
||||
using var watcher = NewWatcher();
|
||||
using var fired = new ManualResetEventSlim(false);
|
||||
watcher.Changed += (_, __) => fired.Set();
|
||||
|
||||
watcher.Start();
|
||||
WriteValue("Probe");
|
||||
|
||||
Assert.True(fired.Wait(Timeout), "Changed did not fire for a value write under the target key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changed_fires_once_a_missing_target_key_appears()
|
||||
{
|
||||
using var watcher = NewWatcher();
|
||||
using var fired = new ManualResetEventSlim(false);
|
||||
watcher.Changed += (_, __) => fired.Set();
|
||||
|
||||
watcher.Start(); // target absent: watches the parent for a new child key
|
||||
CreateTarget();
|
||||
|
||||
Assert.True(fired.Wait(Timeout), "Changed did not fire when the target key appeared");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_from_another_thread_returns_while_a_handler_is_running()
|
||||
{
|
||||
CreateTarget();
|
||||
var watcher = NewWatcher();
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
watcher.Changed += (_, __) => { entered.Set(); release.Wait(Timeout); };
|
||||
|
||||
try
|
||||
{
|
||||
watcher.Start();
|
||||
WriteValue("Probe");
|
||||
Assert.True(entered.Wait(Timeout), "handler was not invoked");
|
||||
|
||||
var dispose = Task.Run(() => watcher.Dispose());
|
||||
|
||||
Assert.True(dispose.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Dispose waited for the running handler: the watcher's lock is held while Changed is raised");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
watcher.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_handler_may_dispose_its_own_watcher()
|
||||
{
|
||||
CreateTarget();
|
||||
var watcher = NewWatcher();
|
||||
using var disposedInHandler = new ManualResetEventSlim(false);
|
||||
int calls = 0;
|
||||
watcher.Changed += (_, __) =>
|
||||
{
|
||||
Interlocked.Increment(ref calls);
|
||||
watcher.Dispose();
|
||||
disposedInHandler.Set();
|
||||
};
|
||||
|
||||
watcher.Start();
|
||||
WriteValue("First");
|
||||
Assert.True(disposedInHandler.Wait(Timeout), "handler did not return from disposing its watcher");
|
||||
|
||||
WriteValue("Second");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, Volatile.Read(ref calls)); // a disposed watcher stays quiet
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handlers_never_overlap_and_a_change_during_a_handler_is_reported_after_it()
|
||||
{
|
||||
CreateTarget();
|
||||
using var watcher = NewWatcher();
|
||||
using var firstEntered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
using var second = new ManualResetEventSlim(false);
|
||||
int calls = 0, running = 0, maxRunning = 0;
|
||||
watcher.Changed += (_, __) =>
|
||||
{
|
||||
var now = Interlocked.Increment(ref running);
|
||||
int seen;
|
||||
while (now > (seen = Volatile.Read(ref maxRunning)))
|
||||
Interlocked.CompareExchange(ref maxRunning, now, seen);
|
||||
|
||||
if (Interlocked.Increment(ref calls) == 1)
|
||||
{
|
||||
firstEntered.Set();
|
||||
release.Wait(Timeout);
|
||||
}
|
||||
else
|
||||
{
|
||||
second.Set();
|
||||
}
|
||||
Interlocked.Decrement(ref running);
|
||||
};
|
||||
|
||||
watcher.Start();
|
||||
WriteValue("First");
|
||||
Assert.True(firstEntered.Wait(Timeout), "handler was not invoked");
|
||||
|
||||
WriteValue("DuringHandler");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, Volatile.Read(ref calls)); // no second handler while the first runs
|
||||
|
||||
release.Set();
|
||||
|
||||
Assert.True(second.Wait(Timeout), "the change that arrived during the handler was never reported");
|
||||
Assert.Equal(1, Volatile.Read(ref maxRunning));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Target_watch_is_not_tied_to_the_arming_thread()
|
||||
{
|
||||
using var watcher = NewWatcher();
|
||||
|
||||
// Re-arming happens on rotating ThreadPool threads. Without this flag Windows signals
|
||||
// the event when such a thread ends, which looks like a change that never happened.
|
||||
Assert.True((watcher.EffectivePrimaryFilter & RegNotifyThreadAgnostic) != 0,
|
||||
"the target watch must carry REG_NOTIFY_THREAD_AGNOSTIC");
|
||||
}
|
||||
}
|
||||
}
|
||||
+319
-75
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
@@ -10,14 +11,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// ConsoleBypassWatcher classification — driven via the internal <c>HandleStart</c>/<c>Classify</c>
|
||||
/// with an injected process probe (no real WMI / processes). Asserts the parent-free discriminator:
|
||||
/// an interactive-session cmd with a bare command line is flagged (high confidence); a scripted
|
||||
/// <c>cmd /c</c> and a session-0 (service) cmd are ignored; an interactive-session cmd whose command
|
||||
/// line cannot be read (instant-close race) is still surfaced at low confidence.
|
||||
/// with injected probes (no real WMI / processes). A console that is still running when probed is
|
||||
/// reported: bare command line (high confidence), <c>/k</c> or unreadable (low). A scripted
|
||||
/// <c>cmd /c</c> and a session-0 cmd are ignored. A start whose process is gone, or whose id now
|
||||
/// belongs to another process, is counted as script activity and never reported as a console.
|
||||
/// </summary>
|
||||
public sealed class ConsoleBypassWatcherTests
|
||||
{
|
||||
private const string Cmd = "cmd.exe";
|
||||
private const string BareCmd = @"C:\WINDOWS\system32\cmd.exe";
|
||||
private const string OobeAccount = "S-1-5-21-1-2-3-1000";
|
||||
private const string Created = "20261111143453.119277+060";
|
||||
private const int Pid = 9636;
|
||||
private const int Parent = 9016;
|
||||
private static readonly DateTime At = new DateTime(2026, 11, 11, 14, 34, 53, DateTimeKind.Utc);
|
||||
|
||||
private static AgentLogger NewLogger(string dir) => new AgentLogger(dir, AgentLogLevel.Info);
|
||||
|
||||
@@ -25,59 +32,67 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
public List<ConsoleSpawnInfo> Detected { get; } = new List<ConsoleSpawnInfo>();
|
||||
public List<int> ParentLookups { get; } = new List<int>();
|
||||
public int WindowsOpened { get; private set; }
|
||||
public ConsoleBypassWatcher Sut { get; }
|
||||
|
||||
public Rig(Func<int, ProcessProbe?> probe)
|
||||
public Rig(Func<int, string, ProcessProbe?> probe, bool alive = true, Func<int, string?>? parentName = null)
|
||||
{
|
||||
Sut = new ConsoleBypassWatcher(NewLogger(_tmp.Path), probe);
|
||||
Sut = new ConsoleBypassWatcher(
|
||||
NewLogger(_tmp.Path),
|
||||
probe,
|
||||
isAlive: _ => alive,
|
||||
parentName: pid => { ParentLookups.Add(pid); return parentName?.Invoke(pid); },
|
||||
utcNow: () => At);
|
||||
Sut.BypassConsoleDetected += (_, info) => Detected.Add(info);
|
||||
Sut.ScriptActivityStarted += (_, __) => WindowsOpened++;
|
||||
}
|
||||
|
||||
public void Trace(int pid = Pid, int parentPid = Parent, int sessionId = 1, string? ownerSid = OobeAccount) =>
|
||||
Sut.HandleStart(pid, parentPid, sessionId, ownerSid, "process_start_trace", Cmd);
|
||||
|
||||
public void Dispose() { Sut.Dispose(); _tmp.Dispose(); }
|
||||
}
|
||||
|
||||
private static Func<int, ProcessProbe?> Probe(string? commandLine, string? owner = @"PC\defaultuser0")
|
||||
=> _ => new ProcessProbe(commandLine, owner);
|
||||
/// <summary>A running process that is the traced one, with the given command line.</summary>
|
||||
private static Func<int, string, ProcessProbe?> Running(
|
||||
string? commandLine, string name = Cmd, int sessionId = 1, int parentPid = Parent, string creationDate = Created)
|
||||
=> (_, __) => new ProcessProbe(name, sessionId, parentPid, commandLine, creationDate);
|
||||
|
||||
private static readonly Func<int, ProcessProbe?> GoneProbe = _ => null;
|
||||
/// <summary>The process has exited before it could be read.</summary>
|
||||
private static readonly Func<int, string, ProcessProbe?> Gone = (_, __) => null;
|
||||
|
||||
// ---------------------------------------------------------------- HandleStart end-to-end
|
||||
// ---------------------------------------------------------------- consoles that are reported
|
||||
|
||||
[Fact]
|
||||
public void Interactive_session_bare_cmd_raises_high_confidence_with_signature()
|
||||
{
|
||||
using var rig = new Rig(Probe(BareCmd, owner: @"PC\defaultuser0"));
|
||||
using var rig = new Rig(Running(BareCmd), parentName: _ => "explorer.exe");
|
||||
|
||||
rig.Sut.HandleStart(pid: 9636, parentPid: 9016, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
|
||||
rig.Trace();
|
||||
|
||||
var info = Assert.Single(rig.Detected);
|
||||
Assert.Equal("cmd.exe", info.ProcessName);
|
||||
Assert.Equal(9636, info.ProcessId);
|
||||
Assert.Equal(9016, info.ParentProcessId);
|
||||
Assert.Equal(Cmd, info.ProcessName);
|
||||
Assert.Equal(Pid, info.ProcessId);
|
||||
Assert.Equal(Parent, info.ParentProcessId);
|
||||
Assert.Equal("explorer.exe", info.ParentProcessName);
|
||||
Assert.Equal(1, info.SessionId);
|
||||
Assert.Equal("high", info.Confidence);
|
||||
Assert.Equal("interactive_console", info.Classification);
|
||||
Assert.Equal(@"PC\defaultuser0", info.Owner);
|
||||
Assert.Equal(OobeAccount, info.Owner);
|
||||
Assert.Equal(BareCmd, info.CommandLine);
|
||||
Assert.Null(rig.Sut.TakeScriptActivity());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_zero_cmd_is_ignored_even_when_bare()
|
||||
public void A_console_whose_launcher_is_gone_has_no_parent_name()
|
||||
{
|
||||
// IME / Intune install cmd run as SYSTEM in session 0 — never an interactive console.
|
||||
using var rig = new Rig(Probe(BareCmd));
|
||||
rig.Sut.HandleStart(1234, 1, sessionId: 0, ownerSidHint: null, detectedVia: "process_start_trace");
|
||||
Assert.Empty(rig.Detected);
|
||||
}
|
||||
// The launcher behind Shift+F10 exits within milliseconds.
|
||||
using var rig = new Rig(Running(BareCmd));
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"C:\WINDOWS\system32\cmd.exe /c ""install.bat""")]
|
||||
[InlineData(@"cmd.exe /C ping")]
|
||||
public void Scripted_cmd_is_ignored(string commandLine)
|
||||
{
|
||||
using var rig = new Rig(Probe(commandLine));
|
||||
rig.Sut.HandleStart(2222, 1, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
|
||||
Assert.Empty(rig.Detected);
|
||||
rig.Trace();
|
||||
|
||||
Assert.Null(Assert.Single(rig.Detected).ParentProcessName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -85,9 +100,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
{
|
||||
// L12: /k runs its command and then leaves a fully interactive shell — a deliberate
|
||||
// false-negative seam if ignored (e.g. a technician-planted `cmd /k whoami`).
|
||||
using var rig = new Rig(Probe(@"cmd.exe /k whoami"));
|
||||
using var rig = new Rig(Running(@"cmd.exe /k whoami"));
|
||||
|
||||
rig.Sut.HandleStart(2222, 1, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
|
||||
rig.Trace();
|
||||
|
||||
var info = Assert.Single(rig.Detected);
|
||||
Assert.Equal("low", info.Confidence);
|
||||
@@ -101,9 +116,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
{
|
||||
// cmd.exe honors only the FIRST /c-or-/k switch; a '/c' inside the /k payload is inert, so
|
||||
// the console is a persistent human-usable shell and must still be surfaced (low confidence).
|
||||
using var rig = new Rig(Probe(commandLine));
|
||||
using var rig = new Rig(Running(commandLine));
|
||||
|
||||
rig.Sut.HandleStart(2223, 1, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
|
||||
rig.Trace();
|
||||
|
||||
var info = Assert.Single(rig.Detected);
|
||||
Assert.Equal("low", info.Confidence);
|
||||
@@ -111,88 +126,317 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Instant_close_unreadable_commandline_raises_low_confidence()
|
||||
public void A_running_process_without_a_readable_command_line_raises_low_confidence()
|
||||
{
|
||||
// Win32_Process query loses the race (process already exited). SessionID from the trace is
|
||||
// still known, so the console is surfaced — low confidence, owner from the trace SID hint.
|
||||
using var rig = new Rig(GoneProbe);
|
||||
using var rig = new Rig(Running(commandLine: null), alive: true);
|
||||
|
||||
rig.Sut.HandleStart(9636, 9016, sessionId: 1, ownerSidHint: "S-1-5-18", detectedVia: "process_start_trace");
|
||||
rig.Trace();
|
||||
|
||||
var info = Assert.Single(rig.Detected);
|
||||
Assert.Equal("low", info.Confidence);
|
||||
Assert.Equal("interactive_session_unclassified", info.Classification);
|
||||
Assert.Equal("S-1-5-18", info.Owner);
|
||||
Assert.Null(info.CommandLine);
|
||||
Assert.Null(rig.Sut.TakeScriptActivity());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Owner_prefers_probe_over_trace_hint()
|
||||
public void A_running_process_that_could_not_be_read_at_all_raises_low_confidence()
|
||||
{
|
||||
using var rig = new Rig(Probe(BareCmd, owner: @"PC\defaultuser0"));
|
||||
rig.Sut.HandleStart(9636, 9016, sessionId: 1, ownerSidHint: "S-1-5-18", detectedVia: "process_start_trace");
|
||||
Assert.Equal(@"PC\defaultuser0", Assert.Single(rig.Detected).Owner);
|
||||
// The query failed. That says nothing about the process: it is reported, never counted away.
|
||||
using var rig = new Rig((_, __) => ProcessProbe.Unreadable, alive: true);
|
||||
|
||||
rig.Trace();
|
||||
|
||||
var info = Assert.Single(rig.Detected);
|
||||
Assert.Equal("low", info.Confidence);
|
||||
Assert.Equal("interactive_session_unclassified", info.Classification);
|
||||
Assert.Equal(OobeAccount, info.Owner);
|
||||
Assert.Null(rig.Sut.TakeScriptActivity());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- starts that are ignored
|
||||
|
||||
[Fact]
|
||||
public void Same_pid_seen_twice_raises_only_once()
|
||||
public void Session_zero_cmd_is_ignored_even_when_bare()
|
||||
{
|
||||
// The startup probe and the live trace can both observe the same still-open console.
|
||||
using var rig = new Rig(Probe(BareCmd));
|
||||
rig.Sut.HandleStart(9636, 9016, 1, null, "startup_probe");
|
||||
rig.Sut.HandleStart(9636, 9016, 1, null, "process_start_trace");
|
||||
Assert.Single(rig.Detected);
|
||||
// IME / Intune install cmd run as SYSTEM in session 0 — never an interactive console.
|
||||
using var rig = new Rig(Running(BareCmd));
|
||||
|
||||
rig.Trace(sessionId: 0);
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Null(rig.Sut.TakeScriptActivity());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"C:\WINDOWS\system32\cmd.exe /c ""install.bat""")]
|
||||
[InlineData(@"cmd.exe /C ping")]
|
||||
public void A_running_scripted_cmd_is_neither_reported_nor_counted(string commandLine)
|
||||
{
|
||||
using var rig = new Rig(Running(commandLine));
|
||||
|
||||
rig.Trace();
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Null(rig.Sut.TakeScriptActivity());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_pid_is_ignored()
|
||||
{
|
||||
using var rig = new Rig(Probe(BareCmd));
|
||||
rig.Sut.HandleStart(0, 9016, 1, null, "process_start_trace");
|
||||
using var rig = new Rig(Running(BareCmd));
|
||||
|
||||
rig.Trace(pid: 0);
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Null(rig.Sut.TakeScriptActivity());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- starts that are counted
|
||||
|
||||
[Fact]
|
||||
public void A_process_that_has_exited_is_counted_and_not_reported()
|
||||
{
|
||||
// WMI delivers the start about a second late; a scripted cmd /c is gone by then. That is
|
||||
// script activity, not a console someone types into.
|
||||
using var rig = new Rig(Gone, parentName: _ => "powershell.exe");
|
||||
|
||||
rig.Trace();
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
var activity = rig.Sut.TakeScriptActivity()!;
|
||||
Assert.Equal(1, activity.Count);
|
||||
Assert.Equal(Cmd, activity.ProcessName);
|
||||
Assert.Equal(At, activity.FirstSeenUtc);
|
||||
Assert.Equal(new[] { 1 }, activity.WindowsSessions);
|
||||
Assert.Equal(new[] { OobeAccount }, activity.Owners);
|
||||
var parent = Assert.Single(activity.Parents);
|
||||
Assert.Equal(Parent, parent.ProcessId);
|
||||
Assert.Equal("powershell.exe", parent.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("SearchFilterHost.exe", 1, Parent)] // another program was given the id
|
||||
[InlineData(Cmd, 0, Parent)] // a service-session cmd was given the id
|
||||
[InlineData(Cmd, 1, 4242)] // a cmd of another parent was given the id
|
||||
public void An_id_that_now_belongs_to_another_process_is_counted_and_not_reported(string name, int sessionId, int parentPid)
|
||||
{
|
||||
// Its command line has no /c, which used to read as a bare interactive console.
|
||||
using var rig = new Rig(Running(@"C:\Windows\System32\other.exe -Embedding", name, sessionId, parentPid));
|
||||
|
||||
rig.Trace();
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_row_without_a_command_line_whose_process_has_ended_is_counted()
|
||||
{
|
||||
using var rig = new Rig(Running(commandLine: null), alive: false);
|
||||
|
||||
rig.Trace();
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_process_that_could_not_be_read_and_is_gone_at_the_second_look_is_counted()
|
||||
{
|
||||
using var rig = new Rig((_, __) => ProcessProbe.Unreadable, alive: false);
|
||||
|
||||
rig.Trace();
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_burst_opens_one_window_and_asks_for_each_parent_once()
|
||||
{
|
||||
using var rig = new Rig(Gone, parentName: _ => "python.exe");
|
||||
|
||||
for (var i = 0; i < 25; i++) rig.Trace(pid: 5000 + i, parentPid: 2688);
|
||||
for (var i = 0; i < 21; i++) rig.Trace(pid: 6000 + i, parentPid: 9296);
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Equal(1, rig.WindowsOpened);
|
||||
Assert.Equal(new[] { 2688, 9296 }, rig.ParentLookups);
|
||||
var activity = rig.Sut.TakeScriptActivity()!;
|
||||
Assert.Equal(46, activity.Count);
|
||||
Assert.Equal(new[] { 25, 21 }, activity.Parents.Select(p => p.Children));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_next_start_after_the_content_was_taken_opens_a_new_window()
|
||||
{
|
||||
using var rig = new Rig(Gone);
|
||||
|
||||
rig.Trace(pid: 1);
|
||||
rig.Trace(pid: 2);
|
||||
rig.Sut.TakeScriptActivity();
|
||||
rig.Trace(pid: 3);
|
||||
|
||||
Assert.Equal(2, rig.WindowsOpened);
|
||||
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nothing_is_reported_or_counted_after_dispose()
|
||||
{
|
||||
var rig = new Rig(Gone);
|
||||
rig.Sut.Dispose();
|
||||
|
||||
rig.Trace();
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Null(rig.Sut.TakeScriptActivity());
|
||||
rig.Dispose();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- de-duplication
|
||||
|
||||
[Fact]
|
||||
public void The_same_process_seen_by_startup_probe_and_trace_raises_only_once()
|
||||
{
|
||||
// The startup probe and the live trace can both observe the same still-open console.
|
||||
using var rig = new Rig(Running(BareCmd));
|
||||
|
||||
rig.Sut.HandleProbed(Pid, Parent, 1, OobeAccount, "startup_probe", Cmd, new ProcessProbe(Cmd, 1, Parent, BareCmd, Created));
|
||||
rig.Trace();
|
||||
|
||||
Assert.Equal("startup_probe", Assert.Single(rig.Detected).DetectedVia);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_later_console_that_was_given_an_id_seen_before_is_reported_again()
|
||||
{
|
||||
var created = Created;
|
||||
using var rig = new Rig((_, __) => new ProcessProbe(Cmd, 1, Parent, BareCmd, created));
|
||||
|
||||
rig.Trace();
|
||||
created = "20261111150324.504661+060";
|
||||
rig.Trace();
|
||||
|
||||
Assert.Equal(2, rig.Detected.Count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the probe's decision
|
||||
|
||||
private static readonly ProcessProbe Row = new ProcessProbe(Cmd, 1, Parent, BareCmd, Created);
|
||||
|
||||
[Theory]
|
||||
[InlineData("NotRunning")]
|
||||
[InlineData("OtherImage")]
|
||||
public void The_probe_says_gone_only_when_the_process_list_is_certain(string listed)
|
||||
{
|
||||
var asked = false;
|
||||
var state = (ConsoleBypassWatcher.ListedAs)Enum.Parse(typeof(ConsoleBypassWatcher.ListedAs), listed);
|
||||
|
||||
var probe = ConsoleBypassWatcher.Probe(Pid, state, _ => { asked = true; return Row; });
|
||||
|
||||
Assert.Null(probe);
|
||||
Assert.False(asked, "a process that is certainly gone costs no query");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Expected")]
|
||||
[InlineData("Unknown")]
|
||||
public void The_probe_returns_the_row_of_a_process_that_may_still_run(string listed)
|
||||
{
|
||||
var state = (ConsoleBypassWatcher.ListedAs)Enum.Parse(typeof(ConsoleBypassWatcher.ListedAs), listed);
|
||||
|
||||
Assert.Same(Row, ConsoleBypassWatcher.Probe(Pid, state, _ => Row));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failed_query_is_unreadable_and_never_gone()
|
||||
{
|
||||
var probe = ConsoleBypassWatcher.Probe(
|
||||
Pid, ConsoleBypassWatcher.ListedAs.Expected, _ => throw new InvalidOperationException("WinMgmt did not answer"));
|
||||
|
||||
Assert.Same(ProcessProbe.Unreadable, probe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_query_without_a_row_is_unreadable_and_never_gone()
|
||||
{
|
||||
var probe = ConsoleBypassWatcher.Probe(Pid, ConsoleBypassWatcher.ListedAs.Expected, _ => null);
|
||||
|
||||
Assert.Same(ProcessProbe.Unreadable, probe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_process_list_tells_running_other_image_and_not_running_apart()
|
||||
{
|
||||
using var me = System.Diagnostics.Process.GetCurrentProcess();
|
||||
const int neverAProcessId = int.MaxValue;
|
||||
|
||||
Assert.Equal(ConsoleBypassWatcher.ListedAs.Expected, ConsoleBypassWatcher.ProcessListState(me.Id, me.ProcessName + ".exe"));
|
||||
Assert.Equal(ConsoleBypassWatcher.ListedAs.OtherImage, ConsoleBypassWatcher.ProcessListState(me.Id, "not-" + me.ProcessName + ".exe"));
|
||||
Assert.Equal(ConsoleBypassWatcher.ListedAs.NotRunning, ConsoleBypassWatcher.ProcessListState(neverAProcessId, Cmd));
|
||||
Assert.True(ConsoleBypassWatcher.IsRunning(me.Id));
|
||||
Assert.False(ConsoleBypassWatcher.IsRunning(neverAProcessId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- IsTracedProcess (pure)
|
||||
|
||||
[Theory]
|
||||
[InlineData("cmd.exe", 1, Parent, true)]
|
||||
[InlineData("CMD.EXE", 2, Parent, true)] // image name compares without case
|
||||
[InlineData("cmd.exe", 1, 0, true)] // the row does not know its parent
|
||||
// A fact that could not be read contradicts nothing — the process stays the traced one.
|
||||
[InlineData(null, null, null, true)]
|
||||
[InlineData(null, 1, Parent, true)]
|
||||
[InlineData("cmd.exe", null, Parent, true)]
|
||||
[InlineData("cmd.exe", 1, null, true)]
|
||||
// A fact that was read and differs makes it another process.
|
||||
[InlineData("conhost.exe", 1, Parent, false)]
|
||||
[InlineData("cmd.exe", 0, Parent, false)]
|
||||
[InlineData("cmd.exe", 1, 77, false)]
|
||||
public void IsTracedProcess_compares_name_session_and_parent(string? name, int? sessionId, int? rowParent, bool expected)
|
||||
{
|
||||
var probe = new ProcessProbe(name, sessionId, rowParent, BareCmd, Created);
|
||||
Assert.Equal(expected, ConsoleBypassWatcher.IsTracedProcess(probe, Cmd, Parent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTracedProcess_accepts_any_parent_when_the_trace_has_none()
|
||||
{
|
||||
var probe = new ProcessProbe(Cmd, 1, 77, BareCmd, Created);
|
||||
Assert.True(ConsoleBypassWatcher.IsTracedProcess(probe, Cmd, parentPid: 0));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Classify (pure)
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, BareCmd, "Ignore")] // service session
|
||||
[InlineData(1, null, "UnclassifiedInteractive")] // instant-close
|
||||
[InlineData(1, null, "UnclassifiedInteractive")] // running, command line not readable
|
||||
[InlineData(1, BareCmd, "InteractiveConsole")] // bare shell
|
||||
[InlineData(1, @"""C:\WINDOWS\system32\cmd.exe""", "InteractiveConsole")]
|
||||
[InlineData(1, "", "InteractiveConsole")]
|
||||
[InlineData(1, @"cmd.exe /d", "InteractiveConsole")] // /d skips AutoRun — still interactive
|
||||
[InlineData(1, @"C:\cmd\cmd.exe", "InteractiveConsole")] // folder named 'cmd', not a switch
|
||||
[InlineData(1, @"""C:/k/cmd.exe""", "InteractiveConsole")] // forward-slash path stripped before scan
|
||||
[InlineData(1, @"cmd.exe /c foo", "Ignore")] // scripted, run-and-exit
|
||||
[InlineData(1, @"cmd.exe /C whoami", "Ignore")]
|
||||
[InlineData(1, @"""C:\WINDOWS\system32\cmd.exe"" /c ""x.bat""", "Ignore")]
|
||||
[InlineData(2, @"C:\x\cmd.exe /K bar", "InteractiveWithCommand")] // L12: /k stays interactive
|
||||
[InlineData(1, @"cmd.exe /k whoami", "InteractiveWithCommand")] // technician-planted shell
|
||||
[InlineData(1, @"cmd.exe /k", "InteractiveWithCommand")]
|
||||
[InlineData(1, @"cmd.exe /D/Q/K stay", "InteractiveWithCommand")] // combined run-and-stay
|
||||
[InlineData(1, @"cmd.exe /d/q/c exit 9", "Ignore")] // combined run-and-exit wins
|
||||
[InlineData(1, @"cmd /k rem /c", "InteractiveWithCommand")] // cmd.exe honors the FIRST switch: /k stays interactive
|
||||
[InlineData(1, @"cmd.exe /k ""title /c""", "InteractiveWithCommand")] // /c inside the /k payload is not a switch
|
||||
[InlineData(1, @"cmd /k /c", "InteractiveWithCommand")] // /c after /k is just the /k payload
|
||||
[InlineData(1, @"cmd.exe /c cmd /k", "Ignore")] // first switch /c wins the other way round
|
||||
[InlineData(1, @"cmd.exe /c rem /k", "Ignore")]
|
||||
public void Classify_maps_session_and_commandline(int sessionId, string? commandLine, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ConsoleBypassWatcher.Classify(sessionId, commandLine).ToString());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- HasScriptArgument (pure)
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"C:\WINDOWS\system32\cmd.exe", false)]
|
||||
[InlineData(@"""C:\WINDOWS\system32\cmd.exe""", false)]
|
||||
[InlineData(@"cmd.exe /d", false)] // /d skips AutoRun — still interactive
|
||||
[InlineData(@"C:\cmd\cmd.exe", false)] // folder named 'cmd', not a switch
|
||||
[InlineData(@"""C:/k/cmd.exe""", false)] // forward-slash path stripped before scan
|
||||
[InlineData("", false)]
|
||||
[InlineData(@"cmd.exe /c whoami", true)]
|
||||
[InlineData(@"cmd.exe /C whoami", true)]
|
||||
[InlineData(@"cmd.exe /k", false)] // L12: /k leaves an interactive shell
|
||||
[InlineData(@"cmd.exe /d/q/c exit 9", true)] // combined switches — the field case
|
||||
[InlineData(@"cmd.exe /D/Q/K stay", false)] // L12: run-and-stay is not scripted
|
||||
[InlineData(@"cmd.exe /k rem /c", false)] // first switch /k wins — payload /c is inert
|
||||
[InlineData(@"cmd.exe /c rem /k", true)] // first switch /c wins
|
||||
[InlineData(@"""C:\WINDOWS\system32\cmd.exe"" /c ""x.bat""", true)]
|
||||
public void HasScriptArgument_detects_run_command_switch(string commandLine, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ConsoleBypassWatcher.HasScriptArgument(commandLine));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"C:\WINDOWS\system32\cmd.exe", "")]
|
||||
[InlineData(@"cmd.exe /d/q/c exit 9", "/d/q/c exit 9")]
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The tally behind <c>console_script_activity</c>: what it counts, what it lists and caps, and
|
||||
/// that taking the content empties it.
|
||||
/// </summary>
|
||||
public sealed class ConsoleScriptActivityTallyTests
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 11, 11, 14, 41, 35, DateTimeKind.Utc);
|
||||
private const string Cmd = "cmd.exe";
|
||||
private const string OobeAccount = "S-1-5-21-1-2-3-1000";
|
||||
|
||||
private static string? NoName(int pid) => null;
|
||||
|
||||
[Fact]
|
||||
public void An_empty_tally_has_nothing_to_take()
|
||||
{
|
||||
Assert.Null(new ConsoleScriptActivityTally().Take());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Starts_are_counted_with_time_span_sessions_owners_and_parents()
|
||||
{
|
||||
var sut = new ConsoleScriptActivityTally();
|
||||
|
||||
sut.Add(Cmd, 101, 50, sessionId: 2, OobeAccount, At, _ => "powershell.exe");
|
||||
sut.Add(Cmd, 102, 50, sessionId: 1, OobeAccount, At.AddSeconds(1), _ => "powershell.exe");
|
||||
sut.Add(Cmd, 103, 60, sessionId: 1, "S-1-5-18", At.AddSeconds(9), _ => "setup.exe");
|
||||
|
||||
var a = sut.Take()!;
|
||||
Assert.Equal(Cmd, a.ProcessName);
|
||||
Assert.Equal(3, a.Count);
|
||||
Assert.Equal(At, a.FirstSeenUtc);
|
||||
Assert.Equal(At.AddSeconds(9), a.LastSeenUtc);
|
||||
Assert.Equal(new[] { 1, 2 }, a.WindowsSessions);
|
||||
Assert.Equal(new[] { OobeAccount, "S-1-5-18" }, a.Owners);
|
||||
Assert.Equal(2, a.OwnersTotal);
|
||||
Assert.Equal(2, a.ParentsTotal);
|
||||
Assert.Equal(new[] { 50, 60 }, a.Parents.Select(p => p.ProcessId));
|
||||
Assert.Equal(new[] { 2, 1 }, a.Parents.Select(p => p.Children));
|
||||
Assert.Equal(new[] { "powershell.exe", "setup.exe" }, a.Parents.Select(p => p.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Taking_empties_the_tally_and_the_next_start_opens_it_again()
|
||||
{
|
||||
var sut = new ConsoleScriptActivityTally();
|
||||
|
||||
Assert.True(sut.Add(Cmd, 101, 50, 1, OobeAccount, At, NoName));
|
||||
Assert.False(sut.Add(Cmd, 102, 50, 1, OobeAccount, At, NoName));
|
||||
Assert.Equal(2, sut.Take()!.Count);
|
||||
|
||||
Assert.Null(sut.Take());
|
||||
Assert.True(sut.Add(Cmd, 103, 50, 1, OobeAccount, At, NoName));
|
||||
Assert.Equal(1, sut.Take()!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_parent_is_looked_up_once_even_when_the_lookup_finds_nothing()
|
||||
{
|
||||
var sut = new ConsoleScriptActivityTally();
|
||||
var asked = new List<int>();
|
||||
string? Lookup(int pid) { asked.Add(pid); return null; }
|
||||
|
||||
for (var i = 0; i < 5; i++) sut.Add(Cmd, 100 + i, 50, 1, OobeAccount, At, Lookup);
|
||||
|
||||
Assert.Equal(new[] { 50 }, asked);
|
||||
var parent = Assert.Single(sut.Take()!.Parents);
|
||||
Assert.Null(parent.Name);
|
||||
Assert.Equal(5, parent.Children);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_parent_that_was_counted_itself_is_named_without_a_lookup()
|
||||
{
|
||||
// A script shell that is already gone, and the children it started.
|
||||
var sut = new ConsoleScriptActivityTally();
|
||||
var asked = new List<int>();
|
||||
string? Lookup(int pid) { asked.Add(pid); return "AgentExecutor.exe"; }
|
||||
|
||||
sut.Add(Cmd, 100, 50, 2, OobeAccount, At, Lookup);
|
||||
for (var i = 1; i <= 6; i++) sut.Add(Cmd, 100 + i, 100, 2, OobeAccount, At, Lookup);
|
||||
|
||||
Assert.Equal(new[] { 50 }, asked);
|
||||
var a = sut.Take()!;
|
||||
Assert.Equal(7, a.Count);
|
||||
Assert.Equal(new[] { 100, 50 }, a.Parents.Select(p => p.ProcessId));
|
||||
Assert.Equal(new[] { Cmd, "AgentExecutor.exe" }, a.Parents.Select(p => p.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lookups_are_limited_per_window_and_allowed_again_after_taking()
|
||||
{
|
||||
var sut = new ConsoleScriptActivityTally();
|
||||
var lookups = 0;
|
||||
string? Lookup(int pid) { lookups++; return "p" + pid + ".exe"; }
|
||||
|
||||
for (var i = 0; i < ConsoleScriptActivityTally.MaxParentLookups + 3; i++)
|
||||
sut.Add(Cmd, 1000 + i, 10 + i, 1, OobeAccount, At, Lookup);
|
||||
|
||||
Assert.Equal(ConsoleScriptActivityTally.MaxParentLookups, lookups);
|
||||
sut.Take();
|
||||
|
||||
sut.Add(Cmd, 2000, 99, 1, OobeAccount, At, Lookup);
|
||||
Assert.Equal(ConsoleScriptActivityTally.MaxParentLookups + 1, lookups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lists_are_capped_and_the_totals_tell_how_much_there_was()
|
||||
{
|
||||
var sut = new ConsoleScriptActivityTally();
|
||||
const int parents = ConsoleScriptActivityTally.MaxParentsListed + 3;
|
||||
const int owners = ConsoleScriptActivityTally.MaxOwnersListed + 2;
|
||||
|
||||
for (var p = 0; p < parents; p++)
|
||||
{
|
||||
// Parent p gets p+1 children, so the busiest parents are the last ones added.
|
||||
for (var c = 0; c <= p; c++)
|
||||
sut.Add(Cmd, 10_000 + p * 100 + c, 500 + p, 1, "S-1-5-21-9-9-9-" + (p % owners), At, NoName);
|
||||
}
|
||||
|
||||
var a = sut.Take()!;
|
||||
Assert.Equal(parents, a.ParentsTotal);
|
||||
Assert.Equal(ConsoleScriptActivityTally.MaxParentsListed, a.Parents.Count);
|
||||
Assert.Equal(500 + parents - 1, a.Parents[0].ProcessId);
|
||||
Assert.Equal(parents, a.Parents[0].Children);
|
||||
Assert.Equal(owners, a.OwnersTotal);
|
||||
Assert.Equal(ConsoleScriptActivityTally.MaxOwnersListed, a.Owners.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_start_without_a_parent_id_or_owner_still_counts()
|
||||
{
|
||||
var sut = new ConsoleScriptActivityTally();
|
||||
|
||||
sut.Add(Cmd, 101, 0, 1, null, At, _ => throw new InvalidOperationException("no parent to look up"));
|
||||
|
||||
var a = sut.Take()!;
|
||||
Assert.Equal(1, a.Count);
|
||||
Assert.Empty(a.Parents);
|
||||
Assert.Equal(0, a.ParentsTotal);
|
||||
Assert.Empty(a.Owners);
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the test waits on a task on purpose: a blocked call is the failure it looks for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="RealmJoinWatcher.Stop"/> while the "RealmJoin key appeared" callback is in
|
||||
/// flight. The callback runs on a real registry watcher over the signed-in user's
|
||||
/// <c>HKU\<sid>\SOFTWARE</c> (writable without elevation); the key probes are injected.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // registry callbacks arrive on the shared ThreadPool
|
||||
public sealed class RealmJoinWatcherStopTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
// A new direct child of HKCU\SOFTWARE is what the parent watch reacts to.
|
||||
private readonly string _probeKey = @"SOFTWARE\AutopilotMonitor.Tests.RealmJoinStop." + Guid.NewGuid().ToString("N");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(_probeKey, throwOnMissingSubKey: false); }
|
||||
catch { /* teardown noise must not fail a test */ }
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_returns_while_the_key_appeared_callback_is_running()
|
||||
{
|
||||
var sid = WindowsIdentity.GetCurrent().User!.Value;
|
||||
var userRealmJoinPath = sid + "\\" + RealmJoinInfo.UserRealmJoinSubPath;
|
||||
|
||||
var appearedProbe = new ManualResetEventSlim(false);
|
||||
var release = new ManualResetEventSlim(false);
|
||||
int armed = 0;
|
||||
bool KeyExists(RegistryHive hive, string subPath)
|
||||
{
|
||||
// Everything is "absent" while the watcher arms. Afterwards the user-scope
|
||||
// RealmJoin key "exists": the callback sees it and goes on to switch watchers.
|
||||
if (hive != RegistryHive.Users || subPath != userRealmJoinPath || Volatile.Read(ref armed) == 0)
|
||||
return false;
|
||||
appearedProbe.Set();
|
||||
release.Wait(Timeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
var watcher = new RealmJoinWatcher(
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
enumeratePackageIds: (_, __) => Array.Empty<string>(),
|
||||
tryReadPackage: (RegistryHive _, string __, string ___, out RealmJoinPackageSnapshot snapshot) =>
|
||||
{
|
||||
snapshot = default!;
|
||||
return false;
|
||||
},
|
||||
keyExists: KeyExists,
|
||||
readVersionInfo: () => default,
|
||||
readUninstallDisplayVersion: () => null,
|
||||
readDeploymentPhase: () => null);
|
||||
|
||||
// Production order: the SID is recorded, then RealmJoin reaches the running phase and
|
||||
// the watcher arms its parent watch on HKU\<sid>\SOFTWARE.
|
||||
watcher.ArmHku(sid);
|
||||
watcher.TriggerNotifyRealmJoinPresenceFromTest(RealmJoinInfo.PhaseRunningThresholdMin);
|
||||
Volatile.Write(ref armed, 1);
|
||||
|
||||
Task stop;
|
||||
try
|
||||
{
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(_probeKey)) Assert.NotNull(key);
|
||||
Assert.True(appearedProbe.Wait(Timeout), "the parent watch did not report the new key");
|
||||
|
||||
stop = Task.Run(() => watcher.Stop());
|
||||
Thread.Sleep(300); // let Stop reach the registry watcher whose callback is parked
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
var returned = stop.Wait(Timeout);
|
||||
// A blocked Stop holds the watcher's lock for good; disposing it would hang the run.
|
||||
if (returned) watcher.Dispose();
|
||||
Assert.True(returned,
|
||||
"Stop and the key-appeared callback blocked each other: watchers are disposed under the state lock");
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -80,6 +80,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
|
||||
Assert.Equal(2, client.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Registration_session_gone_report_is_exempt_from_the_cooldown_like_the_telemetry_one()
|
||||
{
|
||||
// The integrity report goes out post-config, seconds before the registration is refused.
|
||||
var clock = Clock();
|
||||
var client = new FakeApiClient();
|
||||
client.Results.Enqueue(true);
|
||||
client.Results.Enqueue(true);
|
||||
var reporter = Reporter(client, clock);
|
||||
|
||||
await reporter.TrySendAsync(AgentErrorType.IntegrityCheckFailed, "m");
|
||||
clock.Advance(TimeSpan.FromSeconds(5));
|
||||
await reporter.TrySendAsync(AgentErrorType.RegistrationRejectedSessionGone, "m", httpStatusCode: 410);
|
||||
|
||||
Assert.Equal(2, client.Calls);
|
||||
Assert.Equal(AgentErrorType.RegistrationRejectedSessionGone, client.Reports[1].ErrorType);
|
||||
Assert.Equal(410, client.Reports[1].HttpStatusCode);
|
||||
|
||||
// Key dedup still applies: a second one is held back.
|
||||
await reporter.TrySendAsync(AgentErrorType.RegistrationRejectedSessionGone, "m", httpStatusCode: 410);
|
||||
Assert.Equal(2, client.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_session_unknown_report_within_the_window_is_still_suppressed()
|
||||
{
|
||||
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// ConsoleBypassHost — what goes onto the single rail: a console the watcher reports becomes a
|
||||
/// Warning <c>oobe_console_spawned</c>; starts the watcher only counts become one Info
|
||||
/// <c>console_script_activity</c> per window, capped per run, and whatever is still counted goes
|
||||
/// out on every way the host stops. The watcher is driven through its internal entry points with
|
||||
/// injected probes; no WMI subscription and no window timer runs here.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // one test runs a real SignalIngress worker
|
||||
public sealed class ConsoleBypassHostTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 11, 11, 14, 41, 35, DateTimeKind.Utc);
|
||||
private const string Cmd = "cmd.exe";
|
||||
private const string BareCmd = @"C:\WINDOWS\system32\cmd.exe";
|
||||
private const string OobeAccount = "S-1-5-21-1-2-3-1000";
|
||||
private const int ScriptHost = 2688;
|
||||
private const int GoneLauncher = 9016;
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private readonly AgentLogger _logger;
|
||||
private string? _runningCommandLine; // null = every probed process has exited
|
||||
private DateTime _now = At;
|
||||
|
||||
public ConsoleBypassHostTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private ConsoleBypassWatcher NewWatcher() => new ConsoleBypassWatcher(
|
||||
_logger,
|
||||
processProbe: (pid, name) => _runningCommandLine == null
|
||||
? null
|
||||
: new ProcessProbe(name, 1, 0, _runningCommandLine, "20261111144135.000000+060"),
|
||||
isAlive: _ => true,
|
||||
parentName: pid => pid == ScriptHost ? "powershell.exe" : null,
|
||||
utcNow: () => _now);
|
||||
|
||||
private ConsoleBypassHost Build(ConsoleBypassWatcher watcher, ISignalIngressSink? ingress = null) =>
|
||||
new ConsoleBypassHost("sess-console", "tenant-console", ingress ?? _sink, new VirtualClock(At), _logger, watcher);
|
||||
|
||||
private static void Trace(ConsoleBypassWatcher watcher, int pid, int parentPid = ScriptHost) =>
|
||||
watcher.HandleStart(pid, parentPid, 1, OobeAccount, "process_start_trace", Cmd);
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> Events(string eventType) =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType).ToList();
|
||||
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
|
||||
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
|
||||
|
||||
// ---------------------------------------------------------------- consoles
|
||||
|
||||
[Fact]
|
||||
public void A_console_is_a_warning_with_immediate_upload_and_the_name_of_its_parent()
|
||||
{
|
||||
_runningCommandLine = BareCmd;
|
||||
var watcher = NewWatcher();
|
||||
using var host = Build(watcher);
|
||||
|
||||
Trace(watcher, pid: 9636);
|
||||
|
||||
var s = Assert.Single(Events(Constants.EventTypes.OobeConsoleSpawned));
|
||||
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
var data = Data(s);
|
||||
Assert.Equal("high", data["confidence"]);
|
||||
Assert.Equal("powershell.exe", data["parentProcessName"]);
|
||||
Assert.Equal(ScriptHost, data["parentProcessId"]);
|
||||
Assert.Equal(OobeAccount, data["owner"]);
|
||||
Assert.Equal(BareCmd, data["commandLine"]);
|
||||
Assert.Empty(Events(Constants.EventTypes.ConsoleScriptActivity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_low_confidence_console_is_uploaded_at_once_too_and_names_a_gone_parent_unknown()
|
||||
{
|
||||
// The confidence follows the command line, which whoever opens the console chooses:
|
||||
// it labels the warning, it never delays it.
|
||||
_runningCommandLine = "cmd.exe /k whoami";
|
||||
var watcher = NewWatcher();
|
||||
using var host = Build(watcher);
|
||||
|
||||
Trace(watcher, pid: 9636, parentPid: GoneLauncher);
|
||||
|
||||
var s = Assert.Single(Events(Constants.EventTypes.OobeConsoleSpawned));
|
||||
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Equal("low", Data(s)["confidence"]);
|
||||
Assert.Equal("unknown", Data(s)["parentProcessName"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- script activity
|
||||
|
||||
[Fact]
|
||||
public void Counted_starts_become_one_info_line_when_the_window_ends()
|
||||
{
|
||||
var watcher = NewWatcher();
|
||||
using var host = Build(watcher);
|
||||
|
||||
Trace(watcher, pid: 5001);
|
||||
_now = At.AddSeconds(1);
|
||||
Trace(watcher, pid: 5002);
|
||||
_now = At.AddSeconds(2);
|
||||
Trace(watcher, pid: 5003);
|
||||
Assert.Empty(_sink.Posted);
|
||||
|
||||
host.WindowElapsedForTest();
|
||||
|
||||
Assert.Empty(Events(Constants.EventTypes.OobeConsoleSpawned));
|
||||
var s = Assert.Single(Events(Constants.EventTypes.ConsoleScriptActivity));
|
||||
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("false", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Equal(At.AddSeconds(2), s.OccurredAtUtc);
|
||||
Assert.Equal(
|
||||
"Script activity: 3 short-lived cmd.exe processes in session 1, started by powershell.exe — counted, not an interactive console",
|
||||
s.Payload![SignalPayloadKeys.Message]);
|
||||
|
||||
var data = Data(s);
|
||||
Assert.Equal(3, data["count"]);
|
||||
Assert.Equal(Cmd, data["processName"]);
|
||||
Assert.Equal(At.ToString("o"), data["firstSeenUtc"]);
|
||||
Assert.Equal(At.AddSeconds(2).ToString("o"), data["lastSeenUtc"]);
|
||||
Assert.Equal(new[] { 1 }, (List<int>)data["windowsSessions"]);
|
||||
Assert.Equal(new[] { OobeAccount }, (List<string>)data["owners"]);
|
||||
Assert.Equal(1, data["ownersTotal"]);
|
||||
Assert.Equal(1, data["parentsTotal"]);
|
||||
var parent = Assert.Single((List<Dictionary<string, object>>)data["parents"]);
|
||||
Assert.Equal(ScriptHost, parent["pid"]);
|
||||
Assert.Equal("powershell.exe", parent["name"]);
|
||||
Assert.Equal(3, parent["children"]);
|
||||
Assert.False(data.ContainsKey("capped"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_without_counted_starts_reports_nothing()
|
||||
{
|
||||
using var host = Build(NewWatcher());
|
||||
|
||||
host.WindowElapsedForTest();
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_window_reports_only_its_own_starts()
|
||||
{
|
||||
var watcher = NewWatcher();
|
||||
using var host = Build(watcher);
|
||||
|
||||
Trace(watcher, pid: 5001);
|
||||
host.WindowElapsedForTest();
|
||||
Trace(watcher, pid: 5002);
|
||||
Trace(watcher, pid: 5003);
|
||||
host.WindowElapsedForTest();
|
||||
|
||||
Assert.Equal(new object[] { 1, 2 }, Events(Constants.EventTypes.ConsoleScriptActivity).Select(s => Data(s)["count"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_the_cap_no_window_reports_and_the_line_at_stop_says_so()
|
||||
{
|
||||
var watcher = NewWatcher();
|
||||
var host = Build(watcher);
|
||||
|
||||
for (var i = 0; i < ConsoleBypassHost.MaxScriptActivitySummaries; i++)
|
||||
{
|
||||
Trace(watcher, pid: 5000 + i);
|
||||
host.WindowElapsedForTest();
|
||||
}
|
||||
Assert.Equal(ConsoleBypassHost.MaxScriptActivitySummaries, Events(Constants.EventTypes.ConsoleScriptActivity).Count);
|
||||
|
||||
Trace(watcher, pid: 7001);
|
||||
Trace(watcher, pid: 7002);
|
||||
host.WindowElapsedForTest();
|
||||
Assert.Equal(ConsoleBypassHost.MaxScriptActivitySummaries, Events(Constants.EventTypes.ConsoleScriptActivity).Count);
|
||||
|
||||
host.Stop();
|
||||
|
||||
var all = Events(Constants.EventTypes.ConsoleScriptActivity);
|
||||
Assert.Equal(ConsoleBypassHost.MaxScriptActivitySummaries + 1, all.Count);
|
||||
Assert.All(all.Take(ConsoleBypassHost.MaxScriptActivitySummaries), s => Assert.False(Data(s).ContainsKey("capped")));
|
||||
var last = Data(all[all.Count - 1]);
|
||||
Assert.Equal(2, last["count"]);
|
||||
Assert.Equal(true, last["capped"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- stopping
|
||||
|
||||
[Fact]
|
||||
public void Stopping_reports_what_was_counted_exactly_once()
|
||||
{
|
||||
var watcher = NewWatcher();
|
||||
var host = Build(watcher);
|
||||
Trace(watcher, pid: 5001);
|
||||
Trace(watcher, pid: 5002);
|
||||
|
||||
host.Stop();
|
||||
host.Stop();
|
||||
host.Dispose();
|
||||
|
||||
Assert.Equal(2, Data(Assert.Single(Events(Constants.EventTypes.ConsoleScriptActivity)))["count"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Desktop_arrival_reports_what_was_counted()
|
||||
{
|
||||
var watcher = NewWatcher();
|
||||
var host = Build(watcher);
|
||||
Trace(watcher, pid: 5001);
|
||||
|
||||
host.StopForDesktopArrival();
|
||||
|
||||
Assert.Single(Events(Constants.EventTypes.ConsoleScriptActivity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_stop_signal_on_the_decision_rail_reports_what_was_counted()
|
||||
{
|
||||
var clock = new VirtualClock(At);
|
||||
using var ingress = new SignalIngress(
|
||||
engine: new DecisionEngine(),
|
||||
signalLog: new SignalLogWriter(_tmp.File("signal-log.jsonl")),
|
||||
traceCounter: new SessionTraceOrdinalProvider(),
|
||||
processor: new FakeDecisionStepProcessor(),
|
||||
clock: clock);
|
||||
var postedEventTypes = new List<string>();
|
||||
ingress.SignalPosted += (kind, payload) =>
|
||||
{
|
||||
if (kind == DecisionSignalKind.InformationalEvent && payload != null
|
||||
&& payload.TryGetValue(SignalPayloadKeys.EventType, out var et))
|
||||
{
|
||||
lock (postedEventTypes) postedEventTypes.Add(et);
|
||||
}
|
||||
};
|
||||
var watcher = NewWatcher();
|
||||
var host = Build(watcher, ingress);
|
||||
ingress.Start();
|
||||
host.StartCore(armWatcher: false);
|
||||
Trace(watcher, pid: 5001);
|
||||
|
||||
ingress.Post(
|
||||
DecisionSignalKind.DesktopArrived, At, "DesktopArrivalDetector",
|
||||
new Evidence(EvidenceKind.Raw, "desktop", "evidence-desktop"));
|
||||
|
||||
Assert.True(SpinWait.SpinUntil(() => { lock (postedEventTypes) return postedEventTypes.Contains(Constants.EventTypes.ConsoleScriptActivity); }, 5000),
|
||||
"the host stopped on the rail signal without reporting what was counted");
|
||||
|
||||
Trace(watcher, pid: 5002); // the watcher is disposed: nothing is counted any more
|
||||
Assert.Null(watcher.TakeScriptActivity());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stopped_ingress_does_not_break_the_stop()
|
||||
{
|
||||
var watcher = NewWatcher();
|
||||
var host = Build(watcher);
|
||||
Trace(watcher, pid: 5001);
|
||||
_sink.ThrowOnPost = new InvalidOperationException("SignalIngress has been stopped.");
|
||||
|
||||
Assert.Null(Record.Exception(() => host.Dispose()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- timeline text
|
||||
|
||||
private static ConsoleScriptActivity Activity(int count, int[] sessions, int parentsTotal, params (int pid, string? name, int children)[] parents) =>
|
||||
new ConsoleScriptActivity(
|
||||
Cmd, count, At, At, sessions,
|
||||
new[] { OobeAccount }, 1,
|
||||
parents.Select(p => new ConsoleScriptParent(p.pid, p.name, p.children)).ToList(), parentsTotal);
|
||||
|
||||
[Fact]
|
||||
public void One_process_from_a_parent_that_is_gone()
|
||||
{
|
||||
Assert.Equal(
|
||||
"Script activity: 1 short-lived cmd.exe process in session 1, started by an unknown program — counted, not an interactive console",
|
||||
ConsoleBypassHost.DescribeScriptActivity(Activity(1, new[] { 1 }, 1, (6688, null, 1))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Several_named_parents_in_two_sessions()
|
||||
{
|
||||
Assert.Equal(
|
||||
"Script activity: 46 short-lived cmd.exe processes in sessions 1, 2, started by 2 parent processes (powershell.exe, cmd.exe) — counted, not an interactive console",
|
||||
ConsoleBypassHost.DescribeScriptActivity(Activity(46, new[] { 1, 2 }, 2, (2688, "powershell.exe", 25), (9296, "cmd.exe", 21))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void More_parents_than_names_end_in_an_ellipsis()
|
||||
{
|
||||
Assert.Equal(
|
||||
"Script activity: 9 short-lived cmd.exe processes in session 2, started by 4 parent processes (python.exe, …) — counted, not an interactive console",
|
||||
ConsoleBypassHost.DescribeScriptActivity(Activity(9, new[] { 2 }, 4, (10, "python.exe", 5), (11, null, 4))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parents_without_any_name_are_only_counted()
|
||||
{
|
||||
Assert.Equal(
|
||||
"Script activity: 3 short-lived cmd.exe processes in session 1, started by 3 parent processes — counted, not an interactive console",
|
||||
ConsoleBypassHost.DescribeScriptActivity(Activity(3, new[] { 1 }, 3, (10, null, 1), (11, null, 1), (12, null, 1))));
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
@@ -304,6 +305,64 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
Assert.Same(step.NewState, sut.CurrentState);
|
||||
}
|
||||
|
||||
// ========================================================================= Step log levels
|
||||
|
||||
[Fact]
|
||||
public void ApplyStep_logs_a_stage_change_at_info()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.Logger.SetLogLevel(AgentLogLevel.Trace);
|
||||
var sut = rig.Build();
|
||||
var (step, signal) = rig.ReduceAbort(); // SessionStarted -> Failed
|
||||
|
||||
sut.ApplyStep(step, signal);
|
||||
|
||||
Assert.Contains(StepLines(rig), l => l.Contains("[INFO] DecisionStep:") && l.Contains("stage=SessionStarted->Failed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyStep_logs_informational_pass_through_and_no_op_at_trace()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.Logger.SetLogLevel(AgentLogLevel.Trace);
|
||||
var sut = rig.Build();
|
||||
|
||||
var (noOp, noOpSignal) = rig.ReduceSessionStarted(ordinal: 0);
|
||||
sut.ApplyStep(noOp, noOpSignal);
|
||||
var (info, infoSignal) = rig.ReduceInformational(sut, ordinal: 1);
|
||||
sut.ApplyStep(info, infoSignal);
|
||||
|
||||
var lines = StepLines(rig);
|
||||
Assert.Equal(2, lines.Length);
|
||||
Assert.All(lines, l => Assert.Contains("[TRACE] DecisionStep:", l));
|
||||
Assert.Contains("(no-op)", lines[0]);
|
||||
Assert.Contains("kind=InformationalEvent", lines[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyStep_logs_informational_pass_through_with_failed_effect_at_debug()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.Logger.SetLogLevel(AgentLogLevel.Trace);
|
||||
rig.Effects.ScriptResult(new EffectRunResult(
|
||||
sessionMustAbort: false,
|
||||
abortReason: null,
|
||||
failures: new[] { new EffectFailure(DecisionEffectKind.EmitEventTimelineEntry, "emit failed", isTransient: false, exhaustedRetries: true) },
|
||||
classifierInvocations: 0,
|
||||
classifierSkippedByAntiLoop: 0));
|
||||
var sut = rig.Build();
|
||||
var (step, signal) = rig.ReduceInformational(sut, ordinal: 0);
|
||||
|
||||
sut.ApplyStep(step, signal);
|
||||
|
||||
Assert.Contains(StepLines(rig), l => l.Contains("[DEBUG] DecisionStep:") && l.Contains("failures=1"));
|
||||
}
|
||||
|
||||
private static string[] StepLines(Rig rig) =>
|
||||
System.IO.File.ReadAllLines(System.IO.Path.Combine(rig.Tmp.Path, "agent.log"))
|
||||
.Where(l => l.Contains("DecisionStep:"))
|
||||
.ToArray();
|
||||
|
||||
// ========================================================================= Argument validation
|
||||
|
||||
[Fact]
|
||||
|
||||
+27
@@ -27,6 +27,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
/// already in flight misses its wake.</item>
|
||||
/// <item>DesktopArrivalHost before AadJoinHost — the AadJoinHost ctor takes the desktop
|
||||
/// host's RequestResetForRealUserSwitch callback, so the target must exist first.</item>
|
||||
/// <item>UserEspKeepAwakeHost before RealmJoinHost — after an agent restart the RealmJoin
|
||||
/// watcher posts realmjoin_detected from its own Start; the keep-awake host hands its
|
||||
/// hold over to RealmJoin (D-413) and must already observe the rail.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class DefaultComponentFactoryOrderingTests : IDisposable
|
||||
@@ -143,6 +146,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keep_awake_host_subscribes_before_the_RealmJoin_watcher_starts()
|
||||
{
|
||||
var hosts = CreateSurfaces(new AnalyzerConfiguration
|
||||
{
|
||||
EnableRealmJoinWatcher = true,
|
||||
KeepAwakeDuringUserEsp = true,
|
||||
}).Hosts;
|
||||
try
|
||||
{
|
||||
AssertBefore<UserEspKeepAwakeHost, RealmJoinHost>(
|
||||
hosts,
|
||||
"D-413 — a realmjoin_detected posted from the watcher's Start (agent restart with " +
|
||||
"RealmJoin installed) must reach the keep-awake host, or it waits for a start it missed.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
try { host.Dispose(); } catch { /* construction-only test — best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, 0)]
|
||||
[InlineData(true, 1)]
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a call that never returns is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Stopping the collector hosts is bounded per host: termination reaches the diagnostics
|
||||
/// upload and the cleanup whatever a collector does. A host that hangs is left behind, named
|
||||
/// in one <c>agent_trace</c>, and never called again; every other host is stopped and disposed
|
||||
/// as before.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")]
|
||||
public sealed class EnrollmentOrchestratorCollectorStopTests
|
||||
{
|
||||
private static readonly TimeSpan Bound = TimeSpan.FromMilliseconds(400);
|
||||
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(10);
|
||||
|
||||
private sealed class Host : ICollectorHost
|
||||
{
|
||||
private readonly List<string> _order;
|
||||
private readonly ManualResetEventSlim? _hangUntil;
|
||||
private readonly Exception? _throwOnStop;
|
||||
private int _stopCalls;
|
||||
private int _disposeCalls;
|
||||
|
||||
public Host(string name, List<string> order, ManualResetEventSlim? hangUntil = null, Exception? throwOnStop = null)
|
||||
{
|
||||
Name = name;
|
||||
_order = order;
|
||||
_hangUntil = hangUntil;
|
||||
_throwOnStop = throwOnStop;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public int StopCalls => Volatile.Read(ref _stopCalls);
|
||||
public int DisposeCalls => Volatile.Read(ref _disposeCalls);
|
||||
|
||||
public void Start() { }
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Interlocked.Increment(ref _stopCalls);
|
||||
lock (_order) _order.Add(Name);
|
||||
if (_throwOnStop != null) throw _throwOnStop;
|
||||
_hangUntil?.Wait();
|
||||
}
|
||||
|
||||
public void Dispose() => Interlocked.Increment(ref _disposeCalls);
|
||||
}
|
||||
|
||||
private sealed class Factory : IComponentFactory
|
||||
{
|
||||
private readonly IReadOnlyList<ICollectorHost> _hosts;
|
||||
public Factory(IReadOnlyList<ICollectorHost> hosts) { _hosts = hosts; }
|
||||
|
||||
public CollectorSurfaces CreateCollectorHosts(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
Func<DecisionState?>? decisionStateProbe = null) => new CollectorSurfaces(_hosts);
|
||||
}
|
||||
|
||||
private static JObject? StopTimeoutEvent(EnrollmentOrchestrator sut) =>
|
||||
sut.SpoolForTests!.Peek(1000)
|
||||
.Where(i => i.Kind == TelemetryItemKind.Event && i.PayloadJson.Contains("collector_host_stop_timeout"))
|
||||
.Select(i => JObject.Parse(i.PayloadJson))
|
||||
.FirstOrDefault();
|
||||
|
||||
[Fact]
|
||||
public void A_host_that_hangs_in_Stop_is_left_behind_and_the_others_are_still_stopped()
|
||||
{
|
||||
using var rig = new EnrollmentOrchestratorRig();
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
var order = new List<string>();
|
||||
var before = new Host("Before", order);
|
||||
var hanging = new Host("Hanging", order, hangUntil: release);
|
||||
var after = new Host("After", order);
|
||||
var sut = rig.Build(
|
||||
componentFactory: new Factory(new ICollectorHost[] { before, hanging, after }),
|
||||
collectorHostStopTimeout: Bound);
|
||||
sut.Start();
|
||||
|
||||
try
|
||||
{
|
||||
var stop = Task.Run(() => sut.StopCollectorHosts());
|
||||
|
||||
Assert.True(stop.Wait(Wait), "StopCollectorHosts never returned although one host hangs");
|
||||
Assert.Equal(new[] { "Before", "Hanging", "After" }, order.ToArray());
|
||||
|
||||
JObject? evt = null;
|
||||
Assert.True(SpinWait.SpinUntil(() => (evt = StopTimeoutEvent(sut)) != null, Wait),
|
||||
"the hanging host was not reported");
|
||||
Assert.Equal("agent_trace", (string?)evt!["EventType"]);
|
||||
var data = (JObject)evt["Data"]!;
|
||||
Assert.Equal("collector_host_stop_timeout", (string?)data["decision"]);
|
||||
Assert.Equal("Hanging", (string?)data["host"]);
|
||||
Assert.Equal(1, (int)data["timeoutSeconds"]!);
|
||||
|
||||
// Stop() afterwards: the stuck host is not touched again, the others are disposed.
|
||||
var fullStop = Task.Run(() => sut.Stop());
|
||||
Assert.True(fullStop.Wait(Wait), "Stop never returned");
|
||||
Assert.Equal(1, before.DisposeCalls);
|
||||
Assert.Equal(0, hanging.DisposeCalls);
|
||||
Assert.Equal(1, after.DisposeCalls);
|
||||
Assert.Equal(1, hanging.StopCalls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Healthy_hosts_are_stopped_and_disposed_in_order_without_a_report()
|
||||
{
|
||||
using var rig = new EnrollmentOrchestratorRig();
|
||||
var order = new List<string>();
|
||||
var first = new Host("First", order);
|
||||
var throwing = new Host("Throwing", order, throwOnStop: new InvalidOperationException("boom"));
|
||||
var last = new Host("Last", order);
|
||||
var hosts = new[] { first, throwing, last };
|
||||
var sut = rig.Build(componentFactory: new Factory(hosts)); // default bound: nothing here comes near it
|
||||
sut.Start();
|
||||
|
||||
sut.StopCollectorHosts();
|
||||
sut.StopCollectorHosts(); // idempotent
|
||||
|
||||
Assert.Equal(new[] { "First", "Throwing", "Last" }, order.ToArray());
|
||||
Assert.All(hosts, h => Assert.Equal(1, h.StopCalls));
|
||||
|
||||
Thread.Sleep(200);
|
||||
Assert.Null(StopTimeoutEvent(sut));
|
||||
|
||||
sut.Stop();
|
||||
Assert.All(hosts, h => Assert.Equal(1, h.DisposeCalls));
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="OfficeInstallDetectorHost"/> with its real watchers: the registry watcher on a
|
||||
/// key under HKCU, the binary watcher on a temp folder, snapshots injected. Stopping the host
|
||||
/// must never wait for a watcher callback, and a completion that coincides with a registry
|
||||
/// callback must not leave the host unable to stop.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // registry + filesystem callbacks arrive on the shared ThreadPool
|
||||
public sealed class OfficeInstallDetectorHostTests : IDisposable
|
||||
{
|
||||
private const string RegistrySubKey = @"SOFTWARE\AutopilotMonitor.Tests\OfficeInstallDetectorHost";
|
||||
private static readonly DateTime At = new DateTime(2026, 11, 10, 9, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private readonly string _installPath;
|
||||
private Func<OfficeC2RSnapshot> _snapshot;
|
||||
|
||||
public OfficeInstallDetectorHostTests()
|
||||
{
|
||||
DeleteTestKey();
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(RegistrySubKey)) Assert.NotNull(key);
|
||||
_installPath = Path.Combine(_tmp.Path, "Office");
|
||||
Directory.CreateDirectory(_installPath);
|
||||
_snapshot = Idle;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DeleteTestKey();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private static void DeleteTestKey()
|
||||
{
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(RegistrySubKey, throwOnMissingSubKey: false); }
|
||||
catch { /* teardown noise must not fail a test */ }
|
||||
}
|
||||
|
||||
private static void TouchRegistry()
|
||||
{
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(RegistrySubKey, writable: true))
|
||||
{
|
||||
Assert.NotNull(key);
|
||||
key!.SetValue("Probe", Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
}
|
||||
|
||||
private OfficeInstallDetectorHost NewHost() =>
|
||||
new OfficeInstallDetectorHost(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
ingress: _sink,
|
||||
clock: new VirtualClock(At),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
settleSeconds: 0,
|
||||
statePersistence: null,
|
||||
resumeState: null,
|
||||
registryHive: RegistryHive.CurrentUser,
|
||||
registrySubKey: RegistrySubKey,
|
||||
snapshotProvider: () => _snapshot());
|
||||
|
||||
// No install scenario, no install path: the detector stays idle and the host keeps
|
||||
// raising OfficeExpected on registry changes.
|
||||
private static OfficeC2RSnapshot Idle() => new OfficeC2RSnapshot { ConfigurationKeyPresent = true };
|
||||
|
||||
private OfficeC2RSnapshot Installing()
|
||||
{
|
||||
var s = new OfficeC2RSnapshot
|
||||
{
|
||||
ConfigurationKeyPresent = true,
|
||||
ActiveScenarioPresent = true,
|
||||
ActiveScenarioName = "INSTALL",
|
||||
InstallScenarioPresent = true,
|
||||
InstallScenarioName = "INSTALL",
|
||||
InstallationPath = _installPath,
|
||||
VersionToReport = "16.0.1.1",
|
||||
};
|
||||
s.Products.Add("O365ProPlusRetail");
|
||||
return s;
|
||||
}
|
||||
|
||||
private int OfficeEvents(string eventType) => _sink.Posted.Count(p =>
|
||||
p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType);
|
||||
|
||||
[Fact]
|
||||
public void Stop_returns_while_a_registry_handler_is_running()
|
||||
{
|
||||
var host = NewHost();
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
host.OfficeExpected += (_, __) => { entered.Set(); release.Wait(Timeout); };
|
||||
|
||||
try
|
||||
{
|
||||
host.Start();
|
||||
TouchRegistry();
|
||||
Assert.True(entered.Wait(Timeout), "the registry change did not reach the host");
|
||||
|
||||
var stop = Task.Run(() => host.Stop());
|
||||
|
||||
Assert.True(stop.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Stop waited for the running registry handler");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_completion_during_a_running_registry_handler_does_not_block_Stop()
|
||||
{
|
||||
var host = NewHost();
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
host.OfficeExpected += (_, __) => { entered.Set(); release.Wait(Timeout); };
|
||||
|
||||
try
|
||||
{
|
||||
// 1) A registry callback is in flight (parked in the host's OfficeExpected handler).
|
||||
host.Start();
|
||||
TouchRegistry();
|
||||
Assert.True(entered.Wait(Timeout), "the registry change did not reach the host");
|
||||
|
||||
// 2) The install starts and completes meanwhile: the binary watcher sees the core
|
||||
// binary, the detector reports completed, the host tears its watchers down.
|
||||
_snapshot = Installing;
|
||||
host.SubmitDoSample(new OfficeDoSample { JobCount = 1, FileSize = 100, TotalBytesDownloaded = 10 });
|
||||
Assert.Equal(1, OfficeEvents(Constants.EventTypes.OfficeInstallStarted));
|
||||
|
||||
var versionDir = Path.Combine(_installPath, "root", "Office16");
|
||||
Directory.CreateDirectory(versionDir);
|
||||
File.WriteAllText(Path.Combine(versionDir, "EXCEL.EXE"), "stub");
|
||||
Assert.True(
|
||||
SpinWait.SpinUntil(() => OfficeEvents(Constants.EventTypes.OfficeInstallCompleted) == 1, Timeout),
|
||||
"the install was not reported as completed");
|
||||
|
||||
// 3) The agent stops while that registry callback is still running.
|
||||
var stop = Task.Run(() => host.Stop());
|
||||
|
||||
Assert.True(stop.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Stop waited behind the completion's teardown, which waited for the running registry handler");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
Assert.Equal(1, OfficeEvents(Constants.EventTypes.OfficeInstallCompleted));
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>The watchers only listen on the real keys; settings are played in, none is changed.</summary>
|
||||
public sealed class ProxySettingsHostTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _ingress = new FakeSignalIngressSink();
|
||||
private readonly VirtualClock _clock = new VirtualClock(new DateTime(2026, 11, 2, 6, 53, 43, DateTimeKind.Utc));
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly ProxyObserver _observer;
|
||||
private ProxySettings _settings = new ProxySettings(true, null, null, null, null);
|
||||
private string _route = "proxy.example.test:8080";
|
||||
|
||||
public ProxySettingsHostTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_observer = new ProxyObserver(() => _settings, () => _route, _logger, _clock, TimeSpan.FromSeconds(30));
|
||||
_observer.CaptureBaseline();
|
||||
}
|
||||
|
||||
private ProxySettingsHost NewHost() => new ProxySettingsHost(_observer, _ingress, _clock, _logger);
|
||||
|
||||
private void SwitchToNoProxy()
|
||||
{
|
||||
_settings = new ProxySettings(false, null, null, null, null);
|
||||
_route = "direct";
|
||||
}
|
||||
|
||||
private List<FakeSignalIngressSink.PostedSignal> ProxyEvents() =>
|
||||
_ingress.Posted
|
||||
.Where(p => p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)
|
||||
&& eventType == Constants.EventTypes.ProxyConfiguration)
|
||||
.ToList();
|
||||
|
||||
private string Log()
|
||||
{
|
||||
var text = string.Empty;
|
||||
foreach (var file in Directory.EnumerateFiles(_tmp.Path, "*.log", SearchOption.AllDirectories))
|
||||
{
|
||||
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream);
|
||||
text += reader.ReadToEnd();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
[Fact]
|
||||
public void A_change_goes_on_the_timeline_as_a_warning_with_what_moved()
|
||||
{
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
SwitchToNoProxy();
|
||||
|
||||
_observer.CheckForChange();
|
||||
|
||||
var signal = Assert.Single(ProxyEvents());
|
||||
Assert.Equal(nameof(EventSeverity.Warning), signal.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("Proxy settings changed: IE settings of this account: auto-detect -> no proxy", signal.Payload[SignalPayloadKeys.Message]);
|
||||
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(signal.TypedPayload);
|
||||
Assert.Equal("changed", data["trigger"]);
|
||||
Assert.Equal("IE settings of this account: auto-detect -> no proxy", data["change"]);
|
||||
Assert.Equal("direct", data["route"]);
|
||||
Assert.Equal("Direct", data["proxyType"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_between_the_baseline_and_the_start_is_noticed_at_the_start()
|
||||
{
|
||||
SwitchToNoProxy();
|
||||
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
|
||||
Assert.Single(ProxyEvents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_change_means_no_event()
|
||||
{
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
|
||||
_observer.CheckForChange();
|
||||
|
||||
Assert.Empty(ProxyEvents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stopped_ingress_does_not_reach_the_upload_path()
|
||||
{
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
_ingress.ThrowOnPost = new InvalidOperationException("SignalIngress has been stopped.");
|
||||
SwitchToNoProxy();
|
||||
|
||||
_observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Contains("uploads are failing", Log());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopping_while_uploads_fail_says_so_once()
|
||||
{
|
||||
var host = NewHost();
|
||||
host.Start();
|
||||
_observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
host.Stop();
|
||||
host.Stop();
|
||||
host.Dispose();
|
||||
|
||||
var log = Log();
|
||||
var first = log.IndexOf("stopping with uploads still failing", StringComparison.Ordinal);
|
||||
Assert.True(first >= 0);
|
||||
Assert.Equal(-1, log.IndexOf("stopping with uploads still failing", first + 1, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_after_the_stop_no_longer_posts()
|
||||
{
|
||||
var host = NewHost();
|
||||
host.Start();
|
||||
host.Stop();
|
||||
SwitchToNoProxy();
|
||||
|
||||
_observer.CheckForChange();
|
||||
|
||||
Assert.Empty(ProxyEvents());
|
||||
host.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_host_that_never_started_stays_silent_on_stop()
|
||||
{
|
||||
var host = NewHost();
|
||||
_observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
host.Dispose();
|
||||
|
||||
Assert.DoesNotContain("stopping with uploads still failing", Log());
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -43,6 +43,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
[InlineData("network_connectivity_check")]
|
||||
// Bookkeeping of health-script runs that were deliberately not reported.
|
||||
[InlineData("script_recurrence_summary")]
|
||||
// Counted short-lived console processes — a spawning script is not enrollment progress.
|
||||
[InlineData("console_script_activity")]
|
||||
// Agent health / control / transport — not device/enrollment progress (P2).
|
||||
[InlineData("collector_degraded")]
|
||||
[InlineData("telemetry_upload_poisoned")]
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="TimelineEventStream.Publish"/> isolates subscribers: a throwing one must neither
|
||||
/// break the emit path nor starve the subscribers after it, and its fault must reach the log
|
||||
/// (Debug once, an identical repeat on Trace).
|
||||
/// </summary>
|
||||
public sealed class TimelineEventStreamTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_throwing_subscriber_is_logged_and_the_next_one_still_runs()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Trace);
|
||||
var sut = new TimelineEventStream(logger);
|
||||
var delivered = 0;
|
||||
sut.EventEmitted += ThrowingSubscriber;
|
||||
sut.EventEmitted += (_, _, _) => delivered++;
|
||||
|
||||
sut.Publish("app_install_started", EnrollmentPhase.Unknown, "ImeLogTracker");
|
||||
sut.Publish("app_install_completed", EnrollmentPhase.Unknown, "ImeLogTracker");
|
||||
|
||||
Assert.Equal(2, delivered);
|
||||
var lines = File.ReadAllLines(Path.Combine(tmp.Path, "agent.log"))
|
||||
.Where(l => l.Contains("TimelineEventStream: subscriber"))
|
||||
.ToArray();
|
||||
Assert.Equal(2, lines.Length);
|
||||
Assert.Contains("[DEBUG]", lines[0]);
|
||||
Assert.Contains("ThrowingSubscriber threw: subscriber bug", lines[0]);
|
||||
Assert.Contains("[TRACE]", lines[1]);
|
||||
}
|
||||
|
||||
private static void ThrowingSubscriber(string eventType, EnrollmentPhase phase, string source) =>
|
||||
throw new InvalidOperationException("subscriber bug");
|
||||
}
|
||||
}
|
||||
+362
-2
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
using Xunit;
|
||||
|
||||
// ManualResetEventSlim.Wait inside the controller is driven deterministically here; SpinUntil is
|
||||
@@ -52,10 +53,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
|
||||
private AgentLogger NewLogger() => new AgentLogger(_tmp.Path);
|
||||
|
||||
private SignalIngress BuildIngress(VirtualClock clock) =>
|
||||
private SignalIngress BuildIngress(VirtualClock clock, string signalLogName = "signal-log.jsonl") =>
|
||||
new SignalIngress(
|
||||
engine: new DecisionEngine(),
|
||||
signalLog: new SignalLogWriter(_tmp.File("signal-log.jsonl")),
|
||||
signalLog: new SignalLogWriter(_tmp.File(signalLogName)),
|
||||
traceCounter: new SessionTraceOrdinalProvider(),
|
||||
processor: new FakeDecisionStepProcessor(),
|
||||
clock: clock);
|
||||
@@ -300,6 +301,365 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
// -------- RealmJoin hand-over (D-413) --------
|
||||
|
||||
/// <summary>Records keep_awake_released payloads from the rail (posted on ThreadPool threads).</summary>
|
||||
private sealed class ReleaseRecorder
|
||||
{
|
||||
private readonly object _lock = new object();
|
||||
private readonly List<Dictionary<string, string>> _released = new List<Dictionary<string, string>>();
|
||||
|
||||
public ReleaseRecorder(SignalIngress ing) => ing.SignalPosted += OnPosted;
|
||||
|
||||
private void OnPosted(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
if (kind != DecisionSignalKind.InformationalEvent || payload == null) return;
|
||||
if (!payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)
|
||||
|| eventType != SharedEventTypes.KeepAwakeReleased) return;
|
||||
var copy = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var kv in payload) copy[kv.Key] = kv.Value;
|
||||
lock (_lock) { _released.Add(copy); }
|
||||
}
|
||||
|
||||
public List<Dictionary<string, string>> Snapshot()
|
||||
{
|
||||
lock (_lock) { return new List<Dictionary<string, string>>(_released); }
|
||||
}
|
||||
}
|
||||
|
||||
private static void PostRealmJoin(SignalIngress ing, DecisionSignalKind kind, int? phase)
|
||||
{
|
||||
var payload = new Dictionary<string, string>();
|
||||
if (phase.HasValue) payload[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = phase.Value.ToString();
|
||||
ing.Post(kind, At, "RealmJoinWatcher", Raw("rj"), payload: payload);
|
||||
}
|
||||
|
||||
private UserEspKeepAwakeHost NewHost(
|
||||
SignalIngress ing,
|
||||
VirtualClock clock,
|
||||
KeepAwakeController controller,
|
||||
Func<bool>? progress = null,
|
||||
TimeSpan? safetyCap = null,
|
||||
TimeSpan? realmJoinWait = null,
|
||||
StartupEventGate? gate = null) =>
|
||||
new UserEspKeepAwakeHost(ing, clock, NewLogger(),
|
||||
controller: controller,
|
||||
safetyCapOverride: safetyCap,
|
||||
espTimeoutProvider: () => null,
|
||||
accountSetupProgressProbe: progress ?? (() => true),
|
||||
realmJoinStartWaitOverride: realmJoinWait,
|
||||
startupGate: gate);
|
||||
|
||||
[Fact]
|
||||
public void Page_exit_with_RealmJoin_still_Blank_holds_until_RealmJoin_starts()
|
||||
{
|
||||
// The common RealmJoin shape: detected during the ESP, deployment starts after the
|
||||
// user-ESP page closed. The gap in between is what the hold now covers.
|
||||
var clock = new VirtualClock(At);
|
||||
using var ing = BuildIngress(clock);
|
||||
var recorder = new ReleaseRecorder(ing);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = NewHost(ing, clock, controller, realmJoinWait: TimeSpan.FromMinutes(5));
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
PostAccountSetupPhase(ing);
|
||||
Assert.True(WaitFor(() => api.PreventCount == 1));
|
||||
PostRealmJoin(ing, DecisionSignalKind.RealmJoinDetected, 0);
|
||||
|
||||
PostEspExiting(ing);
|
||||
Assert.True(WaitFor(() => host.AwaitingRealmJoinForTest), "the page exit must start the RealmJoin wait");
|
||||
PostEspExiting(ing); // a repeated exit changes nothing
|
||||
Assert.False(WaitFor(() => api.AllowCount > 0, 500), "the hold must outlive the page exit while RealmJoin is Blank");
|
||||
Assert.True(controller.IsEngaged);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(90));
|
||||
PostRealmJoin(ing, DecisionSignalKind.RealmJoinPhaseChanged, 101);
|
||||
|
||||
Assert.True(WaitFor(() => api.AllowCount == 1), "RealmJoin leaving Blank must release the hold");
|
||||
Assert.True(WaitFor(() => recorder.Snapshot().Count == 1));
|
||||
var released = recorder.Snapshot()[0];
|
||||
Assert.Equal(UserEspKeepAwakeHost.ReasonRealmJoinStarted, released["reason"]);
|
||||
Assert.Equal("90", released["waitedSeconds"]);
|
||||
|
||||
host.Dispose();
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealmJoin_wait_bound_releases_when_RealmJoin_never_starts()
|
||||
{
|
||||
// RealmJoin may never leave Blank in the session — the wait is bounded.
|
||||
var clock = new VirtualClock(At);
|
||||
using var ing = BuildIngress(clock);
|
||||
var recorder = new ReleaseRecorder(ing);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = NewHost(ing, clock, controller, realmJoinWait: TimeSpan.FromMilliseconds(150));
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
PostAccountSetupPhase(ing);
|
||||
Assert.True(WaitFor(() => api.PreventCount == 1));
|
||||
PostRealmJoin(ing, DecisionSignalKind.RealmJoinDetected, 0);
|
||||
PostEspExiting(ing);
|
||||
|
||||
Assert.True(WaitFor(() => api.AllowCount == 1), "the RealmJoin wait must end the hold");
|
||||
Assert.True(WaitFor(() => recorder.Snapshot().Count == 1));
|
||||
var released = recorder.Snapshot()[0];
|
||||
Assert.Equal(UserEspKeepAwakeHost.ReasonRealmJoinNotStarted, released["reason"]);
|
||||
Assert.True(released.ContainsKey("waitMinutes"));
|
||||
|
||||
host.Dispose();
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_exit_replaces_the_ESP_safety_cap_with_the_RealmJoin_wait()
|
||||
{
|
||||
// One timer, one bound per phase of the hold: once the page closed, the ESP-sized cap
|
||||
// must not end the RealmJoin wait.
|
||||
var clock = new VirtualClock(At);
|
||||
using var ing = BuildIngress(clock);
|
||||
var recorder = new ReleaseRecorder(ing);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = NewHost(ing, clock, controller,
|
||||
safetyCap: TimeSpan.FromSeconds(1), realmJoinWait: TimeSpan.FromMinutes(5));
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
PostRealmJoin(ing, DecisionSignalKind.RealmJoinDetected, 0);
|
||||
PostAccountSetupPhase(ing);
|
||||
Assert.True(WaitFor(() => api.PreventCount == 1));
|
||||
PostEspExiting(ing);
|
||||
Assert.True(WaitFor(() => host.AwaitingRealmJoinForTest));
|
||||
|
||||
Assert.False(WaitFor(() => api.AllowCount > 0, 1500), "the superseded ESP cap must not fire");
|
||||
|
||||
PostRealmJoin(ing, DecisionSignalKind.RealmJoinPhaseChanged, 101);
|
||||
Assert.True(WaitFor(() => api.AllowCount == 1));
|
||||
Assert.True(WaitFor(() => recorder.Snapshot().Count == 1));
|
||||
Assert.Equal(UserEspKeepAwakeHost.ReasonRealmJoinStarted, recorder.Snapshot()[0]["reason"]);
|
||||
|
||||
host.Dispose();
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_exit_without_RealmJoin_releases_as_before()
|
||||
{
|
||||
var clock = new VirtualClock(At);
|
||||
using var ing = BuildIngress(clock);
|
||||
var recorder = new ReleaseRecorder(ing);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = NewHost(ing, clock, controller, realmJoinWait: TimeSpan.FromMinutes(5));
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
PostAccountSetupPhase(ing);
|
||||
Assert.True(WaitFor(() => api.PreventCount == 1));
|
||||
PostEspExiting(ing);
|
||||
|
||||
Assert.True(WaitFor(() => api.AllowCount == 1));
|
||||
Assert.True(WaitFor(() => recorder.Snapshot().Count == 1));
|
||||
Assert.Equal(UserEspKeepAwakeHost.ReasonUserEspExited, recorder.Snapshot()[0]["reason"]);
|
||||
Assert.False(host.AwaitingRealmJoinForTest);
|
||||
|
||||
host.Dispose();
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DecisionSignalKind.RealmJoinPhaseChanged, 100)]
|
||||
[InlineData(DecisionSignalKind.RealmJoinPhaseChanged, 210)]
|
||||
[InlineData(DecisionSignalKind.RealmJoinResolved, null)]
|
||||
public void RealmJoin_leaving_Blank_before_the_page_exit_releases_the_hold(DecisionSignalKind kind, int? phase)
|
||||
{
|
||||
// RealmJoin can start shortly before the page closes; it keeps the device awake from
|
||||
// there. Resolved carries no phase and still means "left Blank".
|
||||
var clock = new VirtualClock(At);
|
||||
using var ing = BuildIngress(clock);
|
||||
var recorder = new ReleaseRecorder(ing);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = NewHost(ing, clock, controller, realmJoinWait: TimeSpan.FromMinutes(5));
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
PostAccountSetupPhase(ing);
|
||||
Assert.True(WaitFor(() => api.PreventCount == 1));
|
||||
PostRealmJoin(ing, DecisionSignalKind.RealmJoinDetected, 0);
|
||||
Assert.False(WaitFor(() => api.AllowCount > 0, 300), "detection alone must not release");
|
||||
|
||||
PostRealmJoin(ing, kind, phase);
|
||||
|
||||
Assert.True(WaitFor(() => api.AllowCount == 1));
|
||||
Assert.True(WaitFor(() => recorder.Snapshot().Count == 1));
|
||||
var released = recorder.Snapshot()[0];
|
||||
Assert.Equal(UserEspKeepAwakeHost.ReasonRealmJoinStarted, released["reason"]);
|
||||
Assert.False(released.ContainsKey("waitedSeconds"));
|
||||
|
||||
host.Dispose();
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealmJoin_already_deploying_before_AccountSetup_prevents_the_engage()
|
||||
{
|
||||
// Agent restart: the watcher reports the running phase from its start, and the
|
||||
// AccountSetup phase is posted again afterwards — there is nothing to hand over.
|
||||
var clock = new VirtualClock(At);
|
||||
using var ing = BuildIngress(clock);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = NewHost(ing, clock, controller, realmJoinWait: TimeSpan.FromMinutes(5));
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
PostRealmJoin(ing, DecisionSignalKind.RealmJoinDetected, 101);
|
||||
PostAccountSetupPhase(ing);
|
||||
|
||||
Assert.False(WaitFor(() => api.PreventCount > 0, 500), "no hold once RealmJoin deploys");
|
||||
Assert.False(controller.IsEngaged);
|
||||
|
||||
host.Dispose();
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
// -------- Page-closed latch across agent restarts (D-414) --------
|
||||
|
||||
/// <summary>
|
||||
/// One agent run: its own ingress and host on the shared state folder. A later run is a new
|
||||
/// <see cref="StartupEventGate"/> loading the same folder, exactly what a restart does.
|
||||
/// </summary>
|
||||
private sealed class AgentRun : IDisposable
|
||||
{
|
||||
public SignalIngress Ingress { get; }
|
||||
public FakeKeepAwakeApi Api { get; } = new FakeKeepAwakeApi();
|
||||
public KeepAwakeController Controller { get; }
|
||||
public StartupEventGate Gate { get; }
|
||||
public UserEspKeepAwakeHost Host { get; }
|
||||
|
||||
public AgentRun(UserEspKeepAwakeHostTests t, string stateDir, string runName, Func<bool> progress)
|
||||
{
|
||||
var clock = new VirtualClock(At);
|
||||
Ingress = t.BuildIngress(clock, $"signal-log-{runName}.jsonl");
|
||||
Controller = new KeepAwakeController(t.NewLogger(), Api);
|
||||
Gate = new StartupEventGate(stateDir, t.NewLogger());
|
||||
Host = t.NewHost(Ingress, clock, Controller, progress: progress,
|
||||
realmJoinWait: TimeSpan.FromMinutes(5), gate: Gate);
|
||||
Ingress.Start();
|
||||
Host.Start();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Host.Dispose();
|
||||
Ingress.Stop();
|
||||
Ingress.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closed_page_keeps_a_later_agent_run_from_engaging()
|
||||
{
|
||||
// After an agent restart the AccountSetup phase reaches the rail again (IME logs it at
|
||||
// the next sign-in, the adapter's dedup lives per process). The page is gone, so no hold.
|
||||
using var stateDir = new TempDirectory();
|
||||
using (var first = new AgentRun(this, stateDir.Path, "1", () => true))
|
||||
{
|
||||
PostAccountSetupPhase(first.Ingress);
|
||||
Assert.True(WaitFor(() => first.Api.PreventCount == 1));
|
||||
PostEspExiting(first.Ingress);
|
||||
Assert.True(WaitFor(() => first.Api.AllowCount == 1));
|
||||
Assert.True(WaitFor(() => first.Gate.AlreadySucceeded(UserEspKeepAwakeHost.UserEspPageClosedKey)));
|
||||
}
|
||||
|
||||
using var second = new AgentRun(this, stateDir.Path, "2", () => true);
|
||||
PostAccountSetupPhase(second.Ingress);
|
||||
|
||||
Assert.False(WaitFor(() => second.Api.PreventCount > 0, 500), "the page already closed in this enrollment");
|
||||
Assert.False(second.Controller.IsEngaged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Restart_before_the_page_closed_engages_again()
|
||||
{
|
||||
// A reboot in the middle of the user ESP: the page is still to come, so the next run holds.
|
||||
using var stateDir = new TempDirectory();
|
||||
using (var first = new AgentRun(this, stateDir.Path, "1", () => true))
|
||||
{
|
||||
PostAccountSetupPhase(first.Ingress);
|
||||
Assert.True(WaitFor(() => first.Api.PreventCount == 1));
|
||||
}
|
||||
|
||||
using var second = new AgentRun(this, stateDir.Path, "2", () => true);
|
||||
PostAccountSetupPhase(second.Ingress);
|
||||
|
||||
Assert.True(WaitFor(() => second.Api.PreventCount == 1), "the user ESP is still running");
|
||||
Assert.True(second.Controller.IsEngaged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handoff_exit_without_AccountSetup_progress_sets_no_latch()
|
||||
{
|
||||
using var stateDir = new TempDirectory();
|
||||
using (var first = new AgentRun(this, stateDir.Path, "1", () => false))
|
||||
{
|
||||
PostAccountSetupPhase(first.Ingress);
|
||||
Assert.True(WaitFor(() => first.Api.PreventCount == 1));
|
||||
PostEspExiting(first.Ingress); // the Device→Account handoff
|
||||
Assert.False(WaitFor(() => first.Api.AllowCount > 0, 300));
|
||||
Assert.False(first.Gate.AlreadySucceeded(UserEspKeepAwakeHost.UserEspPageClosedKey));
|
||||
}
|
||||
|
||||
using var second = new AgentRun(this, stateDir.Path, "2", () => true);
|
||||
PostAccountSetupPhase(second.Ingress);
|
||||
|
||||
Assert.True(WaitFor(() => second.Api.PreventCount == 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_exit_while_waiting_for_RealmJoin_is_latched()
|
||||
{
|
||||
// The hold outlived the page while RealmJoin was Blank, then the agent restarted: after
|
||||
// the reboot RealmJoin needs the next sign-in anyway, so the next run does not hold.
|
||||
using var stateDir = new TempDirectory();
|
||||
using (var first = new AgentRun(this, stateDir.Path, "1", () => true))
|
||||
{
|
||||
PostRealmJoin(first.Ingress, DecisionSignalKind.RealmJoinDetected, 0);
|
||||
PostAccountSetupPhase(first.Ingress);
|
||||
Assert.True(WaitFor(() => first.Api.PreventCount == 1));
|
||||
PostEspExiting(first.Ingress);
|
||||
Assert.True(WaitFor(() => first.Host.AwaitingRealmJoinForTest));
|
||||
Assert.True(WaitFor(() => first.Gate.AlreadySucceeded(UserEspKeepAwakeHost.UserEspPageClosedKey)));
|
||||
}
|
||||
|
||||
using var second = new AgentRun(this, stateDir.Path, "2", () => true);
|
||||
PostRealmJoin(second.Ingress, DecisionSignalKind.RealmJoinDetected, 0);
|
||||
PostAccountSetupPhase(second.Ingress);
|
||||
|
||||
Assert.False(WaitFor(() => second.Api.PreventCount > 0, 500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_exit_seen_while_idle_is_latched_and_blocks_a_later_engage()
|
||||
{
|
||||
// The exit counts in every hold state — here the host never engaged before it.
|
||||
using var stateDir = new TempDirectory();
|
||||
using var run = new AgentRun(this, stateDir.Path, "1", () => true);
|
||||
|
||||
PostEspExiting(run.Ingress);
|
||||
Assert.True(WaitFor(() => run.Gate.AlreadySucceeded(UserEspKeepAwakeHost.UserEspPageClosedKey)),
|
||||
"an exit seen while idle must still be latched");
|
||||
|
||||
PostAccountSetupPhase(run.Ingress);
|
||||
Assert.False(WaitFor(() => run.Api.PreventCount > 0, 500));
|
||||
}
|
||||
|
||||
// -------- KeepAwakeController: late-confirm (ready-timeout) edge --------
|
||||
|
||||
[Fact]
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Runtime;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
using ProgramAlias = AutopilotMonitor.Agent.V2.Program;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// A registration the backend answered with HTTP 410 at process start: the session was deleted.
|
||||
/// Exactly one <see cref="AgentErrorType.RegistrationRejectedSessionGone"/> report, then the
|
||||
/// start-time end of monitoring with a forced cleanup — the same end state as the emergency
|
||||
/// break and the kill signal. The WhiteGlove Part 2 start that registers a new session instead
|
||||
/// is covered by <c>SessionRegistrationHelperTests</c>; here only its trace on <c>agent_started</c>.
|
||||
/// </summary>
|
||||
public sealed class SessionGoneAtRegistrationTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly SessionIdPersistence _persistence;
|
||||
private readonly string _stateDir;
|
||||
private readonly List<string> _sequence = new List<string>();
|
||||
|
||||
public SessionGoneAtRegistrationTests()
|
||||
{
|
||||
_logger = new AgentLogger(Path.Combine(_tmp.Path, "logs"), AgentLogLevel.Info);
|
||||
_persistence = new SessionIdPersistence(_tmp.Path);
|
||||
_persistence.GetOrCreate(_logger);
|
||||
_stateDir = Path.Combine(_tmp.Path, "State");
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private sealed class RecordingEmergencyReporter : EmergencyReporter
|
||||
{
|
||||
private readonly List<string> _sequence;
|
||||
public List<(AgentErrorType ErrorType, string Message, int? HttpStatusCode, int Attempts)> Reports { get; } =
|
||||
new List<(AgentErrorType, string, int?, int)>();
|
||||
|
||||
public RecordingEmergencyReporter(List<string> sequence)
|
||||
: base(apiClient: null!, sessionId: "S1", tenantId: "T1", agentVersion: "0.0.0", logger: null!)
|
||||
{
|
||||
_sequence = sequence;
|
||||
}
|
||||
|
||||
public override Task TrySendAsync(
|
||||
AgentErrorType errorType,
|
||||
string message,
|
||||
int? httpStatusCode = null,
|
||||
long? sequenceNumber = null,
|
||||
int attempts = 1,
|
||||
TimeSpan? perAttemptTimeout = null,
|
||||
TimeSpan? retryDelay = null,
|
||||
double? sessionAgeHours = null,
|
||||
RegistrationFailureSummary? priorRegistrationFailure = null)
|
||||
{
|
||||
Reports.Add((errorType, message, httpStatusCode, attempts));
|
||||
_sequence.Add("report");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private CleanupService CleanupFactory()
|
||||
{
|
||||
_sequence.Add("cleanup");
|
||||
// The real factory spawns a PowerShell cleanup script; throwing makes the cleanup retry
|
||||
// swallow and log it, so the call is counted without a side effect (as in ProgramGuardsTests).
|
||||
throw new InvalidOperationException("test-harness: cleanup factory must not spawn real PowerShell in unit tests");
|
||||
}
|
||||
|
||||
private bool MarkerExists => File.Exists(Path.Combine(_stateDir, "enrollment-complete.marker"));
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)] // forced like the kill signal: SelfDestructOnComplete does not gate it
|
||||
public void Reports_once_then_ends_monitoring_with_a_forced_cleanup(bool selfDestructOnComplete)
|
||||
{
|
||||
_persistence.SaveWhiteGloveComplete(_logger);
|
||||
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1", SelfDestructOnComplete = selfDestructOnComplete };
|
||||
var reporter = new RecordingEmergencyReporter(_sequence);
|
||||
|
||||
var exitCode = BackendSessionRegistration.ConcludeSessionGone(
|
||||
config, reporter, _persistence, _stateDir, CleanupFactory, consoleMode: false, _logger);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
// The report leaves before the cleanup deletes the agent.
|
||||
Assert.Equal(new[] { "report", "cleanup" }, _sequence);
|
||||
|
||||
var report = Assert.Single(reporter.Reports);
|
||||
Assert.Equal(AgentErrorType.RegistrationRejectedSessionGone, report.ErrorType);
|
||||
Assert.Equal(410, report.HttpStatusCode);
|
||||
Assert.Contains("410", report.Message);
|
||||
Assert.True(report.Attempts > 1, "the process ends right after: the one report gets a retry");
|
||||
|
||||
Assert.True(MarkerExists);
|
||||
Assert.False(_persistence.SessionExists());
|
||||
Assert.False(_persistence.IsWhiteGloveResume());
|
||||
Assert.Null(_persistence.LoadSessionCreatedAt());
|
||||
// The next start ends at the bootstrap guard, exactly as after the emergency break.
|
||||
Assert.True(ProgramAlias.CheckEnrollmentCompleteMarker(
|
||||
_stateDir, selfDestructOnComplete: false, cleanupServiceFactory: null, _logger, consoleMode: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ends_monitoring_without_a_reporter()
|
||||
{
|
||||
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1" };
|
||||
|
||||
var exitCode = BackendSessionRegistration.ConcludeSessionGone(
|
||||
config, emergencyReporter: null!, _persistence, _stateDir, CleanupFactory, consoleMode: false, _logger);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Equal(new[] { "cleanup" }, _sequence);
|
||||
Assert.True(MarkerExists);
|
||||
Assert.False(_persistence.SessionExists());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SessionRotationReasons.SessionGone)]
|
||||
[InlineData(SessionRotationReasons.OwnerMismatch)]
|
||||
public void Agent_started_names_why_the_session_was_rotated(string reason)
|
||||
{
|
||||
var data = EmitAgentStarted(rotatedFromSessionId: "11111111-1111-1111-1111-111111111111", reason);
|
||||
|
||||
Assert.Equal(true, data["sessionRotated"]);
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111", data["previousSessionId"]);
|
||||
Assert.Equal(reason, data["sessionRotationReason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Agent_started_carries_no_rotation_reason_on_the_normal_path()
|
||||
{
|
||||
var data = EmitAgentStarted(rotatedFromSessionId: null, sessionRotationReason: null);
|
||||
|
||||
Assert.Equal(false, data["sessionRotated"]);
|
||||
Assert.False(data.ContainsKey("sessionRotationReason"));
|
||||
}
|
||||
|
||||
private Dictionary<string, object> EmitAgentStarted(string? rotatedFromSessionId, string? sessionRotationReason)
|
||||
{
|
||||
var sink = new FakeSignalIngressSink();
|
||||
LifecycleEmitters.EmitAgentStarted(
|
||||
new InformationalEventPost(sink, SystemClock.Instance, _logger),
|
||||
new AgentConfiguration { SessionId = "S1", TenantId = "00000000-0000-0000-0000-000000000001", CommandLineArgs = string.Empty },
|
||||
new ProgramAlias.PreviousExitSummary { ExitType = "reboot_kill" },
|
||||
"2.0.test",
|
||||
remoteConfigService: null,
|
||||
_logger,
|
||||
rotatedFromSessionId: rotatedFromSessionId,
|
||||
sessionRotationReason: sessionRotationReason);
|
||||
return (Dictionary<string, object>)Assert.Single(sink.Posted).TypedPayload!;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -7,7 +7,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression coverage for the IsHybridJoin detection that runs at session-registration
|
||||
/// time. Field evidence (session c4c8d206-…, Lenovo ThinkPad T14 Gen 5, Win11 23H2
|
||||
/// time. Field evidence (Lenovo ThinkPad T14 Gen 5, Win11 23H2
|
||||
/// 22631.4317): the AutopilotPolicyCache key on this device exposes
|
||||
/// <c>CloudAssignedDomainJoinMethod</c> only inside the embedded <c>PolicyJsonCache</c>
|
||||
/// blob, not as a top-level registry value. Without the JSON fallback the agent registered
|
||||
@@ -47,11 +47,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
[Fact]
|
||||
public void Missing_TopLevel_falls_back_to_PolicyJsonCache_one_returns_true()
|
||||
{
|
||||
// Real-world payload from the Bayer Lenovo T14 Gen 5 / Win11 23H2 22631.4317
|
||||
// device that triggered this fix. Truncated for readability — the only relevant
|
||||
// Shape of a real PolicyJsonCache payload (Win11 23H2 22631.4317) with a
|
||||
// placeholder tenant domain. Truncated for readability — the only relevant
|
||||
// key is CloudAssignedDomainJoinMethod=1.
|
||||
const string policyJson =
|
||||
"{\r\n \"CloudAssignedTenantDomain\": \"bayergroup.onmicrosoft.com\",\r\n" +
|
||||
"{\r\n \"CloudAssignedTenantDomain\": \"contoso.onmicrosoft.com\",\r\n" +
|
||||
" \"CloudAssignedDomainJoinMethod\": 1,\r\n" +
|
||||
" \"AutopilotMode\": 2\r\n}";
|
||||
|
||||
|
||||
+131
@@ -166,6 +166,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
|
||||
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
|
||||
Assert.Equal(SessionRotationReasons.OwnerMismatch, result.RotationReason);
|
||||
}
|
||||
|
||||
private static BackendRefusalException TenantConflict()
|
||||
@@ -313,6 +314,136 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Equal(1, tracker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
// ── 410 at registration: the backend deleted the session ────────────
|
||||
|
||||
private static BackendSessionGoneException SessionGone()
|
||||
=> new BackendSessionGoneException("Backend returned 410 Gone: the session was deleted or is being deleted.", 410);
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_ends_the_run_at_once_without_retry_rotation_or_auth_failure()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var rotations = 0;
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
|
||||
clock: SystemClock.Instance, logger: logger);
|
||||
|
||||
// The rotation hook is wired on every start; without the Part 2 flag it must stay unused.
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
authFailureTracker: tracker,
|
||||
backoffDelay: _ => throw new InvalidOperationException("a 410 must not be retried"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { rotations++; return Guid.NewGuid().ToString(); });
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(410, result.HttpStatusCode);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
Assert.Equal(0, rotations);
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
Assert.Null(result.RotatedFromSessionId);
|
||||
Assert.Null(result.RotationReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_at_a_whiteglove_part2_start_rotates_once_and_registers_the_new_session()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var originalSessionId = config.SessionId;
|
||||
var rotatedTo = Guid.NewGuid().ToString();
|
||||
var seenSessionIds = new System.Collections.Generic.List<string>();
|
||||
var rotations = 0;
|
||||
|
||||
var apiClient = new FakeApiClient(
|
||||
attempt => attempt == 1
|
||||
? throw SessionGone()
|
||||
: new RegisterSessionResponse { Success = true, SessionId = rotatedTo, ValidatedBy = ValidatorType.AutopilotV1 },
|
||||
onRegister: r => seenSessionIds.Add(r.SessionId));
|
||||
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
|
||||
clock: SystemClock.Instance, logger: logger);
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
authFailureTracker: tracker,
|
||||
backoffDelay: _ => throw new InvalidOperationException("rotation must retry without backoff"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { rotations++; config.SessionId = rotatedTo; return rotatedTo; },
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
|
||||
Assert.Equal(2, apiClient.CallCount);
|
||||
Assert.Equal(1, rotations);
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
|
||||
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
|
||||
Assert.Equal(SessionRotationReasons.SessionGone, result.RotationReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_session_gone_after_rotation_ends_the_run()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var rotations = 0;
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
backoffDelay: _ => throw new InvalidOperationException("a 410 must not be retried"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { rotations++; config.SessionId = Guid.NewGuid().ToString(); return config.SessionId; },
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(2, apiClient.CallCount);
|
||||
Assert.Equal(1, rotations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_with_a_failing_rotation_ends_the_run()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => throw new IOException("disk full"),
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
Assert.Null(result.RotatedFromSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_at_a_whiteglove_part2_start_without_rotation_hook_ends_the_run()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_after_six_exceptions_without_auth_error()
|
||||
{
|
||||
|
||||
@@ -2,7 +2,9 @@ using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
// xUnit1031 (no blocking Task.Wait in tests) intentionally suppressed — the API
|
||||
@@ -22,9 +24,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
/// Drives <see cref="TenantIdAwaiter.WaitForTenantIdCore"/> through every interesting
|
||||
/// path with a fake <see cref="IRegistryChangeSignal"/> + scripted probe so the
|
||||
/// orchestration logic is verified without touching the registry. The public
|
||||
/// <see cref="TenantIdAwaiter.WaitForTenantId"/> wrapper isn't covered here — the
|
||||
/// HKCU integration test in <c>RegistryWatcherIntegrationTests</c> proves the
|
||||
/// CompositeRegistryChangeSignal / RegNotifyChangeKeyValue plumbing separately.
|
||||
/// <c>WaitForTenantId</c> wrapper is covered through its internal overload with a scripted
|
||||
/// resolver (which probes log); the HKCU integration test in
|
||||
/// <c>RegistryWatcherIntegrationTests</c> proves the CompositeRegistryChangeSignal /
|
||||
/// RegNotifyChangeKeyValue plumbing separately.
|
||||
/// <para>
|
||||
/// Single-threaded collection: these tests use <c>Thread.Sleep</c> as a synchronisation
|
||||
/// primitive (waiting for the awaiter to enter its WaitAny loop before triggering the
|
||||
@@ -258,5 +261,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
logger: null!,
|
||||
ct: CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initial_miss_never_probes_with_the_logger()
|
||||
{
|
||||
// AgentBootstrap probes with its logger before it calls the awaiter; the resolver's
|
||||
// miss block must not land a second time from the awaiter's own first check.
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
int loggedProbes = 0, silentProbes = 0;
|
||||
|
||||
var result = TenantIdAwaiter.WaitForTenantId(
|
||||
timeoutSeconds: 1,
|
||||
logger: logger,
|
||||
ct: CancellationToken.None,
|
||||
resolve: l => { if (l != null) loggedProbes++; else silentProbes++; return null!; },
|
||||
signalFactory: () => new FakeSignal());
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.Equal(0, loggedProbes);
|
||||
Assert.True(silentProbes >= 1, $"expected a silent first check, saw {silentProbes}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initial_hit_probes_once_more_with_the_logger_for_the_source_line()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var loggedProbes = 0;
|
||||
|
||||
var result = TenantIdAwaiter.WaitForTenantId(
|
||||
timeoutSeconds: 600,
|
||||
logger: logger,
|
||||
ct: CancellationToken.None,
|
||||
resolve: l => { if (l != null) loggedProbes++; return "tenant-1"; },
|
||||
signalFactory: () => throw new InvalidOperationException("no wait expected on a hit"));
|
||||
|
||||
Assert.Equal("tenant-1", result);
|
||||
Assert.Equal(1, loggedProbes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -258,13 +258,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
using var f = new Fixture();
|
||||
using var adapter = new AadJoinWatcherAdapter(
|
||||
f.Watcher, f.Ingress, f.Clock,
|
||||
onRealUserJoined: () => throw new InvalidOperationException("boom"));
|
||||
onRealUserJoined: () => throw new InvalidOperationException("boom"),
|
||||
logger: f.Logger);
|
||||
|
||||
// The signal post completes BEFORE the callback fires — even if the callback
|
||||
// throws, the decision signal must already be on the bus.
|
||||
adapter.TriggerFromTest("alice@contoso.com", "x");
|
||||
|
||||
Assert.Single(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.AadUserJoinedLate);
|
||||
// ...and the fault is in the log instead of vanishing.
|
||||
Assert.Contains(
|
||||
"[WARN] AadJoinWatcherAdapter: onRealUserJoined callback threw: boom",
|
||||
System.IO.File.ReadAllText(System.IO.Path.Combine(f.Tmp.Path, "agent.log")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+105
@@ -600,6 +600,111 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.DoesNotContain(ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPackageCompleted);
|
||||
}
|
||||
|
||||
// ---- Detected carries the current phase (service-key attach) -------------------------
|
||||
|
||||
private sealed class PhaseHarness : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
|
||||
public int? Phase;
|
||||
public RealmJoinWatcher Watcher { get; }
|
||||
public RealmJoinWatcherAdapter Adapter { get; }
|
||||
|
||||
public PhaseHarness()
|
||||
{
|
||||
var registry = new FakeRealmJoinRegistry();
|
||||
Watcher = new RealmJoinWatcher(
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
enumeratePackageIds: registry.Enumerate,
|
||||
tryReadPackage: registry.TryRead,
|
||||
keyExists: registry.KeyExists,
|
||||
readVersionInfo: () => RealmJoinVersionInfo.Empty,
|
||||
readUninstallDisplayVersion: () => null,
|
||||
readDeploymentPhase: () => Phase);
|
||||
Adapter = new RealmJoinWatcherAdapter(Watcher, Ingress, new VirtualClock(Fixed));
|
||||
}
|
||||
|
||||
public string DetectedPhase() =>
|
||||
Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinDetected)
|
||||
.Payload![DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Adapter.Dispose();
|
||||
Watcher.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(101)]
|
||||
[InlineData(200)]
|
||||
public void Detected_carries_the_phase_RealmJoin_is_in_when_the_service_key_attaches(int phase)
|
||||
{
|
||||
// Agent restarted while RJ deploys. Detected used to say Blank, which reset the
|
||||
// engine's persisted LastDeploymentPhase and hid from the keep-awake host that RJ had
|
||||
// started — no PhaseChanged follows while the phase stays put.
|
||||
using var h = new PhaseHarness { Phase = phase };
|
||||
|
||||
h.Watcher.TriggerServiceStageAttachedFromTest();
|
||||
|
||||
Assert.Equal(phase.ToString(), h.DetectedPhase());
|
||||
Assert.True(h.Watcher.PackageWatchersArmedForTest);
|
||||
Assert.DoesNotContain(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPhaseChanged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detected_with_a_completed_phase_resolves_in_the_same_pass()
|
||||
{
|
||||
using var h = new PhaseHarness { Phase = RealmJoinInfo.PhaseCompletedFirstDeployment };
|
||||
|
||||
h.Watcher.TriggerServiceStageAttachedFromTest();
|
||||
|
||||
Assert.Equal("110", h.DetectedPhase());
|
||||
var kinds = h.Ingress.Posted
|
||||
.Where(p => p.Kind == DecisionSignalKind.RealmJoinDetected || p.Kind == DecisionSignalKind.RealmJoinResolved)
|
||||
.Select(p => p.Kind)
|
||||
.ToList();
|
||||
Assert.Equal(new[] { DecisionSignalKind.RealmJoinDetected, DecisionSignalKind.RealmJoinResolved }, kinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Phase_not_written_at_attach_reports_Blank_and_the_first_value_surfaces_as_PhaseChanged()
|
||||
{
|
||||
// Detected says Blank while Parameters\DeploymentPhase is missing; that report is the
|
||||
// baseline, so a first value of 101 must not be swallowed silently.
|
||||
using var h = new PhaseHarness { Phase = null };
|
||||
|
||||
h.Watcher.TriggerServiceStageAttachedFromTest();
|
||||
Assert.Equal("0", h.DetectedPhase());
|
||||
Assert.False(h.Watcher.PackageWatchersArmedForTest);
|
||||
|
||||
h.Phase = 101;
|
||||
h.Watcher.TriggerCheckParametersFromTest();
|
||||
|
||||
var changed = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPhaseChanged);
|
||||
Assert.Equal("101", changed.Payload![DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase]);
|
||||
Assert.Equal("0", changed.Payload[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase]);
|
||||
Assert.True(h.Watcher.PackageWatchersArmedForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blank_at_attach_then_running_fires_one_PhaseChanged()
|
||||
{
|
||||
// The common first-run shape (RJ installed during the ESP, deployment starts after
|
||||
// the user-ESP page closed): Blank on attach, 0 -> 101 later.
|
||||
using var h = new PhaseHarness { Phase = 0 };
|
||||
|
||||
h.Watcher.TriggerServiceStageAttachedFromTest();
|
||||
h.Watcher.TriggerCheckParametersFromTest(); // same value again — no change
|
||||
h.Phase = 101;
|
||||
h.Watcher.TriggerCheckParametersFromTest();
|
||||
|
||||
Assert.Equal("0", h.DetectedPhase());
|
||||
var changed = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPhaseChanged);
|
||||
Assert.Equal("0", changed.Payload![DecisionEngine.RealmJoinPayloadKeys.PreviousPhase]);
|
||||
}
|
||||
|
||||
|
||||
// ---- auto-update (Uninstall\RealmJoin) -----------------------------------------------
|
||||
|
||||
|
||||
+43
-2
@@ -12,8 +12,8 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression coverage for the duplicate <c>performance_snapshot</c> events observed on
|
||||
/// session c4c8d206-… (Bayer / Lenovo T14 Gen 5): two snapshots fired with timestamps
|
||||
/// Regression coverage for duplicate <c>performance_snapshot</c> events observed in the
|
||||
/// field (Lenovo T14 Gen 5): two snapshots fired with timestamps
|
||||
/// 0–1ms apart, the second one carrying a fresh-baseline net delta of 0. Root cause:
|
||||
/// <see cref="System.Threading.Timer"/> is documented to dispatch callbacks on different
|
||||
/// ThreadPool threads when a callback exceeds the period, and the
|
||||
@@ -126,6 +126,47 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
Assert.Equal(2, collector.CollectInvocationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_then_Dispose_logs_Stopped_once()
|
||||
{
|
||||
// Hosts call Stop and then Dispose, which stops again; the log used to show
|
||||
// "[X] Stopped" twice in the same millisecond.
|
||||
using var tmp = new TempDirectory();
|
||||
var collector = new BlockingCollector(NewPost(), NewLogger(tmp));
|
||||
|
||||
collector.Start();
|
||||
collector.Stop();
|
||||
collector.Dispose();
|
||||
|
||||
var log = File.ReadAllText(Path.Combine(tmp.Path, "logs", "agent.log"));
|
||||
Assert.Equal(1, CountOccurrences(log, "[BlockingCollector] Stopped"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_without_Start_logs_no_Stopped_line()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var collector = new BlockingCollector(NewPost(), logger);
|
||||
logger.Info("marker"); // make sure the log file exists
|
||||
|
||||
collector.Dispose();
|
||||
|
||||
var log = File.ReadAllText(Path.Combine(tmp.Path, "logs", "agent.log"));
|
||||
Assert.DoesNotContain("[BlockingCollector] Stopped", log);
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string text, string value)
|
||||
{
|
||||
int count = 0, index = 0;
|
||||
while ((index = text.IndexOf(value, index, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
index += value.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only collector subclass: signals when <see cref="Collect"/> is entered and
|
||||
/// blocks until the test releases it, so we can exercise the reentrancy gate
|
||||
|
||||
+31
@@ -138,6 +138,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
() => client.GetDiagnosticsUploadUrlAsync("tenant-1", "session-1", "package.zip"));
|
||||
}
|
||||
|
||||
// ── 410: session gone, only when the backend said so ────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_410_on_registration_is_session_gone()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Gone,
|
||||
"{\"success\":false,\"message\":\"Session is being deleted by an administrator (state=Tombstoned); registration is rejected.\"}",
|
||||
"application/json");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendSessionGoneException>(
|
||||
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
|
||||
|
||||
Assert.Equal(410, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_410_without_the_backends_echo_stays_a_plain_http_error()
|
||||
{
|
||||
// Answered on the network path: the caller would end monitoring on a session-gone
|
||||
// signal, so it must stay an ordinary, retried failure.
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Gone,
|
||||
"<html><head><title>Gone</title></head><body>Gone</body></html>",
|
||||
"text/html",
|
||||
fromBackend: false);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
|
||||
+162
-9
@@ -6,6 +6,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -14,8 +15,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="BackendTelemetryUploader"/> emits upload-cadence log lines (flushing / ingest
|
||||
/// OK / ingest TRANSIENT / ingest PERMANENT) so the transport path is observable. The
|
||||
/// success-path lines (flushing, ingest OK) are pipeline mechanics and live on Debug;
|
||||
/// OK / ingest TRANSIENT / ingest PERMANENT) so the transport path is observable. The send
|
||||
/// ("flushing") is mechanics and lives on Trace, the success record ("ingest OK") on Verbose;
|
||||
/// failures (TRANSIENT/PERMANENT/TIMEOUT/NETWORK) live on Warning/Error and remain
|
||||
/// visible at the default Info level. We assert on log-file content because AgentLogger
|
||||
/// writes to disk; checking the file is the most faithful production proxy.
|
||||
@@ -35,6 +36,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
requiresImmediateFlush: false,
|
||||
enqueuedAtUtc: At);
|
||||
|
||||
private static ProxyObserver NewProxyObserver(AgentLogger logger) =>
|
||||
new ProxyObserver(
|
||||
() => new ProxySettings(false, null, null, null, null),
|
||||
() => "direct",
|
||||
logger,
|
||||
new VirtualClock(At),
|
||||
TimeSpan.FromSeconds(30));
|
||||
|
||||
private static string ReadLogs(string path)
|
||||
{
|
||||
// AgentLogger writes to a dated file; scan the whole dir to stay robust to format.
|
||||
@@ -54,11 +63,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadBatch_logs_flush_start_and_ingest_OK_on_success_at_debug()
|
||||
public async Task UploadBatch_logs_flush_start_at_trace_and_ingest_OK_at_verbose()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
// Success-path transport lines live on Debug; capture at that level to assert them.
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Debug);
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Trace);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueStatus(HttpStatusCode.OK);
|
||||
@@ -75,11 +83,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
|
||||
// AgentLogger writes each line with File.AppendAllText — content is already on disk.
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains("BackendTelemetryUploader: flushing 2 item(s)", logContent);
|
||||
// Each upload now carries a per-request correlation id (X-Correlation-ID) for
|
||||
// end-to-end tracing; it precedes the item count in the cadence log lines.
|
||||
Assert.Contains("[TRACE] BackendTelemetryUploader: flushing 2 item(s)", logContent);
|
||||
// Each upload carries a per-request correlation id (X-Correlation-ID) for end-to-end
|
||||
// tracing; the success record carries the batch's item range so it stands alone.
|
||||
Assert.Contains("[VERBOSE] BackendTelemetryUploader: ingest OK (corr=", logContent);
|
||||
Assert.Contains("items=2, firstId=1, lastId=2, bytes=", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadBatch_success_at_verbose_logs_the_record_without_the_send_line()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Verbose);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueStatus(HttpStatusCode.OK);
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http,
|
||||
baseUrl: "https://backend.test",
|
||||
tenantId: "T1",
|
||||
logger: logger);
|
||||
|
||||
var result = await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
Assert.True(result.Success);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.DoesNotContain("BackendTelemetryUploader: flushing", logContent);
|
||||
Assert.Contains("BackendTelemetryUploader: ingest OK (corr=", logContent);
|
||||
Assert.Contains("items=2", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -212,5 +244,126 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.DoesNotContain("flushing", logContent);
|
||||
Assert.DoesNotContain("ingest", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Network_failure_names_the_route_and_opens_a_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new HttpRequestException("An error occurred while sending the request."));
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains("ingest NETWORK fail after", logContent);
|
||||
Assert.Contains("items=1, route=direct): ", logContent);
|
||||
Assert.Contains("ProxyObserver: uploads are failing since", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_names_the_route_and_opens_a_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new TaskCanceledException("A task was canceled."));
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains("ingest TIMEOUT after", logContent);
|
||||
Assert.Contains("items=1, route=direct): ", logContent);
|
||||
Assert.Contains("ProxyObserver: uploads are failing since", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interception_names_the_route_and_opens_a_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueForeign(HttpStatusCode.Forbidden, InterceptionFixtures.CertificateRequiredPage);
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
var result = await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
Assert.True(result.IsIntercepted);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains(", route=direct).", logContent);
|
||||
Assert.Contains("ProxyObserver: uploads are failing since", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_successful_upload_ends_the_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new HttpRequestException("An error occurred while sending the request."));
|
||||
handler.QueueStatus(HttpStatusCode.OK);
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
Assert.Contains("ProxyObserver: uploads work again after 1 failed attempt(s)", ReadLogs(tmp.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_answer_from_the_backend_that_is_not_a_success_leaves_the_series_alone()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueStatus(HttpStatusCode.InternalServerError);
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
Assert.DoesNotContain("ProxyObserver: uploads", ReadLogs(tmp.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_an_observer_the_failure_line_stays_as_it_was()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new HttpRequestException("An error occurred while sending the request."));
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger);
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains("items=1): ", logContent);
|
||||
Assert.DoesNotContain("route=", logContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -550,6 +550,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.Contains("network", result.ErrorReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Network_error_reason_names_the_cause_instead_of_the_generic_text()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.Handler.QueueThrow(new HttpRequestException(
|
||||
"An error occurred while sending the request.",
|
||||
new WebException(
|
||||
"Unable to connect to the remote server",
|
||||
new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.TimedOut),
|
||||
WebExceptionStatus.ConnectFailure,
|
||||
null)));
|
||||
|
||||
var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsTransient);
|
||||
Assert.StartsWith("network: status=ConnectFailure, socket=TimedOut: ", result.ErrorReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpClient_timeout_maps_to_Transient()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
/// <summary>Settings and route are played in: no test changes a real proxy setting.</summary>
|
||||
public sealed class ProxyObserverTests : IDisposable
|
||||
{
|
||||
private static readonly ProxySettings AutoDetect = new ProxySettings(true, null, null, null, null);
|
||||
private static readonly ProxySettings NoProxy = new ProxySettings(false, null, null, null, null);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly VirtualClock _clock = new VirtualClock(new DateTime(2026, 11, 2, 6, 29, 13, DateTimeKind.Utc));
|
||||
private ProxySettings _settings = AutoDetect;
|
||||
private string _route = "proxy.example.test:8080";
|
||||
|
||||
private ProxyObserver NewObserver(Func<string>? readRoute = null, TimeSpan? routeBound = null, Func<ProxySettings>? readSettings = null) =>
|
||||
new ProxyObserver(
|
||||
readSettings ?? (() => _settings),
|
||||
readRoute ?? (() => _route),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
_clock,
|
||||
routeBound ?? TimeSpan.FromSeconds(30));
|
||||
|
||||
private ProxyObserver StartedObserver()
|
||||
{
|
||||
var observer = NewObserver();
|
||||
observer.CaptureBaseline();
|
||||
return observer;
|
||||
}
|
||||
|
||||
private void SwitchToNoProxy()
|
||||
{
|
||||
_settings = NoProxy;
|
||||
_route = "direct";
|
||||
}
|
||||
|
||||
private List<string> LogLines()
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var file in Directory.EnumerateFiles(_tmp.Path, "*.log", SearchOption.AllDirectories))
|
||||
{
|
||||
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream);
|
||||
lines.AddRange(reader.ReadToEnd().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
return lines.Where(l => l.Contains("ProxyObserver:")).ToList();
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
[Fact]
|
||||
public void The_baseline_names_settings_and_route_at_info()
|
||||
{
|
||||
StartedObserver();
|
||||
|
||||
var line = Assert.Single(LogLines());
|
||||
Assert.Contains("[INFO]", line);
|
||||
Assert.Contains("start - IE settings of this account: auto-detect; WinHTTP: direct; route to backend: proxy.example.test:8080", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_rebuild_of_the_clients_does_not_repeat_the_baseline()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
foreach (var _ in new[] { 1, 2 })
|
||||
{
|
||||
new ProxyObserver(() => _settings, () => _route, logger, _clock, TimeSpan.FromSeconds(30)).CaptureBaseline();
|
||||
}
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_warns_once_with_both_sides()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.CheckForChange();
|
||||
observer.CheckForChange();
|
||||
|
||||
var line = Assert.Single(LogLines().Skip(1));
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("proxy settings changed - IE settings of this account: auto-detect -> no proxy; route to backend: proxy.example.test:8080 -> direct.", line);
|
||||
Assert.Contains("bitsadmin /util /getieproxy localsystem", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rewriting_the_same_content_says_nothing()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = new ProxySettings(true, null, null, null, null);
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_winhttp_change_says_uploads_do_not_use_it()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = new ProxySettings(true, null, null, null, "p:8080");
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("WinHTTP: direct -> proxy p:8080", line);
|
||||
Assert.Contains("do not use WinHTTP", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_winhttp_change_is_not_blamed_for_failing_uploads()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = new ProxySettings(true, null, null, null, "p:8080");
|
||||
observer.CheckForChange();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", LogLines().Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_reaches_the_subscriber_with_settings_and_route()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
ProxySettings? seen = null;
|
||||
string? seenWhat = null;
|
||||
string? seenRoute = null;
|
||||
observer.SettingsChanged += (settings, what, route) =>
|
||||
{
|
||||
seen = settings;
|
||||
seenWhat = what;
|
||||
seenRoute = route;
|
||||
};
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Equal(NoProxy, seen);
|
||||
Assert.Equal("IE settings of this account: auto-detect -> no proxy", seenWhat);
|
||||
Assert.Equal("direct", seenRoute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_subscriber_that_throws_does_not_reach_the_caller()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.SettingsChanged += (_, _, _) => throw new InvalidOperationException("SignalIngress has been stopped.");
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Contains(LogLines(), l => l.Contains("proxy settings changed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_first_failure_ties_the_series_to_the_change()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_clock.Advance(TimeSpan.FromMinutes(23));
|
||||
observer.OnSendSucceeded();
|
||||
_clock.Advance(TimeSpan.FromSeconds(90));
|
||||
SwitchToNoProxy();
|
||||
observer.CheckForChange();
|
||||
_clock.Advance(TimeSpan.FromSeconds(62));
|
||||
|
||||
observer.OnSendFailed(TimeSpan.FromSeconds(21));
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("uploads are failing since 06:54:24, 41 s after the proxy settings changed at 06:53:43 (IE settings of this account: auto-detect -> no proxy).", line);
|
||||
Assert.Contains("Route to backend: direct. Last successful upload 06:52:13.", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failure_notices_a_change_the_watcher_did_not_report()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
var lines = LogLines();
|
||||
Assert.Contains(lines, l => l.Contains("proxy settings changed - IE settings of this account: auto-detect -> no proxy"));
|
||||
Assert.Contains("after the proxy settings changed", lines.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_series_writes_its_finding_once()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Single(LogLines(), l => l.Contains("uploads are failing"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failure_without_a_change_says_the_settings_are_unchanged()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", line);
|
||||
Assert.Contains("Route to backend: proxy.example.test:8080. No successful upload yet.", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_before_the_last_success_is_not_blamed()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
observer.CheckForChange();
|
||||
_clock.Advance(TimeSpan.FromMinutes(1));
|
||||
observer.OnSendSucceeded();
|
||||
_clock.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Contains("Proxy settings of this account unchanged since the last successful upload: no proxy.", LogLines().Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recovery_is_noted_and_a_new_series_gets_its_own_finding()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
_clock.Advance(TimeSpan.FromMinutes(9));
|
||||
|
||||
observer.OnSendSucceeded();
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[INFO]", line);
|
||||
Assert.Contains("uploads work again after 2 failed attempt(s) over 9 min; route to backend: proxy.example.test:8080.", line);
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
Assert.Equal(2, LogLines().Count(l => l.Contains("uploads are failing")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recovery_reports_settings_that_came_back()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
_settings = AutoDetect;
|
||||
_route = "proxy.example.test:8080";
|
||||
|
||||
observer.OnSendSucceeded();
|
||||
|
||||
Assert.Contains(LogLines(), l => l.Contains("no proxy -> auto-detect"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_success_without_a_series_stays_silent()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
|
||||
observer.OnSendSucceeded();
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopping_while_failing_sums_it_up()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.OnSendSucceeded();
|
||||
_clock.Advance(TimeSpan.FromMinutes(1));
|
||||
SwitchToNoProxy();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
observer.LogOpenFailures();
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("stopping with uploads still failing: 2 failed attempt(s) since 06:30:13, last success 06:29:13, route to backend: direct.", line);
|
||||
Assert.Contains("Proxy settings changed at 06:30:13 (IE settings of this account: auto-detect -> no proxy).", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopping_without_a_series_stays_silent()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendSucceeded();
|
||||
var before = LogLines().Count;
|
||||
|
||||
observer.LogOpenFailures();
|
||||
|
||||
Assert.Equal(before, LogLines().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_settings_read_that_throws_is_not_a_change_and_never_reaches_the_upload()
|
||||
{
|
||||
var throwing = false;
|
||||
var observer = NewObserver(readSettings: () => throwing ? throw new InvalidOperationException("boom") : _settings);
|
||||
observer.CaptureBaseline();
|
||||
throwing = true;
|
||||
|
||||
observer.CheckForChange();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.DoesNotContain(LogLines(), l => l.Contains("proxy settings changed"));
|
||||
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", LogLines().Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unreadable_state_is_not_a_change()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = ProxySettings.Unreadable(5);
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_route_read_that_throws_reads_as_unknown()
|
||||
{
|
||||
var observer = NewObserver(readRoute: () => throw new InvalidOperationException("boom"));
|
||||
|
||||
Assert.Equal("unknown", observer.RouteText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_hanging_route_read_gives_up_and_does_not_pile_up()
|
||||
{
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
var reads = 0;
|
||||
var observer = NewObserver(
|
||||
readRoute: () =>
|
||||
{
|
||||
Interlocked.Increment(ref reads);
|
||||
release.Wait();
|
||||
return "direct";
|
||||
},
|
||||
routeBound: TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.Equal("unknown", observer.RouteText());
|
||||
Assert.Equal("unknown", observer.RouteText());
|
||||
Assert.Equal(1, Volatile.Read(ref reads));
|
||||
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
public sealed class ProxySettingsTests
|
||||
{
|
||||
private static ProxySettings Settings(
|
||||
bool autoDetect = false, string? pac = null, string? proxy = null, string? bypass = null, string? winHttp = null) =>
|
||||
new ProxySettings(autoDetect, pac, proxy, bypass, winHttp);
|
||||
|
||||
[Fact]
|
||||
public void Each_kind_of_setting_describes_itself()
|
||||
{
|
||||
Assert.Equal("no proxy", Settings().Describe());
|
||||
Assert.Equal("auto-detect", Settings(autoDetect: true).Describe());
|
||||
Assert.Equal("PAC https://pac.example.test/proxy.pac", Settings(pac: "https://pac.example.test/proxy.pac").Describe());
|
||||
Assert.Equal("proxy proxy.example.test:8080", Settings(proxy: "proxy.example.test:8080").Describe());
|
||||
Assert.Equal("auto-detect, proxy p:8080", Settings(autoDetect: true, proxy: "p:8080").Describe());
|
||||
Assert.Equal("direct", Settings().DescribeWinHttp());
|
||||
Assert.Equal("proxy p:8080", Settings(winHttp: "p:8080").DescribeWinHttp());
|
||||
Assert.Equal("unreadable (Win32 error 5)", ProxySettings.Unreadable(5).Describe());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, null, null, "Direct")]
|
||||
[InlineData(true, null, null, "AutoDetect")]
|
||||
[InlineData(true, "https://pac.example.test/p.pac", null, "PAC")]
|
||||
[InlineData(true, "https://pac.example.test/p.pac", "p:8080", "Proxy")]
|
||||
public void Type_keeps_the_values_the_download_rule_matches(bool autoDetect, string? pac, string? proxy, string expected)
|
||||
{
|
||||
Assert.Equal(expected, Settings(autoDetect, pac, proxy).ProxyType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unreadable_state_has_its_own_type()
|
||||
{
|
||||
Assert.Equal("Unknown", ProxySettings.Unreadable(5).ProxyType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equal_content_is_equal_and_any_field_makes_it_different()
|
||||
{
|
||||
Assert.Equal(Settings(autoDetect: true), Settings(autoDetect: true));
|
||||
Assert.NotEqual(Settings(autoDetect: true), Settings());
|
||||
Assert.NotEqual(Settings(pac: "https://a.test/p"), Settings(pac: "https://b.test/p"));
|
||||
Assert.NotEqual(Settings(proxy: "a:1"), Settings(proxy: "b:1"));
|
||||
Assert.NotEqual(Settings(proxy: "a:1", bypass: "x"), Settings(proxy: "a:1", bypass: "y"));
|
||||
Assert.NotEqual(Settings(winHttp: "a:1"), Settings());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_string_counts_as_not_set()
|
||||
{
|
||||
Assert.Equal(Settings(), Settings(pac: "", proxy: "", bypass: "", winHttp: ""));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("user:secret@p:8080")]
|
||||
[InlineData("http://user:secret@p:8080")]
|
||||
[InlineData("http=user:secret@p:8080;https=user:secret@q:8443")]
|
||||
[InlineData("user:se=cret@p:8080")]
|
||||
[InlineData("user:se;cret@p:8080")]
|
||||
[InlineData("http=user:se;cret@p:8080")]
|
||||
[InlineData("user:se://cret@p:8080")]
|
||||
public void Credentials_never_reach_logs_or_events(string configured)
|
||||
{
|
||||
var settings = Settings(proxy: configured, winHttp: configured);
|
||||
var data = settings.ToEventData();
|
||||
|
||||
foreach (var text in new[] { settings.DescribeAll(), (string)data["proxyServer"], (string)data["winHttpProxy"] })
|
||||
{
|
||||
Assert.DoesNotContain("user", text);
|
||||
Assert.DoesNotContain("cret", text);
|
||||
}
|
||||
|
||||
Assert.Equal(ProxySettings.CredentialsHidden, settings.Proxy);
|
||||
Assert.Equal(ProxySettings.CredentialsHidden, settings.WinHttpProxy);
|
||||
Assert.True((bool)data["proxyEnabled"]);
|
||||
Assert.True((bool)data["winHttpProxyConfigured"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("p:8080")]
|
||||
[InlineData("http://p:8080")]
|
||||
[InlineData("http=p:8080;https=q:8443")]
|
||||
public void A_value_without_credentials_stays_as_it_is(string configured)
|
||||
{
|
||||
Assert.Equal(configured, Settings(proxy: configured).Proxy);
|
||||
Assert.Equal(configured, Settings(winHttp: configured).WinHttpProxy);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://pac.example.test/p.pac?key=secret", "https://pac.example.test/p.pac")]
|
||||
[InlineData("http://user:secret@pac.example.test:8080/p.pac", "http://pac.example.test:8080/p.pac")]
|
||||
[InlineData("http://user:se=cr;et@pac.example.test/p.pac", "http://pac.example.test/p.pac")]
|
||||
[InlineData("not a url with secret", "(unparsable URL)")]
|
||||
public void A_pac_url_keeps_scheme_host_port_and_path(string configured, string expected)
|
||||
{
|
||||
Assert.Equal(expected, Settings(pac: configured).PacUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_names_what_moved()
|
||||
{
|
||||
var before = Settings(autoDetect: true);
|
||||
|
||||
Assert.Equal("IE settings of this account: auto-detect -> no proxy", Settings().DescribeChangeFrom(before));
|
||||
Assert.Equal("WinHTTP: direct -> proxy p:1", Settings(autoDetect: true, winHttp: "p:1").DescribeChangeFrom(before));
|
||||
Assert.Equal(
|
||||
"IE settings of this account: auto-detect -> no proxy; WinHTTP: direct -> proxy p:1",
|
||||
Settings(winHttp: "p:1").DescribeChangeFrom(before));
|
||||
Assert.Equal(
|
||||
"IE settings of this account: proxy bypass list changed",
|
||||
Settings(proxy: "p:1", bypass: "b").DescribeChangeFrom(Settings(proxy: "p:1", bypass: "a")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_account_settings_count_as_a_change_for_uploads()
|
||||
{
|
||||
var before = Settings(autoDetect: true);
|
||||
|
||||
Assert.True(Settings().DiffersForUploadsFrom(before));
|
||||
Assert.False(Settings(autoDetect: true, winHttp: "p:1").DiffersForUploadsFrom(before));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_event_tells_auto_detect_from_direct()
|
||||
{
|
||||
var data = Settings(autoDetect: true).ToEventData();
|
||||
|
||||
Assert.Equal("AutoDetect", data["proxyType"]);
|
||||
Assert.True((bool)data["autoDetect"]);
|
||||
Assert.False((bool)data["proxyEnabled"]);
|
||||
Assert.Null(data["proxyServer"]);
|
||||
Assert.False((bool)data["winHttpProxyConfigured"]);
|
||||
Assert.Null(data["winHttpProxy"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_event_carries_a_real_winhttp_proxy()
|
||||
{
|
||||
var data = Settings(winHttp: "p:8080").ToEventData();
|
||||
|
||||
Assert.True((bool)data["winHttpProxyConfigured"]);
|
||||
Assert.Equal("p:8080", data["winHttpProxy"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_real_settings_of_this_account_can_be_read()
|
||||
{
|
||||
var settings = ProxySettingsReader.Read();
|
||||
|
||||
Assert.False(settings.IsUnreadable, settings.Describe());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_real_route_can_be_read()
|
||||
{
|
||||
var read = Task.Run(() => ProxySettingsReader.ReadRoute(new Uri("https://backend.example.invalid/")));
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(await read));
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -3,6 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -33,10 +34,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
public FakeBackendTelemetryUploader Uploader { get; } = new FakeBackendTelemetryUploader();
|
||||
public TelemetryUploadOrchestrator Sut { get; }
|
||||
|
||||
public Rig()
|
||||
public Rig(AgentLogger? logger = null)
|
||||
{
|
||||
Spool = new TelemetrySpool(Tmp.Path, Clock);
|
||||
Sut = new TelemetryUploadOrchestrator(Spool, Uploader, Clock, batchSize: 10, retryBackoffs: new[] { TimeSpan.Zero });
|
||||
Sut = new TelemetryUploadOrchestrator(Spool, Uploader, Clock, batchSize: 10, retryBackoffs: new[] { TimeSpan.Zero }, logger: logger);
|
||||
}
|
||||
|
||||
public void Dispose() { Sut.Dispose(); Tmp.Dispose(); }
|
||||
@@ -140,7 +141,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
[Fact]
|
||||
public async Task Handler_exception_does_not_break_drain_cursor()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
using var logTmp = new TempDirectory();
|
||||
var logger = new AgentLogger(logTmp.Path, AgentLogLevel.Info);
|
||||
using var rig = new Rig(logger);
|
||||
rig.Sut.Enqueue(Draft());
|
||||
rig.Uploader.QueueOkWithSignals(deviceKillSignal: true);
|
||||
|
||||
@@ -150,6 +153,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
// Upload still succeeded — the cursor moves, drain returns success.
|
||||
Assert.Equal(1, drain.UploadedItems);
|
||||
Assert.Equal(0, drain.FailedBatches);
|
||||
// The handler fault is no longer silent.
|
||||
Assert.Contains(
|
||||
"[WARN] TelemetryUploadOrchestrator: ServerResponseReceived handler threw: handler bug",
|
||||
System.IO.File.ReadAllText(System.IO.Path.Combine(logTmp.Path, "agent.log")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,7 +2,9 @@ using System;
|
||||
using System.ComponentModel;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using Xunit;
|
||||
|
||||
@@ -69,5 +71,56 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.Equal("boom", TransportErrorCodes.MessageWithNativeCode(new InvalidOperationException("boom")));
|
||||
Assert.Null(TransportErrorCodes.MessageWithNativeCode(null!));
|
||||
}
|
||||
|
||||
// The .NET Framework shape of a failed connect or name lookup through HttpClient.
|
||||
private static Exception SendFailureChain(WebExceptionStatus status, SocketError socketError) =>
|
||||
new HttpRequestException(
|
||||
"An error occurred while sending the request.",
|
||||
new WebException("Unable to connect to the remote server", new SocketException((int)socketError), status, null));
|
||||
|
||||
[Theory]
|
||||
[InlineData(WebExceptionStatus.ConnectFailure, SocketError.TimedOut, "status=ConnectFailure, socket=TimedOut: ")]
|
||||
[InlineData(WebExceptionStatus.NameResolutionFailure, SocketError.HostNotFound, "status=NameResolutionFailure, socket=HostNotFound: ")]
|
||||
public void Send_failure_names_status_and_socket_error(WebExceptionStatus status, SocketError socketError, string expectedPrefix)
|
||||
{
|
||||
var described = TransportErrorCodes.DescribeSendFailure(SendFailureChain(status, socketError));
|
||||
|
||||
Assert.StartsWith(expectedPrefix, described);
|
||||
Assert.DoesNotContain("An error occurred while sending the request", described);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Send_failure_of_a_tls_handshake_names_status_and_native_code()
|
||||
{
|
||||
var described = TransportErrorCodes.DescribeSendFailure(TlsFailureChain(new Win32Exception(IllegalMessage)));
|
||||
|
||||
Assert.StartsWith("status=SecureChannelFailure, win32=0x80090326: ", described);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Send_failure_without_details_is_the_innermost_message()
|
||||
{
|
||||
Assert.Equal("inner", TransportErrorCodes.DescribeSendFailure(new HttpRequestException("outer", new InvalidOperationException("inner"))));
|
||||
Assert.Equal("boom", TransportErrorCodes.DescribeSendFailure(new InvalidOperationException("boom")));
|
||||
Assert.Null(TransportErrorCodes.DescribeSendFailure(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Real_refused_connect_names_the_endpoint_it_tried()
|
||||
{
|
||||
// The endpoint in the message is what tells the backend from a proxy, and only a real
|
||||
// connect produces it: a hand-built SocketException carries none.
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
|
||||
using var client = new HttpClient(new HttpClientHandler { UseProxy = false });
|
||||
var failure = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"http://127.0.0.1:{port}/"));
|
||||
|
||||
var described = TransportErrorCodes.DescribeSendFailure(failure);
|
||||
Assert.StartsWith("status=ConnectFailure, socket=ConnectionRefused: ", described);
|
||||
Assert.Contains($"127.0.0.1:{port}", described);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<Import Project="V2Isolation.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -53,7 +53,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Log verbosity level: Info (default), Debug, Verbose.
|
||||
/// Log verbosity level: Info (default), Debug, Verbose, Trace (see <see cref="AgentLogLevel"/>).
|
||||
/// Overridable via remote config.
|
||||
/// </summary>
|
||||
public AgentLogLevel LogLevel { get; set; } = AgentLogLevel.Info;
|
||||
|
||||
@@ -151,9 +151,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
try
|
||||
{
|
||||
if (attempt == 1)
|
||||
_logger.Info("Fetching remote configuration from backend...");
|
||||
_logger.Debug("Fetching remote configuration from backend...");
|
||||
else
|
||||
_logger.Info($"Fetching remote configuration from backend (attempt {attempt}/{maxAttempts})...");
|
||||
_logger.Debug($"Fetching remote configuration from backend (attempt {attempt}/{maxAttempts})...");
|
||||
|
||||
var config = await CallBackendAsync();
|
||||
|
||||
@@ -208,7 +208,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
if (attempt < maxAttempts)
|
||||
{
|
||||
var delay = InitialFetchRetryBackoff[attempt - 1];
|
||||
_logger.Info($"Retrying remote config fetch in {delay.TotalSeconds:0}s...");
|
||||
_logger.Debug($"Retrying remote config fetch in {delay.TotalSeconds:0}s...");
|
||||
try { await DelayBetweenAttemptsAsync(delay); }
|
||||
catch (TaskCanceledException) { break; }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
{
|
||||
/// <summary>
|
||||
/// Log verbosity levels for the agent.
|
||||
/// Info = normal operational messages (default)
|
||||
/// Debug = includes DEBUG-tagged lines (component state, config, decisions)
|
||||
/// Verbose = includes VERBOSE-tagged lines (per-event details, hot-path tracing)
|
||||
/// Log verbosity levels for the agent. Each level adds to the one before it.
|
||||
/// Info = the enrollment flow an admin follows: one line per thing that happened
|
||||
/// (state change, milestone, result), repeated only when its content changed.
|
||||
/// Debug = why and how: decision details, extra attributes, suppression decisions,
|
||||
/// attempts in retry loops. Grows with the number of apps, scripts and phases,
|
||||
/// not with elapsed time.
|
||||
/// Verbose = everything known about one thing: full payloads, raw JSON, adapter mappings,
|
||||
/// the record of each upload batch. Every line carries content of its own.
|
||||
/// Trace = the mechanics: watcher arm/fire, poll ticks without a result, debounce,
|
||||
/// per-item enqueue, per-signal pass-through. Grows with time or with the number
|
||||
/// of notifications; one line alone says almost nothing.
|
||||
/// An identical repeat of a line goes to Trace after its first occurrence (repeatKey).
|
||||
/// A caught unexpected exception is never logged below Debug. Warning and Error are always
|
||||
/// written.
|
||||
/// </summary>
|
||||
public enum AgentLogLevel
|
||||
{
|
||||
@@ -32,6 +43,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
private AgentLogLevel _logLevel;
|
||||
private readonly object _lockObject = new object();
|
||||
|
||||
// Last message written under each repeatKey; bounded because callers key by call site
|
||||
// plus subject (rule, category, file, policy).
|
||||
private readonly ConcurrentDictionary<string, string> _lastMessageByRepeatKey =
|
||||
new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
private readonly long _maxFileSizeBytes;
|
||||
private readonly string _logDirectory;
|
||||
private readonly string _logFileBaseName; // "agent"
|
||||
@@ -122,21 +138,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
Log("TRACE", message);
|
||||
}
|
||||
|
||||
public void Verbose(string message)
|
||||
/// <param name="repeatKey">Names the call site and its subject. A message equal to the
|
||||
/// last one written under this key goes to Trace instead.</param>
|
||||
public void Verbose(string message, string repeatKey = null)
|
||||
{
|
||||
if (_logLevel >= AgentLogLevel.Verbose)
|
||||
Log("VERBOSE", message);
|
||||
if (_logLevel < AgentLogLevel.Verbose) return;
|
||||
if (IsRepeat(repeatKey, message)) Trace(message);
|
||||
else Log("VERBOSE", message);
|
||||
}
|
||||
|
||||
public void Debug(string message)
|
||||
/// <param name="repeatKey">Names the call site and its subject. A message equal to the
|
||||
/// last one written under this key goes to Trace instead.</param>
|
||||
public void Debug(string message, string repeatKey = null)
|
||||
{
|
||||
if (_logLevel >= AgentLogLevel.Debug)
|
||||
Log("DEBUG", message);
|
||||
if (_logLevel < AgentLogLevel.Debug) return;
|
||||
if (IsRepeat(repeatKey, message)) Trace(message);
|
||||
else Log("DEBUG", message);
|
||||
}
|
||||
|
||||
public void Info(string message)
|
||||
/// <param name="repeatKey">Names the call site and its subject. A message equal to the
|
||||
/// last one written under this key goes to Trace instead.</param>
|
||||
public void Info(string message, string repeatKey = null)
|
||||
{
|
||||
Log("INFO", message);
|
||||
if (IsRepeat(repeatKey, message)) Trace(message);
|
||||
else Log("INFO", message);
|
||||
}
|
||||
|
||||
// Records the message under the key and reports whether it equals the previous one.
|
||||
// Only reached once the requested level is enabled, so a line that was never written
|
||||
// cannot turn its first visible occurrence into a repeat.
|
||||
private bool IsRepeat(string repeatKey, string message)
|
||||
{
|
||||
if (repeatKey == null) return false;
|
||||
var repeat = false;
|
||||
_lastMessageByRepeatKey.AddOrUpdate(
|
||||
repeatKey,
|
||||
message,
|
||||
(_, previous) =>
|
||||
{
|
||||
repeat = string.Equals(previous, message, StringComparison.Ordinal);
|
||||
return message;
|
||||
});
|
||||
return repeat;
|
||||
}
|
||||
|
||||
public void Warning(string message)
|
||||
|
||||
+1
-1
@@ -426,7 +426,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
if (_logger == null) return;
|
||||
var stateList = string.Join(", ", this.Select(x => $"{x.Name ?? x.Id}:{x.InstallationState}"));
|
||||
_logger.Debug($"App states [{Count}]: {stateList}");
|
||||
_logger.Verbose($"App states [{Count}]: {stateList}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Which file a path holds right now: volume serial plus file index. IME rolls a log with
|
||||
/// <c>File.Move</c>, so the identity stays with the bytes when the name changes.
|
||||
/// </summary>
|
||||
internal static class ImeLogFileIdentity
|
||||
{
|
||||
/// <summary>
|
||||
/// Null when the file cannot be opened. An attribute-only handle with full sharing, so it
|
||||
/// can never make IME's rename of the log fail.
|
||||
/// </summary>
|
||||
public static string TryRead(string path)
|
||||
{
|
||||
using (var handle = FileNativeMethods.CreateFileW(
|
||||
path,
|
||||
FileNativeMethods.FILE_READ_ATTRIBUTES,
|
||||
FileNativeMethods.FILE_SHARE_READ | FileNativeMethods.FILE_SHARE_WRITE | FileNativeMethods.FILE_SHARE_DELETE,
|
||||
IntPtr.Zero,
|
||||
FileNativeMethods.OPEN_EXISTING,
|
||||
0,
|
||||
IntPtr.Zero))
|
||||
{
|
||||
if (handle.IsInvalid) return null;
|
||||
if (!FileNativeMethods.GetFileInformationByHandle(handle, out var info)) return null;
|
||||
return $"{info.VolumeSerialNumber:X8}-{info.FileIndexHigh:X8}{info.FileIndexLow:X8}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-26
@@ -19,7 +19,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public partial class ImeLogTracker
|
||||
{
|
||||
// PR3-A2: dedup state for `IME impersonation` lines. ~24 identical per session at
|
||||
// DEBUG flooded the log; we now emit on user change + a 60s rollup with the count.
|
||||
// DEBUG flooded the log; a user change logs at Debug, the 60s rollup with the count at Trace.
|
||||
private string _lastImpersonationUser;
|
||||
private int _impersonationRepeatCount;
|
||||
private DateTime _impersonationLastRollupUtc = DateTime.MinValue;
|
||||
@@ -111,12 +111,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _impersonationLastRollupUtc >= ImpersonationRollupInterval)
|
||||
{
|
||||
_logger.Debug($"IME impersonation: same as before (user='{current}', n={_impersonationRepeatCount} since last rollup)");
|
||||
_logger.Trace($"IME impersonation: same as before (user='{current}', n={_impersonationRepeatCount} since last rollup)");
|
||||
_impersonationLastRollupUtc = now;
|
||||
_impersonationRepeatCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Info for what a line of this enrollment reports. A line replayed from a previous
|
||||
/// enrollment's log (historic-replay guard) is context, not progress: Debug. Only for
|
||||
/// handlers that run while that line is processed, since the flag describes the line.
|
||||
/// </summary>
|
||||
private void LogLineActivity(string message, string repeatKey = null)
|
||||
{
|
||||
if (_currentLineIsHistoricReplay) _logger.Debug(message + " (historic line)", repeatKey);
|
||||
else _logger.Info(message, repeatKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of IME's check right after an install (D-392). After an exit code that asked for a
|
||||
/// restart that check decides nothing: IME sets no error then and checks again after the
|
||||
@@ -276,7 +287,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// map). Prefer the source CMTrace timestamp; latest start wins on a policy re-run.
|
||||
var startedAtUtc = LastMatchedLogTimestamp ?? UtcNowProvider();
|
||||
_healthScriptStartTimes[id] = (startedAtUtc, CaptureLastMatchedProvenance());
|
||||
_logger.Info($"ImeLogTracker: health script started: {id}");
|
||||
LogLineActivity($"ImeLogTracker: health script started: {id}", repeatKey: "ImeLogTracker.hsStarted:" + id);
|
||||
|
||||
// A replayed line of a previous enrollment bypasses the recurrence gate: the
|
||||
// adapter drops its event, and a dropped run must not count as the policy's
|
||||
@@ -285,7 +296,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
&& !_recurringScripts.OnStart(id, startedAtUtc, policyType, LastMatchedPatternId))
|
||||
{
|
||||
_stateDirty = true;
|
||||
_logger.Debug($"ImeLogTracker: health script {id} is a recurring policy — start held until its result is known");
|
||||
_logger.Debug($"ImeLogTracker: health script {id} is a recurring policy — start held until its result is known", repeatKey: "ImeLogTracker.hsStartHeld:" + id);
|
||||
return;
|
||||
}
|
||||
_stateDirty = true;
|
||||
@@ -501,7 +512,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
RecordInvocationMarker(null, isClose: false);
|
||||
NoteInvocationActivity();
|
||||
if (!string.IsNullOrEmpty(kind) && !string.Equals(kind, "powershell", StringComparison.OrdinalIgnoreCase))
|
||||
_logger.Debug($"ImeLogTracker: AgentExecutor invocation of kind '{kind}' — exit/output lines not captured as a platform script");
|
||||
_logger.Trace($"ImeLogTracker: AgentExecutor invocation of kind '{kind}' — exit/output lines not captured as a platform script");
|
||||
}
|
||||
|
||||
private void HandleAgentExecutorCompleted()
|
||||
@@ -1076,10 +1087,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
DurationBasis = "script_runtime",
|
||||
};
|
||||
|
||||
_logger.Info($"ImeLogTracker: health-script {scriptPart} early-signal for {id}: " +
|
||||
$"compliance={compliance}, exit={exitCode}, " +
|
||||
$"stdout={(stdout == null ? "n/a" : stdout.Length + " chars")}, " +
|
||||
$"stderr={(stderr == null ? "n/a" : stderr.Length + " chars")}");
|
||||
LogLineActivity($"ImeLogTracker: health-script {scriptPart} early-signal for {id}: " +
|
||||
$"compliance={compliance}, exit={exitCode}, " +
|
||||
$"stdout={(stdout == null ? "n/a" : stdout.Length + " chars")}, " +
|
||||
$"stderr={(stderr == null ? "n/a" : stderr.Length + " chars")}",
|
||||
repeatKey: "ImeLogTracker.hsEarly:" + scriptPart + ":" + id);
|
||||
|
||||
EmitScriptEvent(enriched);
|
||||
|
||||
@@ -1303,11 +1315,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
EmitScriptEvent(postDetection);
|
||||
}
|
||||
|
||||
_logger.Info($"ImeLogTracker: health-script result emitted for {policyId} " +
|
||||
$"(status={remediationStatus?.ToString() ?? "?"}, " +
|
||||
$"firstDetectExit={firstDetectExit?.ToString() ?? "n/a"}, " +
|
||||
$"remediationExit={remediationExit?.ToString() ?? "n/a"}, " +
|
||||
$"lastDetectExit={lastDetectExit?.ToString() ?? "n/a"})");
|
||||
// "result for", not "emitted": the recurrence gate in EmitScriptEvent may hold a
|
||||
// repeat back and logs that itself.
|
||||
LogLineActivity($"ImeLogTracker: health-script result for {policyId} " +
|
||||
$"(status={remediationStatus?.ToString() ?? "?"}, " +
|
||||
$"firstDetectExit={firstDetectExit?.ToString() ?? "n/a"}, " +
|
||||
$"remediationExit={remediationExit?.ToString() ?? "n/a"}, " +
|
||||
$"lastDetectExit={lastDetectExit?.ToString() ?? "n/a"})",
|
||||
repeatKey: "ImeLogTracker.hsResult:" + policyId);
|
||||
}
|
||||
|
||||
private static ScriptExecutionState BuildHealthScriptPhase(
|
||||
@@ -1377,7 +1392,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_stateDirty = true;
|
||||
if (!emit)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: health script {script.PolicyId} {script.ScriptPart} repeats its last reported result — counted, not emitted");
|
||||
_logger.Debug($"ImeLogTracker: health script {script.PolicyId} {script.ScriptPart} repeats its last reported result — counted, not emitted", repeatKey: "ImeLogTracker.hsRepeat:" + script.PolicyId + ":" + script.ScriptPart);
|
||||
return;
|
||||
}
|
||||
if (releasedStart != null) RaiseScriptStarted(releasedStart);
|
||||
@@ -1433,7 +1448,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
private void HandleImeStarted()
|
||||
{
|
||||
_logger.Info("ImeLogTracker: IME Agent Started detected");
|
||||
LogLineActivity("ImeLogTracker: IME Agent Started detected");
|
||||
|
||||
// Log any currently active app — it will be re-evaluated by new log entries after IME restart.
|
||||
// We intentionally do NOT mark it as Installed here because IME may retry the app.
|
||||
@@ -1509,11 +1524,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
_logger.Debug(
|
||||
"ImeLogTracker: zero-app user session observed before the user ESP phase " +
|
||||
$"(current phase: {_lastEspPhaseDetected ?? "(none)"}) - deferring until AccountSetup");
|
||||
$"(current phase: {_lastEspPhaseDetected ?? "(none)"}) - deferring until AccountSetup", repeatKey: "ImeLogTracker.zeroAppDeferred");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info("ImeLogTracker: user session enumerated with zero targeted apps - treating as user session completed");
|
||||
_logger.Info("ImeLogTracker: user session enumerated with zero targeted apps - treating as user session completed", repeatKey: "ImeLogTracker.zeroAppUserSession");
|
||||
OnUserSessionCompleted?.Invoke();
|
||||
}
|
||||
|
||||
@@ -1531,7 +1546,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// which would corrupt tracking if we allowed it to trigger a phase change.
|
||||
if (phaseOrd < _currentPhaseOrder)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: ignoring backward phase transition to {espPhaseString} (current phase order: {_currentPhaseOrder})");
|
||||
_logger.Trace($"ImeLogTracker: ignoring backward phase transition to {espPhaseString} (current phase order: {_currentPhaseOrder})");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1572,7 +1587,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
_phasePackageSnapshots[_lastEspPhaseDetected] =
|
||||
new List<AppPackageState>(_packageStates);
|
||||
_logger.Info($"ImeLogTracker: Snapshotted {_packageStates.CountAll} package states from {_lastEspPhaseDetected} phase");
|
||||
_logger.Debug($"ImeLogTracker: Snapshotted {_packageStates.CountAll} package states from {_lastEspPhaseDetected} phase");
|
||||
}
|
||||
|
||||
_packageStates.Clear();
|
||||
@@ -1586,12 +1601,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
// First detection of any phase OR a real transition into a new phase: surface on Info
|
||||
// so phase boundaries are visible without enabling Debug. Re-matches of the same phase
|
||||
// (IME re-emits the phase string periodically as it re-evaluates app sets) stay on
|
||||
// Debug to avoid Info repetition.
|
||||
// (IME re-emits the phase string periodically as it re-evaluates app sets) are
|
||||
// identical repeats and go to Trace (D-394).
|
||||
if (isFirstOrChangedPhase)
|
||||
_logger.Info($"ImeLogTracker: ESP phase detected: {espPhaseString}");
|
||||
else
|
||||
_logger.Debug($"ImeLogTracker: ESP phase detected: {espPhaseString}");
|
||||
_logger.Trace($"ImeLogTracker: ESP phase detected: {espPhaseString}");
|
||||
ActivatePatterns(logPhaseIsCurrentPhase);
|
||||
|
||||
OnEspPhaseChanged?.Invoke(espPhaseString);
|
||||
@@ -1645,7 +1660,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
// Ignore user-targeted packages during device setup (we only track device phase in general)
|
||||
_packageStates.AddUpdateFromJsonPolicies(policies, ignoreUserTargeted: false);
|
||||
_logger.Info($"ImeLogTracker: discovered {policies.Count} policies, tracking {_packageStates.Count} packages");
|
||||
_logger.Info($"ImeLogTracker: discovered {policies.Count} policies, tracking {_packageStates.Count} packages", repeatKey: "ImeLogTracker.policies");
|
||||
OnPoliciesDiscovered?.Invoke(policiesJson);
|
||||
}
|
||||
}
|
||||
@@ -1694,7 +1709,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
_allAppsCompletedFired = true;
|
||||
_logger.Info($"ImeLogTracker: all {_packageStates.CountAll} apps completed");
|
||||
OnAllAppsCompleted?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1709,7 +1723,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
if (changed)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: {pkg.Name ?? id} downloading: {bytes}/{ofbytes}");
|
||||
_logger.Verbose($"ImeLogTracker: {pkg.Name ?? id} downloading: {bytes}/{ofbytes}");
|
||||
OnAppStateChanged?.Invoke(pkg, oldState, AppInstallationState.Downloading);
|
||||
}
|
||||
}
|
||||
|
||||
+71
-20
@@ -51,23 +51,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
if (!Directory.Exists(_logFolder))
|
||||
return;
|
||||
|
||||
// Get all matching log files, sorted by name (archived files come before current).
|
||||
// M2: a SINGLE directory enumeration filtered in-memory against the patterns, instead
|
||||
// of one Directory.GetFiles(pattern) per LogFilePattern every 100 ms poll (~90 folder
|
||||
// enumerations/s → 1). The in-memory regex match is anchored, so it is also immune to
|
||||
// the Win32 "*.ext" 8.3 search-pattern quirk.
|
||||
var files = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var filePath in Directory.EnumerateFiles(_logFolder))
|
||||
{
|
||||
if (MatchesLogFilePattern(Path.GetFileName(filePath)))
|
||||
files.Add(filePath);
|
||||
}
|
||||
}
|
||||
catch (DirectoryNotFoundException) { }
|
||||
|
||||
files.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
var files = EnumerateLogFiles();
|
||||
if (FileIdentitiesAreDue(files)) ReconcileFileIdentities(files);
|
||||
|
||||
// Σ(file length − bookmark) after this pass — the tracker's queue depth.
|
||||
long backlogBytes = 0;
|
||||
@@ -83,7 +68,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
catch (FileNotFoundException) { }
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: IO error reading {filePath}: {ex.Message}");
|
||||
_logger.Debug($"ImeLogTracker: IO error reading {filePath}: {ex.Message}", repeatKey: "ImeLogTracker.ioError:" + filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +79,66 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All matching log files, sorted by name (archived files come before current).
|
||||
/// M2: a SINGLE directory enumeration filtered in-memory against the patterns, instead
|
||||
/// of one Directory.GetFiles(pattern) per LogFilePattern every 100 ms poll (~90 folder
|
||||
/// enumerations/s → 1). The in-memory regex match is anchored, so it is also immune to
|
||||
/// the Win32 "*.ext" 8.3 search-pattern quirk.
|
||||
/// </summary>
|
||||
private List<string> EnumerateLogFiles()
|
||||
{
|
||||
var files = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var filePath in Directory.EnumerateFiles(_logFolder))
|
||||
{
|
||||
if (MatchesLogFilePattern(Path.GetFileName(filePath)))
|
||||
files.Add(filePath);
|
||||
}
|
||||
}
|
||||
catch (DirectoryNotFoundException) { }
|
||||
|
||||
files.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
return files;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On the first pass (a restart can have rolled logs over) and when a file has no known
|
||||
/// identity yet, which is how a rolled-over archive first shows up. Nothing is opened in
|
||||
/// the regular poll.
|
||||
/// </summary>
|
||||
private bool FileIdentitiesAreDue(List<string> files)
|
||||
{
|
||||
if (!_fileIdentitiesReconciled) return true;
|
||||
foreach (var filePath in files)
|
||||
{
|
||||
if (!_positionTracker.HasIdentity(filePath)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets every bookmark follow its file (see <see cref="LogFilePositionTracker.Reconcile"/>).
|
||||
/// Reading a rolled-over log again would replay every app and script line in it: finished
|
||||
/// apps fall back to Installing, and the subgraph line of an older app marks the app that is
|
||||
/// running right now as skipped.
|
||||
/// </summary>
|
||||
private void ReconcileFileIdentities(List<string> files)
|
||||
{
|
||||
_fileIdentitiesReconciled = true;
|
||||
|
||||
var idByPath = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var filePath in files) idByPath[filePath] = FileIdentityProvider(filePath);
|
||||
|
||||
foreach (var move in _positionTracker.Reconcile(idByPath))
|
||||
{
|
||||
MoveFileContext(move.From, move.To);
|
||||
_logger.Info($"ImeLogTracker: {Path.GetFileName(move.From)} rolled over to {Path.GetFileName(move.To)} - continuing at offset {move.Position} instead of reading it again");
|
||||
}
|
||||
_stateDirty = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One pass over one file: the bytes behind the bookmark, on a multi-writer file preceded
|
||||
/// by the overwrite check that may move the bookmark back first (see
|
||||
@@ -104,6 +149,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (!fileInfo.Exists) return 0;
|
||||
|
||||
// IME renamed the file after this pass listed the folder, or its archive sorts behind it
|
||||
// (_IntuneManagementExtension.log). Either way the archive needs the bookmark before the
|
||||
// read below resets it.
|
||||
if (_positionTracker.IsBehindBookmark(filePath, fileInfo.Length))
|
||||
ReconcileFileIdentities(EnumerateLogFiles());
|
||||
|
||||
// Captured BEFORE MarkChecked below stamps this pass. Growth measured
|
||||
// against a previously observed state is what makes this pass's lines valid
|
||||
// "written now" anchors — for the per-file measurement AND per-line anchoring.
|
||||
@@ -588,7 +639,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_currentLineIsHistoricReplay = isStaleReplayLine;
|
||||
if (isStaleReplayLine && AppMutatingActions.Contains(pattern.Action?.ToLower() ?? string.Empty))
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: skipped app action '{pattern.Action}' for historic line ({ResolveEntryUtc(entry):o})");
|
||||
_logger.Trace($"ImeLogTracker: skipped app action '{pattern.Action}' for historic line ({ResolveEntryUtc(entry):o})");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -684,7 +735,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
break;
|
||||
|
||||
case "enrollmentcompleted":
|
||||
_logger.Info("ImeLogTracker: User session completed detected");
|
||||
LogLineActivity("ImeLogTracker: User session completed detected", repeatKey: "ImeLogTracker.userSessionCompleted");
|
||||
OnUserSessionCompleted?.Invoke();
|
||||
break;
|
||||
|
||||
|
||||
+29
@@ -616,6 +616,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
private void InvalidateInvocationMarkersFrom(string fileName, long offset) => TakeInvocationMarkersFrom(fileName, offset, out _);
|
||||
|
||||
/// <summary>
|
||||
/// A log was rolled over: what the tracker knows about its bytes goes with them. The markers keep
|
||||
/// <see cref="_lastPlatformMarker"/> alive, so the run in flight still owns its lines in the fresh file;
|
||||
/// the ledger stays behind and empties, an archive is not checked for overwrites.
|
||||
/// </summary>
|
||||
private void MoveFileContext(string fromPath, string toPath)
|
||||
{
|
||||
var from = Path.GetFileName(fromPath);
|
||||
var to = Path.GetFileName(toPath);
|
||||
|
||||
List<InvocationMarker> markers;
|
||||
if (_invocationMarkers.TryGetValue(from, out markers))
|
||||
{
|
||||
_invocationMarkers.Remove(from);
|
||||
_invocationMarkers[to] = markers;
|
||||
}
|
||||
if (_platformMarkerFiles.Contains(from)) _platformMarkerFiles.Add(to);
|
||||
|
||||
FileLedger ledger;
|
||||
if (_ledgers.TryGetValue(from, out ledger)) ledger.Clear();
|
||||
|
||||
HeldTail held;
|
||||
if (_heldTails.TryGetValue(fromPath, out held))
|
||||
{
|
||||
_heldTails.Remove(fromPath);
|
||||
_heldTails[toPath] = held;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An entry that reads unchanged after a rewind keeps its markers: gives back the set-aside markers at exactly
|
||||
/// <paramref name="entryOffset"/>. Set-aside markers in front of it belonged to entries that changed and stay
|
||||
|
||||
+15
-12
@@ -88,6 +88,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// system clock, ImeLogHost rewires it to the agent IClock, tests to their VirtualClock.
|
||||
internal Func<DateTime> UtcNowProvider { get; set; } = () => DateTime.UtcNow;
|
||||
|
||||
// Which file a path holds; null when unreadable. Tests swap it to cover that path.
|
||||
internal Func<string, string> FileIdentityProvider { get; set; } = ImeLogFileIdentity.TryRead;
|
||||
|
||||
// False until the bookmarks were checked against the disk once in this process.
|
||||
private bool _fileIdentitiesReconciled;
|
||||
|
||||
/// <summary>
|
||||
/// CMTrace timestamps from IME are emitted in UTC, but DateTimeKind may arrive as
|
||||
/// Unspecified depending on the parser path. Normalize via SpecifyKind=Utc rather than
|
||||
@@ -494,7 +500,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public Action OnImeStarted { get; set; }
|
||||
public Action<AppPackageState, AppInstallationState, AppInstallationState> OnAppStateChanged { get; set; }
|
||||
public Action<string> OnPoliciesDiscovered { get; set; }
|
||||
public Action OnAllAppsCompleted { get; set; }
|
||||
public Action OnUserSessionCompleted { get; set; }
|
||||
public Action<string> OnImeSessionChange { get; set; }
|
||||
// Args: errorCode (may be empty when IME logged none), raw failure line excerpt.
|
||||
@@ -954,13 +959,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
if (!Directory.Exists(_logFolder)) return;
|
||||
var inputs = new List<ImeLogEraPreScan.ScanInput>();
|
||||
var files = new List<string>();
|
||||
foreach (var filePath in Directory.EnumerateFiles(_logFolder))
|
||||
{
|
||||
if (MatchesLogFilePattern(Path.GetFileName(filePath)))
|
||||
files.Add(filePath);
|
||||
}
|
||||
files.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
var files = EnumerateLogFiles();
|
||||
// First: the read below resets the bookmark of a file that shrank, and the archive
|
||||
// that file moved to during the downtime still needs that bookmark.
|
||||
ReconcileFileIdentities(files);
|
||||
foreach (var filePath in files)
|
||||
{
|
||||
var info = new FileInfo(filePath);
|
||||
@@ -994,9 +996,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
LoadState();
|
||||
|
||||
// Era anchors for the backlog the first pass is about to read (2026-09-04). Runs
|
||||
// before the poll loop so every replayed line already finds its era; read-only on
|
||||
// the position tracker. Replay tooling anchors nothing — its logs did not come from
|
||||
// this device's bootstrap.
|
||||
// before the poll loop so every replayed line already finds its era. Replay tooling
|
||||
// anchors nothing — its logs did not come from this device's bootstrap.
|
||||
if (!SimulationMode) PreScanEras();
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
@@ -1180,6 +1181,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
var fullPath = Path.Combine(_logFolder, kvp.Key);
|
||||
_positionTracker.RestorePosition(fullPath, kvp.Value.Position, kvp.Value.LastKnownSize);
|
||||
_positionTracker.RestoreFileId(fullPath, kvp.Value.FileId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1346,7 +1348,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
state.FilePositions[fileName] = new FilePositionData
|
||||
{
|
||||
Position = kvp.Value.Position,
|
||||
LastKnownSize = kvp.Value.LastKnownSize
|
||||
LastKnownSize = kvp.Value.LastKnownSize,
|
||||
FileId = _positionTracker.GetFileId(kvp.Key)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-33
@@ -3,8 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
@@ -130,7 +129,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
try { pid = process.Id; } catch { }
|
||||
try { session = process.SessionId; } catch { }
|
||||
try { start = process.StartTime.ToUniversalTime(); } catch { }
|
||||
try { path = QueryImagePath(pid); } catch { }
|
||||
try { path = ProcessImagePath.Query(pid); } catch { }
|
||||
if (path == null)
|
||||
{
|
||||
try { path = process.MainModule?.FileName; } catch { }
|
||||
@@ -138,35 +137,5 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
return new ImeProcessCandidate(pid, session, path, start);
|
||||
}
|
||||
|
||||
private const int PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr OpenProcess(int desiredAccess, bool inheritHandle, int processId);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CloseHandle(IntPtr handle);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool QueryFullProcessImageName(IntPtr process, int flags, StringBuilder exeName, ref int size);
|
||||
|
||||
private static string QueryImagePath(int pid)
|
||||
{
|
||||
if (pid <= 0) return null;
|
||||
var handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
|
||||
if (handle == IntPtr.Zero) return null;
|
||||
try
|
||||
{
|
||||
var sb = new StringBuilder(1024);
|
||||
int size = sb.Capacity;
|
||||
return QueryFullProcessImageName(handle, 0, sb, ref size) && size > 0 ? sb.ToString(0, size) : null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-6
@@ -200,6 +200,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Process attached;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -208,12 +209,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_discoveryTimer?.Dispose();
|
||||
_discoveryTimer = null;
|
||||
|
||||
if (_imeProcess != null)
|
||||
{
|
||||
try { _imeProcess.Exited -= OnImeExited; } catch { }
|
||||
_imeProcess.Dispose();
|
||||
_imeProcess = null;
|
||||
}
|
||||
attached = _imeProcess;
|
||||
_imeProcess = null;
|
||||
}
|
||||
|
||||
// Not under the lock: .NET holds its own lock on the Process while it delivers Exited,
|
||||
// OnImeExited takes _lock, and Process.Dispose waits for that delivery to finish.
|
||||
if (attached != null)
|
||||
{
|
||||
try { attached.Exited -= OnImeExited; } catch { }
|
||||
try { attached.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -250,5 +250,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
public long Position { get; set; }
|
||||
public long LastKnownSize { get; set; }
|
||||
|
||||
/// <summary>Which file the position belongs to (see <see cref="LogFilePositionTracker.Reconcile"/>); null on older state files.</summary>
|
||||
public string FileId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
+127
-6
@@ -13,12 +13,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
private readonly Dictionary<string, FilePositionState> _positions
|
||||
= new Dictionary<string, FilePositionState>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the safe read position for a file, detecting rollover/truncation.
|
||||
/// If the current file size is smaller than the stored position, the file
|
||||
/// has been rotated - resets to 0 to read from beginning.
|
||||
/// Returns 0 if no position has been recorded yet.
|
||||
/// </summary>
|
||||
// Which file each path held when last checked (see Reconcile). Apart from _positions: a file
|
||||
// whose identity is known but which no pass looked at yet must still count as unseen for
|
||||
// HasSeen. A null value means the identity could not be read.
|
||||
private readonly Dictionary<string, string> _fileIds
|
||||
= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether this file was already observed in an earlier pass.
|
||||
/// <para>
|
||||
@@ -31,6 +31,112 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// </summary>
|
||||
public bool HasSeen(string filePath) => _positions.ContainsKey(filePath);
|
||||
|
||||
/// <summary>Whether <see cref="Reconcile"/> already looked at this path.</summary>
|
||||
public bool HasIdentity(string filePath) => _fileIds.ContainsKey(filePath);
|
||||
|
||||
/// <summary>The identity recorded for this path, or null.</summary>
|
||||
public string GetFileId(string filePath)
|
||||
{
|
||||
string id;
|
||||
return _fileIds.TryGetValue(filePath, out id) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>Restores a persisted identity. <see cref="Reconcile"/> checks it against the disk on the next pass.</summary>
|
||||
public void RestoreFileId(string filePath, string fileId)
|
||||
{
|
||||
if (fileId != null) _fileIds[filePath] = fileId;
|
||||
}
|
||||
|
||||
/// <summary>True when the file is smaller than its bookmark: it was rolled over or truncated.</summary>
|
||||
public bool IsBehindBookmark(string filePath, long currentFileSize)
|
||||
{
|
||||
FilePositionState state;
|
||||
return _positions.TryGetValue(filePath, out state) && currentFileSize < state.Position;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings the bookmarks in line with the files on disk. A bookmark belongs to a file, not to
|
||||
/// a name: IME rolls a log by renaming it, so a known file under a new path keeps its
|
||||
/// position and only the bytes behind it are still unread. The old path starts at 0 with
|
||||
/// whatever file it holds next, and keeps its freshness reference because that file was
|
||||
/// written since the last look. Returns the moves.
|
||||
/// <list type="bullet">
|
||||
/// <item>A file that is still at its old path as well (a hard link) moves nothing.</item>
|
||||
/// <item>A path that holds another file with no bookmark to take over starts at 0, even
|
||||
/// when the new file is longer than the old position.</item>
|
||||
/// <item>An unreadable identity (null) changes nothing, the path keeps its bookmark.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public List<BookmarkMove> Reconcile(IDictionary<string, string> idByPath)
|
||||
{
|
||||
var moves = new List<BookmarkMove>();
|
||||
var claimed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in idByPath)
|
||||
{
|
||||
if (entry.Value == null || GetFileId(entry.Key) == entry.Value) continue;
|
||||
var holder = FindBookmarkLeftBehind(entry.Value, entry.Key, idByPath);
|
||||
if (holder != null && claimed.Add(holder))
|
||||
moves.Add(new BookmarkMove(holder, entry.Key, _positions[holder].Position));
|
||||
}
|
||||
|
||||
// In two steps: a path can be source and target at once (X.log becomes _X.log while
|
||||
// _X.log's own file was replaced).
|
||||
var movedStates = new List<FilePositionState>();
|
||||
foreach (var move in moves)
|
||||
{
|
||||
var state = _positions[move.From];
|
||||
movedStates.Add(state);
|
||||
_positions[move.From] = new FilePositionState { LastCheckedUtc = state.LastCheckedUtc };
|
||||
_fileIds.Remove(move.From);
|
||||
}
|
||||
for (var i = 0; i < moves.Count; i++)
|
||||
{
|
||||
_positions[moves[i].To] = movedStates[i];
|
||||
_fileIds[moves[i].To] = idByPath[moves[i].To];
|
||||
}
|
||||
|
||||
foreach (var entry in idByPath)
|
||||
{
|
||||
if (entry.Value == null)
|
||||
{
|
||||
if (!_fileIds.ContainsKey(entry.Key)) _fileIds[entry.Key] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
string known;
|
||||
FilePositionState state;
|
||||
if (_fileIds.TryGetValue(entry.Key, out known) && known != null && known != entry.Value
|
||||
&& _positions.TryGetValue(entry.Key, out state))
|
||||
{
|
||||
state.Position = 0;
|
||||
state.LastKnownSize = 0;
|
||||
}
|
||||
_fileIds[entry.Key] = entry.Value;
|
||||
}
|
||||
|
||||
return moves;
|
||||
}
|
||||
|
||||
private string FindBookmarkLeftBehind(string fileId, string newPath, IDictionary<string, string> idByPath)
|
||||
{
|
||||
foreach (var known in _fileIds)
|
||||
{
|
||||
if (known.Value != fileId || string.Equals(known.Key, newPath, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (!_positions.ContainsKey(known.Key)) continue;
|
||||
|
||||
string stillThere;
|
||||
if (idByPath.TryGetValue(known.Key, out stillThere) && stillThere == fileId) continue;
|
||||
return known.Key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the safe read position for a file, detecting rollover/truncation.
|
||||
/// If the current file size is smaller than the stored position, the file
|
||||
/// has been rotated - resets to 0 to read from beginning.
|
||||
/// Returns 0 if no position has been recorded yet.
|
||||
/// </summary>
|
||||
public long GetSafePosition(string filePath, long currentFileSize)
|
||||
{
|
||||
FilePositionState state;
|
||||
@@ -144,6 +250,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One bookmark that followed its file to a new path.</summary>
|
||||
public sealed class BookmarkMove
|
||||
{
|
||||
public BookmarkMove(string from, string to, long position)
|
||||
{
|
||||
From = from;
|
||||
To = to;
|
||||
Position = position;
|
||||
}
|
||||
|
||||
public string From { get; }
|
||||
public string To { get; }
|
||||
public long Position { get; }
|
||||
}
|
||||
|
||||
public class FilePositionState
|
||||
{
|
||||
public long Position { get; set; }
|
||||
|
||||
+11
@@ -80,5 +80,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
return email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase)
|
||||
|| email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The domain part of a sign-in address, or "unknown" when there is none. Telemetry
|
||||
/// carries this instead of the address: the part before the last '@' stays on the device.
|
||||
/// </summary>
|
||||
internal static string ExtractUserDomain(string email)
|
||||
{
|
||||
if (string.IsNullOrEmpty(email)) return "unknown";
|
||||
var at = email.LastIndexOf('@');
|
||||
return at >= 0 && at + 1 < email.Length ? email.Substring(at + 1) : "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -225,7 +225,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// Check against exclusion list
|
||||
if (IsExcludedUser(owner))
|
||||
{
|
||||
_logger.Debug($"DesktopArrivalDetector: explorer.exe PID {proc.Id} owned by excluded user '{owner}' — skipping");
|
||||
_logger.Debug($"DesktopArrivalDetector: explorer.exe PID {proc.Id} owned by excluded user '{owner}' — skipping", repeatKey: "DesktopArrivalDetector.excludedUser");
|
||||
|
||||
// Trace this decision once so it's visible in the backend
|
||||
if (!_excludedUserTraced)
|
||||
@@ -238,7 +238,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
$"explorer.exe found but owned by excluded user '{owner}' — not a real user desktop",
|
||||
new Dictionary<string, object> { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", owner } });
|
||||
}
|
||||
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); }
|
||||
catch (Exception ex) { _logger.Debug($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -254,7 +254,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
$"Real user desktop detected (explorer.exe PID {proc.Id}, user '[redacted]')",
|
||||
new Dictionary<string, object> { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", "[redacted]" } });
|
||||
}
|
||||
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); }
|
||||
catch (Exception ex) { _logger.Debug($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); }
|
||||
|
||||
// First-poll observability snapshot still fires before we stop polling —
|
||||
// there is value in distinguishing "found on first poll" (Stage already
|
||||
@@ -335,7 +335,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{ "wtsErrorCount", _wtsErrorCountSinceStart },
|
||||
});
|
||||
}
|
||||
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: first-poll emit failed: {ex.Message}"); }
|
||||
catch (Exception ex) { _logger.Debug($"DesktopArrivalDetector: first-poll emit failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -371,7 +371,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{ "wtsErrorCount", _wtsErrorCountSinceStart },
|
||||
});
|
||||
}
|
||||
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: no-candidate emit failed: {ex.Message}"); }
|
||||
catch (Exception ex) { _logger.Debug($"DesktopArrivalDetector: no-candidate emit failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -393,7 +393,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{ "startReason", startReason },
|
||||
});
|
||||
}
|
||||
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: detector-started emit failed: {ex.Message}"); }
|
||||
catch (Exception ex) { _logger.Debug($"DesktopArrivalDetector: detector-started emit failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -445,7 +445,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Verbose($"DesktopArrivalDetector: OOBE state check failed: {ex.Message}");
|
||||
_logger.Debug($"DesktopArrivalDetector: OOBE state check failed: {ex.Message}", repeatKey: "DesktopArrivalDetector.oobeCheckFailed");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -471,7 +471,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_logger.Info("Stopping ESP and Hello tracker");
|
||||
// Hosts call Stop and then Dispose, which stops again; the second pass finds nothing.
|
||||
if (_helloTracker != null || _shellCoreTracker != null || _provisioningTracker != null || _modernDeploymentTracker != null)
|
||||
_logger.Info("Stopping ESP and Hello tracker");
|
||||
|
||||
DisposeTracker(ref _modernDeploymentTracker, "ModernDeployment", t => t.Stop());
|
||||
DisposeProvisioningTracker();
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
if (rootKey == null)
|
||||
{
|
||||
logger?.Debug("EspPolicyProviderProbe: EnrollmentStatusTracking registry key not found");
|
||||
logger?.Debug("EspPolicyProviderProbe: EnrollmentStatusTracking registry key not found", repeatKey: "EspPolicyProviderProbe.noTrackingKey");
|
||||
return EspPolicyProviderSnapshot.Empty;
|
||||
}
|
||||
|
||||
|
||||
+11
-8
@@ -418,7 +418,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug("Periodic Hello policy check: No WHfB policy found yet - will check again");
|
||||
_logger.Trace("Periodic Hello policy check: No WHfB policy found yet - will check again");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,7 +636,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
severity = EventSeverity.Info;
|
||||
message = "Windows Hello for Business provisioned successfully - NGC key registered";
|
||||
shouldTriggerHelloCompleted = MarkHelloCompleted();
|
||||
_logger.Info("Windows Hello provisioning COMPLETED successfully");
|
||||
// A repeat (live plus backfill) is already reported by MarkHelloCompleted.
|
||||
if (shouldTriggerHelloCompleted) _logger.Info("Windows Hello provisioning COMPLETED successfully");
|
||||
break;
|
||||
|
||||
case EventId_NgcKeyRegistrationFailed: // 301
|
||||
@@ -644,7 +645,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
severity = EventSeverity.Error;
|
||||
message = "Windows Hello for Business provisioning failed - NGC key registration error";
|
||||
shouldTriggerHelloCompleted = MarkHelloCompleted();
|
||||
_logger.Info("Windows Hello provisioning COMPLETED with failure");
|
||||
if (shouldTriggerHelloCompleted) _logger.Info("Windows Hello provisioning COMPLETED with failure");
|
||||
break;
|
||||
|
||||
case EventId_ProvisioningWillNotLaunch: // 360
|
||||
@@ -658,7 +659,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
severity = EventSeverity.Warning;
|
||||
message = "Windows Hello for Business provisioning blocked";
|
||||
shouldTriggerHelloCompleted = MarkHelloCompleted();
|
||||
_logger.Info("Windows Hello provisioning COMPLETED (blocked)");
|
||||
if (shouldTriggerHelloCompleted) _logger.Info("Windows Hello provisioning COMPLETED (blocked)");
|
||||
break;
|
||||
|
||||
case EventId_PinStatus: // 376
|
||||
@@ -719,12 +720,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|| eventType == Constants.EventTypes.DeviceRegistrationEvent
|
||||
});
|
||||
|
||||
_logger.Info($"Hello event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
|
||||
_logger.Debug($"Hello event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
|
||||
|
||||
if (shouldTriggerHelloCompleted)
|
||||
{
|
||||
EmitHelloPolicyDetectionMismatch(eventType, eventId, timestamp);
|
||||
try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { }
|
||||
try { HelloCompleted?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Warning($"HelloTracker: HelloCompleted handler threw: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -969,12 +971,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
ImmediateUpload = shouldTriggerHelloCompleted
|
||||
});
|
||||
|
||||
_logger.Info($"HelloForBusiness event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
|
||||
_logger.Debug($"HelloForBusiness event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
|
||||
|
||||
if (shouldTriggerHelloCompleted)
|
||||
{
|
||||
EmitHelloPolicyDetectionMismatch(eventType, eventId, timestamp);
|
||||
try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { }
|
||||
try { HelloCompleted?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Warning($"HelloTracker: HelloCompleted handler threw: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -545,7 +545,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
if (description.IndexOf("WhiteGlove", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
_logger.Trace($"ManagementService Event 509 without WhiteGlove keyword — ignoring: {description}");
|
||||
_logger.Debug($"ManagementService Event 509 without WhiteGlove keyword — ignoring: {description}", repeatKey: "ModernDeploymentTracker.nonWhiteGlove509");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -624,7 +624,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
using (record)
|
||||
{
|
||||
found++;
|
||||
_logger.Info($"Backfill found ManagementService Event {record.Id} at {record.TimeCreated:O}");
|
||||
_logger.Debug($"Backfill found ManagementService Event {record.Id} at {record.TimeCreated:O}");
|
||||
ProcessRecord(record, "ManagementService", ManagementChannel, isBackfill: true);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -97,7 +97,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
hasValues: entries.Any(e => !oobeOnly || IsOobeName(e.Key)), line: line);
|
||||
}
|
||||
|
||||
/// <summary>One agent.log line per present key, one naming the absent keys.</summary>
|
||||
/// <summary>
|
||||
/// One agent.log line per present key, one naming the absent keys. Debug: registry detail
|
||||
/// behind the update page; the oobe_update_state event carries the content.
|
||||
/// </summary>
|
||||
public static void Log(AgentLogger logger, string moment, IReadOnlyList<KeyState> states)
|
||||
{
|
||||
if (logger == null || states == null) return;
|
||||
@@ -110,11 +113,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
absent.Add(state.Path);
|
||||
continue;
|
||||
}
|
||||
logger.Info($"OOBE update registry ({moment}): HKLM\\{state.Path}: {state.Line}");
|
||||
logger.Debug($"OOBE update registry ({moment}): HKLM\\{state.Path}: {state.Line}");
|
||||
}
|
||||
|
||||
if (absent.Count > 0)
|
||||
logger.Info($"OOBE update registry ({moment}): absent: {string.Join(", ", absent)}");
|
||||
logger.Debug($"OOBE update registry ({moment}): absent: {string.Join(", ", absent)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+8
-11
@@ -514,13 +514,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
changedCount++;
|
||||
bool isNew = !_lastProvisioningJson.ContainsKey(categoryName);
|
||||
// Guard: the raw-JSON interpolation is materialized regardless of log
|
||||
// level (Trace() only drops the string after it is built), and this runs
|
||||
// per changed category every poll — skip the alloc when Trace is off.
|
||||
if (_logger.LogLevel >= AgentLogLevel.Trace)
|
||||
{
|
||||
_logger.Trace($"CheckProvisioningStatus: {categoryName} — {(isNew ? "NEW" : "CHANGED")} (json length={jsonValue.Length})");
|
||||
_logger.Trace($"CheckProvisioningStatus: {categoryName} — raw JSON: {jsonValue}");
|
||||
}
|
||||
// level (Verbose() only drops the string after it is built), and this runs
|
||||
// per changed category every poll — skip the alloc when Verbose is off.
|
||||
if (_logger.LogLevel >= AgentLogLevel.Verbose)
|
||||
_logger.Verbose($"CheckProvisioningStatus: {categoryName} {(isNew ? "new" : "changed")}, raw JSON: {jsonValue}");
|
||||
|
||||
_lastProvisioningJson[categoryName] = jsonValue;
|
||||
// TryFireProvisioningFailure (inside ProcessCategoryStatus) arms the
|
||||
@@ -1007,7 +1004,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
bool isFirstSeen = !_provisioningCategorySeen.Contains(categoryName);
|
||||
bool categorySucceededChanged = HasCategorySucceededChanged(categoryName, categorySucceeded);
|
||||
|
||||
_logger.Trace($"ProcessCategory: {categoryLabel} — succeeded={categorySucceeded?.ToString() ?? "null"}, " +
|
||||
_logger.Verbose($"ProcessCategory: {categoryLabel} — succeeded={categorySucceeded?.ToString() ?? "null"}, " +
|
||||
$"isFirstSeen={isFirstSeen}, succeededChanged={categorySucceededChanged}, " +
|
||||
$"subcategories={subcategories.Count} [{string.Join(", ", subcategories.Select(s => $"{s.Name}={s.State}"))}]");
|
||||
|
||||
@@ -1050,7 +1047,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var failureTransitions = transitions.Where(t => t.IsFailure).ToList();
|
||||
if (transitions.Count > 0)
|
||||
{
|
||||
_logger.Trace($"ProcessCategory: {categoryLabel} — subcategory transitions: " +
|
||||
_logger.Debug($"ProcessCategory: {categoryLabel} — subcategory transitions: " +
|
||||
string.Join(", ", transitions.Select(t => $"{t.SubcategoryName}: {t.OldState}→{t.NewState} (failure={t.IsFailure})")));
|
||||
}
|
||||
|
||||
@@ -1115,7 +1112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
var errorCode = TryExtractErrorCode(failedSubcategoryInfo?.StatusText);
|
||||
|
||||
_logger.Info(
|
||||
_logger.Debug(
|
||||
$"Provisioning subcategory failure: {categoryLabel}/{failedSubcategoryName ?? "category-level"} — " +
|
||||
$"escalating as {failureTypeName} (errorCode={errorCode ?? "n/a"})");
|
||||
_logger.Warning($"Provisioning failure detected: {failureTypeName}");
|
||||
@@ -1886,7 +1883,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Data = eventData
|
||||
});
|
||||
|
||||
_logger.Info($"Provisioning status event: {categoryLabel} — {statusText} (succeeded={succeeded?.ToString() ?? "in_progress"})");
|
||||
_logger.Info($"Provisioning status event: {categoryLabel} — {statusText} (succeeded={succeeded?.ToString() ?? "in_progress"})", repeatKey: "ProvisioningStatus.event:" + categoryLabel);
|
||||
}
|
||||
|
||||
private void EmitRawRegistryDump(string categoryLabel, string rawJson, string trigger)
|
||||
|
||||
+72
-27
@@ -114,6 +114,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// fire once; <see cref="RealmJoinPackageStarted"/> / <see cref="RealmJoinPackageCompleted"/>
|
||||
/// fire once per <c>(scope, packageId)</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="RealmJoinDetected"/> carries the phase RJ is in when the service key is first
|
||||
/// seen in this process — Blank (0) only while the value is not written yet. After an agent
|
||||
/// restart RJ may already be deploying: the engine's restart path and the keep-awake
|
||||
/// hand-over to RJ both read the phase from Detected, because no PhaseChanged follows when
|
||||
/// the phase does not change again.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RealmJoinWatcher : IDisposable
|
||||
{
|
||||
@@ -127,6 +134,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
internal delegate bool KeyExistsDelegate(RegistryHive hive, string subPath);
|
||||
internal delegate RealmJoinVersionInfo ReadVersionInfoDelegate();
|
||||
internal delegate string? ReadUninstallDisplayVersionDelegate();
|
||||
internal delegate int? ReadDeploymentPhaseDelegate();
|
||||
|
||||
/// <summary>
|
||||
/// Debounce for the Uninstall-key watcher. Longer than <see cref="DebounceMilliseconds"/>:
|
||||
@@ -141,6 +149,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly KeyExistsDelegate _keyExists;
|
||||
private readonly ReadVersionInfoDelegate _readVersionInfo;
|
||||
private readonly ReadUninstallDisplayVersionDelegate _readUninstallDisplayVersion;
|
||||
private readonly ReadDeploymentPhaseDelegate _readDeploymentPhase;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
// Auto-update stage (armed once Detected fired, held until Stop).
|
||||
@@ -185,6 +194,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// subtree) into RJ's lifecycle.
|
||||
private bool _packageWatchersArmed;
|
||||
private bool _disposed;
|
||||
// Set by Stop under _stateLock: a registry callback that is still running then must not
|
||||
// arm or attach another watcher.
|
||||
private bool _stopped;
|
||||
|
||||
public event EventHandler<RealmJoinDetectedEventArgs>? RealmJoinDetected;
|
||||
public event EventHandler<RealmJoinResolvedEventArgs>? RealmJoinResolved;
|
||||
@@ -204,7 +216,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
TryReadPackageDelegate tryReadPackage,
|
||||
KeyExistsDelegate keyExists,
|
||||
ReadVersionInfoDelegate? readVersionInfo = null,
|
||||
ReadUninstallDisplayVersionDelegate? readUninstallDisplayVersion = null)
|
||||
ReadUninstallDisplayVersionDelegate? readUninstallDisplayVersion = null,
|
||||
ReadDeploymentPhaseDelegate? readDeploymentPhase = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_enumeratePackageIds = enumeratePackageIds ?? throw new ArgumentNullException(nameof(enumeratePackageIds));
|
||||
@@ -212,11 +225,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_keyExists = keyExists ?? throw new ArgumentNullException(nameof(keyExists));
|
||||
_readVersionInfo = readVersionInfo ?? RealmJoinInfo.TryReadRealmJoinVersionInfo;
|
||||
_readUninstallDisplayVersion = readUninstallDisplayVersion ?? RealmJoinInfo.TryReadUninstallDisplayVersion;
|
||||
_readDeploymentPhase = readDeploymentPhase ?? (() => RealmJoinInfo.TryReadDeploymentPhase(RegistryHive.LocalMachine));
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(RealmJoinWatcher));
|
||||
lock (_stateLock) { _stopped = false; }
|
||||
|
||||
// The Services\realmjoin gate is the SOLE entry point. HKLM\SOFTWARE\RealmJoin and
|
||||
// the HKU watcher do not run until this fires — RJ presence is the prerequisite for
|
||||
@@ -266,24 +281,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
try
|
||||
{
|
||||
var watchers = new List<RegistryWatcher>(8);
|
||||
lock (_stateLock)
|
||||
{
|
||||
DisposeAndNull(ref _servicesAppearanceWatcher);
|
||||
DisposeAndNull(ref _machineSoftwareAppearanceWatcher);
|
||||
DisposeAndNull(ref _userSoftwareAppearanceWatcher);
|
||||
DisposeAndNull(ref _uninstallAppearanceWatcher);
|
||||
_stopped = true;
|
||||
|
||||
Take(ref _servicesAppearanceWatcher, watchers);
|
||||
Take(ref _machineSoftwareAppearanceWatcher, watchers);
|
||||
Take(ref _userSoftwareAppearanceWatcher, watchers);
|
||||
Take(ref _uninstallAppearanceWatcher, watchers);
|
||||
|
||||
_serviceRealmjoinDebounce?.Dispose(); _serviceRealmjoinDebounce = null;
|
||||
_machineRealmJoinDebounce?.Dispose(); _machineRealmJoinDebounce = null;
|
||||
_userRealmJoinDebounce?.Dispose(); _userRealmJoinDebounce = null;
|
||||
_uninstallDebounce?.Dispose(); _uninstallDebounce = null;
|
||||
|
||||
DisposeAndNull(ref _serviceRealmjoinWatcher);
|
||||
DisposeAndNull(ref _machineRealmJoinWatcher);
|
||||
DisposeAndNull(ref _userRealmJoinWatcher);
|
||||
DisposeAndNull(ref _uninstallWatcher);
|
||||
Take(ref _serviceRealmjoinWatcher, watchers);
|
||||
Take(ref _machineRealmJoinWatcher, watchers);
|
||||
Take(ref _userRealmJoinWatcher, watchers);
|
||||
Take(ref _uninstallWatcher, watchers);
|
||||
}
|
||||
_logger.Info($"RealmJoinWatcher: stopped ({reason})");
|
||||
|
||||
// Disposed outside the lock: RegistryWatcher.Dispose waits for a callback that is
|
||||
// still running, and the appearance callback takes _stateLock.
|
||||
foreach (var watcher in watchers)
|
||||
{
|
||||
try { watcher.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
// Hosts call Stop and then Dispose, which stops again; only the pass that
|
||||
// stopped a watcher logs.
|
||||
if (watchers.Count > 0) _logger.Info($"RealmJoinWatcher: stopped ({reason})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -377,6 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Action clearSlot,
|
||||
Action onAppeared)
|
||||
{
|
||||
lock (_stateLock) { if (_stopped) return; }
|
||||
try
|
||||
{
|
||||
var watcher = new RegistryWatcher(
|
||||
@@ -426,7 +455,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_serviceRealmjoinWatcher != null) return;
|
||||
if (_stopped || _serviceRealmjoinWatcher != null) return;
|
||||
try
|
||||
{
|
||||
_serviceRealmjoinDebounce = new Timer(_ => CheckParameters(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
@@ -449,21 +478,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
// The appearance of Services\realmjoin is itself the RJ-detected signal —
|
||||
// surface it even if Parameters\DeploymentPhase isn't populated yet. The package
|
||||
// watchers (HKLM + HKU SOFTWARE\RealmJoin) intentionally stay disarmed here:
|
||||
// NotifyRealmJoinPresence arms them once DeploymentPhase >= PhaseRunningThresholdMin
|
||||
// is observed. Before that the same subtree may still receive in-flight IME
|
||||
// package writes that are not part of RJ's lifecycle.
|
||||
NotifyRealmJoinPresence(phase: null);
|
||||
CheckParameters();
|
||||
NotifyServiceStageAttached();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The appearance of Services\realmjoin is itself the RJ-detected signal — surfaced even
|
||||
/// if Parameters\DeploymentPhase isn't populated yet. One read feeds Detected, so it
|
||||
/// carries the phase RJ is in right now (see the class remarks). The package watchers
|
||||
/// (HKLM + HKU SOFTWARE\RealmJoin) stay disarmed below PhaseRunningThresholdMin: before
|
||||
/// that the same subtree may still receive in-flight IME package writes that are not
|
||||
/// part of RJ's lifecycle.
|
||||
/// </summary>
|
||||
private void NotifyServiceStageAttached()
|
||||
{
|
||||
int? phase = null;
|
||||
try { phase = _readDeploymentPhase(); }
|
||||
catch (Exception ex) { _logger.Warning($"RealmJoinWatcher: DeploymentPhase read at attach threw: {ex.Message}"); }
|
||||
NotifyRealmJoinPresence(phase);
|
||||
}
|
||||
|
||||
private void AttachMachineRealmJoinSubtreeWatcher()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_machineRealmJoinWatcher != null) return;
|
||||
if (_stopped || _machineRealmJoinWatcher != null) return;
|
||||
try
|
||||
{
|
||||
// Seed dedup sets with every package sub-key already present at arming time.
|
||||
@@ -506,7 +544,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_userRealmJoinWatcher != null) return;
|
||||
if (_stopped || _userRealmJoinWatcher != null) return;
|
||||
try
|
||||
{
|
||||
// Seed dedup sets — see AttachMachineRealmJoinSubtreeWatcher for rationale.
|
||||
@@ -547,7 +585,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
try
|
||||
{
|
||||
var phase = RealmJoinInfo.TryReadDeploymentPhase(RegistryHive.LocalMachine);
|
||||
var phase = _readDeploymentPhase();
|
||||
if (phase == null) return;
|
||||
NotifyRealmJoinPresence(phase);
|
||||
}
|
||||
@@ -577,6 +615,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
fireDetected = !_detectedFired;
|
||||
if (fireDetected) _detectedFired = true;
|
||||
|
||||
// Detected reports Blank (0) while the value is not written yet. That report is
|
||||
// the baseline, so a first real value of 100+ surfaces as PhaseChanged instead of
|
||||
// silently setting _lastPhase.
|
||||
if (fireDetected && !phase.HasValue && !_lastPhase.HasValue) _lastPhase = 0;
|
||||
|
||||
if (phase.HasValue)
|
||||
{
|
||||
if (_lastPhase.HasValue && _lastPhase.Value != phase.Value)
|
||||
@@ -683,7 +726,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_disposed || _uninstallWatcher != null) return;
|
||||
if (_disposed || _stopped || _uninstallWatcher != null) return;
|
||||
try
|
||||
{
|
||||
_uninstallDebounce = new Timer(_ => CheckAutoUpdate(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
@@ -913,15 +956,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
private static void DisposeAndNull(ref RegistryWatcher? w)
|
||||
// Caller holds _stateLock; the taken watchers are disposed after it is released.
|
||||
private static void Take(ref RegistryWatcher? slot, List<RegistryWatcher> taken)
|
||||
{
|
||||
if (w == null) return;
|
||||
try { w.Dispose(); } catch { }
|
||||
w = null;
|
||||
if (slot == null) return;
|
||||
taken.Add(slot);
|
||||
slot = null;
|
||||
}
|
||||
|
||||
// Test seams — drive the read paths deterministically without touching the real registry.
|
||||
internal void TriggerCheckParametersFromTest() => CheckParameters();
|
||||
internal void TriggerServiceStageAttachedFromTest() => NotifyServiceStageAttached();
|
||||
internal void TriggerCheckMachinePackagesFromTest() => CheckMachinePackages();
|
||||
internal void TriggerCheckUserPackagesFromTest() => CheckUserPackages();
|
||||
|
||||
|
||||
+1
-1
@@ -384,7 +384,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
_logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})");
|
||||
_logger.Debug($"Shell-Core event detected: {eventType} (EventID {eventId})");
|
||||
|
||||
// Set the source-event timestamp BEFORE each event raise so adapters / coordinators
|
||||
// can read it during their synchronous handler. Cleared in finally so a stale value
|
||||
|
||||
+1
-1
@@ -568,7 +568,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace($"StallProbeCollector: eventlog scan of {channel} failed: {ex.Message}");
|
||||
_logger.Debug($"StallProbeCollector: eventlog scan of {channel} failed: {ex.Message}", repeatKey: "StallProbeCollector.scanFailed:" + channel);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
/// Win32 file-handle declarations for <c>PinnedSourceFolder</c>: open a file or directory
|
||||
/// without following a reparse point at its final component, and ask the handle where it
|
||||
/// really points (<c>GetFinalPathNameByHandle</c>) instead of trusting the path string.
|
||||
/// <c>ImeLogFileIdentity</c> reads a file's identity through the same declarations.
|
||||
/// </summary>
|
||||
internal static class FileNativeMethods
|
||||
{
|
||||
@@ -18,6 +19,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
|
||||
public const uint FILE_SHARE_READ = 0x1;
|
||||
public const uint FILE_SHARE_WRITE = 0x2;
|
||||
public const uint FILE_SHARE_DELETE = 0x4;
|
||||
|
||||
public const uint OPEN_EXISTING = 3;
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Full image path of another process through <c>QueryFullProcessImageName</c>. It needs only
|
||||
/// <c>PROCESS_QUERY_LIMITED_INFORMATION</c> and resolves across bitness, where
|
||||
/// <c>Process.MainModule</c> throws.
|
||||
/// </summary>
|
||||
internal static class ProcessImagePath
|
||||
{
|
||||
private const int PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr OpenProcess(int desiredAccess, bool inheritHandle, int processId);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CloseHandle(IntPtr handle);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool QueryFullProcessImageName(IntPtr process, int flags, StringBuilder exeName, ref int size);
|
||||
|
||||
/// <summary>The path, or <c>null</c> when the process cannot be opened or queried.</summary>
|
||||
public static string Query(int pid)
|
||||
{
|
||||
if (pid <= 0) return null;
|
||||
var handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
|
||||
if (handle == IntPtr.Zero) return null;
|
||||
try
|
||||
{
|
||||
var sb = new StringBuilder(1024);
|
||||
int size = sb.Capacity;
|
||||
return QueryFullProcessImageName(handle, 0, sb, ref size) && size > 0 ? sb.ToString(0, size) : null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Management;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
@@ -121,5 +122,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owner SID of a <c>Win32_Process</c> row via <c>GetOwnerSid</c>, or <c>null</c> on failure.
|
||||
/// The row must have been selected with <c>Handle</c>: without its key property WMI cannot
|
||||
/// invoke a method on the object and throws.
|
||||
/// </summary>
|
||||
public static string OwnerSid(ManagementObject process)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var result = process.InvokeMethod("GetOwnerSid", null, null))
|
||||
{
|
||||
if (result != null && Convert.ToUInt32(result["ReturnValue"]) == 0)
|
||||
return result["Sid"]?.ToString();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process gone or owner not readable.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
internal static class WinHttpNativeMethods
|
||||
{
|
||||
public const int ERROR_FILE_NOT_FOUND = 2;
|
||||
public const uint WINHTTP_ACCESS_TYPE_NAMED_PROXY = 3;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct WINHTTP_CURRENT_USER_IE_PROXY_CONFIG
|
||||
{
|
||||
[MarshalAs(UnmanagedType.Bool)]
|
||||
public bool fAutoDetect;
|
||||
public IntPtr lpszAutoConfigUrl;
|
||||
public IntPtr lpszProxy;
|
||||
public IntPtr lpszProxyBypass;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct WINHTTP_PROXY_INFO
|
||||
{
|
||||
public uint dwAccessType;
|
||||
public IntPtr lpszProxy;
|
||||
public IntPtr lpszProxyBypass;
|
||||
}
|
||||
|
||||
[DllImport("winhttp.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool WinHttpGetIEProxyConfigForCurrentUser(ref WINHTTP_CURRENT_USER_IE_PROXY_CONFIG config);
|
||||
|
||||
[DllImport("winhttp.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool WinHttpGetDefaultProxyConfiguration(ref WINHTTP_PROXY_INFO info);
|
||||
|
||||
// WinHTTP allocates the strings of both structs with GlobalAlloc; the caller frees them.
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern IntPtr GlobalFree(IntPtr hMem);
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -344,7 +344,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
_logger.Info($"Diagnostics package created: {zipFileName} ({zipBytes.Length / 1024} KB)");
|
||||
|
||||
// Fetch a short-lived upload URL just before uploading — never stored in config or on disk
|
||||
_logger.Info("Requesting diagnostics upload URL from backend...");
|
||||
_logger.Debug("Requesting diagnostics upload URL from backend...");
|
||||
var uploadUrlResponse = await _apiClient.GetDiagnosticsUploadUrlAsync(
|
||||
_configuration.TenantId,
|
||||
_configuration.SessionId,
|
||||
@@ -708,7 +708,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
// Expected outcome, not a packaging problem: the subtree is off limits by design.
|
||||
foreach (var refused in refusedDirectories)
|
||||
{
|
||||
_logger.Info($"Diagnostics subfolder skipped by guard: {refused}");
|
||||
_logger.Debug($"Diagnostics subfolder skipped by guard: {refused}");
|
||||
ManifestLine($"SKIPPED DIR (path guard): {refused}");
|
||||
}
|
||||
|
||||
@@ -800,7 +800,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
|
||||
tracker.RecordIncluded(length);
|
||||
_logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
|
||||
_logger.Verbose($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
|
||||
ManifestLine($"ADDED: {entryName} ({length} bytes)");
|
||||
}
|
||||
}
|
||||
@@ -1066,7 +1066,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info($"Uploading diagnostics package (attempt {attempt}/{maxRetries}, {data.Length / 1024} KB)...");
|
||||
_logger.Debug($"Uploading diagnostics package (attempt {attempt}/{maxRetries}, {data.Length / 1024} KB)...");
|
||||
|
||||
using var content = new ByteArrayContent(data);
|
||||
content.Headers.Add("x-ms-blob-type", "BlockBlob");
|
||||
@@ -1101,7 +1101,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (attempt < maxRetries)
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // 2s, 4s, 8s
|
||||
_logger.Info($"Retrying blob upload in {delay.TotalSeconds}s...");
|
||||
_logger.Debug($"Retrying blob upload in {delay.TotalSeconds}s...");
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
var dedupKey = BuildDedupKey(action);
|
||||
if (!_seenActionIds.TryAdd(dedupKey, 0))
|
||||
{
|
||||
_logger.Debug($"ServerActionDispatcher: duplicate action '{action.Type}' ({dedupKey}) — skipping");
|
||||
_logger.Debug($"ServerActionDispatcher: duplicate action '{action.Type}' ({dedupKey}) — skipping", repeatKey: "ServerActionDispatcher.duplicate:" + dedupKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -50,7 +50,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
using (explorerProcess)
|
||||
{
|
||||
sessionId = (uint)explorerProcess.SessionId;
|
||||
logger.Info($"UserSessionProcessLauncher: Found explorer.exe PID {explorerProcess.Id} in session {sessionId}");
|
||||
logger.Debug($"UserSessionProcessLauncher: Found explorer.exe PID {explorerProcess.Id} in session {sessionId}");
|
||||
}
|
||||
|
||||
// Get the interactive session token via WTS.
|
||||
@@ -80,7 +80,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.Info($"UserSessionProcessLauncher: Token duplicated and stamped to session {sessionId}");
|
||||
logger.Debug($"UserSessionProcessLauncher: Token duplicated and stamped to session {sessionId}");
|
||||
|
||||
// Create an environment block for the user (using session-stamped token)
|
||||
if (!ProcessNativeMethods.CreateEnvironmentBlock(out environment, dupToken, false))
|
||||
@@ -121,7 +121,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
cxhProcess = FindProcessInSession("CloudExperienceHost", sessionId);
|
||||
if (cxhProcess != null)
|
||||
logger.Info($"UserSessionProcessLauncher: CloudExperienceHost PID {cxhProcess.Id} running " +
|
||||
logger.Debug($"UserSessionProcessLauncher: CloudExperienceHost PID {cxhProcess.Id} running " +
|
||||
$"in session {sessionId} — will monitor for exit during retries");
|
||||
}
|
||||
catch { }
|
||||
@@ -162,7 +162,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
|
||||
ProcessNativeMethods.CloseHandle(pi.hThread);
|
||||
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — created PID {pi.dwProcessId}" +
|
||||
logger.Debug($"UserSessionProcessLauncher: Attempt {attempt} — created PID {pi.dwProcessId}" +
|
||||
$" [{(useExplicitDesktop ? "explicit" : "default")} desktop], verifying startup...");
|
||||
|
||||
// Wait 3s to verify the process survives DLL init + CLR bootstrap.
|
||||
@@ -190,10 +190,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
// On 23H2: CXH never exits → WaitForExit times out → same as regular retry interval.
|
||||
if (cxhProcess != null)
|
||||
{
|
||||
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A, waiting for CXH exit (up to {retryIntervalSeconds}s)...");
|
||||
logger.Debug($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A, waiting for CXH exit (up to {retryIntervalSeconds}s)...");
|
||||
if (cxhProcess.WaitForExit(retryIntervalSeconds * 1000))
|
||||
{
|
||||
logger.Info("UserSessionProcessLauncher: CloudExperienceHost exited — desktop should be available");
|
||||
logger.Debug("UserSessionProcessLauncher: CloudExperienceHost exited — desktop should be available");
|
||||
cxhProcess.Dispose();
|
||||
cxhProcess = null;
|
||||
System.Threading.Thread.Sleep(500); // brief delay for desktop init
|
||||
@@ -201,7 +201,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A (desktop locked?) — retrying in {retryIntervalSeconds}s...");
|
||||
logger.Debug($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A (desktop locked?) — retrying in {retryIntervalSeconds}s...");
|
||||
System.Threading.Thread.Sleep(retryIntervalSeconds * 1000);
|
||||
}
|
||||
continue;
|
||||
|
||||
+17
-5
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -31,7 +32,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// This analyzer reports facts ONLY, always at <see cref="EventSeverity.Info"/>. It never reads
|
||||
/// or emits the <c>DefaultPassword</c> value — only its presence. Whether an AutoLogon is a
|
||||
/// problem is judged by backend analyze-rules (ANALYZE-SEC-003 = plaintext DefaultPassword on
|
||||
/// disk → escalated; ANALYZE-SEC-004 = optional kiosk allow-list template).
|
||||
/// disk → escalated).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Trigger</b>: NOT at agent start — the Winlogon keys are written later by a provisioning
|
||||
@@ -81,7 +82,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// <summary>Final-shutdown scan — captures any AutoLogon written during the user phase.</summary>
|
||||
public void AnalyzeAtShutdown()
|
||||
{
|
||||
_logger.Info($"{Name}: Running shutdown AutoLogon scan");
|
||||
_logger.Debug($"{Name}: Running shutdown AutoLogon scan");
|
||||
RunScan("shutdown");
|
||||
}
|
||||
|
||||
@@ -91,7 +92,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// </summary>
|
||||
public void AnalyzeAtDeviceSetupComplete()
|
||||
{
|
||||
_logger.Info($"{Name}: Running device-setup-complete AutoLogon scan");
|
||||
_logger.Debug($"{Name}: Running device-setup-complete AutoLogon scan");
|
||||
RunScan("device_setup_complete");
|
||||
}
|
||||
|
||||
@@ -218,9 +219,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{ "force_auto_logon", snap.ForceAutoLogon ?? string.Empty },
|
||||
{ "force_auto_logon_enabled", forceAutoLogonEnabled },
|
||||
{ "autologon_enabled", autologonEnabled },
|
||||
{ "default_user_name", snap.DefaultUserName ?? string.Empty },
|
||||
{ "default_user_name", ReduceUserName(snap.DefaultUserName) },
|
||||
{ "default_domain_name", snap.DefaultDomainName ?? string.Empty },
|
||||
{ "alt_default_user_name", snap.AltDefaultUserName ?? string.Empty },
|
||||
{ "alt_default_user_name", ReduceUserName(snap.AltDefaultUserName) },
|
||||
{ "alt_default_domain_name", snap.AltDefaultDomainName ?? string.Empty },
|
||||
{ "auto_logon_sid", snap.AutoLogonSid ?? string.Empty },
|
||||
{ "auto_logon_count", snap.AutoLogonCount.HasValue ? (object)snap.AutoLogonCount.Value : string.Empty },
|
||||
@@ -244,6 +245,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
// Small fail-soft helpers.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A sign-in address is reported as its domain only. Local account names (kiosk,
|
||||
/// defaultuser0) and the provisioning placeholders stay readable.
|
||||
/// </summary>
|
||||
internal static string ReduceUserName(string? userName)
|
||||
{
|
||||
if (userName is not string name || name.Length == 0) return string.Empty;
|
||||
if (name.IndexOf('@') < 0 || AadJoinInfo.IsPlaceholderUserEmail(name)) return name;
|
||||
return "[redacted]@" + AadJoinInfo.ExtractUserDomain(name);
|
||||
}
|
||||
|
||||
/// <summary>AutoLogon flags are REG_SZ "0"/"1"; treat "1"/"true" (any case) as enabled.</summary>
|
||||
private static bool IsTruthy(string? value)
|
||||
{
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
{
|
||||
_logger.Info($"{Name}: scanning prefetch for a post-boot cmd.exe console artifact");
|
||||
_logger.Debug($"{Name}: scanning prefetch for a post-boot cmd.exe console artifact");
|
||||
try { RunScan(); }
|
||||
catch (Exception ex) { _logger.Error($"{Name}: scan failed unexpectedly", ex); }
|
||||
}
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
{
|
||||
_logger.Info($"{Name}: Running startup analysis");
|
||||
_logger.Debug($"{Name}: Running startup analysis");
|
||||
RunAnalysis("startup");
|
||||
}
|
||||
|
||||
|
||||
+239
-129
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
@@ -36,21 +37,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// (<c>builtin_administrator_enabled</c>) but not scored: whether OOBE enables it
|
||||
/// transiently is not established, so the field first collects evidence.
|
||||
///
|
||||
/// Built-in accounts are recognised by well-known RID (500/501/503/504 on the machine SID)
|
||||
/// in addition to their English names, so localized installs (Gast, Invité, Administrateur)
|
||||
/// do not flag their own disabled Guest/Administrator.
|
||||
/// Allowed list (<see cref="LocalAdminAllowList"/> built-ins plus the tenant's entries): an entry
|
||||
/// allows an account in any role, or — <c>name|user</c> — only while it is not an
|
||||
/// Administrators member. A restriction always wins over an entry that allows more. The four
|
||||
/// built-in accounts are decided by their well-known RID alone (500 any role; 501/503/504 never
|
||||
/// an administrator), so localized names (Gast, Administrateur) are recognised and no name entry
|
||||
/// can lift or impose a role on them. Profile folders have no role.
|
||||
///
|
||||
/// At shutdown the logged-in users are allowed dynamically — but only identities that are
|
||||
/// NOT local SAM accounts (Entra / domain users, whose profile folder is the only artifact
|
||||
/// they leave). A logged-in local account is exactly the delta this analyzer exists to
|
||||
/// catch and is never exempted by its own login (CWE-807: the login state is under the
|
||||
/// At shutdown the logged-in users are allowed dynamically — as profile folders only, and only
|
||||
/// identities that are NOT local SAM accounts (Entra / domain users, whose profile folder is the
|
||||
/// only artifact they leave). A logged-in local account is exactly the delta this analyzer exists
|
||||
/// to catch and is never exempted by its own login (CWE-807: the login state is under the
|
||||
/// adversary's control); it is reported as <c>logged_in_local_accounts</c>.
|
||||
///
|
||||
/// Emits a single "local_admin_analysis" event at startup and at shutdown,
|
||||
/// enabling delta detection between pre- and post-enrollment state.
|
||||
///
|
||||
/// Tenant-supplied allowed accounts may contain glob wildcards (e.g. "adm-*");
|
||||
/// see <see cref="MatchesAllowedEntry"/>.
|
||||
/// </summary>
|
||||
public class LocalAdminAnalyzer : IAgentAnalyzer
|
||||
{
|
||||
@@ -58,26 +59,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly List<string> _allowedAccounts;
|
||||
|
||||
// Built-in accounts and profile folders always present on a freshly imaged Windows device.
|
||||
// "Public", "Default", "Default User", "All Users" are folders/junctions in C:\Users, not user accounts.
|
||||
// "defaultuser0" is a temporary OOBE/Autopilot system account created during enrollment.
|
||||
private static readonly List<string> BuiltInAllowedAccounts = new List<string>
|
||||
{
|
||||
"Administrator",
|
||||
"Guest",
|
||||
"DefaultAccount",
|
||||
"WDAGUtilityAccount",
|
||||
"defaultuser0", // Temporary OOBE/Autopilot system account, present during enrollment
|
||||
"defaultuser1", // Sometimes seen in OOBE, but not always present
|
||||
"defaultuser2", // Sometimes seen in OOBE, but not always present
|
||||
"kioskUser0", // Assigned Access (kiosk profile) autologon account, created by Windows
|
||||
"Public", // Profile folder (not a user account)
|
||||
"Default", // Default user profile template
|
||||
"Default User", // Symlink to Default in some Windows versions
|
||||
"All Users" // Junction pointing to C:\ProgramData, always present
|
||||
};
|
||||
private readonly List<AllowedEntry> _allowedEntries;
|
||||
|
||||
public string Name => "LocalAdminAnalyzer";
|
||||
|
||||
@@ -97,30 +79,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_startupGate = startupGate;
|
||||
|
||||
// Tenant-supplied accounts are additive (union with built-ins, not replacement)
|
||||
_allowedAccounts = new List<string>(BuiltInAllowedAccounts);
|
||||
if (allowedAccounts != null)
|
||||
{
|
||||
foreach (var account in allowedAccounts)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(account) &&
|
||||
!_allowedAccounts.Any(a => string.Equals(a, account, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_allowedAccounts.Add(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
var ignored = new List<string>();
|
||||
_allowedEntries = BuildAllowedEntries(allowedAccounts, ignored);
|
||||
foreach (var entry in ignored)
|
||||
_logger.Warning($"{Name}: Allowed-list entry '{entry}' is not valid (expected 'name' or 'name|user') — ignored, it allows nothing");
|
||||
}
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
{
|
||||
_logger.Info($"{Name}: Running startup analysis");
|
||||
_logger.Debug($"{Name}: Running startup analysis");
|
||||
RunAnalysis("startup", EnrollmentPhase.Unknown);
|
||||
}
|
||||
|
||||
public void AnalyzeAtShutdown()
|
||||
{
|
||||
_logger.Info($"{Name}: Running shutdown analysis");
|
||||
_logger.Debug($"{Name}: Running shutdown analysis");
|
||||
|
||||
// At shutdown a user has logged in — their profile folder is expected. Detect logged-in
|
||||
// users via explorer.exe owner (same technique as DesktopArrivalDetector); RunAnalysis
|
||||
@@ -149,46 +122,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
foreach (var user in loggedIn.LocalAccounts)
|
||||
_logger.Warning($"{Name}: Logged-in user '{user}' is a local account — not allowed dynamically");
|
||||
|
||||
// Build effective allowed list: static + dynamic (non-local logged-in users at shutdown)
|
||||
var effectiveAllowed = _allowedAccounts;
|
||||
// Effective list: static entries + the profile folders of non-local logged-in users at
|
||||
// shutdown. Folder-only: a login never allows a local ACCOUNT — not even when the WMI
|
||||
// inventory failed and the split above could not recognise a local name.
|
||||
var effectiveEntries = _allowedEntries;
|
||||
if (loggedIn.Allowed.Count > 0)
|
||||
{
|
||||
effectiveAllowed = new List<string>(_allowedAccounts);
|
||||
effectiveEntries = new List<AllowedEntry>(_allowedEntries);
|
||||
foreach (var user in loggedIn.Allowed)
|
||||
{
|
||||
if (!effectiveAllowed.Any(a => string.Equals(a, user, StringComparison.OrdinalIgnoreCase)))
|
||||
if (!effectiveEntries.Any(e => string.Equals(e.Pattern, user, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
effectiveAllowed.Add(user);
|
||||
_logger.Info($"{Name}: Dynamically allowing logged-in user: {user}");
|
||||
effectiveEntries.Add(new AllowedEntry(user, LocalAccountRole.Any, profileFolderOnly: true));
|
||||
_logger.Info($"{Name}: Dynamically allowing profile folder of logged-in user: {user}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bypassNroResult = CheckBypassNroRegistry();
|
||||
var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveAllowed);
|
||||
var profilesResult = CheckUserProfiles(effectiveAllowed);
|
||||
var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveEntries);
|
||||
var profilesResult = CheckUserProfiles(effectiveEntries);
|
||||
|
||||
int confidenceScore = 0;
|
||||
|
||||
if (bypassNroResult.Value == 1)
|
||||
confidenceScore += 20;
|
||||
|
||||
if (accountsResult.Unexpected.Count > 0)
|
||||
confidenceScore += 40;
|
||||
|
||||
// An unexpected account that is also a member of Administrators is the
|
||||
// actual backdoor (enabled or dormant) — high indicator on its own.
|
||||
if (accountsResult.UnexpectedAdminMembers.Count > 0)
|
||||
confidenceScore += 40;
|
||||
|
||||
// Profile overlap: unexpected account AND matching C:\Users folder
|
||||
bool profileOverlap = accountsResult.Unexpected.Any(a =>
|
||||
profilesResult.Unexpected.Any(p =>
|
||||
string.Equals(a, p, StringComparison.OrdinalIgnoreCase)));
|
||||
if (profileOverlap)
|
||||
confidenceScore += 40;
|
||||
|
||||
confidenceScore = Math.Min(confidenceScore, 100);
|
||||
var confidenceScore = ComputeConfidenceScore(
|
||||
bypassNroResult.Value == 1, accountsResult.Unexpected, accountsResult.UnexpectedAdminMembers, profilesResult.Unexpected);
|
||||
|
||||
EventSeverity severity;
|
||||
string findingLabel;
|
||||
@@ -219,6 +175,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
$"bypassNro={bypassNroResult.Value}, " +
|
||||
$"unexpectedAccounts={accountsResult.Unexpected.Count}, " +
|
||||
$"unexpectedAdminMembers={accountsResult.UnexpectedAdminMembers.Count}, " +
|
||||
$"roleViolations={accountsResult.RoleViolations.Count}, " +
|
||||
$"adminGroupEnumerated={accountsResult.AdministratorsGroupEnumerated}, " +
|
||||
$"builtinAdministratorEnabled={accountsResult.BuiltInAdministratorEnabled?.ToString() ?? "unknown"}, " +
|
||||
$"unexpectedProfiles={profilesResult.Unexpected.Count}");
|
||||
@@ -230,7 +187,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{ "finding", findingLabel },
|
||||
{ "triggered_at", trigger },
|
||||
{ "enrollment_phase_at_check", phase.ToString() },
|
||||
{ "allowed_accounts", effectiveAllowed },
|
||||
// Account entries in canonical form ("Guest|user"); the folder-only entries are
|
||||
// constant or listed in dynamically_allowed_users.
|
||||
{ "allowed_accounts", _allowedEntries.Where(e => !e.ProfileFolderOnly).Select(e => e.Display).ToList() },
|
||||
{ "dynamically_allowed_users", loggedIn.Allowed },
|
||||
{ "logged_in_local_accounts", loggedIn.LocalAccounts },
|
||||
{ "checks", new Dictionary<string, object>
|
||||
@@ -243,6 +202,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
},
|
||||
{ "unexpected_accounts", accountsResult.Unexpected },
|
||||
{ "unexpected_admin_members", accountsResult.UnexpectedAdminMembers },
|
||||
{ "role_violations", accountsResult.RoleViolations },
|
||||
{ "unexpected_profiles", profilesResult.Unexpected },
|
||||
{ "accounts_checked", accountsResult.AllChecked },
|
||||
{ "account_details", accountsResult.AccountDetails },
|
||||
@@ -299,31 +259,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence 0-100: BypassNRO +20, any unexpected account +40, any unexpected Administrators
|
||||
/// member +40 (the actual backdoor, enabled or dormant; a role violation is one), an unexpected
|
||||
/// account with a C:\Users folder of the same name +40. Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static int ComputeConfidenceScore(
|
||||
bool bypassNro, IList<string> unexpectedAccounts, IList<string> unexpectedAdminMembers, IList<string> unexpectedProfiles)
|
||||
{
|
||||
var score = 0;
|
||||
if (bypassNro)
|
||||
score += 20;
|
||||
if (unexpectedAccounts.Count > 0)
|
||||
score += 40;
|
||||
if (unexpectedAdminMembers.Count > 0)
|
||||
score += 40;
|
||||
if (unexpectedAccounts.Any(a => unexpectedProfiles.Any(p => string.Equals(a, p, StringComparison.OrdinalIgnoreCase))))
|
||||
score += 40;
|
||||
return Math.Min(score, 100);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Allowed-list matching
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Matches an account or profile-folder name against one allowed-list entry.
|
||||
/// Entries containing <c>*</c> or <c>?</c> are glob patterns (same semantics as the
|
||||
/// Matches an account or profile-folder name against one allowed-list pattern.
|
||||
/// Patterns containing <c>*</c> or <c>?</c> are globs (same semantics as the
|
||||
/// hardware whitelist: <c>*</c> = any run, <c>?</c> = one char, case-insensitive);
|
||||
/// all other entries match exactly. No ambiguity with literal names — <c>*</c> and
|
||||
/// all other patterns match exactly. No ambiguity with literal names — <c>*</c> and
|
||||
/// <c>?</c> are invalid characters in Windows account names.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static bool MatchesAllowedEntry(string name, string entry)
|
||||
internal static bool MatchesAllowedEntry(string name, string pattern)
|
||||
{
|
||||
if (entry.IndexOf('*') < 0 && entry.IndexOf('?') < 0)
|
||||
return string.Equals(entry, name, StringComparison.OrdinalIgnoreCase);
|
||||
if (pattern.IndexOf('*') < 0 && pattern.IndexOf('?') < 0)
|
||||
return string.Equals(pattern, name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var regexPattern = "^" + Regex.Escape(entry)
|
||||
var regexPattern = "^" + Regex.Escape(pattern)
|
||||
.Replace("\\*", ".*")
|
||||
.Replace("\\?", ".") + "$";
|
||||
|
||||
try
|
||||
{
|
||||
// Timeout guards against pathological patterns (ReDoS)
|
||||
return Regex.IsMatch(name, regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100));
|
||||
// Timeout guards against pathological patterns (ReDoS). CultureInvariant: under a
|
||||
// Turkish culture "i" and "I" are no case pair, so "WLapsAdmin*" missed "WLAPSADMIN01".
|
||||
return Regex.IsMatch(name, regexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
@@ -331,39 +313,107 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllowed(string name, List<string> allowedAccounts)
|
||||
/// <summary>
|
||||
/// The effective static list: the built-in account-name and profile-folder entries of
|
||||
/// <see cref="LocalAdminAllowList.BuiltIns"/> (RID entries are decided by RID, see
|
||||
/// <see cref="Decide"/>) plus the tenant's entries. An entry that does not parse is added to
|
||||
/// <paramref name="ignored"/> and allows nothing. Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static List<AllowedEntry> BuildAllowedEntries(IEnumerable<string> tenantEntries, List<string> ignored)
|
||||
{
|
||||
return allowedAccounts.Any(a => MatchesAllowedEntry(name, a));
|
||||
var entries = new List<AllowedEntry>();
|
||||
foreach (var builtIn in LocalAdminAllowList.BuiltIns)
|
||||
{
|
||||
if (builtIn.Match == LocalAdminBuiltInMatch.AccountName)
|
||||
AddDistinct(entries, new AllowedEntry(builtIn.Name, builtIn.Role, profileFolderOnly: false));
|
||||
else if (builtIn.Match == LocalAdminBuiltInMatch.ProfileFolder)
|
||||
AddDistinct(entries, new AllowedEntry(builtIn.Name, LocalAccountRole.Any, profileFolderOnly: true));
|
||||
}
|
||||
|
||||
foreach (var raw in tenantEntries ?? Enumerable.Empty<string>())
|
||||
{
|
||||
if (LocalAdminAllowList.TryParseEntry(raw, out var pattern, out var role))
|
||||
AddDistinct(entries, new AllowedEntry(pattern, role, profileFolderOnly: false));
|
||||
else if (!string.IsNullOrWhiteSpace(raw))
|
||||
ignored.Add(raw);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static void AddDistinct(List<AllowedEntry> entries, AllowedEntry entry)
|
||||
{
|
||||
if (!entries.Any(e => e.ProfileFolderOnly == entry.ProfileFolderOnly
|
||||
&& string.Equals(e.Display, entry.Display, StringComparison.OrdinalIgnoreCase)))
|
||||
entries.Add(entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known built-in accounts of the local machine domain, recognised by RID so that
|
||||
/// localized names (Gast, Invité, Administrateur, …) are not flagged:
|
||||
/// 500 Administrator, 501 Guest, 503 DefaultAccount, 504 WDAGUtilityAccount.
|
||||
/// Only S-1-5-21-… SIDs qualify (the WMI query is already restricted to local accounts).
|
||||
/// Decides one local account. The built-in accounts are decided by their RID alone: 500 in any
|
||||
/// role, 501/503/504 never as an administrator — no name entry, built-in or tenant, lifts or
|
||||
/// imposes a role on them. Without a SID (a WMI corner case) the English name stands in for the
|
||||
/// RID. Every other account is allowed when an account entry matches it, unless it is an
|
||||
/// administrator and ANY matching entry is standard-user only: a restriction always wins.
|
||||
/// Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static AccountDecision Decide(string name, string sid, bool isAdmin, IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var builtIn = FindBuiltInRidEntry(name, sid);
|
||||
if (builtIn != null)
|
||||
return isAdmin && builtIn.Role == LocalAccountRole.StandardUser ? AccountDecision.RoleViolation : AccountDecision.Allowed;
|
||||
|
||||
var covered = false;
|
||||
var restricted = false;
|
||||
foreach (var entry in allowed)
|
||||
{
|
||||
if (entry.ProfileFolderOnly || !MatchesAllowedEntry(name, entry.Pattern))
|
||||
continue;
|
||||
covered = true;
|
||||
if (entry.Role == LocalAccountRole.StandardUser)
|
||||
restricted = true;
|
||||
}
|
||||
|
||||
if (!covered)
|
||||
return AccountDecision.Unlisted;
|
||||
return isAdmin && restricted ? AccountDecision.RoleViolation : AccountDecision.Allowed;
|
||||
}
|
||||
|
||||
private static LocalAdminBuiltInEntry FindBuiltInRidEntry(string name, string sid)
|
||||
{
|
||||
var rid = MachineAccountRid(sid);
|
||||
foreach (var builtIn in LocalAdminAllowList.BuiltIns)
|
||||
{
|
||||
if (builtIn.Match != LocalAdminBuiltInMatch.Rid)
|
||||
continue;
|
||||
var matches = rid.HasValue
|
||||
? builtIn.Rid == rid.Value
|
||||
: string.IsNullOrEmpty(sid) && string.Equals(builtIn.Name, name, StringComparison.OrdinalIgnoreCase);
|
||||
if (matches)
|
||||
return builtIn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RID (the last sub-authority) of a local-machine account SID (<c>S-1-5-21-…</c>; the WMI query
|
||||
/// and the member filter already restrict to local accounts), else null.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static bool IsWellKnownBuiltInRid(string sid)
|
||||
internal static int? MachineAccountRid(string sid)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sid) || !sid.StartsWith("S-1-5-21-", StringComparison.Ordinal))
|
||||
return false;
|
||||
return null;
|
||||
|
||||
var dash = sid.LastIndexOf('-');
|
||||
if (dash < 0 || dash == sid.Length - 1)
|
||||
return false;
|
||||
|
||||
switch (sid.Substring(dash + 1))
|
||||
{
|
||||
case "500":
|
||||
case "501":
|
||||
case "503":
|
||||
case "504":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return int.TryParse(sid.Substring(dash + 1), NumberStyles.None, CultureInfo.InvariantCulture, out var rid)
|
||||
? rid
|
||||
: (int?)null;
|
||||
}
|
||||
|
||||
/// <summary>A profile folder has no role: any entry of the same name allows it.</summary>
|
||||
private static bool IsAllowedFolder(string folderName, IReadOnlyList<AllowedEntry> allowed)
|
||||
=> allowed.Any(e => MatchesAllowedEntry(folderName, e.Pattern));
|
||||
|
||||
/// <summary>
|
||||
/// Splits the logged-in user names into those that may be allowed dynamically (not a
|
||||
/// local SAM account — Entra / domain identities whose only artifact is a profile
|
||||
@@ -481,7 +531,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
return accounts;
|
||||
}
|
||||
|
||||
private LocalAccountCheckResult CheckLocalAdminAccounts(List<LocalAccountInfo> accounts, List<string> allowedAccounts)
|
||||
private LocalAccountCheckResult CheckLocalAdminAccounts(List<LocalAccountInfo> accounts, IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
// Administrators-group membership is read from the SAM, independent of WMI and of
|
||||
// the enabled state — a dormant backdoor with admin rights is still a member.
|
||||
@@ -489,13 +539,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
if (!group.Succeeded)
|
||||
_logger.Warning($"{Name}: Failed to enumerate members of '{group.GroupName}': NET_API_STATUS={group.ErrorCode}");
|
||||
|
||||
var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowedAccounts);
|
||||
var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowed);
|
||||
result.AdministratorsGroupErrorCode = group.ErrorCode;
|
||||
|
||||
foreach (var name in result.Unexpected)
|
||||
_logger.Debug($"{Name}: Unexpected local account: {name}");
|
||||
foreach (var name in result.UnexpectedAdminMembers)
|
||||
_logger.Debug($"{Name}: Unexpected Administrators member: {name}");
|
||||
foreach (var name in result.RoleViolations)
|
||||
_logger.Debug($"{Name}: Allowed only as a standard user but an Administrators member: {name}");
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -508,10 +560,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// - every local account is checked, disabled ones included (state is reported);
|
||||
/// - a local 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;
|
||||
/// - an account is allowed by name (allowed list, globs) or by well-known RID
|
||||
/// (<see cref="IsWellKnownBuiltInRid"/>) — localized built-ins are never flagged;
|
||||
/// - <c>UnexpectedAdminMembers</c> = unexpected accounts holding membership, plus local
|
||||
/// members not on the allowed list that the WMI inventory did not return at all;
|
||||
/// - each account is decided by <see cref="Decide"/>; an account no entry allows, or one
|
||||
/// that breaks a standard-user entry, is unexpected (and an unexpected admin when it
|
||||
/// holds membership; the latter also a role violation);
|
||||
/// - local members the WMI inventory did not return at all are decided as administrators
|
||||
/// and land in both unexpected lists, like any unknown administrator;
|
||||
/// - with membership unknown (enumeration failed) nobody counts as an administrator, so a
|
||||
/// standard-user entry acts like a plain one;
|
||||
/// - members outside the local machine domain (Entra role SIDs, domain groups) are
|
||||
/// listed for delta comparison but never flagged — they are expected on joined devices.
|
||||
/// </summary>
|
||||
@@ -520,7 +575,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
IList<LocalGroupMember> adminMembers,
|
||||
bool adminGroupEnumerated,
|
||||
string machineName,
|
||||
List<string> allowedAccounts)
|
||||
IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var result = new LocalAccountCheckResult { AdministratorsGroupEnumerated = adminGroupEnumerated };
|
||||
|
||||
@@ -539,45 +594,65 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
var inventorySids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var inventoryNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var account in accounts ?? new List<LocalAccountInfo>())
|
||||
{
|
||||
var isAdmin = (!string.IsNullOrEmpty(account.Sid) && adminSids.Contains(account.Sid))
|
||||
|| adminLocalNames.Contains(account.Name);
|
||||
var rid = MachineAccountRid(account.Sid);
|
||||
|
||||
if (!string.IsNullOrEmpty(account.Sid))
|
||||
inventorySids.Add(account.Sid);
|
||||
inventoryNames.Add(account.Name);
|
||||
|
||||
result.AllChecked.Add(account.Name);
|
||||
result.AccountDetails.Add(new Dictionary<string, object>
|
||||
{
|
||||
{ "name", account.Name },
|
||||
{ "disabled", account.Disabled },
|
||||
{ "administrators_member", isAdmin }
|
||||
{ "administrators_member", isAdmin },
|
||||
{ "rid", rid }
|
||||
});
|
||||
|
||||
if (account.Sid != null && account.Sid.EndsWith("-500", StringComparison.Ordinal))
|
||||
if (rid == 500)
|
||||
result.BuiltInAdministratorEnabled = !account.Disabled;
|
||||
|
||||
if (IsWellKnownBuiltInRid(account.Sid) || IsAllowed(account.Name, allowedAccounts))
|
||||
continue;
|
||||
|
||||
result.Unexpected.Add(account.Name);
|
||||
if (isAdmin)
|
||||
result.UnexpectedAdminMembers.Add(account.Name);
|
||||
Record(result, account.Name, Decide(account.Name, account.Sid, isAdmin, allowed), isAdmin);
|
||||
}
|
||||
|
||||
// Local members the WMI inventory did not surface (WMI failure, or an account the
|
||||
// provider does not list) — still an unexpected admin when not on the allowed list.
|
||||
// provider does not list): the SAM is authoritative for membership.
|
||||
foreach (var member in adminLocalMembers)
|
||||
{
|
||||
var name = member.Name;
|
||||
if (IsWellKnownBuiltInRid(member.Sid) || IsAllowed(name, allowedAccounts))
|
||||
var returnedByWmi = !string.IsNullOrEmpty(member.Sid)
|
||||
? inventorySids.Contains(member.Sid)
|
||||
: inventoryNames.Contains(member.Name);
|
||||
if (returnedByWmi)
|
||||
continue;
|
||||
if (result.UnexpectedAdminMembers.Any(u => string.Equals(u, name, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
result.UnexpectedAdminMembers.Add(name);
|
||||
Record(result, member.Name, Decide(member.Name, member.Sid, isAdmin: true, allowed), isAdmin: true);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void Record(LocalAccountCheckResult result, string name, AccountDecision decision, bool isAdmin)
|
||||
{
|
||||
if (decision == AccountDecision.Allowed)
|
||||
return;
|
||||
AddOnce(result.Unexpected, name);
|
||||
if (isAdmin)
|
||||
AddOnce(result.UnexpectedAdminMembers, name);
|
||||
if (decision == AccountDecision.RoleViolation)
|
||||
AddOnce(result.RoleViolations, name);
|
||||
}
|
||||
|
||||
private static void AddOnce(List<string> names, string name)
|
||||
{
|
||||
if (!names.Any(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase)))
|
||||
names.Add(name);
|
||||
}
|
||||
|
||||
private static bool IsLocalMember(LocalGroupMember member, string machineName)
|
||||
{
|
||||
var domain = member.Domain;
|
||||
@@ -586,7 +661,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
return string.Equals(domain, machineName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private UserProfileCheckResult CheckUserProfiles(List<string> allowedAccounts)
|
||||
private UserProfileCheckResult CheckUserProfiles(IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var allFound = new List<string>();
|
||||
var unexpected = new List<string>();
|
||||
@@ -616,7 +691,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
|
||||
allFound.Add(folderName);
|
||||
|
||||
if (!IsAllowed(folderName, allowedAccounts))
|
||||
if (!IsAllowedFolder(folderName, allowed))
|
||||
{
|
||||
unexpected.Add(folderName);
|
||||
_logger.Debug($"{Name}: Unexpected profile folder: {folderName}");
|
||||
@@ -740,7 +815,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// <summary>Unexpected accounts that are members of the Administrators group.</summary>
|
||||
public List<string> UnexpectedAdminMembers { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Per-account state: name, disabled, administrators_member.</summary>
|
||||
/// <summary>Administrators members that an entry allows only as a standard user (subset of <see cref="UnexpectedAdminMembers"/>).</summary>
|
||||
public List<string> RoleViolations { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Per-account state: name, disabled, administrators_member, rid.</summary>
|
||||
public List<Dictionary<string, object>> AccountDetails { get; set; } = new List<Dictionary<string, object>>();
|
||||
|
||||
/// <summary>Every Administrators member (DOMAIN\name or SID), local or not — for delta comparison.</summary>
|
||||
@@ -753,6 +831,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
public bool? BuiltInAdministratorEnabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One effective allowed-list entry: a name or glob and what it may allow.</summary>
|
||||
internal sealed class AllowedEntry
|
||||
{
|
||||
public AllowedEntry(string pattern, LocalAccountRole role, bool profileFolderOnly)
|
||||
{
|
||||
Pattern = pattern;
|
||||
Role = role;
|
||||
ProfileFolderOnly = profileFolderOnly;
|
||||
}
|
||||
|
||||
public string Pattern { get; }
|
||||
|
||||
public LocalAccountRole Role { get; }
|
||||
|
||||
/// <summary>Allows the C:\Users folder of this name, never a local account.</summary>
|
||||
public bool ProfileFolderOnly { get; }
|
||||
|
||||
/// <summary>Canonical entry text as the event lists it ("Guest|user").</summary>
|
||||
public string Display => LocalAdminAllowList.Format(Pattern, Role);
|
||||
}
|
||||
|
||||
internal enum AccountDecision
|
||||
{
|
||||
Allowed,
|
||||
|
||||
/// <summary>No entry allows the account.</summary>
|
||||
Unlisted,
|
||||
|
||||
/// <summary>An administrator that a standard-user entry (or a built-in RID) allows only as a standard user.</summary>
|
||||
RoleViolation,
|
||||
}
|
||||
|
||||
/// <summary>Logged-in users at shutdown, split by whether the name is a local SAM account.</summary>
|
||||
internal sealed class LoggedInUserSplit
|
||||
{
|
||||
|
||||
+5
-5
@@ -92,7 +92,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
{
|
||||
_logger.Info($"{Name}: Running startup analysis (baseline inventory)");
|
||||
_logger.Debug($"{Name}: Running startup analysis (baseline inventory)");
|
||||
try
|
||||
{
|
||||
// The collection ALWAYS runs — _startupInventory is the in-memory base for the
|
||||
@@ -106,7 +106,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
Constants.EventTypes.SoftwareInventoryAnalysis + ":startup",
|
||||
BuildInventoryFingerprint(_startupInventory)))
|
||||
{
|
||||
_logger.Info($"{Name}: baseline inventory unchanged since last emission — startup events suppressed (restart dedup), delta base kept in memory");
|
||||
_logger.Debug($"{Name}: baseline inventory unchanged since last emission — startup events suppressed (restart dedup), delta base kept in memory");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _part1ShutdownSnapshotEmitted, 1, 0) != 0)
|
||||
{
|
||||
_logger.Info($"{Name}: AnalyzeAtShutdown(whiteGlovePart=1) skipped — Part-1 snapshot already emitted (likely by WhiteGloveInventoryTrigger)");
|
||||
_logger.Debug($"{Name}: AnalyzeAtShutdown(whiteGlovePart=1) skipped — Part-1 snapshot already emitted (likely by WhiteGloveInventoryTrigger)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -164,12 +164,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _part2ShutdownSnapshotEmitted, 1, 0) != 0)
|
||||
{
|
||||
_logger.Info($"{Name}: AnalyzeAtShutdown(whiteGlovePart=2) skipped — Part-2 snapshot already emitted");
|
||||
_logger.Debug($"{Name}: AnalyzeAtShutdown(whiteGlovePart=2) skipped — Part-2 snapshot already emitted");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info($"{Name}: Running shutdown analysis (delta detection, whiteGlovePart={whiteGlovePart?.ToString() ?? "none"})");
|
||||
_logger.Debug($"{Name}: Running shutdown analysis (delta detection, whiteGlovePart={whiteGlovePart?.ToString() ?? "none"})");
|
||||
try
|
||||
{
|
||||
var currentInventory = CollectAndNormalize();
|
||||
|
||||
@@ -62,9 +62,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry
|
||||
/// <summary>Stops the collection timer without disposing the collector.</summary>
|
||||
public void Stop()
|
||||
{
|
||||
// Hosts call Stop and then Dispose, which stops again; only a running timer logs.
|
||||
var wasRunning = _timer != null;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
Logger.Info($"[{GetType().Name}] Stopped");
|
||||
if (wasRunning) Logger.Info($"[{GetType().Name}] Stopped");
|
||||
OnAfterStop();
|
||||
}
|
||||
|
||||
@@ -117,7 +119,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry
|
||||
// duration of any single slow Collect.
|
||||
if (Interlocked.CompareExchange(ref _collecting, 1, 0) != 0)
|
||||
{
|
||||
Logger.Debug($"[{GetType().Name}] tick skipped — previous Collect still running");
|
||||
Logger.Trace($"[{GetType().Name}] tick skipped — previous Collect still running");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user