mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-13 12:59:24 +00:00
Compare commits
208 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cc120c182 | |||
| 6a68580c87 | |||
| 6f48aab4bf | |||
| 80852de813 | |||
| fa4a016388 | |||
| 996240fa0e | |||
| ed209b5f1a | |||
| a694f07f89 | |||
| fcd678eb4a | |||
| 560fa844b1 | |||
| 6198f5ac21 | |||
| e96c35a311 | |||
| dc7b960bb2 | |||
| 7f17b5c484 | |||
| 7cffcc8799 | |||
| 0cefbc17cf | |||
| 94324bf137 | |||
| 456b8f6025 | |||
| c3faece6d8 | |||
| f0fee75b08 | |||
| ac381a39ad | |||
| a1936b7cb6 | |||
| ba53b82c00 | |||
| 7f53dcf223 | |||
| cd2ea05878 | |||
| ccdd8c17ce | |||
| 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 |
+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: c5604145b3...155a138209
@@ -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: 2609b9f3e5...2e314b1de5
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ID-003",
|
||||
"title": "Critical Vulnerability Detected",
|
||||
"description": "Fires when the vulnerability correlation finds critical-severity CVEs or CISA KEV entries in installed software. This indicates the device has software with known, actively exploited vulnerabilities.",
|
||||
"description": "Fires when the vulnerability correlation finds critical-severity CVEs in installed software that has to be updated by hand. Software that updates itself, such as browsers, is listed in the vulnerability report but does not trigger this rule.",
|
||||
"severity": "critical",
|
||||
"category": "security",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -19,7 +19,7 @@
|
||||
"signal": "critical_vulnerabilities",
|
||||
"source": "event_type",
|
||||
"eventType": "vulnerability_report",
|
||||
"dataField": "scan_summary.critical_cves",
|
||||
"dataField": "scan_summary.actionable_critical_cves",
|
||||
"operator": "gte",
|
||||
"value": "1",
|
||||
"required": true
|
||||
@@ -28,7 +28,16 @@
|
||||
"signal": "kev_matches",
|
||||
"source": "event_type",
|
||||
"eventType": "vulnerability_report",
|
||||
"dataField": "scan_summary.kev_matches",
|
||||
"dataField": "scan_summary.actionable_kev_matches",
|
||||
"operator": "gte",
|
||||
"value": "1",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "self_updating_findings",
|
||||
"source": "event_type",
|
||||
"eventType": "vulnerability_report",
|
||||
"dataField": "scan_summary.self_updating_cves",
|
||||
"operator": "gte",
|
||||
"value": "1",
|
||||
"required": false
|
||||
@@ -42,7 +51,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 80,
|
||||
"explanation": "The device has installed software with critical vulnerabilities. Software with CVEs rated CRITICAL (CVSS >= 9.0) or listed in the CISA Known Exploited Vulnerabilities catalog was detected. These vulnerabilities are actively targeted by attackers and should be remediated immediately.",
|
||||
"explanation": "The device has installed software with critical vulnerabilities that an update of the software has to close. CVEs rated CRITICAL (CVSS >= 9.0) were found in software that is updated through your deployment. Findings listed in the CISA Known Exploited Vulnerabilities catalog are known to be used in attacks and should be remediated first. Software that updates itself, such as browsers, is not counted here, because it is expected to bring itself up to date after enrollment. Its findings are still listed in the vulnerability report.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review vulnerability findings",
|
||||
@@ -74,7 +83,7 @@
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Installed software has {{critical_vulnerabilities.value}} critical vulnerabilities",
|
||||
"summary": "Critical CVEs in software that needs a manual update: {{critical_vulnerabilities.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "vulnerabilities",
|
||||
@@ -90,6 +99,11 @@
|
||||
"label": "Known exploited",
|
||||
"value": "{{kev_matches.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "CVEs in self-updating software",
|
||||
"value": "{{self_updating_findings.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+43
-103
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -4240,10 +4259,10 @@
|
||||
{
|
||||
"ruleId": "ANALYZE-ID-003",
|
||||
"title": "Critical Vulnerability Detected",
|
||||
"description": "Fires when the vulnerability correlation finds critical-severity CVEs or CISA KEV entries in installed software. This indicates the device has software with known, actively exploited vulnerabilities.",
|
||||
"description": "Fires when the vulnerability correlation finds critical-severity CVEs in installed software that has to be updated by hand. Software that updates itself, such as browsers, is listed in the vulnerability report but does not trigger this rule.",
|
||||
"severity": "critical",
|
||||
"category": "security",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -4257,7 +4276,7 @@
|
||||
"signal": "critical_vulnerabilities",
|
||||
"source": "event_type",
|
||||
"eventType": "vulnerability_report",
|
||||
"dataField": "scan_summary.critical_cves",
|
||||
"dataField": "scan_summary.actionable_critical_cves",
|
||||
"operator": "gte",
|
||||
"value": "1",
|
||||
"required": true
|
||||
@@ -4266,7 +4285,16 @@
|
||||
"signal": "kev_matches",
|
||||
"source": "event_type",
|
||||
"eventType": "vulnerability_report",
|
||||
"dataField": "scan_summary.kev_matches",
|
||||
"dataField": "scan_summary.actionable_kev_matches",
|
||||
"operator": "gte",
|
||||
"value": "1",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "self_updating_findings",
|
||||
"source": "event_type",
|
||||
"eventType": "vulnerability_report",
|
||||
"dataField": "scan_summary.self_updating_cves",
|
||||
"operator": "gte",
|
||||
"value": "1",
|
||||
"required": false
|
||||
@@ -4280,7 +4308,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 80,
|
||||
"explanation": "The device has installed software with critical vulnerabilities. Software with CVEs rated CRITICAL (CVSS >= 9.0) or listed in the CISA Known Exploited Vulnerabilities catalog was detected. These vulnerabilities are actively targeted by attackers and should be remediated immediately.",
|
||||
"explanation": "The device has installed software with critical vulnerabilities that an update of the software has to close. CVEs rated CRITICAL (CVSS >= 9.0) were found in software that is updated through your deployment. Findings listed in the CISA Known Exploited Vulnerabilities catalog are known to be used in attacks and should be remediated first. Software that updates itself, such as browsers, is not counted here, because it is expected to bring itself up to date after enrollment. Its findings are still listed in the vulnerability report.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review vulnerability findings",
|
||||
@@ -4312,7 +4340,7 @@
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Installed software has {{critical_vulnerabilities.value}} critical vulnerabilities",
|
||||
"summary": "Critical CVEs in software that needs a manual update: {{critical_vulnerabilities.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "vulnerabilities",
|
||||
@@ -4328,6 +4356,11 @@
|
||||
"label": "Known exploited",
|
||||
"value": "{{kev_matches.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "CVEs in self-updating software",
|
||||
"value": "{{self_updating_findings.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -4907,99 +4940,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",
|
||||
|
||||
-2677
File diff suppressed because it is too large
Load Diff
+6148
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
|
||||
+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
|
||||
{
|
||||
|
||||
+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))));
|
||||
}
|
||||
}
|
||||
}
|
||||
+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")]
|
||||
|
||||
+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()
|
||||
{
|
||||
|
||||
+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) -----------------------------------------------
|
||||
|
||||
|
||||
+2
-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
|
||||
|
||||
+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)]
|
||||
|
||||
+130
@@ -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;
|
||||
@@ -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.
|
||||
@@ -235,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-1
@@ -1709,7 +1709,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
_allAppsCompletedFired = true;
|
||||
_logger.Info($"ImeLogTracker: all {_packageStates.CountAll} apps completed");
|
||||
OnAllAppsCompleted?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+68
-17
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
+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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+70
-29
@@ -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,27 +281,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
try
|
||||
{
|
||||
var stoppedAny = false;
|
||||
var watchers = new List<RegistryWatcher>(8);
|
||||
lock (_stateLock)
|
||||
{
|
||||
stoppedAny |= DisposeAndNull(ref _servicesAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _machineSoftwareAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _userSoftwareAppearanceWatcher);
|
||||
stoppedAny |= 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;
|
||||
|
||||
stoppedAny |= DisposeAndNull(ref _serviceRealmjoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _machineRealmJoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _userRealmJoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _uninstallWatcher);
|
||||
Take(ref _serviceRealmjoinWatcher, watchers);
|
||||
Take(ref _machineRealmJoinWatcher, watchers);
|
||||
Take(ref _userRealmJoinWatcher, watchers);
|
||||
Take(ref _uninstallWatcher, watchers);
|
||||
}
|
||||
|
||||
// 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 (stoppedAny) _logger.Info($"RealmJoinWatcher: stopped ({reason})");
|
||||
if (watchers.Count > 0) _logger.Info($"RealmJoinWatcher: stopped ({reason})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -380,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(
|
||||
@@ -429,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);
|
||||
@@ -452,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.
|
||||
@@ -509,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.
|
||||
@@ -550,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);
|
||||
}
|
||||
@@ -580,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)
|
||||
@@ -686,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);
|
||||
@@ -916,16 +956,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
private static bool 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 false;
|
||||
try { w.Dispose(); } catch { }
|
||||
w = null;
|
||||
return true;
|
||||
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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+15
-3
@@ -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
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
+237
-127
@@ -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,19 +79,10 @@ 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()
|
||||
@@ -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
|
||||
{
|
||||
|
||||
+51
-62
@@ -11,6 +11,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -113,34 +114,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Internet Settings"))
|
||||
{
|
||||
if (key != null)
|
||||
{
|
||||
data["proxyEnabled"] = key.GetValue("ProxyEnable")?.ToString() == "1";
|
||||
data["proxyServer"] = key.GetValue("ProxyServer")?.ToString();
|
||||
data["proxyOverride"] = key.GetValue("ProxyOverride")?.ToString();
|
||||
data["autoConfigUrl"] = key.GetValue("AutoConfigURL")?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// Also check WinHTTP proxy
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"))
|
||||
{
|
||||
data["winHttpProxyConfigured"] = key?.GetValue("WinHttpSettings") != null;
|
||||
}
|
||||
|
||||
var proxyType = "Direct";
|
||||
if (data.ContainsKey("proxyEnabled") && (bool)data["proxyEnabled"])
|
||||
proxyType = "Proxy";
|
||||
else if (data.ContainsKey("autoConfigUrl") && data["autoConfigUrl"] != null)
|
||||
proxyType = "PAC";
|
||||
|
||||
data["proxyType"] = proxyType;
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.ProxyConfiguration, $"Proxy configuration: {proxyType}", data);
|
||||
var settings = ProxySettingsReader.Read();
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.ProxyConfiguration, $"Proxy configuration: {settings.ProxyType}", settings.ToEventData());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -665,53 +640,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
Dictionary<string, object> data;
|
||||
|
||||
// Read AAD join info via shared helper. Placeholder users (foouser@, autopilot@)
|
||||
// are transient provisioning accounts and must NOT be treated as a real AAD join;
|
||||
// HasAadJoinedUser is only set for real user emails.
|
||||
if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser))
|
||||
if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser)
|
||||
&& !string.IsNullOrEmpty(thumbprint))
|
||||
{
|
||||
// Still need TenantId — open the subkey directly when we have a thumbprint.
|
||||
if (!string.IsNullOrEmpty(thumbprint))
|
||||
// Still need TenantId — open the subkey directly.
|
||||
string tenantId;
|
||||
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
|
||||
using (var subKey = joinInfoKey?.OpenSubKey(thumbprint))
|
||||
{
|
||||
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
|
||||
using (var subKey = joinInfoKey?.OpenSubKey(thumbprint))
|
||||
{
|
||||
data["tenantId"] = subKey?.GetValue("TenantId")?.ToString();
|
||||
}
|
||||
|
||||
data["userEmail"] = userEmail;
|
||||
data["joinType"] = "Azure AD Joined";
|
||||
data["thumbprint"] = thumbprint;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userEmail))
|
||||
{
|
||||
if (isPlaceholderUser)
|
||||
{
|
||||
IsFooUserDetected = true;
|
||||
data["isFooUser"] = true;
|
||||
_logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join");
|
||||
}
|
||||
else
|
||||
{
|
||||
HasAadJoinedUser = true;
|
||||
}
|
||||
}
|
||||
tenantId = subKey?.GetValue("TenantId")?.ToString();
|
||||
}
|
||||
else
|
||||
|
||||
data = BuildAadJoinedData(tenantId, userEmail, thumbprint, isPlaceholderUser);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userEmail))
|
||||
{
|
||||
data["joinType"] = "Not Joined";
|
||||
if (isPlaceholderUser)
|
||||
{
|
||||
IsFooUserDetected = true;
|
||||
_logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join");
|
||||
}
|
||||
else
|
||||
{
|
||||
HasAadJoinedUser = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
data["joinType"] = "Not Joined";
|
||||
data = new Dictionary<string, object> { ["joinType"] = "Not Joined" };
|
||||
}
|
||||
|
||||
object joinTypeValue;
|
||||
var joinType = data.TryGetValue("joinType", out joinTypeValue) ? joinTypeValue?.ToString() ?? "Unknown" : "Unknown";
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, $"AAD join: {joinType}", data);
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, $"AAD join: {data["joinType"]}", data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -719,6 +684,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>aad_join_status</c> payload of a joined device. The signed-in address is
|
||||
/// reported as its domain only.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, object> BuildAadJoinedData(
|
||||
string tenantId, string userEmail, string thumbprint, bool isPlaceholderUser)
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["tenantId"] = tenantId,
|
||||
["joinType"] = "Azure AD Joined",
|
||||
["thumbprint"] = thumbprint,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userEmail))
|
||||
{
|
||||
data["userDomain"] = AadJoinInfo.ExtractUserDomain(userEmail);
|
||||
if (isPlaceholderUser)
|
||||
data["isFooUser"] = true;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads ESP skip configuration from the enrollment FirstSync registry key via the
|
||||
/// shared <see cref="EspSkipConfigurationProbe"/> (also used by
|
||||
@@ -1004,7 +993,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
var fingerprint = Persistence.StartupEventGate.ComputeFingerprint(data, excludedFields);
|
||||
if (_startupGate.ShouldEmit(eventType, fingerprint)) return true;
|
||||
|
||||
_logger.Debug($"EnrollmentTracker: '{eventType}' suppressed — payload unchanged since last emission (restart dedup)");
|
||||
_logger.Debug($"EnrollmentTracker: '{eventType}' suppressed — payload unchanged since last emission (restart dedup)", repeatKey: "DeviceInfo.unchanged:" + eventType);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+16
-12
@@ -79,11 +79,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
public void Start()
|
||||
{
|
||||
bool raise;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
TryArm();
|
||||
raise = TryArm();
|
||||
}
|
||||
|
||||
if (raise) Raise();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -129,17 +132,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
// Caller holds _lock.
|
||||
private void TryArm()
|
||||
// Caller holds _lock. Returns true when BinaryAppeared is due; the caller raises it after
|
||||
// releasing the lock, like OnRecheck does — the handler probes and may dispose this watcher.
|
||||
private bool TryArm()
|
||||
{
|
||||
if (_disposed || _fsw != null) return;
|
||||
if (_disposed || _fsw != null) return false;
|
||||
|
||||
// Initial scan — the binaries may already be present (armed late / update over existing).
|
||||
if (CoreBinariesPresent())
|
||||
{
|
||||
Raise();
|
||||
return;
|
||||
}
|
||||
if (CoreBinariesPresent()) return true;
|
||||
|
||||
if (!Directory.Exists(_installationPath))
|
||||
{
|
||||
@@ -154,7 +154,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_logger.Debug($"[OfficeBinaryWatcher] install path '{_installationPath}' never appeared — giving up (no completion proof)");
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
@@ -172,17 +172,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
_logger.Info($"[OfficeBinaryWatcher] watching '{_installationPath}' for core Office binaries");
|
||||
|
||||
// Race: a binary may have appeared between the scan above and arming — re-scan once.
|
||||
if (CoreBinariesPresent()) Raise();
|
||||
return CoreBinariesPresent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[OfficeBinaryWatcher] could not watch '{_installationPath}': {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnArmRetry(object? state)
|
||||
{
|
||||
lock (_lock) { TryArm(); }
|
||||
bool raise;
|
||||
lock (_lock) { raise = TryArm(); }
|
||||
|
||||
if (raise) Raise();
|
||||
}
|
||||
|
||||
private void OnFileEvent(object sender, FileSystemEventArgs e) => CheckCandidate(e.Name);
|
||||
|
||||
+159
-58
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Management;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
@@ -15,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// idle polling:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Start</b> — a true ETW-backed push via WMI <c>Win32_ProcessStartTrace</c>
|
||||
/// (<see cref="ManagementEventWatcher"/>), plus a one-shot startup probe so an install already
|
||||
/// (<see cref="ManagementEventWatcher"/>), plus a startup probe so an install already
|
||||
/// in flight when the agent boots (realistic during OOBE) is still caught.</item>
|
||||
/// <item><b>Stop</b> — <see cref="Process.Exited"/> on each tracked worker (clean OS event, same
|
||||
/// pattern as <c>ImeProcessWatcher</c>).</item>
|
||||
@@ -26,6 +27,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// a single active window). Fail-soft: if the WMI watcher cannot start, it is logged and the
|
||||
/// startup probe still covers the already-running case — no polling fallback by design.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Locking</b>: the lock guards this watcher's own state. No event is raised and no
|
||||
/// <see cref="Process"/> is disposed while it is held: .NET holds its own lock on a
|
||||
/// <see cref="Process"/> for as long as it delivers <see cref="Process.Exited"/>, that handler
|
||||
/// takes this lock, and <see cref="Process.Dispose()"/> waits for the delivery to finish.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class OfficeProcessWatcher : IDisposable
|
||||
{
|
||||
@@ -53,6 +60,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// </summary>
|
||||
public event EventHandler? Stopped;
|
||||
|
||||
/// <summary>Test seam: enumerates the running workers (default: Process.GetProcessesByName).</summary>
|
||||
internal Func<Process[]> WorkerSource { get; set; } = () => Process.GetProcessesByName(WorkerProcessNameNoExt);
|
||||
|
||||
/// <param name="settleSeconds">Grace period after the last worker exits before the window is
|
||||
/// declared closed; a new worker within it keeps the window open. <=0 closes immediately.</param>
|
||||
public OfficeProcessWatcher(AgentLogger logger, int settleSeconds = 10)
|
||||
@@ -67,7 +77,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
get { lock (_lock) { return _active; } }
|
||||
}
|
||||
|
||||
public void Start()
|
||||
public void Start() => Start(armWmi: true);
|
||||
|
||||
/// <summary>Test seam: <paramref name="armWmi"/> = false leaves out the WMI subscription.</summary>
|
||||
internal void Start(bool armWmi)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
@@ -75,10 +88,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
_started = true;
|
||||
}
|
||||
|
||||
// 1) Startup probe — catch a worker already running before the watcher armed.
|
||||
// 1) Startup probe — catch a worker already running before the subscription exists. It
|
||||
// stays on the caller's thread: it needs no WMI, so a worker that is already running is
|
||||
// reported even when the subscription below never completes.
|
||||
ProbeRunningWorkers();
|
||||
|
||||
// 2) ETW-backed push on worker creation — no polling. WinMgmt can hang during OOBE, so the
|
||||
// subscription never runs on the caller's thread (the orchestrator starts its hosts one
|
||||
// after another on it).
|
||||
if (armWmi) Task.Run(() => ArmStartTrace());
|
||||
}
|
||||
|
||||
private void ProbeRunningWorkers()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var proc in Process.GetProcessesByName(WorkerProcessNameNoExt))
|
||||
foreach (var proc in WorkerSource())
|
||||
{
|
||||
TrackWorker(proc);
|
||||
}
|
||||
@@ -87,24 +112,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_logger.Warning($"[OfficeProcessWatcher] startup probe failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2) ETW-backed push on worker creation — no polling.
|
||||
private void ArmStartTrace()
|
||||
{
|
||||
ManagementEventWatcher? watcher = null;
|
||||
try
|
||||
{
|
||||
var query = new WqlEventQuery(
|
||||
$"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = '{WorkerProcessName}'");
|
||||
_startWatcher = new ManagementEventWatcher(query);
|
||||
_startWatcher.EventArrived += OnProcessStartTrace;
|
||||
_startWatcher.Start();
|
||||
_logger.Info($"[OfficeProcessWatcher] watching Win32_ProcessStartTrace for {WorkerProcessName}");
|
||||
watcher = new ManagementEventWatcher(new WqlEventQuery(
|
||||
$"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = '{WorkerProcessName}'"));
|
||||
watcher.EventArrived += OnProcessStartTrace;
|
||||
watcher.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// No polling fallback by design — the startup probe already covers the in-flight case.
|
||||
_logger.Warning($"[OfficeProcessWatcher] could not start WMI process-start watcher " +
|
||||
$"({ex.GetType().Name}: {ex.Message}); relying on the startup probe only.");
|
||||
_startWatcher = null;
|
||||
if (watcher != null) TearDownStartTrace(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
// Published only once it is armed, so Dispose never meets a half-started subscription; one
|
||||
// that completes after Dispose is taken down right here.
|
||||
bool keep;
|
||||
lock (_lock)
|
||||
{
|
||||
keep = !_disposed;
|
||||
if (keep) _startWatcher = watcher;
|
||||
}
|
||||
if (!keep)
|
||||
{
|
||||
TearDownStartTrace(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info($"[OfficeProcessWatcher] watching Win32_ProcessStartTrace for {WorkerProcessName}");
|
||||
|
||||
// A worker may have started between the probe in Start and the subscription.
|
||||
ProbeRunningWorkers();
|
||||
}
|
||||
|
||||
private void TearDownStartTrace(ManagementEventWatcher watcher)
|
||||
{
|
||||
try { watcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { watcher.Stop(); } catch { }
|
||||
try { watcher.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
private void OnProcessStartTrace(object sender, EventArrivedEventArgs e)
|
||||
@@ -113,12 +166,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
var pidObj = e.NewEvent?["ProcessID"];
|
||||
if (pidObj == null) return;
|
||||
var pid = Convert.ToInt32(pidObj);
|
||||
|
||||
Process? proc = null;
|
||||
try { proc = Process.GetProcessById(pid); }
|
||||
catch { /* already gone — ignore */ }
|
||||
if (proc != null) TrackWorker(proc);
|
||||
TrackTracedProcess(Convert.ToInt32(pidObj), WorkerProcessNameNoExt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -126,41 +174,76 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the process a start trace announced (internal for tests). WMI delivers the trace
|
||||
/// with a delay, so the id may already belong to another process: only a process that still
|
||||
/// carries the worker's name is tracked. A foreign process would hold the install window
|
||||
/// open until it exits.
|
||||
/// </summary>
|
||||
internal void TrackTracedProcess(int pid, string processNameNoExt)
|
||||
{
|
||||
Process? proc = null;
|
||||
try { proc = Process.GetProcessById(pid); }
|
||||
catch { /* already gone — ignore */ }
|
||||
if (proc == null) return;
|
||||
|
||||
bool isWorker;
|
||||
try { isWorker = string.Equals(proc.ProcessName, processNameNoExt, StringComparison.OrdinalIgnoreCase); }
|
||||
catch { isWorker = false; /* exited between the lookup and the name read */ }
|
||||
|
||||
if (isWorker)
|
||||
{
|
||||
TrackWorker(proc);
|
||||
return;
|
||||
}
|
||||
|
||||
try { proc.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
private void TrackWorker(Process proc)
|
||||
{
|
||||
bool tracked = false;
|
||||
bool raiseStarted = false;
|
||||
try
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) { try { proc.Dispose(); } catch { } return; }
|
||||
if (_workers.ContainsKey(proc.Id)) { try { proc.Dispose(); } catch { } return; }
|
||||
if (!_disposed && !_workers.ContainsKey(proc.Id))
|
||||
{
|
||||
try
|
||||
{
|
||||
proc.EnableRaisingEvents = true;
|
||||
proc.Exited += OnWorkerExited;
|
||||
tracked = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process exited between discovery and Exited-hookup — drop it.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
proc.EnableRaisingEvents = true;
|
||||
proc.Exited += OnWorkerExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process exited between discovery and Exited-hookup — drop it.
|
||||
try { proc.Dispose(); } catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
_workers[proc.Id] = proc;
|
||||
// A worker is back — cancel any pending settle (the window must stay open).
|
||||
CancelSettleTimer();
|
||||
if (!_active)
|
||||
{
|
||||
_active = true;
|
||||
raiseStarted = true;
|
||||
if (tracked)
|
||||
{
|
||||
_workers[proc.Id] = proc;
|
||||
// A worker is back — cancel any pending settle (the window must stay open).
|
||||
CancelSettleTimer();
|
||||
if (!_active)
|
||||
{
|
||||
_active = true;
|
||||
raiseStarted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"[OfficeProcessWatcher] TrackWorker error: {ex.Message}");
|
||||
}
|
||||
|
||||
if (!tracked)
|
||||
{
|
||||
// Disposed, already tracked under another handle, or gone before the hookup.
|
||||
try { proc.Dispose(); } catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,6 +252,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
_logger.Info($"[OfficeProcessWatcher] Office worker started (PID={SafePid(proc)}) — install active");
|
||||
RaiseStarted();
|
||||
}
|
||||
|
||||
// .NET raises Exited once per Process object. A worker that exited before the hookup
|
||||
// above was subscribed would stay tracked forever and the window would never close.
|
||||
if (HasExitedSafe(proc))
|
||||
ThreadPool.QueueUserWorkItem(_ => OnWorkerExited(proc, EventArgs.Empty));
|
||||
}
|
||||
|
||||
private void OnWorkerExited(object? sender, EventArgs e)
|
||||
@@ -178,11 +266,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_workers.Remove(SafePid(proc)))
|
||||
{
|
||||
try { proc.Exited -= OnWorkerExited; } catch { }
|
||||
try { proc.Dispose(); } catch { }
|
||||
}
|
||||
// Only the call that still finds this worker acts: after Dispose nothing is tracked,
|
||||
// and TrackWorker's own re-check can deliver the same exit a second time.
|
||||
if (_disposed || !_workers.TryGetValue(SafePid(proc), out var tracked) || !ReferenceEquals(tracked, proc))
|
||||
return;
|
||||
_workers.Remove(SafePid(proc));
|
||||
|
||||
if (_active && _workers.Count == 0)
|
||||
{
|
||||
if (_settleSeconds <= 0)
|
||||
@@ -200,6 +289,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
try { proc.Exited -= OnWorkerExited; } catch { }
|
||||
try { proc.Dispose(); } catch { }
|
||||
|
||||
if (raiseStoppedNow)
|
||||
{
|
||||
_logger.Info("[OfficeProcessWatcher] last Office worker exited — install window closed");
|
||||
@@ -260,31 +352,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
try { return proc.Id; } catch { return -1; }
|
||||
}
|
||||
|
||||
private static bool HasExitedSafe(Process proc)
|
||||
{
|
||||
try { return proc.HasExited; } catch { return false; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ManagementEventWatcher? startWatcher;
|
||||
Process[] workers;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
if (_startWatcher != null)
|
||||
{
|
||||
try { _startWatcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { _startWatcher.Stop(); } catch { }
|
||||
try { _startWatcher.Dispose(); } catch { }
|
||||
startWatcher = _startWatcher;
|
||||
_startWatcher = null;
|
||||
CancelSettleTimer();
|
||||
workers = new Process[_workers.Count];
|
||||
_workers.Values.CopyTo(workers, 0);
|
||||
_workers.Clear();
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
if (startWatcher != null)
|
||||
{
|
||||
CancelSettleTimer();
|
||||
foreach (var kv in _workers)
|
||||
{
|
||||
try { kv.Value.Exited -= OnWorkerExited; } catch { }
|
||||
try { kv.Value.Dispose(); } catch { }
|
||||
}
|
||||
_workers.Clear();
|
||||
// Cancelling a WMI subscription can take tens of seconds on a busy WinMgmt. The caller
|
||||
// is the agent's stop path, so it never waits for it.
|
||||
var armed = startWatcher;
|
||||
Task.Run(() => TearDownStartTrace(armed));
|
||||
}
|
||||
|
||||
foreach (var worker in workers)
|
||||
{
|
||||
try { worker.Exited -= OnWorkerExited; } catch { }
|
||||
try { worker.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+65
-28
@@ -26,6 +26,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// </para>
|
||||
/// One-shot per OS contract, so it re-arms after each fire. Fail-soft: any failure is logged and
|
||||
/// the watcher stays quiet (the detector still gets the process Started/Stopped boundary events).
|
||||
/// <para>
|
||||
/// <b>Locking</b>: the lock guards this watcher's own state and is never held while
|
||||
/// <see cref="Changed"/> is raised. A handler may dispose the watcher or take locks of its own,
|
||||
/// and <see cref="Dispose"/> never waits for a handler. Raising under the lock deadlocks against
|
||||
/// an owner that disposes this watcher under a lock its handler also takes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RegistryChangeWatcher : IDisposable
|
||||
{
|
||||
@@ -34,10 +40,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
ChangeName = 0x00000001, // subkey added/removed (ClickToRun appearing; Scenario\* churn)
|
||||
ChangeLastSet = 0x00000004, // value set (Configuration: VersionToReport / StreamingFinished)
|
||||
// The registration must outlive the thread that issued it: re-arming happens on rotating
|
||||
// ThreadPool threads, and without this flag Windows signals the event when such a thread
|
||||
// ends — reported here as a change that never happened.
|
||||
ThreadAgnostic = 0x10000000,
|
||||
}
|
||||
|
||||
private const RegNotifyFilter PrimaryFilter = RegNotifyFilter.ChangeName | RegNotifyFilter.ChangeLastSet;
|
||||
private const RegNotifyFilter BootstrapFilter = RegNotifyFilter.ChangeName; // cheap: direct-child create/delete only
|
||||
private const RegNotifyFilter PrimaryFilter =
|
||||
RegNotifyFilter.ChangeName | RegNotifyFilter.ChangeLastSet | RegNotifyFilter.ThreadAgnostic;
|
||||
// Cheap: direct-child create/delete only.
|
||||
private const RegNotifyFilter BootstrapFilter = RegNotifyFilter.ChangeName | RegNotifyFilter.ThreadAgnostic;
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
private static extern int RegNotifyChangeKeyValue(
|
||||
@@ -49,6 +61,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
private enum WatchMode { None, Primary, Bootstrap }
|
||||
|
||||
private readonly RegistryHive _hive;
|
||||
private readonly string _hiveLabel;
|
||||
private readonly string _targetSubKey;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly object _lock = new object();
|
||||
@@ -66,13 +80,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
/// <param name="targetSubKey">HKLM-relative key, e.g. <c>SOFTWARE\Microsoft\Office\ClickToRun</c>.</param>
|
||||
public RegistryChangeWatcher(string targetSubKey, AgentLogger logger)
|
||||
: this(RegistryHive.LocalMachine, targetSubKey, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam: production always watches HKLM; tests use a hive they may write to.</summary>
|
||||
internal RegistryChangeWatcher(RegistryHive hive, string targetSubKey, AgentLogger logger)
|
||||
{
|
||||
_hive = hive;
|
||||
_hiveLabel = hive == RegistryHive.LocalMachine ? "HKLM" : hive.ToString();
|
||||
_targetSubKey = targetSubKey ?? throw new ArgumentNullException(nameof(targetSubKey));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>The filter the full target watch is armed with (tests).</summary>
|
||||
internal uint EffectivePrimaryFilter => (uint)PrimaryFilter;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
bool raise = false;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_started || _disposed) return;
|
||||
@@ -81,24 +107,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
// Forced Registry64 view — consistent with the C2R reads (AnyCPU net48 may otherwise
|
||||
// resolve the stale WOW6432Node mirror).
|
||||
_baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
_baseKey = RegistryKey.OpenBaseKey(_hive, RegistryView.Registry64);
|
||||
_signal = new AutoResetEvent(false);
|
||||
ArmBestEffort(raiseOnPromote: false);
|
||||
raise = ArmBestEffort(targetIsNews: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] failed to start on HKLM\\{_targetSubKey}: {ex.Message}");
|
||||
_logger.Warning($"[RegistryChangeWatcher] failed to start on {_hiveLabel}\\{_targetSubKey}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (raise) RaiseChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Arms the best available watch: the target key (full, subtree) if it exists, otherwise a cheap
|
||||
/// name-only watch on the nearest existing ancestor until the target appears. Must hold <see cref="_lock"/>.
|
||||
/// Returns true when the target key appeared and the caller has to raise <see cref="Changed"/>
|
||||
/// for it — after releasing the lock.
|
||||
/// </summary>
|
||||
private void ArmBestEffort(bool raiseOnPromote)
|
||||
/// <param name="targetIsNews">False when finding the target right away is the expected start
|
||||
/// state; true when the watch was waiting for it.</param>
|
||||
private bool ArmBestEffort(bool targetIsNews)
|
||||
{
|
||||
if (_disposed || _baseKey == null) return;
|
||||
if (_disposed || _baseKey == null) return false;
|
||||
|
||||
CloseWatchedKey();
|
||||
|
||||
@@ -107,27 +139,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_watchedKey = target;
|
||||
_mode = WatchMode.Primary;
|
||||
if (!ArmNotify(subtree: true, PrimaryFilter)) return;
|
||||
if (raiseOnPromote) RaiseChanged();
|
||||
return;
|
||||
return ArmNotify(subtree: true, PrimaryFilter) && targetIsNews;
|
||||
}
|
||||
|
||||
// Target absent — watch the nearest existing ancestor (non-recursive, name-only = cheap).
|
||||
var ancestor = OpenNearestExistingAncestor(out var ancestorPath);
|
||||
if (ancestor == null)
|
||||
{
|
||||
_logger.Debug($"[RegistryChangeWatcher] no existing ancestor for HKLM\\{_targetSubKey}; not arming");
|
||||
return;
|
||||
_logger.Debug($"[RegistryChangeWatcher] no existing ancestor for {_hiveLabel}\\{_targetSubKey}; not arming");
|
||||
return false;
|
||||
}
|
||||
_watchedKey = ancestor;
|
||||
_mode = WatchMode.Bootstrap;
|
||||
_logger.Debug($"[RegistryChangeWatcher] target HKLM\\{_targetSubKey} absent — bootstrap-watching HKLM\\{ancestorPath} (name-only)", repeatKey: "RegistryChangeWatcher.bootstrap:" + _targetSubKey);
|
||||
if (!ArmNotify(subtree: false, BootstrapFilter)) return;
|
||||
_logger.Debug($"[RegistryChangeWatcher] target {_hiveLabel}\\{_targetSubKey} absent — bootstrap-watching {_hiveLabel}\\{ancestorPath} (name-only)", repeatKey: "RegistryChangeWatcher.bootstrap:" + _targetSubKey);
|
||||
if (!ArmNotify(subtree: false, BootstrapFilter)) return false;
|
||||
|
||||
// TOCTOU: the target may have been created between TryOpen and arming the ancestor watch.
|
||||
using (var recheck = TryOpen(_targetSubKey))
|
||||
{
|
||||
if (recheck != null) ArmBestEffort(raiseOnPromote: true);
|
||||
return recheck != null && ArmBestEffort(targetIsNews: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +169,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
int rc = RegNotifyChangeKeyValue(_watchedKey.Handle, subtree, filter, _signal.SafeWaitHandle, fAsynchronous: true);
|
||||
if (rc != 0)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] RegNotifyChangeKeyValue failed rc={rc} (HKLM\\{_targetSubKey})");
|
||||
_logger.Warning($"[RegistryChangeWatcher] RegNotifyChangeKeyValue failed rc={rc} ({_hiveLabel}\\{_targetSubKey})");
|
||||
return false;
|
||||
}
|
||||
UnregisterWait();
|
||||
@@ -149,29 +179,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] arm failed (HKLM\\{_targetSubKey}): {ex.Message}");
|
||||
_logger.Warning($"[RegistryChangeWatcher] arm failed ({_hiveLabel}\\{_targetSubKey}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSignalled(object? state, bool timedOut)
|
||||
{
|
||||
bool raise;
|
||||
bool rearmAfterHandler;
|
||||
lock (_lock)
|
||||
{
|
||||
UnregisterWait();
|
||||
if (_disposed || timedOut) return;
|
||||
|
||||
if (_mode == WatchMode.Primary)
|
||||
{
|
||||
RaiseChanged();
|
||||
ArmNotify(subtree: true, PrimaryFilter); // re-arm on the same target key
|
||||
}
|
||||
else
|
||||
{
|
||||
// A direct child changed under the ancestor — promote to the target watch if it now
|
||||
// exists (raises Changed on promotion), else keep the cheap bootstrap watch.
|
||||
ArmBestEffort(raiseOnPromote: true);
|
||||
}
|
||||
rearmAfterHandler = _mode == WatchMode.Primary;
|
||||
// Bootstrap: a direct child changed under the ancestor — promote to the target watch if
|
||||
// it now exists (then Changed is due), else keep the cheap bootstrap watch.
|
||||
raise = rearmAfterHandler || ArmBestEffort(targetIsNews: true);
|
||||
}
|
||||
|
||||
if (raise) RaiseChanged();
|
||||
|
||||
if (!rearmAfterHandler) return;
|
||||
lock (_lock)
|
||||
{
|
||||
// Re-arm on the same target key, and only now: no wait was registered while the handler
|
||||
// ran, so handlers never overlap, and a change that arrived meanwhile completes this
|
||||
// registration at once.
|
||||
ArmNotify(subtree: true, PrimaryFilter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
// Never called with _lock held.
|
||||
private void RaiseChanged()
|
||||
{
|
||||
var handler = Changed;
|
||||
|
||||
+2
@@ -160,6 +160,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
// Suppress events when network hasn't actually changed (DHCP renewals, OS chatter)
|
||||
if (previousState != null && previousState.IsSameNetwork(newState))
|
||||
{
|
||||
// Every occurrence on purpose, no repeatKey: when it happened is the information
|
||||
// (did anything move on the network when uploads stopped?).
|
||||
_logger.Debug("NetworkChangeDetector: network address changed but same logical network — suppressing");
|
||||
return;
|
||||
}
|
||||
|
||||
+278
-87
@@ -1,10 +1,14 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Management;
|
||||
using System.Security.Principal;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
{
|
||||
@@ -26,10 +30,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Instant-close fallback.</b> Reading the command line still races if the process exits before
|
||||
/// the probe. In that case (<c>SessionID ≠ 0</c> but command line unreadable) the console is still
|
||||
/// surfaced, tagged <c>confidence:"low"</c> — better than missing it. A truly race-free capture of
|
||||
/// the command line would require the ETW Kernel-Process provider (deferred).
|
||||
/// <b>A process id is not an identity.</b> WMI delivers the start trace in batches about once a
|
||||
/// second, so the probe runs up to a second after the start. By then a scripted <c>cmd /c</c> has
|
||||
/// usually exited, and Windows may have given its id to another process. A start whose process is
|
||||
/// gone, or whose id now belongs to a different process (other image name, session 0, other
|
||||
/// parent), is not a console someone types into: it is counted in
|
||||
/// <see cref="ConsoleScriptActivityTally"/> and reported as one summary, never as a console.
|
||||
/// The comparison uses no clock on purpose — trace time and process start time need not follow
|
||||
/// the same clock corrections during OOBE, and a wrong tolerance would count real consoles away.
|
||||
/// Counting needs certainty: the process list no longer has the id, or a fact that was read
|
||||
/// contradicts the trace. A query that fails, or a fact that cannot be read, proves nothing —
|
||||
/// such a process is reported at low confidence, exactly as a running console nobody could
|
||||
/// classify.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Lifecycle.</b> The host stops this watcher once the real-user desktop has arrived (wired in
|
||||
@@ -69,9 +81,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
}
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly Func<int, ProcessProbe?> _processProbe;
|
||||
private readonly Func<int, string, ProcessProbe?> _processProbe;
|
||||
private readonly Func<int, bool> _isAlive;
|
||||
private readonly Func<int, string?> _parentName;
|
||||
private readonly Func<DateTime> _utcNow;
|
||||
private readonly ConsoleScriptActivityTally _tally = new ConsoleScriptActivityTally();
|
||||
private readonly object _lock = new object();
|
||||
private readonly HashSet<int> _seen = new HashSet<int>();
|
||||
private readonly HashSet<string> _seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
private ManagementEventWatcher? _startWatcher;
|
||||
private bool _started;
|
||||
@@ -79,11 +95,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
|
||||
/// <summary>
|
||||
/// Raised once per process when a watched console is classified as an interactive console
|
||||
/// (high confidence) or an unclassifiable interactive-session console (low confidence). The
|
||||
/// (high confidence) or as one that stays open or cannot be classified (low confidence). The
|
||||
/// host translates this into a Warning <c>oobe_console_spawned</c> event.
|
||||
/// </summary>
|
||||
public event EventHandler<ConsoleSpawnInfo>? BypassConsoleDetected;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a short-lived start is counted into an empty tally. The host opens its
|
||||
/// reporting window on it and collects the content with <see cref="TakeScriptActivity"/>.
|
||||
/// </summary>
|
||||
internal event EventHandler? ScriptActivityStarted;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the WMI <c>Win32_ProcessStartTrace</c> watcher fails to arm — the LIVE detector
|
||||
/// is dead and only the one-shot startup probe ran. The host surfaces this as a
|
||||
@@ -92,12 +114,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
/// </summary>
|
||||
public event EventHandler<Exception>? WatcherArmFailed;
|
||||
|
||||
/// <param name="processProbe">Resolves the command line + owner for a live PID; returns null
|
||||
/// when the process has already exited (the instant-close race). Defaults to a WMI query.</param>
|
||||
public ConsoleBypassWatcher(AgentLogger logger, Func<int, ProcessProbe?>? processProbe = null)
|
||||
/// <param name="processProbe">Reads a live process by id; the second argument is the image
|
||||
/// name the trace announced. Returns null only when the process has certainly exited or
|
||||
/// the id certainly runs another image, and <see cref="ProcessProbe.Unreadable"/> when it
|
||||
/// could not be read. Defaults to a process-list check followed by one WMI row.</param>
|
||||
/// <param name="isAlive">False only when a process id certainly no longer runs. Defaults to
|
||||
/// the process list.</param>
|
||||
/// <param name="parentName">Image file name of a process, or null when it cannot be read.</param>
|
||||
/// <param name="utcNow">Clock for the timestamps of counted starts.</param>
|
||||
public ConsoleBypassWatcher(
|
||||
AgentLogger logger,
|
||||
Func<int, string, ProcessProbe?>? processProbe = null,
|
||||
Func<int, bool>? isAlive = null,
|
||||
Func<int, string?>? parentName = null,
|
||||
Func<DateTime>? utcNow = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_processProbe = processProbe ?? DefaultProbe;
|
||||
_isAlive = isAlive ?? IsRunning;
|
||||
_parentName = parentName ?? ImageFileName;
|
||||
_utcNow = utcNow ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
@@ -114,7 +150,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
// delayed every host after this one — so they run on a background thread. Exceptions
|
||||
// are contained in StartCore; a disposal racing the background start is handled by
|
||||
// the _disposed re-checks inside.
|
||||
System.Threading.Tasks.Task.Run(() => StartCore());
|
||||
Task.Run(() => StartCore());
|
||||
}
|
||||
|
||||
private void StartCore()
|
||||
@@ -147,7 +183,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
watcher.Start();
|
||||
|
||||
// Re-check after arming: Dispose() may have run between the lock release above and
|
||||
// Start() — it then stopped a not-yet-armed watcher and this thread armed it right
|
||||
// Start() — it then tore down a not-yet-armed watcher and this thread armed it right
|
||||
// back, leaving a WMI subscription no code path could ever reach again. If disposal
|
||||
// won that race, tear the watcher down here.
|
||||
bool disposedDuringArm;
|
||||
@@ -158,9 +194,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
}
|
||||
if (disposedDuringArm)
|
||||
{
|
||||
try { watcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { watcher.Stop(); } catch { }
|
||||
try { watcher.Dispose(); } catch { }
|
||||
TearDownStartTrace(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -182,27 +216,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Startup probe — catch a console already open before the watcher armed. Win32_Process
|
||||
// exposes SessionId directly, so the same classification path is reused. Runs AFTER the
|
||||
// arm so there is no gap; overlap with the live trace is deduped by _seen.
|
||||
// 2) Startup probe — catch a console already open before the watcher armed. One query
|
||||
// carries everything the classification needs; Handle is selected because the owner
|
||||
// is a WMI method call on the row. Runs AFTER the arm so there is no gap; overlap
|
||||
// with the live trace is deduped by _seen.
|
||||
try
|
||||
{
|
||||
var nameFilter = string.Join(" OR ",
|
||||
Array.ConvertAll(WatchedProcessNames, n => $"Name = '{n}'"));
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
$"SELECT Name, ProcessId, ParentProcessId, SessionId FROM Win32_Process WHERE {nameFilter}");
|
||||
"SELECT Handle, Name, ProcessId, ParentProcessId, SessionId, CommandLine, CreationDate " +
|
||||
$"FROM Win32_Process WHERE {nameFilter}");
|
||||
using var results = searcher.Get();
|
||||
foreach (var mo in results)
|
||||
foreach (ManagementObject mo in results)
|
||||
{
|
||||
using (mo)
|
||||
{
|
||||
HandleStart(
|
||||
ToInt(mo["ProcessId"]),
|
||||
ToInt(mo["ParentProcessId"]),
|
||||
ToInt(mo["SessionId"]),
|
||||
ownerSidHint: null,
|
||||
detectedVia: "startup_probe",
|
||||
processName: mo["Name"]?.ToString() ?? WatchedProcessNames[0]);
|
||||
var sessionId = ToInt(mo["SessionId"]);
|
||||
var commandLine = mo["CommandLine"]?.ToString();
|
||||
// The owner costs a method call: only for a row that will be reported.
|
||||
if (Classify(sessionId, commandLine) == ConsoleVerdict.Ignore) continue;
|
||||
|
||||
var name = mo["Name"]?.ToString() ?? WatchedProcessNames[0];
|
||||
var parentPid = ToInt(mo["ParentProcessId"]);
|
||||
HandleProbed(
|
||||
ToInt(mo["ProcessId"]), parentPid, sessionId, ProcessOwnerLookup.OwnerSid(mo), "startup_probe", name,
|
||||
new ProcessProbe(name, sessionId, parentPid, commandLine, mo["CreationDate"]?.ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,13 +269,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classification core (internal for tests). Gates race-free on <paramref name="sessionId"/>,
|
||||
/// then probes the live process for its command line; raises <see cref="BypassConsoleDetected"/>
|
||||
/// once per PID for an interactive console (bare command line → high confidence) or an
|
||||
/// unreadable interactive-session console (instant-close → low confidence). Scripted
|
||||
/// invocations (<c>cmd /c</c>) and session-0 (service) cmd are ignored.
|
||||
/// One traced start (internal for tests). Gates race-free on <paramref name="sessionId"/>,
|
||||
/// then probes the process: a start that is gone or whose id belongs to another process is
|
||||
/// counted, everything else goes through <see cref="HandleProbed"/>.
|
||||
/// </summary>
|
||||
internal void HandleStart(int pid, int parentPid, int sessionId, string? ownerSidHint, string detectedVia, string? processName = null)
|
||||
internal void HandleStart(int pid, int parentPid, int sessionId, string? ownerSid, string detectedVia, string? processName = null)
|
||||
{
|
||||
if (pid <= 0) return;
|
||||
lock (_lock) { if (_disposed) return; }
|
||||
@@ -245,16 +282,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
// bulk of legitimate IME/Intune install cmd (SYSTEM, session 0) without even probing.
|
||||
if (sessionId == 0) return;
|
||||
|
||||
var probe = _processProbe(pid); // null when the process already exited (instant-close)
|
||||
var verdict = Classify(sessionId, probe?.CommandLine);
|
||||
var name = processName ?? WatchedProcessNames[0];
|
||||
var probe = _processProbe(pid, name);
|
||||
if (probe == null || !IsTracedProcess(probe, name, parentPid))
|
||||
{
|
||||
CountShortLived(name, pid, parentPid, sessionId, ownerSid);
|
||||
return;
|
||||
}
|
||||
|
||||
HandleProbed(pid, parentPid, sessionId, ownerSid, detectedVia, name, probe);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classification of a process that was read while it ran (internal for tests). Raises
|
||||
/// <see cref="BypassConsoleDetected"/> once per process for an interactive console (bare
|
||||
/// command line → high confidence), one that stays open after its command (<c>/k</c> → low)
|
||||
/// or a running one whose command line WMI does not return (low). A scripted <c>cmd /c</c>
|
||||
/// is ignored.
|
||||
/// </summary>
|
||||
internal void HandleProbed(int pid, int parentPid, int sessionId, string? ownerSid, string detectedVia, string processName, ProcessProbe probe)
|
||||
{
|
||||
lock (_lock) { if (_disposed) return; }
|
||||
|
||||
// A row without a command line is only a console nobody could classify while the
|
||||
// process still runs; one that ended in the meantime is a short-lived start.
|
||||
if (probe.CommandLine == null && !_isAlive(pid))
|
||||
{
|
||||
CountShortLived(processName, pid, parentPid, sessionId, ownerSid);
|
||||
return;
|
||||
}
|
||||
|
||||
var verdict = Classify(sessionId, probe.CommandLine);
|
||||
if (verdict == ConsoleVerdict.Ignore) return; // scripted invocation (cmd /c ...)
|
||||
|
||||
// Dedup only on a flagged hit, so the startup probe + live trace observing the same console
|
||||
// collapse to one detection, and an ignored start never pre-empts a later real one.
|
||||
// collapse to one detection, and an ignored start never pre-empts a later real one. The
|
||||
// creation time is part of the key: a later console may be given an id seen before.
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (!_seen.Add(pid)) return;
|
||||
if (!_seen.Add(pid + "|" + probe.CreationDate)) return;
|
||||
}
|
||||
|
||||
bool high = verdict == ConsoleVerdict.InteractiveConsole;
|
||||
@@ -266,15 +333,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
default: classification = "interactive_session_unclassified"; break;
|
||||
}
|
||||
var info = new ConsoleSpawnInfo(
|
||||
processName: processName ?? WatchedProcessNames[0],
|
||||
processName: processName,
|
||||
processId: pid,
|
||||
parentProcessId: parentPid,
|
||||
sessionId: sessionId,
|
||||
owner: probe?.Owner ?? ownerSidHint,
|
||||
commandLine: probe?.CommandLine,
|
||||
owner: ownerSid,
|
||||
commandLine: probe.CommandLine,
|
||||
confidence: high ? "high" : "low",
|
||||
classification: classification,
|
||||
detectedVia: detectedVia);
|
||||
detectedVia: detectedVia,
|
||||
parentProcessName: SafeParentName(parentPid));
|
||||
|
||||
_logger.Warning($"[ConsoleBypassWatcher] interactive console detected: {info.ProcessName} (PID={pid}) " +
|
||||
$"session={sessionId}, confidence={info.Confidence}, via={detectedVia} — possible Shift+F10");
|
||||
@@ -283,14 +351,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
catch (Exception ex) { _logger.Warning($"[ConsoleBypassWatcher] handler threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The counted short-lived starts since the last call, or null when there were none. The
|
||||
/// host calls it when its window ends and when it stops.
|
||||
/// </summary>
|
||||
internal ConsoleScriptActivity? TakeScriptActivity() => _tally.Take();
|
||||
|
||||
private void CountShortLived(string processName, int pid, int parentPid, int sessionId, string? ownerSid)
|
||||
{
|
||||
var opened = _tally.Add(processName, pid, parentPid, sessionId, ownerSid, _utcNow(), SafeParentName);
|
||||
_logger.Trace($"[ConsoleBypassWatcher] short-lived {processName} counted (PID={pid}, parent={parentPid}, session={sessionId})");
|
||||
if (!opened) return;
|
||||
|
||||
try { ScriptActivityStarted?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Debug($"[ConsoleBypassWatcher] script-activity handler threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private string? SafeParentName(int parentPid)
|
||||
{
|
||||
if (parentPid <= 0) return null;
|
||||
try { return _parentName(parentPid); }
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
internal enum ConsoleVerdict { Ignore, InteractiveConsole, UnclassifiedInteractive, InteractiveWithCommand }
|
||||
|
||||
/// <summary>
|
||||
/// Pure classification (internal for tests): session-0 → Ignore; a scripted <c>cmd /c</c> →
|
||||
/// Ignore; <c>cmd /k</c> (runs its command, then STAYS interactive — L12) →
|
||||
/// InteractiveWithCommand (low); a bare command line → InteractiveConsole (high); an
|
||||
/// unreadable command line in an interactive session → UnclassifiedInteractive (low, the
|
||||
/// instant-close fallback).
|
||||
/// InteractiveWithCommand (low); a bare command line → InteractiveConsole (high); a running
|
||||
/// process without a readable command line in an interactive session →
|
||||
/// UnclassifiedInteractive (low).
|
||||
/// </summary>
|
||||
internal static ConsoleVerdict Classify(int sessionId, string? commandLine)
|
||||
{
|
||||
@@ -304,15 +395,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when the FIRST <c>/c</c>-or-<c>/k</c> switch on the command line is <c>/c</c>
|
||||
/// (a scripted run-and-exit invocation, mirroring cmd.exe precedence), false for a bare
|
||||
/// interactive shell or a <c>/k</c> run-and-stay shell — even one whose payload contains a
|
||||
/// later <c>/c</c>. The executable token is stripped first so a path can never spoof a
|
||||
/// switch, and switches may be combined (e.g. <c>cmd /d/q/c exit 9</c>).</summary>
|
||||
internal static bool HasScriptArgument(string commandLine)
|
||||
/// <summary>
|
||||
/// Whether a probed process is still the one the trace announced (internal for tests). Only
|
||||
/// a fact that contradicts the trace makes it another process — another image name, the
|
||||
/// service session, another parent. A fact that could not be read contradicts nothing: the
|
||||
/// process is then reported, never counted away.
|
||||
/// </summary>
|
||||
internal static bool IsTracedProcess(ProcessProbe probe, string processName, int parentPid)
|
||||
{
|
||||
if (string.IsNullOrEmpty(commandLine)) return false;
|
||||
return FirstRunSwitch(StripExecutable(commandLine)) == RunSwitch.RunAndExit;
|
||||
if (probe.Name != null && !string.Equals(probe.Name, processName, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (probe.SessionId == 0) return false;
|
||||
return parentPid <= 0 || probe.ParentProcessId == null || probe.ParentProcessId <= 0 || probe.ParentProcessId == parentPid;
|
||||
}
|
||||
|
||||
/// <summary>Returns the command line with the leading executable token (quoted or
|
||||
@@ -330,44 +423,100 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
return space >= 0 ? s.Substring(space + 1) : string.Empty;
|
||||
}
|
||||
|
||||
private static ProcessProbe? DefaultProbe(int pid)
|
||||
// The process list first: a WMI query takes a few hundred milliseconds, and in a burst
|
||||
// almost every traced process has already exited. Queried one after the other on the
|
||||
// delivery thread they would pile up, and a real console closed during the backlog would
|
||||
// be counted as a script.
|
||||
private static ProcessProbe? DefaultProbe(int pid, string processName) =>
|
||||
Probe(pid, ProcessListState(pid, processName), ReadRow);
|
||||
|
||||
/// <summary>
|
||||
/// The probe's decision (internal for tests). Null only when the process list says so for
|
||||
/// certain: the id no longer runs, or it runs another image. Everything else comes back as
|
||||
/// a probe — with the row's facts, or <see cref="ProcessProbe.Unreadable"/> when the query
|
||||
/// failed or returned nothing. A failed query says nothing about the process, so it must
|
||||
/// never read as "exited".
|
||||
/// </summary>
|
||||
internal static ProcessProbe? Probe(int pid, ListedAs listed, Func<int, ProcessProbe?> readRow)
|
||||
{
|
||||
if (listed == ListedAs.NotRunning || listed == ListedAs.OtherImage) return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
$"SELECT CommandLine FROM Win32_Process WHERE ProcessId = {pid}");
|
||||
using var results = searcher.Get();
|
||||
foreach (ManagementObject mo in results)
|
||||
{
|
||||
using (mo)
|
||||
{
|
||||
var commandLine = mo["CommandLine"]?.ToString();
|
||||
return new ProcessProbe(commandLine, TryGetOwner(mo));
|
||||
}
|
||||
}
|
||||
return readRow(pid) ?? ProcessProbe.Unreadable;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Process gone or query failed — treated as the instant-close fallback (low confidence).
|
||||
return ProcessProbe.Unreadable; // WinMgmt can fail or hang during OOBE
|
||||
}
|
||||
}
|
||||
|
||||
private static ProcessProbe? ReadRow(int pid)
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
$"SELECT Name, SessionId, ParentProcessId, CommandLine, CreationDate FROM Win32_Process WHERE ProcessId = {pid}");
|
||||
using var results = searcher.Get();
|
||||
foreach (ManagementObject mo in results)
|
||||
{
|
||||
using (mo)
|
||||
{
|
||||
return new ProcessProbe(
|
||||
mo["Name"]?.ToString(),
|
||||
ToNullableInt(mo["SessionId"]),
|
||||
ToNullableInt(mo["ParentProcessId"]),
|
||||
mo["CommandLine"]?.ToString(),
|
||||
mo["CreationDate"]?.ToString());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? TryGetOwner(ManagementObject process)
|
||||
/// <summary>What the process list says about an id and the image name expected under it.</summary>
|
||||
internal enum ListedAs { NotRunning, OtherImage, Expected, Unknown }
|
||||
|
||||
/// <summary>Internal for tests.</summary>
|
||||
internal static ListedAs ProcessListState(int pid, string processName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var outParams = process.InvokeMethod("GetOwner", null, null);
|
||||
if (outParams != null && Convert.ToInt32(outParams["ReturnValue"]) == 0)
|
||||
{
|
||||
var domain = outParams["Domain"]?.ToString();
|
||||
var user = outParams["User"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(user))
|
||||
return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}";
|
||||
}
|
||||
using var process = Process.GetProcessById(pid);
|
||||
return string.Equals(process.ProcessName, Path.GetFileNameWithoutExtension(processName), StringComparison.OrdinalIgnoreCase)
|
||||
? ListedAs.Expected
|
||||
: ListedAs.OtherImage;
|
||||
}
|
||||
catch { /* best-effort enrichment */ }
|
||||
return null;
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return ListedAs.NotRunning; // what GetProcessById throws for an id that does not run
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return ListedAs.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>False only when the id certainly does not run; when that cannot be told, true.
|
||||
/// Internal for tests.</summary>
|
||||
internal static bool IsRunning(int pid)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(pid);
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ImageFileName(int pid)
|
||||
{
|
||||
var path = ProcessImagePath.Query(pid);
|
||||
return string.IsNullOrEmpty(path) ? null : Path.GetFileName(path);
|
||||
}
|
||||
|
||||
private static string? TryReadSid(object? sidValue)
|
||||
@@ -388,42 +537,77 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
private static int? ToNullableInt(object? value)
|
||||
{
|
||||
if (value == null) return null;
|
||||
try { return Convert.ToInt32(value); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private void TearDownStartTrace(ManagementEventWatcher watcher)
|
||||
{
|
||||
try { watcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { watcher.Stop(); } catch { }
|
||||
try { watcher.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ManagementEventWatcher? startWatcher;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
startWatcher = _startWatcher;
|
||||
_startWatcher = null;
|
||||
}
|
||||
|
||||
if (_startWatcher != null)
|
||||
if (startWatcher != null)
|
||||
{
|
||||
try { _startWatcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { _startWatcher.Stop(); } catch { }
|
||||
try { _startWatcher.Dispose(); } catch { }
|
||||
_startWatcher = null;
|
||||
// Cancelling a WMI subscription can take tens of seconds on a busy WinMgmt. The caller
|
||||
// is the agent's stop path, so it never waits for it.
|
||||
var armed = startWatcher;
|
||||
Task.Run(() => TearDownStartTrace(armed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Command line + owner for a probed PID; either may be null (best-effort).</summary>
|
||||
/// <summary>
|
||||
/// What was read about a running process: the facts that tell whether it is still the process
|
||||
/// a start trace announced, and its command line. Every field is null when it could not be
|
||||
/// read; null never means "no" or "zero".
|
||||
/// </summary>
|
||||
public sealed class ProcessProbe
|
||||
{
|
||||
public ProcessProbe(string? commandLine, string? owner)
|
||||
/// <summary>
|
||||
/// A process that runs but about which nothing could be read (the query failed or returned
|
||||
/// no row). It goes on as the traced process without a command line, so it is reported.
|
||||
/// </summary>
|
||||
internal static readonly ProcessProbe Unreadable = new ProcessProbe(null, null, null, null, null);
|
||||
|
||||
public ProcessProbe(string? name, int? sessionId, int? parentProcessId, string? commandLine, string? creationDate)
|
||||
{
|
||||
Name = name;
|
||||
SessionId = sessionId;
|
||||
ParentProcessId = parentProcessId;
|
||||
CommandLine = commandLine;
|
||||
Owner = owner;
|
||||
CreationDate = creationDate;
|
||||
}
|
||||
|
||||
public string? Name { get; }
|
||||
public int? SessionId { get; }
|
||||
public int? ParentProcessId { get; }
|
||||
public string? CommandLine { get; }
|
||||
public string? Owner { get; }
|
||||
/// <summary>The raw WMI creation time; only compared for equality.</summary>
|
||||
public string? CreationDate { get; }
|
||||
}
|
||||
|
||||
/// <summary>Immutable detail record for a detected interactive console spawn.</summary>
|
||||
public sealed class ConsoleSpawnInfo
|
||||
{
|
||||
public ConsoleSpawnInfo(string processName, int processId, int parentProcessId, int sessionId,
|
||||
string? owner, string? commandLine, string confidence, string classification, string detectedVia)
|
||||
string? owner, string? commandLine, string confidence, string classification, string detectedVia,
|
||||
string? parentProcessName)
|
||||
{
|
||||
ProcessName = processName;
|
||||
ProcessId = processId;
|
||||
@@ -434,17 +618,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
Confidence = confidence;
|
||||
Classification = classification;
|
||||
DetectedVia = detectedVia;
|
||||
ParentProcessName = parentProcessName;
|
||||
}
|
||||
|
||||
public string ProcessName { get; }
|
||||
public int ProcessId { get; }
|
||||
public int ParentProcessId { get; }
|
||||
public int SessionId { get; }
|
||||
/// <summary>Owner SID, or null when it could not be read.</summary>
|
||||
public string? Owner { get; }
|
||||
public string? CommandLine { get; }
|
||||
/// <summary>"high" (bare interactive shell) or "low" (interactive session, command line unreadable).</summary>
|
||||
/// <summary>"high" (bare interactive shell) or "low" (stays open after a command, or not classifiable).</summary>
|
||||
public string Confidence { get; }
|
||||
public string Classification { get; }
|
||||
public string DetectedVia { get; }
|
||||
/// <summary>
|
||||
/// Image name of the parent while it still ran, else null. The launcher behind Shift+F10
|
||||
/// exits within milliseconds, so null is the expected value for that case.
|
||||
/// </summary>
|
||||
public string? ParentProcessName { get; }
|
||||
}
|
||||
}
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Counts console processes that were gone before they could be inspected — script children,
|
||||
/// reported as one summary per window instead of one event each. Thread-safe. The content
|
||||
/// leaves through <see cref="Take"/>, which hands out copies and leaves an empty tally behind,
|
||||
/// so a start is never counted in two summaries.
|
||||
/// </summary>
|
||||
internal sealed class ConsoleScriptActivityTally
|
||||
{
|
||||
internal const int MaxOwnersListed = 5;
|
||||
internal const int MaxParentsListed = 10;
|
||||
internal const int MaxParentLookups = 10;
|
||||
|
||||
// A spawner can keep running for hours after the host has stopped opening windows. The
|
||||
// count grows with it; what is remembered per start does not.
|
||||
private const int MaxTrackedParents = 256;
|
||||
private const int MaxTrackedProcessIds = 2048;
|
||||
private const int MaxTrackedOwners = 64;
|
||||
|
||||
private sealed class Parent
|
||||
{
|
||||
public string? Name;
|
||||
public int Children;
|
||||
}
|
||||
|
||||
private readonly object _lock = new object();
|
||||
private readonly SortedSet<int> _sessions = new SortedSet<int>();
|
||||
private readonly List<string> _owners = new List<string>();
|
||||
private readonly Dictionary<int, Parent> _parents = new Dictionary<int, Parent>();
|
||||
private readonly HashSet<int> _processIds = new HashSet<int>();
|
||||
private string _processName = string.Empty;
|
||||
private int _count;
|
||||
private int _parentLookups;
|
||||
private DateTime _firstSeenUtc;
|
||||
private DateTime _lastSeenUtc;
|
||||
|
||||
/// <summary>
|
||||
/// Counts one start. <paramref name="parentName"/> is asked at most once per parent and at
|
||||
/// most <see cref="MaxParentLookups"/> times per window, outside the lock; a parent that is
|
||||
/// itself one of the counted processes is named without asking.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> when this start opened an empty tally.</returns>
|
||||
public bool Add(
|
||||
string processName, int processId, int parentProcessId, int sessionId, string? ownerSid,
|
||||
DateTime seenUtc, Func<int, string?> parentName)
|
||||
{
|
||||
bool opened;
|
||||
Parent? toResolve = null;
|
||||
lock (_lock)
|
||||
{
|
||||
opened = _count == 0;
|
||||
if (opened)
|
||||
{
|
||||
_processName = processName;
|
||||
_firstSeenUtc = seenUtc;
|
||||
}
|
||||
_count++;
|
||||
_lastSeenUtc = seenUtc;
|
||||
_sessions.Add(sessionId);
|
||||
|
||||
if (!string.IsNullOrEmpty(ownerSid) && _owners.Count < MaxTrackedOwners && !_owners.Contains(ownerSid!))
|
||||
_owners.Add(ownerSid!);
|
||||
|
||||
if (parentProcessId > 0)
|
||||
{
|
||||
if (_parents.TryGetValue(parentProcessId, out var parent))
|
||||
{
|
||||
parent.Children++;
|
||||
}
|
||||
else if (_parents.Count < MaxTrackedParents)
|
||||
{
|
||||
parent = new Parent { Children = 1 };
|
||||
_parents[parentProcessId] = parent;
|
||||
if (_processIds.Contains(parentProcessId))
|
||||
{
|
||||
parent.Name = processName;
|
||||
}
|
||||
else if (_parentLookups < MaxParentLookups)
|
||||
{
|
||||
_parentLookups++;
|
||||
toResolve = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_processIds.Count < MaxTrackedProcessIds) _processIds.Add(processId);
|
||||
}
|
||||
|
||||
if (toResolve != null)
|
||||
{
|
||||
var name = parentName(parentProcessId);
|
||||
lock (_lock) { toResolve.Name = name; }
|
||||
}
|
||||
|
||||
return opened;
|
||||
}
|
||||
|
||||
/// <summary>The counted content, or <c>null</c> when nothing was counted. Empties the tally.</summary>
|
||||
public ConsoleScriptActivity? Take()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_count == 0) return null;
|
||||
|
||||
var parents = _parents
|
||||
.Select(kv => new ConsoleScriptParent(kv.Key, kv.Value.Name, kv.Value.Children))
|
||||
.OrderByDescending(p => p.Children)
|
||||
.ThenBy(p => p.ProcessId)
|
||||
.ToList();
|
||||
|
||||
var activity = new ConsoleScriptActivity(
|
||||
_processName, _count, _firstSeenUtc, _lastSeenUtc,
|
||||
_sessions.ToList(),
|
||||
_owners.Take(MaxOwnersListed).ToList(), _owners.Count,
|
||||
parents.Take(MaxParentsListed).ToList(), parents.Count);
|
||||
|
||||
_count = 0;
|
||||
_parentLookups = 0;
|
||||
_processName = string.Empty;
|
||||
_sessions.Clear();
|
||||
_owners.Clear();
|
||||
_parents.Clear();
|
||||
_processIds.Clear();
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One window of counted console processes. Lists are capped; the totals are not.</summary>
|
||||
internal sealed class ConsoleScriptActivity
|
||||
{
|
||||
public ConsoleScriptActivity(
|
||||
string processName, int count, DateTime firstSeenUtc, DateTime lastSeenUtc,
|
||||
IReadOnlyList<int> windowsSessions,
|
||||
IReadOnlyList<string> owners, int ownersTotal,
|
||||
IReadOnlyList<ConsoleScriptParent> parents, int parentsTotal)
|
||||
{
|
||||
ProcessName = processName;
|
||||
Count = count;
|
||||
FirstSeenUtc = firstSeenUtc;
|
||||
LastSeenUtc = lastSeenUtc;
|
||||
WindowsSessions = windowsSessions;
|
||||
Owners = owners;
|
||||
OwnersTotal = ownersTotal;
|
||||
Parents = parents;
|
||||
ParentsTotal = parentsTotal;
|
||||
}
|
||||
|
||||
public string ProcessName { get; }
|
||||
public int Count { get; }
|
||||
public DateTime FirstSeenUtc { get; }
|
||||
public DateTime LastSeenUtc { get; }
|
||||
public IReadOnlyList<int> WindowsSessions { get; }
|
||||
public IReadOnlyList<string> Owners { get; }
|
||||
public int OwnersTotal { get; }
|
||||
/// <summary>Parents by number of children, most first.</summary>
|
||||
public IReadOnlyList<ConsoleScriptParent> Parents { get; }
|
||||
public int ParentsTotal { get; }
|
||||
}
|
||||
|
||||
/// <summary>A parent process of counted console processes. Only its image name is kept, never its command line.</summary>
|
||||
internal sealed class ConsoleScriptParent
|
||||
{
|
||||
public ConsoleScriptParent(int processId, string? name, int children)
|
||||
{
|
||||
ProcessId = processId;
|
||||
Name = name;
|
||||
Children = children;
|
||||
}
|
||||
|
||||
public int ProcessId { get; }
|
||||
/// <summary><c>null</c> when the parent had exited or was not looked up.</summary>
|
||||
public string? Name { get; }
|
||||
public int Children { get; }
|
||||
}
|
||||
}
|
||||
@@ -260,6 +260,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
await ThrowOnAuthFailureAsync(response, correlationId).ConfigureAwait(false);
|
||||
await ThrowOnCodedConflictAsync(response).ConfigureAwait(false);
|
||||
ThrowOnSessionGone(response, correlationId);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
@@ -412,6 +413,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
errorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws <see cref="BackendSessionGoneException"/> for a 410 the backend answered: the
|
||||
/// session was deleted or is being deleted. Only a response that echoes this request's
|
||||
/// <c>X-Correlation-ID</c> counts — the caller ends monitoring on it, so a 410 from the
|
||||
/// network path stays an ordinary failure (<c>EnsureSuccessStatusCode</c>) and is retried.
|
||||
/// </summary>
|
||||
private static void ThrowOnSessionGone(HttpResponseMessage response, string correlationId)
|
||||
{
|
||||
if (response.StatusCode != HttpStatusCode.Gone)
|
||||
return;
|
||||
if (!ResponseOriginClassifier.EchoesCorrelationId(response, correlationId))
|
||||
return;
|
||||
|
||||
throw new BackendSessionGoneException(
|
||||
"Backend returned 410 Gone: the session was deleted or is being deleted.",
|
||||
(int)response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulls <c>errorCode</c> out of a backend JSON error body. Tolerant: any parse failure
|
||||
/// or a non-string value yields null, never an exception.
|
||||
@@ -537,4 +556,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown for a 410 Gone the backend answered: the session named in the request was deleted or
|
||||
/// is being deleted. No retry can clear it and it says nothing about the device's authorization.
|
||||
/// </summary>
|
||||
public class BackendSessionGoneException : Exception
|
||||
{
|
||||
/// <summary>HTTP status code returned by the backend (410).</summary>
|
||||
public int StatusCode { get; }
|
||||
|
||||
public BackendSessionGoneException(string message, int statusCode) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallow — distress channel must never cascade failures into the caller
|
||||
_logger?.Debug($"[DistressChannel] Failed to send report: {ex.Message}");
|
||||
_logger?.Debug($"[DistressChannel] Failed to send report: {TransportErrorCodes.DescribeSendFailure(ex)}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// - Same error key (ErrorType + HTTP status code) is only ever sent once per session
|
||||
/// - At most <see cref="MaxReportsPerSession"/> emergency reports total per session
|
||||
/// - At most one report every <see cref="MinIntervalMinutes"/> minutes regardless of error type
|
||||
/// (the terminal <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/> report is exempt)
|
||||
/// (the terminal session-gone reports <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/>
|
||||
/// and <see cref="AgentErrorType.RegistrationRejectedSessionGone"/> are exempt)
|
||||
///
|
||||
/// A failure in this reporter is silently swallowed — it must never cascade into the main loop.
|
||||
/// </summary>
|
||||
@@ -123,11 +124,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
return;
|
||||
}
|
||||
|
||||
// The session-unknown report is terminal and sent at most once per process (the
|
||||
// A session-gone report is terminal and sent at most once per process (the
|
||||
// termination follows immediately): a routine report minutes earlier must not cost
|
||||
// the last word about the session. Only the cooldown is waived; key dedup and the
|
||||
// per-session maximum still apply.
|
||||
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown;
|
||||
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown
|
||||
|| errorType == AgentErrorType.RegistrationRejectedSessionGone;
|
||||
if (!exemptFromCooldown && _lastReportTime != null &&
|
||||
(now - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
|
||||
{
|
||||
@@ -180,7 +182,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallow — emergency channel must never cascade failures into the caller
|
||||
_logger?.Debug($"[EmergencyChannel] Failed to send report: {ex.Message}");
|
||||
_logger?.Debug($"[EmergencyChannel] Failed to send report: {TransportErrorCodes.DescribeSendFailure(ex)}");
|
||||
}
|
||||
|
||||
if (!delivered)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
@@ -31,6 +33,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
return code == null ? ex.Message : $"{ex.Message} (win32 {code})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a failed send hit, in one line: the <see cref="WebException.Status"/>, the socket error
|
||||
/// or native code, and the innermost message. On .NET Framework a socket error's message names
|
||||
/// the endpoint it tried, which tells a backend that does not answer from a proxy that does not.
|
||||
/// </summary>
|
||||
public static string DescribeSendFailure(Exception ex)
|
||||
{
|
||||
if (ex == null) return null;
|
||||
|
||||
WebExceptionStatus? status = null;
|
||||
SocketError? socketError = null;
|
||||
var innermost = ex;
|
||||
for (var current = ex; current != null; current = current.InnerException)
|
||||
{
|
||||
innermost = current;
|
||||
if (current is WebException web && status == null) status = web.Status;
|
||||
if (current is SocketException socket) socketError = socket.SocketErrorCode;
|
||||
}
|
||||
|
||||
var code = socketError.HasValue ? $"socket={socketError.Value}" : NativeCodePart(ex);
|
||||
var details = status.HasValue
|
||||
? (code == null ? $"status={status.Value}" : $"status={status.Value}, {code}")
|
||||
: code;
|
||||
return details == null ? innermost.Message : $"{details}: {innermost.Message}";
|
||||
}
|
||||
|
||||
private static string NativeCodePart(Exception ex)
|
||||
{
|
||||
var code = InnermostNativeCode(ex);
|
||||
return code == null ? null : $"win32={code}";
|
||||
}
|
||||
|
||||
/// <summary>Eight-digit hex with <c>0x</c>: <c>-2146893018</c> → <c>0x80090326</c>.</summary>
|
||||
public static string Hex(int value) => "0x" + value.ToString("X8", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
private readonly DateTime? _previousRunLastAliveUtc;
|
||||
|
||||
private readonly Transport.ProxyObserver? _proxyObserver;
|
||||
|
||||
public DefaultComponentFactory(
|
||||
AgentConfiguration agentConfig,
|
||||
AgentConfigResponse remoteConfig,
|
||||
@@ -105,7 +107,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
Persistence.StartupEventGate? startupEventGate = null,
|
||||
DateTime? previousBootUtc = null,
|
||||
string? previousExitType = null,
|
||||
DateTime? previousRunLastAliveUtc = null)
|
||||
DateTime? previousRunLastAliveUtc = null,
|
||||
Transport.ProxyObserver? proxyObserver = null)
|
||||
{
|
||||
_agentConfig = agentConfig ?? throw new ArgumentNullException(nameof(agentConfig));
|
||||
_remoteConfig = remoteConfig ?? throw new ArgumentNullException(nameof(remoteConfig));
|
||||
@@ -116,6 +119,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_previousBootUtc = previousBootUtc;
|
||||
_previousExitType = previousExitType;
|
||||
_previousRunLastAliveUtc = previousRunLastAliveUtc;
|
||||
_proxyObserver = proxyObserver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -352,28 +356,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
hosts.Add(aadJoinHost);
|
||||
aadJoinHostRef = aadJoinHost;
|
||||
|
||||
// RealmJoin support is opt-in per tenant (portal toggle → AnalyzerConfiguration.
|
||||
// EnableRealmJoinWatcher, default off). When disabled, leave realmJoinHost null:
|
||||
// the DesktopArrival observer above already null-guards it, so the HKU watcher is
|
||||
// never armed and no RealmJoin signals are produced. The compile-time const
|
||||
// RealmJoinHost.RealmJoinTrackingEnabled remains a build-time master kill-switch;
|
||||
// the effective enable is (remote flag AND const).
|
||||
var analyzers = _remoteConfig.Analyzers ?? new AnalyzerConfiguration();
|
||||
if (analyzers.EnableRealmJoinWatcher && RealmJoinHost.RealmJoinTrackingEnabled)
|
||||
{
|
||||
realmJoinHost = new RealmJoinHost(logger, ingress, clock);
|
||||
hosts.Add(realmJoinHost);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Info($"DefaultComponentFactory: RealmJoinHost not created (EnableRealmJoinWatcher={analyzers.EnableRealmJoinWatcher}, RealmJoinTrackingEnabled={RealmJoinHost.RealmJoinTrackingEnabled})");
|
||||
}
|
||||
|
||||
// Keep-awake during User-ESP — opt-in per tenant (portal toggle → AnalyzerConfiguration.
|
||||
// KeepAwakeDuringUserEsp, default off). The host observes the signal rail and holds the
|
||||
// device awake (system + display) from AccountSetup entry until provisioning completes
|
||||
// (or a safety cap), so idle standby cannot stall app installs / account setup. Reboots
|
||||
// device awake (system + display) from AccountSetup entry until the user-ESP page
|
||||
// closes — with the RealmJoin Watcher on, until RealmJoin starts its deployment (D-413) —
|
||||
// or a safety cap, so idle standby cannot stall app installs / account setup. Reboots
|
||||
// are unaffected; the OS auto-clears the hold on process exit.
|
||||
// ORDER: before the RealmJoinHost — after an agent restart the watcher posts
|
||||
// realmjoin_detected from its own Start, and this host must already be subscribed.
|
||||
if (analyzers.KeepAwakeDuringUserEsp)
|
||||
{
|
||||
hosts.Add(new UserEspKeepAwakeHost(
|
||||
@@ -382,7 +374,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
logger: logger,
|
||||
// q8n: release on the user-ESP page exit, i.e. a Shell-Core 62407 that arrives
|
||||
// after the AccountSetup registry left notStarted — never on the handoff exit.
|
||||
accountSetupProgressProbe: () => espAndHelloHost.HasAccountSetupProgress));
|
||||
accountSetupProgressProbe: () => espAndHelloHost.HasAccountSetupProgress,
|
||||
// D-414: that exit is latched here, so a later agent run does not engage again.
|
||||
startupGate: _startupEventGate));
|
||||
}
|
||||
|
||||
// RealmJoin support is opt-in per tenant (portal toggle → AnalyzerConfiguration.
|
||||
// EnableRealmJoinWatcher, default off). When disabled, leave realmJoinHost null:
|
||||
// the DesktopArrival observer above already null-guards it, so the HKU watcher is
|
||||
// never armed and no RealmJoin signals are produced. The compile-time const
|
||||
// RealmJoinHost.RealmJoinTrackingEnabled remains a build-time master kill-switch;
|
||||
// the effective enable is (remote flag AND const).
|
||||
if (analyzers.EnableRealmJoinWatcher && RealmJoinHost.RealmJoinTrackingEnabled)
|
||||
{
|
||||
realmJoinHost = new RealmJoinHost(logger, ingress, clock);
|
||||
hosts.Add(realmJoinHost);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Info($"DefaultComponentFactory: RealmJoinHost not created (EnableRealmJoinWatcher={analyzers.EnableRealmJoinWatcher}, RealmJoinTrackingEnabled={RealmJoinHost.RealmJoinTrackingEnabled})");
|
||||
}
|
||||
|
||||
// OOBE-console / Shift+F10 detection — opt-OUT per tenant (portal toggle →
|
||||
@@ -666,6 +676,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
clock: clock,
|
||||
logger: logger));
|
||||
|
||||
// The exact time of a proxy change for the agent log and the timeline. Always on: when
|
||||
// this matters, nothing else from the device reaches the backend.
|
||||
if (_proxyObserver != null)
|
||||
{
|
||||
hosts.Add(new ProxySettingsHost(
|
||||
observer: _proxyObserver,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
logger: logger));
|
||||
}
|
||||
|
||||
// Live AC/battery watcher — WMI Win32_PowerManagementEvent push (no polling), emits
|
||||
// power_state_change on AC↔battery transitions and 50/30/15 % downward threshold
|
||||
// crossings. Deliberately NOT under PeriodicCollectorLifecycleHost: it is push-based
|
||||
|
||||
@@ -67,6 +67,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <summary>Default Timeout für den Terminal-Drain beim Stop.</summary>
|
||||
public static readonly TimeSpan DefaultTerminalDrainTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// How long termination waits for ONE collector host to stop (and later to dispose). A whole
|
||||
/// healthy stop pass takes about 0.2 s and the slowest single host seen in the field 7 s, so
|
||||
/// this only ever ends the wait for a host that hangs.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultCollectorHostStopTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly string _stateDirectory;
|
||||
@@ -81,6 +88,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly int _quarantineThreshold;
|
||||
private readonly TimeSpan _drainInterval;
|
||||
private readonly TimeSpan _terminalDrainTimeout;
|
||||
private readonly TimeSpan _collectorHostStopTimeout;
|
||||
private readonly TimeSpan? _agentMaxLifetime;
|
||||
private readonly int _uploadBatchSize;
|
||||
|
||||
@@ -124,6 +132,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private SessionTraceOrdinalProvider? _traceCounter;
|
||||
private SignalIngress? _ingress;
|
||||
private IReadOnlyList<ICollectorHost>? _collectorHosts;
|
||||
// Hosts that did not come back from Stop or Dispose in time. A thread is still inside them,
|
||||
// so nothing calls into them again. Locked on itself.
|
||||
private readonly HashSet<ICollectorHost> _unresponsiveHosts = new HashSet<ICollectorHost>();
|
||||
|
||||
private EventHandler<DeadlineFiredEventArgs>? _deadlineBridge;
|
||||
|
||||
@@ -181,7 +192,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int uploadBatchSize = 100,
|
||||
IDeadlineScheduler? schedulerOverride = null,
|
||||
Func<bool>? traceEventsEnabled = null,
|
||||
Func<bool>? espContinueAnywayObservationEnabled = null)
|
||||
Func<bool>? espContinueAnywayObservationEnabled = null,
|
||||
TimeSpan? collectorHostStopTimeout = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
|
||||
@@ -211,6 +223,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_quarantineThreshold = quarantineThreshold;
|
||||
_drainInterval = drainInterval ?? DefaultDrainInterval;
|
||||
_terminalDrainTimeout = terminalDrainTimeout ?? DefaultTerminalDrainTimeout;
|
||||
_collectorHostStopTimeout = collectorHostStopTimeout ?? DefaultCollectorHostStopTimeout;
|
||||
|
||||
if (agentMaxLifetime.HasValue && agentMaxLifetime.Value <= TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(agentMaxLifetime), "AgentMaxLifetime must be positive when set.");
|
||||
@@ -947,6 +960,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// diagnostics ZIP is built so periodic collectors (PerformanceCollector,
|
||||
/// AgentSelfMetricsCollector, …) don't keep emitting <c>performance_snapshot</c> /
|
||||
/// <c>agent_metrics_snapshot</c> events while the package is being assembled.
|
||||
/// <para>
|
||||
/// Bounded per host (<see cref="DefaultCollectorHostStopTimeout"/>): a host that hangs is
|
||||
/// left behind and the remaining hosts are still stopped. The diagnostics upload and the
|
||||
/// self-destruct come after this call, so it has to return whatever a collector does.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void StopCollectorHosts()
|
||||
{
|
||||
@@ -955,8 +973,94 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
foreach (var host in _collectorHosts)
|
||||
{
|
||||
try { host.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' stop failed: {ex.Message}"); }
|
||||
RunHostStepBounded(host, "stop", h => h.Stop());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one host's Stop or Dispose on a thread of its own and waits at most
|
||||
/// <see cref="_collectorHostStopTimeout"/> for it.
|
||||
/// </summary>
|
||||
private void RunHostStepBounded(ICollectorHost host, string step, Action<ICollectorHost> action)
|
||||
{
|
||||
lock (_unresponsiveHosts)
|
||||
{
|
||||
if (_unresponsiveHosts.Contains(host)) return;
|
||||
}
|
||||
|
||||
Exception? failure = null;
|
||||
var worker = new Thread(() =>
|
||||
{
|
||||
try { action(host); }
|
||||
catch (Exception ex) { failure = ex; }
|
||||
})
|
||||
{
|
||||
// Not a ThreadPool thread: a host that hangs would keep one for good, and the pool may
|
||||
// be the very thing that is stuck. Background, so it cannot keep the process alive.
|
||||
IsBackground = true,
|
||||
Name = $"CollectorHost.{step}:{host.Name}",
|
||||
};
|
||||
try { worker.Start(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' {step} could not be started: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (worker.Join(_collectorHostStopTimeout))
|
||||
{
|
||||
if (failure == null) return;
|
||||
if (step == "stop")
|
||||
_logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' stop failed: {failure.Message}");
|
||||
else
|
||||
_logger.Debug($"EnrollmentOrchestrator: host '{host.Name}' {step} failed: {failure.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_unresponsiveHosts)
|
||||
{
|
||||
_unresponsiveHosts.Add(host);
|
||||
}
|
||||
var seconds = (int)Math.Ceiling(_collectorHostStopTimeout.TotalSeconds);
|
||||
_logger.Warning(
|
||||
$"EnrollmentOrchestrator: host '{host.Name}' did not return from {step} within {seconds}s — " +
|
||||
"termination continues without it.");
|
||||
if (step == "stop") EmitCollectorHostStopTimeout(host.Name, seconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>agent_trace</c> per host that hung in Stop, so the fleet can count it. Deliberately
|
||||
/// not <c>collector_degraded</c>: that one says the collector's signals are missing and gates
|
||||
/// analyze rules, while a host that will not stop has missed nothing. (A host that hangs in
|
||||
/// Dispose is logged only — the ingress is already down by then.)
|
||||
/// </summary>
|
||||
private void EmitCollectorHostStopTimeout(string hostName, int timeoutSeconds)
|
||||
{
|
||||
var ingress = _ingress;
|
||||
if (ingress == null) return;
|
||||
try
|
||||
{
|
||||
new InformationalEventPost(ingress, _clock).Emit(new Shared.Models.EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.AgentTrace,
|
||||
Severity = Shared.Models.EventSeverity.Warning,
|
||||
Source = "EnrollmentOrchestrator",
|
||||
Phase = Shared.Models.EnrollmentPhase.Unknown,
|
||||
Message = $"Collector host '{hostName}' did not stop within {timeoutSeconds}s; termination continued without it.",
|
||||
Timestamp = _clock.UtcNow,
|
||||
Data = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["decision"] = "collector_host_stop_timeout",
|
||||
["host"] = hostName,
|
||||
["timeoutSeconds"] = timeoutSeconds,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"EnrollmentOrchestrator: failed to post collector_host_stop_timeout: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1043,12 +1147,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: final snapshot save failed: {ex.Message}"); }
|
||||
|
||||
// 7) Dispose aller Disposables.
|
||||
// 7) Dispose all disposables. Hosts go through the same bound as in StopCollectorHosts;
|
||||
// one that is still stuck in Stop is skipped (its Dispose would wait on the same thing).
|
||||
if (_collectorHosts != null)
|
||||
{
|
||||
foreach (var host in _collectorHosts)
|
||||
{
|
||||
try { host.Dispose(); } catch { /* best-effort */ }
|
||||
RunHostStepBounded(host, "dispose", h => h.Dispose());
|
||||
}
|
||||
}
|
||||
try { _transport?.Dispose(); } catch { /* best-effort */ }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#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.Enrollment.SystemSignals;
|
||||
@@ -21,6 +22,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// only sees consoles spawned after the agent started; the pre-agent OOBE window is covered
|
||||
/// (coarsely) by the ConsolePrefetchScanner. The composition root calls <see cref="Stop"/> once the
|
||||
/// real-user desktop arrives (Shift+F10 no longer possible) to avoid post-enrollment false positives.
|
||||
/// <para>
|
||||
/// Short-lived console processes (script children) are not events of their own: the watcher
|
||||
/// counts them and this host reports one Info <c>console_script_activity</c> per window. The
|
||||
/// first counted start opens a window of <see cref="ScriptActivityWindow"/>; at most
|
||||
/// <see cref="MaxScriptActivitySummaries"/> windows are reported per agent run, whatever comes
|
||||
/// after goes into the one line written when the host stops.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class ConsoleBypassHost : ICollectorHost
|
||||
{
|
||||
@@ -28,6 +36,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
"best-effort: only consoles spawned after the agent started are observable; a Shift+F10 " +
|
||||
"pressed earlier in OOBE (before agent install) is covered coarsely by console_prefetch_detected";
|
||||
|
||||
internal const int MaxScriptActivitySummaries = 20;
|
||||
private static readonly TimeSpan ScriptActivityWindow = TimeSpan.FromSeconds(60);
|
||||
|
||||
public string Name => "ConsoleBypassWatcher";
|
||||
|
||||
private readonly string _sessionId;
|
||||
@@ -36,7 +47,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly ConsoleBypassWatcher _watcher;
|
||||
private readonly SignalIngress? _observableIngress;
|
||||
private readonly object _summaryLock = new object();
|
||||
private Action<AutopilotMonitor.DecisionCore.Signals.DecisionSignalKind, IReadOnlyDictionary<string, string>?>? _signalPostedHandler;
|
||||
private Timer? _windowTimer;
|
||||
private int _summariesEmitted;
|
||||
private int _disposed;
|
||||
|
||||
public ConsoleBypassHost(
|
||||
@@ -55,11 +69,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_post = new InformationalEventPost(ingress, clock, logger);
|
||||
_watcher = watcher ?? new ConsoleBypassWatcher(logger);
|
||||
_watcher.BypassConsoleDetected += OnBypassConsoleDetected;
|
||||
_watcher.ScriptActivityStarted += OnScriptActivityStarted;
|
||||
_watcher.WatcherArmFailed += OnWatcherArmFailed;
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
public void Start() => StartCore(armWatcher: true);
|
||||
|
||||
/// <summary>Internal for tests: <paramref name="armWatcher"/> = false leaves the WMI
|
||||
/// subscription and the startup probe out; everything else is the real start.</summary>
|
||||
internal void StartCore(bool armWatcher)
|
||||
{
|
||||
// L14 fallback: the primary stop path is the DesktopArrivalDetector's real-user-owner
|
||||
// callback (StopForDesktopArrival). If the DAD misses the owner (no-candidate timeout,
|
||||
@@ -73,7 +92,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_observableIngress.SignalPosted += _signalPostedHandler;
|
||||
}
|
||||
|
||||
_watcher.Start();
|
||||
if (armWatcher) _watcher.Start();
|
||||
}
|
||||
|
||||
private void OnSignalPosted(AutopilotMonitor.DecisionCore.Signals.DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
@@ -117,6 +136,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Interactive console spawned during enrollment: {info.ProcessName} " +
|
||||
$"(session {info.SessionId}, {info.Confidence} confidence) — possible Shift+F10",
|
||||
// At once, whatever the confidence: the confidence follows the command line,
|
||||
// and that is chosen by whoever opens the console.
|
||||
ImmediateUpload = true,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
@@ -124,6 +145,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{ "processName", info.ProcessName },
|
||||
{ "processId", info.ProcessId },
|
||||
{ "parentProcessId", info.ParentProcessId },
|
||||
{ "parentProcessName", info.ParentProcessName ?? "unknown" },
|
||||
{ "sessionId", info.SessionId },
|
||||
{ "owner", info.Owner ?? "unknown" },
|
||||
{ "commandLine", info.CommandLine ?? "unavailable" },
|
||||
@@ -141,6 +163,132 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
private void OnScriptActivityStarted(object? sender, EventArgs e)
|
||||
{
|
||||
lock (_summaryLock)
|
||||
{
|
||||
// Checked under the lock Dispose takes for the timer, so no timer is created behind it.
|
||||
if (Interlocked.CompareExchange(ref _disposed, 0, 0) == 1) return;
|
||||
// Past the cap no window opens any more; the watcher keeps counting for the stop line.
|
||||
if (_summariesEmitted >= MaxScriptActivitySummaries) return;
|
||||
|
||||
try
|
||||
{
|
||||
if (_windowTimer == null)
|
||||
_windowTimer = new Timer(_ => WindowElapsed(), null, ScriptActivityWindow, Timeout.InfiniteTimeSpan);
|
||||
else
|
||||
_windowTimer.Change(ScriptActivityWindow, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// stopping
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Internal for tests: what the window timer runs when it elapses.</summary>
|
||||
internal void WindowElapsedForTest() => WindowElapsed();
|
||||
|
||||
private void WindowElapsed()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _disposed, 0, 0) == 1) return;
|
||||
ReportScriptActivity(stopping: false);
|
||||
}
|
||||
|
||||
private void ReportScriptActivity(bool stopping)
|
||||
{
|
||||
ConsoleScriptActivity? activity;
|
||||
bool capped;
|
||||
lock (_summaryLock)
|
||||
{
|
||||
capped = _summariesEmitted >= MaxScriptActivitySummaries;
|
||||
if (capped && !stopping) return;
|
||||
|
||||
activity = _watcher.TakeScriptActivity();
|
||||
if (activity == null) return;
|
||||
_summariesEmitted++;
|
||||
}
|
||||
|
||||
// Outside the lock: a post blocks while the ingress is full.
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "count", activity.Count },
|
||||
{ "processName", activity.ProcessName },
|
||||
{ "firstSeenUtc", activity.FirstSeenUtc.ToString("o") },
|
||||
{ "lastSeenUtc", activity.LastSeenUtc.ToString("o") },
|
||||
{ "windowsSessions", activity.WindowsSessions.ToList() },
|
||||
{ "owners", activity.Owners.ToList() },
|
||||
{ "ownersTotal", activity.OwnersTotal },
|
||||
{
|
||||
"parents", activity.Parents.Select(p => new Dictionary<string, object>
|
||||
{
|
||||
{ "pid", p.ProcessId },
|
||||
{ "name", p.Name ?? "unknown" },
|
||||
{ "children", p.Children },
|
||||
}).ToList()
|
||||
},
|
||||
{ "parentsTotal", activity.ParentsTotal },
|
||||
};
|
||||
if (capped) data["capped"] = true;
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.ConsoleScriptActivity,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = Name,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Timestamp = activity.LastSeenUtc,
|
||||
Message = DescribeScriptActivity(activity),
|
||||
ImmediateUpload = false,
|
||||
Data = data,
|
||||
});
|
||||
|
||||
_logger.Debug($"[ConsoleBypassHost] script activity reported: {activity.Count} short-lived {activity.ProcessName} " +
|
||||
$"from {activity.ParentsTotal} parent(s){(capped ? " (summary cap reached)" : string.Empty)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[ConsoleBypassHost] failed to emit console_script_activity: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The timeline text of one summary (internal for tests).</summary>
|
||||
internal static string DescribeScriptActivity(ConsoleScriptActivity activity)
|
||||
{
|
||||
var processes = activity.Count == 1
|
||||
? $"1 short-lived {activity.ProcessName} process"
|
||||
: $"{activity.Count} short-lived {activity.ProcessName} processes";
|
||||
|
||||
var sessions = activity.WindowsSessions.Count == 1
|
||||
? $"session {activity.WindowsSessions[0]}"
|
||||
: $"sessions {string.Join(", ", activity.WindowsSessions)}";
|
||||
|
||||
string startedBy;
|
||||
if (activity.ParentsTotal == 1 && activity.Parents.Count == 1)
|
||||
{
|
||||
startedBy = activity.Parents[0].Name ?? "an unknown program";
|
||||
}
|
||||
else
|
||||
{
|
||||
var names = activity.Parents
|
||||
.Select(p => p.Name)
|
||||
.Where(n => !string.IsNullOrEmpty(n))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var shown = names.Take(3).ToList();
|
||||
var more = names.Count > shown.Count || activity.Parents.Any(p => p.Name == null) || activity.ParentsTotal > activity.Parents.Count;
|
||||
startedBy = $"{activity.ParentsTotal} parent processes";
|
||||
if (shown.Count > 0)
|
||||
startedBy += $" ({string.Join(", ", shown)}{(more ? ", …" : string.Empty)})";
|
||||
}
|
||||
|
||||
return $"Script activity: {processes} in {sessions}, started by {startedBy} — counted, not an interactive console";
|
||||
}
|
||||
|
||||
// The WMI live watcher could not arm — surface it as collector_degraded so the backend can
|
||||
// tell "no console detected" from "live detector never started" (MON-D1 pattern).
|
||||
private void OnWatcherArmFailed(object? sender, Exception ex)
|
||||
@@ -151,12 +299,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
|
||||
// What was counted goes out first. It has to happen here, in the host's stop: the
|
||||
// handlers below go away, and once the orchestrator has stopped the ingress a post throws.
|
||||
ReportScriptActivity(stopping: true);
|
||||
|
||||
if (_observableIngress != null && _signalPostedHandler != null)
|
||||
{
|
||||
try { _observableIngress.SignalPosted -= _signalPostedHandler; } catch { }
|
||||
}
|
||||
try { _watcher.BypassConsoleDetected -= OnBypassConsoleDetected; } catch { }
|
||||
try { _watcher.ScriptActivityStarted -= OnScriptActivityStarted; } catch { }
|
||||
try { _watcher.WatcherArmFailed -= OnWatcherArmFailed; } catch { }
|
||||
|
||||
lock (_summaryLock)
|
||||
{
|
||||
try { _windowTimer?.Dispose(); } catch { }
|
||||
_windowTimer = null;
|
||||
}
|
||||
|
||||
try { _watcher.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
|
||||
+64
-23
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
@@ -49,9 +50,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly OfficeInstallDetector _detector;
|
||||
private readonly OfficeProcessWatcher _processWatcher;
|
||||
private readonly AgentLogger _logger;
|
||||
// Guards the fields below, nothing else. Never held across a call into a watcher or the
|
||||
// detector: their callbacks come back into this host and take it.
|
||||
private readonly object _lock = new object();
|
||||
|
||||
private readonly OfficeInstallStateData? _resumeState;
|
||||
private readonly RegistryHive _registryHive;
|
||||
private readonly string _registrySubKey;
|
||||
private RegistryChangeWatcher? _registryWatcher;
|
||||
private OfficeBinaryWatcher? _binaryWatcher;
|
||||
private bool _pathObserved; // InstallationPath known → stop poking the DO collector
|
||||
@@ -74,16 +79,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int settleSeconds,
|
||||
OfficeInstallStatePersistence? statePersistence = null,
|
||||
OfficeInstallStateData? resumeState = null)
|
||||
: this(sessionId, tenantId, ingress, clock, logger, settleSeconds, statePersistence, resumeState,
|
||||
RegistryHive.LocalMachine, ClickToRunSubKey, snapshotProvider: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: points the registry watcher at a key a test may write to and feeds the
|
||||
/// detector synthetic snapshots. Production uses the constructor above.
|
||||
/// </summary>
|
||||
internal OfficeInstallDetectorHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AgentLogger logger,
|
||||
int settleSeconds,
|
||||
OfficeInstallStatePersistence? statePersistence,
|
||||
OfficeInstallStateData? resumeState,
|
||||
RegistryHive registryHive,
|
||||
string registrySubKey,
|
||||
Func<OfficeC2RSnapshot>? snapshotProvider)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_resumeState = resumeState;
|
||||
_registryHive = registryHive;
|
||||
_registrySubKey = registrySubKey ?? throw new ArgumentNullException(nameof(registrySubKey));
|
||||
|
||||
var post = new InformationalEventPost(ingress, clock);
|
||||
_detector = new OfficeInstallDetector(sessionId, tenantId, post, logger, clock,
|
||||
onInstallationPathObserved: OnInstallationPathObserved,
|
||||
statePersistence: statePersistence);
|
||||
statePersistence: statePersistence)
|
||||
{
|
||||
SnapshotProvider = snapshotProvider,
|
||||
};
|
||||
_processWatcher = new OfficeProcessWatcher(logger, settleSeconds);
|
||||
_processWatcher.Started += OnWorkerStarted;
|
||||
}
|
||||
@@ -108,12 +139,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Arm the registry watcher up front (not at worker-start) so the early Scenario\INSTALL key
|
||||
// is caught before the (late) worker process. The watcher's bootstrap fallback handles the
|
||||
// case where the ClickToRun key does not exist yet on a clean first install.
|
||||
lock (_lock)
|
||||
{
|
||||
_registryWatcher = new RegistryChangeWatcher(ClickToRunSubKey, _logger);
|
||||
_registryWatcher.Changed += OnRegistryChanged;
|
||||
_registryWatcher.Start();
|
||||
}
|
||||
var registryWatcher = new RegistryChangeWatcher(_registryHive, _registrySubKey, _logger);
|
||||
registryWatcher.Changed += OnRegistryChanged;
|
||||
lock (_lock) { _registryWatcher = registryWatcher; }
|
||||
registryWatcher.Start();
|
||||
_processWatcher.Start();
|
||||
|
||||
// Resume a persisted *Active* lifecycle (enrollments span reboots) AFTER the watchers are
|
||||
@@ -134,7 +163,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// re-poking it on every registry value churn is pure log noise (field session 7da7dead).
|
||||
bool raise;
|
||||
lock (_lock) { raise = !_pathObserved && !_lifecycleEnded; }
|
||||
if (raise)
|
||||
// A callback can still be running after Dispose returned — Dispose never waits for one.
|
||||
if (raise && Volatile.Read(ref _disposed) == 0)
|
||||
{
|
||||
try { OfficeExpected?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Warning($"[OfficeInstallDetectorHost] OfficeExpected handler threw: {ex.Message}"); }
|
||||
@@ -147,15 +177,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private void OnInstallationPathObserved(string? installationPath)
|
||||
{
|
||||
OfficeBinaryWatcher watcher;
|
||||
lock (_lock)
|
||||
{
|
||||
_pathObserved = true; // stop raising OfficeExpected (lifecycle has started)
|
||||
if (_lifecycleEnded || _binaryWatcher != null || string.IsNullOrEmpty(installationPath)) return;
|
||||
if (Volatile.Read(ref _disposed) == 1) return;
|
||||
|
||||
_binaryWatcher = new OfficeBinaryWatcher(installationPath!, OfficeInstallDetector.CoreBinaries, _logger);
|
||||
_binaryWatcher.BinaryAppeared += OnBinaryAppeared;
|
||||
_binaryWatcher.Start();
|
||||
watcher = new OfficeBinaryWatcher(installationPath!, OfficeInstallDetector.CoreBinaries, _logger);
|
||||
watcher.BinaryAppeared += OnBinaryAppeared;
|
||||
_binaryWatcher = watcher;
|
||||
}
|
||||
|
||||
// Start can raise BinaryAppeared right away (Office already on disk), and that handler
|
||||
// ends in DisposeWatchers.
|
||||
watcher.Start();
|
||||
}
|
||||
|
||||
private void OnBinaryAppeared(object? sender, EventArgs e)
|
||||
@@ -191,20 +227,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private void DisposeWatchers()
|
||||
{
|
||||
RegistryChangeWatcher? registryWatcher;
|
||||
OfficeBinaryWatcher? binaryWatcher;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_registryWatcher != null)
|
||||
{
|
||||
try { _registryWatcher.Changed -= OnRegistryChanged; } catch { }
|
||||
try { _registryWatcher.Dispose(); } catch { }
|
||||
_registryWatcher = null;
|
||||
}
|
||||
if (_binaryWatcher != null)
|
||||
{
|
||||
try { _binaryWatcher.BinaryAppeared -= OnBinaryAppeared; } catch { }
|
||||
try { _binaryWatcher.Dispose(); } catch { }
|
||||
_binaryWatcher = null;
|
||||
}
|
||||
registryWatcher = _registryWatcher;
|
||||
_registryWatcher = null;
|
||||
binaryWatcher = _binaryWatcher;
|
||||
_binaryWatcher = null;
|
||||
}
|
||||
|
||||
if (registryWatcher != null)
|
||||
{
|
||||
try { registryWatcher.Changed -= OnRegistryChanged; } catch { }
|
||||
try { registryWatcher.Dispose(); } catch { }
|
||||
}
|
||||
if (binaryWatcher != null)
|
||||
{
|
||||
try { binaryWatcher.BinaryAppeared -= OnBinaryAppeared; } catch { }
|
||||
try { binaryWatcher.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Tells <see cref="ProxyObserver"/> the moment proxy settings change, so the log has the exact
|
||||
/// time. A watcher that fails costs only that time: the observer compares again on every failed
|
||||
/// upload.
|
||||
/// </summary>
|
||||
internal sealed class ProxySettingsHost : ICollectorHost
|
||||
{
|
||||
private const string Source = "ProxySettingsHost";
|
||||
private const int DebounceMs = 1000;
|
||||
|
||||
// The whole subtree: "Connections" does not exist yet in an account nothing has written to.
|
||||
// HKCU of a SYSTEM process is HKU\S-1-5-18.
|
||||
private const string AccountSettingsKey = @"Software\Microsoft\Windows\CurrentVersion\Internet Settings";
|
||||
// Holds WinHttpSettings, which netsh winhttp writes.
|
||||
private const string MachineConnectionsKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections";
|
||||
|
||||
private const RegistryNativeMethods.RegChangeNotifyFilter Filter =
|
||||
RegistryNativeMethods.RegChangeNotifyFilter.Name |
|
||||
RegistryNativeMethods.RegChangeNotifyFilter.LastSet;
|
||||
|
||||
private readonly ProxyObserver _observer;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly List<RegistryWatcher> _watchers = new List<RegistryWatcher>();
|
||||
private readonly Timer _debounce;
|
||||
private int _started;
|
||||
private int _stopped;
|
||||
|
||||
public string Name => "ProxySettingsWatcher";
|
||||
|
||||
public ProxySettingsHost(
|
||||
ProxyObserver observer,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AgentLogger logger)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_observer = observer ?? throw new ArgumentNullException(nameof(observer));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_post = new InformationalEventPost(ingress, clock);
|
||||
_debounce = new Timer(_ => OnDebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
|
||||
AddWatcher(RegistryHive.CurrentUser, AccountSettingsKey, watchSubtree: true, "account settings");
|
||||
AddWatcher(RegistryHive.LocalMachine, MachineConnectionsKey, watchSubtree: false, "WinHTTP settings");
|
||||
}
|
||||
|
||||
private void AddWatcher(RegistryHive hive, string subKey, bool watchSubtree, string label)
|
||||
{
|
||||
var watcher = new RegistryWatcher(hive, subKey, watchSubtree, RegistryView.Registry64, Filter);
|
||||
watcher.Changed += (_, _) => _debounce.Change(DebounceMs, Timeout.Infinite);
|
||||
watcher.Error += (_, ex) => _logger.Warning($"ProxySettingsHost: {label} watcher error - {ex.GetType().Name}: {ex.Message}");
|
||||
_watchers.Add(watcher);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _started, 1) == 1) return;
|
||||
|
||||
_observer.SettingsChanged += OnSettingsChanged;
|
||||
foreach (var watcher in _watchers)
|
||||
{
|
||||
try { watcher.Start(); }
|
||||
catch (Exception ex) { _logger.Warning($"ProxySettingsHost: a watcher failed to start: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Whatever moved between the baseline and the start of the watchers.
|
||||
OnDebounceElapsed();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _stopped, 1) == 1) return;
|
||||
|
||||
DisposeWatchers();
|
||||
_observer.SettingsChanged -= OnSettingsChanged;
|
||||
if (Volatile.Read(ref _started) == 1) _observer.LogOpenFailures();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
_debounce.Dispose();
|
||||
}
|
||||
|
||||
private void DisposeWatchers()
|
||||
{
|
||||
foreach (var watcher in _watchers)
|
||||
{
|
||||
try { watcher.Dispose(); }
|
||||
catch (Exception ex) { _logger.Debug($"ProxySettingsHost: disposing a watcher threw: {ex.Message}"); }
|
||||
}
|
||||
_watchers.Clear();
|
||||
}
|
||||
|
||||
private void OnDebounceElapsed()
|
||||
{
|
||||
_logger.Trace("ProxySettingsHost: checking the proxy settings.");
|
||||
try { _observer.CheckForChange(); }
|
||||
catch (Exception ex) { _logger.Warning($"ProxySettingsHost: checking the proxy settings failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits in the spool if uploads are down, so the timeline explains the gap once they work
|
||||
/// again. The portal shows the newest event of this type.
|
||||
/// </summary>
|
||||
private void OnSettingsChanged(ProxySettings settings, string what, string route)
|
||||
{
|
||||
var data = settings.ToEventData();
|
||||
data["trigger"] = "changed";
|
||||
data["change"] = what;
|
||||
data["route"] = route;
|
||||
|
||||
_post.Emit(
|
||||
eventType: Constants.EventTypes.ProxyConfiguration,
|
||||
source: Source,
|
||||
message: $"Proxy settings changed: {what}",
|
||||
severity: EventSeverity.Warning,
|
||||
typedPayload: data);
|
||||
}
|
||||
}
|
||||
}
|
||||
+229
-39
@@ -1,10 +1,12 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -21,11 +23,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// It observes the central signal rail (<see cref="SignalIngress.SignalPosted"/>, the same
|
||||
/// hook <c>GatherRuleExecutorHost</c> uses for phase_change triggers):
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Engage</b> when a signal reports the ESP phase as <c>AccountSetup</c>.</item>
|
||||
/// <item><b>Engage</b> when a signal reports the ESP phase as <c>AccountSetup</c> — unless the
|
||||
/// user-ESP page already closed in this enrollment (D-414). The page exit is latched in the
|
||||
/// <see cref="StartupEventGate"/>, because after an agent restart the AccountSetup phase
|
||||
/// reaches the rail again (IME logs it at the next sign-in, and the adapter's dedup lives
|
||||
/// per process).</item>
|
||||
/// <item><b>Release</b> on <see cref="DecisionSignalKind.EspExiting"/> once the AccountSetup
|
||||
/// registry shows progress (the real user-ESP page exit — see below), on
|
||||
/// <see cref="Stop"/> (session teardown), or on the safety-cap timer (a backstop so a
|
||||
/// device is never held awake for the agent's full max-lifetime).</item>
|
||||
/// <item><b>RealmJoin hand-over</b> (D-413): RealmJoin signals only exist when the tenant runs
|
||||
/// the RealmJoin Watcher. When RealmJoin was detected and is still Blank as the user-ESP
|
||||
/// page closes, the hold stays until RealmJoin leaves Blank (it keeps the device awake
|
||||
/// itself from there), bounded by <see cref="RealmJoinStartWaitMinutes"/>. RealmJoin
|
||||
/// leaving Blank ends any hold; once it has, the host does not engage in this process.</item>
|
||||
/// </list>
|
||||
/// The hold itself is owned by <see cref="KeepAwakeController"/>; the OS also auto-clears it
|
||||
/// on process exit / reboot, so it can never leak.
|
||||
@@ -57,7 +68,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// than host construction) means the ESP policy is already present in the registry. When the
|
||||
/// value is unavailable (ESP skipped, WDP v2 with no ESP, or unreadable) the cap falls back to
|
||||
/// <see cref="DefaultFallbackCapMinutes"/>. The resolved cap and its source are reported on the
|
||||
/// <c>keep_awake_engaged</c> event for fleet-wide observability.
|
||||
/// <c>keep_awake_engaged</c> event for fleet-wide observability. The RealmJoin wait replaces
|
||||
/// this cap when the page closes: one timer, one bound per phase of the hold.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class UserEspKeepAwakeHost : ICollectorHost
|
||||
@@ -78,9 +90,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
internal const int EspCapMarginMinutes = 30;
|
||||
|
||||
/// <summary>
|
||||
/// How long the hold waits for RealmJoin to leave Blank after the user-ESP page closed
|
||||
/// (D-413 has the measured start delays behind the value).
|
||||
/// </summary>
|
||||
internal const int RealmJoinStartWaitMinutes = 30;
|
||||
|
||||
/// <summary><see cref="StartupEventGate"/> latch: the user-ESP page closed in this enrollment (D-414).</summary>
|
||||
internal const string UserEspPageClosedKey = "keep_awake.user_esp_page_closed";
|
||||
|
||||
// Release reasons (carried on the keep_awake_released event payload). "account_setup_complete"
|
||||
// (release on the synthesized completion signal) was retired with q8n — see the class doc.
|
||||
internal const string ReasonUserEspExited = "user_esp_exited";
|
||||
internal const string ReasonRealmJoinStarted = "realmjoin_started";
|
||||
internal const string ReasonRealmJoinNotStarted = "realmjoin_not_started";
|
||||
internal const string ReasonHostStop = "host_stop";
|
||||
internal const string ReasonSafetyCap = "safety_cap";
|
||||
|
||||
@@ -92,13 +115,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private static readonly string AccountSetupPhaseName = EnrollmentPhase.AccountSetup.ToString();
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly IClock _clock;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly KeepAwakeController _controller;
|
||||
private readonly SignalIngress? _observableIngress;
|
||||
private readonly int _fallbackCapMinutes;
|
||||
private readonly TimeSpan? _safetyCapOverride;
|
||||
private readonly TimeSpan _realmJoinStartWait;
|
||||
private readonly Func<int?> _espTimeoutProvider;
|
||||
private readonly Func<bool> _accountSetupProgressProbe;
|
||||
private readonly StartupEventGate? _startupGate;
|
||||
|
||||
private enum HoldState { Idle, Engaging, Engaged, Released }
|
||||
|
||||
@@ -107,7 +133,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private Timer? _safetyCapTimer;
|
||||
private HoldState _state = HoldState.Idle; // guarded by _gate
|
||||
private int _activeCapMinutes; // guarded by _gate — for the cap-elapsed log line
|
||||
private int _capGeneration; // guarded by _gate — a superseded timer callback is ignored
|
||||
private bool _awaitingRealmJoin; // guarded by _gate — page closed, RealmJoin still Blank
|
||||
private DateTime _userEspExitedUtc; // guarded by _gate — valid while _awaitingRealmJoin
|
||||
private int _disposed;
|
||||
private int _pageClosedLatched; // 0/1 — the latch write runs once per process
|
||||
|
||||
// RealmJoin observations of this process. Monotonic, written on the posting thread so the
|
||||
// ThreadPool work that follows always sees them (0/1 for Volatile/Interlocked).
|
||||
private int _realmJoinDetected;
|
||||
private int _realmJoinStarted;
|
||||
|
||||
public UserEspKeepAwakeHost(
|
||||
ISignalIngressSink ingress,
|
||||
@@ -117,17 +152,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
KeepAwakeController? controller = null,
|
||||
TimeSpan? safetyCapOverride = null,
|
||||
Func<int?>? espTimeoutProvider = null,
|
||||
Func<bool>? accountSetupProgressProbe = null)
|
||||
Func<bool>? accountSetupProgressProbe = null,
|
||||
TimeSpan? realmJoinStartWaitOverride = null,
|
||||
StartupEventGate? startupGate = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_post = new InformationalEventPost(ingress, clock, logger);
|
||||
_controller = controller ?? new KeepAwakeController(logger);
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
_fallbackCapMinutes = fallbackCapMinutes > 0 ? fallbackCapMinutes : DefaultFallbackCapMinutes;
|
||||
// safetyCapOverride is a test seam for a fixed (e.g. sub-second) cap.
|
||||
// safetyCapOverride / realmJoinStartWaitOverride are test seams for fixed (e.g. sub-second) bounds.
|
||||
_safetyCapOverride = safetyCapOverride;
|
||||
_realmJoinStartWait = realmJoinStartWaitOverride ?? TimeSpan.FromMinutes(RealmJoinStartWaitMinutes);
|
||||
// The ESP SyncFailureTimeout is read at engage time (the ESP policy is present by then).
|
||||
_espTimeoutProvider = espTimeoutProvider
|
||||
?? (() => EspSkipConfigurationProbe.ReadFull(_logger).SyncFailureTimeoutMinutes);
|
||||
@@ -135,6 +173,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// lasts until the safety cap (erring long, never early). Production always wires the
|
||||
// EspAndHelloHost probe (DefaultComponentFactory).
|
||||
_accountSetupProgressProbe = accountSetupProgressProbe ?? (() => false);
|
||||
// Without a gate (tests) the page-closed latch is off and every agent run engages anew.
|
||||
_startupGate = startupGate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -180,21 +220,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// (which briefly waits on the keep-awake thread) is dispatched to the ThreadPool.
|
||||
try
|
||||
{
|
||||
if (kind == DecisionSignalKind.EspExiting)
|
||||
switch (kind)
|
||||
{
|
||||
// Only the user-ESP page exit releases: the Device→Account handoff 62407 arrives
|
||||
// before any AccountSetup subcategory has left notStarted, the real exit after.
|
||||
// Cheap to skip while idle; the probe is a locked dictionary scan.
|
||||
if (IsEngagedOrEngaging() && ProbeAccountSetupProgress())
|
||||
ThreadPool.QueueUserWorkItem(_ => SafeRelease(ReasonUserEspExited, EventSeverity.Info));
|
||||
return;
|
||||
case DecisionSignalKind.EspExiting:
|
||||
// Only the user-ESP page exit counts: the Device→Account handoff 62407 arrives
|
||||
// before any AccountSetup subcategory has left notStarted, the real exit after.
|
||||
// Checked in every hold state — the page-closed latch (D-414) must also be set
|
||||
// while idle, capped or waiting. The probe is a locked dictionary scan.
|
||||
if (ProbeAccountSetupProgress())
|
||||
ThreadPool.QueueUserWorkItem(_ => SafeRun(OnUserEspPageClosed, "user-ESP exit"));
|
||||
return;
|
||||
|
||||
case DecisionSignalKind.RealmJoinDetected:
|
||||
case DecisionSignalKind.RealmJoinPhaseChanged:
|
||||
case DecisionSignalKind.RealmJoinResolved:
|
||||
ObserveRealmJoin(kind, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload != null
|
||||
&& payload.TryGetValue(SignalPayloadKeys.EspPhase, out var phaseName)
|
||||
&& string.Equals(phaseName, AccountSetupPhaseName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ThreadPool.QueueUserWorkItem(_ => SafeEngage());
|
||||
ThreadPool.QueueUserWorkItem(_ => SafeRun(Engage, "engage"));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -203,15 +251,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
private void SafeEngage()
|
||||
/// <summary>
|
||||
/// Records what the RealmJoin Watcher reported. The flags are set here, on the posting
|
||||
/// thread, before any work is queued: the ThreadPool items run in no fixed order, and each
|
||||
/// re-checks the hold state under <see cref="_gate"/>, so no hand-over is lost.
|
||||
/// </summary>
|
||||
private void ObserveRealmJoin(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
try { Engage(); }
|
||||
catch (Exception ex) { _logger.Error("UserEspKeepAwakeHost: engage failed.", ex); }
|
||||
Volatile.Write(ref _realmJoinDetected, 1);
|
||||
|
||||
// Resolved is phase 110 by definition; Detected and PhaseChanged carry the phase.
|
||||
var leftBlank = kind == DecisionSignalKind.RealmJoinResolved
|
||||
|| (payload != null
|
||||
&& payload.TryGetValue(DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase, out var raw)
|
||||
&& int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var phase)
|
||||
&& phase >= RealmJoinInfo.PhaseRunningThresholdMin);
|
||||
|
||||
if (leftBlank && Interlocked.Exchange(ref _realmJoinStarted, 1) == 0)
|
||||
ThreadPool.QueueUserWorkItem(_ => SafeRun(OnRealmJoinStarted, "RealmJoin hand-over"));
|
||||
}
|
||||
|
||||
private bool IsEngagedOrEngaging()
|
||||
private void SafeRun(Action work, string what)
|
||||
{
|
||||
lock (_gate) { return _state == HoldState.Engaging || _state == HoldState.Engaged; }
|
||||
try { work(); }
|
||||
catch (Exception ex) { _logger.Error($"UserEspKeepAwakeHost: {what} failed.", ex); }
|
||||
}
|
||||
|
||||
private bool ProbeAccountSetupProgress()
|
||||
@@ -224,12 +287,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
private void SafeRelease(string reason, EventSeverity severity)
|
||||
{
|
||||
try { Release(reason, severity, emit: true); }
|
||||
catch (Exception ex) { _logger.Error("UserEspKeepAwakeHost: release failed.", ex); }
|
||||
}
|
||||
|
||||
/// <summary>Engage the hold exactly once (Idle → Engaging → Engaged). No-op from any other state.</summary>
|
||||
private void Engage()
|
||||
{
|
||||
@@ -239,6 +296,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state != HoldState.Idle) return;
|
||||
if (UserEspPageClosedEarlier())
|
||||
{
|
||||
// Typically after an agent restart: the AccountSetup phase reached the rail
|
||||
// again, but the user-ESP page of this enrollment is already gone.
|
||||
_state = HoldState.Released;
|
||||
_logger.Info("UserEspKeepAwakeHost: the user-ESP page already closed in this enrollment — keep-awake not engaged.");
|
||||
return;
|
||||
}
|
||||
if (Volatile.Read(ref _realmJoinStarted) == 1)
|
||||
{
|
||||
// Only after an agent restart: RealmJoin deploys (or is done) before the
|
||||
// AccountSetup phase is seen again — nothing left to hand over.
|
||||
_state = HoldState.Released;
|
||||
_logger.Info("UserEspKeepAwakeHost: RealmJoin has already started its deployment — keep-awake not engaged.");
|
||||
return;
|
||||
}
|
||||
// L16 (delta review 2026-07-02): claim the transition, but run the controller's
|
||||
// Engage OUTSIDE the lock — it can block up to ~5 s on the keep-awake thread's
|
||||
// ready-wait, and holding _gate across that stalled a concurrent Stop→Release.
|
||||
@@ -272,8 +345,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
|
||||
_activeCapMinutes = capMinutes;
|
||||
// One-shot backstop (NOT periodic): fire once after the cap, then release.
|
||||
_safetyCapTimer = new Timer(OnSafetyCapElapsed, null, cap, Timeout.InfiniteTimeSpan);
|
||||
// One-shot backstop (NOT periodic). The page may have closed while engaging — then
|
||||
// the RealmJoin wait is the bound already.
|
||||
ArmCapTimer(_awaitingRealmJoin ? _realmJoinStartWait : cap);
|
||||
_state = HoldState.Engaged;
|
||||
}
|
||||
|
||||
@@ -281,7 +355,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
["osAccepted"] = osAccepted ? "true" : "false",
|
||||
["scope"] = "system+display",
|
||||
["capMinutes"] = capMinutes.ToString(),
|
||||
["capMinutes"] = capMinutes.ToString(CultureInfo.InvariantCulture),
|
||||
["capSource"] = capSource,
|
||||
});
|
||||
}
|
||||
@@ -296,41 +370,151 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The user-ESP page closed. The fact is latched for later agent runs first. Without
|
||||
/// RealmJoin, or with RealmJoin already past Blank, the hold ends here. With RealmJoin still
|
||||
/// Blank it waits for the hand-over: the ESP-sized cap gives way to the RealmJoin wait.
|
||||
/// </summary>
|
||||
private void OnUserEspPageClosed()
|
||||
{
|
||||
LatchUserEspPageClosed();
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state != HoldState.Engaging && _state != HoldState.Engaged) return;
|
||||
if (_awaitingRealmJoin) return; // a repeated exit signal
|
||||
|
||||
if (Volatile.Read(ref _realmJoinDetected) == 1 && Volatile.Read(ref _realmJoinStarted) == 0)
|
||||
{
|
||||
_awaitingRealmJoin = true;
|
||||
_userEspExitedUtc = _clock.UtcNow;
|
||||
// While Engaging, Engage arms the wait when it completes.
|
||||
if (_state == HoldState.Engaged) ArmCapTimer(_realmJoinStartWait);
|
||||
_logger.Info($"UserEspKeepAwakeHost: user-ESP page closed before RealmJoin started its deployment — holding keep-awake until it starts (max {_realmJoinStartWait.TotalMinutes:0} min).");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Release(ReasonUserEspExited, EventSeverity.Info);
|
||||
}
|
||||
|
||||
// MarkSucceeded persists at once (fail-soft): the page is gone for this enrollment.
|
||||
private void LatchUserEspPageClosed()
|
||||
{
|
||||
if (_startupGate == null || Interlocked.Exchange(ref _pageClosedLatched, 1) == 1) return;
|
||||
try { _startupGate.MarkSucceeded(UserEspPageClosedKey); }
|
||||
catch (Exception ex) { _logger.Debug($"UserEspKeepAwakeHost: page-closed latch write threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// In-memory lookup (no I/O) — safe under _gate.
|
||||
private bool UserEspPageClosedEarlier()
|
||||
{
|
||||
if (_startupGate == null) return false;
|
||||
try { return _startupGate.AlreadySucceeded(UserEspPageClosedKey); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"UserEspKeepAwakeHost: page-closed latch read threw: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>RealmJoin left Blank — it keeps the device awake from here.</summary>
|
||||
private void OnRealmJoinStarted()
|
||||
{
|
||||
Dictionary<string, string>? extra = null;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state == HoldState.Released) return;
|
||||
if (_state == HoldState.Idle)
|
||||
{
|
||||
// Only after an agent restart (RealmJoin starts after the user-ESP page closed):
|
||||
// no hold to hand over, and none to take up later in this process.
|
||||
_state = HoldState.Released;
|
||||
_logger.Info("UserEspKeepAwakeHost: RealmJoin has already started its deployment — keep-awake will not engage.");
|
||||
return;
|
||||
}
|
||||
if (_awaitingRealmJoin)
|
||||
{
|
||||
var waited = Math.Max(0, (_clock.UtcNow - _userEspExitedUtc).TotalSeconds);
|
||||
extra = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["waitedSeconds"] = ((long)waited).ToString(CultureInfo.InvariantCulture),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Release(ReasonRealmJoinStarted, EventSeverity.Info, extra);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Release the hold exactly once (Idle/Engaging/Engaged → Released). Idempotent — a second
|
||||
/// call (or a competing completion / cap / stop trigger) is a no-op. A release that lands
|
||||
/// while an Engage is mid-flight (Engaging) wins: the engage path detects the final state
|
||||
/// after its blocking controller call and rolls the OS hold back (L16).
|
||||
/// </summary>
|
||||
private void Release(string reason, EventSeverity severity, bool emit)
|
||||
private void Release(string reason, EventSeverity severity, IReadOnlyDictionary<string, string>? extra = null)
|
||||
{
|
||||
bool wasEngaged;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state == HoldState.Released) return;
|
||||
DisarmSafetyCap();
|
||||
DisarmCapTimer();
|
||||
wasEngaged = _controller.Release();
|
||||
_state = HoldState.Released;
|
||||
}
|
||||
|
||||
if (wasEngaged && emit)
|
||||
if (!wasEngaged) return;
|
||||
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal) { ["reason"] = reason };
|
||||
if (extra != null)
|
||||
{
|
||||
Emit(Constants.EventTypes.KeepAwakeReleased, severity, new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["reason"] = reason,
|
||||
});
|
||||
foreach (var kv in extra) data[kv.Key] = kv.Value;
|
||||
}
|
||||
Emit(Constants.EventTypes.KeepAwakeReleased, severity, data);
|
||||
}
|
||||
|
||||
private void OnSafetyCapElapsed(object? state)
|
||||
private void OnCapTimerElapsed(object? timerState)
|
||||
{
|
||||
_logger.Warning($"UserEspKeepAwakeHost: safety cap ({_activeCapMinutes} min) elapsed without User-ESP completion — releasing keep-awake.");
|
||||
SafeRelease(ReasonSafetyCap, EventSeverity.Warning);
|
||||
string reason;
|
||||
EventSeverity severity;
|
||||
Dictionary<string, string>? extra = null;
|
||||
lock (_gate)
|
||||
{
|
||||
// A timer replaced at the page exit can still fire once — only the current one counts.
|
||||
if (!(timerState is int generation) || generation != _capGeneration || _state == HoldState.Released) return;
|
||||
|
||||
if (_awaitingRealmJoin)
|
||||
{
|
||||
reason = ReasonRealmJoinNotStarted;
|
||||
severity = EventSeverity.Info;
|
||||
extra = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["waitMinutes"] = ((int)Math.Round(_realmJoinStartWait.TotalMinutes)).ToString(CultureInfo.InvariantCulture),
|
||||
};
|
||||
_logger.Info($"UserEspKeepAwakeHost: RealmJoin did not start within {_realmJoinStartWait.TotalMinutes:0} min after the user-ESP page closed — releasing keep-awake.");
|
||||
}
|
||||
else
|
||||
{
|
||||
reason = ReasonSafetyCap;
|
||||
severity = EventSeverity.Warning;
|
||||
_logger.Warning($"UserEspKeepAwakeHost: safety cap ({_activeCapMinutes} min) elapsed without User-ESP completion — releasing keep-awake.");
|
||||
}
|
||||
}
|
||||
|
||||
SafeRun(() => Release(reason, severity, extra), "release");
|
||||
}
|
||||
|
||||
// Must be called under _gate.
|
||||
private void DisarmSafetyCap()
|
||||
private void ArmCapTimer(TimeSpan dueIn)
|
||||
{
|
||||
DisarmCapTimer();
|
||||
_safetyCapTimer = new Timer(OnCapTimerElapsed, _capGeneration, dueIn, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
// Must be called under _gate.
|
||||
private void DisarmCapTimer()
|
||||
{
|
||||
_capGeneration++;
|
||||
var timer = _safetyCapTimer;
|
||||
_safetyCapTimer = null;
|
||||
try { timer?.Dispose(); } catch { }
|
||||
@@ -369,7 +553,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
// Release synchronously on teardown. If a completion/cap release already won, this is a
|
||||
// no-op (state already Released). Emit is best-effort — the ingress may already be down.
|
||||
Release(ReasonHostStop, EventSeverity.Info, emit: true);
|
||||
Release(ReasonHostStop, EventSeverity.Info);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -378,5 +562,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
Stop();
|
||||
try { _controller.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
// Test seam — the page closed while RealmJoin was still Blank and the hold now waits for it.
|
||||
internal bool AwaitingRealmJoinForTest
|
||||
{
|
||||
get { lock (_gate) { return _awaitingRealmJoin; } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// The OOBE update registry snapshot (D-310) is observation at agent start/stop and at
|
||||
// the update page — the page's own records (oobe_update_page) stay activity.
|
||||
SharedConstants.EventTypes.OobeUpdateState,
|
||||
// The count of short-lived console processes: a script that keeps spawning them for
|
||||
// hours would otherwise hold the periodic collectors awake and reset the stall clock.
|
||||
// The work those scripts do arrives as its own app, script or phase event.
|
||||
SharedConstants.EventTypes.ConsoleScriptActivity,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -46,6 +46,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// authorized agent.</item>
|
||||
/// <item>Any other coded 409 (<see cref="BackendRefusalException"/>) → <see cref="SessionRegistrationOutcome.Failed"/>
|
||||
/// without retrying — a refusal with a code does not clear by waiting.</item>
|
||||
/// <item>410 from the backend (<see cref="BackendSessionGoneException"/>): the persisted
|
||||
/// session was deleted. With <c>rotateOnSessionGone</c> (a WhiteGlove Part 2 start, whose
|
||||
/// enrollment is only now continuing) the session is rotated like above and registered
|
||||
/// once more. Every other start returns <see cref="SessionRegistrationOutcome.SessionGone"/>
|
||||
/// without retrying — the caller ends monitoring. So does a second 410 or a failed rotation:
|
||||
/// the 48-hour break skips a WhiteGlove resume, so no path here may leave it retrying.</item>
|
||||
/// <item>Any other exception on the last attempt → <see cref="EmergencyReporter.TrySendAsync"/>
|
||||
/// with <c>AgentErrorType.RegisterSessionFailed</c> so operators see the final cause.</item>
|
||||
/// </list>
|
||||
@@ -75,6 +81,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// Optional pre-registration link wait used by tests. Production callers pass
|
||||
/// <c>null</c> → <see cref="WaitForNetworkLinkAsync"/> (bounded by <see cref="NetworkLinkWaitMax"/>).
|
||||
/// </param>
|
||||
/// <param name="rotateOnSessionGone">
|
||||
/// True at a WhiteGlove Part 2 start: a 410 for the persisted session then becomes a new
|
||||
/// session through <paramref name="rotateSession"/> instead of ending monitoring.
|
||||
/// </param>
|
||||
public static async Task<SessionRegistrationResult> RegisterWithRetryAsync(
|
||||
BackendApiClient apiClient,
|
||||
AgentConfiguration agentConfig,
|
||||
@@ -86,7 +96,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
Func<Exception, Task> onTerminalTransportFailure = null,
|
||||
(string Manufacturer, string Model, string SerialNumber)? deviceHardware = null,
|
||||
Func<AgentLogger, Task> networkLinkWait = null,
|
||||
Func<string> rotateSession = null)
|
||||
Func<string> rotateSession = null,
|
||||
bool rotateOnSessionGone = false)
|
||||
{
|
||||
if (apiClient == null) throw new ArgumentNullException(nameof(apiClient));
|
||||
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
|
||||
@@ -101,6 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
string lastError = null;
|
||||
Exception lastException = null;
|
||||
string rotatedFromSessionId = null;
|
||||
string rotationReason = null;
|
||||
|
||||
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
|
||||
{
|
||||
@@ -112,7 +124,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
if (response != null && response.Success)
|
||||
{
|
||||
logger.Info($"Session registered successfully: {response.SessionId} (validatedBy={response.ValidatedBy}, adminAction={response.AdminAction ?? "(none)"})");
|
||||
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId);
|
||||
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId, rotationReason);
|
||||
}
|
||||
|
||||
lastError = response?.Message ?? "(null response)";
|
||||
@@ -149,9 +161,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
$"Backend refused registration with {Constants.AgentErrorCodes.SessionOwnerMismatch}: session {rotatedFromSessionId} " +
|
||||
$"is not this device's (bound to another device identity after a re-enrollment without wipe, or claimed by another tenant). " +
|
||||
$"Rotated to {newSessionId}; re-registering.");
|
||||
rotationReason = SessionRotationReasons.OwnerMismatch;
|
||||
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
|
||||
continue;
|
||||
}
|
||||
catch (BackendSessionGoneException ex)
|
||||
{
|
||||
if (rotateOnSessionGone && rotateSession != null && rotatedFromSessionId == null)
|
||||
{
|
||||
var goneSessionId = registration.SessionId;
|
||||
string newSessionId = null;
|
||||
try
|
||||
{
|
||||
newSessionId = rotateSession();
|
||||
}
|
||||
catch (Exception rotateEx)
|
||||
{
|
||||
logger.Error($"Backend answered 410 for session {goneSessionId} but session rotation failed: {rotateEx.Message}", rotateEx);
|
||||
}
|
||||
|
||||
if (newSessionId != null)
|
||||
{
|
||||
rotatedFromSessionId = goneSessionId;
|
||||
rotationReason = SessionRotationReasons.SessionGone;
|
||||
logger.Warning(
|
||||
$"Backend answered 410 for session {goneSessionId} at the start of a WhiteGlove Part 2: " +
|
||||
$"the session was deleted. Rotated to {newSessionId}; re-registering.");
|
||||
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.Error($"Session registration refused with 410 Gone — the backend deleted session {registration.SessionId}.");
|
||||
return SessionRegistrationResult.SessionGone(ex.StatusCode, ex.Message);
|
||||
}
|
||||
catch (BackendRefusalException ex)
|
||||
{
|
||||
logger.Error($"Session registration refused ({ex.StatusCode}, {ex.ErrorCode}): {ex.Message}");
|
||||
@@ -294,6 +337,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
|
||||
/// <summary>All attempts failed with non-auth errors (network, 5xx, malformed response).</summary>
|
||||
Failed = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 410 from the backend: the persisted session was deleted. No retry was attempted; the
|
||||
/// caller ends monitoring and cleans up.
|
||||
/// </summary>
|
||||
SessionGone = 3,
|
||||
}
|
||||
|
||||
/// <summary>Why a run registered under a new SessionId (<see cref="SessionRegistrationResult.RotationReason"/>).</summary>
|
||||
public static class SessionRotationReasons
|
||||
{
|
||||
/// <summary>The backend refused the persisted id with <c>session_owner_mismatch</c>.</summary>
|
||||
public const string OwnerMismatch = "owner_mismatch";
|
||||
|
||||
/// <summary>The backend answered 410 for the persisted id at a WhiteGlove Part 2 start.</summary>
|
||||
public const string SessionGone = "session_gone";
|
||||
}
|
||||
|
||||
/// <summary>Result of <see cref="SessionRegistrationHelper.RegisterWithRetryAsync"/>.</summary>
|
||||
@@ -308,12 +367,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
public ValidatorType ValidatedBy => Response?.ValidatedBy ?? ValidatorType.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// SESSION-OWNER-BINDING: the SessionId this run started with when the backend refused it
|
||||
/// with <c>session_owner_mismatch</c> and the session was rotated before the successful
|
||||
/// The SessionId this run started with when the backend refused it (<c>session_owner_mismatch</c>,
|
||||
/// or 410 at a WhiteGlove Part 2 start) and the session was rotated before the successful
|
||||
/// registration. Null on the normal path.
|
||||
/// </summary>
|
||||
public string RotatedFromSessionId { get; }
|
||||
|
||||
/// <summary>One of <see cref="SessionRotationReasons"/> when <see cref="RotatedFromSessionId"/> is set, else null.</summary>
|
||||
public string RotationReason { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the run ended on a 401/403 that something on the network path answered (TLS
|
||||
/// inspection, a proxy page — <see cref="BackendAuthException.IsForeignResponse"/>), not
|
||||
@@ -327,7 +389,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
int httpStatusCode,
|
||||
string errorMessage,
|
||||
string rotatedFromSessionId = null,
|
||||
bool isIntercepted = false)
|
||||
bool isIntercepted = false,
|
||||
string rotationReason = null)
|
||||
{
|
||||
Outcome = outcome;
|
||||
Response = response;
|
||||
@@ -335,10 +398,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
ErrorMessage = errorMessage;
|
||||
RotatedFromSessionId = rotatedFromSessionId;
|
||||
IsIntercepted = isIntercepted;
|
||||
RotationReason = rotatedFromSessionId != null ? rotationReason : null;
|
||||
}
|
||||
|
||||
public static SessionRegistrationResult Succeeded(RegisterSessionResponse response, string rotatedFromSessionId = null)
|
||||
=> new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId);
|
||||
public static SessionRegistrationResult Succeeded(
|
||||
RegisterSessionResponse response, string rotatedFromSessionId = null, string rotationReason = null)
|
||||
=> new SessionRegistrationResult(
|
||||
SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId, rotationReason: rotationReason);
|
||||
|
||||
public static SessionRegistrationResult SessionGone(int statusCode, string message)
|
||||
=> new SessionRegistrationResult(SessionRegistrationOutcome.SessionGone, null, statusCode, message);
|
||||
|
||||
public static SessionRegistrationResult AuthFailed(int statusCode, string message, bool intercepted = false)
|
||||
=> new SessionRegistrationResult(SessionRegistrationOutcome.AuthFailed, null, statusCode, message, isIntercepted: intercepted);
|
||||
|
||||
@@ -78,7 +78,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
{
|
||||
// Don't log the full email in decision signals — user PII. Keep domain only
|
||||
// for audit trail; full email lives only in local agent logs if at all.
|
||||
["userDomain"] = ExtractDomain(userEmail),
|
||||
["userDomain"] = AadJoinInfo.ExtractUserDomain(userEmail),
|
||||
["hasThumbprint"] = string.IsNullOrEmpty(thumbprint) ? "false" : "true",
|
||||
// The AadJoinWatcher fires AadUserJoined ONLY when isPlaceholderUser==false
|
||||
// (placeholder accounts go via the separate PlaceholderUserDetected event).
|
||||
@@ -137,7 +137,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
var placeholderType = ClassifyPlaceholder(userEmail);
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["userDomain"] = ExtractDomain(userEmail),
|
||||
["userDomain"] = AadJoinInfo.ExtractUserDomain(userEmail),
|
||||
["placeholderType"] = placeholderType,
|
||||
["registryKey"] = @"HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo",
|
||||
};
|
||||
@@ -154,13 +154,6 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
occurredAtUtc: _clock.UtcNow);
|
||||
}
|
||||
|
||||
private static string ExtractDomain(string email)
|
||||
{
|
||||
if (string.IsNullOrEmpty(email)) return "unknown";
|
||||
var at = email.IndexOf('@');
|
||||
return at >= 0 && at + 1 < email.Length ? email.Substring(at + 1) : "unknown";
|
||||
}
|
||||
|
||||
private static string ClassifyPlaceholder(string email)
|
||||
{
|
||||
if (string.IsNullOrEmpty(email)) return "unknown";
|
||||
|
||||
+3
-1
@@ -324,7 +324,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// DeliveryOptimizationCollector, …) before the diagnostics ZIP is built so
|
||||
// late `performance_snapshot` / `agent_metrics_snapshot` events don't slip in
|
||||
// after `diagnostics_collecting` and the snapshot captured in the package
|
||||
// matches the timeline. Best-effort — never blocks termination.
|
||||
// matches the timeline. Best-effort, and bounded per host by the orchestrator:
|
||||
// a collector that hangs is left behind, so the upload and the cleanup below
|
||||
// still run.
|
||||
StopPeripheralCollectorsBestEffort();
|
||||
|
||||
RunUploadDiagnosticsWithEvents(args);
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// Makes the agent log say which way uploads go and what happened to it. Something on the device
|
||||
/// can change the proxy settings of the agent's account; .NET follows at once, and from then on
|
||||
/// no channel may reach the backend, so the log on the device is the only witness.
|
||||
/// <para>
|
||||
/// Compares with what held at the start instead of judging a value: "no proxy" is the right
|
||||
/// state on one network and the broken one on the next. Reads on failures and on registry
|
||||
/// changes, never by polling. Nothing here throws into the upload path.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ProxyObserver
|
||||
{
|
||||
internal const string RouteUnknown = "unknown";
|
||||
|
||||
private static readonly TimeSpan DefaultRouteBound = TimeSpan.FromSeconds(3);
|
||||
|
||||
private readonly Func<ProxySettings> _readSettings;
|
||||
private readonly Func<string> _readRoute;
|
||||
private readonly IClock _clock;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly TimeSpan _routeBound;
|
||||
private readonly object _sync = new object();
|
||||
|
||||
private ProxySettings? _current;
|
||||
private string _lastRoute = RouteUnknown;
|
||||
private Task<string>? _routeRead;
|
||||
private DateTime? _uploadChangeAtUtc;
|
||||
private string? _uploadChange;
|
||||
private DateTime? _failingSinceUtc;
|
||||
private int _failedAttempts;
|
||||
private DateTime? _lastSuccessUtc;
|
||||
|
||||
/// <summary>Settings now, what changed, route now. Raised outside the lock, on the caller's thread.</summary>
|
||||
internal event Action<ProxySettings, string, string>? SettingsChanged;
|
||||
|
||||
public ProxyObserver(string baseUrl, AgentLogger logger, IClock clock)
|
||||
: this(
|
||||
ProxySettingsReader.Read,
|
||||
RouteReaderFor(baseUrl),
|
||||
logger,
|
||||
clock,
|
||||
DefaultRouteBound)
|
||||
{
|
||||
}
|
||||
|
||||
internal ProxyObserver(
|
||||
Func<ProxySettings> readSettings,
|
||||
Func<string> readRoute,
|
||||
AgentLogger logger,
|
||||
IClock clock,
|
||||
TimeSpan routeBound)
|
||||
{
|
||||
_readSettings = readSettings ?? throw new ArgumentNullException(nameof(readSettings));
|
||||
_readRoute = readRoute ?? throw new ArgumentNullException(nameof(readRoute));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_routeBound = routeBound;
|
||||
}
|
||||
|
||||
private static Func<string> RouteReaderFor(string baseUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var target)) return () => RouteUnknown;
|
||||
return () => ProxySettingsReader.ReadRoute(target);
|
||||
}
|
||||
|
||||
/// <summary>Logs the state to compare against. Runs before the first request of the agent.</summary>
|
||||
public void CaptureBaseline()
|
||||
{
|
||||
var settings = ReadSettingsSafe();
|
||||
var route = ReadRouteBounded();
|
||||
lock (_sync)
|
||||
{
|
||||
if (_current == null && !settings.IsUnreadable) _current = settings;
|
||||
_lastRoute = route;
|
||||
}
|
||||
// Same text on a rebuild of the clients after an endpoint migration: the repeat goes to Trace.
|
||||
_logger.Info($"ProxyObserver: start - {settings.DescribeAll()}; route to backend: {route}", repeatKey: "ProxyObserver.baseline");
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the settings and reports a change of content once, whoever notices it first.</summary>
|
||||
public void CheckForChange()
|
||||
{
|
||||
var settings = ReadSettingsSafe();
|
||||
if (settings.IsUnreadable) return;
|
||||
|
||||
ProxySettings previous;
|
||||
var affectsUploads = false;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_current == null)
|
||||
{
|
||||
_current = settings;
|
||||
return;
|
||||
}
|
||||
if (_current.Equals(settings)) return;
|
||||
|
||||
previous = _current;
|
||||
_current = settings;
|
||||
if (settings.DiffersForUploadsFrom(previous))
|
||||
{
|
||||
affectsUploads = true;
|
||||
_uploadChangeAtUtc = _clock.UtcNow;
|
||||
_uploadChange = settings.DescribeUploadChangeFrom(previous);
|
||||
}
|
||||
}
|
||||
|
||||
var route = ReadRouteBounded();
|
||||
string routeBefore;
|
||||
lock (_sync)
|
||||
{
|
||||
routeBefore = _lastRoute;
|
||||
_lastRoute = route;
|
||||
}
|
||||
|
||||
var what = settings.DescribeChangeFrom(previous);
|
||||
_logger.Warning(affectsUploads
|
||||
? $"ProxyObserver: proxy settings changed - {what}; route to backend: {(routeBefore == route ? route : $"{routeBefore} -> {route}")}. " +
|
||||
"The agent's uploads follow these settings. Check with: bitsadmin /util /getieproxy localsystem"
|
||||
: $"ProxyObserver: proxy settings changed - {what}. The agent's uploads do not use WinHTTP, services that rely on its default do.");
|
||||
|
||||
try
|
||||
{
|
||||
SettingsChanged?.Invoke(settings, what, route);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ProxyObserver: change handler threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The next hop to the backend: <c>direct</c>, <c>host:port</c> or <c>unknown</c>.</summary>
|
||||
public string RouteText() => ReadRouteBounded();
|
||||
|
||||
/// <summary>
|
||||
/// An upload did not reach the backend. The first of a series says what the reader needs to
|
||||
/// know: when it began and whether the proxy settings moved before that.
|
||||
/// </summary>
|
||||
public void OnSendFailed(TimeSpan attemptDuration)
|
||||
{
|
||||
// Also catches a change the registry watcher did not report.
|
||||
CheckForChange();
|
||||
|
||||
DateTime since;
|
||||
DateTime? lastSuccess;
|
||||
DateTime? changedAt;
|
||||
string? change;
|
||||
string? current;
|
||||
lock (_sync)
|
||||
{
|
||||
_failedAttempts++;
|
||||
if (_failingSinceUtc != null) return;
|
||||
|
||||
since = _clock.UtcNow - attemptDuration;
|
||||
_failingSinceUtc = since;
|
||||
lastSuccess = _lastSuccessUtc;
|
||||
changedAt = _uploadChangeAtUtc;
|
||||
change = _uploadChange;
|
||||
current = _current?.Describe();
|
||||
}
|
||||
|
||||
var route = ReadRouteBounded();
|
||||
var lastUpload = lastSuccess == null ? "No successful upload yet." : $"Last successful upload {Time(lastSuccess.Value)}.";
|
||||
|
||||
if (changedAt != null && (lastSuccess == null || changedAt > lastSuccess))
|
||||
{
|
||||
_logger.Warning(
|
||||
$"ProxyObserver: uploads are failing since {Time(since)}, {Span(since - changedAt.Value)} after the proxy settings changed at {Time(changedAt.Value)} ({change}). " +
|
||||
$"Route to backend: {route}. {lastUpload}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var unchangedSince = changedAt == null ? "start" : "the last successful upload";
|
||||
_logger.Warning(
|
||||
$"ProxyObserver: uploads are failing since {Time(since)}. Proxy settings of this account unchanged since {unchangedSince}: {current ?? RouteUnknown}. " +
|
||||
$"Route to backend: {route}. {lastUpload}");
|
||||
}
|
||||
}
|
||||
|
||||
public void OnSendSucceeded()
|
||||
{
|
||||
DateTime since;
|
||||
int attempts;
|
||||
lock (_sync)
|
||||
{
|
||||
_lastSuccessUtc = _clock.UtcNow;
|
||||
if (_failingSinceUtc == null) return;
|
||||
|
||||
since = _failingSinceUtc.Value;
|
||||
attempts = _failedAttempts;
|
||||
_failingSinceUtc = null;
|
||||
_failedAttempts = 0;
|
||||
}
|
||||
|
||||
CheckForChange();
|
||||
_logger.Info($"ProxyObserver: uploads work again after {attempts} failed attempt(s) over {Span(_clock.UtcNow - since)}; route to backend: {ReadRouteBounded()}.");
|
||||
}
|
||||
|
||||
/// <summary>The end of the log is read first, so a run that ends while failing says so there.</summary>
|
||||
public void LogOpenFailures()
|
||||
{
|
||||
DateTime since;
|
||||
int attempts;
|
||||
DateTime? lastSuccess;
|
||||
DateTime? changedAt;
|
||||
string? change;
|
||||
string? current;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_failingSinceUtc == null) return;
|
||||
|
||||
since = _failingSinceUtc.Value;
|
||||
attempts = _failedAttempts;
|
||||
lastSuccess = _lastSuccessUtc;
|
||||
changedAt = _uploadChangeAtUtc;
|
||||
change = _uploadChange;
|
||||
current = _current?.Describe();
|
||||
}
|
||||
|
||||
var lastUpload = lastSuccess == null ? "no successful upload" : $"last success {Time(lastSuccess.Value)}";
|
||||
var settings = changedAt == null
|
||||
? $"Proxy settings of this account unchanged since start: {current ?? RouteUnknown}."
|
||||
: $"Proxy settings changed at {Time(changedAt.Value)} ({change}).";
|
||||
_logger.Warning(
|
||||
$"ProxyObserver: stopping with uploads still failing: {attempts} failed attempt(s) since {Time(since)}, {lastUpload}, route to backend: {ReadRouteBounded()}. {settings}");
|
||||
}
|
||||
|
||||
private ProxySettings ReadSettingsSafe()
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = _readSettings();
|
||||
if (settings.IsUnreadable) _logger.Debug($"ProxyObserver: proxy settings {settings.Describe()}", repeatKey: "ProxyObserver.unreadable");
|
||||
return settings;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ProxyObserver: reading the proxy settings threw: {ex.Message}", repeatKey: "ProxyObserver.readThrew");
|
||||
return ProxySettings.Unreadable(-1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-detect can hang and must hold up neither the start nor an upload. A read still under
|
||||
/// way is joined, so a hanging one does not pile up pool threads.
|
||||
/// </summary>
|
||||
private string ReadRouteBounded()
|
||||
{
|
||||
Task<string> read;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_routeRead == null || _routeRead.IsCompleted) _routeRead = Task.Run(ReadRouteSafe);
|
||||
read = _routeRead;
|
||||
}
|
||||
return read.Wait(_routeBound) ? read.Result : RouteUnknown;
|
||||
}
|
||||
|
||||
private string ReadRouteSafe()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _readRoute();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ProxyObserver: reading the route threw: {ex.Message}", repeatKey: "ProxyObserver.routeThrew");
|
||||
return RouteUnknown;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Time(DateTime utc) => utc.ToString("HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Span(TimeSpan span)
|
||||
{
|
||||
var seconds = Math.Max(0, (long)span.TotalSeconds);
|
||||
return seconds < 120 ? $"{seconds} s" : $"{seconds / 60} min";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// The proxy settings Windows holds for the account this process runs as (what
|
||||
/// <c>bitsadmin /util /getieproxy localsystem</c> shows for SYSTEM and what .NET uses), plus the
|
||||
/// WinHTTP proxy (<c>netsh winhttp show proxy</c>). Compared by content, because WinINET rewrites
|
||||
/// its settings blob without changing anything. The values end up in logs and events, so
|
||||
/// credentials and PAC query strings are kept out on the way in.
|
||||
/// </summary>
|
||||
internal sealed class ProxySettings : IEquatable<ProxySettings>
|
||||
{
|
||||
internal const string CredentialsHidden = "(value with credentials, not shown)";
|
||||
|
||||
public bool AutoDetect { get; }
|
||||
public string PacUrl { get; }
|
||||
public string Proxy { get; }
|
||||
public string Bypass { get; }
|
||||
public string WinHttpProxy { get; }
|
||||
|
||||
/// <summary>Win32 error of a failed read; null when the settings were read.</summary>
|
||||
public int? ReadError { get; }
|
||||
|
||||
public bool IsUnreadable => ReadError != null;
|
||||
|
||||
public ProxySettings(bool autoDetect, string pacUrl, string proxy, string bypass, string winHttpProxy)
|
||||
{
|
||||
AutoDetect = autoDetect;
|
||||
PacUrl = SanitizeUrl(pacUrl);
|
||||
Proxy = HideCredentials(proxy);
|
||||
Bypass = EmptyToNull(bypass);
|
||||
WinHttpProxy = HideCredentials(winHttpProxy);
|
||||
}
|
||||
|
||||
private ProxySettings(int readError)
|
||||
{
|
||||
ReadError = readError;
|
||||
}
|
||||
|
||||
public static ProxySettings Unreadable(int win32Error) => new ProxySettings(win32Error);
|
||||
|
||||
/// <summary>Kept in step with ANALYZE-CORR-003, which matches <c>Proxy|PAC</c>.</summary>
|
||||
public string ProxyType =>
|
||||
IsUnreadable ? "Unknown"
|
||||
: Proxy != null ? "Proxy"
|
||||
: PacUrl != null ? "PAC"
|
||||
: AutoDetect ? "AutoDetect"
|
||||
: "Direct";
|
||||
|
||||
public string Describe()
|
||||
{
|
||||
if (IsUnreadable) return $"unreadable (Win32 error {ReadError})";
|
||||
|
||||
var parts = new List<string>(3);
|
||||
if (AutoDetect) parts.Add("auto-detect");
|
||||
if (PacUrl != null) parts.Add($"PAC {PacUrl}");
|
||||
if (Proxy != null) parts.Add($"proxy {Proxy}");
|
||||
return parts.Count == 0 ? "no proxy" : string.Join(", ", parts);
|
||||
}
|
||||
|
||||
public string DescribeWinHttp() => WinHttpProxy == null ? "direct" : $"proxy {WinHttpProxy}";
|
||||
|
||||
public string DescribeAll() => $"IE settings of this account: {Describe()}; WinHTTP: {DescribeWinHttp()}";
|
||||
|
||||
/// <summary>The account's own settings, which the agent's uploads follow; WinHTTP is apart.</summary>
|
||||
public bool DiffersForUploadsFrom(ProxySettings other) =>
|
||||
AutoDetect != other.AutoDetect
|
||||
|| PacUrl != other.PacUrl
|
||||
|| Proxy != other.Proxy
|
||||
|| Bypass != other.Bypass;
|
||||
|
||||
public string DescribeUploadChangeFrom(ProxySettings previous) =>
|
||||
Describe() == previous.Describe()
|
||||
? "IE settings of this account: proxy bypass list changed"
|
||||
: $"IE settings of this account: {previous.Describe()} -> {Describe()}";
|
||||
|
||||
public string DescribeChangeFrom(ProxySettings previous)
|
||||
{
|
||||
var parts = new List<string>(2);
|
||||
if (DiffersForUploadsFrom(previous)) parts.Add(DescribeUploadChangeFrom(previous));
|
||||
if (WinHttpProxy != previous.WinHttpProxy) parts.Add($"WinHTTP: {previous.DescribeWinHttp()} -> {DescribeWinHttp()}");
|
||||
return string.Join("; ", parts);
|
||||
}
|
||||
|
||||
/// <summary>Payload of the <c>proxy_configuration</c> event.</summary>
|
||||
public Dictionary<string, object> ToEventData() => new Dictionary<string, object>
|
||||
{
|
||||
{ "proxyType", ProxyType },
|
||||
{ "autoDetect", AutoDetect },
|
||||
{ "proxyEnabled", Proxy != null },
|
||||
{ "proxyServer", Proxy },
|
||||
{ "proxyOverride", Bypass },
|
||||
{ "autoConfigUrl", PacUrl },
|
||||
{ "winHttpProxyConfigured", WinHttpProxy != null },
|
||||
{ "winHttpProxy", WinHttpProxy },
|
||||
};
|
||||
|
||||
public bool Equals(ProxySettings other) =>
|
||||
other != null
|
||||
&& ReadError == other.ReadError
|
||||
&& !DiffersForUploadsFrom(other)
|
||||
&& WinHttpProxy == other.WinHttpProxy;
|
||||
|
||||
public override bool Equals(object obj) => Equals(obj as ProxySettings);
|
||||
|
||||
public override int GetHashCode() => (AutoDetect, PacUrl, Proxy, Bypass, WinHttpProxy, ReadError).GetHashCode();
|
||||
|
||||
private static string EmptyToNull(string value) => string.IsNullOrEmpty(value) ? null : value;
|
||||
|
||||
/// <summary>Scheme, host, port and path. A query string can carry a key; an unparsable URL is not repeated.</summary>
|
||||
private static string SanitizeUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return null;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || string.IsNullOrEmpty(uri.Host)) return "(unparsable URL)";
|
||||
var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
|
||||
return $"{uri.Scheme}://{uri.Host}{port}{uri.AbsolutePath}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A proxy string has no defined form for credentials and a password can hold <c>=</c>,
|
||||
/// <c>;</c> or <c>://</c>, so a value with an <c>@</c> is replaced as a whole, not taken apart.
|
||||
/// </summary>
|
||||
private static string HideCredentials(string proxy)
|
||||
{
|
||||
if (string.IsNullOrEmpty(proxy)) return null;
|
||||
return proxy.IndexOf('@') < 0 ? proxy : CredentialsHidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Runtime.InteropServices;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport
|
||||
{
|
||||
internal static class ProxySettingsReader
|
||||
{
|
||||
public const string RouteDirect = "direct";
|
||||
|
||||
/// <summary>Reads the registry through WinHTTP, no network. Does not throw on a failed read.</summary>
|
||||
public static ProxySettings Read()
|
||||
{
|
||||
var ie = new WinHttpNativeMethods.WINHTTP_CURRENT_USER_IE_PROXY_CONFIG();
|
||||
var winHttp = new WinHttpNativeMethods.WINHTTP_PROXY_INFO();
|
||||
try
|
||||
{
|
||||
if (!WinHttpNativeMethods.WinHttpGetIEProxyConfigForCurrentUser(ref ie))
|
||||
{
|
||||
var error = Marshal.GetLastWin32Error();
|
||||
// The account has never had settings written, so nothing configures a proxy.
|
||||
if (error != WinHttpNativeMethods.ERROR_FILE_NOT_FOUND) return ProxySettings.Unreadable(error);
|
||||
}
|
||||
|
||||
if (!WinHttpNativeMethods.WinHttpGetDefaultProxyConfiguration(ref winHttp))
|
||||
{
|
||||
return ProxySettings.Unreadable(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
return new ProxySettings(
|
||||
autoDetect: ie.fAutoDetect,
|
||||
pacUrl: Marshal.PtrToStringUni(ie.lpszAutoConfigUrl),
|
||||
proxy: Marshal.PtrToStringUni(ie.lpszProxy),
|
||||
bypass: Marshal.PtrToStringUni(ie.lpszProxyBypass),
|
||||
winHttpProxy: winHttp.dwAccessType == WinHttpNativeMethods.WINHTTP_ACCESS_TYPE_NAMED_PROXY
|
||||
? Marshal.PtrToStringUni(winHttp.lpszProxy)
|
||||
: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Free(ie.lpszAutoConfigUrl);
|
||||
Free(ie.lpszProxy);
|
||||
Free(ie.lpszProxyBypass);
|
||||
Free(winHttp.lpszProxy);
|
||||
Free(winHttp.lpszProxyBypass);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The next hop .NET chooses for <paramref name="target"/>: the same decision an
|
||||
/// <c>HttpClientHandler</c> with default settings takes, PAC and auto-detect evaluated.
|
||||
/// Auto-detect can take seconds on a network without WPAD.
|
||||
/// </summary>
|
||||
public static string ReadRoute(Uri target)
|
||||
{
|
||||
var hop = WebRequest.DefaultWebProxy?.GetProxy(target);
|
||||
if (hop == null || string.Equals(hop.Host, target.Host, StringComparison.OrdinalIgnoreCase)) return RouteDirect;
|
||||
return hop.IsDefaultPort ? hop.Host : $"{hop.Host}:{hop.Port}";
|
||||
}
|
||||
|
||||
private static void Free(IntPtr memory)
|
||||
{
|
||||
if (memory != IntPtr.Zero) WinHttpNativeMethods.GlobalFree(memory);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user